中间表示(IR):编译器的“通用语言”

说实话,中间表示(IR)是整个编译优化的基石。没有它,我们根本没法做那些花里胡哨的优化。

我刚开始接触编译器时,觉得前端解析完源码,直接生成机器码不就完事了?后来才发现,这想法太天真了。不同CPU架构差异巨大,如果编译器为每种语言、每种架构都写一套优化逻辑,那工作量简直不敢想。

IR就是来解决这个问题的。它像一座桥,连接了前端和后端。前端把源码翻译成IR,后端再把IR翻译成目标机器码。这样一来,优化工作就可以集中在IR层面,一次优化,所有架构都受益。

三地址码:最朴素的IR形式

三地址码,名字听着挺唬人,其实特别简单。每条指令最多有三个操作数:两个源操作数,一个目标操作数。

举个例子,你写了个表达式 a = b + c * d,编译器会把它拆成:

t1 = c * d
a = b + t1

看到了吗?每条指令都只做一件事。这就是三地址码的核心思想——化繁为简。

我个人习惯把三地址码分成几种常见形式:

  • 赋值指令:x = y
  • 二元运算:x = y op z
  • 一元运算:x = op y
  • 跳转指令:if x goto L
  • 函数调用:param x 和 call f

我在项目中遇到过一个问题:三地址码虽然简单,但变量名满天飞。同一个变量在不同地方被赋值多次,优化时根本搞不清哪个值才是真正需要的。这就引出了下一个话题——SSA。

静态单赋值形式(SSA):让数据依赖一目了然

SSA的核心规则就一条:每个变量只被赋值一次。

你可能会问:「那循环里变量不断更新怎么办?」

好问题。SSA引入了φ函数(读作“fai函数”)来解决这个问题。φ函数会根据控制流选择正确的值。

看个例子,原始代码:

x = 0
if (cond) {
    x = 1
} else {
    x = 2
}
y = x + 1

转换成SSA形式后:

x1 = 0
if (cond) {
    x2 = 1
} else {
    x3 = 2
}
x4 = φ(x2, x3)
y1 = x4 + 1

每个x都有不同的版本号,最后用φ函数合并。这样一来,数据流就清晰了。

为什么SSA这么重要?

因为很多优化算法在SSA上实现起来特别简单。比如常量传播、死代码消除、循环不变量外提……这些优化在SSA上跑,效率高得不是一星半点。

我曾经在一个项目里,把优化器从三地址码改成SSA,编译时间没增加多少,但优化效果提升了30%以上。嗯,这投入很值。

控制流图(CFG):程序执行的“地图”

控制流图,说白了就是画出程序所有可能的执行路径。

每个基本块(一段顺序执行的代码)是一个节点,控制流跳转是边。CFG能帮我们回答很多问题:

  • 这段代码会不会执行到?(可达性分析)
  • 循环的入口和出口在哪?(循环分析)
  • 哪些路径是冗余的?(路径优化)

我画个简单的CFG结构图,你一看就明白了:

入口 基本块1 x = 0; if (cond) 真 假 基本块2 x = 1 基本块3 x = 2 基本块4 y = x + 1

你看,有了CFG,程序的执行路径就一目了然了。优化器可以沿着这些路径做各种分析。

小技巧:构建CFG时,别忘了处理不可达代码。我见过不少编译器因为没处理好死代码,导致优化结果出错。嗯,这坑我踩过。

数据流图:追踪值的“来龙去脉”

如果说CFG关注的是控制流,那数据流图关注的就是数据怎么在程序里流动。

数据流分析要回答的问题包括:

  • 这个变量在某个点还活着吗?(活跃变量分析)
  • 这个表达式之前算过吗?(可用表达式分析)
  • 这个变量一定被初始化了吗?(到达定值分析)

举个例子,活跃变量分析:

a = 1        // a被定义
b = a + 2    // a被使用,b被定义
c = b * 3    // b被使用,c被定义
// 到这里,a和b都不再被使用,它们“死了”
// c还活着,因为后面可能还会用到

数据流分析通常用数据流方程来描述。每个基本块有入口状态和出口状态,通过迭代计算,最终得到整个程序的数据流信息。

注意:数据流分析是保守的。也就是说,我们宁可多保留一些信息,也不能漏掉。漏掉可能导致优化错误,多保留只是优化效果差一点。安全第一。

四种IR的关系

这四种IR不是孤立的,它们相辅相成:

IR类型 关注点 典型用途
三地址码 指令粒度 基础代码生成
SSA 变量版本 高级优化
CFG 控制流路径 结构分析、循环优化
数据流图 数据依赖 寄存器分配、死代码消除

实际编译器里,这些IR往往是结合使用的。比如LLVM的IR,它既是SSA形式,又包含了CFG信息。你想想看,这样设计多巧妙——一次构建,多种分析都能用。

我个人习惯在构建IR时,先搭好CFG骨架,然后在每个基本块里填充三地址码,最后转换成SSA形式。这个流程走下来,后续的优化就顺了。

好了,关于IR的基础知识就聊到这。记住一句话:IR设计得好不好,直接决定了编译器的优化上限。别在这块偷懒,否则后面有你受的。

一句话总结:三地址码是骨架,SSA是血肉,CFG是地图,数据流图是导航。四者结合,才能让编译器真正“看懂”程序。

可可精品资料库