CFN Cloud
Cloud Future New Life
en zh
2026-01-09 · 0 次浏览

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 约定:

  1. 参数从右到左压栈。
  2. call 会把返回地址压栈,并跳转到被调函数入口。
  3. 返回值通常放在 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+8ebp+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

可见:

  • 参数 cdebp+8ebp+12 取出;
  • 结果放到局部变量 eebp-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 流程。这也解释了为什么 mainret 会跳到一个看起来“陌生”的地址。

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 zonersp 下方 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. 调试实践清单

  1. 编译时保留符号与帧指针。
  2. bt 确认调用链。
  3. info registers 确认 esp/ebp/eip
  4. x/20x $esp 对照栈上的返回地址和参数。
  5. call 前后用 disas 观察参数压栈和跳转细节。

FAQ

Q1:为什么要用 ebp 作为固定基址?
A:它提供稳定的参考点,便于用固定偏移访问参数与局部变量。优化时可能省略。

Q2:为什么栈向低地址增长?
A:历史与 ABI 设计选择,本质上是约定问题。

Q3:返回值一定在 eax/rax 吗?
A:整数/指针通常如此,但结构体返回可能用隐藏指针或多个寄存器。

Q4:为什么 gdb 的回溯有时不完整?
A:帧指针被省略、优化重排或缺乏调试信息会影响回溯。

Q5:尾调用优化如何影响栈?
A:尾调用会复用当前栈帧,减少一层调用记录。

参考链接