第 09 篇用 alloca/load/store 把可变变量映射到 LLVM IR 的栈槽,if/else 和 while 通过 LLVM 的基本块和条件跳转来实现。程序跑起来了,但控制流完全藏在 LLVM 里——我们自己的编译器看到的仍然是一棵 HIR 树,无法对分支和循环做任何分析。第 11–12 篇要构造 SSA,前提是程序已经被拆成显式的基本块和跳转。本篇就来做这件事。

什么是基本块

基本块(basic block)是一段顺序执行的指令序列,满足两个约束:

  1. 控制流只能从第一条指令进入。不存在从外部跳到块中间的边。
  2. 控制流只能从最后一条指令离开。这条指令叫做终结指令(terminator),它要么跳转到另一个块,要么从函数返回。块内其余指令不改变控制流。

换句话说,基本块要么整体执行,要么整体不执行。这个性质让后续的数据流分析可以把整个块当作一个节点处理,而不必逐条指令跟踪控制流。

终结指令只有三种形式就够了:

  • 无条件跳转:执行完本块,跳到目标块继续。
  • 条件分支:根据一个布尔值,跳到两个目标块之一。
  • 返回:离开当前函数,把值交给调用者。

一个块如果没有终结指令,就不知道执行完之后去哪里。一个块如果在终结指令之后还有普通指令,那些指令永远不会被执行。两种情况都是 IR 构造错误。

控制流图

把函数里的所有基本块当作节点,把跳转关系当作有向边,就得到控制流图(Control Flow Graph, CFG)。每个函数恰好有一个入口块(entry block),没有前驱,对应函数参数的绑定位置。以 Return 结尾的块是出口块,一个函数可以有多个出口。

CFG 是编译器中间表示的骨架。支配关系、活跃变量、SSA 构造、不可达代码检测——全都建立在 CFG 上。没有 CFG,这些分析无从开始。

Block 与 Term 的 Rust 定义

以下是本系列编译器的自制 CFG 数据结构。每个函数被表示为一组 Block,每个 Block 以一个 Term 结尾:

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
/// 基本块的唯一标识。
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct BlockId(u32);

/// 基本块。
pub struct Block {
pub id: BlockId,
/// 块参数,等价于其他表示中的 phi 节点(后文解释)。
pub params: Vec<(VarId, Ty)>,
/// 顺序执行的普通指令。
pub body: Vec<Inst>,
/// 终结指令,决定离开本块后的去向。
pub terminator: Term,
}

/// 终结指令。
pub enum Term {
/// 无条件跳转到目标块,携带块参数。
Jump(BlockId, Vec<Operand>),
/// 条件分支。cond 为 true 跳 then_block,否则跳 else_block。
Branch {
cond: Operand,
then_block: BlockId,
else_block: BlockId,
},
/// 函数返回。
Return(Operand),
}

Inst 是普通指令,包括算术、比较、函数调用等——它们不改变控制流。Operand 是指令的操作数,可以是常量、变量引用或临时值。这些类型在前几篇已经存在,这里不再重复。

关键设计决策:Blockterminator 字段不是 Option<Term>,而是必须存在的 Term。一个没有终结指令的块在类型层面就构造不出来。Rust 的类型系统替我们排除了一类错误。

从 HIR 降低到 CFG

HIR 是树形结构,CFG 是图。降低(lowering)过程把嵌套的 if/else 和 while 拆成扁平的块和跳转。

if/else → 菱形分支

源码:

1
2
3
4
5
6
if cond {
a = 1;
} else {
a = 2;
}
// use a

生成四个块:

1
2
3
4
5
6
7
8
9
10
11
12
bb0:                          // 计算 cond
%c = ...
Branch(%c, bb1, bb2)

bb1: // then
Jump(bb3, 1)

bb2: // else
Jump(bb3, 2)

bb3(a: i64): // merge,接收块参数
// use a

bb1bb2 分别带着不同的值跳到 bb3bb3 声明了一个块参数 a,在运行时它会接收到来自实际执行路径的值。这就是用块参数替代 phi 节点的做法(下一节详述)。

while → 循环头与回边

源码:

1
2
3
while cond {
body;
}

生成三个块:

1
2
3
4
5
6
7
8
9
10
bb_header:                    // 循环头,判断条件
%c = ...
Branch(%c, bb_body, bb_exit)

bb_body: // 循环体
...
Jump(bb_header) // 回边

