一套简单的编程语言中间表示(IR)设计与详解

1. 概述 (Overview)

1.1 什么是中间表示 (IR)?

中间表示(Intermediate Representation, IR)是编译器中前端和后端之间的一个桥梁。前端负责将源代码(如 C、Java)解析成一种抽象的、树状的结构(如抽象语法树 AST),而后端则负责将这种结构转换成特定目标机器的机器码。

IR 就是介于这两者之间的一种表示形式。它既脱离了源代码的语法细节,也独立于目标机器的具体架构。

1.2 为什么需要 IR?

使用 IR 的主要好处在于解耦模块化

  1. 支持多语言和多平台

    • 我们可以为 N 种不同的编程语言设计 N 个前端,它们都生成同一种 IR。
    • 我们也可以为 M 种不同的目标架构(如 x86, ARM)设计 M 个后端,它们都处理同一种 IR。
    • 这样,我们只需要 N + M 个组件,就可以实现 N * M 种语言到平台的编译,而不需要 N * M 个独立的编译器。
  2. 方便优化

    • 大多数代码优化都在 IR 上进行。IR 的结构通常比 AST 更线性、更接近机器指令,但又比机器码更抽象、更具通���性,这使得分析和变换代码变得更加容易。例如,常量折叠、公共子表达式消除、死代码删除等优化都非常适合在 IR 层面实现。

1.3 本次设计的目标

我们将为一种简单的、类似 C 的过程式语言(我们称之为 “SimpleC”)设计一套 IR。设计目标如下:

  • 清晰易懂:指令集简单明了,易于理解和实现。
  • 易于生成:从 AST 转换到该 IR 的过程应该相对直接。
  • 支持关键优化:其结构应能方便地支持一些基础的编译器优化。
  • 平台无关:不依赖任何特定的 CPU 架构。

2. 核心设计决策:三地址码 (3AC)

我们选择三地址码(Three-Address Code, 3AC) 作为我们 IR 的基础形式。

什么是三地址码?

三地址码是一种指令形式,其中每条指令最多有三个操作数(地址)。其通用形式如下:

result = operand1 op operand2

例如,对于源代码 x = y + z * 5;,其 3AC 形式可能如下:

t1 = z * 5
t2 = y + t1
x = t2

其中 t1t2 是编译器生成的临时变量。

为什么选择 3AC?

  • 结构简单:每条指令只执行一个基本操作,使得指令的分析和处理非常简单。
  • 操作数明确:所有的操作数(包括中间��果)都被显式地命名(如 t1, t2),这对于数据流分析和优化至关重要。
  • 指令线性化:3AC 将复杂的表达式和嵌套的控制流拆解成一个线性的指令序列,更接近最终的机器码。

3. IR 指令集详解

我们的 IR 由一系列指令构成。每条指令包含一个操作码(opcode)和零到三个操作数。操作数可以是临时变量(虚拟寄存器)常量标签

我们将临时变量表示为 %t 后跟数字(如 %t0, %t1),将标签表示为 L 后跟数字(如 L0, L1)。

3.1 算术与逻辑运算指令

这类指令遵循 dest = src1 op src2 的格式。

