章节目录

汇编:代码地址和间接调用

本节阅读量:

上一节已经确定机器层面的交接方式:函数值暂时表示成一个代码地址,第一个机器字参数放进 %rdi,返回值从 %rax 取回,每次调用拥有独立栈帧。

现在把贯穿示例的 IR 真正翻译成 x86-64 汇编:

1
2
3
4
5
6
7
8
f.0 = function lambda0
inc0 = f.0
t.1 = call inc0 41
return t.1
lambda0(x):
  t.0 = x + 1
  return t.0
end lambda0

代码在:

1
code/04_functions/src/compile/assembly.cpp

第一遍可以先跳过槽位分配器的实现,只追踪两组核心映射:

1
2
function_ref -> leaq 取得代码地址
call         -> %rdi 准备参数,call *%rcx 进入函数,%rax 接回结果

第二遍再看:emitter 怎样处理主程序和函数列表

汇编生成器的入口清楚地反映了 IrProgram 的两层结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::string emit(const IrProgram& program, Target target) {
    out_ += ".globl " + main_label(target) + "\n";
    out_ += main_label(target) + ":\n";
    emit_body(program.ops, program.result, nullptr);

    for (const auto& function : program.functions) {
        out_ += label_text(function.label) + ":\n";
        emit_body(function.ops, function.result, &function.param);
    }

    return out_;
}

main_label() 在 Linux 上返回 main,在 macOS 上返回 _main。语言内部生成的函数 label 会经过:

1
2
3
std::string label_text(const std::string& label) const {
    return ".L" + label;
}

因此 IR 的 lambda0 在汇编里写成 .Llambda0。以 .L 开头的名字用于当前汇编文件内部,不需要像 main 一样导出给链接器。

主程序和普通函数最终都交给 emit_body()。区别只有参数:主程序传 nullptr,函数体传自己的参数名。

第二遍再看:每个 body 都重新分配栈槽

assign_stack_slots() 为当前 body 的参数和每个会产生结果的操作分配槽位:

 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
StackSlots assign_stack_slots(const std::vector<Op>& ops, const std::string* param) {
    StackSlots slots;
    int offset = -8;

    if (param != nullptr) {
        assign_dst(slots, *param, offset);
    }

    for (const auto& op : ops) {
        switch (op.kind) {
        case OpKind::copy:
        case OpKind::add:
        case OpKind::equal:
        case OpKind::function_ref:
        case OpKind::call:
        case OpKind::input:
        case OpKind::output:
            assign_dst(slots, op.dst, offset);
            break;
        case OpKind::branch:
        case OpKind::jump:
        case OpKind::label:
            break;
        }
    }

    return slots;
}

function_ref 会产生函数地址,call 会产生返回值,因此它们和 copy、add、equal 一样需要 dst 栈槽。章末可选实验中的 input、output 也接收 C++ runtime 的返回值,所以同样分配目的槽位。分支、跳转和 label 不产生值,不分配槽位。

assign_dst() 只在名字第一次出现时分配位置。这样 if 两条路径写同一个结果名时,仍会共用一个栈槽。

emit_body() 每次都重新计算槽位,并把总大小补齐到 16 字节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void emit_body(const std::vector<Op>& ops,
               const Operand& result,
               const std::string* param) {
    slots_ = assign_stack_slots(ops, param);
    stack_size_ = align_to_16(slots_.size() * 8);

    emit_prologue();
    if (param != nullptr) {
        out_ += "    movq %rdi, " + stack_slot(*param) + "\n";
    }

    for (const auto& op : ops) {
        emit_op(op);
    }

    out_ += "    movq " + operand_text(result) + ", %rax\n";
    emit_epilogue();
    out_ += "    retq\n";
}

这里包含一个完整函数体的固定顺序:

1
2
3
4
5
分配并建立当前栈帧
如果有参数,把 %rdi 保存到参数槽
依次生成当前 body 的操作
把 body 结果放入 %rax
收起当前栈帧并 retq

