ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

llvm-project 上手地图:从构建到源码阅读的完整路径

llvm-project 上手地图:从构建到源码阅读的完整路径 很多人第一次打开 llvm-project 这个仓库时会有点懵说好的是研究编译器怎么刷出来一个比大多数操作系统源码还庞大的怪物llvm-project 是 LLVM 基金会维护的 mono-repo 仓库里面不只有编译器还有调试器、链接器、标准库、基础库、用于写编译器的框架甚至还有一套面向机器学习的编译器基础设施。这篇文章不打算讲某个具体 patch 的实现而是从拿到这个仓库之后从哪下手、怎么理解、怎么构建、怎么用起来这条线把 llvm-project 的真实面貌拆开聊一遍顺便把我在本地折腾这套东西时踩过的坑一并交代清楚。如果你是学编译原理的学生、做性能优化的工程师、想自研语言的开发者或者只是好奇编译器本身是怎么造出来的这篇内容可以当作一份索引式的上手地图。1. 先搞清楚llvm-project 到底是个什么项目1.1 它不是一个编译器而是一条完整的工具链生产线很多教程会顺手写一句LLVM 是开源的编译器基础设施但这句话等于没说。打开 llvm-project 之后你会看到十几个一级子目录各自顶着不同的名字。这里随便列几个就能感受到这个项目的体量子项目一句话定位llvm核心框架包含 LLVM IR、优化器、CodeGen、目标后端clangC/C/Objective-C 前端日常命令行用得最多的部分clang-tools-extraclang-tidy、clangd、include-what-you-use 等附加工具lld高性能链接器替代系统自带的 ldlibcxx / libcxxabiC 标准库及 ABI 层compiler-rt运行时库包含 sanitizer、profile、溢出检查等底层支持mlir面向机器学习与编译器分层的多层级 IR 框架polly基于多面体模型的循环优化flangFortran 前端openmpOpenMP 运行时与编译支持lldb调试器功能对标 gdb如果你只装了 clang 这个二进制你拿到的是编译驱动 前端 一堆库的集合。但如果你拿到的是 llvm-project 源码你其实拿到的是整条编译工具链的生产线前端把高级语言翻译成 LLVM IR优化器在 IR 上做变换后端把 IR 变成目标平台的汇编和机器码链接器负责把目标文件捆成可执行文件调试器再基于这些信息提供源码级排错能力。这一整套东西才配得上project这个词。1.2 为什么它的架构会被反复抄作业LLVM 的思路在 2000 年提出时其实很简单把传统编译器的前后端彻底拆开。GCC 当时的前端后端是层层咬合的关系要支持一门新语言或者一个新 CPU代价都相当肉疼。LLVM 则定死了一套中间表示前端的活是把语言变成 IR后端的活是把 IR 变成机器码中间这层大家共享。谁想支持新语言就用 clang 以外的新前端来生产 IR谁想支持新芯片就写一个新的后端来消费 IR。这套分层带来的收益今天已经看得很清楚了。Rust 的 rustc 一开始就直接借用 LLVM 做后端省掉了从零开始写优化器和 CodeGen 的巨大工程量Julia、Swift、甚至一些 GPU 的编译器也都在 LLVM 之上做文章。MLIR 出现之后LLVM 甚至把自己的 IR 分层哲学又往下推了一层专门服务于机器学习编译器这种需要多级抽象的场景。mlir 子目录能出现在这个仓库里恰恰说明 LLVM 的项目边界已经不只是编译器三个字能概括的了。1.3 新手最需要建立的三个认知不要把 llvm-project 当成一个待读完的代码库它没有读完这种状态。理解它的方式是把链路跑通让前端、IR、优化、后端各环节在自己的控制下走一遍。不要只盯着 llvm 目录里的 C 代码clang、lld、compiler-rt 这些子项目才是你日常最常接触到的东西。不要被构建时间吓退合理的配置和工具链能让你在一台普通机器上完成全套构建后面第 3 节详细说。2. 仓库结构与核心模块从顶层看懂项目版图2.1 一级目录各自独立又互相依赖llvm-project 的顶层目录基本就是子项目的边界。每个子项目内部又统一采用类似的结构学习成本因此低了很多。比如 llvm 目录下有include/、lib/、tools/、utils/、test/等一级子目录clang、lld、mlir 也遵循同样的组织方式。以 llvm 核心目录为例路径内容备注llvm/include/llvm公共头文件声明所有核心接口读代码前先在这层看接口定义llvm/lib/IRLLVM IR 的核心数据类型定义比如Module、Function、BasicBlock、Instruction理解了这几个类你就理解了 IR 的骨架llvm/lib/PassesPass 注册与调度框架NewPM 就在这想动手写优化 pass新版本看这个目录llvm/lib/CodeGen后端公共流程包括指令选择、寄存器分配、指令调度这是后端最难啃的部分llvm/lib/Target各个 CPU 后端的实现X86、AArch64、RISCV 等想移植新芯片就盯这里llvm/lib/Transforms经典标量优化、向量化、IPO 等具体 pass 实现想学优化算法主要看这里llvm/tools对外命令行工具比如opt、llc、lli、llvm-as日常调试 IR 靠它们llvm/test回归测试集lit驱动的测试框架提交 patch 时必须跑这一层2.2 几个必须第一时间认识的命令行工具构建完 llvm-project或安装完整版 LLVM之后你会看到一长串llvm-开头的可执行文件。对上手理解这个项目来说下面这几个工具优先级最高clangC/C 前端入口也是最常用的驱动。真正在终端里干活就靠它。clangC 前端入口驱动 C 编译。optIR 级优化器输入 bitcode 或 IR 文本输出优化后的 IR。写 pass 跑测试绕不开它。llc将 LLVM IR 转为目标平台汇编或目标文件。想看后端怎么输出机器码用它。lli直接用 JIT 方式运行 LLVM IR。想快速验证一段 IR 能跑出什么结果用它。llvm-as/llvm-dis在文本 IR 和二进制 bitcode 之间互转。LLVM IR 有两种等价形态这哥俩负责翻译。llvm-nm、llvm-objdump、llvm-readelf替代 binutils 系列的目标文件查看工具调试链接相关问题很顺手。lld链接器入口调用-fuse-ldlld即可让 clang 使用它。我的建议是先不看源码把这几个工具在终端里各摸一遍用 clang 编译一段简单 C 程序再用 clang 的-S -emit-llvm生成中间代码用opt过一遍最后用llc生成汇编。这条链路走通之后你再看源码时就有一个我知道你在这条链路的哪个环节的大局观。2.3 子项目之间的依赖关系与构建顺序llvm-project 的构建系统是跨子项目统一的使用 CMake 驱动。你不需要手动按顺序编译 clang、lld、libcxx、compiler-rt因为总的 CMake 文件会把它们的依赖关系处理好。LLVM_PROJECTS_TO_BUILD这个参数决定你要额外编译哪些子项目。比如-DLLVM_PROJECTS_TO_BUILDclang;clang-tools-extra;lld;libcxx;libcxxabi注意单独编译 llvm 和在同一个构建目录里带子项目一起编这两者之间的二进制产物目录、安装布局都有差异。我建议第一次就按llvm clang lld来配这三个是理解工具链主流程的最佳组合。libcxx 可以等后续需要改标准库时再单独加mlir 和 flang 对新手来说一开始不建议动。3. 从零构建 llvm-project一份踩过坑的配置清单3.1 构建前的必要准备构建 LLVM 本身对操作系统的要求不复杂但有几个条件会直接影响成败条件建议值原因内存16GB 起步32GB 更稳链接大型 C 目标文件时内存峰值很高8GB 机器配 Debug 模式很容易 OOM磁盘至少 50GB 可用空间Release 构建产物 中间文件 源码本身轻松吃掉 30-50GB操作系统Linux/macOS 最顺手Windows 需要额外处理 MSVC 兼容问题不是不行是坑更多CMake3.20 以上LLVM 对 CMake 版本有要求太老直接报错编译器自举推荐用系统 gcc/clang 先编出一版 LLVM官方支持用宿主编译器构建编出来的 clang 可以再编自己Ninja建议使用比 Makefile 并行性好增量构建快很多开发者社区里有个默认建议Release 模式 Assertions 开启。这个组合既保证运行速度又能在开发调试时帮你抓到内存越界等 bug。CMake 参数是-DCMAKE_BUILD_TYPERelease -DLLVM_ENABLE_ASSERTIONSON。纯 Debug 模式编译出的 clang 慢得没法日常用纯 Release 有时又过于自信把该报错的地方悄悄吞掉所以 ReleaseAssertions 是最均衡的开发配置。3.2 一个完整的构建命令组合下面这组命令是我在 Ubuntu 22.04 上实测过多次的推荐组合git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build cd build cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_CCACHE_BUILDON \ ../llvm ninja -j$(nproc)参数逐一说一下-DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV默认情况下 LLVM 会为所有支持的后端生成代码像 AMDGPU、BPF、PowerPC、SystemZ、MSP430、AVR 这些你根本用不到的 Target 也会编译出来白白增加时间开销。按需裁剪之后构建时间能缩短一半以上。只在本机跑的话单独留X86就够了。-DLLVM_ENABLE_PROJECTSclang;lld这是从 LLVM 14 开始推荐的写法之前是-DLLVM_ENABLE_PROJECTSclang;lld也没错但现在更推荐这种带目录分割的写法。它告诉构建系统除了 llvm 核心还需要 clang 和 lld 这两个子项目。-DLLVM_CCACHE_BUILDON加一层 ccache 缓存增量编译成本大幅降低。我第一次没开改一行代码后要重新链接一个几 GB 的 clang中途真的会怀疑人生。构建完成后所有二进制都在build/bin下。验证方式很直接./bin/clang --version能正常输出版本号说明 LLVM 与 Clang 链路已经通了。3.3 常见踩坑与对应解法内存不足导致链接崩溃典型现象是ninja跑到 lld 链接 clang 时进程被 OOM Killer 杀掉。解法有几个方向一是换 Release Assertions 而不是 Debug二是减少并行任务数-j4甚至-j2三是用lld做宿主链接器比系统ld快且省内存。可以这样切换-DLLVM_USE_LINKERlld前提是你已经有一个可用的 lld 或系统装了 lld。磁盘被 test 目录塞满默认构建会生成大量测试文件如果只是先跑通链路可以关掉测试构建-DLLVM_INCLUDE_TESTSOFF-DLLVM_INCLUDE_BENCHMARKSOFF。CMake 版本太老Ubuntu 20.04 自带的 CMake 3.16 编不了新版 LLVM建议用 pip 装一个更新的或者用 Kitware 官方 APT 源。我经历过一次整整排查了半小时最后发现就是版本问题。Windows 上 LLD 链接报错如果你非要在 Visual Studio 工具链下构建注意从 Windows SDK 里补齐libcmt等运行时库的链接路径。这一步对新手不友好我更建议先在有 Linux 环境的机器上跑通整套流程。3.4 我的实际构建体感在一台 16 核 32GB 内存的 Linux 机器上全量 ReleaseAssertions 构建 llvmclanglld仅 X86 目标首次构建大约需要 25-40 分钟取决于 CPU 主频和磁盘 IO。开 ccache 后第二次构建只需要几分钟。这个时间成本在编译器开发里真的很值得——因为你会反复修改、反复编译、反复跑测试增量构建速度才是决定每天心情的关键指标。4. LLVM IR 与 Pass 机制理解这层万物皆可中间表示的设计4.1 LLVM IR 长什么样LLVM IR 是理解整个项目的钥匙。它既可以被当成文本文件看也可以被编码成二进制的 bitcode在内存中则以 C 对象的形式存在。三者等价这让编译器开发者可以非常方便地在各个阶段做调试。把下面这段 C 代码交给 clang 生成 IRint add(int a, int b) { return a b; }执行clang -S -emit-llvm add.c -o add.ll你会得到一段类似这样的文本 IRdefine i32 add(i32 noundef %a, i32 noundef %b) { entry: %add add nsw i32 %a, %b ret i32 %add }用生活化的方式类比LLVM IR 就像一种所有编译语言都能说、所有芯片都能听懂的世界语。前端负责把 C、C、Rust、Swift 翻译成世界语优化器在世界语上做文章后端再把世界语翻译成具体芯片的方言。因为大家都在同一层中间表示上工作优化器不需要关心原始语言是啥后端也不需要关心最终执行的高级语义整个工程复杂度因此被拆散并降低。4.2 Pass 是优化黑魔法的最小单位对 IR 做变换的基本单元叫 Pass。一个 Pass 接收一个模块通常是一整个源文件的 IR经过分析或修改输出一个新的模块。Pass 大致分两类分析 Pass只读取 IR计算某些信息。比如统计函数调用次数、分析变量之间的关系、计算循环深度。证书这类 Pass 不会改代码输出的是分析结果供后续 Pass 使用。变换 Pass修改 IR 本身。比如死代码消除、循环展开、内联等。这类 Pass 是真正干活的。这种小函数 小 Pass的组合是 LLVM 优化器最鲜明的风格。在提交代码的 review 时开发者通常只会把改动集中在一个很小的 Pass 里这也是 LLVM 代码审查文化的一部分。想手动体验 Pass 的作用可以用optopt -passesinstcombine add.ll -S -o add.opt.llinstcombine是一个经典组合变换 Pass能把大量冗余模式化简。比如对add(a, 0)这种能优化掉的特例它会直接把加法操作替换为a。这就是编译器优化的微观体现。跑完之后对比add.ll和add.opt.ll非常治愈。4.3 NewPM老 Pass 管理器与新 Pass 管理器的故事LLVM 历史上存在过两套 Pass 框架。旧框架由legacy::PassManager承载Pass 以继承FunctionPass等类的方式注册接口稳定但调度机制比较僵硬。新框架叫 NewPM引入了 PassBuilder提供了更清晰的流水线和调试输出选项。包括opt -passes...在内的一套工作流都基于 NewPM。可以这么理解旧的 Pass 管理像把一堆工人扔进车间让他们自己协调虽然也能出货但流程不可控新的 Pass 管理把每个工人安排进标准流水线可以插队、可以调位置、可以单独测试改造起来得心应手。写新 Pass 时如果你用新版本 LLVM社区推荐直接基于 NewPM 开发。在代码里注册 Pass 的入口大概长这样llvm::PassPluginLibraryInfo getPassPluginInfo() { const auto callback [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-pass) { FPM.addPass(MyPass()); return true; } return false; }); }; return {LLVM_PLUGIN_API_VERSION, MyPass, LLVM_VERSION_STRING, callback}; }用opt -load-pass-pluginlibMyPass.so -passesmy-pass就能把自定义 Pass 注入 pipeline。很多开发者第一次在 LLVM 里留下自己代码就是从写这么一个小 Pass 开始的。4.4 从 IR 到汇编llc 把最后一步走完IR 优化完后续工作是交给后端。llc这个工具会完成指令选择、寄存器分配、指令调度、汇编输出llc add.opt.ll -o add.s打开add.s看看X86 上通常就是addl %esi, %edi movl %edi, %eax retq这一步可以把你的理解闭环高级语言 - 前端 - IR - 优化 - 后端 - 汇编 - 链接。后端自身也是一大堆算法在支撑尤其寄存器分配堪称 LLVM 后端里最烧脑的部分。这里不展开但你可以记住后续想深入了解后端第一站看llvm/lib/CodeGen/RegAllocFast.cpp这个相对好读也是一个 entry-level 的经典入口。5. Clang 的前端到后端一条日常高效工作流5.1 clang 的常用姿势和 gcc 侧对比很多人以为 clang 只是 gcc 的一个替代品其实 clang 在设计目标上更强调模块化、可嵌入性和诊断质量。日常使用时gcc 能做的 clang 基本都能做而且诊断信息通常更有亲和力。下面几个命令非常常用# 编译并链接 clang main.c -o main # 只生成目标文件不链接 clang -c main.c -o main.o # 生成汇编 clang -S main.c -o main.s # 生成 LLVM IR clang -S -emit-llvm main.c -o main.ll # 优化等级O0/O1/O2/O3/Os/Oz clang -O2 main.c -o main一个常被新开发忽略的参数是-fcolor-diagnostics新版默认开启。它让报错信息里不同类型的内容用不同颜色区分比如高亮错误位置的行和列。终端下面看代码定位速度快非常多。另一个非常有用的参数是-fdiagnostics-show-optionclang -fdiagnostics-show-option -c bad.c它会在诊断信息末尾显示建议关闭的 warning 名称比如-Wunused-variable你看到之后就能用-Wno-unused-variable随手关掉这个噪音。5.2 用 clang 观察语言到机器的每一跳学习编译原理的人最大的痛点就是过程不透明。clang 的-S -emit-llvm配合llc能把不透明打开。拿一段稍微复杂点的 C 代码举例int sum(int n) { int s 0; for (int i 0; i n; i) { s i; } return s; }先生成 IR 看优化前后的差别clang -O0 -S -emit-llvm sum.c -o sum.O0.ll clang -O2 -S -emit-llvm sum.c -o sum.O2.llO0的 IR 里循环保留得非常直白O2的 IR 在经过 indvars、loop-unroll、instcombine 等 pass 之后很可能直接变成等差数列求和公式——循环整个被优化没了。这个过程放到现实中的大型代码里就是编译器性能优化的缩略图。反复用-O0和-O2对比你对优化 pass 的直觉会建立得很快。5.3 交叉编译与目标选项LLVM 的设计天然适合交叉编译因为后端和目标描述是解耦的。要在 x86 机器上给 AArch64 交叉编译clang --targetaarch64-linux-gnu -marcharmv8-a -c foo.c -o foo.o前提是你的构建时把 AArch64 后端包含进去第 3 节里LLVM_TARGETS_TO_BUILD加了 AArch64 就是为了干这个。配合lld可以直接做全静态交叉链接clang --targetaarch64-linux-gnu -fuse-ldlld foo.c -o foo.elf这种能力在嵌入式开发里非常香。传统方案要装一整套 arm-linux-gnueabihf 工具链而 clang 一条命令就搞定了。5.4 用 sanitizer 在早期抓到内存问题clang 内置一组运行时 sanitizer能在运行期帮你发现未定义行为。最常用的三个Sanitizer检测对象开启方式AddressSanitizer (ASan)堆溢出、栈溢出、use-after-free 等内存错误-fsanitizeaddressUndefinedBehaviorSanitizer (UBSan)整数溢出、空指针、移位越界等未定义行为-fsanitizeundefinedThreadSanitizer (TSan)数据竞争-fsanitizethread日常开发里很多人只依赖 valgrind但 valgrind 的慢在大型项目里难以接受。ASan 在编译期插入检查代码运行时开销通常在 1.5-3 倍之间已经非常接近生产可用了。实测效果clang -fsanitizeaddress -g test.c -o test_asan ./test_asan如果 test.c 里有越界访问ASan 会打印完整的调用栈、分配栈和出错位置比单纯段错误好排查一万倍。6. 给想深入源码或参与贡献的人从会用到会改6.1 读源码的推荐路径读 llvm-project 源码最大的敌人是不知道该从哪里进入。我推荐一条相对平滑的顺序先读llvm/docs/里的 GettingStarted 和 LangRef。LangRef 是 LLVM IR 的语言规范虽然长但可以随用随查不需要一次读完。读llvm/include/llvm/IR/Module.h、Function.h、BasicBlock.h、Instruction.h这四个头文件的注释把 IR 的对象模型先搭起来。它们之间是树状包含关系Module 包含多个 FunctionFunction 包含多个 BasicBlockBasicBlock 包含多条 Instruction。这条链几乎出现在所有分析和变换 pass 的入口函数里。挑一个简单 pass 精读。比如llvm/lib/Transforms/InstCombine/InstCombineAddSub.cpp因为instcombine的代码短小精悍注释丰富很适合第一次了解这个 pass 到底怎么改 IR。读 clang 的 AST 那部分从clang/lib/AST/Expr.cpp之类的小文件入手。clang 的前端旅程大致是 词法解析 - 语法解析 - AST - 语义分析 - 生成 IR。你不需要一次全搞懂只需建立一个 map知道哪类问题去哪个目录找。最后如果对后端有兴趣可以读llvm/lib/Target/X86/X86ISelLowering.cpp这里是 X86 后端里做指令选择和 lowering 的主战场能回答指令怎么被选出来这个问题。6.2 第一次提交 Patch 的完整动作想给 llvm-project 提交代码第一步其实不是写代码而是跑通流程。现在的协作流程基于 GitHub操作和大多数开源项目类似fork llvm-project 到自己账号创建分支改代码写测试测试文件放在llvm/test/或对应子项目的test/目录使用 lit FileCheck 写回归测试本地跑ninja check-llvm check-clang验证提交 PR等 review如果想要初次 low-hanging fruit 的贡献方向可以从这几个入手类型例子难点修文档官网和 docs 里过时的参数说明几乎没有补充诊断给某个 pass 加上更友好的 warning 信息需要了解对应 pass 的逻辑补测试给新发现的 bug 加回归测试需要有 bug 场景和最小复现修复静态分析 warning给代码里未处理的情况加显式处理需要理解上下文翻译 LangRef 的部分章节社区一直有中文文档贡献的需求需要准确理解术语初次建议不要直接冲击巨型新功能。LLVM 的 code review 非常严格你花一晚上写的一个大 passreview 后大概率被拆成十八个小 patch 再逐个讨论。参与这个社区耐心和沟通能力跟工程能力同等重要。6.3 一个拿上台面的学习方法针对那些想彻底搞懂 LLVM的人我建议采用三周链路法第一周构建 llvm-project用 clang/opt/llc 跑通 main - IR - optimized IR - assembly 的链路每天换着花样用命令行观察不同代码生成的不同结果。第二周读 LLVM Kaleidoscope 教程就是用 C 从零实现一门简单语言并用 LLVM 翻译成机器码的经典教程。它几乎覆盖了前端生成 IR 所需的所有核心概念从词法分析到 JIT 全都有。第三周挑一个自己熟悉的 C 项目把其中几个关键文件用clang -S -emit-llvm转成 IR再用opt做不同优化对比 IR 变化选一个 pass 读源码直到能解释变化原因。这三周之后你再看 llvm-project就不再是天书而是一座可以按地图探索的城市。剩下的事情无非是决定先逛哪个街区。7. 关于学习方向的一点个人提醒聊到最后分享一个我自己的体会接触 llvm-project 初期最让人焦虑的地方在于不知道学什么才算学会。这个项目太大了大到几乎没有一个人能通吃所有模块。真正有价值的做法是给自己定一个小目标——是想改进某个优化 pass还是想给某块嵌入式芯片做后端支持还是想深入 clang 的前端诊断。目标定了源码阅读范围自然就窄了构建配置、测试方法、社区资料也都跟着聚焦了。我最初就是单纯想搞清楚 clang 的告警是怎么生成的结果一路从诊断接口追到 SourceManager又从 SourceManager 追到 Lexer最后对整个前端词法分析流程建立起了概念。这个过程没有走太多弯路也没有依赖什么现成教程核心就是不断回答自己脑子里冒出来的它为什么知道这一行是错的这个问题。所以如果你现在正对着某个编译错误或者一段晦涩源码发愁可以换个思路这不是障碍这本身就是学习的第一步。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进