目标:建立编译器全流程概念,搭好 LLVM 环境,深入理解 IR、SSA、CFG 三大核心。今天学完你应该能:看懂任意 LLVM IR、手绘 CFG、解释 SSA 的 φ 节点、独立搭建 LLVM 开发环境。
1. 编译器的阶段划分(龙书第 1 章)
1.1 编译器是一个翻译流水线
编译器不是"一个程序",而是一组阶段的串联。每个阶段把程序从一种表示翻译成下一种:
源程序(字符流)
│
▼
┌──────────┐
│ 词法分析 │ 把字符切成 Token: "x=42" → IDENT(x) ASSIGN NUMBER(42)
└──────────┘
│ Token流
▼
┌──────────┐
│ 语法分析 │ 把 Token 建成树: 生成 AST (Abstract Syntax Tree)
└──────────┘
│ AST
▼
┌──────────┐
│ 语义分析 │ 类型检查、作用域解析、符号表填充
└──────────┘
│ 标注的AST
▼
┌──────────┐
│中间代码生成│ AST → 三地址码 / LLVM IR (与语言无关,与机器无关)
└──────────┘
│ IR
▼
┌──────────┐
│ 代码优化 │ 对 IR 做机器无关优化(死代码消除、循环优化...)
└──────────┘
│ 优化后的IR
▼
┌──────────┐
│ 代码生成 │ IR → 目标机器码(指令选择、寄存器分配、指令调度)
└──────────┘
│ 汇编/机器码
▼
目标程序
核心认知:三个阶段构成三段式设计(Three-Phase Design):
前端(语言相关) 中端(机器无关优化) 后端(机器相关)
C ─┐ ┌── x86
C++ ─┤ ├── ARM
Rust ─┼──→ LLVM IR ──→ 优化的LLVM IR ──→├── RISC-V
Swift ─┤ ├── BPF
... ─┘ └── ...
这就是为什么 LLVM 能支持几十种语言和几十种 CPU——前端和后端完全解耦,它们只通过 IR 交流。给 LLVM 添加一个新语言只需要写前端,添加一个新 CPU 只需要写后端,中端优化对所有人免费。
1.2 符号表——贯穿全程的核心数据结构
每个阶段都可能用到符号表:
// 符号表条目的核心字段
struct Symbol {
char *name; // "x"
Type *type; // int
Scope *scope; // 全局 / 函数内 / 块内
union {
int int_val; // 如果编译期能确定值
int offset; // 栈帧中的偏移(后端填入)
void *llvm_val; // LLVM Value 指针(中端填入)
};
};
词法分析器往符号表里插入名字,语义分析器往符号表里写入类型,代码生成器往符号表里写入栈偏移。三个阶段通过同一个符号表传递信息——这就是龙书说的"符号表贯穿全程"。
1.3 遍(Pass)的概念
一个"遍" = 从头到尾读完整个中间表示并做某件事。编译器通常把多个阶段组织为若干遍:
-O0: 前端 → 后端 (越快越好,不做优化)
-O2: 前端 → mem2reg → GVN → LICM → ... → 后端 (几十个优化遍)
每个 Pass 只做一件事。LLVM 的 Pass 体系(New PassManager)支持:
- 分析 Pass:计算信息供其他 Pass 用(支配树、循环信息、活跃变量…)
- 变换 Pass:改写 IR(死代码消除、内联、循环展开…)
一个关键设计:分析 Pass 的结果可以被缓存。比如支配树被 10 个变换 Pass 用到,它只算一次——PassManager 负责管理依赖和失效。
2. LLVM 环境搭建
2.1 安装 LLVM
Windows (推荐通过包管理器):
# 用 winget
winget install LLVM.LLVM
# 或者 choco
choco install llvm
Linux (Ubuntu):
sudo apt install llvm-18 clang-18 lld-18
# 或者下载预编译二进制: https://github.com/llvm/llvm-project/releases
macOS:
brew install llvm@18
安装完验证:
clang --version # 前端编译器
opt --version # IR 优化器
llc --version # 静态编译器(IR→汇编)
lli --version # IR 解释器/JIT执行器
llvm-as --version # IR文本→IR二进制
llvm-dis --version # IR二进制→IR文本
2.2 第一个 LLVM IR:从 C 到 IR
写一个最小的 C 程序:
// hello.c
int add(int a, int b) {
return a + b;
}
生成并阅读 IR:
# -S: 输出文本; -emit-llvm: 输出 LLVM IR; -O0: 不做优化
clang -S -emit-llvm -O0 hello.c -o hello.ll
cat hello.ll
生成的 LLVM IR(手动精简注释版):
; Module 级别信息
; 注意: 下面展示的是 -O0 的 IR(内存形式),并非真正的 SSA
define i32 @add(i32 %a, i32 %b) #0 {
entry:
; alloca = 在栈上分配空间。这是 C 变量 "a" 和 "b" 在栈上的家
%a.addr = alloca i32, align 4 ; 给参数 a 分配栈空间
%b.addr = alloca i32, align 4 ; 给参数 b 分配栈空间
; 把函数参数存到栈上(因为 -O0 不做优化,每个变量都必须有栈地址)
store i32 %a, ptr %a.addr, align 4
store i32 %b, ptr %b.addr, align 4
; 从栈上加载变量值到临时寄存器
%0 = load i32, ptr %a.addr, align 4
%1 = load i32, ptr %b.addr, align 4
; 真正的加法
%add = add nsw i32 %0, %1
; 返回结果
ret i32 %add
}
; 属性信息(内联策略、栈保护等)
attributes #0 = { noinline nounwind ... }
关键观察:-O0 下的 IR 把每个 C 变量映射到一块栈内存(alloca),每次读写都显式 load/store。这不是真正的 SSA——%a.addr 这个指针才是 “变量”,它可以被 store 多次;SSA 约束仅作用于虚拟寄存器,栈内存属于可改写存储,不受 SSA 规则管控,必须经由 Mem2Reg 优化消除内存操作,才能将 C 语言变量转化为原生 SSA 值。
现在用 mem2reg 提升为真正的 SSA:
# 跑 mem2reg pass,把 alloca/load/store 提升为 SSA 寄存器
opt -passes=mem2reg hello.ll -S -o hello_ssa.ll
cat hello_ssa.ll
define i32 @add(i32 %a, i32 %b) #0 {
entry:
; alloca/load/store 全部消失了!
; %a 和 %b 直接就是 SSA 寄存器——不需要栈
%add = add nsw i32 %a, %b
ret i32 %add
}
从 5 条指令变成 1 条。这就是 mem2reg 的价值,也是理解 SSA 的关键:当你用 alloca 模拟可变变量时,mem2reg 能自动分析并消除这些冗余操作。
2.3 完整的编译管线演练
# 一条命令走完所有阶段
clang hello.c -S -emit-llvm -o hello.ll # C → LLVM IR (文本)
opt hello.ll -S -passes=mem2reg -o hello_ssa.ll # IR → 优化IR
llc hello_ssa.ll -o hello.s # 优化IR → 汇编
clang hello.s -o hello # 汇编 → 可执行文件
./hello # 运行
# 或者一步到位:
clang hello.c -O2 -o hello && ./hello
# 查看每个优化阶段的 IR:
clang hello.c -S -emit-llvm -O2 -o - # 最终优化结果
clang hello.c -mllvm -print-after-all -O2 2>&1 | less # 每步的IR变化
3. LLVM IR 深度解析(LLVM 书第 2 章)
3.1 LLVM IR 的三种形态
内存表示(C++对象) ← 编译器内部操作,各种 Pass 读写的形态
│ llvm::WriteBitcodeToFile()
▼
位码(.bc 文件) ← 二进制格式,紧凑高效,适合 linker 间传递
│ llvm-dis
▼
文本 IR(.ll 文件) ← 人类可读,调试和学习用
实际工作中:
- 写 Pass → 操作内存表示(
llvm::Instruction,llvm::BasicBlock等 C++ 对象) - 调试验证 → 看
.ll文件,或在 Pass 中F.dump()打印 IR - LTO 链接时优化 → 用
.bc文件传递
3.2 LLVM IR 的类型系统
; 基础类型
i1 ; 1 位整数(布尔值)
i8 ; 8 位整数(char)
i32 ; 32 位整数(int)
i64 ; 64 位整数(long long)
float ; 32 位浮点
double ; 64 位浮点
void ; 无类型(用于无返回值的函数)
; 指针类型
ptr ; 不透明指针(LLVM 15+ 默认,不指定指向什么)
i32* ; 指向 i32 的指针(LLVM 14 及之前)
; 聚合类型
[i32 x 4] ; 4 个 i32 的数组
{i32, double, i8} ; 结构体
<4 x float> ; 4 个 float 的向量(SIMD 用)
; 函数类型
i32 (i32, i32) ; 接受两个 i32,返回 i32 的函数类型
3.3 SSA(静态单赋值)—— LLVM IR 的灵魂
问题:为什么要 SSA?
传统三地址码中,一个变量可以被赋值多次:
// 传统三地址码
x = 1 // 定义 1
...
x = x + 1 // 定义 2 —— x 现在指向另一个值了
y = x * 2 // 用的是定义 2 的 x
编译器想回答"y 用了哪个 x 的值?"就必须做复杂的 use-def 分析。
SSA 解决这个问题:每个变量只被赋值一次,所以每个 use 的 def 是明确的——不需要分析,直接就是指针。
; SSA 形式
%x1 = 1 ; 定义 1 —— 永远不会被"覆盖"
...
%x2 = add %x1, 1 ; 定义 2 —— 全新变量
%y = mul %x2, 2 ; 用的绝对是 %x2
从普通代码构造 SSA:两个问题要解决
问题 1:同一个变量在不同路径赋值
if (cond) {
x = 1; // 路径 A
} else {
x = 2; // 路径 B
}
// x 应该是什么?
use(x); // 到达这里时,x 可能来自路径 A 或 B
解决方案:φ 函数(phi function):
entry:
br i1 %cond, label %then, label %else
then: ; 路径 A
br label %merge
else: ; 路径 B
br label %merge
merge:
%x = phi i32 [ 1, %then ], [ 2, %else ] ; φ 节点!
; phi i32 的意思是:我是 i32 类型
; [1, %then] 的意思是:如果从 %then 来,我的值是 1
; [2, %else] 的意思是:如果从 %else 来,我的值是 2
call void @use(i32 %x)
φ 的语义:根据实际执行路径选择对应值。在合并点,φ 节点同时存在多个可能的来源,但它是一个确定性选择——走到 merge 时,具体从哪个前驱来的是已知的。
问题 2:循环中的变量
int i = 0;
while (i < 10) {
i = i + 1; // i 被重新赋值了!
}
entry:
br label %loop
loop:
%i = phi i32 [ 0, %entry ], [ %i.next, %loop ] ; 关键!
; 第一次从 entry 来 → i = 0
; 之后从 loop 来 → i = %i.next(上一轮迭代的值)
%cond = icmp slt i32 %i, 10
br i1 %cond, label %body, label %exit
body:
%i.next = add nsw i32 %i, 1 ; i + 1 = 新定义(SSA 要求的唯一赋值)
br label %loop ; 跳回 loop,%i.next 成为 phi 的新参数
exit:
ret i32 %i
这段 IR 每行理解:
%i = phi i32 [ 0, %entry ], [ %i.next, %loop ]→ 循环的入口值%i.next = add nsw i32 %i, 1→ 迭代后的新值,这是 SSA 中唯一的"i+1"定义br label %loop→ 跳回去,此时 phi 节点选[ %i.next, %loop ]- 循环中的
%i从不被修改,每次迭代产生新的%i.next——这就是 SSA 的本质
3.4 基本块(Basic Block)与控制流图(CFG)
定义
基本块 = 一段直线代码,只有第一条指令是入口,只有最后一条指令是出口(terminator)。
; 这是一个基本块
%0 = load i32, ptr %p
%1 = add i32 %0, 1
store i32 %1, ptr %p
br label %next ; terminator 指令 —— 必须是块内最后一条
Terminator 指令:LLVM 中只有以下几种可以结束基本块:
| 指令 | 含义 |
|---|---|
br label %target | 无条件跳转 |
br i1 %cond, label %true, label %false | 条件跳转 |
switch i32 %val, label %default [ i32 1, label %case1 ... ] | 多路跳转 |
ret i32 %val | 返回 |
ret void | 无值返回 |
unreachable | 不可达(UB 触发后的标记) |
invoke … | 带异常处理的调用 |
画 CFG
控制流图(CFG)= 节点是基本块,边是跳转。
int abs(int x) {
if (x >= 0)
return x;
else
return -x;
}
LLVM IR 和对应的 CFG:
define i32 @abs(i32 %x) {
entry:
%cmp = icmp sge i32 %x, 0 ; x >= 0 ?
br i1 %cmp, label %if.then, label %if.else
if.then:
ret i32 %x ; 返回 x
if.else:
%neg = sub i32 0, %x ; 0 - x = -x
ret i32 %neg
}
CFG(ASCII 图):
[entry]
x >= 0?
/ \
true false
/ \
[if.then] [if.else]
ret x %neg=0-x
ret %neg
一个稍复杂的 CFG(循环):
int sum(int n) {
for (int s = 0,i = 1; i <= n; i++)
s += i;
return s;
}
define i32 @sum(i32 %n) {
entry:
br label %for.cond ; 进入循环条件判断
for.cond: ; 循环头
%s = phi i32 [ 0, %entry ], [ %s.next, %for.inc ]
%i = phi i32 [ 1, %entry ], [ %i.next, %for.inc ]
%cmp = icmp sle i32 %i, %n
br i1 %cmp, label %for.body, label %for.end
for.body: ; 循环体
%s.next = add nsw i32 %s, %i
br label %for.inc
for.inc: ; i++ 块
%i.next = add nsw i32 %i, 1
br label %for.cond ; 回边!
for.end: ; 循环出口
ret i32 %s
}
CFG:
[entry]
│
▼
┌[for.cond]◄──────┐
│ s=φ(0,s.next) │
│ i=φ(1,i.next) │ 回边
│ i <= n? │
└──┬──────┬──────┘
true false
│ │
▼ ▼
[for.body] [for.end]
s.next=s+i ret s
│
▼
[for.inc]
i.next=i+1
│
└──────────┘
手动画 CFG 的技巧:
- 每个
label定义一个新基本块的入口 - 每条
br是基本块的出口 - 从 entry 开始,顺着 br 画箭头
- 回边(朝循环头方向的边)就是循环结构
3.5 LLVM IR 指令全览
下面列出你会在 Day2-Day4 中最常遇到的指令。每条都值得记:
; === 终结符指令(总是块的最后一条) ===
br label %dest ; 无条件跳转
br i1 %cond, label %t, label %f ; 条件跳转
ret i32 %val ; 返回值
ret void ; 返回空
switch i32 %v, label %default [i32 0, label %c0, i32 1, label %c1]
unreachable ; 不可达
; === 一元运算 ===
%x = fneg float %y ; 浮点取负
; === 二元运算 ===
%x = add i32 %a, %b ; 整数加
%x = sub i32 %a, %b ; 整数减
%x = mul i32 %a, %b ; 整数乘
%x = sdiv i32 %a, %b ; 有符号除
%x = udiv i32 %a, %b ; 无符号除
%x = srem i32 %a, %b ; 有符号取余
%x = urem i32 %a, %b ; 无符号取余
%x = fadd float %a, %b ; 浮点加
%x = fmul float %a, %b ; 浮点乘
; === 位运算 ===
%x = shl i32 %a, 3 ; 左移
%x = lshr i32 %a, 3 ; 逻辑右移(高位补0)
%x = ashr i32 %a, 3 ; 算术右移(高位补符号位)
%x = and i32 %a, %b ; 按位与
%x = or i32 %a, %b ; 按位或
%x = xor i32 %a, %b ; 按位异或
; === 比较 ===
%x = icmp eq i32 %a, %b ; 整数等于
%x = icmp ne i32 %a, %b ; 整数不等
%x = icmp sgt i32 %a, %b ; 有符号大于
%x = icmp sge i32 %a, %b ; 有符号大于等于
%x = icmp slt i32 %a, %b ; 有符号小于
%x = icmp sle i32 %a, %b ; 有符号小于等于
%x = fcmp oeq float %a, %b ; 浮点等于(ordered)
; === 内存操作 ===
%p = alloca i32 ; 栈上分配
%v = load i32, ptr %p ; 读取
store i32 42, ptr %p ; 写入
%p = getelementptr [10 x i32], ptr %arr, i32 0, i32 3 ; 取 arr[3] 的地址
; === 转换 ===
%x = trunc i32 %a to i8 ; 截断
%x = zext i8 %a to i32 ; 零扩展
%x = sext i8 %a to i32 ; 符号扩展
%x = fptoui float %a to i32 ; 浮点→无符号整数
%x = sitofp i32 %a to float ; 有符号整数→浮点
%x = bitcast i32* %a to i8* ; 位重解释(不改变位,只改变类型)
; === 其他 ===
%x = call i32 @foo(i32 %a, i32 %b) ; 调用函数
%x = select i1 %cond, i32 %a, i32 %b ; 条件选择(类似三元运算符)
%x = phi i32 [ 0, %entry ], [ %y, %loop ] ; φ 节点
3.6 实战:阅读 LLVM IR 完整示例
// example.c
int max3(int a, int b, int c) {
int m = a;
if (b > m) m = b;
if (c > m) m = c;
return m;
}
# 生成优化后的 IR(这才是"真正的"LLVM IR 风格)
clang -S -emit-llvm -O2 example.c -o example.ll
define i32 @max3(i32 %a, i32 %b, i32 %c) {
entry:
; 第一个比较: b > a ?
%cmp1 = icmp sgt i32 %b, %a
; select: 如果 b > a,选 b,否则选 a
%m.1 = select i1 %cmp1, i32 %b, i32 %a
; 第二个比较: c > m.1 ?
%cmp2 = icmp sgt i32 %c, %m.1
; select: 如果 c > m.1,选 c,否则选 m.1
%m.2 = select i1 %cmp2, i32 %c, i32 %m.1
ret i32 %m.2
}
逐行解读这个 IR 的精妙之处:
- 没有
alloca:-O2自动跑了mem2reg,所有变量都是 SSA 寄存器 - 用了
select而非br+phi:LLVM 优化器发现 if-else 足够简单,用select避免分支,减少了基本块数量 %m.1→%m.2:SSA 的自然结果——中间值各有一个名字- 两个
select串在一起:逻辑完全等价于原始控制流,但没有分支,CPU 流水线不会因为分支预测失败而 stall
4. 练习
练习 1:写 C 生成 IR(15 分钟)
对以下每种控制流结构,写对应的 C 函数,生成 -O0 和 -O2 的 IR,对比:
// 1. 简单 if-else
int abs_val(int x) { if (x >= 0) return x; else return -x; }
// 2. for 循环
int factorial(int n) {
int r = 1;
for (int i = 2; i <= n; i++) r *= i;
return r;
}
// 3. while 循环
int gcd(int a, int b) {
while (b != 0) { int t = b; b = a % b; a = t; }
return a;
}
// 4. switch
int classify(char c) {
switch (c) {
case 'a': return 1;
case 'b': return 2;
default: return 0;
}
}
对每个函数:
- 找出基本块的边界
- 手动画 CFG
- 找出 phi 节点(在
-O0的 IR 中你能在mem2reg之后看到)
1. abs_val —— 简单 if-else
-O0 IR:
define i32 @abs_val(i32 %x) {
entry:
%x.addr = alloca i32, align 4
store i32 %x, ptr %x.addr, align 4
%0 = load i32, ptr %x.addr, align 4
%cmp = icmp sge i32 %0, 0 ; x >= 0 ?
br i1 %cmp, label %if.then, label %if.else
if.then:
%1 = load i32, ptr %x.addr, align 4
ret i32 %1 ; return x
if.else:
%2 = load i32, ptr %x.addr, align 4
%sub = sub nsw i32 0, %2 ; 0 - x = -x
ret i32 %sub
}
基本块: entry、if.then、if.else(3 个块)
CFG:
[entry]
x >= 0 ?
/ \
true false
/ \
[if.then] [if.else]
ret x %sub = 0-x
ret %sub
没有循环,所以没有 phi 节点(即使 mem2reg 后也没有)。
-O2 IR:
define i32 @abs_val(i32 %x) {
entry:
%cmp = icmp sgt i32 %x, -1 ; x > -1 即 x >= 0
%sub = sub nsw i32 0, %x ; -x(不管用不用,都算出来)
%ret = select i1 %cmp, i32 %x, i32 %sub ; 条件选择!
ret i32 %ret
}
关键变化:
select替代了br+ret——if-else 变成了无分支代码- 只有一个基本块——CFG 退化成了单节点
-x被投机计算(speculative execution),反正开销可以忽略
2. factorial —— for 循环
-O0 IR(简化了属性标注):
define i32 @factorial(i32 %n) {
entry:
%n.addr = alloca i32
%r = alloca i32
%i = alloca i32
store i32 %n, ptr %n.addr
store i32 1, ptr %r ; r = 1
store i32 2, ptr %i ; i = 2
br label %for.cond
for.cond: ; 循环条件块
%0 = load i32, ptr %i
%1 = load i32, ptr %n.addr
%cmp = icmp sle i32 %0, %1 ; i <= n ?
br i1 %cmp, label %for.body, label %for.end
for.body: ; 循环体
%2 = load i32, ptr %i
%3 = load i32, ptr %r
%mul = mul nsw i32 %3, %2 ; r * i
store i32 %mul, ptr %r ; r = r * i
br label %for.inc
for.inc: ; i++ 块
%4 = load i32, ptr %i
%inc = add nsw i32 %4, 1 ; i + 1
store i32 %inc, ptr %i ; i = i + 1
br label %for.cond ; ← 回边!
for.end: ; 循环出口
%5 = load i32, ptr %r
ret i32 %5
}
基本块: entry、for.cond、for.body、for.inc、for.end(5 个块)
CFG:
[entry]
r=1, i=2
│
▼
┌[for.cond]◄──────┐
│ i <= n ? │
└──┬──────┬──────┘
true false
│ │
▼ ▼
[for.body] [for.end]
r = r * i ret r
│
▼
[for.inc]
i = i + 1
│
└──────────┘ ← 回边
mem2reg 后的 phi 节点:
define i32 @factorial(i32 %n) {
entry:
br label %for.cond
for.cond:
%r = phi i32 [ 1, %entry ], [ %mul, %for.inc ] ; ← phi 节点!
%i = phi i32 [ 2, %entry ], [ %inc, %for.inc ] ; ← phi 节点!
%cmp = icmp sle i32 %i, %n
br i1 %cmp, label %for.body, label %for.end
for.body:
%mul = mul nsw i32 %r, %i
br label %for.inc
for.inc:
%inc = add nsw i32 %i, 1
br label %for.cond
for.end:
ret i32 %r
}
phi 节点解读:
%r = phi [1, %entry], [%mul, %for.inc]:第一次从 entry 来是 1,之后从 for.inc 来是上一轮迭代的%mul%i = phi [2, %entry], [%inc, %for.inc]:第一次是 2,之后是上一轮的%inc
3. gcd —— while 循环
-O0 IR:
define i32 @gcd(i32 %a, i32 %b) {
entry:
%a.addr = alloca i32
%b.addr = alloca i32
%t = alloca i32
store i32 %a, ptr %a.addr
store i32 %b, ptr %b.addr
br label %while.cond
while.cond: ; while (b != 0)
%0 = load i32, ptr %b.addr
%cmp = icmp ne i32 %0, 0
br i1 %cmp, label %while.body, label %while.end
while.body:
%1 = load i32, ptr %b.addr
store i32 %1, ptr %t ; t = b
%2 = load i32, ptr %a.addr
%3 = load i32, ptr %b.addr
%rem = srem i32 %2, %3 ; a % b
store i32 %rem, ptr %b.addr ; b = a % b
%4 = load i32, ptr %t
store i32 %4, ptr %a.addr ; a = t
br label %while.cond ; ← 回边
while.end:
%5 = load i32, ptr %a.addr
ret i32 %5
}
基本块: entry、while.cond、while.body、while.end(4 个块)
CFG:
[entry]
a.addr=a, b.addr=b
│
▼
┌[while.cond]◄──────┐
│ b != 0 ? │
└──┬──────┬────────┘
true false
│ │
▼ ▼
[while.body] [while.end]
t=b; b=a%b ret a
a=t
│
└──────────┘ ← 回边
mem2reg 后的 phi 节点:
define i32 @gcd(i32 %a, i32 %b) {
entry:
br label %while.cond
while.cond:
%a.phi = phi i32 [ %a, %entry ], [ %b.load, %while.body ]
%b.phi = phi i32 [ %b, %entry ], [ %rem, %while.body ]
%cmp = icmp ne i32 %b.phi, 0
br i1 %cmp, label %while.body, label %while.end
while.body:
%b.load = load i32, ptr ??? ; 等等——这个不好翻译
; ...
实际上 gcd 的 mem2reg 比较微妙——t = b 这个临时变量涉及对 %b 的两次读取(一次存 t,一次和 %a 一起算 a%b),mem2reg 后 %b 直接就是 SSA 寄存器:
; mem2reg 后的实际结果
define i32 @gcd(i32 %a, i32 %b) {
entry:
br label %while.cond
while.cond:
%a.0 = phi i32 [ %a, %entry ], [ %b.0, %while.body ]
%b.0 = phi i32 [ %b, %entry ], [ %rem, %while.body ]
%cmp = icmp ne i32 %b.0, 0
br i1 %cmp, label %while.body, label %while.end
while.body:
%rem = srem i32 %a.0, %b.0 ; a % b
br label %while.cond ; 下一轮: a=b, b=a%b
while.end:
ret i32 %a.0
}
注意 t = b 这条赋值直接消失了——因为 SSA 中 %b.0 在下一轮迭代正好从 phi 的 [%b.0, %while.body] 这个入口得到上一轮的 %b.0 值,也就是 b 在进入 while.body 时的值。gcd 语义天然被 phi 的循环行为表达了。
4. classify —— switch
-O0 IR:
define i32 @classify(i8 %c) {
entry:
%c.addr = alloca i8
store i8 %c, ptr %c.addr
%0 = load i8, ptr %c.addr
%conv = sext i8 %0 to i32 ; char → int 符号扩展
switch i32 %conv, label %sw.default [
i32 97, label %sw.bb ; 'a' = 97
i32 98, label %sw.bb1 ; 'b' = 98
]
sw.bb: ; case 'a'
ret i32 1
sw.bb1: ; case 'b'
ret i32 2
sw.default: ; default
ret i32 0
}
基本块: entry、sw.bb、sw.bb1、sw.default(4 个块)
CFG:
[entry]
switch %c
/ | \
'a' 'b' default
/ | \
▼ ▼ ▼
[sw.bb] [sw.bb1] [sw.default]
ret 1 ret 2 ret 0
没有循环 → 没有 phi 节点。
-O2 IR:基本一样——switch 只有两个 case,LLVM 不会把它转成跳转表。如果用 10+ 个 case,O2 会生成 jump table。
小结:有循环才有 phi 节点。if-else 和 switch 是无环控制流,不需要 phi——它们的汇合点只是"多条路径到这里",但每条路径只走一次,不会像循环那样"反复经过汇合点、每次带不同值"。
练习 2:手动模拟 mem2reg(10 分钟)
拿这段 -O0 IR:
define i32 @foo(i1 %cond) {
entry:
%x = alloca i32
store i32 10, ptr %x
br i1 %cond, label %then, label %else
then:
store i32 20, ptr %x
br label %merge
else:
br label %merge
merge:
%val = load i32, ptr %x
ret i32 %val
}
模拟 mem2reg 的转换过程:
- 找出所有
store指令的位置 - 计算支配边界
- 确定 φ 节点应该放在哪里
- 写出转换后的 SSA IR
- 用
opt -passes=mem2reg验证你的答案
第 1 步:找出所有 store 指令
变量 %x 有两个 store:
| Store | 所在块 | 值 |
|---|---|---|
| S1 | entry | 10 |
| S2 | then | 20 |
第 2 步:计算支配关系
CFG:
[entry]
/ \
true false
/ \
[then] [else]
\ /
[merge]
支配关系:
dom(entry) = {entry}
dom(then) = {entry, then}
dom(else) = {entry, else}
dom(merge) = {entry, merge} ← 注意!entry 支配 merge(到 merge 必过 entry)
then 不支配 merge(走 else 路径不到 then)
else 不支配 merge(走 then 路径不到 else)
立即支配者:
idom(entry) = (无)
idom(then) = entry
idom(else) = entry
idom(merge) = entry ← merge 的 idom 是 entry,不是 then/else
第 3 步:计算支配边界
定义:DF[n] = { x | n 支配 x 的某个前驱,但 n 不严格支配 x }
DF(entry) = ∅ ; entry 支配所有节点,没有"出走"的边
DF(then) = {merge} ; then 支配 then 自己,then→merge 的边,终点 merge 不被 then 严格支配
DF(else) = {merge} ; 同理,else→merge 的边
DF(merge) = ∅
第 4 步:确定 φ 节点位置
SSA 构造规则:变量在块 B 中有 store → 在 DF[B] 的每个块中插入该变量的 φ 节点。
S1 在 entry → DF[entry] = ∅ → 不插 φ
S2 在 then → DF[then] = {merge} → 在 merge 中插 x 的 φ 节点!
else 中没有 store,不需要处理。
φ 节点插在 merge 的开头。因为 S1 和 S2 都定义了 x,而 merge 同时被 then(有 S2)和 else(无 store,来的时候 x 还剩 S1 的值)到达,所以 merge 需要 φ 来汇合:
- 从 then 来 → x = 20(S2 的值)
- 从 else 来 → x = 10(S1 的值,因为 else 路径没覆盖它)
第 5 步:写出转换后的 SSA IR
原始 IR 中 %x 是个内存位置(alloca),所有操作通过 store/load 间接访问。
mem2reg 后的等价 SSA IR:
define i32 @foo(i1 %cond) {
entry:
br i1 %cond, label %then, label %else
then:
br label %merge
else:
br label %merge
merge:
%x = phi i32 [ 20, %then ], [ 10, %else ] ; ← φ 节点!
ret i32 %x
}
逐行理解:
%x = phi i32 [ 20, %then ], [ 10, %else ]:如果从%then来,%x = 20;如果从%else来,%x = 10alloca、store、load全部消失了——它们被 SSA 寄存器%x的 φ 节点表达- 基本块从 4 个减少到 3 个(entry 空了?看下面)
实际 LLVM 输出(opt -passes=mem2reg 后):
define i32 @foo(i1 %cond) {
entry:
br i1 %cond, label %then, label %else
then: ; preds = %entry
br label %merge
else: ; preds = %entry
br label %merge
merge: ; preds = %then, %else
%.0 = phi i32 [ 20, %then ], [ 10, %else ] ; LLVM 自动命名为 %.0
ret i32 %.0
}
验证
# 保存原始 IR 到文件
cat > foo.ll << 'EOF'
define i32 @foo(i1 %cond) {
entry:
%x = alloca i32
store i32 10, ptr %x
br i1 %cond, label %then, label %else
then:
store i32 20, ptr %x
br label %merge
else:
br label %merge
merge:
%val = load i32, ptr %x
ret i32 %val
}
EOF
opt -passes=mem2reg foo.ll -S
# 输出与上面的结果一致
关键洞察
-
φ 节点来自支配边界:S2 在 then 中 → DF[then] = {merge} → φ 插在 merge。这是确定的算法,不是"猜"。
-
else 没有 store,怎么知道值是 10? 因为 else 的前驱只有 entry,而 entry 中的 store(S1) 在 else 中没有被"杀死"——S1 的 x 值 10 沿着 entry→else→merge 这条路径活到了 merge。
-
如果 else 里也有 store(比如
store i32 30),那么 DF[else] = {merge},merge 已经在步骤 4 中有了 φ。此时 φ 会有三个入口:[20, %then], [30, %else], [10, ???]——等等,10 从哪条路径来?答案:必须在 entry→else 路径上做分析。如果有br i1 %cond, label %then, label %else且 else 也有 store,那 φ 就只需要两个入口[20, %then], [30, %else]——因为 else 路径上 S1(10) 被 S3(30) 覆盖了。
练习 3:LLVM 工具链热身(10 分钟)
# 生成不同优化级别的 IR,感受差异
clang -S -emit-llvm -O0 factorial.c -o factorial_O0.ll
clang -S -emit-llvm -O2 factorial.c -o factorial_O2.ll
diff factorial_O0.ll factorial_O2.ll | head -50
# 用 opt 逐个跑 Pass,观察变化
opt -passes=mem2reg factorial_O0.ll -S -o factorial_mem2reg.ll
opt -passes=mem2reg,licm factorial_O0.ll -S -o factorial_licm.ll
# 看 LLVM 的 CFG(需要 graphviz)
opt -passes='dot-cfg' factorial_O0.ll -disable-output
dot -Tpng cfg.foo.dot -o cfg.png
▼ 练习 3 参考答案——运行结果与解读
1. -O0 vs -O2 IR 对比(以 factorial 为例)
# 先把 factorial 函数写入文件
cat > factorial.c << 'EOF'
int factorial(int n) {
int r = 1;
for (int i = 2; i <= n; i++) r *= i;
return r;
}
EOF
clang -S -emit-llvm -O0 factorial.c -o factorial_O0.ll
clang -S -emit-llvm -O2 factorial.c -o factorial_O2.ll
diff 结果解读(手动标注):
< define i32 @factorial(i32 %n) #0 {
---
> define i32 @factorial(i32 %n) local_unnamed_addr #0 {
↑ O2 带了更多属性注解
< %r = alloca i32 ← O0 在栈上分配 r
< %i = alloca i32 ← O0 在栈上分配 i
< store i32 1, ptr %r
< store i32 2, ptr %i
< br label %for.cond
---
> %cmp5 = icmp sgt i32 %n, 1 ← O2 直接比较 n > 1(因为 n<=1 时循环不执行)
> br i1 %cmp5, label %for.body.preheader, label %for.end
关键变化:
1. O0 有 5 个基本块(entry/for.cond/for.body/for.inc/for.end)
2. O2 通常只有 3-4 个基本块(preheader/body/latch/exit),alloca 全部消失
3. O2 把 for.inc 合并进了 latch
4. 指令数从 ~20 降到 ~10
factorial_O0.ll 关键内容(完整版):
define i32 @factorial(i32 %n) {
entry:
%n.addr = alloca i32, align 4
%r = alloca i32, align 4
%i = alloca i32, align 4
store i32 %n, ptr %n.addr, align 4
store i32 1, ptr %r, align 4
store i32 2, ptr %i, align 4
br label %for.cond
for.cond:
%0 = load i32, ptr %i, align 4
%1 = load i32, ptr %n.addr, align 4
%cmp = icmp sle i32 %0, %1
br i1 %cmp, label %for.body, label %for.end
for.body:
%2 = load i32, ptr %i, align 4
%3 = load i32, ptr %r, align 4
%mul = mul nsw i32 %3, %2
store i32 %mul, ptr %r, align 4
br label %for.inc
for.inc:
%4 = load i32, ptr %i, align 4
%inc = add nsw i32 %4, 1
store i32 %inc, ptr %i, align 4
br label %for.cond
for.end:
%5 = load i32, ptr %r, align 4
ret i32 %5
}
factorial_O2.ll 关键内容:
define i32 @factorial(i32 %n) local_unnamed_addr {
entry:
%cmp5 = icmp sgt i32 %n, 1 ; n > 1?(≤1 直接返回 1)
br i1 %cmp5, label %for.body.preheader, label %for.end
for.body.preheader: ; 循环预入口(preheader)
br label %for.body
for.body: ; 合并了 body + latch
%i.07 = phi i32 [ %inc, %for.body ], [ 2, %for.body.preheader ]
%r.06 = phi i32 [ %mul, %for.body ], [ 1, %for.body.preheader ]
%mul = mul nsw i32 %r.06, %i.07
%inc = add nuw nsw i32 %i.07, 1
%cmp = icmp eq i32 %i.07, %n ; i == n?(代替 i <= n)
br i1 %cmp, label %for.end.loopexit, label %for.body
for.end.loopexit:
%mul.lcssa = phi i32 [ %mul, %for.body ] ; LCSSA φ:循环出口专用
br label %for.end
for.end:
%r.0.lcssa = phi i32 [ 1, %entry ], [ %mul.lcssa, %for.end.loopexit ]
ret i32 %r.0.lcssa
}
O2 的精妙之处:
| 优化 | 体现 |
|---|---|
| 提前判空 | n > 1:n≤1 时跳过整个循环,直接返回 1 |
| 合并基本块 | for.body 同时包含了原来的 body 和 inc |
| 归纳变量优化 | i <= n 变成了 i == n(循环计数器恰好从 2 走到 n) |
| LCSSA | %mul.lcssa 是循环闭包 SSA 形式——循环内定义的变量在循环外使用时插入专用 φ |
2. opt 逐个跑 Pass
opt -passes=mem2reg factorial_O0.ll -S -o step1.ll
opt -passes=mem2reg,gvn factorial_O0.ll -S -o step2.ll
opt -passes=mem2reg,gvn,licm factorial_O0.ll -S -o step3.ll
step1(mem2reg 后)的 IR:
define i32 @factorial(i32 %n) {
entry:
br label %for.cond
for.cond:
%r.0 = phi i32 [ 1, %entry ], [ %mul, %for.inc ] ; ← alloca 消失,变成 SSA
%i.0 = phi i32 [ 2, %entry ], [ %inc, %for.inc ]
%cmp = icmp sle i32 %i.0, %n
br i1 %cmp, label %for.body, label %for.end
for.body:
%mul = mul nsw i32 %r.0, %i.0
br label %for.inc
for.inc:
%inc = add nsw i32 %i.0, 1
br label %for.cond
for.end:
ret i32 %r.0
}
对比原始 -O0 的 IR:
alloca全部消失 →%r和%i变成了 φ 节点load全部消失 → 直接用 SSA 寄存器store全部消失 → 值的传递由 φ 的参数完成- 指令数从 ~20 降到 ~10
step2(mem2reg + GVN 后):factorial 这个例子中 GVN 通常没有额外效果——没有冗余的公共子表达式可消除。可以换个例子感受 GVN:
# 写一个有明显公共子表达式的例子
cat > gvn_test.c << 'EOF'
int test(int a, int b, int c) {
int x = a + b;
int y = c * 2;
int z = a + b; // 公共子表达式!
return x + z;
}
EOF
clang -S -emit-llvm -O0 gvn_test.c -o gvn_test.ll
opt -passes=mem2reg gvn_test.ll -S -o gvn_step1.ll
opt -passes=mem2reg,gvn gvn_test.ll -S -o gvn_step2.ll
diff gvn_step1.ll gvn_step2.ll
你会看到 %z = add i32 %a, %b 被替换为直接使用 %x。
step3(mem2reg + GVN + LICM 后):factorial 中 LICM 也没有额外效果(循环内没有循环不变计算)。换个例子:
cat > licm_test.c << 'EOF'
int test(int a, int b, int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += a * b; // a * b 在循环内不变!
}
return sum;
}
EOF
clang -S -emit-llvm -O0 licm_test.c -o licm_test.ll
opt -passes=mem2reg licm_test.ll -S -o licm_step1.ll
opt -passes=mem2reg,licm licm_test.ll -S -o licm_step2.ll
step2 中 %mul = mul i32 %a, %b 被从 for.body 移到了循环外的专用块。
3. 用 dot-cfg 看 CFG
# 生成 CFG dot 文件
opt -passes='dot-cfg' factorial_O0.ll -disable-output
# 会生成 .dot 文件,比如:
# cfg.factorial.dot —— 每个函数一个 .dot 文件
# 用 graphviz 渲染为 PNG:
dot -Tpng cfg.factorial.dot -o cfg.png
# 如果没装 graphviz,可以直接看 .dot 文本:
cat cfg.factorial.dot
.dot 文件内容(factorial -O0 版本):
digraph "CFG for 'factorial' function" {
label="CFG for 'factorial' function";
Node0x1 [shape=record,label="{entry:\l ...\l br label \%for.cond\l}"];
Node0x2 [shape=record,label="{for.cond:\l ...\l br i1 \%cmp, label \%for.body, label \%for.end\l}"];
Node0x3 [shape=record,label="{for.body:\l ...\l br label \%for.inc\l}"];
Node0x4 [shape=record,label="{for.inc:\l ...\l br label \%for.cond\l}"];
Node0x5 [shape=record,label="{for.end:\l ...\l ret i32 ...\l}"];
Node0x1 -> Node0x2;
Node0x2 -> Node0x3;
Node0x2 -> Node0x5;
Node0x3 -> Node0x4;
Node0x4 -> Node0x2;
}
这和图 3.4 节手绘的 CFG 完全一致——5 个节点,回边 for.inc → for.cond 清晰可见。
今日小结
今天你建立的三条认知:
1. 编译器 = 前端 + 中端 + 后端,通过 IR 解耦
C/C++/Rust → Clang → LLVM IR → opt → llc → x86/ARM/RISC-V
2. LLVM IR 的 SSA 形式
每个变量只赋值一次 → use-def 关系无需分析 → 优化算法简化
φ 节点处理控制流汇合 → 支配边界确定 φ 节点的位置
mem2reg: 把 -O0 的 alloca/store/load 自动提升为 SSA
3. 基本块 + CFG 是所有分析的基础
BB = 直线代码 + terminator
CFG = BB 节点 + br 边
Day2 的数据流分析和支配分析都建立在 CFG 之上
明天要用的:看懂任意 LLVM IR、手动找出基本块、理解 φ 节点的含义——这三项是 Day2 数据流分析的前置技能。


被折叠的 条评论
为什么被折叠?