对贯穿示例,主程序的 f.0、inc0、t.1 分别得到 -8、-16、-24,24 字节向上补成 32 字节。lambda0 先为参数 x 分配 -8,再为 t.0 分配 -16,正好需要 16 字节。

function_ref:取得代码地址,不执行代码

IR:

1
f.0 = function lambda0

对应 emit_op() 的这一支:

1
2
3
4
case OpKind::function_ref:
    out_ += "    leaq " + label_text(op.target) + "(%rip), %rax\n";
    out_ += "    movq %rax, " + stack_slot(op.dst) + "\n";
    return;

生成的汇编是:

1
2
    leaq .Llambda0(%rip), %rax
    movq %rax, -8(%rbp)

leaq 在这里不会读取或运行 .Llambda0 里的指令。指令编码保存标签相对 %rip 的位移,CPU 用它算出 .Llambda0 的实际代码地址;%rax 中得到的是可供间接 call 使用的地址,不是那段位移。这种 RIP-relative 写法在程序的装载位置改变后仍能正确引用同一份代码。

第二行再把这个地址保存到 f.0 的栈槽。后面的普通 copy 会把它复制给 let 局部名字 inc0:

1
2
    movq -8(%rbp), %rax
    movq %rax, -16(%rbp)

机器只是在复制 8 字节,不需要为“复制函数值”增加另一条专用指令。

call:通过运行时地址间接调用

IR:

1
t.1 = call inc0 41

对应的 emitter 分支是:

1
2
3
4
5
6
case OpKind::call:
    out_ += "    movq " + operand_text(op.lhs) + ", %rcx\n";
    out_ += "    movq " + operand_text(op.rhs) + ", %rdi\n";
    out_ += "    call *%rcx\n";
    out_ += "    movq %rax, " + stack_slot(op.dst) + "\n";
    return;

生成:

1
2
3
4
    movq -16(%rbp), %rcx
    movq $41, %rdi
    call *%rcx
    movq %rax, -24(%rbp)

可以按四步读:

1
2
3
4
把 callee 的代码地址读进 %rcx
把一个机器字参数放进 %rdi
通过 %rcx 中的地址发起调用
返回后把 %rax 保存为 call 表达式的结果

call *%rcx 中的 * 表示间接调用:目标来自寄存器里的运行时值。它不同于把目标写死在指令中的直接调用:

1
2
call .Llambda0       # 直接调用固定 label
call *%rcx           # 间接调用 %rcx 中保存的地址

本章允许函数先进入 let、作为参数传递,甚至由另一个函数返回。调用位置未必知道这个值最初来自哪个 lambda,所以必须保留间接调用。

完整汇编

运行:

1
2
cd code/04_functions
./mini compile examples/function_value.lang -o out.s --target linux

当前生成器在 Linux target 下会写出下面这份完整汇编:

 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
.globl main
main:
    pushq %rbp
    movq %rsp, %rbp
    subq $32, %rsp
    leaq .Llambda0(%rip), %rax
    movq %rax, -8(%rbp)
    movq -8(%rbp), %rax
    movq %rax, -16(%rbp)
    movq -16(%rbp), %rcx
    movq $41, %rdi
    call *%rcx
    movq %rax, -24(%rbp)
    movq -24(%rbp), %rax
    movq %rbp, %rsp
    popq %rbp
    retq
.Llambda0:
    pushq %rbp
    movq %rsp, %rbp
    subq $16, %rsp
    movq %rdi, -8(%rbp)
    movq -8(%rbp), %rax
    addq $1, %rax
    movq %rax, -16(%rbp)
    movq -16(%rbp), %rax
    movq %rbp, %rsp
    popq %rbp
    retq

macOS target 的主入口会写成 _main;内部的 .Llambda0 和这里相同。

为什么 main 不会顺序落进 lambda0

从汇编文件的文字顺序看,.Llambda0 紧跟在 main 后面,初学者很容易担心:主程序执行完会不会继续往下走进函数体?

