章节目录

IR:两个函数值临时指向同一个入口

本节阅读量:

解释器通过调用环境建立 sum -> callee。编译器没有运行时的名字环境,它必须把同一件事提前变成明确的 IR 操作。

这里不需要增加“递归调用”这种特殊机器动作。普通函数调用已经能通过函数值完成递归;lowering 只需要确保函数体内部也能取得自己的入口地址。

先观察完整结果

运行:

1
2
3
cd code/05_recursion
make
./mini ir examples/recursive_sum.lang

当前实现输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
f.0 = function lambda0
t.5 = call f.0 5
return t.5
lambda0(n):
  sum0 = function lambda0
  t.0 = eq? n 0
  if t.0 goto then0 else else0
  then0:
  t.1 = 0
  goto end0
  else0:
  t.2 = sub1 n
  t.3 = call sum0 t.2
  t.4 = n + t.3
  t.1 = t.4
  end0:
  return t.1
end lambda0

先抓住其中最重要的三行:

1
2
3
f.0   = function lambda0    主程序取得函数入口地址
sum0 = function lambda0    函数体再次取得同一个入口地址
t.3  = call sum0 t.2       通过函数体自己的临时值递归调用

f.0 和 sum0 是两个不同的 IR 临时值,却都表示同一个标签 lambda0 的地址。它们分别属于主程序和函数体,之后也会进入不同的机器栈帧。

IR 只新增 sub1 操作

第五章的 OpKind 在第四章基础上增加 sub1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
enum class OpKind {
    copy,
    add,
    sub1,
    equal,
    branch,
    jump,
    label,
    function_ref,
    call,
};

这里没有 letrec 操作,也没有 recursive_call 操作:

  • letrec 在 lowering 时展开成独立 IrFunction、两个 function_ref 和已有的名字绑定;
  • 自调用仍然使用第四章已经存在的 call;
  • sub1 才是本章唯一需要直接交给汇编生成器的新操作。

这也说明 AST 和 IR 不需要一一对应。AST 保留源语言的作用域结构,IR 则只保留后端继续工作所需的动作。

sub1 的 lowering 与 add 很接近:先降低子表达式,再产生一个新临时值。

1
2
3
4
5
6
7
8
case ExprKind::sub1: {
    const auto& sub1_expr = static_cast<const Sub1Expr&>(expr);
    Operand value = lower_expr(*sub1_expr.expr, ops, env);
    std::string dst = new_temp();
    ops.push_back(
        {OpKind::sub1, dst, std::move(value), Operand::integer(0), "", ""});
    return Operand::temp(dst);
}

例如 (sub1 n) 变成:

1
t.2 = sub1 n

lowering 的三个上下文没有改变

第五章继续沿用前几章建立的接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
using Env = std::vector<std::pair<std::string, Operand>>;

class IrLowerer {
    // ...
    Operand lower_expr(const Expr& expr,
                       std::vector<Op>& ops,
                       Env& env);

    IrProgram program_;
};

三个上下文各有清楚的职责:

上下文 作用
program_ 保存整段程序,包括主程序与所有独立函数
ops 当前正在追加操作的列表,可能属于主程序,也可能属于某个函数
env 当前词法位置的源语言名字到 IR operand 的映射

IrProgram 是整个 lowering pass 的状态,不需要在每一层递归调用之间来回传递。ops 和 env 却必须显式传入,因为降低函数体时,这两者都要切换到函数自己的上下文。

入口保持很简单:

1
2
3
4
5
IrProgram lower(const Expr& expr) {
    Env env;
    program_.result = lower_expr(expr, program_.ops, env);
    return std::move(program_);
}

主程序从空环境开始,操作追加到 program_.ops。遇到函数后,再创建独立的 function.ops 和 function_env。

第一步:主程序取得函数地址

letrec lowering 先为函数选择标签和主程序中的函数值临时:

1
2
3
4
5
6
7
8
9
std::string label = "lambda" + std::to_string(next_function_++);
std::string function_value =
    "f." + std::to_string(next_function_value_++);
ops.push_back({OpKind::function_ref,
               function_value,
               Operand::integer(0),
               Operand::integer(0),
               label,
               ""});

对贯穿示例,这一步产生:

1
f.0 = function lambda0

它的意思不是执行 lambda0,而是把 lambda0 的入口地址保存为值。随后 lowering 会在 letrec 的 body 环境里建立:

1
sum -> f.0

于是最外层 (sum 5) 能变成:

1
t.5 = call f.0 5

第二步:函数体建立自己的自引用

主程序里的 f.0 不能直接供递归函数体使用。f.0 将来属于 main 的栈帧,而每次执行 lambda0 时都有另一份独立栈帧。

因此 lowering 在函数体自己的操作列表中,再放入一个指向相同标签的 function_ref:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::string self = letrec_expr.name + std::to_string(next_local_++);
IrFunction function;
function.label = label;
function.param = letrec_expr.param;
function.ops.push_back({OpKind::function_ref,
                        self,
                        Operand::integer(0),
                        Operand::integer(0),
                        label,
                        ""});

贯穿示例中的 self 是 sum0,所以函数体开头得到:

1
sum0 = function lambda0

接着只为函数体建立一份新的名字环境:

1
2
3
4
5
Env function_env;
function_env.push_back(
    {letrec_expr.name, Operand::temp(self)});
