LLVM项目拆解:从IR、Pass到后端,带你读懂编译器工厂

发布时间:2026/9/20 4:36:32
LLVM项目拆解:从IR、Pass到后端,带你读懂编译器工厂
说实话我最初拿到llvm-project这个项目标题的时候第一反应不是“这是那个编译器”而是“这是一座宝山但也是一座让人迷路的山”。很多朋友在 GitHub 上看到这个仓库时第一反应通常是哇70 多个仓库的 monorepo几十个 G 的代码我到底该从哪里看起甚至很多人刚把 CMake 跑完就已经劝退了。我一直觉得LLVM 项目最迷人的地方不在于它能编译 C/C/Rust/Swift而在于它把“编译器”这件原本只有少数人能碰的工程做成了可以随你拆解、组合、插件的积木系统。你可以不写编译器但只要你见过 LLVM IR 长什么样、跑过一次自定义 Pass你对代码的理解层级就会不一样。这篇文章不是教科书式的源码解读而是基于我实际啃代码、踩坑、跑实验的经验整理。我会把 LLVM 项目的整体架构、关键模块、构建方法、Pass 编写实操、后端扩展思路和常见坑都过一遍。如果你想让自己的技术视野里多一块“编译器世界观”的拼图这篇文章应该能帮你在 llvm-project 里找到方向而不是被它吓跑。1. 项目到底在解决什么问题1.1 一个编译器为什么值得花几年时间研究传统编译器一般是一条单行道源码进来词法分析、语法分析、语义分析生成中间表示然后一路翻译到目标机器的汇编。这套流程当然能工作但它有一个致命的问题每支持一门新语言就要把整条链路重写一次每支持一种新 CPU 架构又要重写一遍后端。llvm-project 的核心思想其实是一句很朴素的话把编译器拆成两半中间用一套稳定的“接口”隔开。前端的任务是任何语言都能做的那部分——把源码变成中间表示后端的任务是任何 CPU 都需要的那部分——把中间表示变成机器码。中间的接口就是 LLVM IRIntermediate Representation中间表示。这个设计有点像 USB 接口你把相机、键盘、手机插到同一个口上不需要为每个设备准备专属插座。前端开发者只需要对着 IR 生成代码后端开发者只需要消费 IR两边不需要知道对方的细节。现在社区里有 Clang 支持 C/C/Objective-C有 Rust 的 rustc 直接使用 LLVM有 Swift 编译器还有 Julia、Zig 等多种语言而后端却可以共享同一套 X86、ARM、RISC-V、NVPTX 等支持。有意思的是这个架构不仅让编译器变得模块化也带火了一个新的方向静态分析与代码生成。因为 LLVM IR 足够规范你不一定非要写一个完整编译器才能用它。你可以写一个分析 Pass只读不改检查代码里的空指针风险你可以写一个优化 Pass对 IR 做变换让循环更快你甚至可以写一个自定义后端把 IR 翻译到你自研芯片的指令上。所有这些都是 llvm-project 直接支持的玩法。1.2 这套代码仓库到底有什么第一次打开 llvm-project 仓库的人通常会被根目录下的文件夹吓到。我来给你列一个最通俗的“导航地图”避免你在里面迷路llvm/LLVM 的核心代码库包括 IR 定义、Pass 框架、优化器、目标无关代码生成等。这是整个项目的发动机。clang/C/C/Objective-C 前端负责把源码翻译成 LLVM IR。clang-tools-extra/基于 Clang 的辅助工具比如 clang-tidy、clangd、include-what-you-use。lld/内置的链接器和 LLVM 生态深度配合链接速度非常快。lldb/调试器和 LLVM 生态共用底层数据结构。mlir/多层中间表示框架适合做编译器之外的 DSL 编译、机器学习模型编译等。flang/Fortran 前端polly/基于多面体模型的循环优化compiler-rt/运行时库libc/、libunwind/等属于更底层的系统库。test/与utils/全项目级别的测试基础设施和辅助脚本。这给我的一个感觉是llvm-project 其实不是“一个编译器”而是一整条“编译器工厂流水线”。你可以只取其中一段来用也可以全部装起来构建一个完整工具链。理解这个分层结构以后再面对代码就不会有恐惧感了。1.3 为什么现在值得认真学一次 LLVM近几年编译器方向热度一直在涨原因不在编译器本身而在它变成了许多上层创新的基石。比如新的编程语言要落地几乎都要考虑用 LLVM 做后端硬件公司要推新的 AI 芯片第一件事是给 LLVM 加一个新的 Target数据库、深度学习框架也开始做“查询编译”“算子编译”底层用的同样是 LLVM。另一个推动力是 MLIR。它把 LLVM 的“一套 IR”扩展成“可以叠加的多层 IR”让你能在较高层次的抽象和底层机器指令之间自由穿梭。现在很多 AI 编译器项目都用它。而 MLIR 的代码就在 llvm-project 仓库里所以直接读这个仓库等于同时理解了传统编译器和现代多层编译框架。从个人成长角度看LLVM 是一个极好的“源码阅读教材”。它的代码质量非常高模块边界清晰注释和测试都非常完善。只要你有一点 C 基础就能从中学会大型工程的组织方式、数据结构的精妙用法、如何写高质量的单元测试。哪怕你以后不写编译器从里面学到的工程能力也绝对值得回票价。2. 把环境搭起来构建与工具链准备2.1 我不要自动化脚本我想自己掌控构建参数很多教程会教你直接用一个setup-llvm.sh之类的脚本一键安装但我想说的是自己动手跑一次 CMake踩过一两个坑你对这个项目的掌控感会完全不同。LLVM 的构建使用的是 CMake默认支持 Ninja 和 Makefile 两套生成器我个人强烈建议用 Ninja因为它在并行构建、输出可读性方面都更舒服。我常用的构建命令大概长这样git clone --depth 1 https://github.com/llvm/llvm-project.git cd llvm-project cmake -S llvm -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld;clang-tools-extra \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_ENABLE_ASSERTIONSON ninja -C build这里有几个参数我需要拆开解释一下因为它们直接影响你的体验-DLLVM_ENABLE_PROJECTS这是你选择要构建哪些子项目的开关。如果你只需要核心 LLVM 库和 opt 工具可以留空但我建议至少加上clang因为看 IR 最好的方式就是clang -emit-llvm。-DLLVM_TARGETS_TO_BUILD控制要支持哪些后端目标架构。如果都用默认值它会尝试构建所有目标耗时极长。我一般只保留当前机器架构和你感兴趣的实验架构比如 X86、AArch64、RISCV。-DLLVM_ENABLE_ASSERTIONSON这个太关键了。断言开启后很多不合理操作会在运行时被assert拦住而不是悄悄产生错误结果。做开发时一定要开。-DCMAKE_BUILD_TYPERelease如果你只想要一个能用的工具链用 Release。如果你还要单步调试 LLVM 源码自己建议用RelWithDebInfo它既有优化又保留调试信息长度也在可接受范围内。我第一次构建时只勾了 X86 目标的 Release 版本机器是 16 核大概 15 分钟就完成了。但如果你不加筛选直接构建全部后端时间可能会到一两个小时甚至更久占用的磁盘空间也很大。建议第一次先最小化配置跑通以后再按需扩展。2.2 构建时最常踩的几个环境坑llvm-project 编译时对工具链版本有要求。比如 C 编译器需要支持 C17 或更新标准较老的 GCC 版本会在编译过程中直接报错。如果你用的是 Ubuntu 20.04 默认的 GCC 9一部分模块还能用但新版 LLVM 可能会要求 GCC 更高版本建议直接安装 GCC 12 或 13或者换用 Clang 编译 LLVM 本身。现在很多 LLVM 开发者就是“用 Clang 编译 Clang”的这叫自举self-hosting体验很好。另一个高发问题是内存不足。链接clang或lld的时候峰值内存可能到 8GB 甚至更高。如果你用的是 8GB 内存的机器链接时可能直接被 OOM。我的建议是减少并行任务数用ninja -j 4或更低的数值来减轻压力同时关闭核心调试符号比如加-DLLVM_INCLUDE_EXAMPLESOFF -DLLVM_INCLUDE_TESTSOFF能省不少内存和时间。构建完成后记得把 build/bin 加到 PATH 环境变量里。这里面的工具会让你相见恨晚clang、opt、llc、llvm-dis、lli、llvm-config、FileCheck。其中llvm-config是一个很有用的配置查询工具写 CMake 插件时经常用到它。2.3 第一个实验亲手“看”一次 IR而不只是听概念工具链搭好之后我建议你从一个小实验开始感受 LLVM 的中间表示到底长什么样。写一个非常简单的 C 文件// sum.c int sum(int a, int b) { return a b; }用 Clang 生成 LLVM IRclang -S -emit-llvm sum.c -o sum.ll此时sum.ll里会出现类似这样的 IRdefine i32 sum(i32 %a, i32 %b) { entry: %add add nsw i32 %a, %b ret i32 %add }说实话我第一次看到这段 IR 时眼前一亮它和汇编有些相似但又保留了类型信息i32保留了变量名用的是静态单赋值SSA形式每个变量只被赋值一次。这种设计让分析和变换变得非常规整。再进一步你可以用优化器看它的变化opt -S -passesinstcombine sum.ll -o sum.opt.ll cat sum.opt.ll当函数逻辑足够简单时你可能会看到代码被简化为更直接的add指令。这就是 LLVM 优化器在做的事情在 IR 层吃掉你代码里冗余的部分把复杂的写法变成简单高效的序列。从这一刻起你不再把“优化”当成一个黑盒而是能看到它每一步到底做了什么。3. 拆开看源码IR、Pass 框架与 TableGen3.1 LLVM IR 你只需要理解三件事如果你没有时间读完整个代码库那至少要理解 LLVM IR 的三大核心概念模块Module、函数Function和基本块BasicBlock。这就像理解一篇文章的结构Module 是整个.ll文件里面有多个全局变量和函数Function 是你写的一个函数由若干 BasicBlock 组成BasicBlock 是一段“顺序执行”的指令序列通常以一个跳转或返回指令结束。IR 指令的类型系统非常丰富。整数类型叫i32浮点类型包括float、double指针类型上用ptr。所有的临时变量都用数字或名字表示且遵循 SSA 形式每个变量定义一次使用时不修改。这让数据流分析变得非常简单很多东西只需要“沿着 def-use 链走一遍”就能拿到。有一种学习方式是写 IR 而不是看 IR。你可以手动创建一个.ll文件甚至直接用 Python 脚本生成一段 IR然后交给opt处理。当你能亲手写出一段不会有歧义的类型标注、控制流完整的 IR 时你对编译器模型的掌握就扎实了。3.2 动手写一个自定义 Pass从 opt 插件开始很多人听说 LLVM 能写 Pass 就兴奋但一看到 New Pass Manager 的代码就懵了。这里我给你一条比“从零改源码”平滑得多的路径先写一个动态库插件用opt直接加载。这样你甚至不需要重新编译整个 LLVM只需要写一个 C 文件编译成.so再用opt -load-pass-plugin加载即可。一个最小可运行的 Function Pass 长这样#include llvm/IR/Function.h #include llvm/IR/Instructions.h #include llvm/IR/LegacyPassManager.h #include llvm/Passes/PassBuilder.h #include llvm/Passes/PassPlugin.h #include llvm/Support/raw_ostream.h using namespace llvm; namespace { struct MyFirstPass : public PassInfoMixinMyFirstPass { PreservedAnalyses run(Function F, FunctionAnalysisManager AM) { for (BasicBlock BB : F) { for (Instruction I : BB) { if (auto *Add dyn_castBinaryOperator(I)) { if (Add-getOpcode() Instruction::Add) { errs() Found add: *Add \n; } } } } return PreservedAnalyses::all(); } }; } // namespace extern C ::llvm::PassPluginLibraryInfo llvmGetPassPluginInfo() { return {LLVM_PLUGIN_API_VERSION, MyFirstPass, LLVM_VERSION_STRING, [](PassBuilder PB) { PB.registerPipelineParsingCallback( [](StringRef Name, FunctionPassManager FPM, ArrayRefPassBuilder::PipelineElement) { if (Name my-first-pass) { FPM.addPass(MyFirstPass()); return true; } return false; }); }}; }这段代码有两个关键点值得你注意。第一dyn_castBinaryOperator是向下转型它安全地区分出“是不是二元运算指令”这是 LLVM 代码里最常见的类型检查方式之一。第二llvmGetPassPluginInfo是插件和opt之间约定的“接口函数”相当于你把 Pass 注册给 PassBuilder 的入口。编译方式也有固定套路用llvm-config --cxxflags --ldflags --libs帮你拼出编译和链接参数。通常我写成clang -fPIC -shared mypass.cpp $(llvm-config --cxxflags --ldflags --libs) -o mypass.so然后在你之前的sum.ll上测试opt -load-pass-plugin./mypass.so -passesmy-first-pass -disable-output sum.ll如果一切正常你会在终端里看到每个add指令的位置和输出。这一步跑通后你就不再是旁观者而是 LLVM 的“插件开发者”了。后面可以尝试修改 IR比如把特定模式的加法替换成乘法、把无用的load删除或者编写一条全局分析 Pass统计整个模块的函数数量。3.3 不要被 TableGen 吓到它其实是代码生成器LLVM 源码里有大量.td文件这些文件用 TableGen 语言编写。桌面端用户初见时会觉得“这又是什么 DSL”其实它的作用很朴素用声明式的方式描述指令集、寄存器、调用约定然后通过 TableGen 工具自动生成 C 代码。这就避免了手写大量重复且容易出错的枚举、模式匹配和汇编解析代码。以 X86 后端为例llvm/lib/Target/X86/X86.td里定义了各种指令的助记符、操作数类型、编码。你改动一个.td文件重新构建后生成的代码就会同步变化。对于想入门后端开发的朋友TableGen 是一个必须正视的入口。它看起来语法怪异但逻辑非常简单用 class 定义模板用 def 实例化具体指令用 multiclass 处理一组相关指令。我第一次在 TableGen 里加一个“伪指令”时以为需要改一堆 C 文件结果发现只要在.td里仿照已有指令写一条记录然后在指令选择表里引用它剩下的工作就是看llvm-tblgen自动生成的代码。这种“声明式表达意图工具链生成实现”的模式对理解现代大型 C 项目非常有启发。3.4 在庞杂代码库中快速定位的学习路径如果你不想一开始就把所有文件都读完我建议采用“目标驱动”的学习方式。先给自己定一个任务然后顺着任务找代码。比如想理解函数调用约定搜索CCAssignFn、LowerFormalArguments。想理解指令选择搜索SelectionDAGISel、match、InstructionSelector。想理解优化管线打开llvm/lib/Passes/PassBuilder.cpp找到buildFunctionSimplificationPipeline。想理解 IR 解析和打印看llvm/lib/AsmParser和llvm/lib/IR/AsmWriter.cpp。我发现用这种方式阅读效率远超“从第一个文件读到最后一个文件”。LLVM 代码虽然多但它的注释和函数命名都非常规范你只要知道自己在找什么很快就能顺藤摸瓜。4. 深入编译器后端从 IR 到机器码的过程4.1 设置好目标为什么需要 SelectionDAGIR 是平台无关的但我们最终要在某个具体 CPU 上执行。后端要解决的问题就是把抽象的add i32 %a, %b翻译成特定架构的机器指令比如 x86 的addl %eax, 4(%rsp)。这个翻译过程需要做很多决策指令能不能直接匹配一条汇编指令还是需要拆成几条数据要放在寄存器还是内存里哪个寄存器可以用来做临时保存LLVM 传统上使用一个称为 SelectionDAG 的中间结构。它会从 IR 构建一个有向无环图节点表示操作和数据依赖然后在这个图上做模式匹配寻找目标指令的“模式”。这个过程复杂但表现力强。近几年又有了 GlobalISel它把指令选择做成更模块化、更可扩展的流水线更适合简单后端的快速移植和新手理解。如果你只是用 LLVM 写优化 Pass那暂时不需要深入后端。但如果你想给一款新芯片做编译器支持那后端这些环节就是必修课。4.2 后端一条龙指令选择、调度、寄存器分配、汇编与二进制输出从一个比较高的视角看LLVM 后端流水线大致分几步把 IR 变成一个初始 DAG按基本块组织。对 DAG 做合法化legalize把目标架构不支持的类型和操作转换成可实现的组合。比如 32 位 RISC-V 上遇到i64运算可能需要拆成两个 32 位寄存器操作。做指令选择instruction selection用目标指令模式匹配 DAG 节点。做指令调度scheduling排序指令以更好地利用 CPU 流水线。做寄存器分配register allocation把虚拟寄存器映射到物理寄存器必要时插入保存和恢复代码。生成 MCInst再通过汇编或直接输出二进制。每一步都有一个独立的 Pass它们组合成流水线由llc工具统一执行。你可以用llc -print-after-all查看每一步的中间结果这也是为什么我强烈建议你在看后端源码时始终配合llc的实验输出而不是只读文字分析。这里有个学习技巧把一行 C 代码编译成汇编保存输出再逐步修改源码里的一个参数看汇编怎么变化。比方说把循环里的int改成unsigned或者把for改成while你会意外地发现后端把很多内容重排了。这些“实验式观察”比读十篇讲解文章都更有效。4.3 给一个新 CPU 写后端到底需要做哪些工作虽然完整地加一个新后端是工程量巨大的事情但理解它的骨架并不难。LLVM 后端在做 Target 移植时需要新增一个目录比如llvm/lib/Target/MyCPU/里面至少要有这些东西MyCpu.td定义寄存器、指令集、调用约定等。MyCpuISelDAGToDAG.cppDAG 选择逻辑。MyCpuISelLowering.cpp处理调用约定、参数传递、返回地址等。MyCpuSubtarget.cpp定义子目标特性比如支持哪些扩展指令。MyCpuMCTargetDesc.cpp描述 MC 层信息用于汇编和反汇编。MyCpuAsmParser.cpp汇编文本语法解析。其中MyCpu.td是基础其他文件大多用生成器和少量手写代码配合。加上 TableGen 自动生成的指令编码表之后你就拥有了一个“看起来能跑”的后端雏形。之后再慢慢调就可以让clang -target mycpu产出可执行代码了。我觉得哪怕你没有机会真的做一个后端试着在纸上给一个极简的 RISC 指令集设计.td文件也是极为有益的脑力训练。它让你明白原来一条add指令背后牵涉到的约定如此之多也让你更加敬畏编译器这门手艺。5. 常见问题与排查清单5.1 我在实践中遇到的 6 个高频问题我在学习和使用 llvm-project 的过程中遇到过多到数不清的问题其中有一些几乎每个新人都要碰一遍。我整理了一张表列出高频问题、现象和解决思路希望对你有用。问题现象可能原因解决方法clang: error: unable to execute command: Segmentation fault可能是你在自编译 LLVM 时调试信息太重或优化级别太低换 Release 或 RelWithDebInfo 重新构建先排除是否特定源码触发undefined reference tollvm::...链接时忘了加对应的-lLLVM...库用llvm-config --libs或--system-libs拼全链接参数libLLVM-18.so: cannot open shared object file运行时找不到动态库路径设置LD_LIBRARY_PATH指向 build/lib或使用llvm-config --libdiropt: unknown pass name插件未正确加载或 Pass 名匹配错误确认-load-pass-plugin路径正确检查 PassBuilder 注册代码构建时内存被 OOM并行度太高或调试符号太多降低ninja -j数量关掉 tests/examples或加 swapPass 修改 IR 后出现维护性崩溃返回的PreservedAnalyses不准确如果改动了很多 IR就返回PreservedAnalyses::none()只读分析可返回all()这些问题看起来零散但排查思路其实是相通的先确定是构建期问题、链接期问题还是运行期问题再对症下药。不要一上去就认为源码有 bug大多数时候是你自己的环境配置或者 Pass 返回的脏信息导致的。5.2 使用 opt 和 llc 做调试的专属建议调试优化器相关问题我的个人习惯是保留.ll后缀的中间文件不要在一条命令里从 clang 直接到机器码中途每跑一个工具就落盘一次。使用-print-after-all和-print-before-all可以配合-filter-print-funcsyourFuncName只打印某个函数避免刷屏。打开统计Pass 里可以用STATISTIC宏统计变更数量运行后用-stats输出。这比errs()打印更规范也更适合自动化测试。测试时尽量用-disable-output避免生成文件干扰你观察。还有一个很实用的技巧如果你想单独调试某个 Pass 的行为先用opt -passes...跑一遍再用llc看后面的后端结果。如果你怀疑是后端的问题就用llc -debug或-debug-onlyisel打开专项日志前提是你的构建开启了LLVM_ENABLE_DEBUG默认 Debug 或 RelWithDebInfo 会开启。5.3 跑测试的两种方式lit 和 FileCheckLLVM 的测试系统非常值得单独夸一夸。它使用lit来编排测试使用FileCheck来校验输出。每个测试文件通常是一个.ll或.c文件文件头部用注释写测试指令和期望结果。比如你想测试一个 Pass 是否能把某种模式优化掉就可以写一个测试文件然后期望输出中不包含某个指令。FileCheck 的匹配关键字是CHECK和CHECK-NOT。它用起来很像正则表达式匹配但更专一也更稳定。我自己测试新 Pass 时通常会先手动跑opt看输出确认符合期望后再把这些输出整理成CHECK-LABEL和CHECK模式。这种“先手动验证再固化到回归测试里”的做法能让你后续改代码时非常有安全感。5.4 源码阅读时避免掉进“无底洞”的方法llvm-project 太大很多人读到某个 Pass 的内部实现时会沿着函数调用一路追到很底层然后忘记自己原本要做什么。我的建议是给阅读设置一个“目标-边界”结构。你只追踪与目标直接相关的调用遇到不相关的高层抽象就先跳过做下记号即可。用现代 IDE 或编辑器配合clangd对跳转的帮助很大。不过有时候跳转进头文件以后也挺容易迷失方向。我通常使用一个很简单的方法在阅读任何文件前先看它顶部注释和文件里的代码结构确认它在整个流水线中的层级位置再往下钻。6. 我的几点学习体会如果你把整个 llvm-project 当作一座城市那么 IR 是道路Pass 是运行在城市里的车辆后端是延伸到各个硬件方向的铁路线测试系统则是无处不在的路牌。你可以只待在这座城市里一个街区生活也可以沿着铁路看看外面的世界。这个项目最大的价值不光是它产出了 Clang 这样的工业级编译器更在于它示范了一种“如何组织一个极其复杂的系统”的工程哲学。我个人非常有感触的一点是LLVM 里的每一个模块都不是孤立设计的。IR 的定义决定了优化器能做什么优化器的需求又反过来影响 IR 的类型系统设计TableGen 让后端描述变得简洁而后端又依赖 IR 的稳定性。这种“互相制约又互相成就”的关系只有在实际写代码时才能真正感受到。最后分享一个我自己的小习惯每年新版本 LLVM 发布时我都会去release notes和PassBuilder.cpp里扫一眼看看有没有新的 Pass 名、新后端、新 API 调整。这不仅让我保持对编译器技术演进的敏感度也让我看到 llvm-project 这个项目如何一步步走向更广泛的应用场景。如果你也想深入最好的开始方式不是读完这篇博客而是跑起那个最小的opt插件实验亲手在 IR 上留下一点自己的印记。