不会,因为 main 的最后一条指令是:

1
retq

retq 根据栈上的返回地址跳回调用 main 的 C 运行时,不会继续执行内存中的下一条指令。.Llambda0: 只是给后面那段代码标出入口,本身也不会主动执行。

程序进入 .Llambda0 的唯一时刻是:

1
call *%rcx

这条 call 把自己的返回位置压栈,再跳到 .Llambda0。lambda0 末尾的另一个 retq 会返回到 call 后面的:

1
movq %rax, -24(%rbp)

所以应该按控制流读这份汇编,而不是把文件中的上下排列误认为永远顺序执行。

当前编译器没有运行时类型检查

解释器的 Value 带有 number 或 function 种类,因此能够在调用数字、把函数交给加法或比较时报告清楚的错误。当前编译器没有对应的类型标签和检查。

在生成的汇编里:

1
2
3
4
$41                         是一个 64 位机器字
.Llambda0 的地址            也是一个 64 位机器字
保存它们的栈槽              没有记录值的种类
call *%rcx                  不会先确认 %rcx 真的是代码地址

例如:

1
(42 1)

解释器会报告“调用需要函数”。编译器却会生成把 42 放进 %rcx 后执行 call *%rcx 的代码,运行时很可能跳到无效地址并崩溃。同样,把函数值交给 + 会把代码地址当整数相加,交给 eq? 会比较地址;这些都不是源语言承诺的结果。

因此,第四章编译路径目前只保证满足下面约定的程序:

1
2
3
实际执行到的 + 和 eq? 的操作数是整数
实际执行到的 call 的 callee 是函数
一个机器字参数和返回值可以是整数,也可以是函数地址

最后一条正是高阶函数能够工作的原因。例如把函数作为参数传入,再在函数体中调用它,参数寄存器与栈槽只需照常搬运一个机器字。这里没有静态类型检查器;这些条件,加上只使用本章示例中的小整数、整数加法不发生有符号 long 溢出,是生成代码能够与解释器保持一致的前提。极大立即数的编码和溢出语义不在本章范围内。

还有一个观察结果的边界:解释器可以把顶层函数值显示成 <function>;编译后的 main 则按 C ABI 返回,随后由 C 运行时把整数返回值变成进程退出状态。

若 %rax 中是代码地址,这个退出状态没有教学意义;即使结果是整数,shell 的 $? 也只适合观察 0 到 255。手动验证编译结果时,应让顶层程序最终算出这个范围内的小整数。

手动验证完整路径

mini compile 只负责生成汇编,不会替你调用 C 编译器或链接器。在 Linux、macOS 或 WSL 的 shell 中可以分三步验证:

1
2
3
4
5
6
cd code/04_functions
make
./mini compile examples/function_value.lang -o out.s
cc out.s -o out
./out
echo $?

./out 本身不会打印内容。程序把语言结果作为 main 的返回值交给 C 运行时,再成为进程退出状态,所以紧接着的 echo $? 应显示:

1
42

也可以先比较解释器和 IR:

1
2
./mini run examples/function_value.lang
./mini ir examples/function_value.lang

第一条命令直接打印 42;第二条展示主程序、函数列表以及连接两者的 function_ref 和 call。至此,同一个源程序已经完整走过:

1
2
3
4
5
源码
-> AST
-> 主程序与函数体 IR
-> 代码地址与间接调用汇编
-> cc 链接后的可执行程序

这条主线仍然用进程退出状态观察结果。若想让生成的汇编主动调用 C++,并直接打印不受 0 到 255 限制的结果,可以继续完成章末的可选 runtime 实验。那里会把固定 primitive (cin)、(cout expr) 分别编译成对两个外部 C++ 函数的直接 call;它只在第四章出现,不扩展成通用 FFI。


4.6 栈帧和调用约定:让两个函数完成交接

上一节

4.8 本章总结

下一节