llvm-project 完全指南:从源码构建到自定义 Pass 在自己动手把 llvm-project 从源码完整编译跑通之前我对它的认知一直停留在“某个编译器项目”的模糊印象里。直到我真正进入这个 Monorepo 仓库看到里面躺着 Clang、LLD、libc、compiler-rt 这一整套工具链源码时才意识到事情没那么简单 —— 这哪里是一个项目分明是整个编程语言基础设施的巨无霸集合。今天想认真聊聊这个仓库包括它的结构、核心组件、怎么把它从源码构建出来以及我自己在实操中踩过的那些坑。无论你是刚接触编译原理的学生、想为某门语言写一个前端或后端的开发者还是纯粹好奇“编译器到底是怎么工作的”的普通程序员这篇内容应该都能给你一个相对完整的切入点。LLVM 不是那种“看完一篇教程就会用了”的玩具但正因为它复杂搞懂它的骨架和关键路径之后你再去看 Rust、Swift、Kotlin/Native 这些它的下游项目时会突然觉得整个世界都通透了。1. 内容整体设计与思路拆解1.1 llvm-project 是什么形态的项目先下一个结论llvm-project 是 LLVM 生态所有核心子项目的统一 Monorepo 源码仓库。Monorepo 这个概念近年来在前端和基础架构圈很流行就是所有代码放在同一个仓库里管理而不是拆成多个小仓库。LLVM 从很多年前就开始这么干了只是当时不叫这个名字而已。这个仓库里到底有什么按源码目录划分大致包括llvm/: 整个 LLVM 的核心基础设施包括中间表示也就是常说的 LLVM IR、优化 Pass、目标后端X86、ARM、RISC-V 等、MC 层机器码处理、JIT 相关的底层库等。这是整个生态的心脏。clang/: C/C/Objective-C 编译器前端也是大多数人接触 LLVM 的入口。lld/: 一个高性能链接器官方文档上写的目标是比系统默认链接器更快、占用内存更少。实际用下来确实快。libc/ 和 libcabi/: 基于 LLVM 许可证的 C 标准库实现和对应的 ABI 层。compiler-rt/: 提供各种运行时支持库包括 AddressSanitizer、UndefinedBehaviorSanitizer 等也就是大家常说的 ASan、UBSan 对应的实现。libunwind/: 栈回溯和异常处理展开相关的库。mlir/: 这几年非常火的多级中间表示框架用于构建可复用、可扩展的编译器基础设施。虽然现在很多人单独提 MLIR但它的主仓库依然在 llvm-project 里。llvmpipe/: 说到这个我突然想到llvmpipe 其实不是 llvm-project 顶层目录里的东西它是 Mesa 3D 图形库中的一个软件渲染器通过 LLVM 的 JIT 能力把图形渲染的着色器编译成 CPU 指令实现纯软件渲染。但它的名字和 LLVM 深度绑定提到 LLVM 的应用场景时经常被拿来举例。LLVM 的位宽支持能到 256 位甚至更宽的 SIMD 向量这让 llvmpipe 这类软件实现在处理大规模向量运算时的性能非常可观。整个仓库的源码量即便只按纯代码行数来算也是千万级别的。这意味着普通开发者基本不可能读完所有代码也没有必要。1.2 为什么用单一仓库管理这么多项目这是我在刚开始接触时第一个冒出来的疑问。按常理来说Clang 作为独立项目、LLD 作为独立项目分开维护不是更方便吗答案是它们的开发节奏和 API 依赖关系太紧密了。举个例子如果你要改 LLVM 核心的某个 IR 表示结构而 Clang 和很多其他子项目都依赖这个结构那么改动必须同步提交。如果分仓库维护你需要先在 llvm 仓库提交一个版本再去 clang 仓库适配新接口提交另一个版本跨仓库的 CI持续集成联动也很麻烦。但是在 Monorepo 里同一个 commit 可以同时修改 llvm 和 clang构建系统也能保证各组件之间的版本是严格匹配的。还有一个现实原因LLVM 的社区贡献模式鼓励“原子提交”。每个提交应该是一个完整的功能或修复不能拆成跨仓库的几个部分。单一仓库天然满足这个要求。1.3 llvmpipe 与底层位宽支持的关系热词里出现的“llvmpipe (llvm 15.0.7, 256 bits” 其实是一个图形栈日志里非常常见的片段。它的含义是当前 Mesa 的软件渲染器使用的是 LLVM 15.0.7 版本且启用了 256 位向量支持。为什么这一步很重要因为在现代 CPU 上一次能处理的数据宽度已经扩展到 256 位对应 AVX2 指令集如果编译器不能生成 256 位运算的代码软件渲染会损失大量性能。从这个细节你就能看到LLVM 的优化和后端质量会直接影响下游所有依赖 JIT 编译能力的项目。这不仅仅是“写个编译器”那么简单它是一整套代码生成与向量化的底层引擎。2. 核心细节解析与实操要点2.1 构建系统选型CMake Ninja 为什么是标配llvm-project 的构建系统基于 CMake几乎没有人用别的构建系统来编译它。CMake 负责生成构建规则底层构建工具可以选择 Unix Makefiles、Ninja 等。官方文档以及所有人的实战经验都推荐 Ninja原因很简单Ninja 在增量编译时的速度优势太明显了刚好匹配 LLVM 这种大型 C 项目的构建需求。我实际测试下来的体会是Ninja 在重新编译的并行调度上非常聪明能够更精细地判断哪些目标依赖已完成、哪些任务可以立即启动而 make 在这方面确实显得笨重。所以第一课就是不要再用 make 编译 LLVM 了除非你想体验一下等半天是什么感觉。还有一个隐藏的问题在 Windows 上编译 llvm-project 建议使用官方支持的 Visual Studio 生成器或 Ninja clang-cl 的组合在 Linux 平台上Ninja GCC 或 Ninja Clang 都很顺。在 macOS 上Apple 的系统工具链也可以直接编但如果你要做 ARM 后端的开发建议先确认宿主机的架构。2.2 核心 CMake 配置项解读一段常用的 CMake 配置命令大概长这样cmake -S llvm-project/llvm -B build \ -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang我来逐个拆解我为什么这么配-G Ninja: 如上所述用 Ninja 作为底层构建工具。-DCMAKE_BUILD_TYPERelease: 编译 LLVM 自身时选择 Release。这里有个常见的误解Release 模式代表 LLVM 编译器自身运行时更快但如果你是想调试 LLVM 自身的代码那就必须用 Debug 或者 RelWithDebInfo不然你在断点处看到的变量全是优化后的混乱状态。-DLLVM_ENABLE_PROJECTS“clang;lld”: 指定这次构建除了核心 LLVM 之外还要启用哪些子项目。注意这里用的是分号分隔并且在 shell 里要给整个参数加上引号。-DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV: 指定需要生成哪些后端。如果默认不加这个参数CMake 会构建所有支持的目标架构耗时非常恐怖。实际只需要构建自己用的和研究的架构即可。-DLLVM_ENABLE_ASSERTIONSON: 这个参数在开发和调试阶段非常有用。开启后LLVM 内部的许多不可变条件会通过 assert 进行校验出问题时能第一时间捕获而不是等到程序崩溃才不知所措。这些配置项看起来零散但你研究源码时每一个都会影响你的编译调试效率和运行效果。花十分钟搞清楚选项的含义后面能省下一整天。2.3 从 GitHub 拉取源码的注意事项llvm-project 的官方仓库在 GitHub 的 llvm/llvm-project。直接 clone 的话因为仓库体积很大网络不稳定时很容易中断。我的建议是git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git其中--depth 1表示只获取最新一条 commit 的快照不下载完整历史这样能大幅减少数据量。--branch指定具体的 release tag一般建议不要用 main 分支因为 main 分支处于持续开发状态可能在使用时遇到 API 变动。选择稳定的 release 版例如 llvmorg-17.0.6 或 llvmorg-18.1.8遇到问题时也更容易搜索到答案。不过只拉一个浅克隆也有缺点如果你想研究某个功能从哪个 commit 开始引入的就需要补充历史。这时可以git fetch --unshallow把全量历史拉回来。这算是折中方案先用浅克隆快速上手需要考古时再补历史。3. 实操过程与核心环节实现3.1 环境准备与依赖安装在 Ubuntu/Debian 系统上编译 llvm-project 前需要准备的基础依赖包括sudo apt update sudo apt install build-essential cmake ninja-build python3 zlib1g-dev libtinfo-dev如果你打算用 Clang 编译 Clang 自身也就是自举那还需要先安装一个可用的 Clang。这里不展开“先有鸡还是先有蛋”的问题了最简单的路径就是用 GCC 先把第一版编出来然后第二遍用 Clang 编译。这里值得多说一句为什么“用 Clang 编译 Clang”在业界是很多 CI 的标准做法因为这样可以保证新的 LLVM 版本确实能正确编译自己的代码也能够在自举过程中提前发现编译器自身的回归问题。自举是一个很好的质量验证手段。3.2 配置与编译现场实录假设代码已经放到~/work/llvm-project并建立了构建目录build-release完整的编译步骤如下cd ~/work # 生成构建系统 cmake -S llvm-project/llvm -B build-release \ -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON执行完这一步CMake 会进行各种探测包括编译器特性、库函数支持等。如果终端没有报错就可以开始构建cmake --build build-release --target clang lld这里我特意指定了构建目标clang和lld。如果你直接执行cmake --build build-release不带目标Ninja 会试图构建所有目标包括一大堆你根本用不到的工具和测试程序整个构建时间成倍增长。不要问我怎么知道的说多了都是泪。在 8 核 16 线程的机器上第一次全量编译 X86 AArch64 后端的 Clang 和 LLD大约需要 20 到 40 分钟。如果你的机器只有四核建议做好心理准备可以去喝杯咖啡再回来。编译完成后验证一下编译出来的 Clang./build-release/bin/clang --version正常情况下你会看到类似如下的输出clang version 18.1.8 Target: x86_64-unknown-linux-gnu Thread model: posix看到这个输出的那一刻整个项目才算真正跑通了。这个clang可执行文件是你从源码一步步构建出来的意义完全不一样。3.3 用编译好的 Clang 生成和解读 LLVM IR有了自己的 Clang下一步就是体验编译器的核心工作流程。以往我们只关心编译产物是“可执行文件”或“目标文件”现在我们把编译器的中间层打开来看。写一个最简单的 C 程序#include stdio.h int add(int a, int b) { return a b; } int main() { int x add(1, 2); printf(%d\n, x); return 0; }然后使用 Clang 的-S -emit-llvm参数生成文本形式的 LLVM IR./build-release/bin/clang -S -emit-llvm test.c -o test.ll打开test.ll你会看到一堆以和%开头的符号这就是 LLVM IR。这是很多初学者第一次接触 IR 时会感到头晕的地方。让我来解释几个关键点IR 是静态单赋值SSA形式的每个变量只能被赋值一次。这是做数据流分析的基础。指令非常接近底层但又不完全等同于具体架构的汇编。比如add i32 %a, %b表示两个 32 位整数相加。函数名前面带表示全局符号局部变量名前面带%。读 IR 的时候我建议你把它理解成“一种有类型的汇编语言”。这里的类型系统很明确i32是 32 位整数i8*是 8 位整数指针。正是这种清晰的结构让后续一系列优化和代码生成成为可能。3.4 用 opt 做优化并观察 IR 变化生成 IR 之后下一步是优化。LLVM 的优化工作由专门的opt工具执行。你可以指定运行哪些优化 Pass也可以使用预设的优化等级。例如不加任何优化参数时add函数会被老老实实地翻译成 IR 指令。但使用-O2优化后函数会发生明显变化./build-release/bin/opt -S -O2 test.ll -o test.opt.ll对比优化前后的 IR你会发现优化后可能直接产生常量传播add(1, 2)被优化成3甚至整个函数的调用都被内联替换。这种观察优化前后差异的方式是我个人认为理解编译器优化最直观的途径。命令中-S表示输出 IR 文本形式-o指定输出文件名。如果不加-Sopt默认输出的是二进制 bitcode 文件虽然也能用但不方便人读。3.5 用 llc 生成目标汇编优化后的 IR 最终会被后端翻译成目标机器汇编这个环节的工具是llc./build-release/bin/llc test.opt.ll -o test.s打开test.s后你会看到真实的 x86 汇编指令。对照 IR 和最终汇编的差异你就能理解后端做指令选择、寄存器分配、指令调度的工作成果。对于我来说这个“从 C 到 IR再从 IR 到汇编”的完整链路就是编译器世界最核心的一条主线。搞懂这一条主线你对 llvm-project 的把握就已经超过大多数只把编译器当成黑盒的开发者了。4. 常见问题与排查技巧实录4.1 编译过程中内存耗尽这是最典型的问题。因为 LLVM 自身的代码量极其庞大链接阶段需要占用大量内存。我在 8GB 内存的机器上试过编译链接clang时直接 OOM内存耗尽。解决思路有几种减少并行任务数: 使用ninja -j 4甚至-j 2限制并发度。构建速度变慢但内存压力大幅下降。使用 LLD 作为自身链接器: 在 CMake 配置中加入-DLLVM_USE_LINKERlld。LLD 在链接大型 C 程序时内存占用通常比 GNU ld 低得多。这也是一种“自己编译自己、自己链接自己”的循环体验。增加交换空间: 如果内存实在太小可以临时增加 swap。但不建议长期依赖因为 swap 会让构建慢到怀疑人生。4.2 链接阶段崩溃ld 报错找不到库在比较早期的版本或者非标准系统环境上链接时可能出现找不到一些系统库的情况。通常是因为系统缺少zlib或tinfo的开发包。按 3.1 节提到的命令安装基础依赖即可解决。Linux 发行版之间的差异还挺大的Arch Linux 和 Ubuntu 的包名不一样如果遇到报错优先搜索对应发行版的包名而不是强行编译一个旧库。4.3 Debug 和 Release 选错带来的灾难我刚开始二次开发时图方便用CMAKE_BUILD_TYPERelease编译然后想调试代码结果断点经常停在不该停的位置变量值也是优化后的结果完全无法定位问题。后来才明白需要专门建立一个build-debug目录使用-DCMAKE_BUILD_TYPEDebug或者至少RelWithDebInfo。所以我的建议是构建目录一定要分开。一个build-release用于日常使用和性能测试一个build-debug用于开发调试。两个目录互不干扰改源码后各自增量编译。4.4 增量编译不生效的诡异现象当你修改了 llvm 某个头文件重新构建却感觉“没反应”。这种情况往往是构建系统没有正确跟踪依赖导致的。最直接的解决办法是删掉对应子目录下的构建缓存重新构建。如果你的 CMake 配置没有变化删除build-release/CMakeCache.txt是不必要的真正需要删的是那些陈旧的.o文件和.d依赖文件。保守的做法是直接重建整个目录虽然费时间但干净。4.5 LLVM 版本与 llvmpipe 的配合问题在图形栈相关的开发工作中你可能会遇到外部项目还在用旧版 LLVM而你已经把系统默认 LLVM 升级到了新版。llvmpipe 就属于这类容易受影响的项目。比如某次我升级系统 LLVM 后发现 llvmpipe 软件渲染的日志变成了(llvm 15.0.7, 256 bits)但应用层还是按旧接口去调用结果发生了版本不匹配的问题。这类问题的排查思路通常是确认当前 Mesa 版本官方支持哪些 LLVM 版本再设置MESA_LLVM_VERSION_OVERRIDE之类的环境变量来强制指定版本。如果还不行就只能检查系统里是否存在多个 LLVM 版本互相干扰。4.6 常见问题速查表问题现象可能原因解决手段链接时内存不足并发任务太多降低-j并发数使用 LLD找不到zlib.h缺少开发包apt install zlib1g-dev调试时变量被优化使用了 Release 构建使用 Debug 或 RelWithDebInfo修改源码后增量构建无变化构建系统依赖跟踪失败清理对应构建目录重新构建外部项目调用 LLVM 报版本错误系统有多个 LLVM 版本设置环境变量强制版本或统一链接路径5. 进阶玩法写一个最简单的自定义 Pass5.1 Pass 是什么为什么它值得学在 LLVM 的架构里优化和变换都是以 Pass 的形式存在的。比如上一节我们用的opt -O2本质上就是按顺序运行了一长串 Pass。如果你想给编译器增加一种新的优化或者做某种自定义的程序分析最直接的方式就是写一个 Pass。这也是很多人接触 llvm-project 源码后想尝试的第一个动手实验。我以一个示例来说明写一个 Function Pass遍历每个函数体打印函数中基本块的数量。需要说明的是不同的 LLVM 版本里 Pass 的写法存在差异。下面我以 LLVM 17 左右的写法为例代码大致如下#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 PassInfoMixinCountBasicBlocksPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned Count 0; for (auto BB : F) { (void)BB; Count; } errs() Function F.getName() has Count basic blocks.\n; return PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getCountBasicBlocksPluginInfo() { return {LLVM_PLUGIN_API_VERSION, CountBasicBlocks, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name count-basic-blocks) { FPM.addPass(CountBasicBlocksPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getCountBasicBlocksPluginInfo(); }这段代码做的事情非常简单统计每个函数里有多少个基本块。写完之后通过-fPIC -shared编译成动态库再配合opt -load-pass-plugin运行./build-release/bin/opt -load-pass-plugin ./CountBasicBlocks.so \ -passescount-basic-blocks -disable-output test.ll如果你能看到每个函数的基本块数量被打印出来那么恭喜你你已经在“自定义编译优化”这条路上迈出了第一步。我个人觉得写 Pass 是理解 LLVM 内部机制最好的方式因为你会被迫去理解 IR 的结构、Pass Manager 的运行逻辑、各个分析结果的依赖性等等。就算你以后不做编译器开发这个经验也能极大提升你对“程序如何被分析和变换”这件事的直觉。5.2 源码阅读路径建议llvm-project 太大从哪读起是个问题。我的建议是如果你感兴趣的是前端从clang/lib/Driver和clang/lib/Sema入手看它如何把 C/C 源码解析成语义分析后的 AST。如果你感兴趣的是中端优化从llvm/lib/Transforms/IPO或llvm/lib/Transforms/Scalar里挑一个相对简单的 Pass 开始。如果你感兴趣的是后端代码生成看看llvm/lib/Target/X86下的文件命名先了解X86ISelLowering.cpp和X86InstrInfo.td。读代码不是说要把每个函数的细节都搞懂而是先从整体结构建立心智模型。LLVM 官方文档中的 “Programmers Manual” 和 “CodeGenerator” 是两篇非常推荐先读的文章即便它们看起来很长。5.3 参与社区贡献的路径说实话普通人想给 llvm-project 提交代码门槛确实不低。代码规范、审查流程、提交信息格式都很严格。但反过来说也正因为严格这个项目的代码质量和可维护性才能达到今天这个水平。如果你是第一次提交我建议不要一上来就试图“发明”一个新优化而是先从修 bug、改进注释、补充测试用例开始。LLVM 的测试体系非常完善每个改动都要伴随相关测试。从这些“简单但实用”的贡献入手能让你在熟悉社区流程的同时逐渐积累对这个庞大系统的理解。最后一个想单独聊几句的话题写到这里我突然想给那些准备入坑的朋友一个额外的提醒不要被“庞大”和“复杂”两个词劝退。llvm-project 是我见过最典型的、通过刻意设计保持“复杂度可控”的大型项目。它把编译器的每个阶段拆得清清楚楚每一层只和相邻层打交道。你不需要立刻掌握全部只沿着一条主线——比如上面提到的 C 到 IR 再到汇编——往里走就会发现它其实是一层一层的完全可以逐步消化。我自己走过的路径是先学会构建和使用 Clang然后开始读 IR 的输出再用 opt 和 llc 观察优化和代码生成最后尝试写一个简单的 Pass。每走一步对编译器的敬畏和兴奋都会增加一点。这个项目不会让你失望但前提是你得真的动手去编译、去运行、去折腾。别停留在“看教程”的阶段亲手把那个几千万行的仓库跑起来一切才刚刚开始。