先理解一下这个主题在讨论什么。Rust 编译器的中间表示 MIR(Mid-level Intermediate Representation)长期使用phi节点来表示控制流汇合处的值选择;而这篇 RFC 提议改用block arguments(基本块参数)来传递这类值。文章会先解释为什么存在两种 SSA 形式、各自解决了什么问题,再结合 LLVM Code Generation 场景分析这次变更的动机、影响面和技术难点,最后用示例对比两种写法的差异,让对编译器内部感兴趣的读者建立完整的分析框架。
1. MIR 与 phi 节点:一段绕不开的编译器基础
1.1 MIR 在 Rust 编译器中的位置
在深入了解 "Change MIR to use block arguments instead of phis" 这个 RFC 之前,先理清 MIR 在 Rust 编译器中的角色。
Rust 编译器的前端处理过程大致如下:
源码 -> AST(抽象语法树) -> HIR(高级中间表示,High-level IR) -> THIR(类型化高级表示,Typed High-level IR) -> MIR(中级中间表示,Mid-level IR) -> LLVM IR / Cranelift IR / GCC IR -> 机器码其中 MIR 是 rustc 内部非常关键的一层。它一方面承担着借用检查(borrow checking)的任务,另一方面也承担着若干编译优化、常量求值、代码生成前的准备等工作。
与 HIR 相比,MIR 已经非常接近传统编译器教科书里的“控制流图 + 三地址码”结构。MIR 中的基本块(basic block)把指令组织成线性序列,块与块之间通过跳转指令连接,整体形成一个 CFG(Control Flow Graph,控制流图)。
下面是一个 Rust 函数对应的 MIR 简化示意图:
fn abs(x: i32) -> i32 { if x < 0 { -x } else { x } }这段代码的 MIR 大致会是:
bb0: { _2 = _1 < 0; switchInt(_2) -> [false: bb2, true: bb1]; } bb1: { _3 = -_1; goto -> bb3; } bb2: { _3 = _1; goto -> bb3; } bb3: { _0 = _3; return; }这里bb0、bb1、bb2、bb3就是基本块。_0是返回值槽,_1是参数,_2、_3是临时变量。
这个例子虽然简单,但已经能看出一个问题:_3的值可能来自bb1,也可能来自bb2。当控制流在bb3汇合时,我们需要一种机制来准确表达“_3到底是哪一个值”。
在传统 SSA 形式中,这种“多路汇合”的需求由phi 指令解决。
1.2 什么是 SSA,为什么需要 phi 节点
SSA(Static Single Assignment,静态单赋值)是编译器中间表示的一种重要形式。它的核心约束非常直观:每个变量只能被赋值一次。
在普通 IR 中,一个变量可以被反复赋值:
v = 1 v = v + 2 v = v * 3而在 SSA 形式中,每次赋值都会引入一个新的“版本”:
v1 = 1 v2 = v1 + 2 v3 = v2 * 3这种“不可变”的特点让数据流分析、常量传播、死代码消除、寄存器分配等优化变得异常方便。
但 SSA 有一个经典问题:如果两个控制流路径在某处汇合,同一个逻辑变量会携带不同的值到达汇合点。此时,SSA 要求每个变量只能有一个定义,可是这里有两条定义路径。
为了解决这个矛盾,编译器引入了一个特殊的指令:phi 节点(phi instruction)。
bb3: { x = phi [bb1: v1, bb2: v2] // 从 bb1 来取 v1,从 bb2 来取 v2 }phi 节点不是一个真正意义上的“计算指令”,它更像一个选择器:它本身不产生新值,而是根据前驱基本块,选择对应传入路径上的值。
1.3 phi 节点在 Rust MIR 中的存在方式
在目前的 rustc 实现中,MIR 里有一个专门的表示:Rvalue::Use、Rvalue::BinaryOp等指令之外,还有一种特殊表达式Rvalue::Ref? 不,实际存放 phi 的地方是mir::Statement中的FakeRead、Assign,以及一种叫mir::TerminatorKind::Goto与SwitchInt配合在汇合点生成赋值的方式。
严谨地说,当前 MIR 并不是把 phi 作为一条独立指令原样保存,而是通过一个叫「Locals 赋值 + 基本块跳转」的框架来表达 SSA 信息。rustc 内部有专门的结构管理这些信息,在部分优化和 MIR 借用检查中,会以“类 phi 信息”的方式处理:
- 代码从 HIR 降级(lowering)到 MIR 时,会尽量生成局部变量赋值。
- 路径汇合处,一个局部变量会被多个基本块赋值。
- 借用检查器需要追踪这些赋值来源。
- 后续优化(如
GVN、DSE)也会依赖这些定义-使用关系。
也就是说,当前 MIR 虽然不会像 LLVM IR 那样出现显式的phi指令文本,但在语义上,它是通过“同一局部变量在多个前驱中赋值、在汇合块读取”的方式来模拟 phi 行为的。
这套方案可行,但并不优雅,尤其在做数据流分析和 MIR 变换时,常常需要额外维护一套映射关系来记录“某个变量在某条边上来自哪个定义”。
2. Block Arguments:另一种 SSA 形态
2.1 用参数代替选择器
Block arguments(基本块参数)是 SSA 的另一种等价表达方式。它的思想很直接:把“选择”这个动作从汇合点前移到跳转边(edge)上。
我们继续用前面的abs函数来演示 phi 写法与 block arguments 写法的差异。
phi 风格:
bb0: { _2 = _1 < 0; switchInt(_2) -> [false: bb2, true: bb1]; } bb1: { _3 = -_1; goto -> bb3; } bb2: { _3 = _1; goto -> bb3; } bb3: { _0 = phi [bb1: _3, bb2: _3]; return; }block arguments 风格:
bb0: { _2 = _1 < 0; switchInt(_2) -> [false: bb2, true: bb1]; } bb1: { _3 = -_1; goto -> bb3(_3); } bb2: { _3 = _1; goto -> bb3(_3); } bb3(v: i32): { // bb3 声明了一个参数 v _0 = v; return; }可以看到,block arguments 不再需要“同一变量多前驱赋值”的特殊结构。每个基本块可以声明自己的参数列表;当一条跳转边跳转到这个块时,跳转指令必须带上对应的实参。
2.2 两种形式在语义上是等价的吗
从数据流角度看,phi 节点和 block arguments 都能完整表达SSA所需的值传递语义,而且两者在表达能力上是等价的。
但从编译器的工程实现角度,两者存在不少差异:
| 对比维度 | phi 节点 | block arguments |
|---|---|---|
| 值传递发生位置 | 汇合块内部 | 跳转边上 |
| 表示路径来源 | 需要记录前驱块映射 | 通过实参位置自然对应 |
| 变换时的局部性 | 修改前驱可能影响 phi 参数列表 | 修改前驱时只需调整参数 |
| 优化分析直观性 | 需要扫描前驱 | 参数和块绑定,直观 |
| 工程实现复杂度 | 需要额外的边信息辅助 | 结构内建,无额外状态 |
从工程角度看,block arguments 最诱人的点在于:它把一个“多对一”的边信息压缩到了每一条跳转边上。每个前驱只需要关心自己传入的参数,不需要关心汇合块里究竟有多少个 phi 节点、这些 phi 节点按什么顺序排列、它应当填充 phi 的哪个槽位。
2.3 其他编译器是怎么做的
block arguments 并不是一个新鲜概念,它已经被多个编译器项目和中间表示体系采用:
- MLIR:MLIR 的基本块天然支持参数,
block arguments是标准设计,而不是通过 phi 指令。 - Cranelift:这是 Rust 生态中最著名的使用 block arguments 的代码生成器。
- Swift SIL:Swift 编译器中间表示 SIL 也使用基本块参数。
- LLVM:LLVM IR 至今仍然使用 phi 节点,但新的 LLVM 内部 pass 和一些新 API 对 phi 的处理也在不断进化。
从这个角度看,Rust MIR 尝试切换到 block arguments,本质上不是一次激进创新,而是跟随了已经经过生产验证的工程路线。
3. 这次 RFC 的核心动机:为什么现在要改
3.1 降低 MIR 变换的复杂度
当前 rustc 的 MIR 中,如果一个局部变量在不同前驱块中被赋值,在汇合处读取它时,rustc 需要回溯到前驱块去寻找定义。这种跨块的“定义-使用”追踪,在编译器开发中是一个持续的心智负担。
举一个具体的例子:假设有一个 MIR pass 想要删除某个不必要的前驱块,并把它的跳转重定向到后继块。在 phi 风格下,你需要同步更新目标块里所有 phi 指令的参数列表,找到“来自被删除块”的参数项,改成“来自新前驱块”的参数项。如果目标块有大量的 phi,或者存在更复杂的菱形结构,这个操作非常容易出错。
而 block arguments 形态下,前驱块与后继块之间的参数传递关系直接被跳转指令携带。删除一个块时,只需把“传给新前驱的参数”合并到新的跳转参数上,缺失参数可以在构建过程中直接暴露,不容易产生隐性错误。
3.2 与前端降低(lowering)过程的配合
从 HIR 降级到 MIR 时,需要根据语法结构生成 CFG。例如,Rust 的?操作符、if-else、match、loop等结构,在降级过程中会生成大量汇合块。
当前的做法是:先生成块结构,再把需要穿越汇合块的值赋给局部变量,最终在汇合后读取。如果目标块支持参数,降级阶段可以更自然地把语义上的“值”直接绑定到块参数上,减少“创建临时变量 + 跨块赋值 + 汇合后读取”这串样板逻辑。
3.3 为未来优化和代码生成铺路
rustc 的 MIR 优化一直围绕“借用检查 + 基础优化”展开,深度优化更多依赖后端 LLVM。但长期看,rustc 希望在 MIR 层面承担更多代码生成管线职责(例如MIR inlining、MIR 常量传播、MIR 死代码消除等)。如果 MIR 能切换到 block arguments,这些 pass 的实现会明显更简洁。
此外,从代码生成角度,MIR 最终要翻译成 LLVM IR。LLVM IR 使用的仍然是 phi 节点。因此从 block arguments 形式到 phi 形式,需要一次转换。这个转换是机械化的:只需要把每个块的参数统一收集,在块的开头生成 phi 节点,再把跳转边的实参对应到 phi 的传入参数即可。这种机械转换非常稳定,适合在 rustc 的codegen阶段完成。
4. 具体设计思路与 LLVM Code Generation 的影响分析
4.1 从 MIR 的当前结构出发
如果要把 MIR 改成 block arguments,需要修改的不仅是“显示形式”,而是整个 MIR 的类型系统和控制流结构。
大致会涉及以下几个层面:
给基本块类型增加参数列表:
BasicBlockData中需要增加类似args: Vec<Local>的定义,表示这个块接收多少个参数。修改跳转指令:
TerminatorKind::Goto、SwitchInt、Call的返回值语义等都需要调整为“携带实参”。局部变量与块参数的关系: 由于块参数本身是一种“值来源”,在借用检查器中,块参数需要被视作一个独立的借用位置或内存位置。
降级过程重构: 从 HIR 到 MIR 的构建过程需要重新设计,优先确定基本块参数,再生成对应的跳转实参。
4.2 对借用检查器的影响
借用检查器是 Rust MIR 最重要的消费者。它检查内存安全时,需要追踪每个值的生命周期、借用关系、移动语义。
在 phi 风格下,如果有两个前驱分别把一个String移动到汇合块,借用检查器需要判断“移动发生在哪条路径上”、“是否所有路径都移动了”、“汇合后是否还有未初始化的可能”。
block arguments 会改变这些信息的呈现方式。移动语义会变得非常显式:前驱块向目标块传入参数,本质上是“传递所有权”。借用检查器可以直接沿着跳转边的实参与目标块的形参识别所有权转移。
这种显式性一方面让语义更清晰,另一方面也意味着借用检查器需要重新设计部分分析逻辑。尤其是在处理Drop标志、移动路径和部分初始化时,需要把“块参数”作为新的分析对象纳入模型。
4.3 对 LLVM Code Generation 的影响
MIR 最终要翻译为 LLVM IR。LLVM IR 目前仍然是 phi 节点模型,因此 block arguments 到 phi 的转换是必要的。
这个转换并不复杂:
// 伪代码:BlockArguments 到 LLVM phi 的转换逻辑 for block in mir_blocks { if block.parameters.is_empty() { continue; } let phi = builder.create_phi(block.parameters.types); for (pred, args) in predecessors(block) { phi.add_incoming(args, pred.terminator_llvm_block); } insert(block.entry, phi); }需要注意的是,在 LLVM IR 中 phi 节点必须位于基本块的最前面(入口处)。从 block arguments 转换时,如果目标块在 LLVM IR 里已经有了其他指令,需要把 phi 插入到所有非 phi 指令之前。这个约束虽然机械,但必须小心处理。
在 MIR 优化 pass 已经做过一轮变换、并且 pass 之间彼此依赖的情况下,转换时的信息完整性非常重要。如果某些优化 pass 不够规范,可能导致 block arguments 与 phi 之间的对应关系丢失,进而产生不正确的 LLVM IR。
这正是这次 RFC 的一个关键技术风险点:MIR 层面的优化 pass 数量虽少,但都要重新验证。
5. 两种表示形式的完整示例对比
5.1 一个包含汇合路径的 Rust 函数
为了更好理解两者差异,这里写一段稍微复杂一点的 Rust 代码:
fn choose(flag: bool, a: i32, b: i32) -> i32 { let v = if flag { a + 1 } else { b - 1 }; v * 2 }5.2 当前 MIR 的 phi 式表达(示意)
用 rustc 的 MIR 风格近似表示:
fn choose(_1: bool, _2: i32, _3: i32) -> i32 { let mut _0: i32; let mut _4: i32; let mut _5: i32; let mut _6: i32; bb0: { _5 = _2 + 1; _4 = _5; goto -> bb2; } bb1: { _6 = _3 - 1; _4 = _6; goto -> bb2; } bb2: { // _4 在这里可能来自 bb0,也可能来自 bb1 _0 = _4 * 2; return; } }在这个当前形态中,_4在bb0和bb1都有赋值,bb2读取它。虽然 rustc 没有显式写出 phi 节点,但从语义上讲,_4的值是一个“phi 选择”。
5.3 block arguments 形态(示意)
如果采用 block arguments 重写,这段 MIR 会变成:
fn choose(_1: bool, _2: i32, _3: i32) -> i32 { let mut _0: i32; bb0: { _5 = _2 + 1; goto -> bb2(_5); } bb1: { _6 = _3 - 1; goto -> bb2(_6); } bb2(v: i32): { _0 = v * 2; return; } }在这个形态中,bb2声明了一个参数v,bb0传入_5,bb1传入_6。原先那个需要跨块追踪的_4直接消失了,值传递关系变得一目了然。
5.4 两种形态在代码生成阶段的差异
如果要在代码生成阶段把这两种形态转换成 LLVM IR:
- 当前形态:需要识别“同一局部变量在多个前驱赋值、在后继块读取”的模式,然后生成
phi。 - block arguments 形态:直接根据块参数和跳转实参生成
phi,无需额外扫描。
本质上,block arguments 把“发现 phi”提前到了 MIR 构建阶段,让后续每一步分析都更加省事。
6. 潜在难点与现实工程约束
6.1 在 rustc 生态中推行这项变更的难度
MIR 是 rustc 内部结构,但它并不是一个封闭的黑盒。围绕 MIR 工作的工具和机制很多:
- rustc_codegen_llvm(LLVM 后端)
- rustc_codegen_cranelift(Cranelift 后端)
- rustc_codegen_gcc(GCC 后端)
- MIR 相关的调试工具(如
-Z dump-mir) - 若干基于 rustc 内部的 MIR pass 和测试
任何一个 MIR 结构变更,都意味着这些子系统需要同步更新。这非常考验 RFC 的实施策略。
6.2 渐进式迁移还是全量重写
如果一次性把所有 MIR 的构建过程从“局部变量赋值模拟 phi”重写为“block arguments”,改动规模会非常大,且容易在初始阶段引入大量回归。
更现实的做法是分阶段推进:
- 先在 MIR 结构层面增加基本块参数的能力。
- 修改 HIR lowering,使新建的 MIR 尽可能使用 block arguments。
- 逐步调整借用检查器和优化 pass,使其既能处理旧结构,也能处理新结构。
- 在某个版本之后,彻底移除旧的“跨块赋值模拟 phi”逻辑。
这种渐进式迁移在 rustc 内部不是没有先例,但需要维护者在很长一段时间内同时兼容两套结构,工作和心智负担都不小。
6.3 对普通 Rust 开发者的影响
这里需要明确一点:这个 RFC 的影响面几乎完全限制在 rustc 编译器的内部实现。普通 Rust 开发者写代码、编译、运行,几乎不会感知到这一变化。
唯一可能感受到的差异在于:
- 编译期错误信息:某些借用检查的提示信息可能更精确,因为 MIR 的结构更适合分析所有权转移。
- 编译速度:如果 MIR 形态更直观,某些分析可能更快或少占用内存。
- 编译器 bug 的潜在变化:任何大规模重构都有短期回归风险,但长期看会降低维护成本。
7. 常见问题与排查思路
由于这个主题面向编译器内部,与其说是“问题排查”,不如说是“理解与验证”。下面几个问题对想深入 rustc 开发或研究 MIR 的读者更有价值。
| 问题 | 原因与背景 | 建议思路 |
|---|---|---|
| 我在哪里能看到 MIR? | rustc 没有默认输出 MIR,需要环境变量或参数打开 | 使用rustc -Z dump-mir或RUSTC_LOG=rustc_mir=debug查看 |
| 这个 RFC 会不会影响的 my crate 的运行性能? | 不会,MIR 是编译期结构,最终生成机器码经过后端优化 | 不必担心运行时影响,关注未来的编译期体验即可 |
| 为什么 LLVM IR 还是 phi,而 MIR 要改成 block arguments? | LLVM 生态依赖 phi,而 rustc 内部优化和借用检查更适 block args | 保持转换层职责分离,rustc 在后端完成 phi 重建 |
| 这个变更和 Windows 上使用 LLVM 工具链有关系吗? | 没有直接关系,这是 rustc 内部 IR 形态变更,不是 LLVM 工具链安装问题 | 面向编译器开发者,普通工具链用户无需操作 |
如果你对编译器内部表示变更感兴趣,推荐从阅读 rustc 的编译文档、MIR 相关 rustc-dev-guide 章节,以及rustc_codegen_cranelift的源码入手——因为 Cranelift 本身采用 block arguments,它的 MIR 转换逻辑可以作为参考实现。
8. 关注这个 RFC 的几个理由与学习建议
如果你是一名对编译器底层感兴趣的 Rust 开发者,这个 RFC 值得关注,原因有三:
它是理解 SSA 表示演进的绝佳案例。 同一个语义,用 phi 和 block arguments 两种方式表达,分析和理解两者的差异能帮助你建立更强的中间表示设计能力。
它展示了编译器架构中“表示选择”如何影响多方系统。 从 lowering、借用检查、优化 pass 到 codegen,一个 IR 形态的改动会波及整条流水线。这种全局观是编译器开发中非常宝贵的能力。
它和 LLVM codegen 的交互非常典型。 换一种表示不是“为了换而换”,而是要综合权衡 IR 表达能力、优化便利性、后端转换成本和工程维护负担。
如果你希望真正掌握这个领域,建议按照下面的路径学习:
- 先准确理解 SSA 与 phi 的必要性。
- 再通过 Cranelift 的文档和源码熟悉 block arguments 的实际用法。
- 对比 rustc 当前 MIR 的 lowering 逻辑和借用检查逻辑。
- 最后尝试为 rustc 编写一个简单的 MIR pass,观察它在 phi 风格下的实现方式,再想象如果改成 block arguments 会如何变化。
等到 RFC 真正实施并合并时,回头再看这篇文章里的示例,你会对这次变更的收益和代价有更深刻的体会。
9. 结语
MIR 从“模拟 phi”迁移到 block arguments,看似是一次内部表示层面的结构性调整,实质上影响着 Rust 编译器未来在借用检查、MIR 优化和后端代码生成方面的发展空间。这个 RFC 的核心价值不在于“推倒重来”,而在于让 MIR 的表示方式更贴合现代编译器工程实践中被反复验证过的设计路线。
对于普通 Rust 用户,这更像是一次无感升级;对于编译器工具链开发者,则是一次需要谨慎评估、渐进落地的架构演进。如果你正站在学习编译原理或参与 rustc 开发的路口,这个 RFC 是一个非常值得拆解的活案例。