函数调用时,栈和寄存器发生了什么?

0 阅读11分钟

函数调用时,栈和寄存器发生了什么?

函数调用就是:调用者准备参数和返回地址,被调用函数建立自己的工作空间,计算完成后恢复现场并返回。

但具体的规则不是由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

双方必须遵守一套约定:

  1. 参数放在哪里;
  2. 返回值放在哪里;
  3. 哪些寄存器可以随便改;
  4. 哪些寄存器必须恢复;
  5. 栈如何对齐;
  6. 返回地址保存在哪里。

这套约定就是调用约定。

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. 进入函数后:建立栈帧

被调用函数可能需要自己的工作空间,用来保存:

  1. 局部变量;
  2. 临时计算结果;
  3. 需要保护的寄存器;
  4. 无法全部放进寄存器的数据;
  5. 调用其他函数时需要保存的信息。

这块空间通常称为:栈帧(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

如果被调用者函数要使用这些寄存器,它必须:

  1. 先保存原值;
  2. 使用寄存器;
  3. 返回前恢复原值。

在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代码在不同操作系统下,可能生成不同的参数传递与栈操作指令。

但核心思想相同:

准备参数
保存返回位置
转移控制权
建立工作空间
计算
保存返回值
恢复现场
返回