栈帧和调用约定:让两个函数完成交接
本节阅读量:前三章生成的程序始终待在 main 里。第四章第一次让 CPU 从调用者跳进另一个函数,执行完后再回到原来的位置。
继续使用同一个程序:
|
|
其中 main 是调用者,lambda0 是被调用者。一次 (inc 41) 必须完成四项交接:
|
|
栈帧负责“这次调用自己的值放在哪里”,调用约定负责“调用者和被调用者怎样交接”。
调用约定是 ABI 中关于函数交接的一部分,可以先把它理解成调用双方共同遵守的寄存器和栈使用协议。第一遍只需要记住四件事:
|
|
本章的值先落成一个机器字
在本书支持的 x86-64 Linux、macOS 和 WSL 环境中,一个机器字是 64 位,也就是 8 字节。本章编译器用一个机器字保存一个源语言值:
|
|
这不是说整数和函数在语言里属于同一种值,而是说它们当前的机器表示大小相同。栈槽、参数寄存器和返回寄存器只搬运 64 位比特;程序根据当前位置的用途,把这 64 位解释成整数或代码地址。
本章尚未加入类型标签,所以机器字本身不会说明“我是整数”还是“我是函数”。这个限制会在下一节具体说明。
本章用到的寄存器
项目生成 x86-64 System V ABI 的 AT&T 汇编。完整 ABI 还有更多规则,本节重点跟踪下面五个 64 位通用寄存器:
| 寄存器 | 在本章中的角色 |
|---|---|
%rdi |
传递第一个机器字参数;它可以是整数,也可以是函数地址 |
%rax |
保存计算的中间值,并传回一个机器字结果 |
%rcx |
在间接调用前暂存 callee 的代码地址 |
%rsp |
stack pointer,指向当前栈顶,随 pushq、call、subq、popq、retq 改变 |
%rbp |
frame pointer,作为当前栈帧的固定基准 |
System V 还能用更多寄存器传参;本章语言只有一个参数,所以只需要第一个参数寄存器 %rdi。
%rcx 是“调用者负责保存”的 caller-saved 寄存器:被调用函数可以改写它。这里调用结束后不再需要原来的函数地址,所以无需保存 %rcx。
%rbp 是“被调用者负责恢复”的 callee-saved 寄存器:被调用函数如果要拿它做自己的栈帧基准,就必须先保存调用者的 %rbp,返回前再恢复。
汇编中还会看到特殊的指令指针 %rip,以及 %rax 的低 8 位 %al。它们不是这张调用交接表遗漏的两个独立 64 位通用寄存器:%rip 用于计算函数标签地址,%al 则继续承担第三章 sete 的单字节结果。
call 为什么还能回来
可以先把 call *%rcx 理解成“跳到 %rcx 保存的地址”。但它比普通跳转多做了一件关键的事:在跳走之前,call 会把返回地址压到栈上。
返回地址就是 call 后面那条指令的地址。函数最后执行 retq 时,CPU 从栈顶取回这个地址并跳过去:
|
|
因此,函数调用不是“跳过去就结束”,而是一趟有明确归途的往返。
每个函数体独立分配栈槽
汇编生成器会分别处理主程序和每个 IrFunction。对于贯穿示例,主程序有三个需要保存的 IR 名字:
|
|
三个栈槽共需 24 字节。为了让栈保持 16 字节对齐,主程序实际分配 32 字节,最后 8 字节只是对齐空间:
|
|
lambda0 则重新从自己的 %rbp 分配槽位:
|
|
它需要 16 字节:
|
|
前三条指令建立栈帧:
|
|
第四条把 %rdi 收到参数 x 的栈槽。之后,函数体就能继续使用前几章的模型:IR 名字对应 %rbp 下方的固定偏移。
主程序的 -8(%rbp) 和函数体的 -8(%rbp) 不会冲突。进入 lambda0 后,%rbp 已经换成新栈帧的基准;偏移相同,基准地址不同,得到的实际内存位置也不同。
两个栈帧怎样同时存在
栈向低地址增长。lambda0 建好栈帧并保存参数后,内存可以画成下面这样:
|
|
这里的 main_rbp 和 lambda_rbp 是为了讲解而取的名字。机器上只有一个 %rbp 寄存器:执行 lambda0 时,它装着 lambda_rbp;原来的 main_rbp 已由 pushq %rbp 保存到 0(%lambda_rbp)。
被调用者的栈帧位于调用者下方。调用期间,主程序的栈帧没有消失,只是暂停使用。lambda0 返回后,popq %rbp 恢复 main_rbp,主程序的 -8、-16、-24 槽位便再次可以按原来的偏移访问。
一次调用的完整时间线
现在把调用者和被调用者放到同一条时间线上:
| 时刻 | 正在做什么 | 关键机器状态 |
|---|---|---|
| 1 | main 求值 lambda |
leaq 得到 .Llambda0 地址,保存到 f.0,再复制到 inc0 |
| 2 | main 准备调用 |
%rcx = inc0 中的代码地址,%rdi = 41 |
| 3 | main 执行 call *%rcx |
返回地址压栈,CPU 跳到 .Llambda0 |
| 4 | lambda0 建立栈帧 |
保存 main 的 %rbp,分配 16 字节,把 %rdi 写入 x |
| 5 | lambda0 执行 body |
从 x 的栈槽读出 41,加 1,把 42 写入 t.0 |
| 6 | lambda0 准备返回 |
把 t.0 读入 %rax,收起自己的栈帧 |
| 7 | lambda0 执行 retq |
弹出返回地址,回到 main 的 call 下一行 |
| 8 | main 接收结果 |
把 %rax 中的 42 保存到 t.1 |
调用位置的核心指令是:
|
|
这四行恰好跨过时间线的第 2、3、8 步。中间的第 4 到第 7 步由 .Llambda0 的代码完成。
函数怎样收起栈帧
函数结果已经放入 %rax 后,生成器输出:
|
|
三条指令依次完成:
|
|
栈帧的生命周期和一次调用相同:进入函数时建立,返回时消失。这也解释了闭包要解决的后续问题——如果返回的函数还想读取创建位置的局部变量,就不能只留下一个即将失效的栈地址。
同一段函数代码如果再次被调用,也会再建立一份新的栈帧。第五章的递归正是反复进入同一个函数入口,并在返回前同时保留多次调用各自的栈帧。
第二遍再看:栈为什么要对齐到 16 字节
下面是机器调用约束,不影响第一遍理解“参数进去、结果回来”。System V ABI 要求调用者在执行 call 前让 %rsp 位于 16 字节边界。可以用 %rsp mod 16 观察贯穿示例;余数 0 表示已经对齐,余数 8 表示相差 8 字节。
| 时刻 | 改变 %rsp 的动作 |
%rsp mod 16 |
|---|---|---|
C 运行时准备调用 main |
调用前已对齐 | 0 |
刚进入 main |
call 压入 8 字节返回地址 |
8 |
main 保存旧 %rbp |
pushq %rbp 再压入 8 字节 |
0 |
main 分配栈帧 |
subq $32, %rsp |
0 |
main 即将调用 lambda0 |
没有额外改变 | 0 |
刚进入 lambda0 |
call 压入 8 字节返回地址 |
8 |
lambda0 保存 main 的 %rbp |
pushq %rbp |
0 |
lambda0 分配栈帧 |
subq $16, %rsp |
0 |
lambda0 恢复旧 %rbp 后、retq 前 |
popq %rbp |
8 |
retq 回到 main |
弹出 8 字节返回地址 | 0 |
为什么局部槽位只需要 24 字节,主程序却 subq $32,现在就清楚了:pushq %rbp 已经把栈重新带回 16 字节边界,后续再减去 16 的倍数,下一次 call 前仍然对齐。
汇编生成器用 align_to_16() 把每个函数体所需的槽位总大小向上补到 16 的倍数。它不是为了让每个局部值都占 16 字节;每个值仍然只占 8 字节,多出的空间只负责整个栈顶的对齐。
现在,参数、局部值、返回地址和返回结果的机器位置都已经确定。下一节可以直接对照汇编生成器,看 function_ref 和 call 怎样落成真正的指令。