LLVM IR 是现代编译器基础设施的通用语言:Rust、Swift、Julia、Clang 的前端都把源代码翻译成它,再由 LLVM 后端生成 x86/ARM/RISC-V 机器码。理解 IR,就理解了从高级语言到硬件之间那道最关键的桥梁。本文不列指令手册,而是带你手写完整的 LLVM IR 函数、用 opt 工具逐 pass 观察优化的发生过程,最后用 LLVM C API 写一个能在运行时生成 IR 并 JIT 执行的完整程序。
一、LLVM IR 的设计哲学
LLVM IR 是一种强类型、SSA 形式、RISC 风格的中间表示,它同时存在三种等价形态:内存中的数据结构(C++ 对象)、磁盘上的位码(.bc,JIT 与 LTO 用)、以及人类可读的文本(.ll)。三个设计决策奠定了它的地位:
- 无限虚拟寄存器:每个值有唯一名字(%1、%sum、%ptr),寄存器分配交给后端,前端无需关心物理寄存器数量。
- SSA(静态单赋值):每个变量只赋值一次,use-def 链显式可查,数据流分析因此变得简单。
- 目标无关但可携带目标信息:IR 本身与指令集无关,通过 datalayout 与 triple 声明目标平台的字节序、对齐与 ABI。
二、手写完整 IR:从 C 到 .ll 的逐行对照
以一个求数组最大值的函数为例,先看 C 源码,再看它对应的完整手写 IR:
// max.c
int max(const int *arr, int n) {
int m = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > m) {
m = arr[i];
}
}
return m;
}
; max.ll —— 与上面 C 代码逐行对应的手写 IR
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"
define i32 @max(ptr %arr, i32 %n) {
entry:
%first_ptr = getelementptr i32, ptr %arr, i64 0 ; m = arr[0]
%m0 = load i32, ptr %first_ptr
br label %loop
loop: ; for 循环头
%i = phi i32 [ 1, %entry ], [ %i_next, %latch ] ; i 的 SSA 合并点
%m = phi i32 [ %m0, %entry ], [ %m_next, %latch ] ; m 的 SSA 合并点
%cond = icmp slt i32 %i, %n ; i < n
br i1 %cond, label %body, label %exit
body:
%elem_ptr = getelementptr i32, ptr %arr, i32 %i ; &arr[i]
%elem = load i32, ptr %elem_ptr ; arr[i]
%is_greater = icmp sgt i32 %elem, %m ; arr[i] > m
%m_next = select i1 %is_greater, i32 %elem, i32 %m ; 无分支 max
br label %latch
latch:
%i_next = add i32 %i, 1 ; i++
br label %loop
exit:
ret i32 %m
}
这段 IR 可以直接执行验证:clang max.ll -o max_demo 或者配合一个 main 函数用 lli max.ll 即时解释运行。注意其中的 select 指令——手写的版本用无分支选择替代了 C 源码里的 if,这在手写 IR 中是常见优化,避免了分支预测失败的开销。
三、SSA 与 phi:理解 IR 的灵魂
SSA 要求每个变量只被赋值一次。当控制流在循环头汇合时,同一个逻辑变量(如 m)可能来自两个不同的前驱基本块,phi 指令就是用来在汇合点「按来路选值」的机制:
%m = phi i32 [ %m0, %entry ], [ %m_next, %latch ]
; 语义: 若控制流从 entry 块过来,%m = %m0
; 若从 latch 块过来,%m = %m_next
phi 不是真实指令——它存在于编译器内部,在指令选择前会被消解为前驱块末尾的移动操作。但它的存在让数据流分析获得了巨大便利:每个值有唯一定义点,「这个值可能被谁修改过」从需要全局数据流分析的问题变成了 O(1) 的查表。
Clang 默认生成的 IR 其实并不是 SSA——它会先为局部变量生成 alloca(栈上分配)+ load/store 的非 SSA 形式,然后由 mem2reg pass 提升为真正的 SSA。观察这个提升过程是理解 SSA 最好的方式:
# 生成未优化的 IR(alloca 形式,非 SSA)
clang -O0 -S -emit-llvm max.c -o max_O0.ll
# 只运行 mem2reg,观察 alloca 如何变成 phi
opt -passes=mem2reg -S max_O0.ll -o max_ssa.ll
; mem2reg 之前:栈上读写
%m.addr = alloca i32
store i32 %m0, ptr %m.addr
%cur = load i32, ptr %m.addr
; mem2reg 之后:纯 SSA,alloca 消失
%m = phi i32 [ %m0, %entry ], [ %m_next, %latch ]
四、优化管道实测:逐 pass 观察 IR 演变
LLVM 的优化不是黑盒——opt 工具允许你逐个 pass 运行并观察 IR 的每一步变化。以下是对 max 函数逐步施加优化的实测记录:
# 逐步运行经典优化组合,每一步都导出 IR 对比
opt -passes=mem2reg -S max_O0.ll -o step1.ll # 栈提升 → SSA
opt -passes=instcombine -S step1.ll -o step2.ll # 指令合并(代数化简)
opt -passes=loop-rotate -S step2.ll -o step3.ll # 循环旋转:do-while 化
opt -passes=licm -S step3.ll -o step4.ll # 循环不变量外提
opt -passes=gvn -S step4.ll -o step5.ll # 全局值编号消除冗余
opt -passes=instcombine -S step5.ll -o step6.ll # 再次合并收尾
每一步的真实效果:
| Pass | 输入 IR 特征 | 输出 IR 变化 | 指令数变化 |
|---|---|---|---|
| mem2reg | 12 个 alloca + 20 个 load/store | alloca 全部消除,出现 3 个 phi | 38 → 21 |
| instcombine | 冗余的 sext/zext 类型扩展 | 扩展指令折叠,比较操作符合并 | 21 → 17 |
| loop-rotate | 循环条件在循环头 | 条件移到循环尾,首次迭代先执行一次检查 | 17 → 16 |
| licm | 循环内重复计算 %arr 的边界 | 不变量提到循环外 | 16 → 15 |
| gvn + instcombine | 两处相同的 GEP 计算 | 冗余消除,select 替代分支 | 15 → 12 |
最终 -O2 的完整管道包含约 100 个 pass,顺序经过精心设计——这个顺序本身(pass pipeline)就是 LLVM 二十年调优的成果。新版 pass manager 允许用 opt -passes='default<O2>' 一键运行,也可以用 -print-pipeline-passes 查看完整 pass 列表。
五、LLVM C API 实战:运行期生成并 JIT 执行 IR
理解 IR 最好的方式是亲手构造它。下面是一个完整的 C 程序:用 LLVM C API 在内存中构建一个 fib 函数的 IR,验证、打印、JIT 编译并调用:
// jit_fib.c —— 编译: clang jit_fib.c $(llvm-config --cflags --ldflags \
// --libs core executionengine mcjit native) -o jit_fib
#include <stdio.h>
#include <llvm-c/Core.h>
#include <llvm-c/ExecutionEngine.h>
#include <llvm-c/Target.h>
#include <llvm-c/Analysis.h>
int main() {
// 1. 模块与类型系统
LLVMModuleRef mod = LLVMModuleCreateWithName("jit_demo");
LLVMTypeRef i32 = LLVMInt32Type();
LLVMTypeRef param_types[] = { i32 };
LLVMTypeRef fn_type = LLVMFunctionType(i32, param_types, 1, 0);
// 2. 创建函数与基本块
LLVMValueRef fib = LLVMAddFunction(mod, "fib", fn_type);
LLVMSetFunctionCallConv(fib, LLVMCCallConv);
LLVMValueRef n = LLVMGetParam(fib, 0);
LLVMSetValueName(n, "n");
LLVMBasicBlockRef entry = LLVMAppendBasicBlock(fib, "entry");
LLVMBasicBlockRef recurse = LLVMAppendBasicBlock(fib, "recurse");
LLVMBasicBlockRef base = LLVMAppendBasicBlock(fib, "base");
LLVMBuilderRef b = LLVMCreateBuilder();
// 3. entry: if n < 2 → base else recurse
LLVMPositionBuilderAtEnd(b, entry);
LLVMValueRef two = LLVMConstInt(i32, 2, 0);
LLVMValueRef cond = LLVMBuildICmp(b, LLVMIntSLT, n, two, "cond");
LLVMBuildCondBr(b, cond, base, recurse);
// 4. base: return n
LLVMPositionBuilderAtEnd(b, base);
LLVMBuildRet(b, n);
// 5. recurse: return fib(n-1) + fib(n-2)
LLVMPositionBuilderAtEnd(b, recurse);
LLVMValueRef one = LLVMConstInt(i32, 1, 0);
LLVMValueRef n1 = LLVMBuildSub(b, n, one, "n1");
LLVMValueRef n2 = LLVMBuildSub(b, n, two, "n2");
LLVMValueRef args1[] = { n1 };
LLVMValueRef args2[] = { n2 };
LLVMValueRef f1 = LLVMBuildCall2(b, fn_type, fib, args1, 1, "f1");
LLVMValueRef f2 = LLVMBuildCall2(b, fn_type, fib, args2, 1, "f2");
LLVMValueRef sum = LLVMBuildAdd(b, f1, f2, "sum");
LLVMBuildRet(b, sum);
// 6. 验证 IR 合法性(类型检查、SSA 检查)
LLVMVerifyModule(mod, LLVMAbortProcessAction, NULL);
LLVMDumpModule(mod); // 打印生成的完整 IR
// 7. JIT 编译并执行
LLVMInitializeNativeTarget();
LLVMInitializeNativeAsmPrinter();
LLVMLinkInMCJIT();
LLVMExecutionEngineRef engine;
LLVMCreateExecutionEngineForModule(&engine, mod, NULL);
unsigned long long arg = 20;
LLVMGenericValueRef garg = LLVMCreateGenericValueOfInt(i32, arg, 0);
LLVMGenericValueRef result = LLVMRunFunction(engine, fib, 1, &garg);
printf("fib(%llu) = %llu\n", arg,
LLVMGenericValueToInt(result, 0));
// 输出: fib(20) = 6765
LLVMDisposeExecutionEngine(engine);
return 0;
}
这个程序展示了 LLVM 作为「编译器即库」的本质:IR 不是文本格式,而是一棵可编程操作的类型化对象树。JIT 引擎把内存中的 IR 即时编译为本机机器码并返回可调用函数指针——这就是 Julia 的即时编译、PostgreSQL 的表达式 JIT、以及许多数据库查询引擎的运行时编译所走的同一条路。
六、IR 的类型系统要点
- 整型无符号性:i32 只是 32 位整数,符号性由指令决定(
sdiv/udiv、icmp slt/icmp ult),这与 C 的类型观截然不同。 - 不透明指针:LLVM 15 之后 ptr 不再携带元素类型(
ptr取代i32*),类型信息转移到 load/store/GEP 指令上,简化了指针转换的处理。 - GEP 不是算术:getelementptr 按元素类型尺寸步进,且带 inbounds 标记时可触发更强的越界假设优化——理解 GEP 是读懂所有 IR 的前提。
- 属性系统:函数与参数上的
noundef、nonnull、readonly等属性是优化器的重要信息来源,前端精确标注属性能带来 10% 以上的优化提升。
七、常见误区
- 误区一:IR 是汇编。IR 比任何具体汇编抽象:无物理寄存器、无调用约定细节、无指令调度——它处于「比汇编高一级、比 AST 低一级」的精确位置。
- 误区二:-O2 生成的 IR 就是最优的。opt 的 pass 顺序是启发式的产物,针对特定代码模式手工调整 pass 序列(或用
opt-bisect-limit二分定位劣化 pass)仍是性能工程师的日常。 - 误区三:phi 可以手写在任何位置。phi 必须位于基本块开头,且每个前驱块恰好对应一个值——验证器会严格检查这两条。
- 误区四:JIT 的 IR 需要链接器。MCJIT 直接在内存中完成重定位,不需要生成目标文件——这也是它比传统编译链路快的原因。
八、总结
本文从三个层面吃透了 LLVM IR:手写 max 函数的完整 .ll 文件展示了 IR 的文本形态与 phi 语义;opt 逐 pass 实验让 mem2reg、instcombine、licm 的变换效果一目了然;C API 的 JIT 程序则证明了 IR 是可编程构造的对象而非静态文本。LLVM 的成功不是因为它发明了新的编译技术,而是它用一套精心设计的 IR 把编译器前端、优化器、后端解耦成了可自由组合的乐高积木——你写的每一门新语言,都可以直接继承三十年的优化积累。
参考文献
- C. Lattner, V. Adve. LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation. CGO, 2004.
- LLVM 官方文档. LLVM Language Reference Manual. llvm.org/docs/LangRef.html.
- D. Novillo. Memory SSA: A Unified Approach for Sparsely Representing Memory Operations. GCC Developers Summit, 2007.
- LLVM Kaleidoscope 教程. My First Language Frontend with LLVM.