llvm-project源码构建与Pass开发:从零理解LLVM编译器架构

发布时间:2026/9/18 10:35:08
llvm-project源码构建与Pass开发:从零理解LLVM编译器架构 我在2015年第一次接触llvm-project这个仓库时第一反应是“这不就是一堆C代码么”随后就在git clone之后被CMake配置界面和动辄几个小时的编译时间劝退了。直到后来真的靠它吃饭我才意识到这个仓库的价值远远不止“编译器”这三个字。它包含的不只是Clang还有LLVM核心库、lld链接器、compiler-rt运行时库、libc、libcabi以及一大批分析和测试工具。你日常用的Xcode、Android Studio、甚至PS5的开发工具链底层都或多或少的躺在llvm-project的肩膀上。这篇文章我想从一个实际开发者的角度把llvm-project从源码到可用的完整路径拆开讲一遍。内容覆盖仓库到底装了什么、怎么用最少的配置完成一次高质量构建、构建中真正值得记录的报错和排查思路、LLVM三阶段架构为什么能成为生态霸主以及你如何基于它写出第一个属于自己的pass。无论是想做编译器方向的技术储备、给团队搭工具链还是单纯想搞明白“这个star超多的仓库为什么这么猛”这篇文章都值得你花十分钟看完。1. llvm-project 到底是干什么的——先搞清楚你clone的是什么1.1 一个仓库八套工具链打开llvm-project的根目录你会看到一堆目录clang、clang-tools-extra、compiler-rt、libc、libclc、libcxx、libcxxabi、libunwind、lld、lldb、llvm、mlir、polly、flang以及越来越多的子项目。很多人第一次看到这个布局就懵了——我到底需要哪些哪些只是用来凑数的其实可以按职责把它们分成四类这样大脑就清爽了。第一类核心编译器框架。这个就是llvm目录里面的内容包括LLVM核心库、优化器、目标后端、汇编器、链接优化器等。它是整个仓库的地基其他所有项目都围绕它运转。第二类前端工具链。最典型的就是clang负责把C/C/Objective-C源码转换成LLVM IR。flang是Fortran前端mlir是多层IR框架clang-tools-extra里是clang-tidy、clangd、clang-format这类配套工具。第三类链接器和运行时库。lld是LLVM的链接器链接速度比系统默认的GNU ld快好几倍。compiler-rt是编译器运行时提供sanitizer、内存检查、性能分析这些底层支持。libcxx和libcxxabi是C标准库和ABI实现libunwind负责栈回溯。第四类调试和测试设施。lldb是LLVM的调试器跟llvm-project配套使用。目录下面还有一整个test体系覆盖了每个子项目的大量回归测试。所以当你clone下llvm-project时你实际上拿到的不是“一个编译器”而是一整套完整的编译工具链生态。这也是它区别于GCC的最核心差异GCC是一个“编译器集合”LLVM是一套“可随意拼装的编译器积木”。1.2 为什么它值得单独开一个仓库一个值得思考的问题是为什么LLVM要把这么多东西全塞进一个仓库存放而不是像其他项目那样搞一堆独立仓库技术上说这种做法有利有弊。好处是同步成本极低——LLVM的改动经常横跨前端、优化器、后端比如给一个IR指令加新属性很可能同时要改llvm库、clang前端、lld链接器、compiler-rt运行时如果分成多个仓库每一个改动都要跨仓库提交PR痛苦指数直线上升。坏处是仓库体积大、克隆慢单次git clone要下载大量历史。但LLVM官方明显认为同步收益远大于仓库体积的代价。从实际体验看我宁可clone一次大的也不想在跨仓库联调时反复处理子模块和版本锁文件的爆炸依赖关系。这个决策到今天依然成立这也是业界出现monorepo风潮的重要原因。2. 从零构建 llvm-project用最少的配置产出能用的工具链2.1 环境准备和磁盘规划在动手之前先看下你的机器配置。这些年我build llvm-project的机器从8核笔记本到64核服务器都用过实话说最低配置建议是4核CPU以上、16GB内存、SSD硬盘并且预留至少100GB可用磁盘空间。为什么磁盘要求这么高因为llvm-project一次全量构建会产生两类东西编译中间文件和二进制产物。Release模式下一个完整的Clang二进制加上LLVM核心库体积轻易突破20GB而构建过程中的.o文件和ninja缓存也占很多。如果你还要跑测试套件那需要的空间会更大。我见过有人用32GB内存的机器做Debug构建结果在链接阶段OOM直接挂掉的例子真实惨案。# 这是我在Ubuntu 22.04上测试过的基础依赖 sudo apt update sudo apt install -y build-essential cmake ninja-build python3 python3-pip sudo apt install -y libssl-dev libxml2-dev zlib1g-dev pip3 install --user lit filecheck这里特别提示一下CMake版本不能太旧。LLVM官方要求CMake 3.20以上ninja则要求1.10以上。如果你的系统自带版本太低建议直接去CMake官网装新版用pip装ninja也行。Python版本建议3.8以上因为LLVM的测试脚本已经全面切换到Python 3了。2.2 CMake参数解析——你要做的是“选择”不是“配置”很多新手在CMake这一步就直接放弃了因为参数看起来太多。我的经验是不要试图理解所有参数只关心几组核心参数就行。下面是我一组推荐配置的完整示例cmake -G Ninja -S llvm-project/llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra;compiler-rt \ -DLLVM_TARGETS_TO_BUILDX86;ARM;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang \ -DLLVM_CCACHE_BUILDON这里每个参数背后的逻辑值得展开说说。-G NinjaNinja比Make快很多而且默认并行度更高。你不需要手动-j指定核数用ninja -j$(nproc)直接拉满就行。-DCMAKE_BUILD_TYPERelease影响优化级别。Release模式编译出来的编译器速度快但编译过程本身比Debug慢一些。日常开发用Release就够了Debug模式主要用于自己调试LLVM代码本身。-DLLVM_ENABLE_PROJECTS决定你额外构建哪些前端和工具。这里我把Clang、lld链接器、clang-tools-extra的工具集和compiler-rt运行时库都选上了。如果你只想快速试验可以先只选clang和lld构建时间会缩短一大截。-DLLVM_TARGETS_TO_BUILD指定编译器生成的目标架构。默认是构建所有支持的目标那构建时间会翻好几倍。实际使用中我建议按你的业务需要来选。X86和AArch64基本覆盖了服务器和桌面端场景。-DLLVM_ENABLE_ASSERTIONSON开启源码中的断言。LLVM内部有大量逻辑检查开着能在开发阶段帮你尽早发现IR操作错误。代价是构建出来的二进制体积偏大、运行稍慢但对开发人员来说完全值得。-DCMAKE_C_COMPILERclang有意思的问题来了第一次构建LLVM的时候本机上还没装Clang怎么办答案是你必须用GCC或者其他已有的编译器来“孵化”它。我第一次构建时用的就是GCC 11。如果你本机已经有一个能跑的Clang那用Clang编译Clang的效果会更好因为LLVM官方对“自举构建”即用Clang编译Clang的支持是最好的生成的速度也更快。但第一回不管怎么样都要经历一次“冷启动”。2.3 构建和验证——耐心跑完认真看日志配置完CMake之后进入构建环节执行cd build ninja -j$(nproc)这个过程会非常漫长。我的经验数据是这样的16核32线程的机器全量构建上面那组配置大约需要60-90分钟8核16线程大约2小时左右。第一次构建时如果看到很多warnings不用担心LLVM代码里有些第三方依赖的警告是正常的只要不是error就行。构建完成后建议先用一个简单的程序验证工具链是否正常工作// hello.cpp #include iostream int main() { std::cout LLVM works! std::endl; return 0; }编译验证build/bin/clang -O2 hello.cpp -o hello ./hello看到输出后你的llvm-project才算真正“活”了。下一步我还会跑一遍基本的测试套件确认工具链完整可用cd build ninja check-llvm check-clang这个测试阶段也很耗时但值得跑。它验证了LLVM核心和Clang前端的基本功能正常也验证了你构建的版本没有明显残缺。3. 构建过程中我踩过的那些坑以及排查链路3.1 内存不足链接器在最后关头把你压垮这是llvm-project构建中最常见、也最让人崩溃的问题。现象是构建过程已经走了90%最后在链接bin/clang或者libLLVM.so的时候进程直接被系统杀掉控制台报出Killed字样或者偶发clang: error: unable to execute command: Killed。我第一次碰到时第一反应是“磁盘满了”清理半天磁盘重新构建依然如此。后来通过dmesg | tail -n 30看到内核日志里有大量Out of memory记录这才确认是OOM杀进程。原因在于链接clang或LLVM静态库时链接器会把所有.o文件的符号表加载进内存符号量巨大16GB内存经常见底。处理方案有三种按优先级排序在CMake中开启CMAKE_EXE_LINKER_FLAGS-fuse-ldlld -Wl,--threads8使用lld作为链接器。lld的内存占用远低于GNU ld速度也快好几倍。这是最有效的办法能直接把峰值内存降三分之一以上。降低并行度ninja -j4强制限制并发数减少同时编译的翻译单元数量。缺点是构建时间明显变长。如果业务允许不要ENABLE_PROJECTS包含compiler-rt。compiler-rt内部的sanitizer运行时链接过程非常吃内存把项目范围缩小一些能明显降低峰值占用。3.2 CMake阶段就报错的坑——版本检测的玄学另一个常见的坑是CMake配置时报Unsupported build configuration或者Python not found一类的错误且没有特别明确的指引。我遇到过最典型的一个系统装的是Python 3.7但LLVM某个版本要求Python 3.8以上CMake探测到的库路径不对导致后续所有脚本都失败。排查过程是这样走下来的先确认Python版本再去看CMakeCache.txt里记录的Python路径发现CMake缓存了系统里另一个旧Python路径。解决办法是清掉build目录下的缓存重新跑CMake或者显式指定-DPython3_EXECUTABLE$(which python3)这种问题本质上就是缓存污染和环境不一致最容易在“换过系统Python版本”“用conda又用apt”的机器上出现。所以做LLVM构建前建议用一个干净的虚拟环境或容器来隔离依赖能少踩很多坑。3.3 链接时找不到符号——库的顺序其实很重要构建过程中你可能会遇到类似undefined reference to llvm::createInstructionCombiningPass()这样的链接错误。新手往往会怀疑自己的代码有问题但这类错误很多时候出在链接库的排列顺序上。静态库链接器比如GNU ld在处理静态库时是单向扫描的一个目标文件引用的符号必须在其后的库中定义如果库A用到库B的符号但B排在A前面链接就会失败。LLVM官方文档里一直强调如果用llvm-config --libs生成链接参数它会自动帮你排好顺序。但如果你是自己手动拼的库列表就很容易踩坑。我遇到过的一个案例在Makefile里写链接参数时用了-lLLVMX86 -lLLVMCore -lLLVMSupport顺序完全反了结果报出一大串undefined reference。改成-lLLVMCore -lLLVMX86 -lLLVMSupport后问题消失。其实LLVM自己也提供了一条更省事的路径——直接链接单个库libLLVM.so共享库模式就没有静态库顺序的问题了。你可以在CMake里开启-DLLVM_BUILD_LLVM_DYLIBON这样所有符号都被打进一个共享库里链接时只需要-lLLVM一个参数。4. 理解 LLVM 三阶段架构为什么这个项目能统治编译器生态4.1 前端、优化器、后端一套“翻译官”式的流水线搞明白llvm-project整个项目怎么运转关键就是理解它的三阶段架构。想象一个场景你有一份中文技术文档需要分别翻译给三个不懂中文的工程师看一个只看英文、一个只看日文、一个只看韩文。你不会分别写三份源文档而是先把中文翻译成“世界语”这种统一中介语言再从中介语分别翻译成英文、日文、韩文。LLVM干的就是这件事。它所定义的那个“世界语”就是LLVM IRIntermediate Representation中间表示。前端Clang负责把C/C源码变成LLVM IR优化器Opt负责对IR进行各种等价变换让它跑得更快后端CodeGen负责把优化后的IR生成目标架构X86、ARM、RISC-V等的机器码。这样设计带来的好处是你只要写一个新前端让任意语言变成IR就能立刻享受所有优化和后端支持你只要写一个新后端就能让所有前端语言支持你的芯片。比如Rust选择通过rustc直接生成LLVM IRSwift也是走LLVMCrystal、Julia、Zig都是如此。而如果GCC要做同样的事整个社区都要跟着折腾。4.2 IR为什么能成为生态枢纽SSA形式和CanonicalizationLLVM IR最微妙的地方在于它被设计成了SSAStatic Single Assignment静态单赋值形式每个变量只能赋值一次。这初听起来很受限但实际上大大简化了优化器的分析和变换逻辑。比如说要判断两个变量是否相等SSA形式下你只需要检查它们是否是同一个“值”而不需要做数据流分析。拿opt工具演示一下IR的威力。假设有下面这段C代码int addmul(int a, int b, int c) { return (a b) * c; }用Clang生成IRbuild/bin/clang -O0 -S -emit-llvm addmul.c -o addmul.ll生成的IR长这样省略了部分细节define i32 addmul(i32 %a, i32 %b, i32 %c) { entry: %add add i32 %a, %b %mul mul i32 %add, %c ret i32 %mul }这里每条指令都是SSA形式%add和%mul每个只赋值一次。再跑一轮opt做优化build/bin/opt -O2 addmul.ll -S -o addmul_opt.ll优化后的IR可能是lea/imul之类指令的组合也可能直接把多条指令融合成一条乘加指令。这种透明、可控的优化过程是完全可读可调试的这也是LLVM让无数编译器研究员兴奋的原因——你看到的是人类可读的IR变换而不是黑盒的汇编输出。4.3 后端的复杂度转移到TableGen理解了IR之后另一个让人震撼的设计是TableGen。如果说IR是LLVM的“灵魂”那TableGen就是LLVM后端开发的“工作台”。后端要对每种目标架构做指令选择、寄存器分配、指令调度这些逻辑如果我全手写光是X86一个架构就能写几十万行重复代码。TableGen做的事情是用声明式语言描述目标架构的特性寄存器种类、指令格式、指令编码等然后由工具自动生成大量C代码。你只需要在.td文件里添加指令描述构建时就会自动生成对应的匹配器和编码器。我第一次接触后端开发时看着X86目录下那一堆.td文件惊了这些文件读起来像规格说明书提供的抽象能力远超手写代码。如果你有“给自家芯片做LLVM后端”的想法第一步一定是学TableGen而不是直接写C。5. 第一次踏进编译器源码写一个属于自己的pass5.1 最朴素的Hello Pass理解IR的“函数级插卡槽”很多人学LLVM的时候卡在“怎么能让我改动的代码真正跑起来”这个问题上。其实最简单的入门路径是写一个pass一段在优化器流水线中运行的代码它会遍历IR做你指定的检查和变换。LLVM有两种pass机制legacy PM和new PassManager。虽然官方主推new PM但legacy PM代码在文档和网上案例里仍然大量存在。我这里用new PassManager的风格来写一个最朴素的函数级pass功能很简单每遇到一个函数就打印它的函数名。创建HelloPass.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 { struct HelloPass : public PassInfoMixinHelloPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { errs() Hello from LLVM! Function: F.getName() \n; return PreservedAnalyses::all(); } }; } // namespace // 注册插件入口 extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, HelloPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-pass) { FPM.addPass(HelloPass()); return true; } return false; }); }}; }构建这个pass并不需要重新编译整个llvm-project可以用clang直接把它编译成动态共享库然后通过opt -load-pass-plugin加载build/bin/clang -shared -fPIC -fno-rtti HelloPass.cpp \ -I llvm-project/llvm/include -I build/include \ -o libHelloPass.so build/bin/clang -O0 -S -emit-llvm addmul.c -o addmul.ll build/bin/opt -load-pass-plugin./libHelloPass.so -passeshello-pass addmul.ll -S -o /dev/null如果一切顺利你会看到控制台打印出Hello from LLVM! Function: addmul那一刻的成就感是别的东西替代不了的——你不再是“使用编译器的人”而是“修改编译器的人”。5.2 从Pass到分析怎么在IR层面真正做出有意义的改动学会了打印函数名之后再往前进一步让你的pass真正修改IR。比如说写一个Pass把所有函数名变成小写或者把一个指令里的add改成sub。实际改IR比遍历要小心得多因为LLVM IR有严格的SSA约束和使用链信息乱改会直接触发断言。这引出一个重要理念写pass之前先问自己“我要做的是分析还是变换”。如果是分析就返回PreservedAnalyses::all()不做任何改变如果是变换你必须仔细更新所有依赖该指令的用户绝不能让IR处于非法状态。初学者最常犯的错是在run函数里直接插入指令却不更新DT、LoopInfo等分析结果结果在后续优化阶段崩得莫名其妙。我的建议是先拿小例子做试验每做一步变换就打印一次IR肉眼对照差异确认SSA约束没有被破坏。虽然现在有verify工具可以做自动化检查但刚开始用肉眼对比一遍你对IR的直觉会成长得非常快。6. 日常使用 llvm-project我常用的有效工作流和工具6.1 让clangd接管IDE索引代码跳转再也不是迷航llvm-project 的代码量非常庞大如果想用编辑器做精准跳转和引用查找传统的ctags或IDE自带的“全文搜索索引”基本扛不住。我目前用的是clangd配合VS Code或Neovim体验跟商业IDE差不多。关键在于配置compile_commands.json。这个文件记录了每一个源文件的编译命令clangd拿到它之后就能精准识别每个文件用了哪些宏、哪些头文件路径。CMake构建时自动生成这个文件只需要加一个参数cmake -DCMAKE_EXPORT_COMPILE_COMMANDSON生成后把它软链到源码根目录ln -s build/compile_commands.json llvm-project/compile_commands.json然后在你的编辑器里启用clangd插件。打开llvm/lib/Transforms下面的任意一个.cpp文件跳转速度会快到让你怀疑以前是怎么忍受的。6.2 ccache 增量构建把二次编译时间从小时级降到分钟级前文提到-DLLVM_CCACHE_BUILDON这里展开讲。ccache是一个编译缓存工具它会记录每个编译单元的预处理结果和编译产物如果你只是改了一个头文件里的一行受影响的文件会被重新编译其他文件的编译结果直接从缓存里拿速度提升极其明显。我实测过一个场景修改llvm/include/llvm/IR/Function.h中的一个接口定义后重新构建用ccache的情况下整个构建时间只有第一次的五分之一到十分之一。如果没有ccache这个改动可能要重编大半个项目。日常开发时还要配合另一个习惯频繁跑ninja但是分模块跑。比如你只改了Clang前端代码那只需跑ninja clang而不用每次都跑全量ninja。llvm-project的ninja构建系统天然支持这种“细粒度目标”的增量构建合理利用这一点能大幅提升开发效率。6.3 llvm-config写外部工具时最省心的辅助工具如果你要编写一个基于LLVM库的外部工具而不是直接修改llvm-project源码那llvm-config是你最好的朋友。它能输出所有你需要的编译和链接参数省去手动拼装的痛苦$ build/bin/llvm-config --cxxflags --ldflags --libs core support -I/home/user/llvm-project/llvm/include -I/home/user/build/include ... -L/home/user/build/lib -lLLVMCore -lLLVMSupport ...写一个小LLVM工具的标准编译命令build/bin/clang my_tool.cpp \ $(build/bin/llvm-config --cxxflags --ldflags --libs core support) \ -o my_tool这个工具在你不想引入庞大的CMake依赖时特别香。它的背后就是LLVM常年维护的库依赖排序逻辑可以说是一个浓缩了无数经验的“链接参数专家”。写在最后的一段经验从第一次被llvm-project的构建日志劝退到如今能在它的源码里面随意游走我最大的体会是这个项目最迷人的地方不仅在于它产出了Clang、lld这些明星工具更在于它亲手把“编译器”从一个高高在上的魔法拉低到了一个普通工程师可以动手改造的工程问题。如果你要走编译器开发这条路对着llvm-project动手写一个pass、改一个IR、加一条目标指令比读十本书都有用。而哪怕你只是日常写应用层代码的工程师理解这套三阶段架构也会让你在排查性能问题、阅读汇编、分析link错误时多出很多底气。最后再分享一个实用小建议如果条件允许优先找一台32GB内存以上的机器来构建llvm-project别再让16GB内存陪你过夜了。