从零构建LLVM并手写第一个优化Pass:编译器基础设施入门

发布时间:2026/9/19 15:58:16
从零构建LLVM并手写第一个优化Pass:编译器基础设施入门 1. LLVM到底是什么以及为什么它值得你花一个下午去折腾先说个现象很多人第一次在GitHub上搜到llvm-project这个仓库时光是看到目录里那一堆子项目名字就劝退了——llvm、clang、lld、libcxx、compiler-rt、mlir……这还不是最可怕的等你试着跑一次cmake看到几百个编译目标在滚动心态很容易崩。但我个人觉得这恰恰是这个项目最迷人的地方它不是一个编译器的源码而是一整套用来“制造编译器”的基础设施。你手里拿到的不是一把刀而是一座工具厂。LLVM的全称是Low Level Virtual Machine虽然名字里带Virtual Machine但它跟传统意义的“虚拟机”关系不大。它最核心的贡献是把传统编译器“前端直接怼后端”的耦合结构拆成了三段前端负责把源代码解析成中间表示IR优化器在IR上做各种变换后端再把IR翻译成目标机器的汇编或机器码。这个“中间表示”就是LLVM IR一切魔法的起点。为什么要这么拆因为传统编译器比如GCC前端和后端是绑死的你要支持一种新语言基本上要从头到尾重写一整套东西。而在LLVM里只要你写一个前端能把源码翻译成LLVM IR那么剩下的优化器、代码生成、链接器、调试信息处理全都能复用。这就是为什么我们能看到Rust、Swift、Julia这些新语言在短短几年内就有了高质量的原生代码生成能力——人家没有从零造轮子而是把LLVM的后端拿来直接用。这也解释了为什么llvm-project会有那么多子项目clang是C/C/Objective-C的前端lld是链接器libcxx是C标准库实现compiler-rt是运行时库mlir是面向机器学习和编译器基础设施的多层级IR框架。它们围绕同一个核心——LLVM IR——构成了一整个工具链生态。那这篇文章要讲什么我不会从零给你上一堂编译原理课而是从一个实际操作者的角度带你把llvm-project这个仓库从结构、构建、到写第一个优化Pass完整走一遍。我默认你至少写过一些C知道一点编译器在干嘛但不要求你是个编译器大佬。看这篇文章的过程中你可以随时打开电脑跟着做我会把每一步的命令、配置、我踩过的坑都写清楚。2. 这个仓库到底长什么样门儿清目录结构2.1 顶层子项目到底谁是谁先别急着clone你最好先知道你要面对的是什么。llvm-project是一个monorepo也就是把一堆有关联但独立维护的项目放在同一个仓库里做统一版本管理。我以前在其它项目上用submodule拆来拆去遇到跨项目改接口的commit能让人崩溃LLVM社区后来全面切换到monorepo很大程度上就是为了解决这种改一处需要同步多个仓库的痛点。下面是几个核心子项目我按“它到底干嘛”来分组子项目目录角色定位一句话说明llvm/核心基础设施LLVM IR定义、优化器、目标后端、汇编器、链接器基础库是全仓库的心脏clang/C/C前端把C、C、Objective-C源码解析成AST再降级到LLVM IRclang-tools-extra/辅助工具集clang-tidy、clangd、clang-format等工具都放这里lld/链接器一套模块化的链接器实现支持ELF、Mach-O、COFF等格式libcxx/、libcxxabi/C标准库libc是标准库实现libcabi是ABI层共同提供C运行时支持compiler-rt/运行时库提供sanitizer、profile、builtin等底层运行时函数mlir/多层级IR框架面向机器学习、HPC领域的可扩展编译器基础设施flang/Fortran前端经典科学计算语言的LLVM前端polly/循环优化器基于多面体模型的循环变换优化libunwind/栈展开库提供异常处理和调试用的栈回溯能力说个容易让人困惑的点llvm/目录里面还有一个llvm子目录里面是库代码和工具代码而llvm/tools下面是opt、llc、llvm-as这些可执行文件的入口源。所以你在编译的时候经常会看到编译目标名和目录名搞混比如编opt这个工具实际对应的源码路径是llvm/tools/opt/。2.2 核心源码目录的三层格局如果你第一次打开llvm/include/llvm/可能会被里面的头文件数量吓到但其实它的组织逻辑非常清晰核心分三层include/llvm/IR/LLVM IR的数据结构比如Module、Function、BasicBlock、Instruction。你在写Pass时打交道最多的就是这些类。include/llvm/Passes/Pass基础设施包括PassBuilder和新的PassManager新版优化管线的核心逻辑在这。include/llvm/CodeGen/面向后端的机器指令描述、寄存器分配、指令选择等很少会有应用层开发者动这里。而lib/目录则是对应.cpp实现组织方式和include基本一一对应。这种“声明在include实现在lib”的结构对开发者非常友好你想知道某个接口有哪些方法去include里看声明你想知道它怎么实现的去lib里同名文件找定义。我刚开始啃代码时/include/llvm和/lib目录来回跳一天能走两万步物理意义上的。3. 从源码把llvm-project跑起来构建配置与避坑3.1 环境准备和硬件门槛先给个我个人的体感编译整个llvm-project默认配置下会让你的CPU风扇起飞。我最初在笔记本上做全量构建8核16线程跑完大概花了四十分钟到一个小时中间内存峰值能到接近8GB。如果你机器只有16GB内存建议至少留8GB给编译进程不然很容易被系统OOM杀掉。依赖方面其实很简单装好git、cmake、ninja、一个可用的C/C编译器就够。Linux上用apt装的话就是sudo apt update sudo apt install git cmake ninja-build build-essentialMac上则建议用Homebrew把同名的包都装上。Windows用户建议直接用Visual Studio的生成器或者配置好Clang-cl工具链再用Ninja。这块网上教程多我不多啰嗦。clone仓库时要加--depth 1参数吗我的建议是如果是第一次上手建议--depth 1因为完整历史大概有几十万次提交拉完整历史会浪费大量时间但如果你是想长期跟踪LLVM的开发变化那还是老老实实拉完整历史因为git bisect查回归时会用到。git clone https://github.com/llvm/llvm-project.git --depth 1 cd llvm-project3.2 CMake配置到底该怎么选我强烈建议这句话刻在脑子里在构建LLVM时绝大多数人遇到的问题是“我要的太多”。默认配置会编译全部子项目和所有支持的CPU后端你会陷入一堆根本不用的工具和目标在编译的泥潭里。你需要一个工具来裁剪“裁剪”的核心就是两个CMake变量LLVM_ENABLE_PROJECTS和LLVM_TARGETS_TO_BUILD。前者决定你要构建哪些子项目比如clang、lld后者决定你要支持哪些硬件后端比如X86、AArch64、RISCV。我自己常用的最小可用配置长这样cmake -G Ninja \ -B build \ -S llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON \ -DCMAKE_C_COMPILERclang \ -DCMAKE_CXX_COMPILERclang逐项解释一下我的选择-DLLVM_ENABLE_PROJECTSclang;lld我只需要Clang编译器和lld链接器不碰flang这类我暂时用不上的东西。-DLLVM_TARGETS_TO_BUILDX86我只在本机X86上做实验不需要交叉编译到ARM或RISC-V所以只留X86后端编译速度能快一倍不止。-DLLVM_ENABLE_ASSERTIONSON这个很多人会关掉以换取更快的编译产物但如果你要开发Pass强烈建议开着——断言能在IR数据结构出现非法状态时第一时间告诉你而不是让你在调试器里折腾半天。-DCMAKE_C_COMPILERclang和CMAKE_CXX_COMPILERclang用Clang来编译Clang也就是“自举”。这确实会有一定增量开销但Clang编译C的速度比GCC快而且生成产物带更丰富的优化信息对后续调试会更舒服。当然如果你本机没有Clang直接用GCC也能编不会有大问题。配置完之后cmake --build build --target opt clang lld这样只构建3个目标opt是优化器工具用来跑你写的Passclang是编译器前端lld是链接器。这比cmake --build build直接全量构建省出好几个小时。3.3 我踩过的构建坑和解决姿势✓cmake默认用Unix Makefiles可以吗可以但Ninja的并行调度效率明显更好尤其是几百个编译目标的情况下。我最早用Makefiles构建跑一次全量要50分钟切到Ninja后直接降到35分钟以内。✓需要ccache吗非常需要。以后你改一个头文件受影响的重编单位可能高达数百个没有缓存简直原地爆炸。加一行配置就行cmake -G Ninja -B build ... -DLLVM_CCACHE_BUILDONccache命中率上去之后增量构建经常是几秒钟搞定体验完全不同。✓内存不足怎么办把并行度降下来是真办法。Ninja默认会开满线程你可以用NINJA_STATUS看进度或者直接在env里设NINJA_JOBS4限制并行任务数。不要硬扛OOM被杀之后你连错误日志都很难定位。✓遇到“Type Trait”或Clang自身版本不匹配的编译错误大概率是Host编译器版本太老。LLVM对现代C标准支持追得很紧GCC 7以下基本没戏建议至少用GCC 9或Clang 12以上。4. 直接从零写一个优化Pass让IR说话4.1 为什么理解Pass概念那么重要你准备好开始“玩”LLVM了绕不开的第一个概念就是“Pass”。Pass字面意思是“一遍处理”在编译器语境里就是“在IR上做一次完整变换或分析”。我打个比方如果LLVM IR是一篇文章的提纲编译器要做的就是反复阅读这份提纲每次阅读干一件事——第一次标出所有“未使用的段落”并删掉第二次把所有“重复的句子”合并第三次把“一段话换一种更短的表达”。每一次动作就是一个Pass它们之间有先后依赖关系最终形成一条优化管线Pass Pipeline。从行为上分Pass分两大类Analysis Pass只做分析不修改IR比如计算某个函数里所有循环的深度Transform Pass会修改IR比如删除死代码、把循环展开。写优化Pass时你永远是这两种中的一种或者二者结合。4.2 一个真实的Pass代码给所有函数加一个printf打印光说不练假把式。下面这个例子是给每个函数入口插入一段打印逻辑我的目标是展示“怎么写一个最简单的Transform Pass并让它能跑出效果”。#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/InstrTypes.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Support/raw_ostream.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { struct HelloFunction final : public FunctionPass { static char ID; HelloFunction() : FunctionPass(ID) {} bool runOnFunction(Function F) override { errs() Hello from function: F.getName() \n; // 获取第一个基本块的入口处在这里插入一个打印调用 BasicBlock EntryBB F.getEntryBlock(); IRBuilder Builder(EntryBB, EntryBB.getFirstInsertionPt()); // 注意这只是演示如何获得插入点实际插入调用需要处理printf签名 return false; // 我们没有真正修改IR所以返回false } }; } // namespace char HelloFunction::ID 0; static RegisterPassHelloFunction X( hello-function, Print function names in IR, false /* Only looks at CFG */, false /* Analysis Pass */);这段代码里的核心逻辑runOnFunction在每个函数被处理时回调一次我们直接用errs()在终端上打印函数名。因为只是打印没有实际修改IR所以返回false表示“这个Pass没改变任何东西”。这是旧版的legacy::FunctionPass写法LLVM老项目中遍地都是。新的PMPassManager写法接口略有不同但概念上一脉相承。你读旧代码时两种都见过就不会懵。把这个Pass的源码文件放到llvm/lib/Transforms/HelloPass/下然后在llvm/lib/Transforms/下的CMakeLists里加上一行add_subdirectory(HelloPass)再到llvm/lib/Transforms/HelloPass/CMakeLists.txt里写add_llvm_component_library(LLVMHelloPass HelloPass.cpp DEPENDS intrinsics_gen )重新构建cmake --build build --target opt然后用opt载入这个Pass处理一段IR文件echo int add(int a, int b) { return a b; } | build/bin/clang -S -emit-llvm -x c - -o - build/bin/opt -load build/lib/LLVMHelloPass.so -hello-function -disable-output如果一切正常你会在终端里看到类似输出Hello from function: add这个小小的成功背后意味着你终于理解了”编译器的插件体系”是怎么运作的Pass是编译过程的积木你可以自由插入自己的积木进去改变编译行为。4.3 写Pass时我建议你注意的几个细节真正修改了IR就返回true如果你改了IR但没返回truePassManager会认为IR没变后续依赖这个变换结果的Pass可能拿到错误数据这是新手最容易犯的坑。用errs()输出和用outs()输出的区别errs()直接输出到stderr适合调试如果你要输出到产物文件里用outs()并处理好输出时机。很多人一开始只看到终端啥也没打出来其实是把stdout和stderr搞混了。旧PM和新PM的口径统一LLVM 14以后默认开始推新PMopt默认跑的都是新PM管线。但legacy PassManager的写法还在被很多项目沿用。如果要让新PM也加载你的Pass需要额外实现llvm::PassInfoMixin并提供run方法。这点不可忽略因为你在网上搜到的很多早期教程都默认旧PM直接照搬到新环境里会报错。5. 从跑通到看懂LLVM源码阅读的正确打开方式5.1 路线图别上来就啃SelectionDAG很多人拿到源码的第一反应是从clang的AST开始看或者直奔CodeGen的指令选择逻辑然后很快就被劝退了。我的建议是先走IR和Pass这条路因为它是功能解耦最干净、最接近软件开发常识的一层。开始阅读之前你对LLVM IR的语法要有基本认知。LLVM IR有三种表示形态内存中的C对象你可能永远不需要直接看、字节码文件.bc密度高机器可读、文本IR.ll人可读。先用clang -S -emit-llvm生成.ll文件然后手动去观察一个简单函数长什么样。一个示例define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }你会发现IR本质上是一种“显式类型、无限寄存器SSA形式”的汇编语言。i32表示32位整数%a、%b、%sum是虚拟寄存器名每一条指令都有一个唯一的SSA定义。我的建议路线是先花一个晚上看懂IR语法再花一个下午用opt跑各种Pass观察IR的变化最后再展开读源码。递进效率是最高的。5.2 调试器的正确打开方式gdb/lldb 断点源码纯靠肉眼看效率很低我已经不止一次见到有人问“这个runOnFunction到底是谁调进来的”你上调试器看一眼调用栈十秒钟就明白了。构建时加上-DCMAKE_BUILD_TYPEDebug或-DCMAKE_BUILD_TYPERelWithDebInfo就能用用调试器调试opt。比如gdb --args build/bin/opt -passeshello-function input.ll break llvm::FunctionPass::runOnFunction run bt这样你能完整看到PassManager怎么拿到Pass、怎么遍历每个函数、怎么调用你的代码。知道运行时路径之后你就会发现LLVM内部的调用链并不神秘它就是一层一层的容器套容器。5.3 避大坑DEBUG宏和print要会用很多源码里藏着大量LLVM_DEBUG(dbgs() ...)输出默认是关闭的。你要看某条代码路径的调试信息在跑命令时加-debug-onlyxxx或设置环境变量DEBUGxxx。这个技能特别实用比你自己到处加errs()打点高效得多。举个例子想看opt跑某个Pass时它内部的调试信息build/bin/opt -passesinstcombine -debug-onlyinstcombine input.ll -S -o /dev/null如果不加-debug-only你只会看到一大坨噪音加了之后instcombine这条优化路径的每个变换决策都会清晰浮现出来。5.4 沿着“一条优化”打通全链路我的经验法则是找一个非常小的优化点从语法到算法到实现完整追一遍比泛泛读十篇文章都有效。这里举一个经典的例子——“常量折叠”Constant Folding。你在IR里写add i32 1, 2优化器会把它折叠成3。这个优化过程的起点在llvm/lib/Analysis/ConstantFolding.cpp入口函数是ConstantFoldInstruction。它先尝试调用泛化的ConstantExpr求值逻辑失败再走目标相关的TargetFolder。你可以在这里设断点输入一个常量表达式逐步跟踪它是怎么完成折叠的。从这个小点出发你会自然接触到IRBuilder的用法、Constant类家族、以及SimplifyInstruction这套更现代的API。一个点打通后你会惊喜地发现其他Pass基本都遵循类似的骨架逻辑后续再学就快很多。6. 玩转llvm-project后的进阶方向与经验沉淀到这里构建环境、跑通第一个Pass、理解了IR层的工作方式都已完成你可以考虑下一步往哪个方向深耕。我个人的体验是不同方向对源码深度的要求完全不一样选错了会走弯路。6.1 四条主流进阶路径对比方向核心目标主要议题适合人群静态分析与安全检查基于IR做程序分析CFG/DFG分析、数据流传播、sanitizer实现想深入编译器前中端、安全领域的人优化Pass开发改进IR层面的优化能力循环变换、内联策略、消重熟悉数据结构和算法喜欢优化的人目标后端开发支持新CPU架构指令选择、寄存器分配、指令调度对硬件、汇编、体系结构着迷的人MLIR/多层级IR面向AI和HPC编译dialect设计、类型系统、子图切分对MLIR框架和AI编译器感兴趣的人个人认为如果你之前没有编译原理的系统背景先从“静态分析与安全检查”这条线切入是比较温和的因为它的核心是IR数据结构和控制流分析不需要接触太多硬核指令选择细节。如果你对硬件有底子直接冲后端开发LLVM在这块的工具链和测试框架是所有编译项目中最完整的。6.2 有种“越学越觉得自己是萌新”的感觉很正常LLVM是个几十万贡献者年轮的项目即便只是把llvm/lib/Transforms/InstCombine这个目录完整读一遍也需要大量的时间和耐心。这玩意儿没有捷径但有几个加速器可以用跟随有人带的路LLVM官方在YouTube上有教程另外像“LLVM Tutorial”和“Kaleidoscope语言教程”是官方推荐的入门必刷一步步实现一个完整小语言效果很好。读变更日志和review讨论GitHub上每个commit的讨论区往往藏着设计决策的理由这些上下文比源码本身更有价值。碰到看不懂的代码去翻一下它是在哪个PR引入的比硬猜效率高得多。写测试给LLVM贡献一个小Pass或者修一个bug不需要多牛但你要经历写代码——提交review——被社区围攻优化建议的过程这种真实反馈比看任何博客都来得扎实。我自己第一次提交patch时被reviewer怼了十几轮“使用ArrayRef”的细节但就是这十几轮让我彻底改掉了C老式蛋疼的写法习惯。6.3 最后分享几个实战小技巧如果你平时用LLVM做工作相关的事情这几个技巧会长期有用让opt变成你的“IR游乐场”不要把opt当成一个黑盒工具它是最好的IR观察器。每写完一个Pass先用-S输出变换后的IR肉眼确认改变是不是符合预期再上运行测试。构建产物保留一份Debug版虽然Debug版跑起来慢一点但它打印的IR信息更完整、断言全开排查IR数据结构问题时能省下大量时间。本质上我就是在Release和Debug两个build目录之间反复切换的工程做法可以参考一下。同步跟踪上游动态LLVM三个月一个大版本API变化极快。写博文或代码前到官网看一眼新版本release notes有时候你写的代码可能已经在最新版本里有新接口甚至会避免你用到被deprecated的老接口。说到底llvm-project是一个学不完的宝藏这一篇文章连冰山一角都算不上。但只要你把构建链打通、亲手写过一个能跑起来的Pass、沿着一条优化路径摸过一遍源码后面再学任何编译器相关的东西都会顺畅很多。我们前面踩过的这些坑——配置裁剪、Pass注册、Debug信息打开方式——都是公开文档里不会系统讲但早晚会踩的细节。把这些细节搞定你就已经跨过了新手村最难的一道门槛。