
如果你是一个对编译器底层感兴趣的程序员你大概率已经在无数技术讨论里见过llvm-project这个名字。在我刚开始接触它的时候心里想的是这不就是一个编译器吗把它clone下来编一下应该就能跑吧结果第一次构建就花了快三个小时中途还因为内存不足死掉了两次最后才发现是我对项目结构的理解完全不够。后来花了不少时间把源码、构建系统和核心代码的调用关系理清楚才真正感受到这套基础设施设计的精妙。如果你也想从“听说过LLVM”到“能上手读源码、能改Pass、能基于它做自己的工具链”这篇文章就是我走过的路的完整记录。1. llvm-project不是“一个项目”而是一整套编译器生态组件1.1 从两行命令说起clang与LLVM IR的关系很多人的第一个疑问是LLVM和Clang到底是不是同一个东西我在早期也混淆过实际上这两个概念分属编译器的不同层次。你可以运行这样两条命令clang -S -emit-llvm hello.c -o hello.ll clang -S hello.c -o hello.s第一条命令会产出LLVM IR中间表示文本文件第二条命令直接产出目标平台的汇编代码。在这个过程里clang作为C/C前端负责把源码解析成抽象语法树再转换成IR而IR之后的所有优化和代码生成才是LLVM核心LLVM Core真正发挥作用的地方。llvm-project这个项目之所以叫“项目”而不是“编译器”就是因为它包含了前端、优化器、后端、链接器、标准库等一整条工具链而不是单独一个可执行文件。理解了这一点再去读官方文档里的“The LLVM Compiler Infrastructure”就好办多了。所谓的Infrastructure就是要提供一套可复用的编译组件前端可以换Clang、Flang、Rustc借用的前端后端也可以换X86、ARM、RISC-V、WebAssembly中间共享的优化层和IR格式是整条链路的粘合剂。1.2 拆开monorepollvm、clang、lld、libc各自扮演什么角色llvm-project以monorepo形式管理根目录下并列着很多子项目。这里我列一下最核心的几块目录作用对应可执行文件/库llvm/核心优化器、IR、目标后端、日常工具opt,llc,lli,llvm-disclang/C/C/Objective-C前端clang,clanglld/高性能链接器支持ELF、Mach-O、COFF等ld.lld,lld-link,ld64.lldlibc/C标准库实现无直接可执行文件编译时通过-stdliblibc使用libcabi/为libc提供ABI支持RTTI、异常处理等无compiler-rt/运行时库Sanitizer、PGO、内存分析等无编译时通过clang自动链接flang/Fortran前端flang-newmlir/多维中间表示层专用于机器学习、编译加速mlir-opt,mlir-translatepolly/多面体模型中循环优化opt的扩展在实际构建时你可以选择只构建核心和Clang也可以选择带上lld和compiler-rt。很多发行版里的clang二进制之所以不能和系统链接器很好地配合就是因为发行版没有把lld一起启用。自己从llvm-project构建时完全可以把这一套都装上体验整套工具链顺畅协作的感觉。1.3 为什么三段式架构能通吃多种语言与硬件传统编译器比如GCC虽然也是“前端-优化-后端”但LLVM把这三段之间的接口标准化成了一门显式的、有文本表示的语言——IR。这意味着任何语言只要写出一个能生成IR的前端就能立刻复用整个优化器、后端和链接器。这带来的直接好处是如果你想发明一门新语言不需要为每一个硬件平台从头写代码生成器只要对接好LLVM后端就能获得X86、ARM、RISC-V等主流平台的支持。这个架构还让“一次编写随处优化”成为可能。因为IR是明确定义的数据结构你可以在IR层面写跨语言的优化Pass比如循环展开、内联、常量传播这些优化对所有接入LLVM的前端语言都生效。在我自己写Pass之后更能体会到这种模式的价值我只需要保证对IR的正确变换不用关心Pass跑在到底是C还是Rust前端产生的IR上。2. 动手构建llvm-project环境评估和CMake配置的实用建议2.1 源码获取的三种方式与版本选择要从零开始使用llvm-project第一步自然是获取源码。官方推荐的方式是git clone https://github.com/llvm/llvm-project.git也可以用--depth做浅克隆来减少首次下载体积git clone --depth1 -b llvmorg-18.1.8 https://github.com/llvm/llvm-project.git这里有一个版本选择的问题。如果你只是学习直接跟踪main分支也没有太大问题但我在生产环境里会锁定一个发布版本比如llvmorg-17.0.6。原因很简单main分支每天都有大量提交很多API接口、Pass注册风格、CMake变量会发生变化今天能编译通过的代码可能一周后就因为接口调整而报错。而release分支经过了完整的测试第三方工具如nvptx后端、libc的兼容性都更稳定。2.2 构建前的资源评估内存、磁盘与时间很多人在第一次构建LLVM时栽在资源预估上。我建议至少准备20GB磁盘空间如果开启了Debug断言和测试35GB也不嫌多。内存方面经典方案是并行-j数量不超过物理内存GB的1.5倍。例如一个8核16GB内存的机器不要直接ninja -j16比较稳妥的是-j6或-j8不然链接阶段很容易因为内存峰谷触发OOM。时间上Release构建生成的clang和opt在中等配置机器上大约需要30到60分钟Debug构建由于没有优化且带大量断言时间可能是Release的三到四倍。如果想快速验证修改可以采用“只构建指定工具”的方式例如ninja opt ninja clang ninja llc这样能大幅缩短迭代周期。我通常先构建opt和clang做Pass开发时只需要这两个工具就足够了。2.3 核心CMake选项逐项解读构建目录最好和源码目录分开在源码目录外新建一个build目录然后执行cmake ../llvm-project/llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;libcxx;libcxxabi \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON \ -GNinja这些选项各自的含义是CMAKE_BUILD_TYPERelease生成优化过的二进制适合平时使用和性能测试。如果是为了调试Pass用Debug会获得更完整的符号和边界检查但物理内存最好在32GB以上。LLVM_ENABLE_PROJECTS决定需要一起构建哪些上层项目。注意这个列表以分号分隔在命令行的引号里要写对。LLVM_TARGETS_TO_BUILD限定目标后端。如果只写X86构建时间会显著降低。但如果你要写后端代码或实验交叉编译就得加上对应目标。可以写host表示只构建当前机器架构的后端省时省事。LLVM_ENABLE_ASSERTIONSON这个选项强烈建议在开发时开启。它会让LLVM在IR合法性检查、Pass管理器验证等位置主动抛出断言错误帮助尽早暴露问题。很多崩溃在Release下表现为段错误而在开启断言的Debug下会变成明确的“IR does not satisfy ... constraint”省去大量排查时间。如果CMake配置完成后又新增了子项目不需要重新创建构建目录只需要重新运行CMake命令并添加新的-DLLVM_ENABLE_PROJECTS即可。我遇到过直接在命令行把clang追加到已有缓存变量里的情况这样很容易导致CMake缓存不一致正确做法是用cmake -DLLVM_ENABLE_PROJECTSclang;lld ...重新配置一遍。2.4 构建失败排查内存不足、Python版本、链接错误下面列几个真实容易踩的坑。第一个坑链接期内存耗尽。LLVM的核心库是几个体积巨大的静态库链接clang或opt时内存消耗特别高。如果Configure成功但Ninja在链接阶段被系统杀掉最常见原因就是并行任务太多。解决办法很简单减少-j参数或者干脆串行只链接一个目标。另一个技巧是使用lld作为链接器来链接LLVM自身lld的内存占用比系统默认的GNU ld小很多。启用方法是在CMake时加一条-DLLVM_USE_LINKERlld前提是你已经构建出了lld或者系统里装了ld.lld。第二个坑Python脚本报错。llvm-project里有些测试和构建步骤依赖Python 3。如果服务器上默认的python指向Python 2CMake测试阶段就会报错。建议在环境中显式设置PYTHON_EXECUTABLE指向Python 3解释器。第三个坑缺少zlib和libxml2。大多数发行版可以通过包管理器安装。比如在Debian/Ubuntu下执行sudo apt install zlib1g-dev libxml2-dev libncurses-dev就能解决大部分可选依赖问题。虽然不装也能构建出核心工具但有些功能比如源码级调试信息解析会受到影响。构建完成之后所有生成的可执行文件都在build/bin/目录下。为了使用方便可以把这一目录加到PATH环境变量里。另外build/里还会生成build/compile_commands.json如果你的编辑器支持clangd或compile_commands.json索引直接用它来导航LLVM源码会非常舒服。3. 源码导航如何高效阅读llvm-project而不迷失3.1 顶层目录与模块依赖关系拿到底层源码后最怕的就是像看普通开源项目那样顺着一个目录从头看结果很快丢失上下文。LLVM的源码量非常大核心llvm/目录下的.cpp和.h加起来超过两万份没有目标地看会非常低效。我的经验是先找到“入口”再沿着一个具体功能走一遍调用链。先说说llvm/目录的内部结构llvm/include/llvm/IR/IR的数据结构定义包括Module,Function,BasicBlock,Instruction等。llvm/lib/IR/IR数据结构的实现以及核心Verifier。llvm/lib/Passes/Pass管理器、标准Pass构建逻辑。llvm/lib/Transforms/各种优化Pass实现比如Scalar,IPO,Vectorize。llvm/lib/CodeGen/目标无关的代码生成每个具体后端又有llvm/lib/Target/TargetName/。llvm/lib/Support/基础工具库字符串、文件系统、命令行选项等。llvm/tools/opt/opt可执行文件的主要入口。如果你的目标是想理解“Pass是怎么跑起来的”那最佳的起点就是llvm/tools/opt/opt.cpp。从main函数往下追看它调用了PassBuilder、FunctionAnalysisManager、ModuleAnalysisManager这就是整套优化管线的关键脉络。3.2 一个优化Pass从注册到执行的调用链现代LLVM的Pass有两种经典类型ModulePass和FunctionPass在new PM下概念变成了对应AnalysisManager中的不同Pass单元但基本思路一致。一个Pass在代码里通常这样声明class MyPass : public PassInfoMixinMyPass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM); };要让opt能通过旗标调用它需要在PassBuilder的registerFunctionAnalyses或registerPipelineParsingCallback里注册。理解这条调用链关键在“注册”和“执行”的分离。Registry机制让Pass变成一个可插拔的组件编译时注册进PassRegistry运行时opt -passesmy-pass通过字符串名称查找到对应Pass对象。如果我想在opt中加入自己的Pass就必须修改PassBuilder的注册回调而不是直接在opt.cpp里加代码。这里面涉及的分析依赖比如LoopAnalysis、DominatorTreeAnalysis也都是在AnalysisManager中按需创建的。理解了这一整套抽象你去看llvm/lib/Transforms/里的所有Pass就不会觉得凌乱了。3.3 用Clang命令行快速观察IR与优化效果读源码比不过实际跑一遍。我经常用下面的命令来观察IR生成和优化效果。比如一个简单的C文件// test.c int foo(int x) { return x * 8; }先生成未优化的IRclang -S -emit-llvm -O0 test.c -o test-O0.ll再生成优化后的IRclang -S -emit-llvm -O2 test.c -o test-O2.ll对比两份文件会看到O2版本直接将mul指令变成了shl这就是编译器前端和优化器协作的直观体现。如果想进一步看到特定Pass执行后的IR快照可以用optopt -S -passesmem2reg test-O0.ll -o test-mem2reg.ll这种工具组合是理解LLVM IR的最佳入口。你会发现Clang前端生成的IR带有大量alloca和load/store指令这是为了保留程序语义而mem2reg这样的Pass会把这些局部变量提升为SSA值让后续的分析和优化更容易进行。写字Pass时我通常也是先生成一个小的IR文本文件再使用opt反复调试确认修改符合预期后再合并到工程里。4. 基于llvm-project做自定义工具写一个真实可跑的Pass4.1 工程结构与新增Pass的代码轮廓打开llvm-project根目录大多数人会拿llvm/lib/Transforms/InstCombine/InstCombine.cpp当作范例。但如果只是想新增一个实验性的独立Pass更合适的方式是放在一个独立的目录里比如llvm/lib/Transforms/HelloWorld/。一个最小可用的Pass长这样#include llvm/IR/Function.h #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 HelloWorldPass : public PassInfoMixinHelloWorldPass { 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, HelloWorld, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name hello-world) { FPM.addPass(HelloWorldPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getHelloWorldPluginInfo(); }注意这里使用了“插件Pass”的方式它最大的好处是不需要重新编译整个LLVM。只要构建出来的opt开启了插件支持默认就是开启的就可以用-load-pass-plugin来加载。这对于前期学习来说是最高效的路径。4.2 在LLVM的CMake体系中注册新Pass如果你想把这个Pass作为内置Pass放进LLVM源码体系和那些官方Pass并列需要在llvm/lib/Transforms/CMakeLists.txt里新增一个目录add_subdirectory(HelloWorld)并在HelloWorld/CMakeLists.txt中写add_llvm_component_library(LLVMHelloWorld HelloWorld.cpp LINK_COMPONENTS Core Support )这种方式的优点是Pass能直接通过opt -passeshello-world调用不需要每次-load-pass-plugin但缺点是你必须重新编译LLVM。如果你的机器性能一般建议先写插件Pass成熟后再考虑融入源码树。4.3 用opt加载Pass并编写lit测试自定义Pass跑通的最快路径如下写一个测试用IR文件test.lldefine i32 main() { %a add i32 1, 2 ret i32 %a }编译插件clang -fPIC -shared -Ipath-to-llvm-project/llvm/include HelloWorld.cpp -o libHelloWorld.so如果你的LLVM不是通过标准安装而是从源码构建的还需要加上-Ibuild/include因为一些由llvm-tblgen生成的*.inc头文件都在构建目录里。运行opt -load-pass-plugin./libHelloWorld.so -passeshello-world test.ll -disable-output如果看到Hello from: main说明整个编译-注册-执行链路是通的。之后可以给Pass写官方风格的lit测试。在llvm/test/Transforms/HelloWorld/下新建一个hello-world.ll; RUN: opt -load-pass-pluginpath/libHelloWorld.so -passeshello-world -disable-output %s | FileCheck %s ; CHECK: Hello from: main define i32 main() { ret i32 0 }然后运行ninja check-llvm或者用llvm-lit单独测试。lit测试框架会自动拼接环境变量但是插件路径通常需要写成绝对路径否则不同环境上会找不到.so文件。4.4 进阶从Pass拿到更细粒度的程序信息一个只打印函数名的Pass演示意义大于实际价值。实际开发中如果你要在Pass里做分析最常用的API是auto DT AM.getResultDominatorTreeAnalysis(F); auto LI AM.getAnalysisLoopAnalysis(F);通过依赖分析结果可以判断一个循环是否可向量化一条指令的操作数是否来自外部内存等等。这些都是优化Pass的核心内容。我建议在读过LoopInfo和ScalarEvolution的源码后再开始写复杂的Pass否则很容易做出“一看很合理一跑就崩”的Pass。写Pass时的最大心得先想清楚你要在IR层做变换还是仅做分析。如果做变换一定要处理好PreservedAnalyses的返回值。如果你复用了某些分析结果但没有在返回值里声明保留Pass管理器会认为所有分析均已失效导致后续Pass重新分析性能大幅下降。反过来如果你声称保存了实际被改变的分析结果并不会自动更新就可能触发断言错误。5. 版本演进与社区协作跟进llvm-project的日常实践5.1 main分支为何值得跟但生产环境必须锁releaseLLVM的迭代节奏非常快社区几乎每天都有几十个提交。如果你想使用最新指令集特性、新Pass策略main分支毫无疑问是信息源。但对于稳定产出来说main分支的风险也很明确可能引入未完全修复的回归可能改变LLVM IR的某些版本兼容性也可能让第三方工具比如你的自定义Pass突然无法编译。我在经历过一次由于FunctionAnalysisManager接口改动导致整个Pass模块重写之后就形成了“开发环境用main跟踪、生产环境锁定期release”的模式。在llvm-project的发布策略里通常每年发布两次major release例如18.x和19.x。release分支的版本号在CMake配置时体现为LLVM_VERSION_MAJOR。锁定时建议连patch版本一起记录因为你构建出来的二进制取决于具体版本不能只用“llvm 18”来归档。5.2 Monorepo带来的git操作变化在llvm-project迁移到monorepo之前Clang和LLVM属于不同仓库跨仓库同步提交是一件痛苦的事情。现在所有子项目放在一个仓库里日常操作简化了很多。比如git log --oneline -10能看到涉及Clang和LLD的提交交错在一起而查找某个特定组件的变更可以用git log --oneline -- clang/lib/CodeGen lld/ELF另外monorepo也让我们能用单个git clone --filterblob:none来减少下载量这在网络环境一般时特别有用。需要注意的是由于仓库体积很大一次git pull --rebase可能会拉取大量对象如果长时间没更新建议用git fetch --shallow-since等浅历史方式来控制数据量。5.3 如何提交Patch与应对代码评审如果你打算向社区贡献代码需要了解LLVM的代码评审工作流。它使用Phabricator目前正逐步迁移到GitHub Pull Requests。典型的流程是git checkout -b my-patch # 修改代码... clang-format --styleLLVM changed-files git commit git diff --stat # 检查修改范围提交信息需要遵循规范一般包含[Clang]或[llvm]等前缀。社区非常看重测试覆盖所以新增Pass时必须附带lit测试。提交到Phabricator后会有机器人运行预提交测试开发者们会在Review里逐行给意见。初看起来这套流程有点重但对于基础设施项目而言这是保障几十个后端、数百个贡献者稳定协作的必要方式。在贡献过程中我个人的经验是看代码风格时不要猜直接用git clang-format提交前务必跑相关测试比如修改的是llvm/lib/Transforms/至少要把check-llvm-transforms跑一遍而不是只跑自己新增的测试。一次测试没覆盖全的Patch被打回重审是常见的事。还有一点值得分享正因为是monorepo提交时极容易混入无关修改。我在一次提交里因为改了clang/lib/Driver的同时顺手调整了lld/ELF/CMakeLists.txt的换行结果评审者要求拆成两个独立提交。所以保持每个Patch聚焦单一问题是参与这个社区的基本礼仪。写在最后的一点经验在我自己使用llvm-project从入门到能动手做Pass的这段时间里最大的体会是不要惧怕项目体量但要学会“贴着具体场景读源码”。只看目录结构容易迷失只跑工具不看代码又得不到深入理解。最好的方式是从一条简单的IR变换开始比如写一个把mul替换成shl的Pass走一遍注册、编译、执行、测试的完整链路然后再逐渐扩展到循环分析和代码生成。这比从头到尾读一遍LLVM Programmer’s Manual要有效得多。如果你也正好卡在第一次构建或第一次写Pass的过程中希望这篇文章能帮你少浪费几次OOM时间。