函数调用与栈帧
引言
cpp
int add(int a, int b) {
int c = a + b;
return c;
}
int main() {
int x = add(1, 2);
}
执行main时,CPU如何执行add?
- 跳到
add的地址去执行 - 需要传递给
add的参数 add执行完毕后结果返回值需要传递给main- 需要返回到
main中调用的地方,且main依旧保持原样
栈与栈帧
栈是一个后进先出的内存区域。同时在linux中,栈向低地址增长(这是 ABI/实现选择)。
为什么选用栈进行函数调用?
栈本身便是后进先出,天然满足保持
add执行后,main保持原样的要求。
栈帧是对函数调用时产生的栈空间的定义。
plain
┌─────────────────────┐ ← 高地址
│ 调用者传入的参数。 │ (可能有)
├─────────────────────┤
│ 返回地址 │ (必须有)
├─────────────────────┤
│ 保存的帧指针 │ (可选)
├─────────────────────┤
│ 保存的被调者寄存器 │ (按需)
├─────────────────────┤
│ 局部变量, 临时空间 │ (按需)
├─────────────────────┤
│ 对齐填充 │ (可能有)
└─────────────────────┘ ← 低地址
汇编代码
让我们看看计算机底层汇编是如何实现上述操作的。
cpp
add(int, int):
push rbp // 压栈
mov rbp, rsp // rbp = rsp
sub rsp, 32 // 给局部变量/对齐留空间
mov DWORD PTR [rbp-20], edi // 将 rbp - 20 的空间存放参数
mov DWORD PTR [rbp-24], esi
mov edx, DWORD PTR [rbp-20] // edx = edi
mov eax, DWORD PTR [rbp-24] // eax = esi
add eax, edx // eax = eax + edx
mov DWORD PTR [rbp-4], eax // rbp - 4 的空间存放 eax
mov eax, DWORD PTR [rbp-4] // eax 等于 rbp - 4 中的值
pop rbp // 弹出 rbp
ret
main:
push rbp // 将 rbp 压入栈中
mov rbp, rsp // rbp = rsp
sub rsp, 16 // 向下开辟 16 字节空间,用于对齐
mov esi, 2 // 传递参数
mov edi, 1 // 传递参数
call add(int, int) // 调用函数
mov DWORD PTR [rbp-4], eax // rbp - 4 的空间存放 eax
mov eax, 0 // eax = 0
leave
ret
调用者传入参数
前 6 个整数/指针参数:走寄存器
Linux x86-64(SysV ABI)约定:
- 第 1 个参数 →
rdi - 第 2 个参数 →
rsi - 第 3 个参数 →
rdx - 第 4 个参数 →
rcx - 第 5 个参数 →
r8 - 第 6 个参数 →
r9
超过 6 个的,才会继续压入到栈上。
所以才有
cpp
mov esi, 2 // 传递参数
mov edi, 1 // 传递参数
寄存器也分32与64位。esi是32位的,rsi是64位的。edi,rdi同理
下面是寄存器用法:

返回地址
call add 做两件事
- 把"下一条指令的地址"压入栈 ------ 这就是 返回地址
- 跳转到
add的入口地址执行
也就是说:call = push 返回地址 + 跳转
ret 做一件事
- 从栈顶弹出一个地址,跳转到那里继续执行
也就是说:ret = pop 返回地址 + 跳回去
所以返回地址由被调用者保存。
保存的帧指针
cpp
push rbp // 压栈
mov rbp, rsp // rbp = rsp
// ...
pop rbp
为什么要保存 rbp
- 调用约定要求 :
rbp通常属于 callee-saved(被调用者若使用必须恢复) - 要形成栈帧链:调试/回溯时可以沿着"保存的 rbp"一级一级找到上层函数
push rbp 是一个"压栈"指令,本质等价于两步:
rsp = rsp - 8[rsp] = rbp(把当前rbp的 8 字节值写到栈顶空间)
pop rbp 是"出栈"指令,本质等价于两步:
rbp = [rsp](把栈顶 8 字节读到 rbp)rsp = rsp + 8
通过push和pop可以
- 将
rbp恢复为进入函数前的旧值 - 栈顶回到原来的位置(向高地址移动 8 字节)
局部变量 / 临时空间
cpp
mov DWORD PTR [rbp-20], edi // 将 rbp - 20 的空间存放参数
mov DWORD PTR [rbp-24], esi
// ...
mov DWORD PTR [rbp-4], eax // rbp - 4 的空间存放 eax
本质上就是通过rbp的偏移,去进行存储。
要求偏移空间是合法的。
- 局部变量区:服务于"源代码语义",写了这个变量,它应该在函数期间存在
- 临时空间:服务于"编译器实现",没写,但机器需要。
对齐填充
cpp
sub rsp, 16 // 向下开辟 16 字节空间,用于对齐
对齐,一个地址如果能被某个数整除,就说它按那个数对齐。例如:
- 地址
0x...10能被 16 整除 → 16 字节对齐 - 地址
0x...18不能被 16 整除(余 8) → 非 16 字节对齐
对齐填充(padding),当你需要某个对齐,但当前位置不满足时,就在中间"多留出一些没用的字节",把后面的内容挪到满足对齐的位置。在栈帧里,这种"没用的字节"就是对齐填充。
在 x86-64 linux(ABI) 里,调用约定要求:
在执行
call指令之前,栈指针rsp需要满足特定对齐(常见要求是 16 字节对齐)。
原因主要有三类:
- ABI 要求
- 性能提升,对齐的数据更利于 CPU 以更少的内存事务读写
- 可变参数、调用链一致性,保证跨编译器、跨语言(C/C++/Rust 等)互调时,栈布局一致且可预期。
返回值
这里有几种情况:
4字节:当函数返回值是4个字节会通过%eax寄存器作为通道,函数将返回值存储在%eax中,返回后函数的调用方再读取%eax。
5-8个字节:通过rax寄存器作为通道。
大于8个字节:以如下代码举例:
cpp
struct A {// ...大于8字节 };
A func() {
A b;
return b;
}
A x = func();
- 调用者分配返回对象空间(temp);
- 把这块空间的地址作为隐藏的第一个参数传给被调用者;
- 被调用函数把结果写入该地址指向的内存。
- 调用者得到这块内存中的结果。
返回值传递方式如图:

参考文章:
https://chengxumiaodaren.com/docs/build-debug/linux-func-call/