LLVM入门实践:从IR与Pass到构建自己的编译器工具链 聊到编译器、编程语言、工具链这几年有个名字怎么都绕不过去——llvm-project。无论你写 C 用的是 Clang还是搞 Rust 默认选 rustc又或者是在研究新一代语言怎么设计最终几乎都会撞进 LLVM 的生态圈里。很多初学者第一次看到 GitHub 上那个装了上百个目录、动辄几个 GB 的仓库会有点犯怵不知道从哪里下手也不太清楚它跟平时用的 GCC 到底差在哪。这里先说一个粗浅的定位LLVM 不是一个普通的编译器而是一整套造编译器的零件库。它把编译源代码这件事从前端、优化到后端完全分开每一段都能独立替换、独立复用。正因为这种设计它才能同时支撑 C/C、Rust、Swift、Julia 这些语言才能让 Apple、Google、ARM 这些厂商在它上面堆出自己的编译器方案。这篇文章我会从 llvm-project 仓库的组成讲起把 IR 和 Pass 这两个最核心的概念掰开揉碎再给你一条可以直接照着操作的上手路径包括怎么从源码构建、怎么写第一个 Pass、以及我在实际调试中踩过的坑。适合刚接触编译器、想动手读源码或准备开发语言后端的读者参考。1. LLVM 到底是什么——从编译器工具链到编译器工厂1.1 一个项目装下整个编译器世界先说点背景。LLVM 最早是 Chris Lattner 在伊利诺伊大学期间开始的项目名字曾经是 Low Level Virtual Machine 的缩写后来项目范围越扩越大LLVM 就变成了一个完整的名称不再特指虚拟机。当年这个项目拿来做的事情很简单设计一种适合做编译优化和目标代码生成的中间表达然后把各种前端语言翻译到这个中间表达上再由统一的后端生成机器码。现在你去看 llvm-project 这个仓库会发现它里面已经远不止一个编译器而是包含 LLVM core库和优化器、ClangC/C/Objective-C 前端、LLD链接器、libcC 标准库、compiler-rt运行时和 Sanitizer、MLIR、FlangFortran 前端等一大堆子项目。很多人第一次接触 LLVM会觉得它和 GCC 差不多都是把源码变成可执行文件的工具链。这个印象对了一半。GCC 整个项目以编译器为核心插件和组件都围绕这个目标而 LLVM 更像是一套编译基础设施它把编译流程拆成了几个可以独立使用、任意拼接的模块。这种拆法带来的直接好处是新语言不需要重新发明优化器和后端只要写一个能生成 LLVM IR 的前端就能立刻享受到成熟的优化、调试和跨平台代码生成能力。Rust 的 rustc、Swift、Zig、Julia 都是这么干的。1.2 为什么要研究这么一个庞然大物从我的实际经验看研究 LLVM 的理由分几类。如果你在做编程语言领域的工作LLVM 基本是绕不开的选项——写一个 toy language 的后端与其用纯汇编从零生成代码不如把 AST 降到 LLVM IR马上就获得寄存器分配、指令选择、各种优化和主流硬件支持。如果你在搞性能优化LLVM 也在几乎所有现代编译器的最底层理解了它的 Pass 工作方式你才能解释为什么某个循环被优化了、为什么某个函数没有被内联、为什么加了一个 flag 之后性能发生了不可思议的变化。甚至就算你只是想把自家项目里的 C 编译时间降下来、把链接速度提上去Clang 和 LLD 也值得认真研究。还有一个更深层面LLVM 的架构对软件工程也有相当大的启发。它把算法和数据分得很清楚优化器就是一个一个独立的 Pass彼此之间只通过 IR 交换信息新增一个优化不需要动其他部分。这种拆分思路对整个软件开发都是好教材。1.3 LLVM 的底层逻辑前端、中端、后端三层分离要理解 llvm-project先记住一个经典的三段式编译架构前端负责词法分析、语法分析、语义分析把源代码转成 AST再生成 LLVM IR。对应到仓库里的 Clang、Flang、以及各类语言前端。中端只跟 LLVM IR 打交道进行各种与目标机器无关的优化比如函数内联、循环展开、常量传播、死代码消除。这是 LLVM core 中 Pass 框架和优化器的主场。后端把优化后的 LLVM IR 转换成目标机的汇编或机器码包括指令选择、寄存器分配、指令调度等。对应 lib/Target 下面的 X86、AArch64、RISCV 等目录。这种分层的直接后果是新语言不需要重新做优化和后端新硬件也不需要等着某个语言单独适配。只要前端能正确生成 IR后端接受 IR 并生成目标码两端就可以自由组合。用一句大白话说LLVM 把编译器这个垂直行业变成了水平分工的组装产业。2. llvm-project 这个仓库里到底装了些什么2.1 仓库目录第一次看容易懵其实有规律很多新手 clone 下来 llvm-project 之后第一反应是这到底是什么llvm、clang、lld、libcxx、compiler-rt、mlir、polly、flang、openmp、lldb、pstl 等等十好几个顶层目录。一开始会觉得乱但用多了会发现这个布局是按职责划分的。我个人的建议是不要试图一次看完全部先抓住绝对核心的两三个子项目其他根据需求再看。最核心的两个是llvm和clang。llvm里是构成优化器和后端的核心库包括 IR 定义、Pass 框架、代码生成、目标描述、各种工具llvm-opt、llc、llvm-as 等。clang是 C 家族语言的前端负责把 C/C/Objective-C 变成 LLVM IR也负责我们平时用的clang编译命令。很多人把 clang 误当成 LLVM 的全部这不对clang 更像是 LLVM 的旗舰前端。2.2 Clang 和 LLVM core 的主角关系Clang 在 llvm-project 里的角色很关键因为它直接决定了普通用户对 LLVM 的体验。平时我们用clang -O2 main.c这样的命令隐含的流程是Clang 前端解析并生成 IR → LLVM Pass 优化该 IR → 后端生成目标机器码。这个流程里前端和优化/后端之间靠 IR 作为契约。为什么 LLVM 能同时支持那么多语言、那么多架构因为只要每个前端输出合法的 LLVM IR后面的优化和代码生成都是通用的。Clang 自身的亮点也不少编译错误提示友好很多场景比 GCC 更容易看懂内置了-fsanitize支持可以直接使用 AddressSanitizer、UndefinedBehaviorSanitizer 查内存和未定义行为问题配合libclang或clangd还能提供代码补全、跳转等 IDE 能力。我在做 C 项目时通常直接用 Clang 来编译用 ASAN 做测试期检查效果很稳。2.3 LLD、libc 和 compiler-rt 这些隐藏武器很多初学者只盯着 Clang 看其实 llvm-project 里几个不那么显眼的项目同样价值巨大。LLD是 LLVM 生态自带的链接器。它最大的优势是快。我做过一个游戏引擎项目用 GNU ld 链接耗时大约 40 多秒换成 LLD 之后压到了 10 秒以内在大型 C 项目上这个差距还会更夸张。用法很简单编译时给 clang 传-fuse-ldlld或者 CMake 里设置CMAKE_EXE_LINKER_FLAGS。LLD 能兼容 GNU ld 的绝大多数输入参数所以换过去基本无痛。libc是 LLVM 的 C 标准库实现。平时在 Linux 上我们用 GCC 自带的 libstdc 比较多但如果你想要一个更干净、模块化更好的实现或者在做跨平台 ABI 一致性测试libc 就是标准选项。对语言实现者和平台开发者来说libc 内部对std::string、std::vector的实现方式也值得一读。compiler-rt是编译器的运行时库。除了初始化代码它更出名的是 Sanitizer 家族AddressSanitizer (ASAN)、ThreadSanitizer (TSAN)、MemorySanitizer (MSAN) 等。在 llvm-project 编译时启用这些在开发阶段跑一轮测试可以非常高效地定位内存泄漏、越界访问和数据竞争问题。2.4 MLIR后起之秀的多层 IRMLIRMulti-Level Intermediate Representation是 llvm-project 里近年来非常活跃的子项目。它的核心理念是与其强迫所有前端都降到一层固定 IR不如让用户自由定义多级别 IR每层处理不同粒度的抽象。举个例子做深度学习编译器时你可能需要一个张量运算级别的高层 IR又要一个接近指令集的低层 IRMLIR 允许在这两者之间建立可考察的 lowering 阶梯。它是个非常有乐高感的框架。你定义自己的操作和类型写 Pass 做转换和下沉最终再通过已有的 lowering 机制降到 LLVM IR。这也解释了为什么近年来新的编译器项目包括很多芯片厂商的 AI 工具链都在 MLIR 上做文章。想深入编译器领域MLIR 值得持续关注但对零基础读者来说先掌握 LLVM IR 和 Pass再进入 MLIR 会顺滑很多。3. 初识 LLVM 最核心的设计中间表达 IR3.1 LLVM IR 到底长什么样前面反复提到 IR那它到底是什么样的LLVM IR 是符合SSA静态单赋值形式的强类型中间语言。SSA 的意思是每个变量只赋值一次每次引用都是这个版本的值。这种看似绕圈的限制反而让优化分析变得非常简便因为变量的定义和使用的数据流关系被写死在名字里了。IR 有三种等价形态。第一种是内存中的 C 对象就是Module、Function、BasicBlock、Instruction等类实例这是优化器运行时操作的形态。第二种是磁盘上的位码bitcode后缀是.bc常见于 LTO 等场景直接序列化内存结构。第三种是文本形式后缀是.ll人可以阅读和修改也用于测试和调试。三者可以在工具链中互换——llvm-as把文本转成 bitcodellvm-dis反过来。来一段经典的文本 IR 感受一下define i32 add(i32 %a, i32 %b) { entry: %sum add i32 %a, %b ret i32 %sum }这里define i32 add(i32 %a, i32 %b)定义了一个返回i32、接受两个i32参数的函数。%sum add i32 %a, %b表示把%a和%b相加并把结果绑定到 SSA 名%sum最后一个ret返回该值。注意代码里的entry是一个 basic block——基本块它和函数、指令一起构成了 IR 的层级结构。3.2 一条代码从源文件到机器码的完整旅程假设我们有这样一段 C 代码int add(int a, int b) { return a b; }当 clang 处理它时内部实际上经历了一条非常清晰的流水线词法分析器读入源文件得到 token语法分析器生成 AST语义分析器做类型检查和约束整理然后一棵宏大的 AST 被lower成上面那段 LLVM IR 文本。之后优化器跑一组 Pass尽量把 IR 变成更高效的等价形式最后后端把 IR 转成汇编和目标机器码。你可以自己跑一下这个过程。把源代码存成add.c然后执行clang -S -emit-llvm add.c -o add.ll-S -emit-llvm的意思是输出 LLVM IR 文本而不是汇编这是学习 LLVM 极有用的一个命令。再试一个优化参数的作用clang -S -emit-llvm -O2 add.c -o add.ll两相对比你会看到优化前后的 IR 差很多甚至有些局部计算会被直接折叠成一个常量返回。这个语义等价但表示更优的过程全靠 Pass 完成。3.3 什么是 Pass为什么它是 LLVM 的灵魂Pass 是 LLVM 优化和转换的基本执行单元。从字面上理解就是一遍又一遍地扫描 IR 并做某种操作。常见的 Pass 包括常量传播、死代码消除、循环展开、内联、向量化等等。每个 Pass 只负责一件事组合起来却能产生复杂强大的优化效果。Pass 通常分几类ModulePass 看整个模块FunctionPass 逐函数处理LoopPass 盯循环BasicBlockPass 处理单个基本块。更现代的写法还会用到带继承的PassInfoMixin和新的 PassManager。2019 年之后 LLVM 默认切到了新 Pass Manager老的 legacy PM 还在兼容期但新代码建议直接用新 Pass 框架。你可以用opt工具单独跑一个 Pass 来观察 IR 的变化。比如对刚才的add.ll做一次指令合并测试opt -passesinstcombine add.ll -S -o add_opt.ll-passes是新 PM 的参数形式后续写 Pass 也一样。这个模式非常适合学习阶段——一边改源码、跑 Pass、看 IR一边理解优化行为。4. 如何快速上手构建并使用 LLVM4.1 从零构建一个可用的 LLVM环境准备与 CMake 配置大致了解了原理我们来点实在的。自己构建一遍 llvm-project 很有必要但也有很多坑分享一下我常用的 最小可行配置。先准备依赖。Ubuntu/Debian 系sudo apt update sudo apt install -y cmake ninja-build git g然后拉取代码并建 build 目录git clone https://github.com/llvm/llvm-project.git cd llvm-project cmake -G Ninja -S llvm -B build \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86 \ -DLLVM_ENABLE_ASSERTIONSON这里有几个点要解释。-S llvm是指定源码根目录为仓库里的llvm因为 CMake 入口在这个子目录。-B build是构建目录。-DLLVM_ENABLE_PROJECTS决定要额外构建哪些子项目初学者一般带上clang和lld就够别贪多否则编译时间会以小时计。-DLLVM_TARGETS_TO_BUILD只保留你需要的目标架构能显著缩短构建时间。-DLLVM_ENABLE_ASSERTIONSON在开发调试时很有用它会打开各种内部断言很多 bug 会在断言里直接暴露。配置完成后开始构建N 后跟并行任务数建议别超过逻辑核数的一半ninja -C build -j8Release 模式下全量构建 llvmclang普通八核机器大概要三十分钟到一小时做好心理准备。期间可以做其他事但不要手贱去 kill 它更不要中途调整-j参数会话里改其实没问题我是说别反复 max-j 又 cancel容易把机器卡崩。4.2 写一个自己的第一个 LLVM Pass用代码理解 IR 操作构建完之后写一个宣称自己来过的 Pass 是最标准的入门路径。假设我们只想在优化编译时打一行日志给每个函数打印名字。建立一个独立目录HelloPass里面放hello.cpp#include llvm/IR/Function.h #include llvm/IR/Instructions.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 Hello : public FunctionPass { static char ID; Hello() : FunctionPass(ID) {} bool runOnFunction(Function F) override { errs() Hello: F.getName() \n; for (auto BB : F) { for (auto Inst : BB) { errs() Inst \n; } } return false; } }; } // namespace char Hello::ID 0; static RegisterPassHello X(hello, Hello World Pass, false, false);编译这个 Pass 要链接 LLVM 的库最方便的方式是直接用 llvm-project/build 下的工具。你在 build 目录里找到llvm-config然后在HelloPass目录里跑../llvm-project/build/bin/llvm-config --cxxflags --ldflags --libs把它输出的参数接到编译命令里或者写个简单的 Makefile。我常用的紧凑写法是CXXFLAGS$(shell ../llvm-project/build/bin/llvm-config --cxxflags) LDFLAGS$(shell ../llvm-project/build/bin/llvm-config --ldflags --libs --system-libs) g -shared -fPIC $${CXXFLAGS} hello.cpp -o HelloPass.so $${LDFLAGS}然后把开始的add.ll喂给 opt../llvm-project/build/bin/opt -load ./HelloPass.so -hello add.ll -S -o add_hello.ll如果看到 IR 里输出了一堆指令列表说明 Pass 已经能跑了。这是一个最最朴素的示例但你已经亲自完成了扩展编译器这个过程。后面可以改成真正的转换类 Pass在 IR 中插入指令、删除指令那会更有意思。4.3 构建和调试时最常见的坑自己构建 LLVM 会遇到几个经典问题。第一内存不够。链接阶段lld常常特别吃内存全量构建时机器很容易卡死。解决办法是并行链接设低一些CMake 配置时加-DLLVM_PARALLEL_LINK_JOBS2一般能保住一条命。第二磁盘空间。仓库加 build 目录动辄十几 GB确保空间充足。第三用错编译器。构建 LLVM 的编译器建议用 GCC 或 Clang 的高版本某些老版本编译器会导致 bootstrap 失败。第四不要把 build 目录放进仓库里否则 git status 会看到成千上万个变更容易误操作。调试方面有三个命令值得记住llvm-dis把 bitcode 转回文本opt -print-after-all输出每个 Pass 执行完之后的 IR 状态这对看优化效果极有价值llc -debug在后端代码生成阶段输出大量调试信息不过输出量巨大建议配合-debug-onlyisel指令选择模块这类细化开关使用。5. 深入一点参与 LLVM 社区的实践与心得5.1 源码阅读路线从 IR 到 Target有兴趣深入的话源码阅读也有一条比较顺的路线。建议从llvm/lib/IR开始这里面定义了Module、Function、BasicBlock、Instruction、Use、User这些核心数据结构。读的时候重点关注一个函数是怎么由基本块组成的指令和操作数之间是怎么通过Use/User互相链接的。理解这一点之后很多优化 Pass 的代码就不会那么可怕了。接着看llvm/lib/Transforms/Utils和llvm/lib/Transforms/Scalar比如 InstructionCombining、GVN、DeadCodeElimination这些都是很经典的算法和教科书理论可以对应上。之后可以进入llvm/lib/CodeGen看 IR 怎么变成 SelectionDAG、怎么做寄存器分配大概了解即可。等你对 IR 和 Pass 都有感觉了再去看llvm/lib/Target/X86或AArch64理解指令选择的 TableGen 描述和各种调度模型。TableGen 也是一个绕不开的点。LLVM 用扩展名为.td的 TableGen 文件描述指令集、寄存器、调用约定等目标信息再用llvm-tblgen生成 C 代码。第一次看会觉得它像一门晦涩的 DSL但其实只要带着这只是一张数据表用来在编译期自动产生重复代码的心态去读就会顺很多。这也是 LLVM 能把这么多新架构支持得又快又好的重要原因。5.2 给新人的几个建议从模仿到上手先说一条最重要的心得不要把 LLVM 当作普通的库去从头读到尾要带着任务啃源码。纯看代码很容易被绕晕而且进度极慢。我认识的高效入坑方式基本都是从 想实现某个优化 或 想给某个指令集加一个功能 反向倒推遇到什么查什么。不懂ConstantExpr就去lib/IR看定义不会写新 Pass 就去lib/Transforms找一个结构最接近的照着改。其次多运行opt和llvm-dis做实验用小的.ll文件试各种 Pass观察优化结果。这种实验式学习比看任何教程都扎实。第三关注llvm/docs/下的官方文档和教程特别是llvm/docs/Passes.rst、llvm/docs/CMake.rst很多疑问都能直接找到答案。第四LLVM 版本迭代很快网上很多旧博客的 API 写法已经过时了你查资料时一定要留意文章对应的版本遇到编译报错先看是不是 API 变了再做其他猜测。5.3 参与上游代码审查和社区的玩法当你做了一些实际修改想回报上游时可以了解下 LLVM 社区的参与方式。现在 LLVM 已经在 GitHub 上开源协作主要入口是 llvm-project 仓库和 LLVM Discourse 论坛。一般流程是在 GitHub 上 fork 仓库、创建分支、写代码和测试、推送、发起 pull request。社区会通过 GitHub 的 pull request 审查机制讨论代码。审查意见通常会非常细致风格上也比较严格新人对 格式 和 测试覆盖 的要求要有心理准备。几乎任何改动都需要配套测试。LLVM 的测试体系特别丰富llvm/test下各种.ll、.mir、.c文件都是用例。跑测试主要用llvm-litbuild/bin/llvm-lit -sv build/test-sv会输出每个用例的名字和结果帮助定位失败。第一次上手跑测试会有很多无关失败不用太慌经常是缺 target 或环境变量没设置好。你只跑自己相关目录的测试就行比如加了一个 Target Pass 就只跑llvm/test/CodeGen/...下相关的子目录避免浪费时间。6. 结尾一点长期体验与扩展方向走到这里你会发现 llvm-project 的视野可以延伸得很远做语言设计的人可能关注新前端做编译器优化的人会钻研 Pass 和 PGO/LTO做硬件的人会在 Target 和 TableGen 里描绘新指令集做 AI 工具链的人会泡在 MLIR 里搭张量和算子的抽象层。每个人拿起这个项目的入口不同但最终都会汇聚到 IR 和 Pass 这个稳定的底座上。最后分享一个小技巧。我在做任何 LLVM 相关实验时几乎都会习惯性打开opt -print-after-all然后观察每个 Pass 对 IR 做了什么。这个习惯帮我节省了大量定位问题的时间也让我对优化器的工作方式形成了很直观的体感。所有看起来很神奇的优化本质上都是一条条具体 IR 变换的叠加少了一步都走不到那一步。以后你被某个优化效果震惊到时不妨先跑一下这个命令看看它到底经历了什么会有不少收获。