function_env.push_back(
    {letrec_expr.param, Operand::temp(letrec_expr.param)});

它包含:

1
2
sum -> sum0
n   -> n

第一条映射让函数体中的 Var(sum) 得到 sum0,第二条映射让 Var(n) 得到当前函数参数。这里没有复制外层 env,因此 lowering 与解释器遵守相同边界:递归函数能看到自己和参数,不能直接读取其他外层变量。

最后把函数体 lowering 到独立操作列表,并登记到整段程序:

1
2
3
function.result =
    lower_expr(*letrec_expr.function_body, function.ops, function_env);
program_.functions.push_back(std::move(function));

第三步:降低 letrec 的 body

独立函数登记完成后,原来的外层 env 一直没有被替换。lowering 只在其中临时加入递归名字:

1
2
3
4
env.push_back({letrec_expr.name, Operand::temp(function_value)});
Operand result = lower_expr(*letrec_expr.body, ops, env);
env.pop_back();
return result;

这对应源语言的作用域规则:

1
2
function_body  使用新的 function_env:self + parameter
letrec body    使用原来的 env:outer bindings + function_value

两份环境不能混为一谈。若把外层 env 直接交给 function_body,就会在尚未实现闭包表示时错误地允许函数读取外层局部值。

完整的 letrec lowering

把三个阶段合在一起,当前代码是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
case ExprKind::letrec: {
    const auto& letrec_expr = static_cast<const LetRecExpr&>(expr);
    std::string label = "lambda" + std::to_string(next_function_++);
    std::string function_value =
        "f." + std::to_string(next_function_value_++);
    ops.push_back({OpKind::function_ref,
                   function_value,
                   Operand::integer(0),
                   Operand::integer(0),
                   label,
                   ""});

    std::string self = letrec_expr.name + std::to_string(next_local_++);
    IrFunction function;
    function.label = label;
    function.param = letrec_expr.param;
    function.ops.push_back({OpKind::function_ref,
                            self,
                            Operand::integer(0),
                            Operand::integer(0),
                            label,
                            ""});

    Env function_env;
    function_env.push_back(
        {letrec_expr.name, Operand::temp(self)});
    function_env.push_back(
        {letrec_expr.param, Operand::temp(letrec_expr.param)});
    function.result =
        lower_expr(*letrec_expr.function_body, function.ops, function_env);
    program_.functions.push_back(std::move(function));

    env.push_back({letrec_expr.name, Operand::temp(function_value)});
    Operand result = lower_expr(*letrec_expr.body, ops, env);
    env.pop_back();
    return result;
}

它没有修改普通 lambda 的 lowering。普通函数的 function_env 仍然只包含参数;只有专用 letrec 会额外加入 self 映射。

按求值顺序阅读完整 IR

函数体的 IR 可以分成四段。

第一段取得 self 并检查停止条件:

1
2
3
sum0 = function lambda0
t.0 = eq? n 0
if t.0 goto then0 else else0

条件为真时,CFG 沿 branch 的 then 边进入 then0,把结果设为 0:

1
2
3
then0:
t.1 = 0
goto end0

当前 lowering 按固定约定把 then0 放在 branch 后面,但在 IR/CFG 层它仍然是显式的 branch 目标。到了汇编层,这个固定的物理布局才会让 then 路径直接顺序执行,并只用条件跳转前往 else;本章不做基本块重排或额外窥孔优化。

条件为假时,先把参数减一,再通过 sum0 调用同一个函数入口:

1
2
3
else0:
t.2 = sub1 n
t.3 = call sum0 t.2

递归调用返回后,当前这一层才能把自己的 n 加到结果上:

1
2
3
4
t.4 = n + t.3
t.1 = t.4
end0:
return t.1

t.3 是更深一层递归的结果,n 则是当前函数调用的参数。两者在 call 之后仍然要参与加法,所以 recursive_sum.lang 中的递归调用不在尾位置。

你还会看到函数体使用了 t.0 到 t.4,主程序的调用结果却编号为 t.5。原因是 lowering 在遇到 letrec 时先完整降低并登记函数体,之后才继续降低 letrec 的 body。编号反映的是 lowering 的处理顺序,不是最终打印行的先后顺序。

函数值被转存后仍然指向同一入口

运行:

1
./mini ir examples/recursive_function_value.lang

主程序开头会变成:

1
2
3
f.0 = function lambda0
f1 = f.0
t.5 = call f1 5

源程序让 letrec 返回函数值,再用普通 let 把它保存为 f。IR 名字虽然从 f.0 复制成了 f1,其中的机器值仍是 lambda0 的地址;函数体内部也仍会用 sum0 取得这个标签。递归能力不依赖外层变量最终叫什么名字。

不支持的捕获会在哪里被发现

对下面的边界示例:

1
./mini ir examples/recursive_capture_error.lang

lowering 会报告:

1
error: undefined variable: base

这是因为 function_env 只有 adddown 和 n。编译器在 lowering 函数体时就遇到了 base,所以它通常比解释器更早发现这个作用域错误;支持范围内的程序结果保持一致,并不要求两条路径在同一时刻报告所有错误。

到这里,递归在 IR 中可以压缩成一句话:主程序和递归函数体各自取得同一个函数标签的地址,再完全复用普通的间接 call。


5.4 解释器:每次调用都把函数自己带进去

上一节

5.6 汇编:调用向下展开,结果逐层返回

下一节