从LLVM架构到自定义Pass:深入编译器基础设施
发布时间:2026/9/18 18:25:00 锦皓数字建站

做编译器这些年我朋友圈里的朋友总爱问一句LLVM 到底是啥有人说是编译器有人说是 Clang 的底层有人说是“造轮子神器”。其实都对但都不完整。llvm-project绝不只是一款编译器它是一整套编译基础设施生态你能用它把 C/C 翻译成机器码也能用它写一个代码分析工具在程序里“找茬”还能用它做 JIT 动态编译让程序运行时现场生成机器指令甚至能拿它做软件渲染器在没有显卡的服务器上跑 OpenGL。这篇文章我想把它从底层架构到日常实操完整拆一遍适合刚准备入门的读者也适合那些想基于 LLVM 做点自己的分析工具、语言玩具、JIT 引擎或者单纯想搞懂“编译器到底是怎么工作的”的同行。1. llvm-project 在解决什么问题先聊一个很基础的疑问编译器的世界已经有 GCC 这种老牌王者了为什么 2000 年还有人要“重复造轮子”搞出个 LLVM1.1 传统编译器的痛点前后端强耦合传统编译器比如早期 GCC 就很有代表性把“听代码语言”“做优化”“生成机器码”三件事直接串在一起。前端只认识某种特定语言后端只服务于某类特定 CPU。今天你想支持一门新语言几乎等于重新写一轮解析、优化、代码生成明天你想适配一款新芯片又得从前到后重新过一遍。这种设计在“语言少、硬件更少”的年代够用但在移动芯片爆发、新语言不断冒出来的时代太累了。LLVM 给出的解法非常彻底把编译过程切成三大块——前端Frontend、中间表示IRIntermediate Representation和后端Backend。所有前端只负责把源码翻译成一份统一的“中间语言”所有优化都在这份中间语言上做所有后端只认这份中间语言把它翻译成不同 CPU 的机器码。这样设计之后新增一种语言就只需要写一个新的前端新增一种 CPU 架构就只需要写一个新的后端。前后端之间的接口稳定下来像乐高一样任意插拔。1.2 “中间人”IR 为什么这么关键这个拆分的核心在于那份“统一中间表示” LLVM IR。你可以把它理解成一种低层但是带类型的、可读的汇编语言。它既保留了程序的真实控制流和数据流信息又屏蔽了具体编程语言的语法差异和 CPU 的指令差异。正因为它“够低层又够结构”优化器能在这个层面做各种花式变换后端也能通过同一个 IR 生成不同指令。我经常给朋友打一个比方LLVM 这套架构像一个中央厨房的净菜配送中心。前端是各地风味的点菜员后端是不同菜系的掌勺厨师而 IR 是已经洗好、切好、按标准化规格打包的净菜。只要把净菜规格定好不管客人点的是川菜还是粤菜切配员只需处理原始食材一次大厨拿到统一的净菜就能下锅。这样做的好处是切配经验可以复用炒菜手艺也可以复用。1.3 它到底“管”了哪些事单看仓库名“llvm-project”你可能会以为它只是一个代码库。实际它是一个monorepo单仓多项目里面同时维护了 ClangC/C 前端、LLVM 核心库、LLD 链接器、libc、compiler-rt、LLVMPipe 软件渲染器等一大堆项目。以常见的 LLVM 15.0.7 版本为例这个版本已经是非常成熟的生产版本它支持 C/C 的新标准特性也集成了新版 pass 管理器在性能分析和优化方面表现稳定。很多团队至今还在用它做线上构建。这个生态覆盖的链路是完整的你写 C/C 代码Clang 把它变成 IRopt 优化器对 IR 做优化llc 把 IR 变成汇编或目标文件lld 再链接成最终可执行文件。同一套 IR 和优化器还能服务 Rust 语言的 rustc早期版本以及各种领域特定语言DSL。所以“llvm-project”这个名字听着朴素实际是一个能撑起整条编译链路的“全家桶”。2. 核心组件拆解从 Clang 到 LLVMPipe既然要实操先得把它拆开看懂。下面这几个组件是日常打交道最多的。2.1 ClangC/C 家族的锋线Clang 是 LLVM 官方的 C/C/Objective-C 前端。它不像 GCC 那样把前端和后端揉在一套代码里而是以 LLVM 库为基础专门负责“听”源码和报错。Clang 的报错信息很有名报错会精确到某个符号、某个类型不匹配的地方甚至给出修改建议。我自己在项目里体验下来第三方库头文件被引入错误时Clang 的诊断比很多编译器都定位更快。Clang 在设计上也被做成了库你想写一个代码重构插件、静态分析器不必去碰复杂的语法树细节可以直接调用 Clang 提供的 API也可以基于它的 AST抽象语法树做自定义检查。实际写代码时Clang 的很多子命令你大概率会用到比如clang -S -emit-llvm可以把 C 代码翻译成 .ll 格式的 IR 文本是学习 IR 最直接的入口。2.2 LLVM IR三种形态一个内核LLVM IR 有三种表现形式底层是同一份内容内存中的数据结构C 对象图编译器在运行时直接操作的形式Bitcode.bc文件二进制编码适合快速加载、存储文本形态.ll文件人类可读的汇编式代码。三种形式可以互相转换核心表达力完全一致。我建议新手直接读文本形式它看起来长这样define i32 add(i32 %a, i32 %b) { %r add i32 %a, %b ret i32 %r }这里的i32表示 32 位整数%a、%b是虚拟寄存器add做加法后把结果赋给%r。熟悉汇编的读者会觉得很亲切但比汇编多了类型系统并且所有值都遵守“静态单赋值SSA”规则——每个变量只被赋值一次。这个 SSA 设计非常关键它让优化器在分析数据流时获得了极大的确定性比如做常量传播时不需要追踪“这个变量在哪被改过”因为根本不会被乱改。2.3 opt 和优化 Pass真正的价值所在光有 IR 没什么用重点在于能对 IR 做“手术”。每个“手术”在 LLVM 里叫Pass优化过程。LLVM 把数百种优化封装成独立 pass有做内存提升的mem2reg有做指令合并和常量折叠的instcombine有做死代码消除的dce还有做内联的inline。优化可以串联成一个个 pipeline。比如opt -O2不是一个 pass而是一组 pass 的顺序执行。我自己在写新的分析 pass 时最喜欢用新版 pass 管理器New Pass Manager它的迭代接口更清晰写新分析时不必关心全局副作用。在 LLVM 15.0.7 里新版 pass 管理器已经是默认选项了不再需要额外的 flag 去开启。注意一个细节老的 pass 管理器里 pass 是“类”会注册到全局注册表新 pass 管理器里 pass 基本是按模块为单位跑的。写插件时要稍微留心新旧区分不要把旧接口硬搬到新环境否则很容易出现编译不过或运行时崩。2.4 后端与 LLVMPipe没有显卡也给你渲染后端负责把 IR 翻译成具体目标架构的指令。LLVM 支持的后端非常多x86、ARM、AArch64、RISC-V、PowerPC 都有。每个后端都有独立的目标描述文件、寄存器分配器、指令选择器、指令调度器。这里必须专门说说LLVMPipe因为它是 LLVM 基因里特别有意思的一个体现。LLVMPipe 是一个纯软件的光栅化渲染器Software Rasterizer它不依赖独立显卡而是用 LLVM 的 JIT 能力在运行时生成针对当前 CPU 优化过的汇编代码然后执行像素处理。LLVMPipe 可以支持 OpenGL 和 Vulkan在云服务器、虚拟机、没有 GPU 的开发板上非常有用。比如你在一台云主机上跑一个需要图形界面的测试程序显卡肯定不存在但通过 Mesa 里的 LLVMPipe程序依然能“渲染”出结果——只是计算全部由 CPU 完成性能不如独立显卡。LLVMPipe 在 x86 平台上经常会用到 256 位 SIMD 指令AVX/AVX2来加速像素处理这也是很多人在日志里看到“llvmpipe (LLVM 15.0.7, 256 bits)”这类信息的原因。想确认当前系统图形后端是不是 LLVMPipe可以跑glxinfo | grep OpenGL renderer如果返回llvmpipe (LLVM 15.0.7, 256 bits)说明程序正在用 CPU 软渲染。对这个信息不要惊慌很多 CI 环境、Docker 容器里没有 GPU这是最正常的降级方案。软件渲染的兼容性是无敌的只是性能上要有心理预期。3. 实操从零构建 llvm-project 15.0.7 并写一个自定义 Pass接下来进入实战环节。我会带你把 llvm-project 15.0.7 完整构建一遍再写一个简单的自定义 Pass输出所有指令icmp整数比较的调用次数并把条件恒真的比较替换成true。这个例子虽小但涵盖了基于 LLVM 做工具开发的基本路径。3.1 环境准备与源码拉取我以 Ubuntu 22.04 为例先安装基础依赖sudo apt update sudo apt install -y build-essential cmake ninja-build python3 git这里用 Ninja 而不是传统的 Make因为构建 LLVM 这种大型项目时Ninja 的并行调度更快而且增量编译更稳。磁盘空间要注意完整构建 llvm-project 的 build 目录通常会消耗 30GB 以上源码加工具链再加其他依赖最好准备 60GB 空余空间。拉取指定版本时建议直接切到 tag 上不要用 master 分支因为 master 处于快速迭代中会有很多兼容性变动git clone --depth 1 --branch llvmorg-15.0.7 https://github.com/llvm/llvm-project.git cd llvm-project如果你只需要默认后端llvm-project/llvm目录就放在这里后续 cmake 指向它即可。浅克隆可以省不少时间和流量但如果之后想看 git 历史里的重要改动就要完整克隆了看你的实际需要。3.2 CMake 配置与构建参数取舍LLVM 把构建抽象成了 CMake参数非常多理解几个关键的能帮你少走弯路。我使用的配置如下cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_BUILD_LLVM_DYLIBON \ -DLLVM_ENABLE_RTTION \ -DLLVM_ENABLE_EHON \ -DBUILD_SHARED_LIBSOFF \ -DCMAKE_INSTALL_PREFIX$HOME/llvm-15.0.7-install \ ../llvm逐个解释CMAKE_BUILD_TYPERelease编译优化后的 LLVM运行速度快适合平常使用。如果要调试 LLVM 本身就改成Debug但库体积和编译时间都会暴涨。LLVM_ENABLE_PROJECTSclang;lld除了 LLVM 核心库还构建 Clang 和 LLD 链接器两个都是日常非常常用的。暂时不需要 compiler-rt因为它主要服务于 sanitizer、性能剖析和运行时支持有需要再加不迟。LLVM_TARGETS_TO_BUILDX86只生成 x86 后端的代码能显著减少编译时间。如果你还需要 ARM 交叉编译再加ARM或AArch64。LLVM_BUILD_LLVM_DYLIBON把 LLVM 核心库合成一个大的动态库。这样你写插件时链接更容易加载也方便缺点是没法对单个库做裁剪。LLVM_ENABLE_RTTI和LLVM_ENABLE_EH默认 LLVM 可能关掉 RTTI 和异常但很多基于 LLVM 的第三方项目会需要它们我默认打开省得到时候别人代码编译不过。BUILD_SHARED_LIBSOFF这里关闭所有组件单独打成动态库避免几十个小 .so 文件搅乱部署。和上面并不冲突核心大库仍然是动态的。执行构建cmake --build . -j$(nproc)第一次构建耗时比较长16 核机器跑 x86-only Release 大概需要 10~30 分钟如果是全目标平台 Debug数小时都很正常。建议不要贪多按需裁剪目标平台。构建完成后安装cmake --install .把安装路径加入 PATHexport PATH$HOME/llvm-15.0.7-install/bin:$PATH export LLVM_DIR$HOME/llvm-15.0.7-install/lib/cmake/llvm3.3 生成并阅读一份 .ll IR先写一个最简单的 C 文件int gcd(int a, int b) { while (b ! 0) { int t b; b a % b; a t; } return a; }用 Clang 生成 IRclang -S -emit-llvm gcd.c -o gcd.ll cat gcd.ll你会看到类似这种结构define i32 gcd(i32 %a, i32 %b) { entry: br label %while.cond while.cond: %b.addr.0 phi i32 [ %b, %entry ], [ %rem, %while.body ] %a.addr.0 phi i32 [ %a, %entry ], [ %t, %while.body ] %cmp icmp ne i32 %b.addr.0, 0 br i1 %cmp, label %while.body, label %while.end while.body: %rem srem i32 %a.addr.0, %b.addr.0 %t getelementptr inbounds i32, i32* %a.addr.0, i32 0 store i32 %b.addr.0, i32* %t, align 4 br label %while.cond while.end: ret i32 %a.addr.0 }看到phi节点别慌这只是 SSA 形式里合并来自不同分支值的标准表示。守着“每个变量只赋值一次”这个规则去读你会慢慢习惯。icmp ne就是在比较b.addr.0和0这是优化器能做非常多后续工作的基础信息。3.4 写一个真正能跑的 Custom Pass为了少碰烦人的构建系统配置我直接用一个独立的 pass 插件方式来做。新建目录和文件mkdir mypass cd mypass touch CMakeLists.txt MyPass.cppCMakeLists.txt内容cmake_minimum_required(VERSION 3.20) project(MyPass) find_package(LLVM REQUIRED CONFIG) message(STATUS Found LLVM ${LLVM_PACKAGE_VERSION}) add_library(MyPass MODULE MyPass.cpp) target_link_libraries(MyPass PRIVATE LLVM)find_package(LLVM REQUIRED CONFIG)会去读取我们设置的LLVM_DIR变量。如果它没找到你可以检查一下echo $LLVM_DIR是否指向安装目录的lib/cmake/llvm。MyPass.cpp内容新版 Pass 管理器写法#include llvm/IR/Function.h #include llvm/IR/IRBuilder.h #include llvm/IR/InstIterator.h #include llvm/IR/InstrTypes.h #include llvm/IR/Instructions.h #include llvm/IR/LegacyPassManager.h #include llvm/Pass.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Transforms/IPO/PassManagerBuilder.h using namespace llvm; namespace { class ReplaceICmpWithTruePass : public PassInfoMixinReplaceICmpWithTruePass { public: PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { int icmpCount 0; for (auto BB : F) { for (auto I : BB) { if (auto *ICmp dyn_castICmpInst(I)) { icmpCount; ICmp-replaceAllUsesWith(ConstantInt::getTrue(ICmp-getType())); ICmp-eraseFromParent(); // 注意因为边遍历边删继续遍历会有风险。 // 这里简化处理实际建议先收集所有比较指令再统一处理。 } } } errs() [MyPass] Replace const ICmp - true in F.getName() , removed icmpCount icmp\n; return PreservedAnalyses::none(); } }; } // namespace这里我犯了一个“简化错误”在遍历BasicBlock的同时删除指令会导致迭代器失效实际生产代码必须先把比较指令收集到SmallVector遍历完再统一替换。写插件时永远不要在迭代器活跃期间删除容器内的元素这是新手最容易踩的坑。修正后把待删除的指令先放到 vectorSmallVectorICmpInst *, 4 ICmps; for (auto BB : F) for (auto I : BB) if (auto *ICmp dyn_castICmpInst(I)) ICmps.push_back(ICmp); for (auto *ICmp : ICmps) { ICmp-replaceAllUsesWith(ConstantInt::getTrue(ICmp-getType())); ICmp-eraseFromParent(); }接着写插件入口extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, ReplaceICmpWithTruePass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name replace-icmp-with-true) { FPM.addPass(ReplaceICmpWithTruePass()); return true; } return false; }); }}; }这样在 opt 命令行里就可以用--passesreplace-icmp-with-true来运行它。构建插件cmake -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_DIR$HOME/llvm-15.0.7-install/lib/cmake/llvm \ -DCMAKE_CXX_STANDARD17 \ . ninja如果构建时出现“找不到 LLVMConfig.cmake”说明LLVM_DIR没生效如果出现一堆模板报错大概率是 LLVM 版本和你用的 C 标准不匹配。每个 LLVM 版本编译所需的 C 标准不同15 系列至少需要 C17。运行opt --load-pass-plugin./MyPass.so --passesreplace-icmp-with-true gcd.ll -o gcd_new.bc opt -S gcd_new.bc -o gcd_new.ll cat gcd_new.ll如果一切正常你会发现输出的 IR 里icmp ne被替换成了 truebr指令的条件变成了i1 true。这只是一个玩具 Pass但它说明白了一件事你可以用几十行 C 深度修改一段中间代码。很多静态分析工具就是这么搭起来的。3.5 构建过程常见性能瓶颈源码构建时最常卡住的是 ParallelLink多个目标文件链接会让内存吃紧。建议链接大目标时用ninja -j2而不是-j$(nproc)或者用lld作为系统链接器速度提升明显sudo update-alternatives --install /usr/bin/ld ld /usr/bin/ld.lld 50使用 LLD 做系统链接器后LLVM 链接阶段的速度可以快数倍内存占用也更收敛。4. 常见问题与排查技巧实录这部分我直接按“踩坑记录”的方式整理内容来自我自己在多个版本的 LLVM 上实操时遇到的问题。4.1 问题速查表问题现象可能原因解决思路cmake找不到 LLVM 配置LLVM_DIR未设置或路径错误export LLVM_DIR$HOME/llvm-15.0.7-install/lib/cmake/llvm再删掉 build 缓存重新 cmake构建中途 OOM并行任务太多、Debug 构建内存开销大降低-j、换成Release、用 LLD 链接、限制LLVM_TARGETS_TO_BUILD插件opt加载报版本不匹配插件用不同 LLVM 版本编译所有 cmake、编译过程必须统一指向 15.0.7 的安装路径IR 查看时不可读直接看了.bc二进制文件opt -S转换或者用llvm-dis file.bc -o file.ll链接时报大量未定义符号LLVM_BUILD_LLVM_DYLIB为 OFF 但插件依赖核心库链接到libLLVM.so或target_link_libraries(MyPass PRIVATE LLVM)运行 pass 后结果没变可能 pass 被跳过或没使用新版 pass 管理器语法查看opt -debug-pass-manager确认 pass 是否进入 pipelinellvmpipe渲染极慢没有 GPU 或驱动未装CPU 软渲染性能有限排查时加大显存虚拟内存实际不能。只能调整需求或换带 GPU 的机器4.2 关于“llvmpipe 256 bits”的疑惑老有人问为什么日志里llvmpipe显示 “256 bits”是不是说它只能用 256 位色彩不是。这里的 256 bits 指 LLVMPipe 为当前 CPU 生成的 SIMD 向量宽度信息。LLVMPipe 会检测处理器的 AVX/AVX 2 支持情况生成 256 位宽的向量指令来并行处理像素数据如果 CPU 支持 512 位比如部分服务器 CPU 启用 AVX-512它也可能显示 512 bits。所以看到 “256 bits” 很正常说明你的 CPU 支持 AVX2LLVMPipe 正充分利用 SIMD 加速。另外在无 GPU 的容器里跑图形测试出现 LLVMPipe 并不等于“程序坏了”它只是一种软件后备方案。遇到和渲染相关的问题时先确认是不是走了 LLVMPipe再判断性能问题还是功能问题。4.3 调试 Pass 的实用技巧新版 pass 管理器提供了一些很棒的调试选项opt -S -debug-pass-manager --passesdefaultO2 gcd.ll -o /dev/null这个命令会输出 O2 优化里实际执行的每个 pass 顺序。写自己的 pass 时我会挂一个--debug-onlyloop-vectorize之类的参数让 pass 内部的调试信息打到终端比自己打errs()快得多。如果 pass 崩溃可以用-print-after-all带上崩溃前的 IR 快照能精确定位是哪一步破坏了 IR 结构。4.4 谨慎对待“内存占用”和“构建时间”构建 llvm-project 最容易劝退新手的就是编译时间。我见过有人在 2 核 4G 的云主机上跑全量 Debug 全后端构建结果跑了一整天还没完。建议条件有限时只构建 X86 后端只构建 Release不启用LLVM_ENABLE_PROJECTSall用 ccache 缓存对象文件二次构建会快很多。我自己的习惯是先按最小配置跑通再按需增量增加组件这样即使出问题定位半径也小得多。5. 后续还能往哪个方向扩展写完第一个 Pass 之后整个世界的边界就已经打开了。你可以把 Pass 扩展成更强的分析器统计每个函数的循环次数、识别重复 Load 模式、伪造一个“死循环”检测器甚至把 LLVM IR 变换成你自己的 DSL 字节码。我个人比较推荐的路线是先读一些生产级的 Pass 源码比如InstCombine、GVN学习它们如何处理边界指令、如何保留PreservedAnalyses然后动手写一个基于FunctionAnalysisManager的自定义分析之后再试着用PassBuilder把自定义 pass 接入 O2 优化管线让它自动参与标准优化流程。另外Clang 的 LibTooling 也值得研究。它能帮你写跨文件的代码重构工具可以批量改工程里所有函数签名这在大型代码库迁移里非常实用。LLVM 的生态已经成熟到“你缺的不是能力而是想象力的边界”这个程度了。我在实际项目里最深的一个体会是LLVM 把“编译器”这门高门槛手艺硬生生做成了“可以拼装的工程项目”。它不会替你思考程序的语义但给了你足够精确的“手术刀”。刚开始觉得 IR 复杂、Pass 接口繁琐多写两三个工具之后你会逐渐感受到这种架构的优雅——所有语言共享一套优化链路所有后端共享一套分析结果这种复用带来的力量是可以重塑计算机软件生产方式的。最后留一个小问题给你练手如果你想把所有add指令都改成sub并让程序依然能通过测试需要在哪里拦截、怎么保证不改变数据流动手试一试比读十篇文档都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。