编译器是怎么把代码变成机器能懂的指令的?为什么 LLVM 能成为编译器基础设施的“瑞士军刀”?
如果你想深入理解编译器,或者想写自己的编译器 Pass,LLVM IR 是绕不开的一关。
📚 LLVM 核心概念速读
| LLVM |
Low Level Virtual Machine(原名),现为品牌名 |
模块化编译器基础设施 |
| IR |
Intermediate Representation |
编译器中间表示,连接前后端 |
| SSA |
Static Single Assignment |
每个变量只赋值一次 |
| CFG |
Control Flow Graph |
控制流图,描述程序执行路径 |
| Pass |
编译优化/分析单元 |
对 IR 进行变换的模块 |
| Basic Block |
基本块 |
顺序执行的指令序列,只有一个入口一个出口 |
🧩 什么是 LLVM
LLVM 不是某个具体编译器的名字,而是一套模块化的编译器基础设施。
传统编译器(如 GCC)采用三段式设计:
1 2
| Frontend → Optimizer → Backend 前端 优化器 后端
|
问题在于:各阶段耦合严重,想支持新语言要重写整套,想支持新架构也要改一堆。
LLVM 的核心创新:统一的中间表示(IR)
1 2 3 4
| Clang (C/C++) ──→ Swift ──→ Rust ──→ LLVM IR ──→ x86 / ARM / RISC-V ... Kotlin Native ──→
|
省流:前端把源码翻译成 IR,后端把 IR 翻译成机器码,中间全是 IR,想加语言加前端,想加架构加后端。
🔧 LLVM 编译流程
以 C 代码为例:
1 2 3
| int add(int a, int b) { return a + b; }
|
编译流程:
flowchart LR
A[源码 .c] --> B[预处理]
B --> C[词法/语法分析]
C --> D[语义分析]
D --> E[生成 IR]
E --> F[IR 优化]
F --> G[指令选择]
G --> H[寄存器分配]
H --> I[生成机器码 .s/.o]
对应命令:
1 2 3 4 5
| clang -emit-llvm -S add.c -o add.ll clang -emit-llvm -c add.c -o add.bc llc add.bc -o add.s clang add.s -o add
|
注意:.ll 是文本格式,.bc 是二进制格式,两者等价可互转。
📖 LLVM IR 结构
IR 文件由三部分组成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:64-n8:16:32:64-S128" target triple = "x86_64-pc-linux-gnu"
@global_var = global i32 42 declare i32 @printf(i8*, ...)
define i32 @add(i32 %a, i32 %b) { entry: %sum = add i32 %a, %b ret i32 %sum }
|
层级结构
1 2 3 4 5
| Module(模块) └── GlobalVariable(全局变量) └── Function(函数) └── BasicBlock(基本块) └── Instruction(指令)
|
类型系统
| 整数 |
i1, i8, i32, i64 |
i1 是 bool |
| 浮点 |
float, double |
32/64 位浮点 |
| 指针 |
i32*, i8** |
指向某类型的指针 |
| 数组 |
[10 x i32] |
固定大小数组 |
| 结构体 |
{i32, float} |
匿名结构体 |
| 向量 |
<4 x i32> |
SIMD 向量类型 |
| 函数 |
i32 (i32, i32)* |
函数指针 |
⚡ IR 指令基础
算术运算
1 2 3 4 5 6 7 8
| %a = add i32 %x, %y %b = sub i32 %x, %y %c = mul i32 %x, %y %d = sdiv i32 %x, %y %e = urem i32 %x, %y %f = and i1 %p, %q %g = or i1 %p, %q %h = xor i1 %p, %q
|
内存操作
1 2 3 4 5 6 7 8 9 10 11 12 13
| %ptr = alloca i32
%val = load i32, i32* %ptr
store i32 42, i32* %ptr
%arr = alloca [10 x i32] %elem = getelementptr [10 x i32], [10 x i32]* %arr, i64 0, i64 5
|
比较指令
1 2 3 4 5
| %cmp = icmp eq i32 %a, %b %cmp = icmp slt i32 %a, %b %cmp = icmp sgt i32 %a, %b %cmp = icmp sle i32 %a, %b %cmp = icmp ne i32 %a, %b
|
控制流
1 2 3 4 5 6 7 8 9 10 11 12
| br label %target
br i1 %cond, label %if_true, label %if_false
%result = call i32 @func(i32 %arg1, i32 %arg2)
ret i32 %result ret void
|
🔄 SSA 与变量
SSA(Static Single Assignment)是 LLVM IR 的核心特性。
规则:每个变量只能被赋值一次。
1 2 3 4 5 6 7
| %x = add i32 %a, %b %x = add i32 %x, %c
%x1 = add i32 %a, %b %x2 = add i32 %x1, %c
|
这有什么好处?
- 简化数据流分析
- 便于检测未定义变量
- 优化 Pass 更容易实现
比喻:SSA 就像“一次性的便签纸”,写完一张就换新的,不用擦掉重写。
那循环怎么办?
1 2 3 4 5
| int sum = 0; for (int i = 0; i < 10; i++) { sum = sum + i; }
|
LLVM 用 phi 指令 解决这个问题。
🔀 phi 指令
phi 指令根据控制流来源选择不同的值。
1 2 3 4
| %result = phi i32 [ %val1, %block1 ], [ %val2, %block2 ]
|
示例:循环求和
1 2 3 4
| int sum = 0; for (int i = 0; i < 3; i++) { sum += i; }
|
对应的 IR:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| define i32 @loop_sum() { entry: br label %loop
loop: %i = phi i32 [ 0, %entry ], [ %i.next, %loop ] %sum = phi i32 [ 0, %entry ], [ %sum.next, %loop ]
%sum.next = add i32 %sum, %i %i.next = add i32 %i, 1 %cond = icmp slt i32 %i.next, 3
br i1 %cond, label %loop, label %exit
exit: ret i32 %sum.next }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ┌─────────┐ │ entry │ └────┬────┘ ↓ ┌─────────────────────────────────────┐ │ loop │ │ %i = phi [0, entry], [%i.next, loop] │ │ %sum = phi [0, entry], [%sum.next, loop] │ │ ...计算... │ │ br %cond, loop, exit │ └────┬────────────────────────────────┘ ↓ (cond=false) ┌─────────┐ │ exit │ └─────────┘
|
省流:phi 就是个“多路选择器”,根据你是从哪个块跳过来的,决定用哪个值。
📊 控制流图 CFG
CFG(Control Flow Graph)描述程序可能的执行路径。
- 节点:基本块(Basic Block)
- 边:可能的跳转
基本块规则
- 只有一个入口(只能从开头进入)
- 只有一个出口(最后一条指令是终结符)
- 内部没有分支
终结符指令
ret |
函数返回 |
br |
跳转(条件/无条件) |
switch |
多路分支 |
unreachable |
不可达代码 |
可视化 CFG
1 2 3 4
| opt -passes=dot-cfg add.ll -disable-output
dot -Tpng add.dot -o add.png
|
🌳 Dominator
Dominator(支配)是控制流分析的核心概念。
定义:节点 A 支配 节点 B,当且仅当从入口到 B 的所有路径都经过 A。
1 2 3 4 5 6 7
| entry │ A ╱ ╲ B C ╲ ╱ D
|
- A 支配 B、C、D
- B 不支配 D(可以从 C 走)
- D 支配 D 自己
相关概念
| 支配者(Dominator) |
所有到 B 的路径都经过 A,则 A dom B |
| 直接支配者(IDom) |
最近的支配者 |
| 支配树 |
以 IDom 关系构建的树 |
| 支配边界 |
A 支配但 A 的支配者不支配的节点集合 |
用途
- SSA 构建:phi 指令插入位置由支配边界决定
- 循环分析:识别循环头和循环体
- 优化:代码移动、冗余删除
🔩 LLVM Pass
Pass 是对 IR 进行变换或分析的基本单元。
Pass 类型
| Analysis Pass |
分析 IR,不修改 |
求支配树、活跃变量分析 |
| Transform Pass |
修改 IR |
常量折叠、死代码删除 |
| Utility Pass |
工具性质 |
验证 IR 正确性 |
常用优化 Pass
1 2 3 4 5 6 7
| opt -passes=default add.ll -S -o add_opt.ll
opt -passes=mem2reg add.ll -S -o add_opt.ll opt -passes=dce add.ll -S -o add_opt.ll opt -passes=inline add.ll -S -o add_opt.ll
|
Pass 管道
1 2
| opt -passes='mem2reg,dce,gvn' add.ll -S -o add_opt.ll
|
flowchart LR
A[原始 IR] --> B[mem2reg]
B --> C[dce]
C --> D[gvn]
D --> E[优化后 IR]
Metadata 是附加在 IR 上的额外信息,不影响程序语义,但能指导优化和代码生成。
语法
1 2 3 4 5 6 7 8 9 10
| define void @foo() !dbg !4 { %x = add i32 %a, %b, !dbg !7 ret void }
!0 = !DIFile(filename: "test.c", directory: "/home") !1 = !{!0}
|
常见用途
!dbg |
调试信息 |
!tbaa |
类型别名分析 |
!llvm.loop |
循环优化提示 |
!nontemporal |
非临时内存访问 |
示例:循环优化提示
1 2 3 4 5 6 7 8 9
| for.body: %i = phi i64 [ 0, %entry ], [ %inc, %for.body ] %inc = add i64 %i, 1 %cmp = icmp ult i64 %inc, %n br i1 %cmp, label %for.body, label %for.end, !llvm.loop !0
!0 = !{!0, !1} !1 = !{!"llvm.loop.unroll.count", i32 4}
|
🛠️ LLVM 工具链
clang |
C/C++ 编译器前端 |
opt |
IR 优化器 |
llc |
IR → 汇编 |
llvm-as |
.ll → .bc(文本转二进制) |
llvm-dis |
.bc → .ll(二进制转文本) |
llvm-link |
链接多个 bitcode |
lli |
解释执行 bitcode |
llvm-config |
查询编译选项 |
常用命令速查
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| opt -passes='default<O2>' -debug-pass=Structure add.ll -o /dev/null
llvm-dis add.bc -o add.ll
llvm-link a.bc b.bc -o combined.bc
lli add.bc
opt -passes=print-callgraph add.ll -disable-output
|
📋 完整 IR 示例
C 源码:
1 2 3 4 5 6 7 8 9
| int factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); }
int main() { return factorial(5); }
|
生成并查看 IR:
1
| clang -emit-llvm -S -O0 factorial.c -o factorial.ll
|
完整 IR:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| source_filename = "factorial.c" target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:64-n8:16:32:64-S128" target triple = "x86_64-pc-linux-gnu"
declare i32 @printf(i8*, ...)
define i32 @factorial(i32 %n) { entry: %n.addr = alloca i32 store i32 %n, i32* %n.addr
%0 = load i32, i32* %n.addr %cmp = icmp sle i32 %0, 1 br i1 %cmp, label %if.then, label %if.else
if.then: br label %return
if.else: %1 = load i32, i32* %n.addr %sub = sub nsw i32 %1, 1 %call = call i32 @factorial(i32 %sub) %2 = load i32, i32* %n.addr %mul = mul nsw i32 %2, %call br label %return
return: %retval.0 = phi i32 [ 1, %if.then ], [ %mul, %if.else ] ret i32 %retval.0 }
define i32 @main() { entry: %call = call i32 @factorial(i32 5) ret i32 %call }
|
优化后(-O2)
1
| clang -emit-llvm -S -O2 factorial.c -o factorial_opt.ll
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| define i32 @factorial(i32 %n) { entry: %cmp = icmp sgt i32 %n, 1 br i1 %cmp, label %if.else, label %return
if.else: %sub = add nsw i32 %n, -1 %call = tail call i32 @factorial(i32 %sub) %mul = mul nsw i32 %call, %n br label %return
return: %retval.0 = phi i32 [ %mul, %if.else ], [ 1, %entry ] ret i32 %retval.0 }
define i32 @main() { entry: ret i32 120 }
|
注意:main() 的返回值被编译期算出来了(5! = 120),这就是常量传播 + 函数内联的效果。
📋 总结
| LLVM 架构 |
前端→IR→后端,IR 是核心 |
| IR 结构 |
Module → Function → BasicBlock → Instruction |
| SSA |
每个变量只赋值一次,简化分析 |
| phi |
根据控制流来源选择值,解决 SSA 的分支问题 |
| CFG |
基本块 + 跳转边,描述程序执行路径 |
| Dominator |
支配关系,用于 SSA 构建和优化 |
| Pass |
IR 变换的基本单元 |
| 工具链 |
clang、opt、llc、lli 等 |
LLVM IR 看起来繁琐,但它为编译器提供了统一的表达。理解 IR,就拿到了编译器世界的“通用护照”。
下一步?试试写一个 LLVM Pass,或者用 LLVM IR 实现一个简单的语言解释器!
参考资源: