很多函数调用文章仍沿用 32 位 x86 的图:参数从右向左压栈,ebp+8 是第一个参数。这套描述不能直接解释今天常见的 Linux x86-64 程序。按照 SysV AMD64 ABI,前六个整数或指针参数优先通过寄存器传递,rbp 也不一定充当帧指针。

本文只讨论 Linux 用户态的 SysV AMD64 ABI,不延伸到 32 位 cdecl、Windows x64 ABI 或 Linux 系统调用 ABI。文章围绕四个可验证的问题展开:参数从哪里进入函数、call 保存了什么、GDB 如何恢复调用栈、优化为何会让源码变量和栈帧“消失”。

1. 准备一个可复现程序

保存为 stack_demo.c:

#include <stdio.h>

__attribute__((noinline))
long add(long left, long right)
{
    long result = left + right;
    return result;
}

__attribute__((noinline))
long calculate(long input)
{
    long doubled = input * 2;
    return add(doubled, 7);
}

int main(void)
{
    volatile long input = 5;
    printf("%ld\n", calculate(input));
    return 0;
}

先构建一个便于观察的版本:

gcc -std=c11 -Wall -Wextra -g3 -O0 \
  -fno-omit-frame-pointer -fno-pie -no-pie \
  stack_demo.c -o stack_demo

./stack_demo
# 17

-O0 减少优化改写,-fno-omit-frame-pointer 尽量保留帧指针,-fno-pie -no-pie 让教学阶段的代码地址更直观。它们不是生产环境的加固参数,尤其不要因为调试方便就在正式构建中关闭 PIE。

2. SysV AMD64 怎样传递参数

对普通整数和指针参数,常用规则如下:

参数或结果 位置
第 1 个参数 rdi
第 2 个参数 rsi
第 3 个参数 rdx
第 4 个参数 rcx
第 5 个参数 r8
第 6 个参数 r9
第 7 个及以后 栈上的参数区
整数返回值 rax

因此,调用 add(doubled, 7) 时,doubled 进入 rdi,7 进入 rsi。编译器可能为了调试或寄存器分配,把这些值再保存到当前栈帧,但这属于生成代码的选择,不是 ABI 要求“所有参数先压栈”。

寄存器还分为两类:

  • Caller-saved:rax、rcx、rdx、rsi、rdi、r8-r11。调用者若需要跨调用保留其值,应自行保存。
  • Callee-saved:rbx、rbp、r12-r15。被调用函数若修改这些寄存器,应在返回前恢复。

栈对齐同样属于 ABI。执行 call 前,rsp 通常需要 16 字节对齐;call 压入 8 字节返回地址后,被调用函数入口处满足 (rsp + 8) % 16 == 0。常见的 push rbp 会再次让 rsp 回到 16 字节边界,方便函数继续调用其他函数。

3. call、ret 与栈帧分别做什么

在本例中,calculate 调用 add 时发生两步:

  1. call add 把下一条指令的地址压入栈顶,作为返回地址。
  2. 处理器把执行位置切换到 add。

add 执行完后,ret 从栈顶取出该地址并恢复到指令指针。call 和 ret 本身不负责保存局部变量,也不会自动保存所有寄存器;这些工作由编译器按照 ABI 和当前函数需求生成。

在保留 rbp 的未优化构建中,常见序言是:

push rbp
mov  rbp, rsp
sub  rsp, 0x10

此时可以把栈帧简化为:

高地址
+----------------------+
| 调用者的栈帧         |
+----------------------+
| 返回地址             |  [rbp + 8]
+----------------------+
| 调用者保存的 rbp     |  [rbp]
+----------------------+
| 局部变量、临时值     |  [rbp - offset]
+----------------------+  <- rsp
低地址

这个图只适用于当前编译结果。优化后,函数可能不用 rbp、不分配栈空间,甚至直接使用 ABI 规定的 128 字节 red zone。把示意图当作固定内存布局,往往是调试误判的起点。

