函数栈帧的创建与销毁-重置版

函数栈帧的创建与销毁

一、从一次普通的函数调用开始

在 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

直接打开或下载本节视频

下载 HTML 页面,在自己的浏览器中交互

视频展示的是本次实际执行过程,而不是所有编译器、优化级别都必须遵循的固定模板。下面将逐条解释这些变化。

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 返回地址

回顾整个过程,我们没有先规定"一个函数栈帧应当长什么样",而是依次观察:

  1. 调用者执行 callq,返回地址入栈。
  2. main 执行 pushq %rbp,保存调用者的帧指针。
  3. movq %rsp, %rbp,确立当前栈帧的参考位置。
  4. subq $0x10, %rsp,预留 16 字节空间。
  5. 四条 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

直接打开或下载本节视频

下载 HTML 页面,在自己的浏览器中交互

动画展示的是本次编译与实验所采用的一组具体状态,并不是 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

直接打开或下载本节视频

下载 HTML 页面,在自己的浏览器中交互

这一节从读取 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

直接打开或下载本节视频

下载 HTML 页面,在自己的浏览器中交互

演示从第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、预留多少栈空间、保存参数副本、插入溢出检测

前面的观察都是真实的,只是它们对应的是这份程序、这套调用约定和这次编译结果。以后遇到另一段反汇编,不必要求它长得一模一样;更有用的是继续追问:参数从哪里来,数据存到哪里,哪些状态需要保留,执行结束后又怎样交还调用者。

掌握了这条思路,具体的栈布局即使变了,也仍然有办法顺着指令读下去。

相关推荐
Wang's Blog1 小时前
Java 项目部署之 Docker工具快速入门: 学习路径与命令速查导引
java·学习·docker
程序猿阿森1 小时前
Warmup 学习率预热详解:为什么训练初期要“先热身再加速”?
深度学习·学习·语言模型
水饺编程1 小时前
第2章:Linux 基础,pwd 命令
linux·c语言·汇编
lisw051 小时前
人类认知和语言学习中的控制论!
人工智能·学习·数学建模
鱼很腾apoc1 小时前
【MySQL】 第1期 从核心概念到表约束全解析
linux·c语言·数据库·学习·mysql
心仪久了会沦陷2 小时前
C语言数据结构入门——队列补充:循环队列的数组实现
c语言·开发语言·数据结构·笔记
妖精的羽翼2 小时前
Web3D(Three.js)应用分级 + 学习路线规划(由浅入深,贴合接单 + 求职)
学习·three.js
爱吃提升2 小时前
Dev-C++ 5.11 完整版快捷键大全(信奥/初学适用)
c语言·c++
小小龙学IT3 小时前
FFmpeg 开源多媒体处理框架深度解析:从命令行到 libav* 全家桶
c语言·ffmpeg·开源