汇编:代码地址和间接调用
本节阅读量:
上一节已经确定机器层面的交接方式:函数值暂时表示成一个代码地址,第一个机器字参数放进 %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:
对应 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:
对应的 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 的最后一条指令是:
retq 根据栈上的返回地址跳回调用 main 的 C 运行时,不会继续执行内存中的下一条指令。.Llambda0: 只是给后面那段代码标出入口,本身也不会主动执行。
程序进入 .Llambda0 的唯一时刻是:
这条 call 把自己的返回位置压栈,再跳到 .Llambda0。lambda0 末尾的另一个 retq 会返回到 call 后面的:
所以应该按控制流读这份汇编,而不是把文件中的上下排列误认为永远顺序执行。
当前编译器没有运行时类型检查
解释器的 Value 带有 number 或 function 种类,因此能够在调用数字、把函数交给加法或比较时报告清楚的错误。当前编译器没有对应的类型标签和检查。
在生成的汇编里:
1
2
3
4
|
$41 是一个 64 位机器字
.Llambda0 的地址 也是一个 64 位机器字
保存它们的栈槽 没有记录值的种类
call *%rcx 不会先确认 %rcx 真的是代码地址
|
例如:
解释器会报告“调用需要函数”。编译器却会生成把 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 $? 应显示:
也可以先比较解释器和 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 栈帧和调用约定:让两个函数完成交接
上一节