我一直觉得,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 --depth=1 https://github.com/llvm/llvm-project.git如果你只是需要最新代码来研究,--depth=1完全够用。如果你打算长期跟进甚至提 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_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86" \ -DBUILD_SHARED_LIBS=ON逐个解释下这几个选项的用意,因为每个选项背后都是真金白银的编译时间:
-G Ninja:Ninja 是 LLVM 官方推荐的构建系统。Makefile 在多核场景下并行度不如 Ninja 好,而且增量编译速度差距明显。别再用 Make 了,听我的,Ninja 能帮你节省至少 30% 的迭代时间。-DCMAKE_BUILD_TYPE=Release:这个直接决定你的编译产物性能。如果你想调试自己的 Pass,建议另建一个 Debug 构建目录,别在 Release 里开O0调试,那根本没法看变量。-DLLVM_ENABLE_PROJECTS="clang;lld":官方非常贴心地做成了组件化,你要用什么就开启什么。全开?可以,但你会多花两三个小时等待一个你根本用不到的后端编译完成。除非你是打包发行版,否则只开自己需要的工具就好。-DLLVM_TARGETS_TO_BUILD="X86":把目标架构裁剪到只剩 X86,编译速度可以提升不少。如果你要研究 ARM 后端,就写"ARM;AArch64"。默认全开,代价同样是时间。-DBUILD_SHARED_LIBS=ON:把 LLVM 自身的基础库编成动态库。这是个人开发调试的秘密武器:改一行库代码后重新链接只需要几秒钟,而不是几十秒的静态链接。代价是最终产物体积更大,运行速度稍有损失,但换来的迭代体验绝对值。
配置完成之后,编译命令极其简单:
ninja如果你想只编译某一部分,比如只要 clang 和 opt:
ninja clang opt关于内存不够这件事,我再多说一句。如果你的机器是 16GB 内存,建议在cmake时加上-DLLVM_PARALLEL_LINK_JOBS=1,这个选项能把链接时的并行任务数限制为 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 IR
build/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 RegisterPass<MyFunctionPass> 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_TYPE=Release \ -DLLVM_DIR=/path/to/build/lib/cmake/llvm \ -DCMAKE_CXX_STANDARD=17 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 -passes=my-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 RegisterPass<Hello> X("hello", "Hello World Pass", false, false);这是完整的旧式写法,它继承自FunctionPass,在runOnFunction里干活。上面我给的样例就是这种。
但新版 LLVM(从 15 版本开始,老 PassManager 逐渐被移出默认构建路径)更推荐使用New Pass Manager(NPM)的写法,核心差别在于:不再使用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 PassInfoMixin<MyNewPass> { 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, ArrayRef<PassBuilder::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-pm=0,老式 API 会报 deprecated 甚至直接不可用。
如果你花了一上午照着老教程写了 Pass,却在链接阶段看到RegisterPass is deprecated或者一堆模板实例化报错,别怀疑自己写错了——是该换新写法的信号了。
6. 调试 LLVM 的常用姿势:不是只有 GDB 一条路
改 LLVM 代码和改普通应用代码有一个很大的不同:当你写了一个 Pass 并跑在opt里时,你其实是在一个极其复杂的“解释器”环境中调试自己那点逻辑。每次opt重启、加载插件、跑 IR,都可能触发各种难以预料的交互。所以我的调试经验特别简单粗暴,分享三招:
第一招:善用outs()或errs()打印调试信息。这听起来像废话,但在 LLVM 这种项目里反而是最高效的方法。你的 Pass 会遍历 IR 指令,打印关键信息能帮你快速定位逻辑是否按预期走了。尤其配合-debug-only=my-pass,你可以把DEBUG宏打开,只打印自己关心模块的日志。
在 Pass 里写:
LLVM_DEBUG(dbgs() << "Visiting instruction: " << I << "\n");然后在 opt 命令行里加上-debug-only=my-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 -passes=my-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 核。尤其当你边编边开着浏览器查文档时,全核编译会让整个系统陷入“软死锁”。我现在养成的习惯是,编译和日常使用同时进行时,把链接任务限制为 1,CPU 并发限制为物理核数减 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 的门槛。这个项目确实大,但大不代表无法掌控,只要从一个点切入、跑通、验证、积累,你会发现它其实比想象中要友好得多。