llvm-project 这个名字,你大概率在 GitHub 热门仓库里见过,Star 数常年霸榜。但真正点进去的人,估计会被目录结构直接劝退:llvm、clang、lld、libcxx、compiler-rt、mlir……每一个子项目都够单独研究好几年。我在编译器工具链方向折腾了挺长一段时间,从最初只会敲clang -O2,到后来尝试改 IR、写自定义 Pass、给后端加指令描述,中间踩过的坑比想象中多得多。这篇文章就是把我对 llvm-project 的完整理解整理出来:它设计上为什么是三层架构、怎么从源码构建一个能跑起来的版本、如何读懂 LLVM IR、以及怎么写第一个属于自己的优化 Pass。不管你是写 C/C++ 的开发者,还是做语言、做工具链方向的人,读完后应该都能对这套体系建立起一个具体而不抽象的认识。
1. 三层架构:llvm-project 解决的核心问题,就是把编译器拆成三个独立部件
1.1 传统编译器的"单体内核"为什么难改
在 LLVM 成为主流之前,开源编译器领域真正的主导者是 GCC。GCC 当然不弱,它能编译几十种语言和几十种 CPU 架构,但它的架构有一个绕不开的结构性问题:前端(语言解析)、中端(优化)、后端(生成机器码)之间耦合得太过紧密。
这话怎么理解?你可以把 GCC 想象成一座把所有房间都打通的老房子。改一个房间的格局,可能影响承重墙;新增一个房间,得先搞清楚整栋楼的管线怎么排。所以 GCC 确实能干活,但想在上面做比较激进的改动,比如给一门新语言接入优化后端,或者给一个新 CPU 架构做代码生成,至少要理解编译器内部大量的历史约定和实现细节。对新手来说,贡献代码的门槛非常高,就算是有经验的团队,也很难在 GCC 内部做一个"小范围、低风险"的实验性改动。
LLVM 恰恰是奔着这个问题去的。它最初是 Chris Lattner 在 UIUC 的博士论文项目,2000 年左右启动,目标很直接:设计一套模块化的编译器基础设施,让前端、优化、后端成为三个可以独立演进的部分。后来 Apple 看中了这套设计,把它大规模引入自己的工具链,随后整个行业都跟着转向。现在你看到的 llvm-project monorepo,就是当年那套设计逐步演化和壮大的结果。
1.2 IR 是三层结构里最关键的枢纽
LLVM 的整体架构可以简化成下图这个逻辑(用文字描述,不用画图):
- 前端(Frontend):把源代码翻译成 LLVM IR(中间表示)。Clang 负责 C/C++/Objective-C,Flang 负责 Fortran,rustc 的前端则属于 Rust 项目自身。
- 中端/优化器(Optimizer):在 LLVM IR 这一层做各种变换和优化。这里跑的"Pass"对源代码语言完全无感。
- 后端(Backend):把最终的 IR 转化成目标机器的汇编或机器码,比如 X86、ARM、RISC-V、NVPTX。
中间这个 IR 是整套架构的灵魂。它长什么样,后文我会专门写一节;这里先理解它的战略意义。
你可以把 IR 想成一种"编译器界的通用语言"。前端负责把各种人类语言翻译成这种通用语言,后端再负责把通用语言翻译成各种硬件方言。于是原本需要"语言数 × 硬件数"个组合才能解决的问题,被压缩成了"语言数 + 硬件数"个组件。举一组数据你就明白了:如果要做 3 种语言、3 种 CPU 架构的编译器,传统的直写方案需要维护 9 套编译实现;而在 LLVM 架构下,只需要 3 个前端 + 3 个后端,中间的优化器只需要维护一份。多一个语言只需要多一个前端,多一个 CPU 只需要多一个后端。
1.3 这套设计到底便宜了谁
收益最明显的是三类人。
做新编程语言的人。Rust 早期直接使用 LLVM 作为后端,Swift、Julia 也一样。这几门语言团队都只需要专注自己的前端和语义设计,机器码生成、寄存器分配、指令调度这些硬骨头全交给 LLVM。如果从零开始写一个能产出高质量机器码的后端,那基本是十年起步的工作量。
做新芯片的公司。ARM、RISC-V 阵营的新架构要想被广泛采用,必须让主流语言都能跑到自家芯片上。按传统思路,那得挨个语言去谈合作,周期极长。抱上 LLVM 大腿之后,只要贡献一个后端,就相当于一次拿到所有 LLVM 支持语言的编译能力。
做编译器研究的人。优化算法本质上就是"对 IR 做等价变换"。在 LLVM 里,你的研究对象是清晰、规范的 IR,而不是各种语言 AST 的复杂差异。这让很多前沿研究可以快速落地到真实项目里,而不是永远停留在论文层面。
提示:如果你只是普通应用层开发者,可能觉得这些都是编译器团队的事。但你现在每天用的 Clang 诊断信息、Xcode 的构建加速、Android NDK 里的工具链、以及 Rust/Swift 的编译产物,背后全都是这套三层架构。理解它,对你排查构建问题、优化编译时间也有实打实的帮助。
2. 从 clone 到 ninja:一次完整的 llvm-project 构建实践
2.1 仓库结构和磁盘规划,先别急着 clone
llvm-project 是 monorepo 结构,也就是所有子项目放在同一个 Git 仓库里。核心目录我先列一张表给你:
| 目录 | 作用 |
|---|---|
llvm/ | 核心基础设施,包含优化器、IR 定义、后端框架,以及opt、llc等工具 |
clang/ | C/C++/Objective-C 前端 |
lld/ | LLVM 的链接器 |
libcxx/libcxxabi/libunwind/ | C++ 标准库实现及配套 |
compiler-rt/ | sanitizer 运行时、profile 运行时等底层库 |
mlir/ | 面向机器学习的多层中间表示框架,比 IR 更高层 |
flang/ | Fortran 前端 |
polly/ | 基于多面体模型的循环优化器 |
clang-tools-extra/ | clang-tidy、clangd 等工具 |
直接git clone整个仓库会有几个 GB 的历史数据,没必要。我建议按指定 tag 拉取浅克隆:
git clone --depth=1 --branch llvmorg-18.1.8 https://github.com/llvm/llvm-project.git cd llvm-project把llvmorg-18.1.8换成你想用的稳定版本号。为什么不直接 clone main 分支?因为 main 是持续集成分支,可能某个 commit 构建是坏的,初学者遇到这种问题根本分不清是环境问题还是上游问题。用 release tag 是所有人的共识。
磁盘规划这里必须提醒一句:至少留 80GB 可用空间。Release 版本构建,build 目录加源码目录很容易吃掉 30GB 到 60GB;如果再开 Debug 符号,100GB 也不稀奇。我第一次构建就是没注意空间,编译到一半磁盘满了,那种挫败感太强了。
2.2 CMake 配置里的几个开关,直接决定你的构建体验
LLVM 使用 CMake 构建。下面是我最常用的配置模板:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache逐个解释这些参数:
-G Ninja:强烈建议用 Ninja 而不是默认的 Unix Makefiles。Ninja 的增量构建和并行调度都更聪明,全量构建也更快。-DLLVM_ENABLE_PROJECTS="clang;lld":指定和 LLVM 一起构建的顶层项目。注意分号在 CMake 里是列表分隔符,shell 里要加引号。-DLLVM_TARGETS_TO_BUILD="X86;AArch64":指定要生成哪些后端的代码。默认是构建所有 target,那编译时间会翻好几倍。只构建你实际需要的后端,尤其如果你是 x86 开发机,写"X86"就够。-DLLVM_ENABLE_ASSERTIONS=ON:开发调试必开。它会打开 LLVM 内部的断言检查,能在早期帮你发现很多 IR 写错或不合法的问题。如果只是要用 Clang 编译业务代码,则没必要开。-DCMAKE_C_COMPILER_LAUNCHER=ccache:开启 ccache 缓存。LLVM 头文件巨多,任何小改动都可能触发几百个文件的重新编译,没 ccache 的话迭代效率低到让人想放弃。
还有一个容易被忽略的开关是LLVM_ENABLE_RUNTIMES。它和LLVM_ENABLE_PROJECTS很容易混淆。简单区分:ENABLE_PROJECTS里面的项目是和当前 LLVM 一起用已有编译器构建的;ENABLE_RUNTIMES里的项目(比如 libcxx、compiler-rt)需要用新构建出来的编译器再构建一遍,也就是自举。想调试 libc++ 或者给 sanitizer 加代码时,才需要打开LLVM_ENABLE_RUNTIMES。
2.3 编译和加速的实操心得
配置完成后,开始编译:
cmake --build build --target clang lld -j$(nproc)如果机器内存小于 16GB,强烈建议把-j限制低一点,比如-j4。链接clang和lld这些大二进制时,内存峰值非常高,用满所有核心可能导致 OOM,直接被系统杀掉。我在一台 8GB 内存的笔记本上试过全核编译,结果就是反复在链接阶段崩溃,最后老老实实-j2。
另外建议分阶段构建:先构建llvm-tblgen和clang-tblgen这种工具,再构建完整目标。不过 Ninja 会自动处理依赖关系,你不需要手动干预,只要目标名写得对就行。
ccache 的效果可以在编译结束后验证:
ccache -s第二次编译同一个目标时,命中率应该能达到 80% 以上。这意味着你改了 clang 里一个源文件,重新链接时大部分头文件编译结果都是缓存命中的,整体时间可以从十分钟级降到一两分钟级。
2.4 构建踩坑实录
这里说三个我在构建过程中真实遇到的问题。
第一个是系统 GCC 版本过旧。LLVM 18 要求编译器的 C++ 标准至少是 C++17,如果你的 Ubuntu 还是 GCC 7,CMake 会直接报错。解决办法就是先装一个比较新的 GCC,或者用 clang 来编译 clang。用新版本 GCC 编译 LLVM 是最省事的路线。
第二个是链接时内存不足。前面提过,解决方法是降低并行度,或者换成 Debug 版本试试。Debug 构建虽然运行慢,但二进制体积更小,链接内存峰值也低一些,对开发调试反而更友好。
第三个是Python 版本问题。LLVM 构建脚本里不少地方依赖 Python 3.6+,老系统默认 Python 版本太低,会导致各种奇怪的 CMake 报错。装一个新版 Python 并确保python3指向它,能省掉很多麻烦。
3. LLVM IR 是一切的枢纽:亲手把 C 代码翻译成中间表示
3.1 读懂 IR 必需的三个概念:SSA、基本块、Phi 节点
LLVM IR 是一种**静态单赋值(SSA)**形式。说人话就是:在 IR 里,每个变量只能被赋值一次。你可能觉得这限制太死板,但这正是它能做高效数据流分析的原因——每个值只有一个定义点,分析和优化时永远不需要等价替换。
IR 的基本结构是基本块(Basic Block):一串连续执行的指令,只有第一条指令可以被跳转进入,只有最后一条指令可以是跳转/返回指令。指令分两类:普通指令做计算,terminator 指令控制流程走向。IR 里的控制流就是用一个个基本块和跳转边构成的图,也就是 CFG(Control Flow Graph)。
当控制流在某个点汇合时,比如 if 分支的两条路径汇合到同一个块,这个块里的某个变量可能来自不同的前驱块。SSA 规定每个变量只能有一个赋值点,那怎么办?答案是Phi 节点。它的语义是"我根据控制流是从哪个前驱块来的,选择对应的值"。Phi 节点是初学者最容易懵的地方,但只要你记住这个"按前驱选值"的语义,后面读复杂 IR 就顺了。
3.2 实操:用 Clang 生成并解读一段 IR
写一段最简单的 C 代码:
// add.c int add(int a, int b) { return a + b; }用 Clang 生成 IR:
clang -S -emit-llvm -O0 add.c -o add.ll打开add.ll,会看到这样的输出(省略了文件头和属性):
define i32 @add(i32 %a, i32 %b) { entry: %add = add i32 %a, %b ret i32 %add }这个已经很简单了。define i32 @add(i32 %a, i32 %b)表示定义一个函数,返回类型i32(32 位整数),参数是%a和%b。add i32 %a, %b是整数加法指令,结果赋给%add。注意每个变量以%开头,并且整个函数里每个变量只赋值一次。
再看稍微复杂点的控制流:
// fact.c int fact(int n) { if (n <= 1) return 1; return n * fact(n - 1); }生成 IR 并打开(这里为了清晰,我用-O1优化过一次):
clang -S -emit-llvm -O1 fact.c -o fact.ll得到的核心部分大致是:
define i32 @fact(i32 %n) { entry: %cmp = icmp sgt i32 %n, 1 br i1 %cmp, label %if.then, label %if.else if.then: %sub = add nsw i32 %n, -1 %call = call i32 @fact(i32 %sub) %mul = mul nsw i32 %n, %call br label %if.end if.else: br label %if.end if.end: %result = phi i32 [ %mul, %if.then ], [ 1, %if.else ] ret i32 %result }这里的icmp sgt i32 %n, 1是比较指令:n 是否大于 1(signed greater than)。br i1 %cmp根据比较结果跳转到if.then或if.else。两个分支最终汇合到if.end块,块里的phi节点就是汇合点:如果来自if.then,取%mul;如果来自if.else,取常量1。这就是 Phi 节点最经典的场景。
3.3 三个工具是你读 IR 的随身三件套
opt:IR 层面的优化器。可以手动指定跑哪些 Pass,是学习 IR 和写 Pass 的核心工具。llc:把 IR 转成目标汇编或目标文件,是后端部分的入口。lli:直接解释执行 IR,相当于一个基于 IR 的虚拟机。
举个例子,mem2reg是 LLVM 里最有名的 Pass 之一,负责把"内存读写作变量"的模式提升为 SSA 寄存器。如果不开优化,Clang 生成的 IR 里会充满alloca、load、store指令,看起来非常繁琐:
clang -S -emit-llvm -O0 add.c -o add_opt0.ll然后跑opt:
opt -passes='mem2reg' add_opt0.ll -S -o add_mem2reg.ll你会看到 IR 瞬间变得清爽,alloca和load/store基本消失。所有优化 Pass 都是在 IR 上做变换,理解这一点很重要,因为你在opt里看到的层层变换,就是编译器从源代码到机器码之间真正发生的事情。
3.4 读 IR 时的四个常见困惑
第一是"为什么不开优化时全是 load/store"?这是正常行为。Clang 的前端为了忠实处理 C/C++ 的各种语义(比如取地址、指针别名),生成 IR 时默认把局部变量都放在栈内存里。只有经过优化 Pass 后,它才会发现"这个变量其实没有取地址,可以安全提升为寄存器"。
第二是"Phi 节点究竟怎么执行"?实际上 Phi 节点并不对应任何真实机器指令,它是给优化器用来传递数据流信息的抽象。后端的指令选择阶段会把 Phi 节点转化为真实的 move 指令。
第三是"undef 和 poison 的区别"。这俩都是"值不确定",但语义不同。undef是"每次读到的值可以是任意值,但不一定是未定义行为";poison则是"一旦以某种方式被使用,就会触发未定义行为"。调试优化后的 IR 时,看到这些关键字别慌,它们大多不会出现在你最终写的业务代码里。
第四是"IR 里那些nsw、nuw标志是什么"?nsw表示 no signed wrap,加法结果不会发生有符号溢出。有了这个标志,优化器才能放心地对算术表达式做更多数学变换。写 Pass 时需要特别注意:如果变换后的代码引入了未定义行为,那责任在 Pass 本身。
提示:读 IR 最有效的方法是边改代码边看 IR。随便写个小函数,加
-O0、-O1、-O2各生成一次,对比同一个函数的 IR 变化,比看十篇文档都管用。
4. TableGen 与 Pass:给 llvm-project 写一个自定义优化要过哪些关
4.1 TableGen:一门专门用来生成"编译器代码"的 DSL
进入写 Pass 和改后端之前,你迟早会遇到 TableGen。LLVM 后端对指令、寄存器、指令选择模式的描述,用的是一门叫 TableGen 的领域特定语言(DSL),源文件后缀是.td。这些.td文件会被llvm-tblgen工具自动生成出大量 C++ 代码,包括指令选择表、汇编解析器、反汇编器等。
为什么 LLVM 要这么绕?因为后端代码量实在太大。如果每个指令都手写一遍匹配逻辑、汇编输出逻辑、反汇编逻辑,代码会重复到无法维护。TableGen 的核心思路就是"一份描述,多处生成"。你在.td里描述一条指令的属性,工具自动生成它在指令选择器、汇编器、反汇编器里的所有相关代码。
举个例子,给一个假想 CPU 定义加法指令:
def ADD : I<(outs GPR:$dst), (ins GPR:$src1, GPR:$src2), [(set GPR:$dst, (add GPR:$src1, GPR:$src2))]>;这行描述的意思是:ADD 指令有三个操作数,输出一个通用寄存器$dst,输入两个通用寄存器$src1和$src2;它匹配 IR 里的add模式,当优化器看到一个整数加法时,会尝试用这条指令来覆盖它。
这就是 TableGen 的威力:你改一行描述,后面所有的匹配逻辑同步更新,不会出现"指令选择器和汇编器不一致"这种 bug。新手刚开始接触 TableGen 会觉得语法别扭,但它本质上只是结构化数据 + 继承 + 模式匹配,多读几个.td文件就习惯了。
4.2 完整代码:写一个统计 add 指令数量的 FunctionPass
假设你想写一个 Pass,统计每个函数里有多少条add指令。这对学习 IR 遍历和 Pass 框架是最合适的练手项目。
先看经典的 Legacy Pass Manager 写法:
// CountAdd.cpp #include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/IR/LegacyPassManager.h" #include "llvm/Pass.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct CountAddPass : public FunctionPass { static char ID; CountAddPass() : FunctionPass(ID) {} bool runOnFunction(Function &F) override { unsigned addCount = 0; for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (auto *BO = dyn_cast<BinaryOperator>(&I)) { if (BO->getOpcode() == Instruction::Add) { ++addCount; } } } } errs() << "Function " << F.getName() << " has " << addCount << " add instructions\n"; return false; // 我们没有修改任何 IR,所以返回 false } }; } // namespace char CountAddPass::ID = 0; static RegisterPass<CountAddPass> X("count-add", "Count integer add instructions");这段代码做了几件事:
- 继承
FunctionPass,表示这是一个以函数为单位运行的 Pass。 - 两层循环遍历函数里的每个基本块、每条指令。
- 用
dyn_cast<BinaryOperator>判断当前指令是不是二元运算。 - 检查 opcode 是否为
Instruction::Add。 - 最后用
errs()打印统计结果。 RegisterPass把 Pass 注册成名为count-add的命令行选项。
再来看 New Pass Manager 的等价写法。新 PM 是 LLVM 现在主推的框架,写法更简洁,核心是继承PassInfoMixin:
// CountAdd.cpp (new PM) #include "llvm/IR/Function.h" #include "llvm/IR/Instructions.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Passes/PassPlugin.h" #include "llvm/Support/raw_ostream.h" using namespace llvm; namespace { struct CountAddPass : public PassInfoMixin<CountAddPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { unsigned addCount = 0; for (BasicBlock &BB : F) { for (Instruction &I : BB) { if (auto *BO = dyn_cast<BinaryOperator>(&I)) { if (BO->getOpcode() == Instruction::Add) { ++addCount; } } } } errs() << "Function " << F.getName() << " has " << addCount << " add instructions\n"; return PreservedAnalyses::all(); } }; } // namespace新 PM 的 run 方法必须返回PreservedAnalyses,告诉优化器这个 Pass 改变了哪些分析结果。如果什么都没改,就返回PreservedAnalyses::all(),意思是所有分析结果都保留。
4.3 把 Pass 编进 opt 并跑起来的完整链路
编译进opt是最直接的集成方式。把CountAdd.cpp放到llvm/lib/Transforms/Utils/目录下,然后在同一个目录的CMakeLists.txt里加上这一行:
add_llvm_component_library(LLVMCountAdd CountAdd.cpp DEPENDS intrinsics_gen )重新编译 opt:
cmake --build build --target opt -j$(nproc)然后随便拿一个.ll文件测试:
opt -count-add add.ll -disable-output输出会类似:
Function add has 1 add instructions Function fact has 3 add instructions如果你用的是新 PM,可以按 Pass Plugin 的方式动态加载,也可以直接内嵌进opt。动态加载是另一种主流方式,需要额外写插件注册代码,这里不展开。对新入门的人来说,直接编进opt是最快看到效果的路径。
4.4 写 Pass 时我踩过的几个坑
第一个坑是dyn_cast返回空指针。dyn_cast是一个运行时类型检查转换,如果类型不匹配会返回nullptr。你必须在每个dyn_cast之后判空,否则解引用空指针会让 opt 直接崩溃,崩溃栈还很难定位。
第二个坑是"修改了 IR 却返回PreservedAnalyses::none()或反过来"。新 PM 里返回值是优化器分析结果缓存保持一致性的关键。如果你改动了 IR 但返回all(),后续依赖分析的 Pass 会用到过期数据,产生难以排查的错误。反过来,如果没改动却返回none(),虽然不会出错,但会导致后续所有分析缓存失效,性能大幅下降。
第三个坑是注册名字冲突。每个 Pass 在 LegacyPassManager 里的名字必须全局唯一。如果你起的名字已经存在,链接时不会报错,但运行时会出现重复注册的警告,而且行为不可预期。
第四个坑是忘掉初始化和清理全局状态。Pass 框架会复用一个 Pass 实例处理多个函数,如果你在成员变量里存了跨函数的状态,第一个函数的信息会污染第二个函数。最优实践是:不要在 Pass 对象里保存跨 run 的状态。
5. LLD、clang-tidy、sanitizer:llvm-project 对普通开发者的价值不只是编译器
5.1 日常开发里真正高频使用的几个组件
很多人以为 llvm-project 只是给编译器工程师用的,其实普通应用开发者也能直接受益。
clang-format:代码格式化工具。团队统一风格这件事,用配置文件和 CI 检查就能自动化,不用在 code review 的时候为缩进吵架。在项目根目录放一个.clang-format,然后clang-format -i src/*.cpp就能一键格式化。
clang-tidy:静态分析工具,能查出很多编译警告发现不了的问题,比如错误使用 move 语义、危险的类型转换、性能隐患。它和 clang-format 通常配套使用,一个管风格,一个管质量。
sanitizer 系列:ASan(AddressSanitizer)检测内存越界、释放后使用;UBSan(UndefinedBehaviorSanitizer)检测未定义行为;TSan(ThreadSanitizer)检测数据竞争。这些工具比 valgrind 快得多,是 C/C++ 开发者的救命稻草。ismuthion
LLD:LLVM 的链接器。链接大型 C++ 项目时,速度比 GNU ld 和 gold 快好几倍。特别是全量链接的 CI 场景,换 LLD 是最便宜的提效手段。
llvm-cov / llvm-profdata:代码覆盖率工具,搭配 Clang 的编译器插桩,能生成精确的分支覆盖率报告。
5.2 在 CI 里把它们组合起来
项目里最实用的组合是"编译器告警 + sanitizer + 静态分析"三条线并跑。
编译告警线:
clang++ -std=c++17 -Wall -Wextra -Wpedantic -Werror \ -fsanitize=address,undefined -g -O1 \ -fno-omit-frame-pointer main.cpp -o main这里-Werror把警告升级为错误,保证新代码不引入任何警告。-fsanitize=address,undefined在编译期插入检查代码,运行时一旦触发内存错误或未定义行为,直接报错并打印调用栈。-fno-omit-frame-pointer保证错误栈里有完整的函数调用链。这条命令可以作为本地开发默认编译参数,对性能的影响在可接受范围。
静态分析线:
clang-tidy main.cpp -- -std=c++17clang-tidy 的检查项很多,推荐刚开始先用默认集合,跑通了再按需开启更严格的规则。千万不要一开始就开几百条规则,那样会被噪音淹没。
链接加速线:
clang++ ... -fuse-ld=lld把链接器换成本项目自己提供的 LLD。大规模项目实测链接时间能减少 50% 以上。
5.3 想深入 llvm-project:我的学习路径建议
如果你已经看了前面的内容,并且想真的动手往 llvm-project 里贡献或做二次开发,我给你一条按我实际经验总结的路径。
第一步:把第二章的构建流程完整跑通,并且能改动一个无关紧要的代码后重新编译。这一步的目的不是学东西,是建立"我能改 LLVM"的信心。
第二步:认真读第三章。把 IR 文档里提到的每个概念都在opt里实际操作一遍。比如用opt -passes=print<cfg>打印 CFG,然后对比函数源码,理解每个基本块和跳转边。
第三步:动手写第四章那种简单 Pass。建议先写输出型 Pass(只统计,不修改 IR),再写变换型 Pass(比如把add X, 0替换为X)。变换型 Pass 会让你真正理解"修改 IR 并正确返回 PreservedAnalyses"这两个最难的点。
第四步:选一个后端看文档。推荐先看 RISC-V 或 X86 的指令选择部分,了解 TableGen 如何描述指令。这时候再回看第四章的 TableGen 示例,你会觉得特别亲切。
第五步:到社区里找一个带good first issue标签的问题,或者自己挑一个简单 bug 去修。LLVM 社区对新人贡献者其实相当友好,只要你的 patch 是清晰的、有测试的。
以我的个人体会来说,真正让我"开窍"的,不是看完了多少概念,而是某天为了修一个实际遇到的问题,我打开opt跑了一遍-print-after-all,亲眼看着 IR 在十几个 Pass 手里被一层层变换成高效的形态。那一刻,之前那些抽象的概念全部落地了。这种体验是只看文档永远得不到的,所以读到这里,你已经知道下一步该干什么了。