章节目录

IR:把用户名字变成内部名字

本节阅读量:

解释器处理变量时,可以在 environment 里直接保存运行时值:

1
x -> 40

编译器不能让 CPU 在运行时直接查找源码里的字符串 x。最终汇编操作数必须落实为立即数、寄存器或内存地址,因此编译器要先解析这个名字指向哪个绑定。

第二章的编译器先做一件中间工作:

1
用户变量名 -> IR 内部名字

例如:

1
x -> x0

后面的汇编生成器再把 IR 名字放到栈槽里:

1
x0 -> -8(%rbp)

先把三个阶段放在一起,避免把它们当成同一张表:

阶段 什么时候存在 对应关系 用途
解释器环境 执行 mini run 时 x -> 40 查到变量当前的运行时值
lowerer 环境 生成 IR 时 x -> temp(x0) 把源码名字解析为唯一 IR 结果
栈槽表 生成汇编时 x0 -> -8(%rbp) 把 IR 结果落实为内存位置

lowerer 环境只是编译期间使用的数据结构,不会被放进生成的程序。目标程序真正运行时,-8(%rbp) 这个位置才会保存数值 40。

普通 let 的 IR

源码:

1
(let x 40 (+ x 2))

IR:

1
2
3
x0 = 40
t.0 = x0 + 2
return t.0

这里的 x0 是 let 绑定的内部名字,t.0 是加法结果的临时名字。源码 identifier 不允许数字和点号,因此这两类内部名字都不会与用户能够写出的名字冲突。点号专门区分普通计算临时值:用户变量 t 的绑定名会变成 t0,不会和 t.0 混在一起。

例如:

1
(+ (let t 1 t) (+ 2 3))

会得到互不冲突的内部名字:

1
2
3
4
t0 = 1
t.0 = 2 + 3
t.1 = t0 + t.0
return t.1

为什么不直接继续叫 x?因为 shadowing 会出现多个同名用户变量:

1
(let x 10 (+ (let x 32 x) x))

如果 IR 里都叫 x,内层和外层就混在一起了。

IR 新增 copy 操作

第一章的 IR 只有加法:

1
t.0 = 40 + 2

第二章需要表示:

1
x0 = 40

生成 copy 是当前朴素 IR 的教学选择,不是实现不可变 let 的唯一办法。编译器理论上可以直接把 x 映射到 value 的操作数;这里仍然生成 x0 = value,是为了让每次绑定都有独立、可观察的内部身份,后续也能统一分配位置。第一版不做删除多余 copy 的优化。

这一步不是加法,只是把一个操作数保存到一个内部名字里,所以 IR 增加了 copy:

1
2
3
4
enum class OpKind {
    copy,
    add,
};

打印 IR 时,copy 写成:

1
dst = lhs

例如:

1
x0 = 40

lower_expr 的环境

代码在:

1
code/02_let/src/compile/ir.cpp

IR lowerer 也需要环境。为了和解释器里的 eval_expr(expr, env) 对齐,第二章开始把环境显式作为 lower_expr 的参数:

1
2
3
using Env = std::vector<std::pair<std::string, Operand>>;

Operand lower_expr(const Expr& expr, std::vector<Op>& ops, Env& env);

它保存的不是运行时值,而是:

1
用户变量名 -> IR 操作数

例如:

1
x -> temp(x0)

这里的 OperandKind::temp 泛指“一个有名字的 IR 结果”。x0 是绑定的内部结果名,t.0 是计算产生的临时结果名;来源不同,但后续汇编生成器都可以把它们分配到栈槽。

查找逻辑和解释器类似:

1
2
3
4
5
6
7
8
Operand lookup(const Env& env, const std::string& name) const {
    for (auto it = env.rbegin(); it != env.rend(); ++it) {
        if (it->first == name) {
            return it->second;
        }
    }
    throw std::runtime_error("undefined variable: " + name);
}

同样是从后往前找,用来支持 shadowing。

变量引用如何 lowering

变量引用分支很短:

1
2
3
4
case ExprKind::variable: {
    const auto& var_expr = static_cast<const VarExpr&>(expr);
    return lookup(env, var_expr.name);
}

可以读成:

1
2
Var(x) 本身不生成新 IR。
它只是查出 x 当前对应哪个操作数。

如果环境里有:

1
x -> x0

那么 Var(x) lowering 后就返回操作数 x0。

let 如何 lowering

let 分支按顺序完成这些步骤:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
case ExprKind::let: {
    const auto& let_expr = static_cast<const LetExpr&>(expr);
    Operand value = lower_expr(*let_expr.value, ops, env);
    std::string local = let_expr.name + std::to_string(next_local_++);
    ops.push_back({OpKind::copy, local, std::move(value), Operand::integer(0)});
    env.push_back({let_expr.name, Operand::temp(local)});
    Operand result = lower_expr(*let_expr.body, ops, env);
    env.pop_back();
    return result;
}

按顺序读:

1
2
3
4
5
6
7
1. 先 lower value,得到 value 的结果在哪里。
2. 生成一个内部名字,例如 x0。
3. 追加 copy 操作:x0 = value。
4. 在环境里加入:用户名 x -> IR 名字 x0。
5. lower body。
6. 离开 let,弹出这个绑定。
7. 返回 body 的结果操作数。

这里的 value 在加入新绑定之前 lower,正好对应解释器规则:

1
2
let 的 value 在旧环境里求值。
let 的 body 在扩展后的环境里求值。

第 6 步的 env.pop_back() 只结束源码名字在 lowerer 中的可见范围。它发生在编译期间,不会生成汇编 popq,也不会立即释放 x0 的栈槽。lowering 先生成整段 IR,汇编生成器随后为所有 op.dst 一次分配固定栈槽;目标函数返回时才整体收回栈帧。

当前 Op 结构为了保持第一章的小模型,所有操作都有 lhs 和 rhs 字段。copy 只使用 lhs,rhs 是占位;真正决定怎么打印和生成汇编的是 OpKind::copy。

从 IR 看 value 和 body 的环境

shadow_value.lang 最能看出新绑定加入的时机:

1
(let x 1 (let x (+ x 1) x))

生成的 IR 是:

1
2
3
4
x0 = 1
t.0 = x0 + 1
x1 = t.0
return x1

内层 value (+ x 1) 使用的是旧环境,所以读到外层 x0。等 t.0 算好以后,lowerer 才建立内层绑定 x1;内层 body 的 x 因而返回 x1。

shadowing 的 IR

运行:

1
2
3
cd code/02_let
make
./mini ir examples/shadow.lang

输出:

1
2
3
4
x0 = 10
x1 = 32
t.0 = x1 + x0
return t.0

对应关系是:

1
2
外层 x -> x0
内层 x -> x1

IR 把这个关系写得很清楚。下一节会把 x0、x1、t.0 这些 IR 名字分配到栈槽。


2.4 解释器:环境与变量查找

上一节

2.6 汇编:为 copy 增加翻译规则

下一节