Rust、Swift、Julia、C++、Fortran 是五门差异极大的语言;x86-64、AArch64、RISC-V、NVIDIA 的 PTX、WebAssembly 是五套差异同样极大的指令集。今天这两侧的绝大多数组合都能编译成功,而世界上并没有二十五个独立的编译器。
中间夹着同一样东西:一种叫 LLVM IR 的语言。它长得像汇编,却有类型;它不对应任何真实芯片,却可以被机器逐条检查合法性。这篇文章讲清楚它内部到底是什么、为什么长成这样、以及这个设计买来了什么、又赔上了什么。
破除误解
误解一:编译成 LLVM IR 就得到了一份可移植的"通用字节码"。
不是。每个 .ll 文件开头都有两行元信息:target triple(目标三元组,例如 x86_64-unknown-linux-gnu)和 target datalayout(指针多宽、各类型如何对齐、字节序如何)。更要命的是 ABI 降级发生在前端:C 语言里一个结构体到底是按值塞进两个寄存器、还是在栈上传地址,规则随目标平台而变,而这个决定是 Clang 做出来后烧进 IR 的。所以给 x86-64 Linux 生成的 IR 拿到 AArch64 上通常是错的——它不是不能跑,是语义已经变了。Apple 曾经要求 watchOS/tvOS 应用上传 bitcode(IR 的二进制形式)以便重新编译,那也只在同一 ABI 家族内成立,而这套机制在 2022 年的 Xcode 14 里被弃用了。
误解二:LLVM 是"Low Level Virtual Machine",所以背后有个虚拟机在跑。
这个全称来自 2002 年前后的项目起源,早已被官方废弃——LLVM 现在只是一个专名,不再展开成任何词组。IR 里没有垃圾回收、没有对象模型、没有内建的异常运行时。它假定下面有一台真实的机器,有寄存器、有平坦的内存、有调用约定。对比 JVM 字节码:JVM 的 invokevirtual 背后是一整套虚方法分派语义,而 LLVM 的 call 就是"按这个平台的调用约定跳过去"。IR 是给编译器后端读的,不是给运行时读的。
误解三:改用 SSA 形式,所有优化就都变简单了。
只对寄存器里的标量成立。SSA 让 %x 这样的值有唯一定义点,于是常量传播、公共子表达式消除几乎是白送的。但内存不在 SSA 里:load 和 store 之间的依赖关系仍然要靠别名分析(判断两个指针会不会指向同一块内存)去猜,而别名分析是编译器里最不精确、最容易出错的部分。LLVM 后来专门加了一层叫 MemorySSA 的分析,给内存操作建一份 SSA 影子——注意它是附加分析层,不是 IR 本身的一部分。SSA 解决了半个问题,另外半个留在原地。
把乘法变成加法
假设你要支持 M 种源语言和 N 种目标架构。最朴素的做法是为每个组合写一个编译器,工作量是 M × N。这不是理论上的抱怨:1980 年代确实存在大量"某语言在某机器上的编译器",彼此毫无复用。
三段式的设想是插一层中介:前端只负责把源语言译成 IR,后端只负责把 IR 译成目标机器码,中间的优化器对两侧都一无所知。于是工作量变成 M + N。
| 规模 | 无 IR(直连) | 有 IR(三段式) |
|---|---|---|
| 5 语言 × 5 架构 | 25 套 | 10 个组件 |
| 10 语言 × 20 架构 | 200 套 | 30 个组件 |
| 新增一门语言的成本 | 写 N 个后端 | 写 1 个前端 |
| 新增一种架构的成本 | 写 M 个前端 | 写 1 个后端 |
这个想法本身很老——1958 年的 UNCOL 提案就是这个思路,此后反复有人尝试。GCC 也是三段式(GENERIC → GIMPLE → RTL),GIMPLE 从 2005 年的 GCC 4.0 起用上了 SSA。所以 LLVM 的贡献不是发明了三段式。
它的贡献是把 IR 当成一等公民的产品来做:有正式的语言参考手册、有稳定的人可读文本语法、有可以链接进任何程序的 C++ 库、有清晰的许可证。GCC 长期在这件事上是相反的姿态——IR 是内部实现细节,不保证跨版本稳定,也不鼓励外部工具接管,其中一部分动机是防止专有前后端把 GCC 当成免费零件。LLVM 走了反方向:2019 年的 LLVM 9 起改用带 LLVM 例外条款的 Apache 2.0 许可证,明确允许闭源产品嵌入。结果就是 Rust、Swift、Julia、Zig、Kotlin/Native、Flang 这些新语言的后端全部长在同一块地基上,而它们的开发者从来不需要读懂寄存器分配。
一种可以读、可以写、可以验证的汇编
LLVM IR 有三种互为无损转换的形态:编译器内存中的 C++ 对象图、磁盘上的位码 .bc、以及人可读的文本 .ll。llvm-as 与 llvm-dis 在后两者之间来回转,信息一位不丢。
这个"人可读"的形态是整套设计里被低估的一环。它意味着你可以在流水线的任意一环把 IR 倒出来看:
clang -S -emit-llvm hello.c -o hello.ll # C 源码 → 文本 IR
opt -passes='mem2reg,instcombine' hello.ll -S # 只跑指定的两个优化
llc -mtriple=aarch64-linux-gnu hello.ll -o hello.s # IR → 汇编
```IR 本身长这样(求两数较大者):
define i32 @max(i32 %a, i32 %b) {
entry:
%cmp = icmp sgt i32 %a, %b ; 有符号大于比较,结果是 i1
br i1 %cmp, label %then, label %else
then:
br label %join
else:
br label %join
join:
%r = phi i32 [ %a, %then ], [ %b, %else ] ; 从哪条边来就取哪个值
ret i32 %r
}
```注意几件事。类型是显式且精确的位宽:i32 就是 32 位整数,没有 C 里 int 那种随平台浮动的含糊,也没有隐式类型提升。比较结果是 i1,一位。函数体由基本块组成(entry、then、else、join),每个基本块是一串没有分支的指令加一条终结指令。
类型系统的边界在哪里,是个有意思的取舍。早期 IR 里指针是有类型的(i32*、%struct.Foo**),听上去更安全。实践中却发现这些类型几乎全是假的——C 的类型双关、联合体、memcpy 让 IR 里到处是 bitcast,指针类型既不可信也不被优化器依赖,只是在给每个 pass 增加"记得插一条转换指令"的负担。于是 LLVM 15(2022 年)默认启用了不透明指针:所有指针统一写成 ptr,指向什么由使用它的指令说了算。虚假的类型信息比没有类型信息更糟,这是删除它的理由。
而 IR 真正靠类型换来的东西是 Verifier(校验器)。它是一个可以在任意 pass 前后运行的检查器,机器判定一批不变量:
- 每个值的使用点必须被它的定义点支配(沿控制流的任何路径到达使用点,都必然先经过定义点);
phi指令的入边必须与所在基本块的前驱一一对应,不多不少;- 每个基本块必须恰好以一条终结指令结束;
- 所有操作数类型必须匹配。
这在工程上的意义是把缺陷检出提前了。一个写错的优化 pass 不会让你在几百个 pass 之后收到一段跑错的机器码,而是在它自己跑完的那一刻就被 opt -passes=verify 抓住。相比之下 GCC 的 RTL 是一种低层的、Lisp 风格的表示,缺少这一层可机器判定的强不变量。
SSA:为什么"每个值只写一次"能省掉一整类分析
静态单赋值(Static Single Assignment)的规则只有一条:程序里每个名字只被赋值一次。原本会被反复赋值的变量 x,被拆成 %x1、%x2、%x3。
代价立刻出现:控制流汇合的地方该用哪个版本?答案是 phi 指令——它不是真实的机器操作,而是一个记号,意思是"如果控制流从 %then 来就取 %a,从 %else 来就取 %b"。
好处是什么?在非 SSA 的 IR 上,要回答"这里读到的 x 是哪次赋值写的",你必须跑一遍到达定义分析——一种在控制流图上迭代到不动点的数据流算法,每次修改代码后都得重算。在 SSA 里这个问题不存在:定义唯一,%x2 就是 %x2,别无可能。
LLVM 把这一点直接编码进了数据结构。Instruction 类同时是一条指令、也是它产生的那个值(都继承自 Value),而每个 Value 维护着一张使用者链表。于是:
- 常量折叠 = 遍历指令,操作数都是常量就换成结果;
- 死代码消除 = 没有使用者且无副作用的指令直接删;
- 全局值编号(GVN)= 给等价的表达式取同一个名字,然后把重复的那个换掉;
- "把所有用到 X 的地方改成用 Y" = 一次
replaceAllUsesWith调用,复杂度只与使用者数量成正比。
IR 就是数据流图本身,不需要在旁边再维护一份。这是 SSA 让优化变简单的实质含义。
还有一层体贴:LLVM 不要求前端自己构造 SSA。Cytron 等人 1991 年那篇论文给出的支配边界算法虽然经典,但不该是每个语言作者的必修课。所以 Clang 这类前端可以偷懒——把所有局部变量都用 alloca 分配在栈上,读写一律用 load/store:
%x = alloca i32 ; 前端产出:局部变量放栈上
store i32 1, ptr %x
%v = load i32, ptr %x ; 一次读
```然后 mem2reg(以及更强的 SROA)这个 pass 会识别出"这块栈空间地址从没泄漏出去",把它整个提升成 SSA 寄存器,上面三行会塌缩成一个常量 1。前端只管把语义写对,SSA 构造是中端的事。
SSA 的代价,逐条列清楚:
一,phi 是虚构的。机器上没有这条指令,寄存器分配之前必须把它消解成前驱块末尾的拷贝。这一步(SSA destruction)有著名的坑:丢失拷贝问题和交换问题,Briggs 等人 1998 年才把正确做法讲清楚。
二,SSA 只覆盖标量。前面已经说过,内存留在 SSA 之外。
三,形式膨胀。控制流越复杂,汇合点越多,phi 越多;一个巨型 switch 加上大量活跃变量,IR 的体积会明显超过等价的源码规模。
Pass 流水线:优化是一长串小改写
LLVM 的优化器不是一个大算法,而是几百个各管一件小事的 pass 排成的序列。它们按作用域分层,外层可以调用内层:
| 粒度 | 作用范围 | 典型 pass |
|---|---|---|
| Module | 整个编译单元 | 内联、全局变量优化、跨过程常量传播 |
| CGSCC | 调用图的强连通分量 | 内联决策(保证被调用者先于调用者处理) |
| Function | 单个函数 | InstCombine、SimplifyCFG、GVN、SROA |
| Loop | 单个循环 | LICM(循环不变量外提)、展开、向量化 |
pass 还分两类:变换 pass 改 IR,分析 pass 只产出信息(支配树、循环信息、别名分析结果、目标机器特征)。分析结果会被缓存,而变换 pass 必须声明自己保住了哪些分析,没声明的就被作废、下次用到时重算。这套依赖与失效关系在旧的 pass 管理器里是隐式的、容易出错的;新 pass 管理器把它显式化,并在 LLVM 13(2021 年)成为默认,LLVM 15 里旧的被彻底移除。
-O2 不是"跑一遍这几百个 pass",而是一条长得多的固定序列——同一个 pass 会在不同位置跑很多次。InstCombine 尤其如此:它是一大堆窥孔改写规则的集合(比如 x * 2 变成 x << 1、把冗余的比较折叠掉),本身收益不大,但它给后面的 pass 清场,让别的 pass 能识别出模式。典型顺序大致是:SROA → EarlyCSE → 函数内联 → InstCombine → SimplifyCFG → GVN → LICM → 循环展开 → 循环向量化 → SLP 向量化 → 再一轮清理。想看清楚跑了什么,opt -passes='default<O2>' -print-after-all 会把每个 pass 之后的 IR 全部打出来。
优化完成后,IR 离开中端进入后端,还要再降两级:
- 指令选择:把 IR 指令翻译成目标机器指令。目标机器的寄存器、指令、时序全部写在 TableGen 的
.td描述文件里,由构建时生成代码。产物是 MIR(Machine IR)——此时仍然是 SSA,只不过用的是无限多的虚拟寄存器。 - 寄存器分配:把无限的虚拟寄存器塞进十几个物理寄存器,塞不下的溢出到栈上。SSA 在这一步终结。
代价
编译慢。 这是最实在的一条。LLVM 的通用性是有价格的:它要支持二十多个后端、要维护完整的分析基础设施、要能处理 C++ 那种规模的程序。WebKit 在 2014 年用 LLVM 做了 FTL JIT 的最高层优化器,两年后(2016 年 2 月)把它换成了自研的 B3。理由不是代码质量——按 WebKit 团队的说法,B3 在他们的基准上生成的代码与 LLVM 相当,但编译速度快了约五倍。原因很直白:JavaScript JIT 只需要 LLVM 能力的一个子集,却要付全套的编译时间。同样的张力出现在 Rust 的调试构建里(大部分时间花在 LLVM 上,于是有了 Cranelift 这个以编译速度为目标的替代后端),也出现在 Zig 自研后端的动机里。
它太低了。 IR 一旦降到 i32、ptr、br,源语言里的信息就永久丢失了:Rust 的所有权、Swift 的泛型约束、数组的边界、张量的形状——后端再聪明也拿不回没写进去的前提。所以现代语言普遍在 LLVM 之上又造一层自己的 IR:Swift 有 SIL(在这一层做所有权和泛型特化),Rust 有 MIR(借用检查在这一层完成),Julia 也有自己的中间层。这条路走到尽头就是 MLIR(2019 年由 Lattner 等人发起,CGO 2021 论文)——与其争论"哪一层才是那个 IR",不如让 IR 本身可分层、可扩展,让张量方言、循环方言、LLVM 方言共存于同一个框架内并逐级下降。MLIR 的存在,等于承认"一个统一 IR 通吃"的最初设想并不成立。
语义有模糊地带。 IR 里有 undef(未定义值,编译器可以在每次读取时假装它是任何值)和 poison(更强的毒性值,任何依赖它的运算结果也是毒的)。这两个概念的精确语义长期没定死,直接后果是真实的误编译——优化器做了一个在纸上看似正确、实则改变程序行为的改写。学术界的介入相当有效:Lee 等人的 PLDI 2017 论文系统梳理了这套语义,freeze 指令在 LLVM 10(2020 年)被加进来,作用是显式地"停止毒性传播";Alive2(PLDI 2021)用有界翻译验证自动检查 InstCombine 的每条改写规则是否保语义,找出了大量长期潜伏的 bug。
pass 顺序没有最优解。 这被称为相位排序问题(phase ordering),至今是开放问题:pass 之间互相影响且非单调,A 之后跑 B 的结果不等于 B 之后跑 A,且哪个更好取决于具体代码。-O2 那条序列是十几年经验调出来的,不是算出来的。所以 -O3 偶尔比 -O2 慢是完全正常的现象,而不是 bug。PGO(按运行时采样反馈优化)和 LTO(链接期跨模块优化)本质上是在给这条盲目的流水线补充事实。
生态集中。 几乎每门新语言都把后端押在同一套代码上。这带来了巨大的复用红利,也带来了单点:LLVM 的一个语义决定会同时影响十几门语言。Rust 把 &mut 翻译成 IR 的 noalias 属性时,就因为 LLVM 对该属性的处理存在 bug,在历史上多次开关这个翻译。
它在生态里的实际位置
JIT。 LLVM 提供 ORC(On-Request Compilation)这套即时编译库。Julia 的整个执行模型建立在它上面,Mesa 的软件渲染器 LLVMpipe 用它生成着色器代码,PostgreSQL 从 11 版(2018 年)起用它把表达式求值编译成机器码。但注意分界线:追求毫秒级响应的 JIT 大多离开了 LLVM——WebKit 换成了 B3,V8 从来就没用过(走的是自研的 TurboFan / Maglev / Sparkplug 分层方案)。LLVM 适合"编译一次、跑很久",不适合"边跑边编译"。
GPU。 NVPTX 后端产出 PTX(NVIDIA 的虚拟指令集),AMDGPU 后端直接产出 GCN/RDNA 机器码,NVIDIA 自己的 nvcc 内部也是 LLVM 系。但 Khronos 的跨厂商标准走了另一条路值得注意:早期的 SPIR 1.x 字面上就是带 OpenCL 元数据的 LLVM IR,而 2015 年的 SPIR-V 改成了独立设计的二进制格式,切断了这层耦合。原因很实际——一个需要跨厂商、跨十年稳定的标准,不能把自己的格式绑在一个半年发一次大版本(2026 年 3 月已到 LLVM 22)的活跃项目上。
Rust 与 Swift。 两者都不是"用 LLVM 当编译器",而是"用 LLVM 当后端"。rustc 的路径是源码 → HIR → MIR(借用检查、单态化在这里)→ LLVM IR → 机器码;Swift 是源码 → AST → SIL(所有权、泛型特化)→ LLVM IR → 机器码。LLVM 负责的是最后那段:目标无关的标量优化、指令选择、寄存器分配、目标码生成。语言的灵魂在它上面那一层。
看清这一点,才算看清 LLVM IR 的位置:它不是"通用程序表示",它是指令集级优化这个特定问题的共享实现。它成功的原因恰恰是它放弃了通用——放弃了保留高层语义、放弃了跨平台可移植、放弃了成为运行时。它把自己收缩成一个足够窄、窄到可以被几十个团队共同依赖的接口。
跨域连接
- 翻译与可译性:三段式编译在结构上就是翻译学里的枢轴语方案——不做 M 种源语到 N 种目标语的直译,而是统统先译进一门中介语。但翻译理论早就指出枢轴语会截断源语独有的语法范畴,编译器版本的这一损失极其具体:所有权、数组边界、张量形状一旦降到
i32与ptr就无法复原,后端再聪明也拿不回没写进去的前提。Swift 造 SIL、Rust 造 MIR、最终走到 MLIR 的分层方言,正是在承认"单一中介语必然留下不可译的残余",于是把一级翻译改成多级下降。 - P 与 NP 问题:后端最核心的一步——把无限多的虚拟寄存器塞进十几个物理寄存器——被 Chaitin 等人在 1981 年归约成图着色,属 NP 完全问题。这条硬边界决定了 LLVM 的姿态:它不追求最优解,而是用贪心分配器加溢出启发式,换取可预测的编译耗时。相位排序面临同样的困境,"哪串 pass 序列对这段代码最好"没有可行的搜索空间,于是
-O2成了一条经验固化的固定流水线,而-O3偶尔比-O2慢正是这种放弃最优的直接后果。 - 半导体制造:三段式里的 N 不是常数,它由芯片产业决定。当 x86、Arm、RISC-V 以及各家 GPU 与 NPU 同时演进、每代都新增指令时,"每门语言各写一套后端"在人力上根本不成立,共享中端于是从优雅变成必需。反向的影响同样强:一种新架构要被采纳,前提条件之一就是有一个可用的 LLVM 后端,RISC-V 生态的起步很大程度上依赖这条路径——硬件的可选项,事实上被编译器基础设施先筛过一遍。
- 工业工程与质量管理:Pass 流水线是一条装配线,而 Verifier 是工位之间的检验站。把"每个使用点必须被其定义点支配""phi 的入边必须匹配前驱"做成机器可判定的不变量,等于把缺陷检出从终检(生成的机器码跑出错误结果)挪到工序间检验,定位成本因此从整条产线缩到单个 pass。Alive2 那类翻译验证工具再往前推了一步:不只检查产物是否合法,而是检查这次改写本身是否保语义——相当于从抽检成品转向验证工艺本身。
- 公共资源治理:几乎所有新语言都把后端押在同一套代码上,LLVM 于是成了典型的公共池资源:谁都受益,谁都想少出维护力。2019 年的 LLVM 9 起改用带例外条款的 Apache 2.0 许可证,本身就是一项治理设计——它保证嵌入 LLVM 的商业产品不必开源,从而把厂商从潜在的搭便车者转成有动机长期投入的共建者。代价是这块公地被少数大投入方的需求牵着走,冷门后端长期靠个别志愿者维护,一旦无人接手就会被移出主干。
参考文献
- Lattner, C. & Adve, V. LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation. CGO, 2004. (LLVM 与其 IR 设计的原始论文)
- Cytron, R., Ferrante, J., Rosen, B. K., Wegman, M. N. & Zadeck, F. K. Efficiently Computing Static Single Assignment Form and the Control Dependence Graph. ACM TOPLAS 13(4), 1991. (SSA 构造与支配边界算法的经典文献)
- Chaitin, G. J. Register Allocation and Spilling via Graph Coloring. SIGPLAN Symposium on Compiler Construction, 1982. (寄存器分配等价于图着色的构造与启发式)
- Lee, J. et al. Taming Undefined Behavior in LLVM. PLDI, 2017. (
undef与poison的语义梳理及由此产生的误编译) - Lopes, N. P., Lee, J., Hur, C.-K., Liu, Z. & Regehr, J. Alive2: Bounded Translation Validation for LLVM. PLDI, 2021. (自动验证优化改写是否保语义)
- Lattner, C. et al. MLIR: Scaling Compiler Infrastructure for the Domain of Machine Learning. CGO, 2021. (可扩展多层 IR 的动机与设计)
延伸阅读
- LLVM Project. LLVM Language Reference Manual. (IR 的规范定义,指令、类型与属性逐条列出)
- Lattner, C. LLVM, 收于 The Architecture of Open Source Applications, Vol. 1, 2011. (作者本人写的整体架构导览,讲三段式为何这样切)
- Pizlo, F. Introducing the B3 JIT Compiler. WebKit Blog, 2016. (从"为什么离开 LLVM"这一侧看编译时间的代价)