目录
#include <stdio.h>
int add(int x, int y)
{
int sum = x + y;
return sum;
}
int main(void)
{
int a = 10;
int b = 20;
int c = add(a, b);
printf("val = %d\n", c);
return 0;
}
当你写下上面这段代码时,你是否想过在运行这段代码时究竟会发生什么?
你是否也有过这样的疑问:add 是怎么被调用的?参数又怎么传给add函数的?a、b、c 又是在内存的什么位置存储的?
为了能更容易的回答这些问题,我将上面的 C 代码转换为对应汇编(Intel 语法,节选 add 与 main)。
正文里形如 [1] 的方括号表示文末参考资料的编号,不是代码,也不是数组下标。
文中汇编都是 GCC 13.3.0(x86_64-linux-gnu)在 -O0 -fno-stack-protector、Intel 语法(-masm=intel)下的真实产物[7],只节选相关函数主体,略去 .cfi、endbr64 这类指令,没有做过手绘改写。汇编块里的 # 注释是为对照 C 源码所加,指令与偏移均照产物原样。Ubuntu 默认打开的栈保护会改写局部变量槽位;关闭后,文首 main 里才是 -4 / -8 / -12。
add:
push rbp # 保存调用方帧基址
mov rbp, rsp # 立起本帧基准
mov DWORD PTR -20[rbp], edi # x spill 到栈槽
mov DWORD PTR -24[rbp], esi # y spill 到栈槽
mov edx, DWORD PTR -20[rbp] # x
mov eax, DWORD PTR -24[rbp] # y
add eax, edx # sum = x + y
mov DWORD PTR -4[rbp], eax # 局部变量 sum
mov eax, DWORD PTR -4[rbp] # 返回值 → eax
pop rbp # 恢复调用方帧基址
ret
.LC0:
.string "val = %d\n"
main:
push rbp # 保存调用方帧基址
mov rbp, rsp # 立起本帧基准
sub rsp, 16 # 给 a/b/c 留 12 字节,取整到 16
mov DWORD PTR -4[rbp], 10 # a
mov DWORD PTR -8[rbp], 20 # b
mov edx, DWORD PTR -8[rbp] # b
mov eax, DWORD PTR -4[rbp] # a
mov esi, edx # 第 2 个参数 y → esi
mov edi, eax # 第 1 个参数 x → edi
call add # 压入返回地址,转入 add
mov DWORD PTR -12[rbp], eax # c 接住返回值
mov eax, DWORD PTR -12[rbp] # c
mov esi, eax # 第 2 个参数:c → esi
lea rax, .LC0[rip] # 格式串 "val = %d\n"
mov rdi, rax # 第 1 个参数:格式串 → rdi
mov eax, 0 # al = 用到的向量寄存器个数
call printf@PLT
mov eax, 0 # return 0
leave # 收掉本帧,恢复旧 rbp
ret
如果你对汇编非常熟悉,那么至此,对于前面提到的问题你应该已经有答案了。
如果不熟悉也没关系,继续读下去也会有答案的。
首先咱们先了解一下栈,这是理解函数调用以及参数传递底层实现原理并回答上述问题的基础。
栈往哪边长,局部变量分布在什么位置?
先看栈朝哪边长,函数进入时占哪一段。
内存地址自低向高编号,栈自高地址向低地址生长。
生长方向由处理器架构决定:x86-64 的 push、pop、call、ret 本身就按地址减小的方向操作栈指针,这套硬件上的任何编译器、任何操作系统都遵循同一方向。也有栈朝高地址生长的架构(例如 HP PA-RISC)[6],这里不讨论。
对开头这段代码来说:进入 main 时,会在当前栈顶之下占用一段连续区域;执行到 call add 时,再在其下为 add 占用另一段。
每一段称为该函数的栈帧(stack frame)[1]。
下文按 System V AMD64 ABI 解读 Linux / macOS 上 GCC、Clang 的产物,Windows x64 的差别见后文”和 Windows x64 的一点差别”。
同一 ABI 之内,不同编译器版本的差别主要在槽位怎么排,不影响这个模型。
管这块区域的寄存器有两个:
rsp:stack pointer,指向当前栈顶,也就是地址较低、靠近最近一次压入内容的那一端。rbp:frame pointer,帧基址。函数入口处会把它设成本帧基准,好让a、b、c、sum用相对它的固定位移寻址。局部变量落在rbp之下,所以位移是负的:-8[rbp]与[rbp-8]是同一个地址。
一个典型栈帧在垂直方向上的布局如下(高地址在上):
图1:典型栈帧布局(高地址在上)
图中自顶向下三层,用开头这段代码来读:
call压入的返回地址,本帧建立后位于rbp+8。进入add之后,这里就是main里call add的下一条指令地址。- 函数入口处
push rbp保存的调用方旧rbp,随后mov rbp, rsp,让当前rbp指向这块保存值。对add来说,旧的那份就是main的基准。 rbp以下(-N[rbp])是本帧的局部变量与参数槽。main的a/b/c,以及add的sum等,都落在这一层。rsp还可能继续下移,给对齐或更多临时空间腾位置。
具体到 a 是 -4 还是别的位移,得看汇编。
调用方一侧:main 怎么传参数
main 怎样立起自己的帧、怎样把 a 和 b 交给 add、又怎样把返回值接进 c。
main:
push rbp
mov rbp, rsp
sub rsp, 16 # 给 a/b/c 留 12 字节,取整到 16
mov DWORD PTR -4[rbp], 10 # a
mov DWORD PTR -8[rbp], 20 # b
mov edx, DWORD PTR -8[rbp] # b
mov eax, DWORD PTR -4[rbp] # a
mov esi, edx # 第 2 个参数 y → esi
mov edi, eax # 第 1 个参数 x → edi
call add # 压入返回地址,转入 add
mov DWORD PTR -12[rbp], eax # c 接住返回值
入口的 push rbp 与 mov rbp, rsp 合称 prologue:保存调用方的帧基址,把本帧基准立起来。
随后 sub rsp, 16 给 a、b、c 留槽。
三个 int 只要 12 字节,却减了 16:System V 要求调用点(即将执行 call 时)的 rsp 16 字节对齐(ABI §3.2.2)[1]。
push rbp 之后 rsp 已对齐,局部区再减下去的字节数通常取 16 的倍数,后面的 call add、call printf 才仍满足约定。12 向上取整到 16,多出来的 4 字节是对齐填充,不是第三个 int 真要 16 字节。
10 和 20 写进 -4[rbp]、-8[rbp] 之后,再读出来装进 esi 与 edi:第 2 个参数走 esi,第 1 个走 edi。
这些寄存器由 System V 约定,整型与指针参数按约定的顺序生效(如下表)[1]:
| 顺序 | 寄存器(32 位视图常用) |
|---|---|
| 第 1 个 | rdi / edi |
| 第 2 个 | rsi / esi |
| 第 3 个 | rdx / edx |
| 第 4 个 | rcx / ecx |
| 第 5 个 | r8 / r8d |
| 第 6 个 | r9 / r9d |
开头的 add(a, b) 正好两个 int,此时全部走寄存器,只有超过 6 个参数时才轮到栈出场。
call add 先把下一条指令的地址压栈,再转入 add。
add 返回时整型结果在 eax 里,main 把它存进 -12[rbp],也就是 c。
main 末尾还会用 leave 丢掉本帧局部区并恢复旧 rbp,再 ret。
参数已经装进 edi / esi 了。
进了 add 之后,它们去哪了?
被调方一侧:add 帧里发生了什么
被调方(callee)add 的汇编:
add:
push rbp
mov rbp, rsp
mov DWORD PTR -20[rbp], edi # x spill 到栈槽
mov DWORD PTR -24[rbp], esi # y spill 到栈槽
mov edx, DWORD PTR -20[rbp]
mov eax, DWORD PTR -24[rbp]
add eax, edx
mov DWORD PTR -4[rbp], eax # 局部变量 sum
mov eax, DWORD PTR -4[rbp]
pop rbp
ret
add 同样用 prologue:push rbp 保存 main 的帧基址,mov rbp, rsp 立起本帧基准。一进门就把 edi、esi 写进 -20[rbp]、-24[rbp]。这是 -O0 下编译器的习惯:参数先 spill 到栈上(寄存器里的值写到栈槽),之后按对象读写,调试方便,ABI 并不强制这一步。算出来的 sum 落在 -4[rbp],再拷进 eax 作为返回值。pop rbp 恢复 main 的帧基址,ret 弹出返回地址,跳回 call add 之后。
add 从头到尾没有 sub rsp,却用到了 -4 到 -24 这些槽。为什么安全?这些槽落在 rsp 之下 128 字节的红区(red zone)里。ABI §3.2.2 规定:信号与中断处理程序不得改写这块区域,因此函数可以把不跨调用存活的临时数据放在这里;叶子函数尤其可以把整帧都放进红区,而不必在 prologue / epilogue 里调整栈指针[1]。开头这段里的 add 不再调用别的函数,是叶子,栈指针不用动。main 还要 call printf,不是叶子,局部变量不能放在 rsp 之下(下一层调用会覆盖),所以必须自己 sub rsp。
call add 返回前、两帧叠在一起时,槽位大致如下(高地址在上,偏移以文中 GCC -O0 -fno-stack-protector 产物为准;图中补出了 main 的 4 字节对齐填充,以及 add 帧里 sum 与 spill 槽之间的未用空隙):
图2:文首示例在 call add 返回前的两帧叠放(示意)
编译运行开头那段完整代码:
$ ./a.out
val = 30
$ echo $?
0
sum 经 eax 变成 c;printf 打出 val = 30,main 的 return 0 使退出码为 0。
c 接下来要交给 printf。
这一步和 add 有什么不一样?
printf 这一行
c 算出来之后,开头代码立刻执行 printf("val = %d\n", c)。
接上 main 里写入 c 之后的指令:
mov eax, DWORD PTR -12[rbp]
mov esi, eax # 第 2 个参数:c → esi
lea rax, .LC0[rip] # 格式串 "val = %d\n"
mov rdi, rax # 第 1 个参数:格式串 → rdi
mov eax, 0 # al = 用到的向量寄存器个数
call printf@PLT
mov eax, 0 # return 0
leave
ret
和 add(a, b) 同一套路:格式串占第 1 个参数槽 rdi,c 占第 2 个 esi。两个实参都在寄存器名额内,这次没有把多余参数压栈。
和 add 相比多出来的,是 call 前的 mov eax, 0。
printf 带 ...,属于可变参数函数。
ABI §3.5.7(以及 §3.2.3 的传参规则)约定:al 给出本次调用用于传参的向量寄存器个数上限[1]。这里传的都是整型 / 指针,用不到向量寄存器,所以填 0。
可变参数还有一层硬约束:每次调用的实参个数只有调用方知道,被调方没法在 ret 里写死”弹 N 字节参数”。
文首这次没有栈上参数,清栈动作看不见。如果实参多到必须压栈时,压下去的那一段谁来收?
参数超过 6 个:栈上传参与调用方清栈
寄存器只有 6 个名额。
此时把开头的 add 扩到 8 个参数,让 a、b 继续参与运算,此时栈上参数区就会出现:
#include <stdio.h>
int add(int a1, int a2, int a3, int a4,
int a5, int a6, int a7, int a8)
{
return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8;
}
int main(void)
{
int a = 10;
int b = 20;
int c = add(a, b, 3, 4, 5, 6, 7, 8);
printf("val = %d\n", c);
return 0;
}
调用方在 call add 前后的节选(以 GCC -O0 -fno-stack-protector x86-64 产物为准;部分版本与 Clang 会用 mov 写入栈参而非 push,参数位置与清栈方仍相同):
mov esi, DWORD PTR -8[rbp] # b → esi(第 2 个)
mov eax, DWORD PTR -4[rbp] # a
push 8 # 第 8 个参数(先压)
push 7 # 第 7 个参数(后压,离栈顶更近)
mov r9d, 6 # 第 6 个 → r9d
mov r8d, 5 # 第 5 个 → r8d
mov ecx, 4 # 第 4 个 → ecx
mov edx, 3 # 第 3 个 → edx
mov edi, eax # 第 1 个 → edi
call add
add rsp, 16 # 调用方回收自己压的 2×8 字节
mov DWORD PTR -12[rbp], eax
# ……随后仍是 printf("val = %d\n", c)
前 6 个照旧走寄存器,第 7、第 8 个由调用方逆序压栈,栈上参数区按 8 字节槽对齐。
call 返回后,main 用 add rsp, 16 把自己压的那段收掉,再把返回值写入 c。
被调方 add(节选):
add:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi # a1
mov DWORD PTR -8[rbp], esi # a2
mov DWORD PTR -12[rbp], edx # a3
mov DWORD PTR -16[rbp], ecx # a4
mov DWORD PTR -20[rbp], r8d # a5
mov DWORD PTR -24[rbp], r9d # a6
mov edx, DWORD PTR -4[rbp]
mov eax, DWORD PTR -8[rbp]
add edx, eax
# ……a3、a4、a5、a6 依次取出累加(指令同形,略)
mov eax, DWORD PTR 16[rbp] # a7:第一个栈上参数
add edx, eax
mov eax, DWORD PTR 24[rbp] # a8:第二个栈上参数
add eax, edx
pop rbp
ret
完成 push rbp 与 mov rbp, rsp 之后,rbp+0 是旧 rbp,rbp+8 是返回地址,所以第一个栈上参数 a7 在 16[rbp],第二个 a8 在 24[rbp]:正偏移,朝调用方的方向读。被调方的 ret 只弹返回地址,不动参数区。
参数区由调用方在 call 之后自己收,谁压的谁收。
printf 这类可变参数函数,N 随每次调用变化,清栈只能由调用方做。
本例两个栈参正好 16 字节,不破坏调用点的 16 字节对齐。若再多一个参数、栈参共 24 字节,编译器会先 sub rsp, 8 做填充,再压栈。这是 ABI 对齐要求下的推论,不是本例汇编里出现的指令。
运行结果:
$ ./a.out
val = 63
$ echo $?
0
10 + 20 + 3 + … + 8 = 63。前 6 个走寄存器,第 7 个起走栈、逆序压、调用方清。
leave、ret,以及"出栈"到底弹出了什么
文首代码用到的指令[2]:
| 指令 | 作用 |
|---|---|
push r | rsp 减 8,把 r 写入新栈顶 |
pop r | 从栈顶读出到 r,rsp 加 8 |
call dest | 压入返回地址,跳到 dest |
ret | 弹出返回地址到 rip,回到调用点之后 |
leave | 等价 mov rsp, rbp 加 pop rbp:丢掉本帧局部区,恢复旧 rbp |
函数返回路径上的”出栈”通常是两步:先拆本帧(leave,或等价的 mov rsp, rbp 与 pop rbp),再弹返回地址(ret)。第 7 个及以后的参数若由调用方 push 过,那段由调用方在 call 之后 add rsp 回收,与上面两步分开。
和 Windows x64 的一点差别
Microsoft x64 同样优先寄存器传参,但寄存器换成 rcx、rdx、r8、r9,只有 4 个名额,而且不管参数是否全部走寄存器,调用方都要在栈上预留 32 字节 home space(shadow store)[3]。细节以 Microsoft Learn 的 x64 calling convention 为准。
读指针、解引用汇编时怎么用这些知识
文首 main 里的 -12[rbp] 就是 c 的槽:当前函数帧内、rbp 往下 12 字节起的一块存储。[rax] 是按寄存器里的地址访存,指针解引用就是这个形态。lea rax, -12[rbp] 只算出槽的地址,不读写槽里的数据。更完整的指针与解引用对照见文集[5];名字修饰与历史约定见文集[4]。
开篇四个问题
- add 如何被调用:
call add压入返回地址并跳转;add用 prologue 立帧,算完后pop rbp/ret弹回main。 - 参数怎么传递:前 6 个整型 / 指针走
rdi、rsi、rdx、rcx、r8、r9;第 7 个起由调用方逆序压栈,被调方从16[rbp]起读,调用方在call之后清栈。 - a、b、c 如何分布:落在本帧
rbp的负偏移槽里(文首main里常见-4/-8/-12);add的 spill 槽与sum同理,都在被调方自己的rbp之下。 - printf 的特殊之处:可变参数约定要求
al给出向量寄存器个数上限;栈上多余参数只能由调用方清理,因为 N 只有调用方知道。
槽的角色稳定,槽的偏移不稳。多写一个局部变量、打开 -fstack-protector、换一个编译器主版本,原来的 -12 可能变成 -16 或 -20。
参考资料
[1] System V Application Binary Interface,AMD64 Architecture Processor Supplement(x86-64-psABI-2023-09-26):§3.2.2 栈帧(栈向低地址生长、调用点 16 字节对齐、rsp 之下 128 字节红区)、§3.2.3 参数传递(整型 / 指针寄存器顺序与可变参数时的 al)、§3.5.7 可变参数列表(al 给出向量寄存器个数上限)、栈上传参布局。
[2] Intel 64 and IA-32 Architectures Software Developer’s Manual, Vol. 2A:PUSH / POP / CALL / RET / LEAVE 指令语义。
[3] Microsoft Learn:x64 calling convention(寄存器 rcx、rdx、r8、r9 与 32 字节 home space / shadow store)。
[4] 文集:《C/C++ 调用约定详解》(名字修饰、历史约定与 32 位 cdecl)。
[5] 文集:《指针的本质:用 x86-64 汇编看清地址、解引用》(在栈帧之上看指针与解引用)。
[6] Michael Kerrisk, The Linux Programming Interface, §6.5:栈的生长方向属硬件实现细节,HP PA-RISC 等实现采用向上生长的栈。
[7] GCC 13.3.0(x86_64-linux-gnu):-O0、-fno-stack-protector、-masm=intel;文中汇编据此产物节选。