Skip to content

IR 与 Lowering

为什么 AST 之后还要再来一层

很多人第一次看编译器都会问:AST 不是已经很结构化了吗,为什么还要继续变?

答案是,AST 更适合表达“语法结构”,但后端更喜欢“动作更明确”的表示。

例如 ifwhilefor 在 AST 里还是高层语句节点,但后端真正关心的是:

  • 条件先怎么算
  • 分支往哪里跳
  • 循环从哪里开始
  • 循环在哪里结束

这就是 IR 要出场的原因:AST 已经很清楚了,但对后端来说还不够“能直接开工”。

先用一个小例子感受 AST 和 IR 的差别

看这段代码:

if (a < b) {
    return a;
} else {
    return b;
}

在 AST 里,它更像这样:

IfStmt
  condition: BinaryExpr(<)
  then: ReturnStmt(Name(a))
  else: ReturnStmt(Name(b))

这很适合表达“语法长什么样”,但还不够适合后端直接生成代码。

到了 IR,更接近:

t0 = load a
t1 = load b
t2 = binary < t0 t1
cjump t2 if_then_0 if_else_1
label if_then_0
return t0
jump if_end_2
label if_else_1
return t1
label if_end_2

这时程序已经不再像挂在墙上的结构图,而更像一张“先做什么、再做什么、跳到哪里去”的执行清单。

这个项目的 IR 长什么样

当前 IR 不是 SSA,也不是特别重型的设计,而是一种简洁的三地址风格表示。

它主要由这些元素组成:

  • 全局变量
  • 函数
  • 临时变量
  • 指令
  • 标签

指令层面常见的有:

  • const
  • copy
  • load
  • store
  • unary
  • binary
  • jump
  • cjump
  • call
  • return

如果你把这套指令和 src/c_core_compiler/ir.py 对照着看,会发现它故意保持得不多。意思很明确:先让前后端之间有条能走通的路,不急着再发明一门更复杂的小语言。

为什么说它更接近“动作序列”

举个例子,AST 里的 if 可能只是一个节点,里面挂着条件、then 分支、else 分支。

但到了 IR,它会被显式展开成类似这种思路:

  1. 先求条件值
  2. 做条件跳转
  3. 落到 then 标签
  4. 执行 then 代码
  5. 跳到 end
  6. 如果有 else,再落到 else 标签

这样后端不需要理解高层控制流结构,只需要忠实输出显式动作。

这里其实就是 IR 的精髓:源码里那些“读的人脑补出来的执行顺序”,到了这里都得摊开写明白。

什么叫 lowering

lowering 可以理解成“把高层结构往低层结构展开”。

这个项目里的 IRBuilder 做的就是这件事。

它不是简单复制 AST,而是把 AST 改写成:

  • 更显式的控制流
  • 更显式的中间结果
  • 更适合后端处理的形式

在当前项目里,这件事主要发生在:

  • src/c_core_compiler/ir_builder.py

你可以直接把这个文件理解成“把树拆成步骤表”的地方。

临时变量在这里为什么重要

表达式一旦拆开计算,就需要地方存中间结果。

例如:

a + b * c

一种典型展开方式会变成:

t0 = b * c
t1 = a + t0

这里的 t0t1 就是临时变量。

它们的存在让复杂表达式可以被拆成一系列小步骤。

如果没有临时变量,后端每次都得抱着整棵表达式树往下翻,既别扭,也不方便后面整理。

控制流结构是怎么被展开的

if

if 会被展开成:

  • 条件求值
  • 条件跳转
  • then 标签
  • else 标签
  • end 标签

while

while 会被展开成:

  • 条件标签
  • 循环体标签
  • 结束标签

并通过 jump / cjump 明确循环回边。

for

for 会被统一拆成四段:

  • 初始化
  • 条件
  • 循环体
  • 更新

这很重要,因为它把表面上一个语法结构,拆成后端更容易消费的标准形态。

也就是说,IRBuilder 不是照着源码誊写一遍,而是在主动把不同表面长相的语法,拧成后端更顺手的套路。

为什么 &&|| 不能当普通二元运算处理

因为它们带短路语义。

例如:

a && b

如果 a 已经是假,就不应该继续求 b

所以在 lowering 时,这类表达式不能只翻译成普通 binary。它必须通过分支和标签来保留“是否继续计算右边”的语义。

当前项目在 IRBuilder 里就专门把这件事显式展开了。

这也是为什么你第一次看 IR 时,常常会有一种感觉:源码明明就几行,怎么到了这里突然话这么多。不是编译器变笨了,而是它把原来藏在语法糖背后的执行顺序全摊出来了。

当前 IR 的边界在哪里

非常关键的一点是:当前 IR 主要覆盖基础 int 子集。

如果 AST 包含这些内容:

  • char
  • 数组
  • 指针
  • 字符串字面量
  • 取地址或解引用

那么当前实现会判定“需要 AST Backend”,而不是继续往这套 IR 下降。

这说明当前 IR 还不是项目里所有语言特性的统一表示层。

这不是嘴上说说,代码里真有这层判断:ir_builder.py 会检查 AST 里有没有超出当前 IR 能力圈的东西,一旦有,就不硬降,直接切去 AST Backend。

这是不是问题

这不是 bug,而是阶段性边界。

更准确地说,当前项目做了一个工程选择:

  • 先把 IR 路径在基础子集上打磨清楚
  • 先保证复杂特性可用
  • 之后再考虑把复杂特性并入统一 IR

这比表面上看着统一、实际上里面一团线头要稳得多。

所以你可以把当前 IR 理解成:

  • 它已经足够解释基础 int 子集的 lowering 思路
  • 但它还不是整个项目所有语言特性的最终统一层

如何观察这一层

可以直接运行:

python3 -m c_core_compiler input.c --emit-ir

或者:

python3 -m c_core_compiler input.c --emit-ir-dot

前者适合看线性 IR 文本,后者适合看控制流图。

如果你想真正理解这个编译器的“中间层思维”,这一层最值得仔细看。

一个很实用的学习方式是:

  1. 先拿 hello.c 看最简单表达式和返回
  2. 再拿 control_flow_demo.c 看标签和跳转
  3. 最后拿 pointer_demo.c 对照“为什么这类特性没有进入当前 IR”

这样你会很清楚地看到:IR 不是换个术语吓人,它就是这个项目现在真正拿来承上启下的中间执行模型。