4. 用反汇编跟一遍调用过程

分别查看两个函数:

objdump -d -M intel --disassemble=calculate stack_demo
objdump -d -M intel --disassemble=add stack_demo

不同 GCC 版本、发行版加固选项和补丁会改变具体指令。一个典型的 calculate 片段可能接近下面这样:

calculate:
    push   rbp
    mov    rbp, rsp
    sub    rsp, 0x10
    mov    QWORD PTR [rbp-0x8], rdi
    mov    rax, QWORD PTR [rbp-0x8]
    add    rax, rax
    mov    QWORD PTR [rbp-0x10], rax
    mov    rax, QWORD PTR [rbp-0x10]
    mov    esi, 0x7
    mov    rdi, rax
    call   add
    leave
    ret

关注数据流,不要死记偏移:

  • 进入 calculate 时,input 已在 rdi。
  • input * 2 的结果最终放入 rdi,成为 add 的第一个参数。
  • 7 放入 rsi,成为第二个参数。
  • call add 把返回地址写入栈顶。
  • add 的返回值经 rax 回到 calculate,再返回给 main。

如果反汇编里出现 endbr64、栈保护检查、不同的局部变量偏移或 pop rbp 而不是 leave,不代表程序异常。先记录完整编译命令,再解释结果。

5. 用 GDB 证明寄存器和返回地址

启动 GDB,并在两个函数的第一条指令处断住:

gdb -q ./stack_demo
set disassembly-flavor intel
set pagination off
break *calculate
break *add
run

第一次停在 calculate 入口时:

info registers rip rsp rbp rdi
x/i $rip
x/gx $rsp

应观察到:

  • rdi 的值是 5,它就是 calculate(input) 的第一个参数。
  • $rsp 指向 calculate 返回 main 时要使用的地址。
  • 此时函数序言尚未完成,不能假定 rbp 已指向 calculate 的栈帧。

使用 display/i $pc 和 stepi 逐条执行,直到走过 mov rbp, rsp,再查看:

info registers rsp rbp
x/2gx $rbp
info frame

在这一版构建中,[$rbp] 是保存的旧 rbp,[$rbp+8] 是返回地址。继续运行到 add:

continue
info registers rdi rsi rsp
x/gx $rsp
bt
finish
p/d $rax

这里应看到 rdi=10、rsi=7,而 finish 返回后 rax=17。具体地址每次运行都可能不同;验证重点是寄存器值和相对关系,不是截图中的某个固定十六进制地址。

6. backtrace 不只是沿着 rbp 链走

保留帧指针时,保存的 rbp 确实能形成易于理解的链。但现代 GDB 还会结合 DWARF 调试信息、调用帧信息(CFI)和机器指令恢复调用者状态。因此下面两句话都不准确:

  • “没有 rbp 就一定不能回溯。”
  • “能执行 bt 就证明物理栈帧完整。”

常用命令:

bt
bt full
frame 1
info args
info locals
thread apply all bt full

回溯仍可能失败或不完整:

  • 栈内存或返回地址已经损坏。
  • 二进制被 strip,且没有对应的独立调试符号。
  • 线上二进制、共享库与调试机上的文件不是同一个 build。
  • JIT、手写汇编或第三方库缺少正确的 unwind 信息。
  • 优化产生内联、尾调用或 <optimized out>。

调试 core dump 时,先核对可执行文件、共享库、build ID 和符号包,再讨论某一帧为什么缺失。错误的二进制也能给出“看起来合理”的函数名和行号。

7. 优化会怎样改变观察结果

再构建一个优化版本:

gcc -std=c11 -Wall -Wextra -g3 -O2 \
  stack_demo.c -o stack_demo_O2

objdump -d -M intel --disassemble=calculate stack_demo_O2