bb_exit: // 循环出口
...

bb_body 末尾跳回 bb_header,这条边叫做回边(back edge),它在 CFG 里形成一个环。回边的存在是循环区别于分支的标志,也是后续数据流分析需要不动点迭代的原因——第 11 篇会详细讨论这一点。

块参数代替 phi 节点

传统 SSA 文献在合流点放置 phi 节点:a = phi [1, bb1], [2, bb2]。phi 的语义是"根据控制流来自哪条边,选择对应的值"。它有效,但构造时必须知道前驱块的顺序,容易出错。

本系列采用块参数(block parameter)。合流块声明参数列表,每个跳转到该块的终结指令在参数位置提供对应的值。效果与 phi 完全等价,但构造更直接:生成跳转时就把值带上,不需要事后回填。

MLIR 和 Cranelift 都使用块参数而非 phi。第 12 篇构造完整 SSA 时,块参数的优势会更明显。现在只需要记住:合流块的参数个数和类型必须与所有跳入它的 Jump/Branch 携带的参数匹配

sum_to 的 CFG

第 09 篇的 sum_to 函数:

1
2
3
4
5
6
7
8
9
fn sum_to(n: i64) -> i64 {
let mut sum: i64 = 0;
let mut i: i64 = 1;
while i <= n {
sum = sum + i;
i = i + 1;
}
return sum;
}

