
很多人看到“llvm-project”这个仓库名第一反应是“这是大佬们玩的东西跟我没什么关系”。但实际上只要你写过C/C、用过Clang、看过编译器报错、甚至只是听说过“自研编译器”这个词你都在和这个项目打交道。我最初接触llvm-project也不是为了搞学术研究纯粹是被“编译慢”和“链接报错看不懂”逼着去翻源码结果一翻就翻进了深坑再也没出来。这篇东西算是我这几年折腾LLVM的一个整理从仓库结构、构建流程、核心设计到实际动手写一个Pass、排查常见问题都尽可能说人话。不管你是想入门编译器开发、想给项目定制工具链还是单纯想理解“编译器到底是怎么工作的”这篇内容应该都能给你一个比较完整的落脚点。1. 先搞明白llvm-project里到底装了什么很多人把“LLVM”和“Clang”混为一谈其实这俩是父子关系而且llvm-project这个仓库里装的远不止这两样。我最早clone的时候也被吓到了——整个仓库几十个顶级目录光看名字根本不知道谁是谁。先理清楚家底后面所有操作才不至于迷路。1.1 这些子项目分别负责什么llvm-project是一个monorepo也就是把一堆有机关联的项目放在同一个版本库里统一管理。核心成员包括llvm整个基础设施的核心提供中间表示IR、优化器、目标代码生成、汇编器、链接器等一系列底层库和工具。Clang是它的前端但LLVM本身并不关心你用什么语言写代码。clangC/C/Objective-C的前端编译器。把源代码解析成语义完整的AST再降级到LLVM IR之后交给LLVM做优化和后端代码生成。lld一个高性能链接器目标是用更少的内存和更快的速度替代系统默认链接器。目前ELF、Mach-O、COFF、wasm等格式都支持得不错。libcC标准库的LLVM实现主要服务Clang。相比GCC的libstdclibc的代码结构更清晰调试体验也好一些。compiler-rt提供编译器运行时支持比如sanitizerAddressSanitizer、UndefinedBehaviorSanitizer、profile工具、一些内置函数实现。libunwind栈展开库配合sanitizer、异常处理等场景。lldb基于LLVM的调试器如果你习惯GDBlldb的上手曲线会非常平滑命令风格也很接近。mlir面向机器学习编译器的基础设施是对LLVM设计思路的一次再抽象。如果你关注AI编译优化这个目录迟早得看。flangFortran前端沉寂很久之后现在已经是LLVM官方发布的组成部分。polly基于多面体模型做循环优化的实验性项目虽然没进入默认主流程但里面的思路非常值得学。简单理解llvm是发动机clang是进气口lld是排气管compiler-rt是润滑油llvm-project就是把整车零件放在同一个车间里维护。1.2 monorepo设计带来的实际差异用monorepo管理和用独立仓库管理对普通用户最大的感知差异在版本一致性和构建便利性上。以前LLVM和Clang分开维护经常出现“LLVM版本和Clang版本不匹配编出来的编译器行为诡异”的问题。合并成llvm-project之后所有子项目共享同一个版本号、同一次release周期API变动时其他组件也同步适配整体稳定性高了不少。另一个好处是构建更灵活。你可以在一次cmake配置中一次性构建出clang、lld、libc全套工具链也可以只构建llvm核心库根据自己的场景裁剪。我经常干的一件事是只编译特定子项目比如只构建lldb需要的前端依赖省掉用不到的后端target编译时间能缩短一大截。当然monorepo也有代价——仓库体积大、clone时间长、git操作变慢。这个后面在构建环节详细说。2. 从零构建一套自己的LLVM工具链如果你只想用现成的编译器直接去release页面下载安装包就行。但一旦你想改源码、加Pass、定制工具链就必须自己从源码构建。第一次构建LLVM是个痛苦的过程我清晰地记得第一次全量构建用了将近两个小时期间还因为内存不足被OOM kill了三次。但只要理解了构建参数就能把时间控制在一个比较可接受的范围。2.1 构建环境和推荐参数先说建议的硬件条件。构建LLVM主要吃内存和CPU核心数尤其是内存。全量构建Debug版本16GB内存是底线我建议32GB起步Release版本会好一些但链接阶段依然可能飙到10GB以上。磁盘空间建议预留100GB以上——这不是开玩笑Debug版本编译产物加所有中间对象文件轻松超过50GB。拉取源码git clone https://github.com/llvm/llvm-project.git cd llvm-project git checkout release/17.x # 切换到稳定release分支这里建议切到release分支而不是用main分支。main分支属于开发前沿API变动频繁有些tutorial里面的写法可能隔一周就失效了。我吃过大亏之后就养成了习惯做项目前先选一个稳定的release版本锁定环境。配置和构建cmake -G Ninja -S llvm -B build \ -DLLVM_ENABLE_PROJECTSclang;lld;compiler-rt \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DBUILD_SHARED_LIBSON cmake --build build --target clang lld -- -j$(nproc)几个参数解释一下-DLLVM_ENABLE_PROJECTS指定要额外构建的子项目多个用分号分隔。不需要的就别加能显著缩短构建时间。-DLLVM_TARGETS_TO_BUILD指定目标架构默认全部架构包括AArch64、ARM、Mips等一堆你根本用不上的target每个target都会生成对应的代码生成器。只留X86的话构建速度至少快一倍磁盘占用也小很多。-DLLVM_ENABLE_ASSERTIONS开启断言。Debug阶段强烈建议开启很多内存问题、IR不合规的问题都是靠assertion暴露出来的。Release可以关掉但代价是出了问题很难定位。-DBUILD_SHARED_LIBS把LLVM库编译成动态库而不是静态库。动态库链接快、磁盘占用小但运行时会有一点性能损失而且发布编译产物时会麻烦一些需要带上所有so。自己做开发建议开方便调试做产品发布建议关。2.2 为什么我推荐Ninja而不是MakeCMake默认生成Makefile但LLVM官方社区基本是Ninja大本营。Ninja最大的优势是增量构建速度快。LLVM这种体量的项目源码文件上万头文件依赖关系极其复杂用Make做增量编译时光解析Makefile和检查依赖就能耗掉很长时间Ninja的设计目标就是极致的构建调度效率它不需要重新解析全局依赖图只在文件变更的子图内做重算。实际操作中的体感差距非常明显。同一个项目改一个头文件后重新编译Make可能需要重新编译几百个文件Ninja可能只编译关联的两百个左右而且依赖解析和任务调度的开销远低于Make。我后来切换到Ninja之后再也没回去过。如果想体验Ninja只需要在配置前安装ninja-build然后cmake参数里指定-G Ninja。后面所有构建都用ninja target增量构建体验会好很多。2.3 构建提速技巧全量构建LLVM最耗时的阶段是最后链接尤其是Debug版本。这里有三个实用提速技巧第一使用lld做链接器。LLVM项目本身就是lld的超大型测试场用lld链接LLVM组件比系统自带的GNU ld快2到5倍内存占用也低不少。构建时加一个参数-DLLVM_USE_LINKERlld第二开启ccache或sccache。编译器缓存工具第一次全量构建后缓存命中后续反复构建能节省大量时间。我个人习惯配合Ninja和ccache用改代码后重新构建经常秒级完成-DLLVM_CCACHE_BUILDON第三分阶段构建。不要每次都ninja install全量构建。开发阶段只构建你要改的子项目或者特定target比如我只改clang前端那就跑ninja clang它会自动构建llvm核心库中clang依赖的那部分但不会动lld、compiler-rt等其他子项目。注意如果内存吃紧可以降低并行度-j4或-j2虽然慢一点但不容易OOM。不要一上来就开满核心数先观察一下内存占用再调整。3. LLVM核心设计为什么它是编译器界的“乐高积木”构建好工具链之后接下来要做的一定是理解它的核心设计。LLVM最伟大的地方不在于某个优化算法有多强而在于它把编译器的阶段耦合拆碎了让前端、优化层、后端口三者可以独立发展像乐高积木一样自由组合。3.1 三段式架构和中间表示IR传统编译器比如早期GCC架构通常是一口大锅前端解析完源码直接生成后端能用的中间结构导致每支持一种新语言、新架构都要动整个编译器。LLVM把这条流水线拆成三个独立阶段前端Frontend负责把源码解析成抽象语法树AST再降级成LLVM IR。不同语言对应不同前端比如C/C用ClangRust用rustc前端Swift有自己的Swift前端。中端Optimizer对LLVM IR做平台无关的优化就是一堆Pass不断改写IR。这里就是“llvm”这个名字的核心也是编译器优化的主战场。后端Backend把优化后的IR转换成一个具体架构的机器码比如X86、ARM、RISC-V。IR越规范、越贴近底层后端生成高质量代码就越容易。这里的LLVM IR是整个设计的灵魂。它介于高级语言和汇编之间是一种静态单赋值SSA形式的三地址码会强制要求每个变量最多赋值一次。你写这样一个简单C函数int add(int a, int b) { return a b; }用clang -S -emit-llvm生成IR会看到大概是这种样子define i32 add(i32 noundef %a, i32 noundef %b) { entry: %add add nsw i32 %a, %b ret i32 %add }%a、%b是入参%add只能被赋值一次这种非SSA结构天然适合做数据流分析。优化器跑各种pass本质上就是不断把IR改写成更高效的IR直到无法再优化为止。3.2 Pass基础设施优化的“插件机制”IR只是材料Pass才是加工工序。每个Pass做的事情非常单一比如死代码消除DCE、常量传播、循环不变量外提、内联展开……但组合起来效果惊人。Pass有三种基本类型FunctionPass每次处理一个函数最常用。ModulePass处理整个翻译单元比如全局优化、跨函数分析。LoopPass专门针对循环做优化比如循环向量化。调试大型优化问题的时候我习惯先用opt工具单独跑一个Pass链观察每一步IR变化。比如clang -S -emit-llvm -Xclang -disable-O0-optnone -O2 -o test.ll test.c opt -passesmem2reg,instcombine -S test.ll -o test.opt.ll这样能把“到底哪个Pass把代码改坏了”精确定位出来。3.3 后端设计与“TableGen”语言LLVM支持几十种指令集架构没有谁是靠手写所有后端代码完成的。大部分后端信息是用TableGen语言描述再自动生成C代码。TableGen本质上是一种“记录语言”专门用来描述指令编码、寄存器、指令选择模式这类高度重复的结构。这套设计大大降低了“让一种新语言跑在一个新架构上”的成本。想支持一个新CPU架构你只需要为它编写后端描述想支持新语言你只需要写一个能生成LLVM IR的前端。这正是LLVM生态能同时覆盖C/C、Rust、Swift、Julia、CUDA等众多语言的原因。4. 真正动手写一个自己的优化Pass理论看再多不如动手写一个。我建议的第一个练手项目是自己写一个FunctionPass比如统计每个函数内部的基本块数量然后插桩打印出来。这样既能体验到Pass的注册与运行机制又不需要太复杂的IR操作。4.1 新Pass Manager下的写法从LLVM 14开始默认使用的是新Pass Manager旧的legacy pass manager逐渐被淘汰。新风格的Pass代码干净很多核心就是一个继承PassInfoMixin的类。直接看代码#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/LegacyPassManager.h #include llvm/IR/Module.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h namespace { class BlockCounterPass : public llvm::PassInfoMixinBlockCounterPass { public: llvm::PreservedAnalyses run(llvm::Function F, llvm::FunctionAnalysisManager AM) { int count 0; for (auto BB : F) { count; } llvm::errs() Function F.getName() has count blocks\n; // 我们没有修改任何东西所以所有分析结果都保留 return llvm::PreservedAnalyses::all(); } }; } // namespace llvm::PassPluginLibraryInfo getBlockCounterPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, BlockCounterPass, LLVM_VERSION_STRING, [](llvm::PassBuilder PB) { PB.registerPipelineParsingCallback( [](llvm::StringRef Name, llvm::FunctionPassManager FPM, llvm::ArrayRefllvm::PassBuilder::PipelineElement) { if (Name block-counter) { FPM.addPass(BlockCounterPass()); return true; } return false; }); }}; } extern C LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return getBlockCounterPassPluginInfo(); }几个关键点Pass类的命名直接作为Mixin模板参数使用这是新PassManager用来做类型识别的机制。run方法返回PreservedAnalyses表示这个Pass跑完之后哪些分析结果依然有效。如果什么都没改直接返回all()即可如果做了修改需要返回none()否则后续依赖旧分析的Pass可能拿到的数据是脏的。插件入口是llvmGetPassPluginInfo用C接口导出符号才能被opt在运行时加载。registerPipelineParsingCallback负责把命令行里的block-counter这个名字映射到Pass实例。4.2 插件怎么编译和使用把这个文件保存为BlockCounter.cpp用我们刚才构建的LLVM开发库编译成动态库clang -shared -fPIC -stdc17 \ $(llvm-config --cxxflags --ldflags --libs) \ BlockCounter.cpp -o libBlockCounter.so测试一下。先写一个测试C文件int foo(int x) { if (x 0) { x 1; } else { x - 1; } return x; }生成未优化的IR再加载插件clang -S -emit-llvm -O0 -Xclang -disable-O0-optnone test.c -o test.ll opt -load-pass-plugin./libBlockCounter.so \ -passesblock-counter -S test.ll -o /dev/null运行结果Function foo has 3 blocks Function main has 1 blocks一个if-else分支在IR层面对应3个基本块入口块、then块、else块再加上最终汇合点。能看到这个结构说明你的Pass已经成功跑起来了并且准确遍历了CFG。这个练手项目虽然简单但它把Pass开发的完整链路走通了一遍。之后无论你写内联优化、死代码消除、还是自己的安全检查工具框架都是这套差异只在于run函数里的操作逻辑。5. 常见问题与避坑指南折腾LLVM这几年我踩过的坑比走过的路还多。这里挑几个反复出现的典型问题每个我都至少遇到过一次有的甚至被折磨了一整周。5.1 构建阶段的问题问题构建到一半内存不足被OOM kill很多人的第一反应是“加内存”但更快的解决方式是减少并行度、开启lld链接、使用Debug模式Debug比Release更省内存因为编译器优化级低生成的中间结构更简单——不对Debug版本的IR和调试信息反而更占内存实际更吃内存的是Debug版本。这个要区分清楚Debug版本编译目标本身的内存占用小于Release版本因为Release要跑大量的优化pass这些pass非常耗内存。但Debug版本的产物和调试符号更占磁盘。内存杀手主要是Release构建尤其是LTO和PGO阶段。我实际调优后的方法是cmake --build build --target clang -- -j2把并行度压到2配合-DLLVM_USE_LINKERlld16GB内存的机器也能稳稳编完。代价只是时间变长但至少不会中途失败。问题llvm-config找不到或版本不对很多人下载了预编译的LLVM二进制然后又从源码编译了另一个版本结果llvm-config --version和clang --version对不上编译Pass时头文件找不到或者链接错乱。解决方案很简单始终只用一套工具链。如果要从源码构建就用源码构建出来的llvm-config如果下载安装包就用安装包自带的llvm-config。混用的体验只有一个词折磨。5.2 开发阶段的问题问题修改IR之后程序崩溃但不知道怎么定位遇到这类问题第一件事是打开-DLLVM_ENABLE_ASSERTIONSON重新构建。很多IR不合规的情况比如本应遵循的SSA约束被破坏、指令插入顺序错误在assertion模式下会直接崩溃并给出断言信息这远比运行到后来莫名段错误要容易排查。第二件事是用opt -verify-each。这个选项会在每个Pass执行前和运行后都检查一遍IR合法性一旦某个Pass把IR改坏它能立刻定位到具体是哪个Pass干的opt -load-pass-plugin./libBlockCounter.so \ -passesblock-counter -verify-each -S test.ll -o /dev/null第三件事是最小化复现用例。写一个脚本反复裁剪输入IR文件直到找到一个最小触发崩溃的样本。LLVM官方有脚本工具比如llvm-reduce专门干这个能自动把几十万行IR缩减到几十行。问题Pass注册了名字但opt提示找不到最常见的原因是插件加载了但pass名不在正确的作用域类型里。比如我用FunctionPassManager注册的pass却在命令行用-passesmodule(block-counter)调用作用域不匹配就会找不到。另外一个高发原因是Pass名字拼写。新PassManager的命名规则是命令行用驼峰风格比如instcombine、mem2reg但你注册时如果自己起了名字必须严格匹配大小写都不能错。5.3 工具链使用阶段的典型问题问题现象可能原因解决办法链接时大量“undefined reference”少了库依赖或库顺序不对用llvm-config --libs查看链接参数把LLVM_LINK_LLVM_DYLIBON改为OFF用静态lib逐个链接编译时头文件找不到头文件路径和库路径不在同一个版本检查llvm-config --includedir和实际安装路径是否一致clang能编但lldb断点不生效编译时没有调试信息或优化等级过高编译加-g -O0确认-fstandalone-debug必要时自研Pass优化失效IR被opt -O0处理过包含退化指令加-Xclang -disable-O0-optnone让IR保留可优化空间内存持续增长最终崩溃某个Pass持有Function*或BasicBlock*指针跨模块使用检查pass中是否有静态变量保存了IR对象的引用IR对象在模块重载后会失效看到最后一行想多说一句写Pass最忌讳在类成员变量里保存Function*、Instruction*等裸指针。跨pass运行时IR会被不断改写旧指针指向的内存早已失效用起来必崩。正确做法是每次run都通过FunctionAnalysisManager获取新的分析结果或者通过F.begin()、F.end()重新遍历不要相信缓存下来的指针。6. 一些掏心窝的建议从我自己的实践来看学LLVM最忌讳“一上来就读源码”。源码当然要读但应该在动手写过几个Pass、跑通过一次工具链之后再去读带着问题读代码效率是漫无目的阅读的十倍以上。想让自己的代码接入LLVM的主线流程我建议按这个顺序进阶第一步跑通构建、用opt和clang玩IR第二步写简单的FunctionPass理解IR操作和Pass机制第三步写一个修改IR的Pass比如做常数折叠、函数内联第四步开始给Clang写语法层面的工具比如clang-tidy插件第五步进入后端给某个target改指令选择。每一步都找一个具体的小项目练手而不是看tutorial看到一半就切下一个话题。比如想理解后端就找“给RISC-V增加一条自定义指令”这种题目虽然过程很痛苦但做完之后你对整个编译流水线的理解会有一个层次上的跃升。另外建议新人一定要加入LLVM社区。官方mailing list、discourse论坛、GitHub issue里的讨论质量非常高你遇到的大部分问题别人在十年前就遇到并解决过了。搜索的时候优先搜官方社区和llvm-dev邮件列表很多答案比Stack Overflow上的更权威也更贴近当前版本的设计。最后分享一个小技巧遇到看不懂的IR或者优化行为时别急着查资料先自己动手改一下源码加几行errs()打印看看实际执行路径是什么样的。我在理解内联优化器和向量化器的行为时都是靠这种方式“捅破窗户纸”的。编译器是个黑盒。动手拆开它是唯一真正弄懂它的方式。