可能出现的变化包括:

  • 局部变量只存在于寄存器,不再有固定栈槽。
  • calculate 通过尾调用直接跳转到 add,当前栈帧不再保留。
  • 编译器省略 rbp 帧指针,改用 rsp 和 unwind 信息。
  • 表达式被合并,源码中的多行对应同一段指令。
  • GDB 显示参数或局部变量为 <optimized out>。

-fno-omit-frame-pointer 能提高部分性能分析和故障回溯的稳定性,但 GCC 也明确说明,它不保证所有目标、所有函数都会保留帧指针。生产环境是否启用,应结合采样器、CPU 开销和现有 unwind 能力评估,不能把它当成修复所有回溯问题的开关。

8. PIE 程序怎样定位崩溃地址

先确认 ELF 类型:

readelf -hW ./stack_demo | grep 'Type:'
readelf -hW ./stack_demo_O2 | grep 'Type:'

教学版使用 -no-pie,通常显示 EXEC;发行版默认构建经常显示 DYN,表示 PIE。对于正在运行的程序或 core dump,让 GDB 使用已经加载的模块信息最可靠:

info files
info sharedlibrary
info proc mappings
info symbol $pc
list *$pc

如果只有日志里的运行时地址,需要先确定它属于哪个模块,再减去该模块的加载基址,得到模块相对地址:

addr2line -e ./your-app -f -C -i 0x<module-relative-address>

共享库地址必须交给对应的 .so 和匹配符号处理,不能全部拿主程序做 addr2line。若怀疑加载了错误版本的库,可在可信程序上使用 LD_DEBUG=libs 查看动态加载决策;不要为了排查一个符号问题,直接运行来源不明的二进制。

9. 常见误判

误判 更准确的判断方式
x86-64 参数都在栈上 先看 ABI 寄存器;栈中副本可能只是 spill。
rbp 永远是帧指针 查看反汇编和 unwind 信息,优化时可能省略。
bt 就是在遍历 rbp 链 GDB 还会使用 DWARF、CFI 和指令分析。
main 是进程执行的第一段代码 _start 和 C 运行库先完成初始化,再调用 main。
同一份 C 源码必然得到同一段汇编 编译器版本、参数、目标 CPU 和加固配置都会改变结果。
有函数名就说明符号匹配 核对 build ID、二进制和共享库版本。

10. 栈布局与安全边界

返回地址通常位于当前函数栈数据的高地址一侧,因此越界写可能破坏控制数据。但知道布局不等于应该依赖布局:优化、对齐、栈保护、ASLR、PIE 和控制流保护都会改变实际结果。

测试阶段优先让越界尽早暴露:

gcc -std=c11 -Wall -Wextra -g3 -O1 \
  -fsanitize=address,undefined -fno-omit-frame-pointer \
  stack_demo.c -o stack_demo_asan

生产构建再按工具链和发行版基线启用加固,例如:

gcc -O2 -g -fstack-protector-strong -D_FORTIFY_SOURCE=2 \
  -fPIE -pie -Wl,-z,relro,-z,now \
  stack_demo.c -o stack_demo_hardened

这些选项用于纵深防御,不能替代边界检查、长度验证和内存安全设计。Sanitizer 适合测试与预发布环境,通常不直接作为生产二进制配置。

11. 排查函数调用问题的顺序

  1. 记录架构、ABI、编译器版本和完整编译参数。
  2. 保留未 strip 的对应二进制、共享库、build ID 与调试符号。
  3. 用 objdump 看真实指令,不从源码猜参数位置。
  4. 用 GDB 在函数入口检查参数寄存器、rsp 和返回地址。
  5. 同时比较 -O0 与生产优化级别,确认内联、尾调用和帧指针变化。
  6. PIE 或共享库地址先转成模块相对地址,再交给 addr2line。
  7. 回溯异常时先排除版本不匹配和栈损坏,再怀疑 GDB。

理解函数调用最有效的方法,是把 ABI、反汇编和运行时状态放在一起验证。只背一张栈帧图,遇到优化后的 x86-64 程序很快就会失效。

参考链接