降低后的 CFG 文本转储(--emit=cfg 输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn sum_to(n: i64) -> i64 {
bb0:
Jump(bb1, 0, 1) // sum=0, i=1

bb1(sum: i64, i: i64): // 循环头
%0 = le(i, n)
Branch(%0, bb2, bb3)

bb2: // 循环体
%1 = add(sum, i)
%2 = add(i, 1)
Jump(bb1, %1, %2) // 回边,更新 sum 和 i

bb3: // 循环出口
Return(sum)
}

对应的 CFG 图:

1
2
3
4
5
6
7
8
9
10
      bb0


┌──→ bb1(sum, i)
│ │
│ Branch
│ ╱ ╲
│ bb2 bb3
│ │ Return
└───┘

四个块,四条边(bb0→bb1, bb1→bb2, bb1→bb3, bb2→bb1)。bb2→bb1 是回边。bb1 有两个块参数 sumi,分别从 bb0 的初始值和 bb2 的更新值中接收。

注意 bb3 里直接使用了 sum——因为 bb3 只能从 bb1 到达,而 bb1 的参数 sumbb3 的作用域内仍然可见。严格来说,bb3 也可以声明自己的块参数来接收 sum,但这里没有合流,直接引用更简洁。第 12 篇统一处理 SSA 重命名时会回到这个问题。

CFG 验证器

IR 在内存里是一堆 Block 结构体,构造过程可能出错。CFG 验证器在每次降低或变换之后检查以下不变量:

  1. 每个块必须以终结指令结尾。 因为 terminator 是必选字段,这条在 Rust 层面已保证。但验证器仍检查 body 中没有混入终结指令。
  2. 终结指令之后不能有普通指令。 终结指令只能出现在 terminator 字段中,body 里的每条 Inst 都必须是非终结的。
  3. 分支和跳转的目标块必须存在。 Jump(bb5, ...) 中的 bb5 必须在当前函数的块集合中。
  4. 块参数个数匹配。 如果 bb1 声明了两个参数,那么所有跳到 bb1JumpBranch 都必须提供恰好两个实参。
  5. 块参数类型匹配。 提供给块参数的值的类型必须与声明类型一致。

验证器的输出是一组诊断,每条指明出错的块和指令位置。通过验证的 CFG 可以安全地交给后续的支配关系计算和 SSA 构造。

核心验证逻辑的结构:

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
fn verify_function(func: &Function) -> Vec<CfgError> {
let mut errors = Vec::new();
let block_ids: HashSet<BlockId> =
func.blocks.iter().map(|b| b.id).collect();

for block in &func.blocks {
// 检查 body 中是否混入了终结指令
for (i, inst) in block.body.iter().enumerate() {
if inst.is_terminator() {
errors.push(CfgError::TermInBody(block.id, i));
}
}
// 检查终结指令的目标块存在且参数匹配
match &block.terminator {
Term::Jump(target, args) => {
check_target(&block_ids, func, block.id,
*target, args, &mut errors);
}
Term::Branch { then_block, else_block, .. } => {
check_target(&block_ids, func, block.id,
*then_block, &[], &mut errors);
check_target(&block_ids, func, block.id,
*else_block, &[], &mut errors);
}
Term::Return(_) => {}
}
}
errors
}

check_target 查找目标块是否在 block_ids 中,再比较实参数量和类型与目标块的 params 声明。实际代码比这里更长,但检查逻辑就是这五条规则的直接翻译。

错误注入实验

构造一个故意出错的 CFG,观察验证器的反应。

实验一:移除终结指令。bb2terminator 替换成一个占位的 Jump 指向不存在的 bb99

1
error: jump target bb99 does not exist (in bb2)

验证器在第三条规则处捕获。如果把 terminator 字段改成 Option<Term> 并设为 None,类型系统本身就会阻止编译——这正是我们把它设为必选字段的原因。

实验二:在 Return 之后追加指令。 假设某次变换不小心在 bb3body 末尾插入了一条 add 指令,而 terminator 仍是 Returnbody 中的 add 本身不违反第一条规则(它不是终结指令)。但如果有人错误地把一条 Jump 插进了 body

1
error: terminator instruction found in body at position 0 (in bb3)

验证器在第二条规则处捕获。终结指令只允许出现在 terminator 字段中。

实验三:块参数个数不匹配。bb0Jump(bb1, 0, 1) 改成 Jump(bb1, 0),少传一个参数:

1
error: bb0 jumps to bb1 with 1 arg(s), but bb1 expects 2 (in bb0)

验证器在第四条规则处捕获。这类错误在手动构造 IR 时很容易犯,验证器每次变换后都应运行。

从 HIR 树到 CFG 图:降低过程的结构

降低算法维护一个"当前块"。遍历 HIR 时,普通语句追加到当前块的 body;遇到 if/else 或 while 时,创建新块并切换。具体步骤:

  1. 为函数创建入口块 bb0,设为当前块。
  2. 遍历 HIR 语句。遇到赋值、表达式语句,生成 Inst 追加到当前块。
  3. 遇到 if cond { then } else { els }
    • 在当前块生成 cond 的求值指令。
    • 创建 bb_thenbb_elsebb_merge
    • 当前块的终结指令设为 Branch(cond, bb_then, bb_else)
    • 递归降低 then 分支到 bb_then,末尾 Jump(bb_merge, ...)
    • 递归降低 else 分支到 bb_else,末尾 Jump(bb_merge, ...)
    • bb_merge 设为新的当前块,继续。
  4. 遇到 while cond { body }
    • 创建 bb_headerbb_bodybb_exit
    • 当前块末尾 Jump(bb_header, ...),携带循环变量初始值。
    • bb_header 中生成条件求值,终结指令 Branch(cond, bb_body, bb_exit)
    • 递归降低 body 到 bb_body,末尾 Jump(bb_header, ...),携带更新后的变量值。
    • bb_exit 设为新的当前块,继续。
  5. 遇到 return expr:当前块终结指令设为 Return(val)

这个过程是一次 HIR 遍历,时间复杂度与 HIR 节点数成正比。降低完成后立即运行验证器。

练习

  1. 为以下函数画出 CFG,标注每个块的终结指令,数清块数和边数:
1
2
3
4
5
6
7
8
9
10
11
12
fn classify(x: i64) -> i64 {
let mut result: i64 = 0;
while x > 0 {
if x > 100 {
result = result + 10;
} else {
result = result + 1;
}
x = x - 1;
}
return result;
}

提示:while 产生循环头、循环体入口和循环出口;if/else 在循环体内部再产生 then 块、else 块和 merge 块。总共需要多少个块?有几条回边?

  1. 在本篇的 sum_to CFG 中,把 bb1Branch 改成 Jump(bb2)(去掉条件判断),预测这个 CFG 的行为。验证器会报错吗?为什么这种语义错误不在验证器的检查范围内?

资料

Engineering a Compiler, 3rd ed. 第 8 章系统讲述了基本块、CFG 和数据流分析框架。MLIR 的 Block 与 Region 设计采用块参数而非 phi 节点,本篇的设计借鉴了这一选择。Cranelift IR 参考同样使用块参数,其文档中解释了与 phi 的等价关系。

上一篇:09 - 条件、循环和可执行程序。下一篇:11 - 支配关系、活跃变量与数据流