函数调用时,栈和寄存器发生了什么?
函数调用就是:调用者准备参数和返回地址,被调用函数建立自己的工作空间,计算完成后恢复现场并返回。
但具体的规则不是由C语言规定的,而是由平台的ABI(应用二进制接口)规定的。
下面主要以x86-64 Linux/macOS 常见的 System V ABI 为例。
1. 从一个简单函数开始
int add(int a, int b) {
int result = a + b;
return result;
}
int main(void) {
int x = 10;
int y = 20;
int z = add(x, y);
return z;
}
执行z = add(x,y); 时,大致经过以下过程:
main准备参数
↓
保存返回位置
↓
跳转到add
↓
add建立自己的栈帧
↓
执行加法
↓
把结果放入返回值寄存器
↓
恢复栈和寄存器
↓
返回main
2. 什么是调用者和被调用者?
在 z = add(x,y);中,main是调用者,英文是caller;add是被调用者,英文是callee。
双方必须遵守一套约定:
- 参数放在哪里;
- 返回值放在哪里;
- 哪些寄存器可以随便改;
- 哪些寄存器必须恢复;
- 栈如何对齐;
- 返回地址保存在哪里。
这套约定就是调用约定。
3. 调用前:参数先放入寄存器
在x86-64 System V调用约定中,前六个整数或指针参数通常依次放入:
第1个参数 → RDI
第2个参数 → RSI
第3个参数 → RDX
第4个参数 → RCX
第5个参数 → R8
第6个参数 → R9
因此,add(10,20);在调用前可能类似:
mov edi, 10
mov esi, 20
call add
这里使用的是32位子寄存器:
EDI 是 RDI 的低32位
ESI 是 RSI 的低32位
所以,进入add时:
EDI = 10
ESI = 20
函数参数a和b在机器层面不一定会立刻存入栈中,它们可能一直保存在寄存器里。
4. 参数超过寄存器数量怎么办?
例如,func(a,b,c,d,e,f,g,h);,前六个整数参数通常进入寄存器:
a → RDI
b → RSI
c → RDX
d → RCX
e → R8
f → R9
剩余参数通常通过栈传递:
g → 栈
h → 栈
因此,现代64位系统中的函数调用并不是“所有参数都压栈”。更准确地说:
优先使用寄存器,寄存器不够时再使用栈。
值得一提的是,浮点参数通常使用另一组寄存器,例如:XMM0、XMM1、XMM2....
5. 执行call时发生了什么?
假设当前正在执行call add
在x86处理器中,call大致完成两件事:
5.1 保存返回地址
CPU需要记住一个东西,那就是:
add执行完以后,应该回到哪里继续执行?
所以它会把call后面那条指令地地址压入栈中。
假设:
call add 位于地址 0x1000
下一条指令位于地址 0x1005
那么返回地址就是:0x1005
栈中就会保存:返回地址,0x1005。
5.2 跳转到被调用函数
CPU把指针指令RIP改为add函数的地址。
之后CPU开始执行add的机器指令。
因此可以把call简化理解为:
push 下一条指令地址
jump 函数地址
6. 栈是什么?
栈时一块“按照后进先出”方式使用的内存区域。
可以想象成一摞盘子:最后放上去的盘子,会最先被拿下来。
程序栈通常从高地址向低地址增长。
假设开始时:RSP = 0x8000
压入一个8字节数据后:RSP = 0x7FF8
内存可能变成:
高地址
0x8000
0x7FF8 返回地址
← RSP
低地址
其中,RSP是栈指针,它指向当前栈顶,压栈时,RSP通常增大;出栈时,RSP通常减小。
7. 进入函数后:建立栈帧
被调用函数可能需要自己的工作空间,用来保存:
- 局部变量;
- 临时计算结果;
- 需要保护的寄存器;
- 无法全部放进寄存器的数据;
- 调用其他函数时需要保存的信息。
这块空间通常称为:栈帧(Stack Frame) 。
未经优化时,一个函数开头可能出现:
push rbp
mov rbp, rsp
sub rsp, 16
这几条指令通常称为函数序言。
7.1 第一步:保存旧的RBP
push rbq,因为当前函数可能需要使用RBP,所以先保存调用者的RBP。
栈大致变成:
高地址
┌─────────────────┐
│ 返回地址 │
├─────────────────┤
│ 调用者原来的RBP │ ← RSP
└─────────────────┘
低地址
7.2 第二步:建立当前栈帧的基准点
mov rbp, rsp,此时,RBP = RSP。
RBP可以作为当前栈帧的固定基准地址。
虽然RSP可能不断变化,但RBP通常保持不变。
于是局部变量可以通过固定偏移访问:
[rbp - 4] → 某个局部变量
[rbp - 8] → 另一个局部变量
[rbp + 8] → 返回地址
实际偏移会因平台和代码而不同。
7.3 第三步:为局部变量分配空间
sub rsp,16
这不是立即向操作系统申请16字节,而只是把栈顶向低地址移动16字节
RSP = RSP - 16
这16字节就成为了当前函数可以使用的栈空间。
栈帧此时可能会变成:
高地址
┌────────────────────┐
│ 调用者的栈帧 │
├────────────────────┤
│ 返回地址 │
├────────────────────┤
│ 调用者原来的RBP │ ← RBP
├────────────────────┤
│ 局部变量a的副本 │
├────────────────────┤
│ 局部变量b的副本 │
├────────────────────┤
│ result │
├────────────────────┤
│ 对齐或临时空间 │ ← RSP
└────────────────────┘
低地址
8. 参数为什么有时会从寄存器复制到栈?
在进入函数时:
a 在 EDI
b 在 ESI
未经优化的代码可能先把它们保存到栈中:
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
对应:
[rbp-4] = a
[rbp-8] = b
然后计算:
mov eax, DWORD PTR [rbp-4]
add eax, DWORD PTR [rbp-8]
这样比较容易调试,但是效率不一定时最高的。
开启优化后,编译器可能完全不回使用栈:
add:
lea eax, [rdi + rsi]
ret
此时,a始终在EDI,b始终在ESI,结果直接放在EAX,没有为result分配实际内存,也没有建立传统栈帧。
所以:
源代码中存在局部变量,不代表运行时一定会在栈中为它分配位置。
9. 寄存器为什么需要保存?
因为CPU寄存器数量有限。
假设 main 正在使用某个寄存器:RBX = main的重要数据。
然后调用add。
如果add修改RBX,返回后main原来的数据就丢失了。
为了避免这种情况的出现,调用约定把寄存器分为了两类。
9.1 调用者保存寄存器
英文名:caller-saved registers
这类寄存器允许被调用函数随意修改。
如果调用者还需要其中的数据,就必须在调用前自己保存。
在System V x86-64中,常见调用者保存寄存器包括:
RAX
RCX
RDX
RSI
RDI
R8
R9
R10
R11
例如:
main在RDX中保存了重要数据
main准备调用add
因为RDX可能被add改掉,main需要先在栈中保存它:
push rdx
call add
pop rdx
实际编译器也可能将它保存到其他寄存器或空间中。
9.2 被调用者保存寄存器
英文名:callee-saved registers
如果被调用者函数要使用这些寄存器,它必须:
- 先保存原值;
- 使用寄存器;
- 返回前恢复原值。
在System V x86-64中,常见被调用者保存寄存器包括:
RBX
RBP
R12
R13
R14
R15
例如:
add:
push rbx
mov rbx, ...
...
pop rbx
ret
这样返回以后,调用者看到的RBX和调用前保持一直。
9.3 把寄存器想象成办公室的桌面
调用者保存寄存器,属于“公共草稿区”,被叫来的人可以随便使用,原主人若还需要内容,就必须提前收好。
被调用者保存寄存器,属于“固定档案区”,被叫来的人可以使用,但离开前必须恢复原状。
10. 函数返回值放在哪里?
对于普通整数和指针,返回值通常放入:RAX。
如果返回的时32位数,则通常使用:EAX。
例如:return a+b;
对应的核心操作可能是:
mov eax, edi
add eax, esi
执行完成后:EAX=30
调用者从EAX中取得结果:
z= add(10,20);
在机器层面可能类似:
调用add
add返回后,EAX中是30
将EAX中的值作为z
浮点返回值通常放在:XMM0。
较大的结构体返回值可能需要通过内存间接返回,规则会更复杂。
11. 函数返回时:销毁栈帧
未经优化的函数结尾可能是:
mov rsp, rbp
pop rbp
ret
也可能写成
leave
ret
其中leave大致等价于:
mov rsp, rbp
pop rbp
11.1 第一步:释放局部变量空间
mov rsp, rbp,把RSP恢复到建立栈帧时的位置。
这样,之前通过:sub rsp,16分配的局部空间就失效了。
注意,此时这些内存中的二进制可能暂时还存在,只是程序不再认为它属于当前函数。
11.2 第二步:恢复调用者的RBP
pop rbp,将栈中保存的旧RBP恢复。
11.3 第三步:执行ret
ret 从栈顶取出返回地址,并放入指令指针RIP。
可以简化为:
RIP = 栈顶的返回地址
RSP = RSP + 8
CPU随后从返回地址继续执行,也就是回到call后面的那条指令。
因此:
call:压入返回地址并跳转
ret:弹出返回地址并跳回
12. 简单总结
调用前:
main正在执行
RDI、RSI准备参数
RSP指向main的栈顶
执行:
call add
之后:
栈
高地址
┌─────────────────────┐
│ main的局部变量 │
├─────────────────────┤
│ 返回地址 │ ← RSP
└─────────────────────┘
低地址
add 建立栈帧后:
高地址
┌─────────────────────┐
│ main的局部变量 │
├─────────────────────┤
│ 返回地址 │
├─────────────────────┤
│ main原来的RBP │ ← RBP
├─────────────────────┤
│ add的局部变量 │
├─────────────────────┤
│ 临时数据 │ ← RSP
└─────────────────────┘
低地址
add 返回后:
add的栈帧被释放
RBP恢复
返回地址被弹出
RIP回到main
EAX保存返回值
13. 嵌套调用时会发生什么?
例如:
void a(void) {
b();
}
void b(void) {
c();
}
调用顺序:main → a → b → c
此时,栈中会形成多个栈帧:
高地址
┌────────────────────┐
│ main的栈帧 │
├────────────────────┤
│ a的返回地址 │
├────────────────────┤
│ a的栈帧 │
├────────────────────┤
│ b的返回地址 │
├────────────────────┤
│ b的栈帧 │
├────────────────────┤
│ c的返回地址 │
├────────────────────┤
│ c的栈帧 │ ← RSP
└────────────────────┘
低地址
返回时顺序相反:
c返回
↓
b返回
↓
a返回
↓
main继续
这就是后进先出。
14. 递归为什么容易导致栈溢出?
例如:
void recurse(void) {
recurse();
}
每调用一次,通常都会增加一个新的栈帧:
recurse第1层
recurse第2层
recurse第3层
recurse第4层
……
而栈的空间是有限的。
在调用层数过多后,RSP会进入不允许访问的区域,从而发生:stack overflow。
这就是栈溢出。
递归不是“函数在调用自己原来的那一份局部变量”,而是:每一层调用通常拥有自己独立的参数、局部变量和返回地址。
15. 为什么局部变量返回后不能继续使用?
例如:
int *bad(void) {
int x = 10;
return &x;
}
调用 bad 时,x 可能位于它的栈帧中:
bad的栈帧
┌──────────────┐
│ x = 10 │
└──────────────┘
函数返回后,栈帧被释放。
&x 指向的位置可能很快被下一个函数调用覆盖。
因此返回局部变量地址会产生悬空指针:
指针仍然保存旧地址
但该地址已经不再属于x
16. 优化后可能根本没有这些步骤
编译器可能进行:
- 省略帧指针;
- 将局部变量全部放入寄存器;
- 函数内联;
- 尾调用优化;
- 删除整个函数调用;
- 直接预先计算结果。
例如:
int add(int a, int b) {
return a + b;
}
int main(void) {
return add(10, 20);
}
优化后可能直接变成:
main:
mov eax, 30
ret
此时:
- 没有给
add建立栈帧; - 没有传递参数;
- 没有真正执行
call add; add甚至可能不存在于最终程序中。
所以前面描述的是典型调用机制,不是每次调用都必然出现的固定指令序列。
17. Windows x64与Linux x64的差异
Windows x64通常使用:
第1个整数参数 → RCX
第2个整数参数 → RDX
第3个整数参数 → R8
第4个整数参数 → R9
而System V x86-64通常使用:
第1个整数参数 → RDI
第2个整数参数 → RSI
第3个整数参数 → RDX
第4个整数参数 → RCX
第5个整数参数 → R8
第6个整数参数 → R9
Windows x64调用者通常还会在栈上预留一块空间,称为:
shadow space
所以同一段C代码在不同操作系统下,可能生成不同的参数传递与栈操作指令。
但核心思想相同:
准备参数
保存返回位置
转移控制权
建立工作空间
计算
保存返回值
恢复现场
返回