指令 语法 描述
ADD ADD dest, src1, src2 dest = src1 + src2
SUB SUB dest, src1, src2 dest = src1 - src2
MUL MUL dest, src1, src2 dest = src1 * src2
DIV DIV dest, src1, src2 dest = src1 / src2
AND AND dest, src1, src2 dest = src1 & src2 (位与)
OR OR dest, src1, src2 `dest = src1
EQ EQ dest, src1, src2 dest = (src1 == src2) (等于)
NE NE dest, src1, src2 dest = (src1 != src2) (不等于)
LT LT dest, src1, src2 dest = (src1 < src2) (小于)
GT GT dest, src1, src2 dest = (src1 > src2) (大于)

3.2 数据移动与内存访问指令

指令 语法 描述
MOV MOV dest, src dest = src (数据拷贝)
LOAD LOAD dest, addr dest = *addr (从内存地址 addr 加载值)
STORE STORE addr, src *addr = src (将 src 的值存入内存地址 addr)
ADDR ADDR dest, var dest = &var (获取变量 var 的地址)

3.3 控制流指令

指令 语法 描述
LABEL LABEL name 定义一个名为 name 的代码标签
JMP JMP label 无条件跳转到 label
JZ JZ cond, label 如果 cond 为 0,则跳转到 label (Jump if Zero)
JNZ JNZ cond, label 如果 cond 不为 0,则跳转到 label (Jump if Not Zero)

3.4 函数调用指令

指令 语法 描述
PARAM PARAM value 传递一个参数给即将调用的函数
CALL CALL dest, func_name 调用函数 func_name,并将返回值存入 dest
RET RET value 从当前函数返回 value
ENTER ENTER func_name 标记函数 func_name 的入口
LEAVE LEAVE func_name 标记函数 func_name 的出口

4. 数据表示

  • 变量
    • 局部变量���在函数开始时,通过 ALLOC 指令(未在上面列出,但实际实现需要)在栈上分配空间。通过 ADDR 获取其地址,然后使用 LOAD/STORE 访问。
    • 临时变量:即虚拟寄存器 (%t0, %t1, …)。它们被认为是无限的,并且不直接对应物理寄存器。寄存器分配是后端的工作。
  • 常量
    • 整型和浮点型常量直接作为操作数使用,例如 ADD %t0, %t1, 42
  • 类型
    • 在我们的简单 IR 中,类型信息是隐式的或通过指令的变体来处理。例如,可以有 ADD_I32 (32位整数加) 和 ADD_F64 (64位浮点数加)。为了简化,我们这里假设类型在指令中是明确的。

5. 示例:从源代码到 IR

让我们来看一个 “SimpleC” 的例子,并将其转换为我们的 IR。

源代码 (sum 函数):

int sum(int n) {
  int i = 0;
  int total = 0;
  while (i < n) {
    total = total + i;
    i = i + 1;
  }
  return total;
}

生成的 IR:

# IR for sum(int n)

ENTER sum

# int i = 0;
# int total = 0;
# (n is passed as a parameter, let's assume it's in %t0)
MOV %t1, 0         # i = 0; (%t1 is i)
MOV %t2, 0         # total = 0; (%t2 is total)

LABEL L0           # Start of the while loop

# while (i < n)
LT %t3, %t1, %t0   # %t3 = (i < n)
JZ %t3, L1         # if (%t3 == 0) jump to L1 (loop exit)

# total = total + i;
ADD %t2, %t2, %t1  # total = total + i

# i = i + 1;
ADD %t1, %t1, 1    # i = i + 1

JMP L0             # Jump back to the start of the loop

LABEL L1           # Loop exit label

# return total;
RET %t2

LEAVE sum

讲解:

  1. ENTER sumLEAVE sum 标记了函数的边界。
  2. MOV 指令用于初始化局部变量 itotal(分别用 %t1%t2 表示)。参数 n 假定已在 %t0 中。
  3. while 循环被转换成一个 LABEL、一个条件比较 (LT)、一个条件跳转 (JZ) 和一个无条件跳转 (JMP) 的组合。
  4. LT %t3, %t1, %t0 计算 i < n,结果(0 或 1)存入 %t3
  5. JZ %t3, L1 检查 %t3。如果为 0(即 i < n 为 false),则跳转到 L1 结束循环。
  6. 循环体内的加法操作被直接翻译成 ADD 指令。
  7. JMP L0 实现了循环的向后跳转。
  8. 最后,RET %t2 返回 total 的最终值。

6. 优化

这套 IR 设计可以很好地支持多种优化。

6.1 常量折叠 (Constant Folding)

如果一个计算的所有操作数都是常量,那么计算可以在编译时完成。

优化前:

MOV %t0, 5
MOV %t1, 10
ADD %t2, %t0, %t1

优化后:

MOV %t2, 15

编译器可以扫描 IR,找到 ADD 指令的操作数是常量,直接计算结果并用 MOV 替换。

6.2 死代码消除 (Dead Code Elimination)

如果一个变量被赋值,但其值从未被使用,那么这个赋值操作就是“死代码”,可以被移除。

优化前:

MOV %t0, 5
ADD %t1, %t0, 2  # %t1 is calculated
MOV %t2, 10      # %t1 is never used again
RET %t2

优化后:

MOV %t0, 5
MOV %t2, 10
RET %t2

通过数据流分析,编译器可以发现 %t1 是一个无用变量,从而移除为其赋值的 ADD 指令。


7. 总结与未来工作

我们设计了一套基于三地址码的简单 IR,它清晰、平台无关,并且能够有效地支持代码生成和优化。通过将高级语言结构(如 while 循环)分解为更基本的跳转和算术指令,它为后端代码生成和各种优化技术提供了坚实的基础。

未来可能的扩展:

  • 引入 SSA 形式:静态单赋值(Static Single Assignment)是目前主流 IR(如 LLVM IR)采用的形式。在 SSA 中,每个变量只被赋值一次。这使得许多数据流分析和优化变得更加简单和强大。
  • 更丰富的类型系统:增加对结构体(struct)、数组(array)等复杂数据类型的支持。
  • 内存管理指令:为支持动态内存分配,可以添加 MALLOCFREE 等指令。
  • 异常处理支持:添加用于实现 try-catch 块的指令。