如果你点开这个标题,大概率是已经被 LLVM 那套庞大的代码库折磨过一轮了:要么是想自己写个编译器后端,要么是想在中间表示层做点静态分析,再要么就是纯粹想搞懂 Clang 到底是怎么把 C++ 变成机器码的。不管哪种动机,llvm-project 这个仓库都是绕不开的庞然大物。我在里面摸爬滚打了几年,从最初面对上百个子目录不知所措,到后来能熟练地改代码、跑测试、提 patch,中间踩过的坑攒了一堆。今天这篇文章不打算复述官方文档,而是把我自己从零开始接触 llvm-project、逐步深入的过程和心得完整写下来,希望能帮你少走点弯路。
这篇文章适合两类人:一类是刚把 llvm-project clone 下来、正准备构建却不知道从哪下手的初学者;另一类是已经在用 LLVM 但只是在 API 层面调用、还没真正深入源码内部的中级用户。我会从仓库结构、构建配置、编写 Pass、测试验证、社区协作这几个角度展开,尽量把“为什么这样做”也讲清楚,而不是只丢给你一串命令。
1. 先搞明白llvm-project仓库里装的是什么
第一次 clone llvm-project 的时候,你大概率会被它的体积吓到——完整历史好几个 GB,checkout 出来之后光.git目录就能占用一大块磁盘。但这其实不是坏事,因为这个仓库已经不是一个单纯的编译器了,而是一整套工具链生态。理解仓库的整体布局,是之后所有操作的基础。
1.1 顶级目录不是随便分的
打开 llvm-project 根目录,你会看到clang、lld、lldb、compiler-rt、mlir、flang、libc、libcxx、libcxxabi、openmp、polly、lldb、clang-tools-extra、bolt、cross-project-tests等一堆目录。很多人以为 LLVM 就是编译器,其实严格来说,llvm这个子目录才是“核心编译器基础设施”——它包含了优化器、代码生成、目标后端、以及一系列库。而clang是 C/C++/Objective-C 的前端,负责把源代码解析成 AST,再降级到 LLVM IR。
这里有个新手很容易混淆的点:我们平时说的“用 LLVM 写编译器”,实际上说的往往是“用 LLVM 的库”来写,而这些库分散在llvm/include、llvm/lib、llvm/tools等目录里。比如你想实现一个静态分析工具,通常会用到llvm/lib/IR、llvm/lib/Analysis、llvm/lib/Transforms这些地方的代码。而clang目录下的东西,只有在你需要处理 C 语言家族的语法和语义时才会牵涉进来。
1.2 各子项目的分工其实很清晰
为了让你快速建立心智模型,我把最关键的几个子项目整理如下:
| 目录 | 作用 | 典型用途 |
|---|---|---|
llvm/ | 核心:IR、优化、代码生成、目标后端 | 写 Pass、研究优化、支持新指令集 |
clang/ | C/C++/ObjC 前端 | 修改语法支持、静态检查、AST 相关工具 |
lld/ | 链接器 | 想深入链接过程时重点关注 |
lldb/ | 调试器 | 和调试器相关的研究 |
mlir/ | 多层级 IR 框架 | 做 DSL、HPC、AI 编译器 |
libcxx/ | C++ 标准库实现 | 需要换标准库实现时关注 |
compiler-rt/ | 运行时库(sanitizer 等) | ASan、UBSan、profile 相关 |
需要特别说明的是,mlir虽然是后起之秀,但它现在几乎成了 llvm-project 里最活跃的部分之一。如果你关注 AI 编译器、芯片厂商的软件栈,肯定会发现它们底层大量用到 MLIR。不过这次我不打算展开 MLIR,因为它的体系足够再写几篇文章,今天仍然聚焦在核心 LLVM 和 Clang 上。
1.3 monorepo 到底意味着什么
llvm-project 采用 monorepo(单仓库多项目)的管理方式,这意味着所有子项目的版本是同步的。以前 LLVM 是分仓库管理的,人们经常因为 Clang 和 LLVM 版本不匹配导致各种诡异问题。现在统一在一个仓库里,你只需要保证 checkout 的是同一个 commit,所有子项目就都对齐了。这个设计极大降低了协作成本,但也带来了新的问题——仓库太大,clone 太慢。我的建议是使用--depth=1做浅克隆,或者干脆用 GitHub 的“下载 ZIP”方式,只在你真正需要 git 历史时才做完整克隆。
2. 源码构建是绕不过去的第一道坎
很多人问我:为什么不直接装发行版自带的llvm包?如果你只是普通用户,确实没必要从源码构建。但如果你要改 llvm-project 里的代码,或者需要自定义配置,就必须自己构建。而且源码构建的很多细节会影响你后续的开发效率,所以我专门用一个章节来聊构建这件事。
2.1 选择合适的版本和构建目录
首先,确定你要构建哪个版本。我建议不要直接 clone main 分支,因为 main 分支是持续集成的状态,可能今天还能编译,明天就被一个未完成的改动搞坏。最好选择一个 release 分支,比如llvmorg-18.1.8这种打了 tag 的版本。如果你要跟社区最新特性,那就做好心理准备:偶尔构建失败是常态。
其次,强烈建议使用“单独构建目录”(out-of-source build)。也就是在 llvm-project 外面建一个build目录,然后在里面运行 cmake。这样能避免污染源码树,出问题时删掉 build 目录就能重新开始。我自己习惯这样组织:
llvm-project/ build-release/ build-debug/这样我可以保留一个 release 版本用于日常测试,另起一个 debug 版本来开发调试验证。两个目录互不干扰,切换成本几乎为零。
2.2 CMake 参数里面藏着大学问
LLVM 的 CMake 配置选项多到你怀疑人生。但核心的我只会用这几个:
cmake -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DCMAKE_INSTALL_PREFIX=$HOME/llvm-install \ ../llvm-project/llvm一个个来解释:
-G Ninja:用 Ninja 而不是默认的 Unix Makefiles。Ninja 的增量编译速度明显更快,而且输出更容易阅读。-DCMAKE_BUILD_TYPE=Release:构建优化版本。如果不开优化,LLVM 自身代码运行效率很低,跑测试会慢得让人崩溃。但如果你要调试 LLVM 本身的代码,就得用Debug或RelWithDebInfo。我推荐RelWithDebInfo,它既有优化也有调试信息,在多数场景下是折中的好选择。-DLLVM_ENABLE_PROJECTS="clang;lld":决定要构建哪些子项目。默认只构建核心 LLVM,这样编译时间会短很多。当你需要 Clang 时一定要把它加进来。多个项目用分号分隔,注意 CMake 的变量传递里分号要加引号。-DLLVM_TARGETS_TO_BUILD="X86;AArch64":指定要生成哪些后端的机器码。如果全开,会额外花费大量编译时间,而且你大概率用不上那些嵌入式后端。只保留你本机架构以及你研究需要的架构就够。-DCMAKE_INSTALL_PREFIX:install 时放置二进制的位置。
此外,如果你是做编译优化研究,大概率还会用到-DLLVM_BUILD_EXAMPLES=ON和-DLLVM_INCLUDE_TESTS=ON。前者会编译一些示例,后者在运行时可以跑 lit 测试。
2.3 构建时间怎么压下来
纯编译 llvm-project 全套项目(包括 clang 和 lld),在 8 核 16 线程的机器上通常要 20-40 分钟。第一次构建慢是正常的,但之后增量编译就会快很多。如果你想进一步压缩时间,有几个技巧:
- 加
-DLLVM_CCACHE_BUILD=ON并安装 ccache,针对头文件频繁改动的情况能大幅节省时间。代价是磁盘多占几个 GB。 - 不要用
-j把 CPU 跑满,留一两个核给系统。否则你的机器会卡到你怀疑人生。 - 用
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++可以用系统已有的 clang 来编译 llvm-project,生成出来的编译器性能通常比 GCC 编译出来的更好,而且能减少一些兼容性问题。 - 如果只是写一个 Pass,你其实不需要构建完整的 clang,很多情况下只构建 llvm 即可。用
opt工具配合一个预生成的.ll文件就能完成测试。
2.4 构建失败的常见原因和排查手段
我见过的构建失败,九成是因为版本不匹配或内存不够。LLVM 的 C++ 模板爆炸和 header 依赖导致编译时内存占用很高,单个文件如X86ISelDAGToDAG.cpp在 Debug 模式下可能吃掉好几个 GB 内存。如果编译中途突然报Killed(被系统杀掉),通常就是内存不足,这时应该减小并行度,或改用 Release 模式。
另一个经典问题是在 macOS 上不使用系统自带的 Clang 而是使用旧版 Xcode Command Line Tools,导致标准库头文件版本不一致,报出一堆莫名错误。解决办法是安装完整版 Xcode 或更新 Command Line Tools。
如果你遇到了cmake报找不到ninja,先检查ninja --version是否正常。如果你之前用过源码安装的 ninja,可能版本太旧或与 CMake 不兼容,建议用包管理器安装最新版。
3. 我的第一个LLVM Pass:从零开始改代码
熟悉完构建之后,就可以动手改代码了。很多初学者对“写 Pass”望而生畏,其实核心概念并不复杂:LLVM 对整个编译过程做了模块化,每个优化或分析功能都作为一个 Pass 在 IR 上执行。你写一个 Pass,就是在实现对 IR 的一段转换或分析逻辑。下面我用一个最简单但完整的例子,带你走一遍整个过程。
3.1 Pass 是什么,它运行在哪一层
先建立直觉。想象 LLVM IR 是一个三地址码的中间表示,里面包含函数(Function)、基本块(BasicBlock)、指令(Instruction)。一个 Pass 能读取这些结构,也能修改它们。打个比方:IR 像是一篇文章,Pass 就像是做重写和批注的人。你可以只读不改(Analysis),也可以大改特改(Transformation)。编译器在-O2时执行的一系列优化,就是由几十个 Pass 组成的流水线。
3.2 创建一个新 Pass 的完整步骤
为了和社区推荐的方式保持一致,我以“新的 Pass 管理器(New PM)”为准,从一个简单的“打印函数名”的 Pass 开始。
第一步,在llvm/lib/Transforms/Utils/下新建一个文件,比如HelloWorld.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 HelloWorldPass : public PassInfoMixin<HelloWorldPass> { public: PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "Hello from: " << F.getName() << "\n"; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getHelloWorldPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "HelloWorldPass", LLVM_VERSION_STRING, [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "hello-world") { FPM.addPass(HelloWorldPass()); return true; } return false; }); }}; } extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloWorldPluginInfo(); }这段代码虽然看着长,但核心只有两个部分:run函数是 Pass 真正工作的位置;llvmGetPassPluginInfo是让opt能动态加载这个插件的入口。
第二步,把它编译成动态链接库。这里的关键是用clang++或g++,并且要加$(llvm-config --cxxflags)和-fPIC -shared。具体命令大致如下:
clang++ -shared -fPIC HelloWorld.cpp $(llvm-config --cxxflags) -o libHelloWorld.so注意:llvm-config是你构建出来的工具,它在build/bin目录下。如果你的环境变量没配好,可以用build/bin/llvm-config的完整路径。
第三步,准备一个测试用的 IR 文件test.ll:
define i32 @add(i32 %a, i32 %b) { %sum = add i32 %a, %b ret i32 %sum }第四步,用opt加载插件并运行 Pass:
opt -load-pass-plugin=./libHelloWorld.so -passes=hello-world test.ll -o /dev/null如果一切正常,你应该能在终端看到:
Hello from: add看到这行输出,说明你的 Pass 已经成功跑起来了。
3.3 为什么推荐插件方式而不是改源码构建
你可能注意到,上面的示例没有动 llvm-project 源码树里任何文件,而是用-load-pass-plugin动态加载。这比在llvm/lib/Transforms/Utils/CMakeLists.txt里加一个add_llvm_component_library然后重新构建要快得多。因为修改源码树内文件会触发重新编译该目录下的所有内容,而且构建时间很长。作为初期试验,使用插件是非常高效的手段。
不过这也有局限:插件无法注册到默认的优化流水线中,只能在命令行用-passes=显式手动运行。如果你希望它成为-O2流水线的一部分,那还是得走源码内修改。这个我们后面再展开。
3.4 run 函数里能干哪些事
上面的例子只实现了打印函数名,这显然没有实际价值。真正写 Pass 时,你通常需要遍历指令,分析数据流,进行模式匹配和重写。我给你一个重要原则:先理解不可变性(const),再动手修改。LLVM 大量使用了 const 正确性和迭代器失效规则。在修改指令之前,务必深入理解Instruction类的方法,比如getOperand、setOperand、replaceAllUsesWith等。否则很容易在遍历过程中因迭代器失效导致崩溃。
举一个简单的实用例子:把所有add指令的操作数顺序交换。这听起来简单,但要考虑浮点加法是否有交换律,还要处理vector、fast-math标志等边界情况。实际写代码时你会慢慢体会到 LLVM 设计的严谨。
4. 改完代码怎么验证:lit测试和FileCheck的正确玩法
很多新手写完 Pass,跑一个简单例子就觉得自己完工了。这远不够。LLVM 社区有一套非常成熟的测试体系,核心是lit和FileCheck。你要想你的代码被社区接受,测试是必须的。即使不参与社区,测试也能帮你防止后续改动破坏已有功能。
4.1 lit测试的路径和语法
在 llvm-project 源码里,几乎每个子目录下都有一个test目录。比如llvm/test/Transforms/下就按 Pass 分类存放了很多.ll文件,每个文件就是一个测试用例。这些文件并不是单纯的 IR,而是用注释形式写上了 RUN 指令和期望输出。
一个典型的 lit 测试长这样:
; RUN: opt < %s -passes=hello-world -S | FileCheck %s ; CHECK: Hello from: add define i32 @add(i32 %a, i32 %b) { %sum = add i32 %a, %b ret i32 %sum }解释一下:; RUN:会被 lit 子进程执行。%s是当前文件路径,-S表示输出 IR(而不是二进制 bitcode),输出会通过管道传给FileCheck %s。FileCheck 会扫描当前文件里的; CHECK注释,并在实际输出中查找匹配的行。
4.2 FileCheck 的关键规则
FileCheck 的行匹配默认是“子串包含”,不是“完整匹配”,所以; CHECK: Hello from: add能在输出Hello from: add时匹配成功,也能在输出Hello from: add_with_default时误匹配。所以测试时最好把 CHECK 行写得更具体一些。
常用的匹配符号:
{{.*}}:匹配任意字符。[[NAME:...]]:定义一个变量,后续可以用[[NAME]]引用,用于检查一致性。比如你要检查计算后的值是否相同,可以把变量捕获下来反复使用。{{[0-9]+}}:匹配数字。CHECK-NOT:检查某个模式在指定范围内不出现,用于排除错误的输出。CHECK-LABEL:用于分割检查块,通常放在函数定义之前,防止匹配串行。
新手最容易犯的错误是:在 FileCheck 里写正则时,把大括号直接写出来,导致匹配失败。记住 FileCheck 的正则外层需要用双层大括号包裹,比如{{.*}}。这是我踩过很多次的坑。
4.3 运行测试的正确姿势
构建完后,build/bin/llvm-lit就是 lit 的入口。如果你想跑全部测试,那时间很长。通常我们会指定某个子目录或文件:
build/bin/llvm-lit llvm/test/Transforms/HelloWorld如果只跑单个文件:
build/bin/llvm-lit -v llvm/test/Transforms/HelloWorld/hello.ll-v能显示更多细节,包括 stdout/stderr。如果测试失败,lit 会打印出实际输出和期望输出的差异。这个差异信息非常宝贵,一定要仔细看,而不是只盯着 PASS/FAIL 的结论。
4.4 测试代码的“边界意识”
写测试用例时,不要只准备一个 happy path。编译器开发里最容易出的问题,恰好是不常见但合法的输入。比如写一个循环展开 Pass,你必须测试:
- 循环边界是变量时(动态边界)
- 循环体中含有 continue / break
- 循环体含有多个退出边
- 带 vector 类型的循环
- 几乎无限循环(有无限循环保护)的情况
这些边界条件能逼着你思考实现的完备性。社区里那些经验丰富的 reviewer 在 review 你的 patch 时,一定会问:“如果遇到这种边界情况怎么办?”如果你在测试里提前覆盖了,就能省去大量沟通成本。
5. 提交补丁之前必须懂的社区协作惯例
如果你打算把自己的改动贡献给 llvm-project,而不仅仅是自娱自乐,那么了解社区的协作流程很重要。LLVM 社区有一套相对固定但可能对新手不太友好的规矩。按照惯例走,你的 patch 才可能被 merge。
5.1 代码评审是绕不开的环节
llvm-project 的代码变更不直接 push 到分支,而是通过 review 系统(目前主要用 Phabricator,也有一部分迁移到了 GitHub Pull Requests)。在提交之前,你需要用git diff或git format-patch生成 patch,传到 review 平台,然后等待社区成员 review。Reviewer 会给出很多意见——语气可能比较直接,但都是对代码质量负责。千万别把 reviewer 的意见当成个人否定。
我自己第一次提交 patch 时,被一个很资深的维护者连续打了七八次回票,原因包括:代码风格不符合clang-format规范、缺少测试、注释不清晰、处理边界情况不对。虽然过程挺难受,但改完之后整个代码质量确实明显上了一个台阶。
5.2 提交信息要遵循规范
提交信息要简洁但信息量足。LLVM 的惯例是:第一行是概要,使用命令祈使句句式,例如[InstCombine] Fold (X - Y) + Y to X,然后空一行,写详细描述。描述里要说明为什么做这个改动,以及主要改动的逻辑。不要写“Fix a bug”这种没营养的话。
5.3 用 clang-format 规范代码格式
LLVM 有自己严格的代码风格:缩进、空格、命名等都和常见工程不太一样。好在社区提供了clang-format工具,配置就在.clang-format文件里。提交前对你的改动运行:
clang-format -i <文件>这样能省去很多格式争论。但要注意:clang-format只处理格式,不管语义,所以还需要自己检查逻辑。
5.4 关联 bug 和文档
如果修复了某个 issue,在提交信息里加上Fixes #12345。如果是新功能,同时更新相关文档(通常在llvm/docs下),社区特别看重这一点。没有文档的改动,可能会被 reviewer 要求补文档才允许合入。这说明 LLVM 团队非常注重可维护性和可理解性。
6. 新人在llvm-project里常见的坑和我的建议
最后这个章节,我想直接给新人提供一份“避坑地图”。这些问题如果你不提前知道,遇到一个就会卡很久。
6.1 不要一开始就啃后端
LLVM 后端涉及目标描述文件(TableGen)、指令选择、寄存器分配、指令调度等非常复杂的东西,新手直接上手很容易被绕晕。我建议先从Transforms和Analysis入手,因为这两块的 IR 层逻辑相对直观,你能快速看到自己的改动对编译结果产生的影响。等你理解 IR 的结构和 Pass 管理机制后,再往后端走,会顺畅很多。
接着就是TableGen。LLVM 用 TableGen 这一 DSL 来写指令集描述和各类目标信息。不看 TableGen,你基本看不懂后端代码。很多人在这上面栽跟头,因为 TableGen 的语法和传统编程语言差别很大。我的建议是先掌握它的核心概念:class、def、multiclass、foreach,以及字段继承覆盖规则。把这个搞清楚,再看XXXInstrInfo.td就不至于一脸懵。
6.2 遇到问题先查官方文档,再查邮件列表
LLVM 的官方文档质量普遍不错,尤其是llvm/docs/下的HowToWriteAPass.md、LangRef.md、CodingStandards.md等,很多资料虽然年代久远但核心概念没变。社区邮件列表(llvm-dev)archive 里几乎可以搜到任何你遇到的问题。Stack Overflow 上关于 LLVM 的老问题也很多,但现在很多答案已经过时。我的经验是:如果你的问题和“新版 Pass Manager”有关,直接看源码,别依赖网上教程。
6.3 修改IR之前,先学会阅读IR
写 Pass 的前提是你能熟练读懂 IR。建议你用clang -O0 -emit-llvm -S生成一个简单 C 文件对应的.ll,逐行理解每条指令的含义。再试试opt -passes=instcombine,simplifycfg之后的 IR,看看优化前后的差异。这个过程比看十篇文章都有效。
我自己培养新人的时候,会让他们做一个练习:把下面的循环手动从 C 翻译成 LLVM IR,再用opt验证:
int sum(int n) { int s = 0; for (int i = 0; i < n; ++i) s += i; return s; }这个练习看上去简单,实际做完之后,你对 basic block、phi 节点、分支跳转的理解会完全不同。
6.4 版本的坑:API变化比你想象的快
LLVM 的 API 变动非常频繁,特别是 Pass 相关的接口。网上很多旧文章还在用 legacy Pass Manager 的接口写 Pass,但新代码已经全面使用 New Pass Manager。如果你照着旧文章敲代码,很可能编译都过不去。建议你在git log里查看相关函数的演进,或者直接用最新 release 分支的文档。遇到接口变化,去llvm/include里搜头文件中的对应声明,往往比搜索引擎更快、更准。
6.5 贡献代码的心态准备
最后聊一点心态。llvm-project 的开发节奏非常快,维护者的时间和精力都很有限,所以他们的回复通常很直接,并不会像教程作者那样考虑你的感受。这其实是好事:只要能写上几句话,你就已经从“读代码的人”变成“参与项目的人”了。不要害怕被拒绝,每个经验丰富的贡献者都是从一版版被否定的 patch 中走过来的。
送你一个小技巧:在提交 patch 前,自己先跑一遍完整的相关测试(至少是check-llvm),把能自己发现的问题都解决掉,这样 review 的效率会高很多,reviewer 对你的印象也会好很多。
6.6 如何高效阅读 llvm-project 源码
读 LLVM 源码和读普通 web 项目不同。普通项目可能顺着函数调用链就能理解,LLVM 则到处都是抽象接口和设计模式。我的建议是“从入口反推”:选一个具体工具,比如opt,看它的main函数,然后沿着命令行解析、Pass 执行、IR 输出的路径走一遍。这个过程中你会接触到llvm/Support/CommandLine.h的参数系统、PassBuilder的流水线构造、raw_ostream的输出机制等,收获会比零散地看文件大得多。
另外一个实用工具是clangd,配合 VS Code 或 Neovim 能提供很好的跳转和补全体验。由于 LLVM 代码量大,纯文本搜索效率太低,必须靠符号索引。构建时生成 compile_commands.json,然后配置 clangd 读取,基本能做到“点到哪里跳到哪”。准备好这些工具,你阅读和修改 llvm-project 的效率会提高不少。
结束前再分享我的几个习惯
说实话,最开始接触 llvm-project 的半年,我无数次动过放弃的念头。后来调整了学习方式,才慢慢走上正轨。如果你也在这条路上摸爬,给你几个最实在的小建议:
- 把 llvm-project 仓库放在固态硬盘上,构建目录也放固态,否则编译慢到你怀疑人生。
- 构建时一定要开
ccache,哪怕你暂时用不上,等依赖改动频繁时你就知道它能救命。 - 手边准备一份 release tag 的文档和一份 main 分支的源码,查旧接口时用 tag,学新特性时看 main。
- 不要一次性追逐太多目标。专注一个小功能,从设计、实现到测试完整走通,比同时打开十个半途而废的方向都有效。
llvm-project 就像一片巨大的森林,每一条小径后面都藏着新的风景。我现在依然觉得它复杂,但至少不再害怕。希望这篇文章也能帮你把恐惧变成好奇,在编译器的世界里找到属于自己的乐趣。