函数栈帧的创建与销毁
一、从一次普通的函数调用开始
在 C 语言中,函数调用是一件再平常不过的事情。我们可以把一段计算过程写进函数,需要的时候调用它,再使用它返回的结果。
例如下面这段程序:
c
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main(void)
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
这段代码并不复杂。main 中定义了三个局部变量,随后将 a 和 b 作为实参传给 Add。Add 计算两数之和,将结果返回,最后由 main 输出:
text
30
如果只关心程序的功能,解释到这里就已经足够了。
不过,我们平时习惯用 C 语言的规则理解函数调用:实参的值传递给形参,函数执行自己的语句,遇到 return 后将结果交回调用者。这样的描述告诉了我们程序应该表现出什么行为,却没有说明这些行为在机器层面是怎样实现的。
以 c = Add(a, b); 为例,a、b 是 main 中的局部变量,而 x、y 属于 Add。当程序执行到这条语句时,两个实参的值需要以某种方式交给被调用的函数。与此同时,main 还必须保留继续执行所需的信息,否则 Add 完成计算之后,就无法回到原来的位置。
而进入 Add 以后,它还需要使用自己的局部变量 z,完成计算,并把结果交还给 main。当这次调用结束,Add 执行期间使用的相关存储空间和执行状态,又会发生怎样的变化?
这些事情在 C 代码中并没有直接写出来,但编译后的程序必须以某种方式完成它们。
因此,这次我们不急着给函数栈帧下定义,而是从这条普通的函数调用出发,观察编译器生成的机器指令,以及指令执行时寄存器和内存中实际发生的变化。
等到这次调用的建立、执行和返回过程逐渐清楚,函数栈帧究竟是什么、它为什么会出现,也就有了可以依据的具体事实。
二、在观察之前,先认识几样工具
上一节留下的问题,需要到编译后的程序里寻找答案。不过,直接面对一整段反汇编,往往会被指令名称、寄存器和地址淹没。我们暂时不需要系统学习汇编语言,只要先建立一套足以读懂接下来实验的观察方法。
这次实验在 Ubuntu x86-64 上进行,使用 Clang 21 编译,并通过 LLDB 逐条观察指令。编译采用 -O0,保留帧指针,并启用地址检测器(AddressSanitizer,ASan)和未定义行为检测器(UndefinedBehaviorSanitizer,UBSan)。这些设置会影响后面看到的指令和栈布局;遇到相关步骤时再解释它们的作用。通常情况下,操作系统会对进程的地址空间布局进行随机化(ASLR),所以即使是同一个程序,每次启动时观察到的代码地址、栈地址等也可能不同。为了便于对照,我们使用同一构建产物,并在 LLDB 启动程序前执行:
lldb
settings set target.disable-aslr true
这条命令要求 LLDB 在启动目标进程时关闭 ASLR。在构建产物、运行环境和启动条件保持一致的情况下,地址通常会比较稳定,便于我们用同一套绝对地址作为参考,连续观察寄存器与内存的变化。不过,它并不保证地址在任何条件下都绝对不变。后文统一使用静态图中的地址体系。补录时个别栈地址发生过变化,展示数据已映射回同一套参考地址;它们用于追踪位置关系,不代表不同运行中的绝对地址必然相同。
2.1 一条指令告诉了我们什么
先看 main 入口处的一小段反汇编:
asm
0x55555566abc8 <+0>: pushq %rbp
0x55555566abc9 <+1>: movq %rsp, %rbp
0x55555566abcc <+4>: subq $0x10, %rsp
每行最左边是这条指令所在的地址;pushq、movq、subq 是指令助记符,后面跟着操作数。<+0>、<+1> 等标记表示相对于当前函数入口的字节偏移,并不是执行步骤的编号。
这里采用的是 AT&T 汇编语法。寄存器名前带 %,例如 %rsp;常量前带 $,例如 $0x10。对本文遇到的普通双操作数指令,可以按"源操作数、目的操作数"的顺序阅读。因此,movq %rsp, %rbp 表示把 %rsp 中的值复制到 %rbp,并不是反过来。
指令名称末尾的字母通常还提示操作宽度:b、l、q 在这里分别对应 1、4、8 字节。例如 movl 操作 4 字节,movq 操作 8 字节。宽度很重要,因为读取或修改某个地址时,我们必须知道究竟涉及多少字节。
2.2 寄存器、地址与内存
寄存器可以暂时理解为处理器用来保存数值的存储位置;内存则通过地址定位。地址本身与地址处保存的内容是两回事。 一个寄存器可以保存整数,也可以保存一个将被当作内存地址使用的数值,具体取决于指令怎样使用它。
例如,后面会看到这样的指令:
asm
movl $0xa, -0x8(%rbp)
movl -0x8(%rbp), %edi
-0x8(%rbp) 是一个内存操作数:以 %rbp 中保存的数值为基准,减去 0x8,得到本次访问的内存地址。这个表达式负责确定位置;实际访问多少字节,则由指令的操作宽度决定。这里两条指令都是 movl,因此访问的都是从该地址开始的连续 4 字节。
第一条把立即数 0xa(十进制的 10)写入这 4 字节内存,覆盖原来的内容;第二条从相同位置读出 4 字节,再把读出的值写入 %edi,覆盖 %edi 原来的值。也就是说,两条指令访问的是同一片内存,但数据传送方向正好相反。
%edi 是 %rdi 的低 32 位对应的寄存器名称。类似地,我们还会遇到 %eax 与 %rax、%esi 与 %rsi。对于本例,先认识这些名字之间的关系就够了,不必把所有通用寄存器一次记完。需要注意的是,在 x86-64 中,向 %eax、%edi 这样的 32 位通用寄存器写入数据,还会把对应 64 位寄存器的高 32 位清零;这与向内存写入 4 字节并不是一回事。
2.3 怎样观察栈的变化
接下来最常出现的两个寄存器是 %rsp 和 %rbp。在本次实验中,%rsp 保存当前栈指针,%rbp 则被编译器用作相对稳定的栈内存定位基准。我们先知道它们的职责,至于为什么要保存旧的 %rbp、又为什么要调整 %rsp,留到实际执行那些指令时再讨论。
还需要建立一个地址方向的概念:在本次 x86-64 实验中,栈向较低的内存地址 方向增长。例如,%rsp 从 0x7fffffffe060 变成 0x7fffffffe058,数值减少了 8,意味着栈指针向低地址移动了 8 字节。
这与图画在纸面上的方向没有必然关系。我们可以把低地址画在上方,也可以画在下方;真正决定地址高低的是地址数值,而不是图中方框的位置。为了方便连续观察,后面的配图会固定一种排列方式,并始终标明高、低地址。
最后还有 %rip。它用于标识处理器接下来执行指令的位置。LLDB 在当前指令前显示的箭头,通常指向尚未执行的指令。因此,看到一条指令及当前寄存器状态时,不应立刻把它的效果当成已经发生;我们会在单步执行后,再比较寄存器和内存的变化。
到这里,准备工作已经足够。我们能够辨认指令地址、操作数和数据宽度,也知道如何通过寄存器与内存地址追踪一个值。接下来就从 main 的第一条指令开始,看看它在调用 Add 之前,先为自己的执行做了哪些准备。
三、main 函数栈帧的建立
前面已经介绍了理解这一过程所需的寄存器、指令和栈的基本行为。现在回到最初的 C 程序,观察函数栈帧究竟怎样形成。
平时阅读这段程序,我们往往直接从 int a = 10; 开始。但在这条 C 语句对应的机器指令执行之前,main 已经经历了函数调用和栈帧建立的若干步骤。
这一节只追踪从调用者进入 main,直到 a、b、c 初始化完成的过程。后续的 Add 调用留待下一节。
3.1 实际反汇编与动态演示
以下是本次 Ubuntu x86-64、Clang 编译结果中,main 入口至局部变量初始化完成的指令。末尾另外列出两条后续指令,标明下一节的起点。地址沿用静态图中的记录,不代表每次运行都会相同。
asm
; main
0x55555566abc8: pushq %rbp
0x55555566abc9: movq %rsp, %rbp
0x55555566abcc: subq $0x10, %rsp
0x55555566abd0: movl $0x0, -0x4(%rbp)
0x55555566abd7: movl $0xa, -0x8(%rbp)
0x55555566abde: movl $0x14, -0xc(%rbp)
0x55555566abe5: movl $0x0, -0x10(%rbp)
; 下一节将从这里继续:准备向 Add 传参
0x55555566abec: movl -0x8(%rbp), %edi
0x55555566abef: movl -0xc(%rbp), %esi
视频和交互页面展示了这一段变化:%rsp、%rbp 如何移动,栈中的返回地址、旧帧指针和局部数据又是怎样依次出现的。
https://github.com/user-attachments/assets/e7b1ca3c-a746-4362-a7d7-bbea24722fef
视频展示的是本次实际执行过程,而不是所有编译器、优化级别都必须遵循的固定模板。下面将逐条解释这些变化。
3.2 从哪里开始观察?
main 并不是程序加载后执行的第一段代码。本次实验中,运行时启动代码经过 C 运行库,最后在 libc 中执行这样一条指令:
asm
0x7ffff7c2a1c8: callq *%rax
0x7ffff7c2a1ca: movl %eax, %edi
call 是机器指令中调用函数的一种方式。这里的 *%rax 表示:调用目标并没有直接写在指令里,而是要从 %rax 寄存器中取得地址。那么,凭什么认定它调用的是 main?
我们可以在这条 callq 执行前,直接让 LLDB 查看 %rax:
lldb
(lldb) register read rax
rax = 0x000055555566abc8 main`main at main.c:9
LLDB 将这个地址识别为 main 的入口。再将 %rax 中的地址作为反汇编起点,可以核对入口指令:
lldb
(lldb) disassemble --start-address `$rax` --count 3
main`main:
0x55555566abc8 <+0>: pushq %rbp
0x55555566abc9 <+1>: movq %rsp, %rbp
0x55555566abcc <+4>: subq $0x10, %rsp
这三条指令与 main 的入口一致:入口地址为 0x55555566abc8。
3.3 callq:为什么进入 main 前,栈已经变了?
刚才那条 callq 不只是让 CPU 跳到 main。它还得考虑一个问题:等 main 执行完,程序应该从哪里接着运行?
答案就是调用指令后面的那条机器指令。对于本次实验,它位于 0x7ffff7c2a1ca,内容是 movl %eax, %edi。CPU 会先把这个接下来应当继续执行的位置 保存到栈里,然后才进入 main。我们把保存下来的这个位置称为「返回地址」。它指向的是调用者的后续指令,而不是 main 计算出来的返回值。
执行调用之前,LLDB 记录到:
text
rsp = 0x7fffffffe060
rbp = 0x7fffffffe0f0
在本次 x86-64 环境中,callq 压入的地址占 8 字节。栈向低地址增长,所以 %rsp 从 0x7fffffffe060 减少 8,变成 0x7fffffffe058,返回地址就保存在这 8 字节里:
text
执行前:rsp = 0x7fffffffe060
执行后:rsp = 0x7fffffffe058
[0x7fffffffe058, 0x7fffffffe060)
返回地址 = 0x7ffff7c2a1ca
到这里,%rbp 还保持着调用者原来的值。换句话说,我们已经进入 main,但 main 自己还没有完成栈帧的建立。接下来才轮到它入口处的指令。
3.4 pushq %rbp:保存调用者的帧指针
main 的第一条指令是:
asm
0x55555566abc8: pushq %rbp
这里的 push 可以直接按「往栈里放入一个东西」来理解。如果只看压栈和出栈的顺序,栈有点像只有一个开口的容器:后放进去的东西先取出来。这个比喻描述的是操作顺序,并不是说栈内存只能从栈顶访问;前面见过的偏移寻址,也能直接读取或修改栈中的其他位置。放入叫 push,取出叫 pop,也就是后进先出。我们这里观察的函数调用栈,就利用了这种组织方式。
指令末尾的 q 表示本次操作的数据宽度为 8 字节。所以 pushq %rbp 就是把 %rbp 里保存的那个 8 字节数值放进栈里。它恰好是调用者原来的帧指针地址。
执行前:
text
rsp = 0x7fffffffe058
rbp = 0x7fffffffe0f0
压入 8 字节后,栈顶向低地址移动了 8 字节:
text
rsp = 0x7fffffffe050
rbp = 0x7fffffffe0f0
[0x7fffffffe050, 0x7fffffffe058)
保存的旧 rbp = 0x7fffffffe0f0
[0x7fffffffe058, 0x7fffffffe060)
返回地址 = 0x7ffff7c2a1ca
如果沿用刚才那个容器的比喻,我们确实是又往栈顶放了一个东西。只是这里的「长高」对应的是地址变小,因为当前平台的栈向低地址方向增长。
还有一个容易忽略的细节:pushq 保存的是 %rbp 当前的值 ,并没有修改 %rbp 寄存器本身。现在栈里多了一份旧值的副本;接下来即使 %rbp 被改写,我们仍然有机会把原值找回来。
当然,这只是当前编译结果采用的栈帧组织方式,并不是所有函数都必须以 pushq %rbp 开头。
3.5 movq %rsp, %rbp:确立当前栈帧的参考位置
接下来执行:
asm
0x55555566abc9: movq %rsp, %rbp
mov 来自 move ,不过它在这里的效果更接近复制数值 :把 %rsp 中的 8 字节数值写进 %rbp。源寄存器 %rsp 不变;目标寄存器 %rbp 原先保存的数值则被覆盖。也正因为如此,上一条指令才要先把旧 %rbp 压入栈中。
text
执行前:rsp = 0x7fffffffe050,rbp = 0x7fffffffe0f0
执行后:rsp = 0x7fffffffe050,rbp = 0x7fffffffe050
现在两个寄存器中的地址相同,但在当前编译结果中承担不同的作用。%rsp 继续指示栈顶;%rbp 则作为当前栈帧相对稳定的参考位置。稍后编译器就能通过 -0x8(%rbp)、-0xc(%rbp) 这样的表达式找到局部变量。
注意,刚才发生的只是一次寄存器赋值,没有向栈中写入新的字节。
3.6 subq $0x10, %rsp:把栈顶再往下挪 16 字节
下一条指令:
asm
0x55555566abcc: subq $0x10, %rsp
sub 就是减法,$0x10 是立即数,也就是直接写在机器指令中的数值,换成十进制就是 16。这条指令没有什么神秘的:把 %rsp 中保存的地址减去 16,再把结果写回 %rsp。
text
执行前:rsp = 0x7fffffffe050,rbp = 0x7fffffffe050
执行后:rsp = 0x7fffffffe040,rbp = 0x7fffffffe050
预留区域:[0x7fffffffe040, 0x7fffffffe050)
栈顶往低地址移动了 16 字节,%rbp 却还留在原处。于是,两个地址之间的这 16 字节就被划入了 main 当前可使用的栈空间。
但这条指令只做了减法。它没有访问这 16 字节,更没有把它们清零 。如果这些位置以前留有其他数据,单凭这条 subq,原有字节并不会消失。动画把区域标成「已预留」,而不是「已经写入四个整数」,原因就在这里。
那么这 16 字节最后会放些什么?继续往下看。
3.7 四次 movl:局部变量及额外槽位的初始化
反汇编接下来是:
asm
0x55555566abd0: movl $0x0, -0x4(%rbp)
0x55555566abd7: movl $0xa, -0x8(%rbp)
0x55555566abde: movl $0x14, -0xc(%rbp)
0x55555566abe5: movl $0x0, -0x10(%rbp)
每条 movl 都向对应地址写入 4 字节。此时 %rbp = 0x7fffffffe050,因此这些偏移量分别落在刚刚预留的 16 字节范围内。
| 寻址表达式 | 实际起始地址 | 写入值 | 当前编译结果中的用途 |
|---|---|---|---|
-0x4(%rbp) |
0x7fffffffe04c |
0 |
编译器额外保留的槽位 |
-0x8(%rbp) |
0x7fffffffe048 |
10 |
a |
-0xc(%rbp) |
0x7fffffffe044 |
20 |
b |
-0x10(%rbp) |
0x7fffffffe040 |
0 |
c |
其中后三条对应 C 源码:
c
int a = 10;
int b = 20;
int c = 0;
第一条却没有对应的第四个 C 局部变量:
asm
movl $0x0, -0x4(%rbp)
在当前这份反汇编中,编译器额外安排了一个 4 字节槽位,并将其写为零。仅凭这条写入指令,不能确定这个额外槽位的完整用途,也不能认定函数最终会从这里取出返回值 。后面分析 main 返回时,我们会看到编译器直接将 %eax 置零,而不是读取这个槽位。此处只需要确认:栈上确实多写了一个零,它不是源码声明的第四个局部变量,也不是 ABI 规定的返回值存储位置。
这说明,源代码中的局部变量与栈帧里的存储槽位,并不保证一一对应。我们能确定的是当前反汇编实际进行了哪些写入,而不是从 C 语法直接推断固定的内存布局。
3.8 汇总:main 的栈帧如何形成
在最后一条初始化指令执行完毕后:
text
rsp = 0x7fffffffe040
rbp = 0x7fffffffe050
将已经确认的栈内存内容按地址从低到高排列:
| 地址范围(半开区间) | 字节数 | 内容 |
|---|---|---|
[0x7fffffffe040, 0x7fffffffe044) |
4 | c = 0 |
[0x7fffffffe044, 0x7fffffffe048) |
4 | b = 20 |
[0x7fffffffe048, 0x7fffffffe04c) |
4 | a = 10 |
[0x7fffffffe04c, 0x7fffffffe050) |
4 | 额外槽位,值为 0 |
[0x7fffffffe050, 0x7fffffffe058) |
8 | 保存的旧 %rbp |
[0x7fffffffe058, 0x7fffffffe060) |
8 | 返回地址 |
回顾整个过程,我们没有先规定"一个函数栈帧应当长什么样",而是依次观察:
- 调用者执行
callq,返回地址入栈。 main执行pushq %rbp,保存调用者的帧指针。movq %rsp, %rbp,确立当前栈帧的参考位置。subq $0x10, %rsp,预留 16 字节空间。- 四条
movl指令,把具体数值写入各自的槽位。
于是,一个包含返回地址、旧帧指针和局部数据的栈帧,就在实际指令的执行中逐步形成了。它不是 C 语言规定的一张固定表格,而是当前调用约定、编译器选择和机器指令共同形成的运行时状态。
接下来,main 即将执行:
c
c = Add(a, b);
我们已经知道 a 和 b 当前存放在哪里。下一节要追踪的,则是它们如何进入参数寄存器,Add 又如何建立自己的栈帧,以及调用结束后执行位置和栈状态怎样返回 main。
四、Add 的调用与栈帧建立
第三节结束时,main 已经完成了三个局部变量的初始化:a = 10、b = 20、c = 0。此时它自己的栈帧已经形成,%rsp 指向 0x7fffffffe040,%rbp 则停在 0x7fffffffe050。
接下来,程序将执行我们一直留到现在的这句代码:
c
c = Add(a, b);
从 C 语言的角度看,这件事很简单:把 a、b 的值交给 Add,等函数计算完成,再把结果赋给 c。但我们现在已经知道,函数调用并不是把执行位置挪过去就结束了。main 还要把参数交出去;Add 要有地方保存自己的数据;等它执行完,程序还得找到回到 main 的路。
这些事情究竟按什么顺序发生?这一节先跟随机器指令进入 Add,观察它的栈帧如何建立。至于 x + y 如何计算、溢出检查怎样介入,以及最后如何返回,留到后面的章节。
4.1 从 main 走向 Add
下面列出 main 准备参数、调用 Add,以及 Add 建立栈帧和初始化变量的指令。末尾另外列出下一节的起点。
asm
; main:准备参数,并调用 Add
0x55555566abec: movl -0x8(%rbp), %edi
0x55555566abef: movl -0xc(%rbp), %esi
0x55555566abf2: callq 0x55555566ab68
; 0x55555566abf7:Add 返回后,main 继续执行的位置
; Add:建立栈帧、保存形参并初始化局部变量
0x55555566ab68: pushq %rbp
0x55555566ab69: movq %rsp, %rbp
0x55555566ab6c: subq $0x20, %rsp
0x55555566ab70: movl %edi, -0x4(%rbp)
0x55555566ab73: movl %esi, -0x8(%rbp)
0x55555566ab76: movl $0x0, -0xc(%rbp)
; 下一节从这里继续
0x55555566ab7d: movl -0x4(%rbp), %eax
这段过程有两个值得追踪的变化:callq 怎样改变栈顶,以及进入 Add 后,%rbp 怎样从 main 的帧基址变成 Add 的帧基址。下面从参数准备开始,逐条分析。
https://github.com/user-attachments/assets/f1d12364-9bcb-43f9-a6ad-e50a0a5af89f
动画展示的是本次编译与实验所采用的一组具体状态,并不是 C 语言规定的函数调用固定形态。视频只演示到
z = 0初始化完成,随后那条读取x的指令尚未执行。
4.2 参数先去哪儿了?
回到刚才的 C 语句:
c
c = Add(a, b);
我们知道 a 和 b 已经存放在 main 的栈中。那么,调用 Add 时,Add 会直接使用 main 中这两个变量原来的存储位置吗?
这次调用传递的是两个整数的值。我们先看 main 为此执行的两条指令:
asm
0x55555566abec: movl -0x8(%rbp), %edi
0x55555566abef: movl -0xc(%rbp), %esi
上一节已经确认,main 的 %rbp 是 0x7fffffffe050。所以 -0x8(%rbp) 指向 a,-0xc(%rbp) 指向 b。这两条 movl 分别从对应位置读取 4 字节,写入 %edi 和 %esi。
| 来源 | 源地址 | 目标寄存器 | 写入的数值 |
|---|---|---|---|
a |
0x7fffffffe048 |
%edi |
10 |
b |
0x7fffffffe044 |
%esi |
20 |
这里没有把 a 或 b 从栈上"搬走"。movl 读取的是它们的值,原来的内存内容仍然保留在 main 的栈帧里。
为什么偏偏是 %edi 和 %esi?这是因为本次 Ubuntu x86-64 环境采用的 System V AMD64 应用二进制接口(ABI) 规定:对于这两个 int 参数,调用者按顺序通过 %edi、%esi 对应的参数寄存器传递。更准确地说,它们分别是 %rdi、%rsi 的低 32 位。写入这类 32 位寄存器时,对应 64 位寄存器的高 32 位也会被清零。
这是一种调用约定 ,并非 C 语言规定"第一个参数必须放在 %edi"。换了平台或调用约定,传参位置可能不同。
到目前为止,main 的 %rsp 和 %rbp 都没有变化。我们只是把两个实参准备好了,真正进入 Add 还得靠下一条指令。
4.3 callq:把返回的位置留下,再进入 Add
asm
0x55555566abf2: callq 0x55555566ab68
第三节我们已经见过 callq:运行库用它进入 main,同时在栈里留下返回地址。现在换成 main 调用 Add,规则并没有改变。
这一次的调用目标 0x55555566ab68 已经直接写在指令中,就是 Add 的入口。可是在跳过去之前,还有一个问题:Add 执行完以后,main 应当从哪里继续?
答案是这条 callq 后面的指令地址------0x55555566abf7。
因此,在本次实验中,执行 callq 前后的栈状态可以写成:
text
执行前:rsp = 0x7fffffffe040
执行后:rsp = 0x7fffffffe038
[0x7fffffffe038, 0x7fffffffe040)
返回地址 = 0x55555566abf7
CPU 把这个 8 字节的返回地址保存到栈中,然后才把执行位置转到 Add 入口。此时 %rbp 仍是 0x7fffffffe050,也就是 main 的帧基址。
这里需要区分两个容易混淆的东西:返回地址 告诉 CPU 将来应该继续执行哪里的指令;函数返回值 则是 Add 计算出来、需要交还给调用者的数据。我们现在压入栈中的只有前者。至于返回值最终怎样交给 main,还没有进行到那一步。
0x55555566abf7 是留给 Add 返回后使用的继续执行位置。当前执行流程已经转到 Add,这一节还不会执行这个地址处的指令。
还有一个更有意思的细节:callq 已经让栈顶向低地址移动了 8 字节,但我们甚至还没有执行 Add 的第一条指令。和第三节进入 main 时一样,函数尚未建立自己的栈帧,调用者留下的返回地址却已经在栈上了。
4.4 进入 Add:新的帧指针从哪里来?
接下来才是 Add 自己的入口指令:
asm
0x55555566ab68: pushq %rbp
0x55555566ab69: movq %rsp, %rbp
0x55555566ab6c: subq $0x20, %rsp
这三条看起来很熟悉。第三节 main 建立栈帧时也用过同样的组合,只不过这里要保存的是 main 的帧指针,而不是运行库调用者的帧指针。
先执行 pushq %rbp。此时 %rbp 仍保存着 main 的帧基址 0x7fffffffe050。pushq 把这个 8 字节数值压到栈上,%rsp 从 0x7fffffffe038 变为 0x7fffffffe030:
text
[0x7fffffffe030, 0x7fffffffe038)
保存的 main 帧指针 = 0x7fffffffe050
[0x7fffffffe038, 0x7fffffffe040)
返回 main 的地址 = 0x55555566abf7
注意,pushq 保存的是 %rbp 原来的数值,并没有直接改动 %rbp 本身。
接着执行:
asm
movq %rsp, %rbp
于是,%rbp 从 0x7fffffffe050 变成了 0x7fffffffe030,与此时的 %rsp 相同。Add 现在有了自己的帧基址,而 main 原来的帧基址并没有消失:它仍然保存在刚才压入的那 8 字节内存中。
如果把两个函数放在同一幅图里看,关系就清楚了:Add 没有得到一块与 main 隔绝的全新栈,而是在同一条调用栈的更低地址处继续使用空间 。main 的局部变量还在原位;Add 所保存的旧 %rbp 和返回地址,则把这一层调用同上一层联系起来。
最后执行:
asm
subq $0x20, %rsp
0x20 是十进制的 32。指令将 %rsp 从 0x7fffffffe030 改为 0x7fffffffe010,在栈顶与 Add 的帧基址之间划出 32 字节:
text
rsp = 0x7fffffffe010
rbp = 0x7fffffffe030
Add 预留区域:
[0x7fffffffe010, 0x7fffffffe030)
同样要强调:subq 只是修改了 %rsp 中的数值,没有把这 32 字节写成零 。至此,Add 的栈空间已经预留出来,但里面哪些位置会保存 x、y、z,还需要看后续实际发生的写入。
4.5 参数明明已经进了寄存器,为什么又写进栈?
我们在调用发生之前,就已经把 10 和 20 放进 %edi、%esi。但 Add 建好栈帧后,紧接着又执行了:
asm
0x55555566ab70: movl %edi, -0x4(%rbp)
0x55555566ab73: movl %esi, -0x8(%rbp)
这一次,movl 的方向与 main 准备参数时正好相反:先前是从内存读取到寄存器 ,现在则是从寄存器写入内存。
此时 Add 的 %rbp = 0x7fffffffe030,所以两条指令的目标是:
text
[0x7fffffffe02c, 0x7fffffffe030) x = 10
[0x7fffffffe028, 0x7fffffffe02c) y = 20
对应到源码,就是:
c
int Add(int x, int y)
这里可能会产生一个疑问:既然参数已经放在 %edi、%esi,Add 直接使用寄存器里的值不就行了吗?为什么还要存一次?
确实可以直接使用寄存器中的值。调用约定只规定参数怎样交到函数手里,并不要求函数始终把它们留在传参寄存器中。
如果把内存比作程序的工作台,寄存器就像 CPU 手边直接处理数据的位置。寄存器数量有限,同一个寄存器可能先用来传参,随后又用来保存计算结果或其他临时数据。把形参保存到栈上的固定位置后,即使后续复用了传参寄存器,也仍能从内存中取回这些值。
不过,这不意味着一个值不能长期留在寄存器里,更不意味着形参必须先写入栈才能计算。如果后续一直需要这个值,而且有合适的寄存器可用,编译器完全可以让它留在寄存器中。
在本次未优化、带调试及检测配置的编译结果中,编译器为形参安排了栈上的存储位置。两条写入完成后,后续指令就能通过相对于 %rbp 的偏移访问 x 和 y。换成其他编译配置,这两次写入可能不会出现。因此,这里看到的是参数通过寄存器传入以后,Add 又保存了栈内副本,不能把这两步合并理解成"参数通过栈传入"。
此刻,同一个数值出现了不止一份:main 的 a、b 仍在原来的栈槽中;%edi、%esi 保存着传入的数值;Add 又在自己的栈帧里写出了 x、y。这些是不同的存储位置,不能因为值相同就把它们当成同一个变量。
这也解释了为什么不能仅凭 C 语言里的 Add(a, b),就想当然地画出参数在机器里的具体摆放方式。必须看本次平台采用的调用约定,以及编译器实际生成了什么指令。
4.6 z = 0:这一节最后一次写入
Add 的源码开头是:
c
int z = 0;
对应的指令为:
asm
0x55555566ab76: movl $0x0, -0xc(%rbp)
现在 %rbp = 0x7fffffffe030,因此 -0xc(%rbp) 是 0x7fffffffe024。这条 movl 将 4 字节的零写入:
text
[0x7fffffffe024, 0x7fffffffe028) z = 0
到这里,x = 10、y = 20、z = 0 都已经有了明确的栈内位置。你会发现,Add 总共预留了 32 字节,而这三个 int 只占其中 12 字节。剩下的区域并没有因此变成"没用的空间",更不能提前假定它已经存放某个计算结果。
对于剩下的 [0x7fffffffe010, 0x7fffffffe024) 这 20 字节,这一节的指令没有向其中写入数据。它们是否在后续被使用、用来保存什么,要等相应指令出现后再判断。现在只把它们标为「已预留,这一节尚未写入」,也不把其中原有的字节当作零。
因此,这一节停在下一条指令之前:
asm
0x55555566ab7d: movl -0x4(%rbp), %eax
它即将读取 x,为后面的计算作准备,但目前还没有执行。
4.7 两个栈帧现在是什么关系?
最后,我们把 Add 初始化完成后的状态放在一起看:
text
rsp = 0x7fffffffe010
rbp = 0x7fffffffe030
按地址从低到高排列,这一节涉及的连续栈空间如下:
| 地址范围(半开区间) | 字节数 | 当前内容 |
|---|---|---|
[0x7fffffffe010, 0x7fffffffe024) |
20 | Add 已预留,这一节未写入;原有字节未展开 |
[0x7fffffffe024, 0x7fffffffe028) |
4 | Add:z = 0 |
[0x7fffffffe028, 0x7fffffffe02c) |
4 | Add:y = 20 |
[0x7fffffffe02c, 0x7fffffffe030) |
4 | Add:x = 10 |
[0x7fffffffe030, 0x7fffffffe038) |
8 | Add 保存的旧 %rbp,值为 0x7fffffffe050 |
[0x7fffffffe038, 0x7fffffffe040) |
8 | Add 返回 main 的地址 0x55555566abf7 |
[0x7fffffffe040, 0x7fffffffe044) |
4 | main:c = 0 |
[0x7fffffffe044, 0x7fffffffe048) |
4 | main:b = 20 |
[0x7fffffffe048, 0x7fffffffe04c) |
4 | main:a = 10 |
[0x7fffffffe04c, 0x7fffffffe050) |
4 | main:编译器额外槽位,值为 0 |
[0x7fffffffe050, 0x7fffffffe058) |
8 | main 保存的旧 %rbp,值为 0x7fffffffe0f0 |
[0x7fffffffe058, 0x7fffffffe060) |
8 | main 返回运行库的地址 0x7ffff7c2a1ca |
表格看起来很长,但其中大部分内容我们早已在第三节见过。这次新增的只是 main 栈帧下方的一层调用记录,以及 Add 自己使用的空间。 main 并没有因为调用 Add 就失去原来的局部变量,它们仍在自己的位置等待调用结束。
回顾这一小段过程:main 先从自己的栈中取出 a、b 的值,通过寄存器交给 Add;callq 留下返回地址;Add 保存 main 的帧指针、建立自己的参考位置,预留空间,并把形参与局部变量写到对应的槽位中。
这样把两层调用放在一起,就能看到:Add 的栈帧是在同一段连续的栈内存中接着建立起来的。等它返回时,保存的旧帧指针和返回地址还要分别承担恢复参考位置、回到调用者的任务。
接下来还有两个问题没有解决:Add 怎样从这些槽位取出数值并完成 x + y?等计算完成后,它又怎样把结果交还给 main,并恢复进入函数之前的栈状态?下一节就从 0x55555566ab7d 这条指令继续。
五、Add 的内部计算
上一节结束时,Add 已经有了自己的栈帧。两个形参的栈内副本分别是 x = 10、y = 20,局部变量 z 也已初始化为 0。此时 %rsp = 0x7fffffffe010,%rbp = 0x7fffffffe030。
接下来执行的是:
c
z = x + y;
return z;
结果当然是 30。不过,从机器指令来看,这里并不是"读取两个数,加起来,直接返回"这么短。我们这次编译启用了未定义行为检测器(UBSan),编译器还插入了有符号整数溢出的检测,并为它安排了操作数副本和临时结果。
这些额外步骤没有改变本例的计算结果,却会改变中途的寄存器状态。下面顺着实际指令,看看 30 怎样算出来、保存下来,最后成为准备交给调用者的返回值。
5.1 从读取形参到准备返回值
这一段正常执行路径对应的指令如下:
asm
; 读取 x、y,并保存副本,供发生溢出时报告参与运算的数值
0x55555566ab7d: movl -0x4(%rbp), %eax
0x55555566ab80: movl %eax, -0x18(%rbp)
0x55555566ab83: movl -0x8(%rbp), %ecx
0x55555566ab86: movl %ecx, -0x14(%rbp)
; 加法及临时结果保存
0x55555566ab89: addl %ecx, %eax
0x55555566ab8b: movl %eax, -0x10(%rbp)
; 判断是否没有溢出:本例条件成立,跳过错误报告,继续使用结果
0x55555566ab8e: seto %al
0x55555566ab91: xorb $-0x1, %al
0x55555566ab93: testb $0x1, %al
0x55555566ab95: jne 0x55555566abb1
; 取回结果,赋给 z,再准备返回值
0x55555566abb1: movl -0x10(%rbp), %eax
0x55555566abb4: movl %eax, -0xc(%rbp)
0x55555566abb7: movl -0xc(%rbp), %eax
; 下一节从这里继续:释放局部栈空间
0x55555566abba: addq $0x20, %rsp
其中,jne 与它的跳转目标之间还有溢出报告分支。本例没有发生溢出,所以没有执行那一段;这里列出的是本次经过的路径。
https://github.com/user-attachments/assets/d1a656bf-4cc6-427e-8569-ec291870858b
这一节从读取
x开始,到返回值准备完成为止。末尾的addq $0x20, %rsp尚未执行,Add也还没有返回main。
5.2 把两个操作数取出来
先执行:
asm
0x55555566ab7d: movl -0x4(%rbp), %eax
此时 %rbp 是 0x7fffffffe030,所以 -0x4(%rbp) 指向 x 的栈槽 [0x7fffffffe02c, 0x7fffffffe030)。这条指令从中读取 4 字节,把数值 10 写入 %eax。
%eax 是 %rax 的低 32 位。和之前写入 %edi、%esi 一样,写入 %eax 也会将对应 64 位寄存器的高 32 位清零。因此,现在 %eax = 10,完整的 %rax 也是 10。
不过,下一条指令没有立即读取 y,而是先保存了一份 x 的副本:
asm
0x55555566ab80: movl %eax, -0x18(%rbp)
它把 10 写入 [0x7fffffffe018, 0x7fffffffe01c)。形参 x 原来的栈槽没有变化;这里是在另一个位置额外保存了一份数值。
随后,编译器对 y 做了同样的准备:
asm
0x55555566ab83: movl -0x8(%rbp), %ecx
0x55555566ab86: movl %ecx, -0x14(%rbp)
第一条从 y 的栈槽读取 20,放入 %ecx,也就是 %rcx 的低 32 位;第二条把它保存到 [0x7fffffffe01c, 0x7fffffffe020)。
如果加法发生溢出,报告代码需要知道参与运算的两个数是多少。这两份额外副本就是为此保留下来的:即使后续寄存器被改写,仍能从栈中取回原来的 x、y,交给溢出报告代码。它们不是源码又声明的两个局部变量,而是落在上一节预留、当时尚未写入的栈空间里的辅助数据。
到这里,真正参加加法的两个操作数已经放进寄存器:
text
eax = 10
ecx = 20
x、y 的栈内副本仍在原位,报告用副本也已经保存好了。
5.3 加法完成了,z 却还没有变化
接下来执行:
asm
0x55555566ab89: addl %ecx, %eax
按照这里使用的操作数顺序,它把 %ecx 中的数值加到 %eax 中,再将结果写回 %eax:
text
执行前:eax = 10,ecx = 20
执行后:eax = 30,ecx = 20
这条指令只更新了寄存器中的结果,并没有向 z 的栈槽写入数据。因此,虽然加法已经算出 30,z 此刻仍然是 0。
紧接着又是一条保存指令:
asm
0x55555566ab8b: movl %eax, -0x10(%rbp)
它把 30 写入 [0x7fffffffe020, 0x7fffffffe024)。这是编译器安排的临时结果槽,与 z 所在的 [0x7fffffffe024, 0x7fffffffe028) 相邻,但两者是不同的位置。
为什么已经有了结果,还要在这里存一次?继续看就会发现,后面的检测会使用 %al。它并不是一个与 %eax 无关的新寄存器,而是 %rax 的最低 8 位,也包含在 %eax 中。改写 %al,就会改动 %eax 的一部分。
所以,30 先被保存在栈中。这样,检测期间即使寄存器中的数值变了,后面仍能把加法结果取回来。
5.4 中间这段检测在做什么?
C 语言中,带符号整数的加法结果如果超出类型能够表示的范围,就属于未定义行为。本次启用的 UBSan 为这次加法插入了检测:若发生有符号溢出,进入报告分支;若没有,继续使用计算结果。
本例是 10 + 20,没有溢出,因此执行路径越过了报告分支,来到 0x55555566abb1。
这几条检测指令可以顺着一个判断来看。
加法结束后,CPU 的溢出标志 OF 已经记录了「是否发生有符号溢出」。seto %al 把这个判断写入 %al:发生溢出时写入 1,没有溢出时写入 0。本例没有溢出,所以 %al = 0。
接下来为什么要取反?因为后面的正常路径关心的是另一个命题:是否没有溢出 。xorb $-0x1, %al 将整个字节按位取反,再由 testb $0x1, %al 检查最低位。原来最低位的 0 表示「没有发生溢出」;取反后,它变成 1,就可以作为「没有溢出」这一条件成立的标记。
本例 %al 从 0x00 变成 0xff,最低位为 1。testb 的检查结果非零,因而 jne 跳转到 0x55555566abb1,直接越过中间的溢出报告代码。既然计算没有出错,就不必报告,接下来取回临时结果并赋给 z。
若原来发生了溢出,seto 得到的是 0x01,取反后为 0xfe,最低位是 0。「没有溢出」这个条件不成立,jne 就不跳转,执行流程会进入报告分支。这是依据指令语义推导的另一条路径,本次 10 + 20 没有经过它。
这里还有一个与结果保存直接相关的细节:%al 是 %eax 的一部分。加法刚结束时 %eax = 30,即 0x0000001e;检测把 %al 改为 0x00、0xff 后,本例的 %eax 就依次变成了 0、255。这些数值用于处理判断,已经不是加法结果。栈中事先保存的临时结果仍然是 30,z 此时也仍然是 0。
这样,保存临时结果、检测、跳转这几步就连起来了:先把结果留住,再判断是否没有溢出;本例判断成立,跳过报告,取回结果继续执行。这些额外步骤来自当前编译配置,并不是普通 C 加法必然生成的固定指令序列。
5.5 从临时结果到 z
跳过报告分支后,首先执行:
asm
0x55555566abb1: movl -0x10(%rbp), %eax
它从临时结果槽中取回 30,写入 %eax,覆盖检测期间留下的 255。此时寄存器重新保存的是计算结果。
然后执行:
asm
0x55555566abb4: movl %eax, -0xc(%rbp)
这次写入的目标才是 z 的栈槽 [0x7fffffffe024, 0x7fffffffe028)。4 字节的数值 30 写入后,z 从 0 变成了 30。
回头对照源码:
c
z = x + y;
我们现在能把它对应到一段完整过程:读取两个形参,计算结果,经过当前编译插入的检测,最后把结果写入 z。一条 C 语句可能对应多条机器指令,它们中途使用的寄存器和临时栈槽,也不一定直接对应源码中的变量。
5.6 return z 先准备了什么?
z 已经是 30,下一条却又把它读了一遍:
asm
0x55555566abb7: movl -0xc(%rbp), %eax
它从 z 的栈槽读取 30,写入 %eax。在本实验采用的 System V AMD64 ABI 中,int 返回值通过 %eax 交给调用者,因此这条指令是在准备 Add 的返回值。
从眼前的数值看,执行前后 %eax 都是 30。但这仍然是一条实际执行的读取指令,它对应的是从 z 取出返回值。当前未优化的编译结果保留了这次读取;不能因为数值相同,就把这一步从执行记录中删掉。
此时,main 的 c 仍然是 0。Add 已经准备好返回值,但还没有回到 main,调用者也还没有执行接收结果的指令。
这与第三节 main 中的额外槽位不同:那个槽位不是源码声明的局部变量,而这里的 -0xc(%rbp) 明确存放着源码变量 z。
由于这段程序写的是 return z;,z 的栈槽既保存局部计算结果,也在返回前提供要交给调用者的数值。可以从用途上说它承接了返回值的准备,但并没有因此多出一个独立的「返回值槽」。真正按调用约定交给 main 的位置仍是 %eax;若源码改为返回别的表达式,z 的栈槽也不一定再参与返回。
5.7 计算结束时,栈里多了什么?
这一节没有调整栈指针或帧指针,它们仍然是:
text
rsp = 0x7fffffffe010
rbp = 0x7fffffffe030
rax = 0x000000000000001e
eax = 30
Add 预留的 32 字节中,现在可以确认以下内容:
| 地址范围(半开区间) | 字节数 | 当前内容 |
|---|---|---|
[0x7fffffffe010, 0x7fffffffe018) |
8 | 已预留,这一节未写入;原有字节未展开 |
[0x7fffffffe018, 0x7fffffffe01c) |
4 | x 的报告用副本,值为 10 |
[0x7fffffffe01c, 0x7fffffffe020) |
4 | y 的报告用副本,值为 20 |
[0x7fffffffe020, 0x7fffffffe024) |
4 | 临时计算结果,值为 30 |
[0x7fffffffe024, 0x7fffffffe028) |
4 | z = 30 |
[0x7fffffffe028, 0x7fffffffe02c) |
4 | y = 20 |
[0x7fffffffe02c, 0x7fffffffe030) |
4 | x = 10 |
更高地址处保存的旧帧指针、返回地址,以及 main 的局部变量,都没有被这一节的指令改写。
上一节只是把空间留出来;这一节才看见其中几个位置的实际用途。源码只有 x、y、z,栈中却还出现了报告用副本和临时结果。具体布局要顺着编译器生成的指令判断,不能只数源码里的变量。
现在结果已经算好,%eax 也准备好了 30。接下来要解决的就是:怎样收回 Add 使用的栈空间,恢复 main 的帧指针,再从保存的返回地址回到调用者。下一节从这里继续:
asm
0x55555566abba: addq $0x20, %rsp
六、从 Add 返回,到 main 收尾
上一节结束时,Add 已经算出了 z = 30,也把它读入 %eax,准备交给调用者。不过,结果准备好了,并不意味着函数已经返回:%rsp 仍是 0x7fffffffe010,%rbp 仍是 0x7fffffffe030,main 中的 c 也还是 0。
接下来还有几件事要做。先收回 Add 使用的栈空间,恢复 main 的帧指针,再回到调用结束后的那条指令。随后,main 接收这个结果,输出它,最后也要把自己的栈帧收起来。
我们顺着这段过程看下去,会遇到两个很容易混在一起的东西:返回值和返回地址。30 是这次计算要交出去的数据;返回地址则告诉 CPU,函数结束以后应该接着执行哪里。它们走的是两条不同的路。
6.1 从返回到收尾的指令
先把这一节经过的指令集中放在一起:
asm
; 衔接上一节:从 z 读取返回值
0x55555566abb7: movl -0xc(%rbp), %eax
; Add 收回局部空间,恢复帧指针,返回 main
0x55555566abba: addq $0x20, %rsp
0x55555566abbe: popq %rbp
0x55555566abbf: retq
; main 接收结果,并准备 printf 的参数
0x55555566abf7: movl %eax, -0x10(%rbp)
0x55555566abfa: movl -0x10(%rbp), %esi
0x55555566abfd: leaq 0x13edc(%rip), %rdi
0x55555566ac04: movb $0x0, %al
0x55555566ac06: callq 0x55555559ad7c
; 进入 ASan 的 printf 转接入口和拦截入口
0x55555559ad7c: jmp 0x5555555ab940
0x5555555ab940: endbr64
; 此处故意省略 printf 内部的执行过程
; 调试停在上述 endbr64 前,再用 thread step-out 整体执行并返回 main
; printf 返回后,main 准备返回值并收回栈帧
0x55555566ac0b: xorl %eax, %eax
0x55555566ac0d: addq $0x10, %rsp
0x55555566ac11: popq %rbp
0x55555566ac12: retq
; main 返回后:下一节从这里继续,此指令尚未执行
0x7ffff7c2a1ca: movl %eax, %edi
中间从 printf 拦截入口回到 main 的部分,是有意跳过的,不是两条相邻指令直接连在一起。调试时使用 thread step-out,让当前函数执行完并返回调用者;其中的 endbr64 和后续内部指令仍然实际执行,只是这里没有逐条展示。
为什么不继续进入 printf?前面已经通过 main 和 Add 看过栈帧的创建、使用与销毁,再完整重复一次,对当前主线的帮助有限。而 printf 还涉及可变参数、格式解析、输出,以及本次编译插入的检测器拦截逻辑。本次实验没有展开这些实现源码与反汇编的对应关系,我们也不打算在这里逐项解释。保留调用前的参数准备、返回地址的压栈和返回后的状态,就足以接着观察 main 怎样收尾。
https://github.com/user-attachments/assets/1a069795-dfdb-4cc0-9671-93a3af5d9c02
演示从第92步开始,包含一次返回值读取作为衔接,随后展示
Add返回、main输出和栈帧释放。第106步停在main的retq前,第107步展示返回运行库后的状态;printf内部整体跳过。
6.2 收回 Add 的局部空间
首先执行:
asm
0x55555566abba: addq $0x20, %rsp
建立 Add 栈帧时,曾经从 %rsp 中减去 0x20,预留32字节。现在做相反的操作,把这32字节收回:
text
执行前:rsp = 0x7fffffffe010
执行后:rsp = 0x7fffffffe030
%rbp 没有变化,仍是 0x7fffffffe030;%eax 中的30也没有变化。
这里说"收回",并不是把那片内存清零。指令只调整了栈指针,没有向 x、y、z 或临时结果槽写入任何数据。原来的字节还留在那里,只是这次调用不再占用那片局部空间,后续执行可以重新使用它。
所以,图中保留数值并标为"已释放"是有意义的:空间是否仍被占用,与里面的字节是否还在,是两回事。看到30还留在原位置,也不能认为已经结束生命周期的局部变量仍然可以继续使用。
6.3 恢复 main 的帧指针
收回局部空间以后,栈顶正好来到 Add 进入时保存旧帧指针的位置:
asm
0x55555566abbe: popq %rbp
0x7fffffffe030 处保存着 0x7fffffffe050,也就是 main 的帧指针。popq 读取这个8字节数值,放回 %rbp,然后把 %rsp 增加8:
text
rbp = 0x7fffffffe050
rsp = 0x7fffffffe038
这一步之后,帧指针已经恢复成 main 的了,但当前指令仍在 Add 中,下一条才是它的 retq。
现在 %rsp 指向的8字节内容是 0x55555566abf7。这个地址不是刚刚计算出来的,而是当初 main 执行 callq Add 时压入栈中的:它指向那次调用结束后,要继续执行的下一条指令。
这条 popq 可以从两个动作来理解。一方面,它把栈顶的8字节数值取出来,交给目标寄存器 %rbp,所以 %rbp 恢复为保存的旧值;另一方面,这8字节已经出栈,栈的占用范围缩小,%rsp 因而向高地址移动8字节。不是 %rbp 自己加了8,而是 %rbp 接收数据、%rsp 调整栈顶,各自完成不同的事。
6.4 retq 怎样回到调用者?
接下来执行:
asm
0x55555566abbf: retq
在这里,retq 从栈顶读取返回地址,将它交给指令指针 %rip,同时把 %rsp 增加8。执行以后:
text
rip = 0x55555566abf7
rsp = 0x7fffffffe040
rbp = 0x7fffffffe050
eax = 30
CPU 接下来就从 0x55555566abf7 继续执行,回到了 main。
回头看,Add 的收尾只有三条指令,却分别完成了三件事:addq 收回局部空间,popq 恢复调用者的帧指针,retq 取出返回地址并转移执行位置。这也与之前建立栈帧、保存旧帧指针和压入返回地址的过程对应起来了。
而计算结果30一直保存在 %eax 中。retq 没有把30压入栈,也没有替 main 给 c 赋值;它解决的是"回到哪里"。回去之后,调用者还要执行自己的接收指令。
从眼前这次返回的效果看,也可以把 retq 理解为一次以返回地址为内容的出栈:取出的地址成为新的 %rip,决定下一条指令的位置;8字节返回地址出栈后,栈的占用范围再次缩小,%rsp 再增加8。这是在说明它的执行效果,并不是说可以直接用一条 popq %rip 来替代它。
6.5 main 接收30
返回后遇到的第一条指令是:
asm
0x55555566abf7: movl %eax, -0x10(%rbp)
此时 %rbp 已经恢复为 0x7fffffffe050,所以 -0x10(%rbp) 对应的是 0x7fffffffe040,也就是 main 的 c。
这条指令将 %eax 中的30写入 c 的4字节栈槽。到这里,源码中的这条语句才完成了调用和结果接收:
c
c = Add(a, b);
Add 中的 z 与 main 中的 c 不是同一个变量,也不在同一个地址。结果先从 z 读入返回值寄存器,再由 main 写入自己的 c。即使 Add 的栈帧已经释放,main 仍然有自己保存好的30。
6.6 为 printf 准备参数
c 已经是30,接下来执行的是:
c
printf("%d\n", c);
对应的参数准备如下:
asm
0x55555566abfa: movl -0x10(%rbp), %esi
0x55555566abfd: leaq 0x13edc(%rip), %rdi
0x55555566ac04: movb $0x0, %al
第一条从 c 的栈槽读取30,放入 %esi,准备第二个参数。第二条把格式字符串的地址 0x55555567eae0 放入 %rdi,准备第一个参数。这里的 leaq 计算并保存地址,不是读取字符串内容。
最后一条为什么要清零 %al?printf 是可变参数函数。在本实验采用的调用约定中,调用这种函数时,%al 用来提供向量寄存器传参的数量信息。本次传入的是格式字符串指针和整数,没有用向量寄存器传参,所以这里写入0。
%al 是 %rax 的低8位,并不是另一个独立寄存器。本例此前 %rax = 30,清零低8位后,完整数值也变成了0。不过,计算结果已经保存在 c 中,打印所需的30也已经读入 %esi,不会因为这次改写而丢失。
6.7 调用 printf,再回来
参数准备好以后执行:
asm
0x55555566ac06: callq 0x55555559ad7c
与之前调用 Add 一样,callq 先把下一条指令的地址压入栈,再进入目标。这次压入的是 0x55555566ac0b,保存位置为 0x7fffffffe038,%rsp 也来到这里。
这个位置之前存放过 Add 的返回地址。Add 返回时,那8字节已经释放;现在调用 printf,同一位置就被新的返回地址覆盖了。栈空间的重复使用,在这里已经能直接看见。
本次编译启用了 AddressSanitizer(ASan),因此调用先进入它安排的 printf 转接入口:
asm
0x55555559ad7c: jmp 0x5555555ab940
这条 jmp 只改变执行位置,不会像 callq 那样再压入一份返回地址。随后进入拦截逻辑,完成输出,再回到 main。我们在这里整体跳过内部执行,不展开它怎样建立栈帧、怎样处理输出。
返回以后停在:
asm
0x55555566ac0b: xorl %eax, %eax
此时这条清零指令还没有执行,%eax = 3。这个3是 printf 的返回值:本次输出了字符 3、0 和换行,共3个字符。它不是 Add 的计算结果,也不是 main 将要返回的状态。
还有一点不能沿用之前的画面:printf 的内部执行可以重新使用栈顶以下已经释放的空间。因此,返回以后,不能再把原先 Add 的 x、y、z 等数值照搬成当前内存内容。没有新的内存读数,就不标注那些位置的具体数据;这也不表示它们被清零了。
6.8 main 也要收起自己的栈帧
输出完成以后,源码只剩:
c
return 0;
对应的指令是:
asm
0x55555566ac0b: xorl %eax, %eax
0x55555566ac0d: addq $0x10, %rsp
0x55555566ac11: popq %rbp
0x55555566ac12: retq
第一条让 %eax 与自身异或,结果为0。这是汇编中常见的寄存器清零方式,也会清零 %rax 的高32位。这里用它准备 main 的返回值0,刚才 printf 留下的3被覆盖了。
接着,addq 收回 main 预留的16字节局部空间,%rsp 从 0x7fffffffe040 来到 0x7fffffffe050。a、b、c 所在的空间已经释放,但这条指令没有清除其中的数据。
随后,popq 读取 main 进入时保存的旧帧指针,将 0x7fffffffe0f0 恢复到 %rbp,并把 %rsp 增加8,来到 0x7fffffffe058。
到第106步,main 的 retq 尚未执行,当前状态是:
text
rip = 0x55555566ac12
rsp = 0x7fffffffe058
rbp = 0x7fffffffe0f0
eax = 0
栈顶保存着 0x7ffff7c2a1ca。和 Add 返回时一样,接下来只需要取出它,就知道要从哪里继续执行。
下面把两次返回的关键变化放在一起:
| 收尾步骤 | Add 返回 main |
main 返回运行库 |
|---|---|---|
| 返回值 | %eax = 30 |
%eax = 0 |
| 收回局部空间 | %rsp 从 0x7fffffffe010 到 0x7fffffffe030 |
%rsp 从 0x7fffffffe040 到 0x7fffffffe050 |
| 恢复旧帧指针 | %rbp = 0x7fffffffe050,%rsp = 0x7fffffffe038 |
%rbp = 0x7fffffffe0f0,%rsp = 0x7fffffffe058 |
执行 retq 后 |
%rip = 0x55555566abf7,%rsp = 0x7fffffffe040 |
%rip = 0x7ffff7c2a1ca,%rsp = 0x7fffffffe060 |
两次返回遵循同样的过程,只是调用者、局部空间大小和返回值不同。
6.9 main 返回,程序就结束了吗?
再执行 main 的 retq,就来到第107步:
text
rip = 0x7ffff7c2a1ca
rsp = 0x7fffffffe060
rbp = 0x7fffffffe0f0
eax = 0
main 已经返回,进程却还没有退出。当前执行位置在运行库中,下一条是:
asm
0x7ffff7c2a1ca: movl %eax, %edi
这时再回看前面的过程,就会发现 main 的返回方式并没有什么特别:它也有调用者,也保存返回地址,也需要恢复调用者的帧指针,最后通过 retq 回去。
那么,最初是谁调用了 main?它拿到返回值0以后,又怎样让整个进程结束?下一节就从这两头接起来:回看进入 main 之前的调用,再看它返回以后的收场。
七、main 的调用者与程序的收场
之前,我们从 main 的第一条指令开始观察,看它建立栈帧、调用 Add,最后释放自己的栈帧并返回。第三节已经看过运行库调用 main 的那条 callq。现在从返回的位置回看,它保存的入口地址从哪里来,main 返回以后又会怎样收场?
把执行位置往前移,就能看到调用它的运行库代码:
asm
0x7ffff7c2a1c4: movq -0x78(%rbp), %rax
0x7ffff7c2a1c8: callq *%rax
第一条从运行库自己的栈中取出 main 的入口地址,放入 %rax。第二条通过这个地址调用 main。
之前调用 Add 时,目标地址直接写在反汇编指令里;这里则先把目标地址放进寄存器,再通过寄存器调用。写法不同,但我们关心的调用过程一样:callq 将下一条指令的地址 0x7ffff7c2a1ca 压入栈中,再进入 main。
调用前,%rsp = 0x7fffffffe060。压入8字节返回地址以后,%rsp 来到 0x7fffffffe058,栈顶保存的就是 0x7ffff7c2a1ca。
所以,进入 main 时,栈并不是空的。调用者已经建立了自己的执行环境,也已经留下了返回地址。第三节看到的 main 栈帧,是在这个基础上继续建立的。
在调用之前,运行库还做了参数保存、上下文准备等工作。其中有些指令涉及运行库和线程环境,已经超出了本文观察函数栈帧的主线。正文到这里知道"调用者做过准备,再调用 main"就够了,具体指令另放在附录中记录。
再回到第六节的末尾。main 执行 retq 后,正好回到当初保存的地址:
asm
0x7ffff7c2a1ca: movl %eax, %edi
0x7ffff7c2a1cc: callq 0x7ffff7c47ba0 ; exit
此时 %eax = 0,是 main 的返回值。运行库把它放进 %edi,作为 exit 的参数,再由退出流程完成必要的清理并结束进程。
我们不再逐条进入这些清理代码。反汇编中列在 callq exit 后面的指令,也不能直接看作本次正常退出还会继续经过的步骤。
到这里,程序前后的关系已经接起来了:运行库调用 main,main 完成自己的工作并返回,运行库接过返回值,负责最后的收场。
八、从这次实验回看函数栈帧
现在再回头看最初的问题:函数调用以后,局部变量放在哪里?调用者怎样把参数交出去?被调用函数结束后,为什么还能回到原来的位置继续执行?
这些问题已经能在同一段过程里接起来了。
main 调用 Add 之前,先把两个整数放进参数寄存器。callq 保存返回地址,再把执行位置转到 Add。进入以后,Add 保存旧帧指针,建立自己的栈基址,并预留局部空间。参数副本、局部变量和编译器安排的辅助数据,随后被写入其中。
计算结束后,Add 把结果放进返回值寄存器,收回局部空间,恢复调用者的帧指针,再通过保存的返回地址回到 main。main 接收结果,继续执行自己的代码。
所以,一次函数调用并不是把调用者的现场丢掉,再从头开始。调用者仍有自己的数据和继续执行的位置;被调用函数在调用约定下使用寄存器与栈空间,结束时再把执行流程交还回去。栈帧就是我们在这次实验中观察这些安排的重要入口。
栈里保存的,并不只有源码中的局部变量
这次 Add 的源码很短,栈中却除了 x、y、z,还有操作数副本和临时结果。它们来自当前编译插入的溢出检测。
这说明,不能只数源码里的变量,就断定函数要占多少栈空间。源码表达的是程序要完成的工作;具体怎样使用寄存器、在哪里保存中间数据,还要看编译器生成的指令。
同样,参数和返回值也不必都通过栈传递。本例的两个整数先通过 %edi、%esi 传入,结果通过 %eax 返回。我们后来在栈中看见参数副本,是因为当前生成的指令又把它们保存了下来。
栈帧释放了,数据为什么还在?
返回过程中,调整 %rsp 的指令没有逐个清除局部变量。它只是收回当前调用占用的空间。
因此,函数结束后,原位置可能暂时还保留着那些字节,但它们已经不能作为仍然有效的局部变量继续使用。之后调用 printf 时,释放过的空间又可能被重新使用,旧数据也就可能被覆盖。
"字节仍然存在"和"变量仍然有效",不能当作同一件事。
这些指令,是所有函数的固定写法吗?
这次实验里的 main 和 Add 都使用了 %rbp,也都能看到保存帧指针、预留空间和恢复现场的过程。但函数栈帧并不要求永远按这套指令建立。
编译器可以省略专门的帧指针,让局部变量长期保存在寄存器中,消除不必要的读写,甚至把函数内联到调用处。函数需要保存什么、预留多少空间,也会随着函数内容、优化选项和检测插桩而改变。
这里可以分清三个层次:
| 层次 | 本例中的体现 |
|---|---|
| C 程序表达的语义 | Add 计算两个整数的和,main 接收并输出结果 |
| 当前 ABI 的调用约定 | 整数参数和返回值使用哪些寄存器,哪些寄存器需要由被调用者保留 |
| 本次编译的具体安排 | 使用 %rbp、预留多少栈空间、保存参数副本、插入溢出检测 |
前面的观察都是真实的,只是它们对应的是这份程序、这套调用约定和这次编译结果。以后遇到另一段反汇编,不必要求它长得一模一样;更有用的是继续追问:参数从哪里来,数据存到哪里,哪些状态需要保留,执行结束后又怎样交还调用者。
掌握了这条思路,具体的栈布局即使变了,也仍然有办法顺着指令读下去。