Linux 函数调用与栈帧机制
从汇编与调试视角拆解函数调用过程、栈帧布局、参数传递、返回地址与 ABI 约定。
本文以一个最小 C 示例为起点,尽量贴近原文的讲解节奏,展开 Linux 下函数调用的栈帧变化、参数传递、返回地址保存、寄存器约定与调试观察方法,并补充一些实践建议。理解这些细节有助于你阅读反汇编、定位崩溃、分析性能热点,也能解释缓冲区溢出为何会改变控制流。
1. 示例代码
int bar(int c, int d)
{
int e = c + d;
return e;
}
int foo(int a, int b)
{
return bar(a, b);
}
int main(void)
{
foo(2, 3);
return 0;
}
建议编译时保留符号并关闭优化:
gcc -g -O0 -fno-omit-frame-pointer foo_bar.c -o a.out
这样用 objdump -dS 或 gdb 观察时,源代码和汇编更容易对应。
2. 函数调用的“核心三件事”
在 x86(32 位)平台上,典型函数调用遵循 cdecl 约定:
- 参数从右到左压栈。
call会把返回地址压栈,并跳转到被调函数入口。- 返回值通常放在
eax。
与调用配套的是函数的序言与收尾(prologue/epilogue):
push %ebp
mov %esp, %ebp
sub $0x10, %esp
它们的作用是:保存旧 ebp,建立新栈帧基址,为局部变量预留空间。函数结束时常见:
leave
ret
leave 相当于 mov %ebp, %esp + pop %ebp,恢复上一层栈帧。
3. 栈如何增长、栈帧如何布局
在 x86 上栈从高地址向低地址增长,esp 指向栈顶,ebp 指向当前栈帧底部。一个简化布局如下(高地址在上):
高地址
| 参数 b | <- ebp+12
| 参数 a | <- ebp+8
| 返回地址 | <- ebp+4
| 旧 ebp | <- ebp
| 局部变量 e | <- ebp-4
低地址(esp 向下增长)
关键点:
- 参数通过
ebp+偏移访问,局部变量通过ebp-偏移访问。 - 旧
ebp保存在栈上,因此栈帧之间形成链表,这也是 gdb 能回溯调用栈的原因。
4. 结合反汇编理解每一步
使用 objdump -dS a.out(节选示意):
080483f2 <foo>:
80483f2: 55 push %ebp
80483f3: 89 e5 mov %esp,%ebp
80483f5: 83 ec 08 sub $0x8,%esp
80483f8: 8b 45 0c mov 0xc(%ebp),%eax
80483fb: 89 44 24 04 mov %eax,0x4(%esp)
80483ff: 8b 45 08 mov 0x8(%ebp),%eax
8048402: 89 04 24 mov %eax,(%esp)
8048405: e8 d2 ff ff ff call 80483dc <bar>
804840a: c9 leave
804840b: c3 ret
它完整体现了:
foo建立栈帧;- 从
ebp+8与ebp+12取参数; - 再次压栈以调用
bar; call保存返回地址并跳转。
再看 bar(节选示意):
080483dc <bar>:
80483dc: 55 push %ebp
80483dd: 89 e5 mov %esp,%ebp
80483df: 83 ec 10 sub $0x10,%esp
80483e2: 8b 45 0c mov 0xc(%ebp),%eax
80483e5: 8b 55 08 mov 0x8(%ebp),%edx
80483e8: 01 d0 add %edx,%eax
80483ea: 89 45 fc mov %eax,-0x4(%ebp)
80483ed: 8b 45 fc mov -0x4(%ebp),%eax
80483f0: c9 leave
80483f1: c3 ret
可见:
- 参数
c和d由ebp+8、ebp+12取出; - 结果放到局部变量
e(ebp-4); - 返回值最终放入
eax。
4.1 更完整的 objdump 输出(节选)
下面展示更完整的一组函数反汇编片段,便于对照调用关系:
$ objdump -dS a.out
...
080483dc <bar>:
80483dc: 55 push %ebp
80483dd: 89 e5 mov %esp,%ebp
80483df: 83 ec 10 sub $0x10,%esp
80483e2: 8b 45 0c mov 0xc(%ebp),%eax
80483e5: 8b 55 08 mov 0x8(%ebp),%edx
80483e8: 01 d0 add %edx,%eax
80483ea: 89 45 fc mov %eax,-0x4(%ebp)
80483ed: 8b 45 fc mov -0x4(%ebp),%eax
80483f0: c9 leave
80483f1: c3 ret
080483f2 <foo>:
80483f2: 55 push %ebp
80483f3: 89 e5 mov %esp,%ebp
80483f5: 83 ec 08 sub $0x8,%esp
80483f8: 8b 45 0c mov 0xc(%ebp),%eax
80483fb: 89 44 24 04 mov %eax,0x4(%esp)
80483ff: 8b 45 08 mov 0x8(%ebp),%eax
8048402: 89 04 24 mov %eax,(%esp)
8048405: e8 d2 ff ff ff call 80483dc <bar>
804840a: c9 leave
804840b: c3 ret
0804840c <main>:
804840c: 55 push %ebp
804840d: 89 e5 mov %esp,%ebp
804840f: 83 ec 08 sub $0x8,%esp
8048412: c7 44 24 04 03 movl $0x3,0x4(%esp)
804841a: c7 04 24 02 00 movl $0x2,(%esp)
8048421: e8 cc ff ff ff call 80483f2 <foo>
8048426: b8 00 00 00 00 mov $0x0,%eax
804842b: c9 leave
804842c: c3 ret
...
5. gdb 里观察真实栈内容
进入 bar 执行到加法后,可以看到调用链和寄存器:
(gdb) bt
#0 bar (c=2, d=3)
#1 foo (a=2, b=3)
#2 main ()
(gdb) info registers
esp 0xbffff678
ebp 0xbffff688
eip 0x080483f0
再用 x/20x $esp 看栈内容(节选示意):
0xbffff678: 0x0804840a 0x00000002 0x00000003 0xbffff698
0xbffff688: 0xbffff6a8 0x08048426 0x00000002 0x00000003
可以解释为:
- 0x0804840a 是
bar返回到foo的地址; - 后面紧跟的是
foo的参数值; - 更高一层保存了
foo返回到main的地址,以及main的参数等信息。
5.1 更完整的 gdb 会话(节选)
下面是更完整的一段调试过程片段(输出会因环境不同而略有差异):
$ gdb a.out
(gdb) start
Temporary breakpoint 1 at 0x8048412: file foo_bar.c, line 20.
Starting program: ./a.out
Temporary breakpoint 1, main () at foo_bar.c:20
20 foo(2, 3);
(gdb) s
foo (a=2, b=3) at foo_bar.c:14
14 return bar(a, b);
(gdb) s
bar (c=2, d=3) at foo_bar.c:8
8 int e = c + d;
(gdb) disas
Dump of assembler code for function bar:
0x080483dc <+0>: push %ebp
0x080483dd <+1>: mov %esp,%ebp
0x080483df <+3>: sub $0x10,%esp
=> 0x080483e2 <+6>: mov 0xc(%ebp),%eax
0x080483e5 <+9>: mov 0x8(%ebp),%edx
0x080483e8 <+12>: add %edx,%eax
0x080483ea <+14>: mov %eax,-0x4(%ebp)
0x080483ed <+17>: mov -0x4(%ebp),%eax
0x080483f0 <+20>: leave
0x080483f1 <+21>: ret
End of assembler dump.
(gdb) bt
#0 bar (c=2, d=3) at foo_bar.c:9
#1 0x0804840a in foo (a=2, b=3) at foo_bar.c:14
#2 0x08048426 in main () at foo_bar.c:20
(gdb) info registers
eax 0x5
esp 0xbffff678
ebp 0xbffff688
eip 0x080483f0
(gdb) x/20x $esp
0xbffff678: 0x0804840a 0x00000002 0x00000003 0xbffff698
0xbffff688: 0xbffff6a8 0x08048426 0x00000002 0x00000003
...
5.2 栈内存图解(示意)
下面将上面的栈内容用“地址 + 含义”的方式画成简化图解(数值为示意):
高地址
0xbffff6a8 [0xbffff6b8] 保存的 ebp(main)
0xbffff6a4 [0x08048426] 返回地址 -> main
0xbffff6a0 [0x00000002] 参数 a(foo)
0xbffff69c [0x00000003] 参数 b(foo)
0xbffff698 [0xbffff6a8] 保存的 ebp(foo)
0xbffff694 [0x0804840a] 返回地址 -> foo
0xbffff690 [0x00000002] 参数 c(bar)
0xbffff68c [0x00000003] 参数 d(bar)
0xbffff688 [0x00000005] 局部变量 e
低地址(esp 向下增长)
在实际机器上,地址和值会因编译器、优化级别、栈对齐策略而有所不同,但整体布局关系是一致的。
6. 从 main 的视角看参数压栈
在 main 的调用段(示意):
8048412: c7 44 24 04 03 00 00 00 movl $0x3,0x4(%esp)
804841a: c7 04 24 02 00 00 00 movl $0x2,(%esp)
8048421: e8 cc ff ff ff call 80483f2 <foo>
这清楚显示参数是“从右到左”依次入栈,call 再压入返回地址。
7. 栈帧链与 backtrace 的原理
每个函数开始都会把旧 ebp 压栈,因此栈中形成:
[bar 的 ebp] -> [foo 的 ebp] -> [main 的 ebp] -> ...
bt 就是沿着这条链回溯。若编译器省略帧指针,回溯需要 DWARF 信息或启发式栈扫描,因此调试时常用 -fno-omit-frame-pointer。
8. main 的调用者是谁
main 并不是进程的“第一条指令”,它由运行库启动逻辑调用,例如 __libc_start_main。在 gdb 中可以看到 main 的返回地址指向 libc 内部代码,main 返回后会继续执行 exit 流程。这也解释了为什么 main 的 ret 会跳到一个看起来“陌生”的地址。
9. 调用约定(Calling Convention)
调用约定是 ABI 的一部分,决定参数传递、返回值位置、寄存器保存规则。以 32 位 cdecl 为例:
- 参数从右到左压栈;
- 返回值在
eax; - 调用者清理参数(
add $0xN, %esp)。
注意:这些规则不是硬件强制,而是操作系统与编译器约定的结果。不同平台与编译器可能不同。
10. x86-64 下的差异
64 位 SysV ABI 中:
- 前 6 个参数通过寄存器传递:
rdi, rsi, rdx, rcx, r8, r9。 - 返回值使用
rax。 - 栈仍保存返回地址和局部变量,但参数上栈明显减少。
- 存在 red zone:
rsp下方 128 字节可被叶子函数临时使用。
因此同样的 C 代码在 x86-64 下栈布局更“干净”。调试前先确认架构是必要步骤。
11. caller-saved vs callee-saved
ABI 规定哪些寄存器由调用者保存,哪些由被调用者保存:
- caller-saved:调用者若希望保留值,需要自己压栈。
- callee-saved:被调用者使用前必须保存,返回时恢复。
这会影响函数入口/退出时的 push/pop 数量,也决定了栈帧的大小。
12. 优化对栈帧的影响
优化会显著改变观察结果:
- 可能省略帧指针;
- 局部变量被寄存器替代;
- 尾调用优化会复用栈帧;
- 指令重排减少栈访问。
因此学习和排错时应尽量关闭优化,并保留帧指针。
13. 与安全的关系
栈上有返回地址和局部变量:
- 如果越界写覆盖返回地址,
ret就会跳到错误位置。 - 现代系统使用 canary、NX/DEP、ASLR、PIE 等降低可利用性。
- 但理解栈帧仍是溢出分析与 ROP 的基础。
14. 调试实践清单
- 编译时保留符号与帧指针。
- 用
bt确认调用链。 - 用
info registers确认esp/ebp/eip。 - 用
x/20x $esp对照栈上的返回地址和参数。 - 在
call前后用disas观察参数压栈和跳转细节。
FAQ
Q1:为什么要用 ebp 作为固定基址?
A:它提供稳定的参考点,便于用固定偏移访问参数与局部变量。优化时可能省略。
Q2:为什么栈向低地址增长?
A:历史与 ABI 设计选择,本质上是约定问题。
Q3:返回值一定在 eax/rax 吗?
A:整数/指针通常如此,但结构体返回可能用隐藏指针或多个寄存器。
Q4:为什么 gdb 的回溯有时不完整?
A:帧指针被省略、优化重排或缺乏调试信息会影响回溯。
Q5:尾调用优化如何影响栈?
A:尾调用会复用当前栈帧,减少一层调用记录。