从第一次在 GitHub 上看到llvm-project这个仓库,到真正把它拉下来跑通一次完整构建,我花了不少时间踩坑。这个项目说白了就是一套编译器基础设施,但它的影响力早就超出了“编译器”三个字。现在主流语言里,C/C++、Rust、Swift、Julia 的后端都基于它,GPU 生态里的 CUDA、ROCm 也绕不开它,甚至很多人每天在用的 Android 和 iOS 工具链,底层都有它的影子。如果你对编译原理、语言实现、性能优化感兴趣,或者就是单纯想搞明白“一段高级语言代码到底是怎么变成机器指令的”,那llvm-project是最值得啃的一个开源项目。
这篇文章我会从一个实际参与过构建、改过 Pass、写过前端接入层的开发者视角,把llvm-project的核心结构、工程思路、构建实操和排坑经验全部摊开讲。不会只停留在“LLVM 很牛”这种层面,而是会告诉你它内部到底怎么组织、哪些概念是必须理解的、实际动手时会遇到哪些问题,以及怎么从零开始参与这个项目。
1. 项目全貌:llvm-project 到底是一套什么体系
1.1 从一次“编译太慢”的现场说起
先讲个真实的场景。几年前我在做一门解释型语言的性能优化,改了语法和运行时之后,需要把生成的中间表示交给本地代码后端来提速。当时团队里有人提议直接生成 C 代码,再丢给 gcc 编译,结果遇到两个非常头疼的问题:一是生成 C 代码这个过程本身有信息损耗,很多高层优化机会直接没了;二是每轮改动都要经历“生成 C -> 调 gcc -> 看汇编”,调试循环长得让人崩溃。
后来换成基于llvm-project来做,思路就完全不一样了。我不需要把中间表示降级成 C,而是直接把自研语言的 AST 翻译成 LLVM IR,然后调用 LLVM 的优化管线,最后由它的后端生成目标平台的机器码。整个过程里,我可以随时把 IR dump 出来看,也可以在任意一个优化 Pass 之后停下来检查中间结果。这种“可插拔、可观察”的编译架构,正是llvm-project最核心的价值。
1.2 仓库里都有什么
llvm-project是一个超大 monorepo,里面其实包含了好几个相对独立的子项目。最开始很多人以为它就是一个叫 LLVM 的库,实际拉下来会发现目录比想象中多得多。按我的习惯,通常会先关注这几个核心目录:
llvm/:核心基础设施,包括 IR 定义、优化 Pass、目标描述、代码生成、汇编器、链接器等。clang/:C/C++/Objective-C 前端,负责把源码解析成 AST,再转成 LLVM IR。lld/:高性能链接器,现在很多 Linux 发行版和 macOS 工具链默认用它。libcxx/、libcxxabi/、libunwind/:C++ 标准库和运行时相关实现。compiler-rt/:运行时支持库,比如 Sanitizer 系列的 AddressSanitizer、UndefinedBehaviorSanitizer。mlir/:面向机器学习和其他专用领域的编译器基础框架。flang/:Fortran 前端;polly/:基于多面体模型的循环优化;clang-tools-extra/:额外工具,比如 clang-tidy、clangd。
2. 核心设计:为什么它敢叫“编译器基础设施”
2.1 三段式编译模型
llvm-project的设计核心可以概括成一句话:把编译过程拆成前端、中端、后端三个阶段,三个部分之间用统一的中间表示(IR)来通信。
传统编译器(比如早期版本的 gcc)经常是“前端和后端强耦合”的,每一种语言都由一套独立的前端驱动整个编译流程,导致前端逻辑没法复用,也很难支持新的目标平台。LLVM 的做法刚好反过来:前端只负责把高级语言翻译成 LLVM IR,中端只负责优化 IR,后端只负责把优化后的 IR 变成机器码。这样一来,新增一门语言只需要写前端,复用了所有的优化和后端能力;新增一个 CPU 架构只需要写后端,所有语言的前端都能立刻受益。
2.2 LLVM IR 到底是什么
LLVM IR 是一种静态单赋值形式(SSA)的中间表示,它看起来介于高级语言和汇编之间。它的一个优秀设计是同时具备可读文本形式(.ll)、二进制位码形式(.bc)和内存中的表示三类形态,而且这三者可以无损转换。这意味着我写 Pass 的时候,可以直接用 C++ API 操作内存里的 IR,也可以随时llvm-dis把 bitcode 变成人可读的文本。
一个简单的加法函数,IR 大概长这样:
define i32 @add(i32 %a, i32 %b) { %sum = add i32 %a, %b ret i32 %sum }不带 SSA 概念的人第一次看这堆%开头的东西会觉得有点乱,但一旦理解了“每个变量只能被赋值一次”,很多优化就变得非常自然。比如公共子表达式消除,在传统编译器里要费劲做数据流分析,在 SSA 形式下直接看值的唯一来源就行,Pass 的复杂度一下子降下来了。
2.3 Pass 架构和“插槽式”设计
LLVM 的优化管线是由一个个 Pass 组成的,而 Pass 本身可以是一个函数级别的小变换,也可以是整个模块级别的分析。设计上近乎“插槽式”:我想做循环展开,就注册一个 LoopUnroll Pass;我想做内联,就调 Inliner Pass;我想 debug 某个模块的 IR,加一个print<loops>或者print<domtree>就行。
这种架构给开发者带来的直接好处是:你可以通过opt -passes=...在命令行上自由组合优化步骤,而不需要重新编译整个编译器。比如我想看看 Dead Code Elimination 在某个 IR 上做了什么,可以这样:
opt -passes=dce -S input.ll -o output.ll输出的output.ll就是跑完 DCE 之后的样子,换一个 Pass 组合跑一遍,对比 diff,基本就能定位很多性能或者正确性问题。这也是我在实际开发里用得最多的工作模式之一。
3. 构建实操:从源码拉取到跑起第一版 Clang
3.1 拉代码和准备工作
llvm-project用 git 管理,仓库非常大,直接git clone可能会等很久。除非你需要完整历史,否则建议用--depth做浅克隆,或者直接下载 release 版源码包。我个人更推荐浅克隆加切换 tag 的组合。
git clone --depth=1 -b llvmorg-17.0.6 https://github.com/llvm/llvm-project.git构建机器建议配置高一点。我自己的经验是:16G 内存起步,32G 会更舒服;磁盘预留 80G 以上,因为编译中间文件非常多。操作系统上,Linux 是最顺的,macOS 也可以,Windows 的话著作者喜欢用 Visual Studio 的生成器,但坑明显更多,新手建议先用 Linux 环境。
3.2 CMake 配置的关键参数
因为我需要的是 Clang、LLD 和一些常用工具,所以通常这样配置:
cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_PROJECTS="clang;lld;clang-tools-extra" \ -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \ -DLLVM_ENABLE_ASSERTIONS=ON这里面有几个参数值得展开讲:
-DLLVM_ENABLE_PROJECTS是决定你构建哪些子项目的开关,不用全开,开太多编译时间成倍上涨。-DLLVM_TARGETS_TO_BUILD指定目标后端。默认会生成所有支持的后端(X86、ARM、AArch64、RISC-V、PowerPC……),但这会让编译时间大幅变长。只留X86和AArch64足够覆盖绝大多数桌面和移动场景。-DLLVM_ENABLE_ASSERTIONS=ON对于开发 LLVM 本身几乎是必须的。很多 IR 合法性检查只在断言开启时生效,不开的话出了内存错误都不知道在哪。如果只是为了发布用工具链,可以考虑 OFF 以提升运行时性能。
3.3 开始编译
配置完成之后,直接 Ninja 拉起来:
ninja -C build如果机器核数多,可以加-j参数,比如 16 核机器用ninja -C build -j16。首次全量构建大概耗时从十几分钟到几个小时不等,取决于机器性能和开了多少项目。我第一次在公司服务器上全量构建带clang;lld;clang-tools-extra大概花了不到 30 分钟,笔记本上则跑了两小时。这种初构建速度其实是可以接受的,因为增量构建通常非常快。
构建完之后,验证一下:
./build/bin/clang --version能看到 clang 版本号和目标平台信息,就说明这套工具链已经可以用了。顺手写一个 hello world 编译试试:
printf 'int main() { return 0; }\n' | ./build/bin/clang -x c - -o /tmp/hello && /tmp/hello4. 看懂源码和写第一个 Pass
4.1 目录导航技巧
拿到llvm-project之后,想要读懂它,得先学会在巨大的代码库里找路。我的建议是:不要从头到尾硬读,而是按“看一个功能 -> 定位到对应 Pass/目录 -> 读相关代码”的方式去学。
拿llvm/lib/Transforms/目录来说,它按功能分了很多子目录,比如InstCombine、Scalar、Vectorize、IPO等。想看某一个 Pass,比如LoopUnrollPass,就直接在Transforms/下面搜名字,通常能在Scalar/或Utils/里找到对应文件。
Clang 的代码结构也类似。前端主要分clang/lib/AST、clang/lib/Sema、clang/lib/CodeGen等几个大块。其中CodeGen负责把 AST 降级成 IR。如果想搞清楚int类型在某个目标上占多少字节,去看clang/lib/AST/ASTContext.cpp里的getTypeInfo,就是很典型的路径。
4.2 中间表示的打印与阅读
很多时候问题不是“写不出 Pass”,而是“看不清 IR”。LLVM 内置了很多打印工具,最常用的就是opt -passes=... -S和llvm-dis。此外,在 pass 里直接加errs() << "IR: " << *F << "\n";这种调试语句也是常规操作——LLVM 内部定义好了operator<<,可以方便地打印Function、BasicBlock、Instruction等对象。
看 IR 的时候,我建议大家养成一个习惯:先用opt -passes=print<loops>这种方式看循环结构,再用print<domtree>看支配关系。这些分析信息在做优化时特别有用。
4.3 写一个简单的 FunctionPass
写一个自定义 Pass 是理解 LLVM 工程哲学最快捷的方式。这里举一个最简单的例子,注册一个 Function Pass,打印每个函数的名字和基本块数量。
#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 { struct CountBasicBlocksPass : public PassInfoMixin<CountBasicBlocksPass> { PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) { errs() << "Function: " << F.getName() << ", BasicBlocks: " << F.size() << "\n"; return PreservedAnalyses::all(); } }; } // namespace extern "C" ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, "CountBasicBlocksPass", "0.1", [](PassBuilder &PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager &FPM, ArrayRef<PassBuilder::PipelineElement>) { if (Name == "count-bb") { FPM.addPass(CountBasicBlocksPass()); return true; } return false; }); }}; }把这段代码编译成动态库,然后通过opt -load-pass-plugin加载,就能在一个自定义 pipeline 中调用:
clang -shared -fPIC -I<path-to-llvm>/llvm/include countbb.cpp -o countbb.so opt -load-pass-plugin=countbb.so -passes="count-bb" -S input.ll -o /dev/null这里有个重点:新版 LLVM 推荐用 New Pass Manager,也就是PassInfoMixin+PassPlugin这套接口。网上不少旧教程还在教legacy::FunctionPass,能跑但已经逐渐边缘化了。写新代码的时候尽量直接做 New Pass Manager 适配。
5. 生态延展:llvm-project 已经不只是在编译 C++
5.1 语言前端的百花齐放
最直接的使用方式是给新语言做前端。Rust 官方编译器rustc的后端最初就使用的 LLVM;Swift 编译器基于 LLVM 深度改造;Julia 的 JIT 也构建在 LLVM 之上。这意味着如果你懂 LLVM IR 的生成规范,你就可以给一门新语言快速获得全球大部分 CPU 平台的支持,包括 x86、ARM、RISC-V 等。
个人感受是,LLVM 把“做一门语言”的门槛从“重新发明整个工具链”降到了“只写前端 + 对接 IR”。这中间还有学校、研究机构在做的各种方言和实验语言,几乎都是基于 LLVM 在迭代。
5.2 MLIR 与 GPU 编译器
近年来mlir成了非常火的方向。它从 LLVM IR 的土壤里长出一层更接近“领域专用抽象”的中间表示,很多深度学习编译器(比如 TensorFlow 相关的部分工具链、Intel 的 oneAPI)都开始基于 MLIR 构建。MLIR 允许定义自己的 dialect,比如先在高阶的 tensor 级别做图优化,再逐步 lower 到循环、再到 LLVM IR。这种层次化降级的思想,在传统编译器里很难实现得这么优雅。
GPU 方面,英伟达的 CUDA 工具链底层大量使用了 LLVM 技术栈,AMD 的 ROCm 编译器也基于 LLVM。写 GPU kernel 的人可能感觉不到,但整个编译管线背后都在依赖 LLVM 的架构。
5.3 静态分析与质量保障工具
clang-tidy和clangd这类工具都基于 Clang 的 AST 和前端基础设施。用clang-tidy做代码风格检查和潜在 bug 扫描,是很多团队的日常操作。compiler-rt里的 AddressSanitizer、ThreadSanitizer 更是 C/C++ 项目定位内存问题的一把好手。只要链接了对应 sanitizer runtime,不用改太多代码就能揪出越界访问、释放后再使用等难缠问题。
我在实际项目中几乎把-fsanitize=address,undefined当成 CI 里的默认配置。它确实会增加运行时间,但对内存安全问题的定位效率提升是惊人的。这也是llvm-project这类基础设施项目“润物细无声”的地方——你或许没有直接用 LLVM 写代码,但你的日常工具链已经深陷其中。
6. 常见问题与排坑实录
6.1 构建阶段最容易踩的坑
构建llvm-project时的典型问题集中在 CMake 配置和资源不足上。比如你把-DLLVM_ENABLE_PROJECTS写成了空格分隔,而不是分号分隔;或者忘了安装zlib、libxml2等依赖,导致编译到某个模块时报找不到头文件。这里给一个检查建议:配置完后立刻看CMakeCache.txt里对应的LLVM_ENABLE_PROJECTS变量,确保是类似clang;lld;clang-tools-extra这样的字符串。
另一个常见坑是内存不足导致编译被杀。LLVM 后端在生成指令选择器相关表格时非常吃内存,链接clang主程序时也需要大内存。如果你的机器内存小于 8G,建议减少并行度,或者用lld链接器替代系统的ld,因为它更快也更省内存。
6.2 调试 LLVM 自身的一些经验
调试 LLVM 时,llvm::errs()是零成本上手的方式。但如果你需要更系统化的调试,可以考虑用-print-after-all看每一个 Pass 后的 IR 变化,用-debug-only=loop-unroll看特定 Pass 的调试信息,前提是LLVM_ENABLE_ASSERTIONS开启且编译了 debug 信息。为了定位某个优化在哪个 Pass 引入问题,我曾经大量使用opt逐步二分 pipeline 集合,配合-S输出 diff,效率很高。
需要注意的是,Release + 无断言模式下很多内部校验会被编译器优化掉,出问题的时候很难定位。所以我一直坚持在开发阶段开LLVM_ENABLE_ASSERTIONS=ON,发布时再关掉。
6.3 我的三条经验教训
第一,不要一开始就去看 TableGen 文件。很多人打开 LLVM 源码后看到.td文件一头雾水,然后放弃。其实 TableGen 是 LLVM 用来描述指令集、寄存器等等领域特定语言的工具,理解它需要先懂得目标后端那部分概念。入门阶段完全可以跳过,只看 Pass 和 IR 处理逻辑。
第二,遇到奇怪问题先升级到 release tag 试一次。LLVM main 分支演进极快,某个最新 commit 可能引入回归。我在 18 版本期间踩到过一次构建失败,切回llvmorg-18.1.8就完全正常。开发新功能时用 release tag,研究新特性时再换 main,是比较稳妥的策略。
第三,参与社区之前,先跑一遍完整的ninja check-llvm和ninja check-clang。本地测试全绿,再提交 PR,既是对维护者负责,也是对自己的时间负责。LLVM 的 code review 非常严格,新代码如果没有测试覆盖,基本上是被打回的。
7. 进一步方向:如何系统化学习与实践
想深入学习llvm-project,有几个路径可以参考。第一,做一个小工具链项目:选一门简单的小语言,写一个前端,把 AST 降级成 LLVM IR,再用lli解释执行或llc编译成可执行文件。这个过程会逼迫你把 IR、Pass、后端接口全部跑一遍。第二,研究已有的 Pass:比如尝试给LoopUnrollPass加一个启发式参数,观察对实际程序的性能影响。第三,为某个开源库写一个 Clang 插件或 clang-tidy 检查规则,这是比较贴近实际业务需求的入门方式。
我个人踩过的坑是:一开始太贪心,想一次把所有模块都看懂,结果几个月过去进展缓慢。后来我换成“用哪里学哪里,带着问题看源码”的策略,每周只聚焦一个子模块,反而很快把 LLVM 的代码架构摸清了。举个例子,为了搞懂opt是怎么加载 Pass 插件的,我先后读了llvm/tools/opt/opt.cpp和PassPlugin.cpp,顺带就理解了 New Pass Manager 的注册机制。这样每一次阅读都有明确的产出,不会迷失在代码海里。
如果想把 LLVM 知识用在工程上,建议从一门你已经很熟悉的语言开始,手动写一个翻译器或解释器,然后尝试对接 LLVM C API 或 C++ API。很多嵌入场景里的 JIT 编译器也是用 MCJIT 或 ORC 实现的,把 ORC 的示例代码跑通一次,你就能理解它和静态编译的差别在哪里。
最后分享一个真实感受:我在做 LLVM 相关开发那段时间,最大的收获其实不是“会用某个工具”,而是理解了现代编译器的设计哲学——模块化、可复用、可观察。这些思想放在任何大型软件系统里都是适用且值得借鉴的。llvm-project不只是编译技术的集大成者,更是一本可以反复翻阅的工程教科书。