6. 中间代码生成:三地址码、静态单赋值形式(SSA)

好,咱们进入编译原理里一个非常核心的环节——中间代码生成。说白了,就是编译器把咱们写的 C 代码,翻译成一种“半成品”的表示形式。为什么要多此一举?直接生成机器码不香吗?

我早年刚接触编译器时也有这个疑问。后来在一个嵌入式项目里,需要同时支持 ARM 和 RISC-V 两种架构,我才真正体会到中间代码的威力——你只需要写一套前端逻辑,后端根据架构换一套生成规则就行。没有中间层?那你就得写两套编译器,累死你。

今天咱们重点聊两种最常见的中间表示:三地址码静态单赋值形式(SSA)。这两兄弟,一个负责“把复杂表达式拆碎”,一个负责“让数据流分析变得简单”。

6.1 三地址码:把表达式拆成原子操作

三地址码,名字很直白——每条指令最多包含三个地址:两个操作数,一个结果。比如:

t1 = a + b
t2 = t1 * c
t3 = t2 - d

你看,原来 C 语言里一句 result = (a + b) * c - d,被拆成了三条三地址码指令。每条指令只做一件事,简单粗暴。

我个人习惯把三地址码想象成“乐高积木”——每个积木块都很小,但组合起来能搭出任何复杂的表达式。编译器后续做优化时,就可以随意替换、重排这些积木块,而不必担心破坏原始代码的结构。

三地址码的常见形式包括:

  • 赋值指令x = y op z,比如 t1 = a + b
  • 复制指令x = y,直接把一个值赋给另一个变量
  • 跳转指令if x goto L,条件跳转
  • 函数调用param xcall freturn y
  • 地址操作x = &yx = *y*x = y

核心要点:三地址码的“三地址”不是死规定,有些指令可能只有两个地址(比如 x = y),有些可能有更多(比如函数调用)。但核心思想不变——每条指令的操作粒度足够小,小到编译器可以轻松分析和变换。

我在项目中遇到过一个问题:一个复杂的数学表达式,直接生成三地址码时,临时变量数量爆炸。后来发现,其实很多临时变量是重复的,可以通过“值编号”技术合并。嗯,这个咱们后面讲优化时会细聊。

6.2 静态单赋值形式(SSA):让每个变量只赋值一次

SSA 是三地址码的“升级版”。它的核心规则很简单:每个变量只被赋值一次。听起来有点反直觉?你想想看,C 代码里一个变量可以被反复赋值,比如:

x = 1
x = x + 2
x = x * 3

在 SSA 形式里,这会被转换成:

x1 = 1
x2 = x1 + 2
x3 = x2 * 3

每个版本 x1x2x3 都是不同的变量,只赋值一次。为什么要这么折腾?

说白了,SSA 让数据流分析变得极其简单。你想知道某个变量的值是从哪来的?在普通三地址码里,你得沿着控制流图回溯,找到最近一次赋值。在 SSA 里,每个变量只有一个定义点,直接定位就行。

我曾经在一个静态分析工具里吃过亏——用普通三地址码做“到达定值分析”,算法复杂得要命,还容易出错。后来换成 SSA,代码量直接砍掉一半,正确率还更高了。

6.3 φ 函数:处理控制流合并

SSA 有个绕不开的问题:当控制流合并时,同一个变量可能来自不同的路径。比如:

if (cond)
    x = 1
else
    x = 2
y = x + 3

在 SSA 里,x 在 if 分支里是 x1 = 1,在 else 分支里是 x2 = 2。那合并后的 y = x + 3 该用哪个 x

这时候就需要 φ 函数(读作“fai 函数”)出场了:

x3 = φ(x1, x2)   // 如果从 if 分支来,取 x1;从 else 分支来,取 x2
y = x3 + 3

φ 函数不是真正的“函数”,它只是一个标记,告诉编译器:“这里需要根据控制流选择值”。实际生成机器码时,φ 函数会被消除,替换成合适的 move 指令。

小技巧:理解 φ 函数的关键,是把它看作“控制流合并点的选择器”。不要试图在脑子里模拟 φ 函数的执行——它只在编译器的中间表示里存在,运行时根本看不到它。

6.4 三地址码 vs SSA:怎么选?

我整理了一个对比表,方便你快速把握两者的差异:

特性 三地址码 SSA
变量赋值次数 可多次赋值 仅一次
数据流分析难度 较复杂,需回溯 简单,直接定位
代码体积 较小 稍大(引入 φ 函数)
优化能力 基础优化 强,适合高级优化
实现复杂度 中等
典型应用 早期编译器、教学 LLVM、GCC 等现代编译器

实际项目中怎么选?我个人建议:如果你在写教学用的玩具编译器,三地址码就够了。但如果你在做工业级编译器,SSA 几乎是标配。LLVM 的核心 IR 就是 SSA 形式的,GCC 也在中间阶段大量使用 SSA。

6.5 从 C 代码到 SSA:一个完整示例

咱们看一个完整的转换过程。假设有这段 C 代码:

int max(int a, int b) {
    int result;
    if (a > b)
        result = a;
    else
        result = b;
    return result;
}

先转成三地址码:

entry:
    if a > b goto L1
    goto L2
L1:
    result = a
    goto L3
L2:
    result = b
    goto L3
L3:
    return result

再转成 SSA 形式:

entry:
    if a > b goto L1
    goto L2
L1:
    result1 = a
    goto L3
L2:
    result2 = b
    goto L3
L3:
    result3 = φ(result1, result2)
    return result3

你看,result 被拆成了 result1result2result3 三个版本。φ 函数在 L3 处合并了两个分支的值。这样,每个变量都只赋值一次,数据流一目了然。

注意:SSA 转换不是简单的“给每个赋值加下标”就完事了。你需要先构建控制流图,识别出所有需要 φ 函数的位置(即控制流合并点),然后再进行变量重命名。这个过程叫“支配边界分析”,咱们后面会专门讲。

6.6 知识体系总览

下面这张图,帮你把本章的核心逻辑串起来:

中间代码生成知识体系 C 源代码 三地址码 (TAC) 每条指令最多三个地址,拆碎表达式 静态单赋值 (SSA) 每个变量只赋值一次,引入 φ 函数 三地址码特性 • 实现简单,适合教学 • 变量可多次赋值 • 数据流分析需回溯 SSA 特性 • 工业级编译器标配 • 每个变量只赋值一次 • 数据流分析直接定位

从图中可以清楚看到:C 源代码先被翻译成三地址码,然后可以进一步转换成 SSA 形式。三地址码是基础,SSA 是增强。两者不是互斥的,而是递进关系。

6.7 避坑指南

最后,分享几个我踩过的坑:

  • φ 函数的位置别搞错:φ 函数只放在控制流合并点,不是每个基本块都要放。我曾经在循环里乱放 φ 函数,结果生成的 SSA 根本没法优化。
  • 临时变量命名要规范:我习惯用 t1, t2, t3%0, %1, %2 这样的模式。别用 tmp_abc_123 这种随机命名,调试时你会疯掉。
  • 注意指针和别名问题:C 语言有指针,两个指针可能指向同一块内存(别名)。SSA 本身不处理别名,需要额外的别名分析。嗯,这个坑我当年踩得特别深。
  • 不要过早优化:先保证中间代码生成正确,再考虑优化。我见过太多人一上来就搞 SSA 构建优化,结果 bug 一堆,最后连正确性都保证不了。

好了,三地址码和 SSA 的核心内容就这些。记住:中间代码是编译器的“通用语言”,掌握了它,你就掌握了编译器优化的钥匙。


公众号:蓝海资料掘金营,微信deep3321