LLVM编译器基础设施:从IR到Pass的完整构建与调试实践

发布时间:2026/9/18 11:43:36
LLVM编译器基础设施:从IR到Pass的完整构建与调试实践
2012年我第一次打开 llvm-project 的源码树时第一反应是这到底算仓库还是某种巨型生物主目录下一堆以 llvm、clang、lld 开头的目录加起来几百万行代码。当时我的想法很朴素这不就是一个编译器吗至于这么复杂后来在编译器后端方向摸爬滚打这些年才逐渐意识到——LLVM 不是“一个编译器”它是编译器界的超级工厂是几乎所有现代语言与芯片之间的那座桥。这篇文章不打算把 llvm-project 里每个模块都念一遍那样三天三夜也念不完。我想用比较实在的方式把它的设计思路、关键组成、怎么动手构建、以及实际工作中会遇到的问题讲清楚。不管你是想入门编译器技术、要给新语言写个后端、还是单纯好奇为什么 Rust 和 Swift 都能用上同一套代码生成引擎这篇文章都适合。我还会把这些年我在源码构建和 Pass 调试上踩过的坑一并分享出来尽量让你少走弯路。1. llvm-project 到底是什么编译器界的超级工厂1.1 从虚拟机到通用编译器基础设施的演变很多人第一次听到 LLVM都会以为它是一个虚拟机。这个印象其实来自它的全称 Low Level Virtual Machine。但如果你抱着“虚拟机”的预期去读源码大概率会一头雾水。因为今天的 LLVM 早就不是虚拟机了它是一整套模块化的编译器组件库。事情的起点在 2000 年前后。Chris Lattner 在 UIUC 做博士研究时设计了一套基于静态单赋值形式的编译中间表示并围绕它搭建了一个可以反复复用的编译基础设施。后来苹果公司把这个技术招入麾下用它开发了 Clang 编译器用来替代当时的 GCC。再往后LLVM 逐渐从“苹果的编译器工具链”长成了整个行业的基础设施Rust 语言官方编译器 rustc 的后端用的是 LLVMSwift 的编译流程里有 LLVMKotlin/Native、Julia 也依赖 LLVM 做机器码生成。为什么它能取代 GCC 成为新一代基础设施关键就在“模块化”三个字。GCC 的架构是一个整体前端、优化器、后端耦合在同一个程序里你想用一个新语言套 GCC 的后端或者给 GCC 加一个新指令集后端都极其痛苦。LLVM 则把编译流程拆成了互相独立的库和工具你可以在不碰其他模块的前提下替换前端、插入优化 Pass或者完全重写后端。很多人不理解的另一点是LLVM 并不直接“编译”任何语言。它只负责中间表示IR的产生、优化和机器码生成。真正读懂 C/C 源码的是 Clang真正把 Fortran 转成 IR 的是 FlangRust 的前端则完全在 rustc 内部。LLVM 拿着前端产物继续干活。这种分工使得新语言不用从零写指令选择、寄存器分配、指令调度这些硬骨头只需要把自家语言翻译成 LLVM IR剩下的交给 LLVM。这套思路说白了就是“不做语言的编译器做编译器的编译器”。1.2 仓库里的组件地图先认清再动手初次进入 llvm-project 仓库最劝退的就是目录太多。实际上你不需要全读懂主要组件的职能可以先用一张表理清楚目录组件职责llvm/LLVM 核心IR 定义、优化 Pass、指令选择、寄存器分配、目标后端框架clang/Clang 前端解析 C/C/Objective-C生成 AST 并转成 LLVM IRlld/LLVM 链接器替代系统 ld/gold 的链接器速度快且支持多种格式libc/libcabi/C 标准库与 ABILLVM 实现的 C 标准库compiler-rt/运行时库提供 sanitizer、profile、builtin 等底层运行时支持mlir/MLIR 框架多级 IR 编译器基础设施当前深度学习编译器热门方向flang/Fortran 前端把 Fortran 翻译到 LLVM IRpolly/循环优化器基于多面体模型的循环变换优化lldb/调试器LLVM 生态的调试器常与 Clang 配合使用需要注意这些组件并不是你 clone 下来就都自动编译的。它们的编译开关完全由 CMake 参数控制这也是新手第一个容易卡住的地方。当你想手搓一个 LLVM 环境时第一件事不是跑 ninja而是想清楚我到底要哪些组件如果只做日常 C/C 编译Clang 加 lld 基本够了。如果做语言前端研究核心的 llvm 和 opt 工具就够用。如果做芯片后端适配依赖的核心主要在 llvm/ 目录下得重点关注 Target 子目录。MLIR 则是另一个相对独立的领域不建议刚入门就把所有项目全部打开编译时间会让你怀疑人生。2. 核心架构拆解三段式编译引擎与 IR 中间表示2.1 前端、优化器、后端为什么必须解耦LLVM 把传统编译器拆成三段前端负责把源代码变成中间表示中端负责在中间表示上做优化后端负责把优化后的中间表示变成目标机器码。这三段通常也叫 Frontend、Middle-end、Backend对应到代码仓库里分别是 clang 目录、llvm 目录下的 lib/Transforms、lib/Target。用生活里的场景打个比方你有一本中文小说想翻译成法文和日文两个版本。传统编译器像是“中文直接翻译成法文”和“中文直接翻译成日文”两条流水线翻译过程中每个语种的技巧完全不通用。LLVM 的做法是先把中文“压缩”成一套高度结构化的思维语言在这套思维语言上统一润色、删和改写内容最后再定向翻译成法文或日文。小说只需要从中文转成思维语言一次法文和日文版本可以分别从思维语言生成。这个解耦带来的收益是乘法变加法。原始方案要支持 3 种语言和 3 种芯片架构需要写 9 套完整的编译器。LLVM 方案只需要写 3 个前端、1 套优化器、3 个后端一共 7 个部分。架构一多省下来的工作量是惊人的。Rust 能快速跑在 RISC-V 上Kotlin 能编译到苹果芯片靠的正是这套解耦结构。这也回答了一个经常被问到的问题为什么 LLVM 的 IR 设计看起来那么“反人类”因为 IR 既不会偏向某一种编程语言也不会偏向某一种 CPU 指令集。它必须把不同语言间的差异抹平同时又给后端保留足够的信息去做高效的指令生成。做了取舍才成了今天这个样子。2.2 IR 的 SSA 形式全生态的通用语LLVM IR 最核心的设计是静态单赋值形式Static Single AssignmentSSA简单说就是每个变量只能被赋值一次。你说这怎么可能平时写代码变量不是反复改来改去其实在 IR 层面编译器可以把每一次赋值都变成一个新变量这样数据流关系会变得极其清晰。给你看个最简单的例子。C 代码int mul_add(int a, int b, int c) { int t a * b; return t c; }用clang -S -emit-llvm转成 LLVM IR得到的结果长这样define i32 mul_add(i32 %a, i32 %b, i32 %c) { entry: %t mul i32 %a, %b %r add i32 %t, %c ret i32 %r }可以看到%t、%r都是只赋值一次的名字。熟悉数学的人会发现这跟数学公式里的变量一样一旦定义就不再变化谁依赖谁一目了然。做优化时编译器只要顺着支配树和数据流图走就能判断某个变量是否被后续代码使用某个计算是否可以提前或延后某个赋值是否可以被删除。控制流合并的时候SSA 会用到一条特殊的phi指令用来表达“这个变量根据我来自哪个分支取值可能不同”。这条指令在真实代码里经常出现很多人第一次读 IR 看到phi会懵但它的存在正是为了让每个变量仍然保持“只赋值一次”的规则。理解了这一点再读 IR 就不会觉得它是天书了。2.3 Pass 管线编译器优化如何流水线作业LLVM 中每个独立的优化或分析步骤都叫一个 Pass一堆 Pass 按顺序串起来就是 Pass 管线。比如一个函数从 IR 进来可能先做内存到寄存器的提升再做公共子表达式消除接着做循环展开最后做指令合并。每个 Pass 只干一件事Pass 之间通过 IR 传递数据。这种插件式设计让优化代码的维护变得非常容易新增一个优化只需要实现一个独立的类加入到管线里而不需要改动其他部分。在旧版 LLVM 里Pass 有 Legacy Pass Manager到了 LLVM 14 之后的 New Pass Manager 时代统一推荐使用新的 Pass 管理方式。日常用opt工具手动跑优化时-passes参数直接指定 Pass 名称比如opt -passesmem2reg,instcombine,loop-unroll -S input.ll -o output.ll会说这个是因为很多老教程还在介绍-mem2reg这种旧写法。如果你用的是新版本 LLVM很可能直接报错说找不到。新 Pass Manager 对缓存、并行执行的支持更好也成了大趋势。Pass 还分成分析和变换两类。分析 Pass 只做信息采集不修改 IR比如计算循环深度、统计调用图变换 Pass 则会修改 IR比如移除死代码、内联函数。两者配合使用变换 Pass 往往需要调用分析 Pass 的结果而分析 Pass 的数据在优化过程中可以被多个变换 Pass 复用提升编译速度。2.4 TableGen 与后端生成芯片适配的加速器如果说 IR 是编译器的通用语那后端就是把通用语翻译成各芯片方言的模块。为了让这个翻译不那么痛苦LLVM 用了一套叫 TableGen 的领域专用语言。开发者用声明式描述指令名称、寄存器、编码格式TableGen 在构建时自动生成大量枯燥的 C 代码。比如你想要后端支持一条加法指令代码里可能要维护操作数类型、编码二进制位、寄存器约束、调度模型等一堆表格。手写这些代码不仅重复而且极容易出错。用 TableGen 描述后指令选择、汇编器、反汇编器、指令调度器等多处代码都能从同一份描述中生成保证一致。这也解释了为什么每出一个新 CPU 架构LLVM 社区都能相对较快地提供支持。虽然写后端依然是编译器领域最难的工程之一但 TableGen 至少把“体力活”部分自动化了。如果你暂时不打算做后端开发理解这一层就够了IR 是统一的后端是各自独立的TableGen 是辅助生成后端代码的工具。3. 从源码构建 llvm-project一份可直接照做的完整流程3.1 构建前的几个关键决策很多教程一上来就让你 clone 仓库、跑 cmake、跑 ninja结果新手折腾一晚上不是内存在链接阶段爆掉就是磁盘空间告急。其实构建 LLVM 前有四个问题必须先想清楚。第一要不要完整历史llvm-project 的仓库非常大普通 clone 下来几个 GB。如果只是编译使用强烈建议浅克隆只取最近的提交。git clone --depth1 https://github.com/llvm/llvm-project.git第二要编哪些组件前面说的 LLVM_ENABLE_PROJECTS 是第一个关键开关。在 LLVM 17 之后的版本里libc、compiler-rt 这些运行时组件逐渐被移到 LLVM_ENABLE_RUNTIMES 下而 clang、lld、mlir 这类编译器工具链仍然走 LLVM_ENABLE_PROJECTS。两个参数不要混用。第三要支持哪些目标架构默认情况下 LLVM 会为所有后端生成代码这个默认值非常暴力会显著增加构建时间和内存消耗。如果你平时只跑 x86那就只开 X86 目标如果还要交叉编译到 ARM 或 RISC-V再追加对应的架构名。第四用哪个构建系统我强烈建议用 Ninja。它比 Unix Makefiles 并行效率更高增量构建更快。生成的二进制文件在调试和开发时几乎每天都要重编Ninja 能省下大量等待时间。3.2 一份可复现的构建命令假设你在一台 x86_64 的 Linux 或 macOS 机器上想编译一个带 Clang 和 LLD 的 Release 版本 LLVM完整命令如下cd llvm-project mkdir build cd build cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_PROJECTSclang;lld \ -DLLVM_TARGETS_TO_BUILDX86;AArch64;RISCV \ -DLLVM_USE_CCACHEON ninja -j8解释几个参数背后的考虑CMAKE_BUILD_TYPERelease会开启优化编译出的 clang 运行速度更快但构建时间稍长。如果做开发调试可以用Debug或RelWithDebInfo代价是生成的编译器体积更大、运行更慢。LLVM_USE_CCACHEON开启编译缓存这个我强烈建议打开。日常开发中你常常只改了一小段代码ccache 能直接命中旧编译结果增量编译时间能从几分钟降到几十秒。ninja -j8后面的数字要结合机器配置调整。构建 LLVM 是 CPU 密集型操作8 核机器开 8 个并发比较合理。内存紧张的机器不要盲目加大并发否则链接阶段容易 OOM。构建完成后验证一下./bin/clang --version能看到类似clang version 19.x.x的输出说明基本环境已经 OK。接着写个 hello world 试试cat hello.c EOF #include stdio.h int main() { printf(hello llvm\n); return 0; } EOF ./bin/clang hello.c -o hello ./hello如果你还想看看编译器到底干了什么这一步就能用上最关键的观察命令./bin/clang -S -emit-llvm hello.c -o hello.ll cat hello.ll看到以、%开头的文本文件就是 LLVM IR。这一步是理解整个编译器工作流程的钥匙建议每个人都在本地跑一遍。3.3 构建时间和体积的预期管理源码构建 LLVM 不是刷个视频就能等完的事情。以只开clang;lld和三个目标架构、8 核并发为例普通配置的机器大概需要 30 到 60 分钟。如果你默认全量构建轻则两三个小时重则整夜。构建目录的空间占用也相当可观。Release 构建一般 20 到 30 GBDebug 构建更大。建议在开始前看一眼磁盘剩余空间别等到链接到一半报No space left on device。还有一个容易被忽略的问题如果 cmake 配置后改动了参数旧的 CMakeCache.txt 会残留旧配置。最稳妥的办法是删除 build 目录重新配置。不要在一个已有缓存的目录里反复切换组件开关各种诡异问题经常就是这么来的。4. 实际应用场景llvm-project 究竟被用来做什么4.1 芯片与硬件公司快速适配新指令集芯片行业是 LLVM 最大的受益者之一。过去一个新的 CPU 架构问世编译器支持是最耗时的环节之一。GCC 对某种新指令集的支持通常紧跟芯片流片时间但生态相对封闭、代码庞大、结构陈旧深入修改的门槛很高。LLVM 的后端结构清晰很多借助 TableGen芯片厂商可以把大量时间花在指令定义和调优上而不是跟晦涩的 C 数据结构搏斗。尤其这几年 RISC-V 生态快速崛起大量芯片公司基于 LLVM 做自有指令扩展。用的是同一个 IR换一个后端实现就能让自己的新语言或新指令集迅速跑起来。我认识不少做 DSP 和 AI 加速芯片的工程师日常工作就是改 llvm-project 里的后端文件让编译器能把 C/C 代码生成他们芯片认识的机器码。4.2 语言社区与静态分析Clang 不止会编译Rust 早期的编译器直接把 Rust 代码翻译成 LLVM IR省掉了从零开发机器码生成器的巨大工程。Swift、Julia、Kotlin/Native 也走了同样的路线。对于语言设计者来说LLVM 的意义在于你只需要聚焦语言本身的语法、语义和类型系统后端和优化都可以“外包”。Clang 还带来了另一个重要生态基于 AST 的工具链。同样一份 C/C 代码Clang 不仅能编译还能提供语法树、类型信息、代码补全和建议。clangd就是很多编辑器里 C/C 智能提示的后端引擎clang-tidy则负责做代码风格检查和静态分析。很多团队的 CI 环境里clang-tidy已经是和单元测试同等重要的环节。它能在代码提交前发现潜在 bug 风险比如未定义行为、危险的类型转换、可疑的空指针解引用。静态分析还有更硬核的方向。一些安全公司会基于 LLVM IR 做深度分析因为 IR 比源码更适合做跨函数的控制流和数据流分析。曾经引发行业关注的各种内存类漏洞自动化检测很多底层技术栈都绕不开 LLVM 提供的分析能力。4.3 MLIR 与高性能计算编译器正在成为 AI 基础设施传统上编译器优化的对象是通用 CPU 指令但近几年深度学习编译成了一个热得发烫的方向。MLIRMulti-Level Intermediate Representation就是 LLVM 家族里专门应对这个挑战的新成员。它不是要取代 LLVM IR而是在 LLVM IR 前增加多层更接近深度学习框架的中间表示。谷歌的 TensorFlow、PyTorch 生态里的很多编译优化以及各种 AI 加速芯片的编译器都开始基于 MLIR 构建。它允许你在高层 IR 上做算子融合、布局转换等大粒度优化再一步步降低到 LLVM IR最终生成目标硬件代码。如果你关注 AI 基础设施的就业方向MLIR 知识几乎是必备项而 llvm-project 仓库里的 mlir/ 目录就是最好的学习材料。5. 构建与 Pass 调试避坑实录那些文档不会告诉你的经验5.1 构建期典型问题速查表代码编译不过是最容易根据报错解决的事真正折磨人的往往是环境层面的问题。我把这些年踩过的坑整理成了一张速查表现象可能原因解决办法cmake 阶段报找不到 C 编译器机器上只有老旧的 GCC或 PATH 没配好安装新版 GCC 或先系统装一个 Clang再重新配置编译到一半内存不足直接死机并发数开太高或开启了过多目标架构调低ninja -j并发减少LLVM_TARGETS_TO_BUILD磁盘空间只剩几位数build 目录体积远超预期清理空间或用LLVM_ENABLE_PROJECTS减少组件修改 CMake 参数后行为没变旧的 CMakeCache.txt 残留删除 build 目录或 CMakeCache.txt 后重新配置链接阶段 OOM同时链接多个大二进制内存峰值过高增加 swap或让链接串行执行减少并发clang 版本号和你预期不一致系统 PATH 里混入了自带的 clang用绝对路径./build/bin/clang或调整 PATH 顺序如果你在编译一些大型项目时发现自己构建的 clang 和系统 clang 版本差异过大建议把源码构建的 bin 目录显式放在 PATH 最前面避免链接到错误的库。5.2 Pass 调试三板斧看 IR、看 Pass 列表、看 CFG做编译优化的人日常主要工作在调试自己写的 Pass。我最常用的调试三板斧如下。第一板斧是观察 IR。编译时带上-S -emit-llvm或者对已有 IR 跑opt -S把结果导出成文本文件对照分析。opt -passesmem2reg,instcombine -S input.ll -o output.ll第二板斧是打印每个 Pass 执行前后的 IR。新 Pass Manager 下可以用opt -passesfunction(mem2reg,instcombine) -print-after-all -filter-print-funcsmy_func_name input.ll这里的-filter-print-funcs很重要。不加它你会被整个文件的 IR 打印淹没加上指定函数名输出立刻清爽。Debug 模式下还可以用-print-before-all看 Pass 执行前的状态。第三板斧是可视化控制流图。使用-view-cfg或-view-cfg-onlyLLVM 会调用 Graphviz 的 dot 工具画出函数 CFG。这个对理解分析类 Pass 的运行逻辑特别有效。很多初学者忽略这个功能其实它比看几十屏文本 IR 直观得多。我自己的经验是给 Pass 加断点不如多打印 IR因为优化问题往往是“某个 Pass 把东西改坏了”而不是“某个函数算错了”。一旦发现某个 Pass 执行后 IR 变得异常立刻就能定位。配合 LLVM 自带的FileCheck框架还能把这些检查固化到回归测试里防止以后再次踩雷。5.3 源码构建还是用发行版预编译包这是每次分享都会被问的问题。我的建议分两种情况。如果你是要深入学习编译器工作原理或者要做 Open Source 贡献那一定要自己编译一次 llvm-project。编译过程本身就是理解整个项目构建结构的绝佳训练。你会知道组件开关在哪、CMake 变量怎么影响结果、链接一个编译器需要哪些库。这些知识是看任何文档都学不来的。如果你只是写 C/C 应用需要一个新的 Clang 工具链那完全没必要源码编译。Ubuntu 用 apt、macOS 用 brew 直接装预编译版本就够了sudo apt install clang lld或者使用 llvm.org 官方提供的预编译二进制。方便快捷而且团队部署时的可复现性更好。只有当你需要自定义后端、修改优化 Pass或者实验最新特性时才值得回到源码构建这条路上。还有一点要注意源码构建出来的 clang 如果版本很新搭配一套很老的系统头文件和 libc 可能会出兼容问题。预编译包一般会把运行时依赖一并处理好这是它在大项目里更省心的重要原因。根据我个人经验最典型的失败路径是这样的什么都没配置直接cmake ../llvm make跑到深夜发现内存不够再回来查参数。如果你正在读这篇文章请直接跳到上一节的命令把组件和目标架构先限制好再加上 ccache这一套流程下来能帮你省掉至少一个晚上。另一个我坚持的习惯是每次构建前先确认磁盘剩余空间和可用内存工具链是日常吃饭的家伙环境越稳效率越高。