
这两年做编译相关的东西只要跟工具链沾边就绕不开 llvm-project 这个名字。一开始我只是把它当成一个“能编译C/C的编译器”后来深入进去才发现这个仓库远不止 Clang 那点事。llvm-project 是一个庞大的基础设施集合它包含了编译器前端Clang、中间层优化框架LLVM Core、后端代码生成、运行时库compiler-rt、libc、libunwind以及调试器、链接器、二进制工具等一系列组件。它解决了编译器开发中一个非常核心的痛点不要为每一门语言、每一个硬件平台都从头写一遍优化和代码生成逻辑而是把“语言前端”和“目标后端”做解耦中间用一套稳定的中间表示来衔接。这个仓库适合谁往大了说任何想深入理解现代编译器工作原理、做语言开发、做芯片工具链适配、做静态分析或安全研究的人都值得花时间啃一啃。往小了说哪怕你只是好奇“为什么 Clang 能省那么多编译时间”顺着 llvm-project 走一圈心里也就有底了。我最初入坑是因为工作中要给一个自研的指令集做编译器后端当时盯着 LLVM 官方的 TableGen 文档看了两个星期才勉强看懂.td文件里那些眼花缭乱的类和关键字。那段时间踩了不少坑也慢慢摸清了整个项目的脉络。这篇文章不打算讲太多晦涩的理论就把我觉得最关键的架构思路、Core 层的工作原理以及从零构建到写第一个 Pass 的完整过程整理出来顺便把那些官方文档里不会写的坑也一并列上。1. 内容整体设计与思路拆解LLVM 三板斧的由来1.1 从 GCC 时代说起为什么需要 LLVM以前写编译器大家习惯的方式是像 GCC 那样由某个具体语言的前端直接走到针对特定 CPU 的代码生成。宏观上看确实能编译出高效的代码但有个致命问题——每加一种语言支持就得把树状中间表示的所有优化都重新过一遍每支持一种新 CPU 架构前端解析出来的语法树也得跟着适配。架构之间的复用性极差工具链变成了一堆“烟囱式”的独立堡垒。LLVM 的破局思路非常直接它把编译过程拆成了三段前端Frontend负责把源代码转成统一的中间表示 LLVM IR。优化器Optimizer只对 LLVM IR 干活做内联、循环优化、常量传播等Pass。后端Backend把优化后的 LLVM IR 转成目标平台的汇编或机器码。这样一来新语言只要实现一个前端把语法转成 IR就能白嫖后续所有优化和后端新芯片只要实现一个后端接收 IR那所有前端语言就都能跑到这块芯片上。这种解耦听起来不算石破天惊但真正做得如此彻底、并且形成完整生态的LLVM 是第一个。我在理解这个架构时喜欢做一个类比LLVM IR 就像是中间商前面的语言是供货商后面的 CPU 是零售商。供货商只负责把商品送到中间商的仓库零售商只从仓库拿货彼此不用知道对方的全套内部流程。只要规定好仓库的货架规格IR 的语义和格式整个供应链就活了。1.2 一个仓库联动整条工具链llvm-project 之所以叫“项目”而不是“编译器”是因为它远远超出了编译器的范畴。刚打开这个仓库时你会看到几十个目录看似混乱但核心部分其实相当清晰clangC/C/Objective-C 前端最常用的入口。llvm核心优化器与后端框架也是 llvm-project 的心脏。lld高性能链接器替代系统默认的 GNU ld速度有明显提升。lldb调试器可以理解成 LLVM 生态里的 GDB。compiler-rt提供__sanitizer、__builtin等底层运行时支持地址消毒器ASan就在这里。libc/libcabiC 标准库和 ABI 库的实现跟 libstdc 打对台。polly基于多面体模型的循环优化器主要用来做更深度的循环变换。mlir为机器学习编译器设计的可扩展中间层近几年非常火很多 AI 加速器工具链都在往它上面迁移。这种全栈式布局让 llvm-project 在工业界和学术界都成了事实上的标准底坐。以前说“编译器”大家只会想到 GCC现在不少芯片原厂的工具链、很多编程语言的官方实现甚至一些研究生课程里的 CPU 实验都会直接拿 LLVM 当基础设施来用。2. 核心细节解析与实操要点读懂 IR、Pass 与后端的骨架2.1 LLVM IR三层表示各有用途LLVM IR 是整个架构的粘合剂它分成三层形式你在学习和使用中都会碰到内存表示编译过程中各种 Pass 在内存中操作的核心数据结构主要由Module、Function、BasicBlock、Instruction等 C 类构成。文本表示就是我们熟悉的.ll文件能直接读、直接写非常适合调试和写测试用例。二进制表示即.bcBitcode文件方便模块间链接和存储常用于 LTO链接时优化。很多资料只强调“IR 是中间表示”但很少解释为什么 LLVM 选定它的一些反直觉特性。比如 IR 被设计成静态单赋值SSA形式就是说每个变量只允许被赋值一次。这看起来降低了灵活性但给优化带来了极大方便因为每个值的定义点和使用点都非常清晰做数据流分析和重写时不用担心变量在后面被悄悄修改。写 Pass 时经常要和指令类型打交道比如LoadInst、StoreInst、CallInst、BranchInst等。刚开始会感觉类很多其实规律很简单LLVM 里每个 IR 节点都继承了Value和User形成一个“使用者与定义者”的双向网状结构。深入操作时像在数据库里做关系查询熟络之后会觉得这套设计非常精妙。2.2 Pass 框架优化器官是怎么工作的优化器的主要工作单位就是 Pass。从依赖关系上可以分为两类分析 PassAnalysis Pass只做分析不修改代码。比如计算循环深度、推断函数属性、构建支配树等。它们的结果可以被别的 Pass 查询避免重复计算。变换 PassTransform Pass真正修改 IR 的 Pass。比如内联 Pass 会把小函数的函数体复制到调用点死代码消除 Pass 会把不被使用的指令删掉。我后来自己写编译优化时最大的感悟是Pass 之间不是一个一个独立运行的木偶而是存在清晰的依赖关系。你一旦写了一个变换 Pass就应该主动使用getAnalysis某分析Pass()去请求上游分析结果。如果忘记声明依赖编译器不会直接报错但可能在你不知情的情况下输出错误的优化结果这种 bug 非常隐蔽排查起来很痛苦。现在 LLVM 框架默认推荐的是新 Pass 管理器New PM。它用类似“流水线”的方式明确指定每个 Pass 的先后顺序也支持跨模块分析模块分析缓存比老的管理器更安全。针对函数级别的 Pass新版里统一用PassInfoMixin和AnalysisInfoMixin这类模板基类写代码风格也更统一。// 一个最基础的函数级变换 Pass 示例 #include llvm/IR/Function.h #include llvm/Pass.h #include llvm/IR/LegacyPassManager.h using namespace llvm; namespace { struct MyDemoPass : public FunctionPass { static char ID; MyDemoPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { // 这里就是你的核心逻辑遍历指令统计或改写 IR errs() Processing function: F.getName() \n; return false; // 返回 true 表示修改了 IR } }; } // end anonymous namespace char MyDemoPass::ID 0; static RegisterPassMyDemoPass X(my-demo-pass, My Demo Pass);这个例子看起来简单但背后有几个值得注意的细节。RegisterPass会把 Pass 注册到 opt 工具里这样运行opt -load-pass-plugin或者静态链接方式编译时就能通过命令行启用。如果你用新 Pass 管理器流程会不同需要实现llvm::PassInfoMixin并提供run(Function F, FunctionAnalysisManager AM)接口。2.3 后端代码生成从 IR 到机器码的“最后一公里”后端是 llvm-project 里信息量密度最高的地方它长得也最唬人。核心流程大致是指令选择SelectionDAG / GlobalISel把平台无关的 IR 指令映射成目标机器的指令或伪指令。不适合直接映射的模式会被“展开”成多个简单操作。指令调度Scheduling重排指令顺序尽量利用 CPU 流水线多发射提高指令级并行。寄存器分配Register Allocation把无限虚拟寄存器映射到有限的物理寄存器上装不下的就“溢出”到内存栈上。指令布局与发射Emit分配基本块地址把指令编码成二进制机器码产出汇编文件或目标文件。这部分跟 CPU 微架构强相关。比如 RISC-V 的后端就有大量针对压缩指令的文件ARM 后端还专门区分 AArch32 和 AArch64连指令集编码格式都完全不同。TableGen.td文件在后端扮演的角色是通过描述性语言生成大量 C 代码。它本质是一套 DSL用来描述指令格式、寄存器类、调用约定等。大多数写后端的新手都卡在这里因为它的语法太“声明式”了不像写普通 C 那样提交逻辑而像是在配置一张庞大的描述表。// RISCVInstrInfo.td 中定义一条指令的简化示例 def ADD : RVInstR0b0000000, 0b000, OPC_ADD, add, SDNode...;这种描述一旦写好LLVM 就会根据它自动生成匹配器、反汇编器、指令打印器等代码。理解了“用声明生成代码”这个思路再看.td文件就不会那么晕了。3. 实操过程与核心环节实现从源码构建到自定义 Pass3.1 构建配置别用系统默认参数直接干如果你只把 llvm-project 当作 Clang 来用大可以直接拉二进制发布包。但既然要学或改源码就得自己编译。第一次构建时最容易犯的错误是无脑cmake .. make结果等了四五个小时甚至直接 OOM。我在折腾多台机器后整理出了一套比较稳的构建方式。首先保证源码完整git clone https://github.com/llvm/llvm-project.git cd llvm-project然后建议建一个独立的 build 目录别把构建产物混进源码目录mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON这里我解释几个关键选项-G Ninja强烈建议用 Ninja 而不是默认的 Unix Makefiles多核并行构建时 Ninja 的任务图调度明显更高效增量编译也更快。-DCMAKE_BUILD_TYPERelease默认是 Debug体积巨大而且运行时超慢。除非你要跟踪源码调试 LLVM 自身否则 Release 会更省时间。-DLLVM_TARGETS_TO_BUILD只构建自己需要的后端。如果全量构建所有后端耗时和磁盘占用都会成倍增加。我一般只保留常用平台。-DLLVM_ENABLE_ASSERTIONSON建议打开它会让 Pass 在违反不变量时马上崩溃暴露问题而不是在错误状态里继续跑下去给你一份看似正常的输出。构建命令ninja # 或者 ninja clang lld按需构建如果机器内存小于 16GB建议限一下并行度否则链接阶段容易内存爆掉ninja -j4构建出来的核心二进制能在 build/bin 目录下找到包括clang、opt、llc、llvm-as等工具。opt就是你用来跑自定义 Pass 最方便的工具。3.2 写一个真正能跑的自定义 Pass为了实践我们来写一个简单但完整的 Pass打印出每个函数里的基本块数量并统计每条指令的操作码。这个例子虽然功能不强但能跑通从编译到加载的完整链路。我用新 Pass 管理器的写法因为这是当前主流。新建一个文件MyStatPass.cpp#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 { class MyStatPass : public PassInfoMixinMyStatPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned bbCount 0; unsigned instCount 0; for (auto BB : F) { bbCount; for (auto I : BB) { instCount; // 输出每条指令的操作码名称 errs() inst: I.getOpcodeName() \n; } } errs() [MyStatPass] Function F.getName() basic blocks: bbCount , instructions: instCount \n; return PreservedAnalyses::all(); } }; } // end anonymous namespace extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, MyStatPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name my-stat-pass) { FPM.addPass(MyStatPass()); return true; } return false; }); }}; }编译这个 Pass 时需要链接 LLVM 的核心库。最省事的方式是用llvm-config提供的编译参数。我构建完的llvm-config在build/bin下可以这样编LLVM_BUILD_DIRpath/to/build $LLVM_BUILD_DIR/bin/llvm-config --cxxflags --ldflags --libs命令行操作clang -fPIC -shared MyStatPass.cpp \ $(llvm-config --cxxflags --ldflags --libs) \ -o MyStatPass.so然后准备一份测试 C 代码// test.c int add(int a, int b) { return a b; } int main(void) { int x add(1, 2); return x; }先用 clang 把它编译成 LLVM IRclang -S -emit-llvm test.c -o test.ll再用 opt 加载插件运行opt -load-pass-plugin./MyStatPass.so \ -passesfunction(my-stat-pass) \ -disable-output test.ll能看到类似输出inst: alloca inst: store inst: store inst: load inst: load inst: add inst: ret [MyStatPass] Function add basic blocks: 1, instructions: 7 ...这一步跑通之后你就已经完成了一次“自定义编译优化”的闭环。后面想继续深入可以试着把某个 IR 指令替换成另一条比如把add改成sub或者做一些简单的死代码消除逐渐你会发现自己能掌控编译器的一小部分行为了。3.3 用 Debug 构建辅助分析 Pass 内部的运行状态你可能会问为什么还需要 Debug 构建因为写 Pass 时最常见的场景是“结果不对但不崩”。调试模式下构建的 LLVM 会启用大量断言和详细日志配合-debug-onlyxxx命令行参数可以打印出很多内部信息。比如你要看某个 Pass 执行时 IR 如何变化可以打开指定调试通道opt -debug-onlyinline -passesinline test.llDebug 构建虽然慢但在开发阶段非常值得。我自己的习惯是保持两套 build一套 Release 用来日常跑测试链一套 Debug 专门分析疑难 bug。这两个目录并不冲突就是磁盘开销会大一些如今存储便宜值得做这个投资。4. 常见问题与排查技巧实录4.1 构建速度过慢与磁盘空间不足很多第一次接触 llvm-project 的人在构建阶段就放弃了因为“等了太久”。除了用 Ninja、只选目标后端之外还可以把源码放到 SSD 上构建也在同一块 SSD 上。机械硬盘在成千上万个文件编译时 IO 会成为明显瓶颈。如果磁盘确实紧张可以配置-DLLVM_PARALLEL_LINK_JOBS1或2限制链接并行度。链接阶段会启动后端进行 LTO 或生成大量符号表内存和 CPU 占用同时飙高限一下能有效防止机器卡死。另外升到较新版本后项目体积进一步膨胀下载源码前先确认磁盘至少留 50GB 空闲是所有必要组件全量构建的花费。4.2 自定义 Pass 没有生效普遍现象是插件编译成功后运行 opt 没有任何输出。原因是 Pass 没有注册到对应的 pipeline 上或者命令行名称对不上。新版 Pass 管理器对名称匹配非常严格需要确保registerPipelineParsingCallback里Name my-stat-pass与命令行-passesfunction(my-stat-pass)完全一致少一个字母都不行。还有一种情况是run()返回了PreservedAnalyses::all()这表示 Pass 告诉框架“我什么都没改”。如果你确实改了 IR需要返回PreservedAnalyses::none()或精确更新分析结果。这直接影响后续 Pass 是否能拿到正确的分析数据很多时候 Pass 不报错但结果不对就是返回值写错了。4.3 编译期与运行期环境不一致如果你把自定义 Pass 用-shared方式编成.so运行 opt 时可能会报版本不匹配或找不到符号。核心原则是Pass 的编译工具链和运行 opt 必须基于同一套 LLVM 头文件和库。不同版本之间 ABI 不保证兼容即使只有小版本差异也可能出问题。排查方法ldd MyStatPass.so | grep llvm能看到链接的是哪个目录下的 libLLVM。用which opt确认 opt 也来自同一个 build 目录不要出现系统/usr/bin/opt加载手工编译.so的情况。4.4 对 IR 修改后生成的汇编不符合预期做后端或指令选择优化时经常会发现 IR 明明正确但最终汇编里有奇怪的多余指令。这种问题往往不是算法错误而是被目标机器的指令选择模式干扰了。比如你以为自己生成了一个很漂亮的add结果目标机型有更高效的lea寻址模式编译器会用lea来完成加法。排查时建议分阶段检查在 IR 层面确认优化正确。在 SelectionDAG 阶段之前打印基 IR。在“指令选择后”与“寄存器分配后”各打印一次定位是哪一步引入的变化。llc -print-after-all -debug-onlyisel test.ll-print-after-all很啰嗦但配合日志管道和 grep往往几分钟就能找到可疑变换。4.5 调试器里看不到源码行信息有些 IR 传给 opt 时会把源文件路径和行号弄丢导致你后端的调试信息不完整甚至是?行号。原因一般是编译测试文件时没用-g选项或者 IR 生成后又被某些 Pass 错误地去掉了!dbg元数据。保证调试信息可用最简单的一句话从clang -g -S -emit-llvm开始。确认 test.ll 里有!dbg节点这样后续 CodeGen 和 lldb 调试才能对得上行号。5. 实操心得与扩展方向我个人在实际操作中最深刻的体会是读 llvm-project 源码不能按文件夹顺序从头读到尾要带着问题去逆向检索。你一旦想明白“我要改的是哪个环节”就顺着“前端 — IR — 优化 — 后端”这条链路去找对应目录通常比漫无目的地翻阅更能建立记忆。另外建议从小工具链入手不要一上来就碰复杂的寄存器分配。可以先写指令选择的一个小 pattern或者改后端的一个伪指令展开逻辑。一点点微小的改动配合llc -marchxxx -mcpuxxx file.ll的对比输出那种“编译器确实按我设想的走了”的感觉是学习这个项目最大的正反馈。llvm-project 的后续扩展方向也很多。如果你对语言实现感兴趣可以去看 Clang 的 AST 与 CodeGen如果你对 AI 芯片或者自研 NPU 感兴趣MLIR 是你绕不开的一站如果你关注代码安全ASan、UBSan 等 runtime 工具也全部在这个仓库里。掌握这套基础设施等于同时掌握了编译、工具链、调试与分析领域的底层通识。最后再分享一个小技巧在 llvm-project 的源码里搜//---开头的注释它会像路标一样告诉你这个文件在整个工具链里的位置。这个看起来不起眼的注释习惯实际上帮我节省了大量摸索时间。希望这篇文章能让你在 llvm-project 这个庞然大物面前少走一点弯路。