LLVM深度实践:从IR到Pass开发的完整指南
提起编译器和底层工具链大多数人第一个想到的是 GCC但近几年如果说哪个项目真正改变了编译器生态的格局我会毫不犹豫指向 LLVM。你写的每一行 Swift 代码每个跑在 iPhone 上的 App包括 Rust 默认的编译后端背后都有 LLVM 的影子。这个项目最厉害的地方不在于它是一个“编译器”而在于它把传统编译器拆成了一堆可自由组合的模块让新语言、新芯片、新优化思路都可以站在同一个基础设施上生长。这篇文章我不打算给你念官方文档而是站在一个真正用它做开发、写 Pass、调试后端的人的角度把 llvm-project 这个仓库的结构、核心设计、构建方式、实操流程和踩坑经历一次讲清楚。适合刚接触 LLVM 想入门的同学也适合已经用 Clang 写代码但是想进一步理解 IR 和 Pass 机制的开发者。1. 先搞清楚 LLVM 到底是个什么项目1.1 它不是一个“编译器”而是一套编译器积木很多初学者会把“LLVM 就是编译 C/C 的编译器”挂在嘴边这个理解不能说全错但会把视野带窄。LLVM 的定位是 compiler infrastructure也就是编译器基础设施。它提供的是构建编译器所需要的各种标准化零件而不是一个绑定语言的成品工具。传统编译器比如 GCC 走的是“整体式”路线前端解析 C/C 语法中端做平台无关优化后端生成目标机器码这三块被缝在一个进程、一套内部数据结构里。好处是紧凑坏处是不容易复用你想给一种新语言做个编译器基本等于从头再来哪怕目标平台和优化逻辑都可以复用工程上也很难把 GCC 的内部逻辑单独抽出来。LLVM 走的是一条完全不同的路。它把整个编译过程拆成三段标准流程前端负责把源码转成中间表示 IR中端优化器只处理 IR后端再把 IR 转换成对应 CPU 架构的汇编和机器码。这三段之间通过一个定义良好的中间层解耦就像一家餐厅的前厅、中央厨房和出餐口各自只关心自己那一段交接靠标准托盘菜单怎么改、盘子换成什么样都不影响其他环节。这种设计最大的红利在于任何人想做一门新语言只需要写好前端把源码翻译成 LLVM IR后面所有优化、指令选择、寄存器分配、多架构支持就全部白嫖现成的。Rust、Swift、Julia 这些语言能快速铺开核心原因之一就是它们砍掉了“自己从零开发优化器和后端”这条最烧钱的路。1.2 三段式架构的革命性到底在哪儿要体会这个架构的价值你可以先看一张传统方案的对比。老方案里后端和优化器和特定语言深度绑定你想让 Python 和 Rust 共享同一套优化逻辑几乎做不到。而在 LLVM 的世界里m 种前端语言加 n 种目标架构工作量不是 m 乘以 n而是把主要成本变成“m 份前端”加“n 份后端”中间共享一个 IR 和优化层。LLVM IR 是这一切的核心枢纽。它有文本形式.ll、二进制位码形式.bc和内存里的 C 对象三种形态设计上强调三条原则类型系统严格、代码采用 SSA静态单赋值形式、每个基本块以终止指令结尾。简单说SSA 意味着每个变量只能被赋值一次这大大简化了数据流分析编译器想追踪一个值的来源时不需要漫山遍野找赋值点。围绕 IR 运行的优化单元叫 Pass每个 Pass 做一件事有的死代码消除有的循环展开有的公共子表达式合并。因为 Pass 只面向 IR所以它完全不懂 C/C 和 Swift 的区别同样的优化逻辑可以服务所有语言。这种“一次优化处处运行”的理念就是 LLVM 能席卷整个编译器行业的底层逻辑。2. 项目全貌llvm-project 仓库里到底装了些什么2.1 LLVM Core核心库与优化器你 git clone 下来的 llvm-project 是一个超大的 monorepo里面最底层、最核心的是 llvm 目录。从这个目录编译出来的产物包括核心库LLVMCore、LLVMIR、LLVMPasses、LLVMCodeGen 等全部以库的形式提供方便外部程序嵌入。命令行工具opt优化器llc后端代码生成llvm-as汇编器llvm-dis反汇编器llvm-link链接器llvm-config查询编译参数。日常工作中我最常用的组合是 clang 加 opt 加 llc。clang 把源码变成 IRopt 对 IR 跑优化llc 把 IR 变成目标平台的汇编。这三个工具串起来就能完整观察一段代码从高级语言到汇编的整个旅程。2.2 Clang把 C/C 翻译成 IR 的前端Clang 是 llvm-project 里名气最大的子项目之一它是 C/C/Objective-C 的编译器前端同时也在不断扩展对 OpenMP、CUDA 等的支持。相比 GCCClang 的编译速度和错误提示亲和度让我用一次就回不去了。例如你把一个变量名拼错Clang 不仅能指出行号列号还会就近给出候选名这对大项目调试简直是救命级的体验。Clang 模块化程度很高不只是拿来编程序还能拆开作静态分析。基于 Clang 的 clang-tidy、clang-analyzer 每天在全球无数 CI 流水线里跑代码检查。还有 clangd它给 VS Code、Vim 等编辑器提供语义补全和跳转体验相当顺滑。2.3 不只是编译LLD、libc、MLIR 与运行时库llvm-project 仓库里除了 LLVM 和 Clang还有一批重要的兄弟项目LLD一个高性能系统链接器。实测下来比 GNU ld 快出不少特别是在大规模 C 项目的链接阶段能明显感觉到时间缩短。libcLLVM 官方的 C 标准库实现。如果你用 Clang 和 libc 搭配新标准特性支持常常比 libstdc 更及时。compiler-rt提供各种运行时支持库最出名的包括 AddressSanitizer、MemorySanitizer、ThreadSanitizer 等工具在做内存错误检测和并发 bug 排查时非常好用。LLDB基于 LLVM 的调试器架构上和 LLVM 的底层库深度集成。MLIR多级中间表示框架是 LLVM 社区面向机器学习和自定义编译器场景推出的新基础层。TensorFlow、PyTorch 的一些底层编译器都有它的参与思路非常前卫。Flang社区正在大力开发的 Fortran 前端对老旧科学计算代码的现代化非常有用。整个项目生态已经膨胀成了一个完整的工具链宇宙不同子项目各司其职相互间通过 IR 和统一的接口协作。学习的时候如果只看 llvm 核心目录会觉得很抽象把这几个子项目串起来看才会明白 monorepo 的良苦用心。3. 从零开始源码构建与工具链实操3.1 获取代码克隆大仓库前的准备工作如果你打算从源码构建 llvm-project第一件事是得有耐心。仓库本身非常庞大完整 clone 下来可能占用好几个 GB 的磁盘空间。如果只是想用最新发布版我建议直接克隆并切换到 tag而不是每天追着 main 分支跑否则今天能编过的代码下周可能就因为 API 变动编不过了。实践里我一般这么做git clone --depth 1 --branch llvmorg-17.0.6 https://github.com/llvm/llvm-project.git加 --depth 1 是为了浅克隆只拉当前分支的最新快照能省不少网络流量和时间。如果你需要开发调试再单独拉历史也不迟。构建环境方面Linux 优先macOS 也可以但 Windows 的 MSVC 构建相对更折腾。内存建议至少 16GB磁盘至少留 80-100GBRelease 版完整编译一次动辄占用几十 GB。3.2 CMake 与 Ninja不踩坑的构建配置LLVM 官方推荐用 CMake 配置、Ninja 构建。Ninja 相比 make 更轻量并行调度更好编译多核机器上提速明显。我常用的配置命令是这样cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86逐项解释一下-S llvm 指定源码目录。注意这里指向的是 llvm 子目录不是仓库根目录。-B build 指定构建目录。-DCMAKE_BUILD_TYPERelease 编译优化版本运行速度快适合日常测试。Debug 版保留完整调试符号适合开发 Pass 和调试内部逻辑但体积大、运行慢。-DLLVM_ENABLE_PROJECTSclang;lld 决定除了核心 LLVM 之外还要构建哪些子项目。这里值用分号分隔整个参数在 shell 里要加引号否则会解析出问题。-DLLVM_TARGETS_TO_BUILDX86 只生成 X86 后端的机器码生成器。默认会生成一大批目标包括 ARM、AArch64、PowerPC 等构建时间翻倍。如果只是本机学习只保留宿主架构就够。配置完成后执行ninja -j8-j8 表示 8 路并行具体数值按 CPU 核数和内存酌情调整。16GB 内存、8 核 16 线程的机器我会用 -j8 到 -j12。内存不够的话并行度开太高会直接 OOM。如果你只想构建部分工具不用把整个项目都编完。比如我常常只编 clang、opt、llc 这三个目标ninja clang opt llc3.3 亲手跑一遍从 C 源码到 IR 再到机器码构建完成后我们用最经典的流程跑一遍直观体会编译过程。先写一个最简单的函数cat add.c EOF int add(int a, int b) { return a b; } EOF第一步用 clang 生成 LLVM IR 文本clang -S -emit-llvm add.c -o add.ll cat add.ll你会看到类似这样的输出define i32 add(i32 %a, i32 %b) { entry: %add add i32 %a, %b ret i32 %add }这就是高级语言落入 LLVM 世界的模样函数名、参数类型、基本块、指令全部结构化控制流和数据流一目了然。IR 里每个临时变量只被赋值一次这就是 SSA为后续优化打下基础。第二步用 opt 跑优化opt -S -O2 add.ll -o add.opt.ll cat add.opt.ll看完你会发现即便没有明显可删的代码优化器也会把指令顺序、表示方式调整得更接近机器习惯。真实项目的 IR 比这个复杂得多但观察小函数的 IR 变化是建立编译器直觉最快的路径。第三步用 llc 生成汇编llc add.ll -o add.s cat add.sX86 下大概率会得到几个 move、一个 add、一个 ret 指令整个链路就打通了。以后遇到任何“C 代码到底被编成了什么”的疑问都可以靠这三板斧自查。3.4 构建过程中常见的配置陷阱第一次构建 LLVM 的人很容易踩到几个坑把 -DLLVM_ENABLE_PROJECTS 和 -DLLVM_ENABLE_RUNTIMES 搞混。前者用来构建 clang、lld 这种与 LLVM 主仓库同级的项目后者用来构建 libc、compiler-rt 这种运行时库两者配置方式不同位置也别填错。选了 Debug 模式又抱怨性能差。Debug 版 LLVM 运行速度可能是 Release 的十倍差距求速度就老老实实用 Release。不指定 LLVM_TARGETS_TO_BUILD 导致编出全部后端。曾经有人没加这个参数构建时间直接翻了倍机器还差点挂掉。4. 理解 LLVM IRLLVM 的核心资产4.1 IR 的三种形态与基础语法LLVM IR 是整个项目的中枢神经它有三种等价的形态可读的文本形式.ll、二进制的 bitcode 形式.bc、内存中的 C 对象形式。三种形态可以相互转换llvm-as 把文本变成 bitcodellvm-dis 把 bitcode 变回文本opt 在内部处理时全是内存对象。IR 语法表面上像一种机器无关的汇编语言但有几件事特别重要强类型所有值都必须有类型包括 i3232 位整数、float、ptr指针、struct 等。类型是 IR 的最小公约数类型不一致的指令在构建时就被拒绝。SSA 形式一个变量只能赋值一次所以循环里需要反复修改的变量就必须用 phi 指令来合并多条路径的值新手很容易在 phi 这里卡住。基本块与终止指令一个基本块是一串顺序执行的指令块的最后一条必须是 terminator比如 ret、br跳转、switch 等。CFG控制流图的节点就是基本块。一段最简单 IR 的完整形态define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }函数定义必须以 define 开头参数占位符带 % 前缀局部变量也是 % 开头全局变量则用 前缀区分。这些细节看着琐碎但理解后读任何 .ll 文件都不会再害怕。4.2 优化级别对 IR 的影响一个可复现的实验上面 add 的例子太简单看不出优化差异。我换一个稍复杂的循环求累加和的例子int sum(int n) { int acc 0; for (int i 0; i n; i) acc i; return acc; }分别用 -O0 和 -O2 生成 IR差距会非常明显。-O0 下是一堆 load/store、branch、phi 指令堆在那里完全照着原始控制流翻译-O2 下优化器可能会把循环分析成一个等差数列公式甚至直接算出结果IR 瞬间变得极短。这个实验是理解编译器优化作用的最直观方式。很多程序员在 C/C 层面争论哪些写法更快、哪些优化编译器会做与其靠猜测不如直接把 .ll 文件打开看。编译器不会骗人IR 会告诉你它到底做了什么。4.3 IR 带来的杀手级能力LTO 与跨语言优化因为整个优化器都建立在 IR 之上所有语言只要翻译成 IR就可以跨模块、跨语言做链接时优化LTO。传统静态库在函数边界只能保留 ABI 规定的调用方式跨函数优化非常受限而 LLVM 的 LTO 能把所有 .o 文件里的 IR 汇合到一个大的“虚拟编译单元”让优化器穿透函数边界做内联、常量传播、死代码消除。跨语言优化也是这个特性的直接红利。比如混合 Rust 与 C 的项目只要两边都走 LLVM 后端最终 LTO 阶段就能跨语言协同优化这在 GCC 时代简直不可想象。经历过大厂超大型项目的读者应该懂链接时那几分钟的 LTO 优化往往能换来明显的性能提升IR 的价值在这个过程中体现得淋漓尽致。5. 实操写一个最简 LLVM Pass 并跑起来5.1 从零开始Pass 的基本结构与代码骨架写 Pass 是真正理解 LLVM 内部机制的必经之路。这里我以新版 Pass 管理器New Pass Manager为例这是 LLVM 17 及以后的主流动法。Pass 本质上是一段对 IR 做变换或分析的代码它通过继承不同基类决定作用粒度FunctionPass 以函数为单位ModulePass 以整个模块为单位。下面这个 Pass 的作用是遍历函数里的每条指令统计加法指令的数量#include llvm/IR/Function.h #include llvm/IR/InstrTypes.h #include llvm/IR/Instructions.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 CountAddPass : public PassInfoMixinCountAddPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { unsigned count 0; for (BasicBlock BB : F) { for (Instruction I : BB) { if (isaBinaryOperator(I)) { BinaryOperator *BO castBinaryOperator(I); if (BO-getOpcode() Instruction::Add) { count; } } } } errs() Function F.getName() has count add instructions\n; return PreservedAnalyses::all(); } }; } // namespace代码逻辑不难遍历 BasicBlock再遍历 Instruction用 isa 判断是不是二元运算符然后检查 opcode 是不是 Add。最后返回 PreservedAnalyses::all() 表示这个 Pass 没有修改任何 IR分析结果对所有分析都保持有效。5.2 注册 Pass让 opt 能加载你的插件写完 Pass 主体后需要注册它。新版 Pass 管理器推荐用 PassPlugin 机制动态加载 .so 文件不需要重新编译整个 opt。注册接口代码extern C ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK llvmGetPassPluginInfo() { return { LLVM_PLUGIN_API_VERSION, CountAddPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) - bool { if (Name count-add) { FPM.addPass(CountAddPass()); return true; } return false; }); }}; }这里的 count-add 就是在命令行传给 opt 的 pass 名字。要注意它和结构体名不同注册的是命令行标识。5.3 编译、加载与运行编译插件时最常踩的坑是 llvm-config 参数拼错。我用的命令是clang -shared -fPIC -fno-rtti \ CountAddPass.cpp -o libCountAddPass.so \ llvm-config --cxxflags --ldflags --libs有几个点务必注意-fno-rtti 一定要加。LLVM 内部默认关闭 C RTTI插件如果开启会导致类型信息对不上运行时行为诡异甚至崩溃。必须用与你的 opt 同一版本、同一构建模式的 llvm-config。Release 编译出的 LLVM 和 Debug 编译出的 opt插件混用很容易出问题。链接时如果报一堆 undefined reference检查一下 --libs 是否真的传进去了。运行插件opt -load-pass-plugin./libCountAddPass.so \ -passescount-add add.ll -S -o /dev/null如果一切正常输出里会打印 add.ll 中每个函数的 add 指令数。第一次看到自己的 Pass 跑起来成就感还是很强的。5.4 Pass 开发中的三个高频翻车点开发 Pass 时我反复踩过的坑有三个第一个是迭代器失效。如果在遍历 Instruction 的过程中删除了某条指令后续迭代器的行为是未定义的程序可能崩溃也可能半夜给你出不靠谱的结果。解决方法是先收集要删除的指令遍历结束后再统一删除或者通过递归删除工具处理。第二个是 PreservedAnalyses 的返回值乱填。如果你的 Pass 确实改了 IR却返回 PreservedAnalyses::all()相当于告诉分析管理器“所有分析都不受影响”那么后续引用旧分析结果的 Pass 就会拿到脏数据排查起来极其痛苦。第三个是插件与主程序版本不匹配。LLVM API 每个大版本都会变plugin 的 ABI 没有长期兼容承诺升级 LLVM 后老插件必须重新编译。这不是 bug是设计如此编译前务必检查版本。6. 常见问题与排查技巧实录到这一节我把这些年真实遇到的问题整理成一份速查表供大家作为 debug 的起点症状可能原因解决方法构建时内存不足ninja 中途退出并行任务太多内存被吃爆调低 -j比如 -j4链接阶段出现大量 undefined referencellvm-config 版本不对或链接参数不全严格使用与工具链匹配的 llvm-config检查 --ldflags、--libsopt 加载插件时报错提示 API 版本不一致插件与 opt 的 LLVM 版本不一致用同一份 llvm-project 编译插件clang 找不到 stdio.h 等系统头文件缺少系统头文件依赖包安装系统构建依赖Linux 下通常需要 libc6-dev、build-essentialRelease 构建速度太慢未限制目标架构且并行度不合理只保留宿主架构控制 -j 数值Pass 运行结果不稳定依赖旧分析数据PreservedAnalyses 返回错误严格按是否修改 IR 返回对应的 preserved 集合除了这张表再分享两个排查溯源的技巧。第一个是善用 opt 的 -debug-pass-manager 选项。新版 Pass 管理器执行时会递归打印每个 Pass 的调用顺序、参数和结果。当你怀疑“某个 Pass 跑没跑、在哪一步出错”的时候开这个选项能快速定位问题环节。第二个是给自己的 Pass 加日志输出用 errs() 而不是 std::cout。LLVM 内部输出流能保持和工具日志一致的缓冲行为和格式在插件模式下不容易丢失输出而且配合 -debug-onlyxxx 可以做到按模块开关日志比改代码重新编译高效得多。在大型真实项目中调试 IR 处理流水线我还会把多步中间结果输出成 .ll 文件然后在每一步之间做 diff。IR 变化一目了然比在内存里凭空推理靠谱得多。把项目基础优化流水线加“每跑完一个 Pass 保存一份 .ll”的临时修改实践效果极佳。7. 我的学习路径与给后来者的建议7.1 官方资料那么多怎么选LLVM 的中文资料稀缺但英文官方文档质量很高核心入口是 llvm.org 的文档区。如果你是新手我建议严格按照这个顺序看先看官方教程 “My First Language Frontend with LLVM”它用 Kaleidoscope 语言一步步带你写出一个完整体验的前端对整个编译链路的理解非常有帮助。再看 “LLVM Language Reference Manual”虽然又长又枯燥但这是 IR 语法的最终权威建议当字典查。最后是 “Writing an LLVM Pass”它带着你完整手写一个 Pass本文第 5 节的思路也来自这份资料看完后可以再对照原文加深印象。还有一条隐藏学习路径直接读源码。llvm-project 仓库里的 examples 目录和 unittest 目录是两块宝藏前者展示功能用法后者展示行为基线和边界情况很多疑问在 unittest 里能找到答案比在网上搜半天的博客强得多。7.2 我给后来者的四条行动建议第一不要一上来就追求把整个 LLVM 读透。LLVM 太大范围失控是新手最常见的问题。先给自己定一个小目标比如“把一段 C 代码用 clang 变成 IR把 IR 优化后变成汇编再把汇编翻译回 C”。这个目标很小但能覆盖一个完整的编译器链路。第二一定要动手写代码写 Pass 和其他代码不一样需要的是对着源码一层层扒接口。第 5 节那个统计 add 指令的 Pass建议亲手敲一遍不要复制粘贴。敲的过程会逼你去查头文件、确认函数签名这些琐碎步骤才是记忆最牢固的环节。第三在真实项目里找问题。LLVM 很适合“业务驱动学习”比如你想给 C 项目加一个定制的 warning 检查最直接的路径就是写一个 Clang plugin。这事本身能产出可交付的价值你在做的过程里也会自然掌握 AST 和编译前端的一堆概念。第四拥抱官方社区。LLVM 的开发者邮件列表和 Discourse 论坛很活跃每年 LLVM Developers Meeting 的视频都在 YouTube 公开里面全是顶级编译器工程师的实战分享。多看看这些内容你会发现自己现在踩的坑大佬们十年前就踩过了而且都有现成的最佳实践。我自己学习 LLVM 的过程前前后后试了很多土办法最后发现最笨的“CtrlF 翻源码”反而打下了最扎实的基础。编译器这种东西纸面理解不如亲手跑一遍、再亲手拆一遍你对它付出耐心它会回馈你极大的主动权——从那时起你就再也不用把优化行为当黑盒对待了写性能优化代码时心里会踏实很多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →