LLVM实战:从编译llvm-project到编写自定义Pass的完整指南 我一直觉得LLVM 这个名字对很多写代码的人来说属于“如雷贯耳但从未深交”。你大概知道 Clang 是它的前端知道 Rust、Swift 甚至 GPU 生态都在它上面构建但真要说自己动手去改点东西、把 llvm-project 拉下来编译一遍很多人心里是发怵的。这份发怵不完全是因为 C 模板晦涩更多是面对一个超大规模的开源工程时不知道从哪里下刀。这篇文章我准备从一个普通开发者的视角聊一聊 llvm-project 这个仓库的真实结构、编译方法以及我是怎么一步步在里面改代码、跑通自定义 Pass 的。不吹架构不讲虚的全部是可落地的操作和路径。1. llvm-project 到底是个什么项目从仓库布局说起先说一个很多人不知道的事实你现在在 GitHub 上看到的 llvm-project 是一个单仓库 (monorepo)它把过去彼此独立的 LLVM、Clang、LLD、libc、compiler-rt 等一大堆子项目全塞进了同一个仓库里。早年不是这样的早年你要配齐一套工具链得分别拉三四个仓库对着版本号小心翼翼对齐那叫一个折腾。后来官方学聪明了搞了个超大规模的单仓库所有组件同步发布、同步迭代版本对齐的问题直接消失代价是仓库体积大到让人怀疑人生。我第一次git clone这个仓库的时候看着进度条一点点龟速前进内心是崩溃的。完整的.git历史大概有几个 GB全量 checkout 到工作区又是好几个 GB。后来我找到了官方推荐的浅克隆方式这个技巧基本可以让你省掉一杯咖啡的时间git clone --depth1 https://github.com/llvm/llvm-project.git如果你只是需要最新代码来研究--depth1完全够用。如果你打算长期跟进甚至提 PR那你最好还是全量拉取因为后续git rebase上游代码的时候缺了历史会很痛苦。拉下来之后第一件事应该是搞懂根目录下那一大堆文件夹各自是干什么的。我当初就犯过懵——一眼扫过去全是llvm、clang、clang-tools-extra、lld、libcxx这样的名字傻傻分不清。这里简单梳理一下你会发现它其实很有条理目录名定位我关注它的理由llvm/核心框架IR、优化器、代码生成、目标后端一切的地基想懂框架必须从这看clang/C/C 前端把源码解析成 AST再到 IR日常写 C/C 时打交道最多的部分lld/官方的链接器链接速度吊打老牌 GNU ld值得了解libcxx/libcxxabi/C 标准库实现和 ABI 层研究 STL 实现的好材料compiler-rt/运行时库Sanitizer、builtins、profile搞性能分析、内存检测绕不开clang-tools-extra/clang-tidy、clangd 等辅助工具日常开发效率就靠它们了polly/基于多面体模型的循环优化做 HPC 场景优化的人会深挖这张表基本就是个小地图。你真要上手改东西先确定自己要在哪个目录里“施工”别走错门。比如你想加一条编译器警告那你去clang/下面找你想加一个 IR 层的优化那你就得钻进llvm/里。方向一旦搞错代码连编译都过不了。2. 编译前必须拿捏死的两个关键点构建目录和 CMake 选项把源码拉下来只是万里长征第一步真正劝退很多人的是这个项目祖传的高配构建需求——内存至少 16GB低于这个数你有极大概率在链接阶段被杀进程磁盘剩余空间建议留 100GB如果你开 Debug 模式那奔着 150GB 去也不奇怪。我第一次编译时用的机器是 8 核 16G编译到一半系统直接卡死最后查了下进程collect2吃掉了 10 多个 G 内存。那会儿我才明白网上那些“小心 OOM”的警告句句都是前人用血泪换来的。但如果你以为硬扛过去就完事那还是天真了。真正决定你编译体验的是构建目录的设计。这里有一个许多小白都会踩的坑直接在源码目录里面跑 CMake。这样一旦构建产物和源码混杂你后续想 clean 都找不到干净的下手处。正确做法是单独建一个构建目录我一般这么来# 在 llvm-project 平级目录下建 build 目录 mkdir build cd build cmake ../llvm-project/llvm \ -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DBUILD_SHARED_LIBSON逐个解释下这几个选项的用意因为每个选项背后都是真金白银的编译时间-G NinjaNinja 是 LLVM 官方推荐的构建系统。Makefile 在多核场景下并行度不如 Ninja 好而且增量编译速度差距明显。别再用 Make 了听我的Ninja 能帮你节省至少 30% 的迭代时间。-DCMAKE_BUILD_TYPERelease这个直接决定你的编译产物性能。如果你想调试自己的 Pass建议另建一个 Debug 构建目录别在 Release 里开O0调试那根本没法看变量。-DLLVM_ENABLE_PROJECTSclang;lld官方非常贴心地做成了组件化你要用什么就开启什么。全开可以但你会多花两三个小时等待一个你根本用不到的后端编译完成。除非你是打包发行版否则只开自己需要的工具就好。-DLLVM_TARGETS_TO_BUILDX86把目标架构裁剪到只剩 X86编译速度可以提升不少。如果你要研究 ARM 后端就写ARM;AArch64。默认全开代价同样是时间。-DBUILD_SHARED_LIBSON把 LLVM 自身的基础库编成动态库。这是个人开发调试的秘密武器改一行库代码后重新链接只需要几秒钟而不是几十秒的静态链接。代价是最终产物体积更大运行速度稍有损失但换来的迭代体验绝对值。配置完成之后编译命令极其简单ninja如果你想只编译某一部分比如只要 clang 和 optninja clang opt关于内存不够这件事我再多说一句。如果你的机器是 16GB 内存建议在cmake时加上-DLLVM_PARALLEL_LINK_JOBS1这个选项能把链接时的并行任务数限制为 1。我不会告诉你链接个clang需要多少个 G只知道当时加上这个参数之后我的系统终于没有在高负载下面死掉。3. 搞定构建之后拿 clang 练手验证工具链能否正常工作当屏幕上终于出现ninja: no work to do那一刻恭喜你已经成功跨过了这个项目最大的门槛。接下来你手里就多了一把真正由自己编译出来的刀——build/bin/clang。这里我特别想说很多人编译完就完事了结果白白浪费了验证工具链的机会。验证的方式非常直观写一个最朴素的 C 文件#include stdio.h int main(void) { printf(Hello from my own clang!\n); return 0; }然后用它编译运行build/bin/clang hello.c -o hello ./hello输出顺利打出来的那一刻我建议你顺手再做几个小实验看看这套工具链是不是真的“活”了实验一看前端生成的语法树build/bin/clang -Xclang -ast-dump -fsyntax-only hello.c你会看到一大坨层层嵌套的 AST 节点这就是 clang 内部对一段代码的第一层抽象。如果你想理解 clang 为什么能精准地报出各种编译错误多看看这个输出有奇效。实验二看优化器中间的 LLVM IRbuild/bin/clang -S -emit-llvm hello.c -o hello.ll打开hello.ll看看里面是一堆带%前缀的虚拟寄存器和call指令。这就是 LLVM 的中介表示层是编译器整个流程中最核心的枢纽。你可以把它理解为一种“中间语言”C 语言被翻译成它再由它被翻译成机器码。明白了这一层后面你写 Pass 就是直接对这个 IR 做变换。这两个实验做完你对 LLVM 的流水线就会有一个从符号到语义的整体感知——这比看多少篇源代码分析都管用。4. 实战 LLVM Pass从一个最简单的 FunctionPass 说起很多教程带你看到这就默认你已经会写 Pass 了。但实际上第一次亲手写 Pass 的人多半都会卡在一个问题上我编译出来的opt怎么找不到我的自定义 Pass这背后其实牵扯到 LLVM 新老两套 Pass 架构的差异。我这里给你一条稳妥的、适合新手的路线。先明确一点我们写 Pass就是要写一段代码让 LLVM 优化器在处理 IR 的时候按照我们自定义的规则把 IR 做一次检查或变换。最常见的场景有做静态分析统计某种指令的出现次数、做自定义优化把某种 pattern 替换成更高效的指令序列。以最经典的“统计函数数量”为例。新建一个目录比如my-pass/在里面放两个文件。CMakeLists.txt内容如下add_llvm_pass_plugin(MyPass MODULE MyPass.cpp )这里的关键是add_llvm_pass_plugin这个函数是 LLVM 官方提供给“外部 Pass”的注册入口它会生成一个动态库让opt能够在运行时加载它。注意我们用的是MODULE关键字这意味着生成的是一个.so动态库而不是编进opt的静态插件。MyPass.cpp内容如下#include llvm/IR/Function.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class MyFunctionPass final : public FunctionPass { public: static char ID; MyFunctionPass() : FunctionPass(ID) {} bool runOnFunction(Function F) override { outs() Found function: F.getName() \n; return false; } }; char MyFunctionPass::ID 0; // 注册老式 Pass static RegisterPassMyFunctionPass X(my-pass, My Custom Function Pass, false /* Only looks at CFG */, false /* Analysis Pass */); } // namespace然后编译它。因为你是单独建了一个插件目录编译方式有两种一种是把这个目录放进 llvm-project 的某个位置然后在主CMakeLists.txt里打开它的开关跟随主项目一起编。这种方式对新手比较友好但需要改主配置。另一种方式是使用 llvm 提供的cmake外部模块模式用下面的命令mkdir build-my-pass cd build-my-pass cmake ../my-pass \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_DIR/path/to/build/lib/cmake/llvm \ -DCMAKE_CXX_STANDARD17 ninja编译成功后会生成一个libMyPass.so或MyPass.so。然后用opt加载它# 先生成一个测试用的 ll 文件 build/bin/clang -S -emit-llvm test.c -o test.ll # 加载插件并跑 pass build/bin/opt -load-pass-plugin./build-my-pass/libMyPass.so -passesmy-pass test.ll -S -o /dev/null如果你在终端看到了Found function: main这样的输出恭喜你写的第一个 Pass 已经成功运行了。这个过程中我踩过最大的坑是漏掉-load-pass-plugin。新版的opt默认使用新的 PassManager如果你不在命令行里显式加载插件就算你的插件已经在目录里opt也一样显示找不到。这个和旧版opt -load mypass.so的语法不一样需要特别留意。5. 从 Legacy Pass 到 New Pass Manager为什么写法差别这么大如果你去看 LLVM 官方文档或者网上一些老博客会发现很多人写的 Pass 长这样using namespace llvm; namespace { struct Hello : public FunctionPass { static char ID; Hello() : FunctionPass(ID) {} bool runOnFunction(Function F) override { errs() Hello: F.getName() \n; return false; } }; } // namespace char Hello::ID 0; static RegisterPassHello X(hello, Hello World Pass, false, false);这是完整的旧式写法它继承自FunctionPass在runOnFunction里干活。上面我给的样例就是这种。但新版 LLVM从 15 版本开始老 PassManager 逐渐被移出默认构建路径更推荐使用New Pass ManagerNPM的写法核心差别在于不再使用runOnFunction这样的隐式回调而是显式地实现run方法并且通过PassBuilder来响应注册事件。新式 Pass 的骨架是这样的#include llvm/IR/PassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { class MyNewPass : public PassInfoMixinMyNewPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { outs() NPM: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace // 注册为插件并注册 pass 的构建回调 llvm::PassPluginLibraryInfo getMyNewPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyNewPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-new-pass) { FPM.addPass(MyNewPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getMyNewPassPluginInfo(); }看到这里你可能会问这两种写法到底谁好其实各有适用场景。我的经验是老式写法Legacy PM胜在代码简单直观跑一次分析拿结果非常快特别适合教学和快速验证想法。新式写法NPM更适合生产环境——流水线更透明、能精细控制每个 Pass 之间的依赖关系16 版本之后默认构建默认不再启用-enable-new-pm0老式 API 会报 deprecated 甚至直接不可用。如果你花了一上午照着老教程写了 Pass却在链接阶段看到RegisterPass is deprecated或者一堆模板实例化报错别怀疑自己写错了——是该换新写法的信号了。6. 调试 LLVM 的常用姿势不是只有 GDB 一条路改 LLVM 代码和改普通应用代码有一个很大的不同当你写了一个 Pass 并跑在opt里时你其实是在一个极其复杂的“解释器”环境中调试自己那点逻辑。每次opt重启、加载插件、跑 IR都可能触发各种难以预料的交互。所以我的调试经验特别简单粗暴分享三招第一招善用outs()或errs()打印调试信息。这听起来像废话但在 LLVM 这种项目里反而是最高效的方法。你的 Pass 会遍历 IR 指令打印关键信息能帮你快速定位逻辑是否按预期走了。尤其配合-debug-onlymy-pass你可以把DEBUG宏打开只打印自己关心模块的日志。在 Pass 里写LLVM_DEBUG(dbgs() Visiting instruction: I \n);然后在 opt 命令行里加上-debug-onlymy-pass这样生产环境不打印任何调试信息但你在开发时能拿到完整日志。第二招用-print-after-all看 IR 的变化。这个选项会告诉你每一轮优化前后IR 到底发生了哪些变化。如果你写了一个 Pass不确定它是否真正生效跑一遍这个参数对比 Pass 前后的 IR dump 是最直观的。第三招遇事不决上 GDB 断点。LLVM 的模板和类型推导非常复杂GDB 打印 STL 容器内容惨不忍睹。我的建议是不要无脑打断点而是先在上面的LLVM_DEBUG打印里缩小问题范围再决定要不要单步。另外从 16 版本起很多 Pass 的 IR 类型和 API 有调整。比如getValue()-stripPointerCasts()一类的用法在新版本里可能被删掉编译时直接报错。遇到这种情况我一般先查迁移文档或者直接在源码里搜新版本的旧 API 名很多改动只是把函数名去掉前缀不要死磕模板。7. 测试驱动的完善给 Pass 添加单元测试和回归用例写完 Pass 只能算完成了一半另一半是给它配上靠谱的测试。LLVM 项目内部有一套完整的lit测试框架这套框架设计得非常精巧它本质上是通过RUN:注释来驱动命令行执行的。假设我要给my-pass写测试。先在项目里建一个test/目录放一个测试文件; RUN: opt -load-pass-plugin%libMyPass -passesmy-pass %s -S -o - | FileCheck %s define i32 main() { ret i32 0 } ; CHECK: Found function: main它的执行逻辑是opt加载插件对当前文件跑my-pass优化输出到 stdout然后FileCheck再去逐行匹配CHECK标记的行。只要有任何一个CHECK行匹配不上测试就挂掉。然后在 CMake 里把它注册进 lit 的测试路径这个操作通常在test/CMakeLists.txt里用add_lit_testsuite完成。我一般图省事会把整个test目录挂在已有的 lit 配置里# lit.local.cfg config.suffixes [.ll, .c]这里有个细节容易坑人如果你的 Pass 没有在输出里打印“Found function”CHECK会直接报error: expected string not found in input。这种失败信息对回归测试来说特别有价值因为它能精准定位到“逻辑改坏在哪一行”。8. 一些值得收藏的实战经验与避坑清单说句掏心窝的话以上这些代码你都能在官方文档里翻到但真正让人少走弯路的往往是那些文档里不会写、只能靠踩坑总结出来的细节。我把这几年折腾 LLVM 时的经验整理成一份避坑清单希望能帮后来人节省几个月的时间。关于源码和编译不要用make用ninja。这已经不是选择题了Ninja 和 Make 的增量编译速度差距服从“指数级”规律项目越大差距越明显。不要图省事把所有 targets 都编译。只留下你当前用的架构其余的全注释掉。这能让你每次调试增量构建的耗时减少一半以上。编译某个特定目标时用ninja -j2让出一些 CPU 核。尤其当你边编边开着浏览器查文档时全核编译会让整个系统陷入“软死锁”。我现在养成的习惯是编译和日常使用同时进行时把链接任务限制为 1CPU 并发限制为物理核数减 1。关于开发和调试尽量用动态库模式开发 Pass。静态链接模式下每次改一行 Pass 代码都要重新链接整个opt动态库模式则可以直接加载改动后的.so调试效率天壤之别。IR 和 Debug 信息要一起保留。如果遇到 Pass 崩溃用clang -g -O1编译产生带调试信息的 IR然后在 GDB 里能看到源码行号比对着无符号的 IR 猜要高效太多。回顾历史提交和 issue 讨论。LLVM 社区极其活跃很多看起来古怪的行为都已经被讨论过很多轮。搜一下llvm-dev邮件列表或者 GitHub issue通常能直接找到前人的解决方案。关于学习路径如果你真想深入 LLVM我的建议是遵循“能跑才算懂”的原则不要一上来就啃那本《LLVM Cookbook》或者官方文档因为里面很多例子已经喂了版本更新的“毒药”。最稳的路径是先自己编译一遍 llvm-project哪怕只是默认配置。看opt帮助里列举的所有 Pass找感兴趣的看一看源码。把clang -S -emit-llvm生成的 IR dump 出来对着 IR 文档逐行理解。写一个会做“整形”的 Pass比如把add替换成mul然后看opt前后 IR 的差异。最终进阶到写一个自动给代码加插桩的分析 Pass。走完这条路你对 LLVM 就不再是“听说过”而是真正有手感了。9. 下一步从改 Pass 走向理解整个优化流水线当你能够熟练编译 llvm-project、写出能跑的自定义 Pass并配好测试之后其实已经踩在了编译器开发的门槛上。这时候再回头看你可能会发现一个更深层的乐趣理解 LLVM 的优化流水线本身就是理解现代编译器如何把“人话”变成“机器话”的整个过程。比如你在写 Pass 时常用的runOnFunction/run接口背后对应着 LLVM 把函数当作优化单元的基本粒度。而你在opt -passes...里填的各种 Pass 名称本质上就是在编排一条流水线先做内存提升mem2reg再做循环展开loop-unroll最后做指令合并instcombine。每道工序都有自己的职责顺序错了优化效果可能差别很大。下一步有两个不错的方向方向一尝试给 Clang 前端加一个新的编译器警告。这条路能让你接触到前端 AST 层面的匹配逻辑比如当if语句的条件永远为真时给出提示。改动点集中在clang/lib/Sema目录里代码风格和写 Pass 完全不同但同样很有意思。方向二探索新的优化 Pass 思路。比如实现一个针对某个特定硬件特性的优化某些架构上对0 - x的指令序列有别的编码方式你就可以写一个 Pass把这条 IR 模式替换成新的目标指令。这就开始真正触碰到编译器“后端”的世界了。我个人其实更推荐方向一因为它能帮你补齐“前端”的知识盲区。很多人写 Pass 写了一年对 IR 了如指掌但对 AST 却一窍不通。而编译器真正重要的工作很大一部分其实发生在前端那颗 AST 树里。最后再分享一个我自己的小习惯每次看 LLVM 的源码我都会在llvm/include/llvm/IR目录里随手翻一翻。这个目录定义了整个 LLVM 世界的“通用语言”——Instruction、BasicBlock、Function、Module每一个类名背后都是一段编译器设计史上的经典决策。你看着这些头文件就像在看一个框架设计者用类型系统写下的注释很多困扰你很久的问题会突然豁然开朗。希望这篇东西能帮你迈过编译和使用 LLVM 的门槛。这个项目确实大但大不代表无法掌控只要从一个点切入、跑通、验证、积累你会发现它其实比想象中要友好得多。