章节目录

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

本节阅读量:

前三章生成的程序始终待在 main 里。第四章第一次让 CPU 从调用者跳进另一个函数,执行完后再回到原来的位置。

继续使用同一个程序:

1
(let inc (lambda x (+ x 1)) (inc 41))

其中 main 是调用者,lambda0 是被调用者。一次 (inc 41) 必须完成四项交接:

1
2
3
4
调用者把函数入口和参数准备好
被调用者得到自己的局部存储空间
被调用者算出结果并交还给调用者
CPU 回到 call 后面的那条指令继续执行

栈帧负责“这次调用自己的值放在哪里”,调用约定负责“调用者和被调用者怎样交接”。

调用约定是 ABI 中关于函数交接的一部分,可以先把它理解成调用双方共同遵守的寄存器和栈使用协议。第一遍只需要记住四件事:

1
2
3
4
%rdi 交出本章唯一的参数
%rax 交回函数结果
call 保存归途,retq 按这个归途返回
每次调用都用自己的 %rbp 访问自己的栈槽

本章的值先落成一个机器字

在本书支持的 x86-64 Linux、macOS 和 WSL 环境中,一个机器字是 64 位,也就是 8 字节。本章编译器用一个机器字保存一个源语言值:

1
2
整数值       一个 64 位整数
函数值       一个 64 位代码地址

这不是说整数和函数在语言里属于同一种值,而是说它们当前的机器表示大小相同。栈槽、参数寄存器和返回寄存器只搬运 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 从栈顶取回这个地址并跳过去:

1
2
call    压入返回地址,然后进入被调用函数
retq    弹出返回地址,然后回到调用者

因此,函数调用不是“跳过去就结束”,而是一趟有明确归途的往返。

每个函数体独立分配栈槽

汇编生成器会分别处理主程序和每个 IrFunction。对于贯穿示例,主程序有三个需要保存的 IR 名字:

1
2
3
f.0   -> -8(%rbp)     lambda0 的代码地址
inc0  -> -16(%rbp)    let 绑定保存的函数值
t.1   -> -24(%rbp)    调用结果

三个栈槽共需 24 字节。为了让栈保持 16 字节对齐,主程序实际分配 32 字节,最后 8 字节只是对齐空间:

1
2
3
4
main:
    pushq %rbp
    movq %rsp, %rbp
    subq $32, %rsp

lambda0 则重新从自己的 %rbp 分配槽位:

1
2
x    -> -8(%rbp)      参数
t.0  -> -16(%rbp)     (+ x 1) 的结果

它需要 16 字节:

1
2
3
4
5
.Llambda0:
    pushq %rbp
    movq %rsp, %rbp
    subq $16, %rsp
    movq %rdi, -8(%rbp)

前三条指令建立栈帧:

1
2
3
pushq %rbp         保存调用者的栈帧基准
movq %rsp, %rbp    让 %rbp 成为当前栈帧的固定基准
subq $16, %rsp     向低地址方向留出 16 字节局部空间

第四条把 %rdi 收到参数 x 的栈槽。之后,函数体就能继续使用前几章的模型:IR 名字对应 %rbp 下方的固定偏移。

主程序的 -8(%rbp) 和函数体的 -8(%rbp) 不会冲突。进入 lambda0 后,%rbp 已经换成新栈帧的基准;偏移相同,基准地址不同,得到的实际内存位置也不同。

两个栈帧怎样同时存在

栈向低地址增长。lambda0 建好栈帧并保存参数后,内存可以画成下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
高地址

8(%main_rbp)          main 返回给 C 运行时的地址
0(%main_rbp)          main 保存的旧 %rbp
-8(%main_rbp)         f.0:lambda0 的代码地址
-16(%main_rbp)        inc0:let 绑定
-24(%main_rbp)        t.1:调用结束后写入
-32(%main_rbp)        对齐空间;call 前的 main %rsp 在这里

8(%lambda_rbp)        lambda0 返回到 main 的地址
0(%lambda_rbp)        保存的 main_rbp
-8(%lambda_rbp)       x = 41
-16(%lambda_rbp)      t.0;当前 %rsp 在这里

低地址

这里的 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

调用位置的核心指令是:

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

这四行恰好跨过时间线的第 2、3、8 步。中间的第 4 到第 7 步由 .Llambda0 的代码完成。

函数怎样收起栈帧

函数结果已经放入 %rax 后,生成器输出:

1
2
3
    movq %rbp, %rsp
    popq %rbp
    retq

三条指令依次完成:

1
2
3
movq %rbp, %rsp    一次丢弃当前函数的所有局部槽位
popq %rbp          恢复调用者的栈帧基准
retq               取出返回地址,回到调用位置

栈帧的生命周期和一次调用相同:进入函数时建立,返回时消失。这也解释了闭包要解决的后续问题——如果返回的函数还想读取创建位置的局部变量,就不能只留下一个即将失效的栈地址。

同一段函数代码如果再次被调用,也会再建立一份新的栈帧。第五章的递归正是反复进入同一个函数入口,并在返回前同时保留多次调用各自的栈帧。

第二遍再看:栈为什么要对齐到 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 怎样落成真正的指令。


4.5 IR:把函数体和函数值分开

上一节

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

下一节