Linux x86-64 函数调用:栈帧、ABI 与 GDB 验证
用可复现的 C 程序和 GDB 实验拆解 SysV AMD64 参数传递、call/ret、栈帧、回溯、PIE 地址定位与优化影响。
很多函数调用文章仍沿用 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 时发生两步:
call add把下一条指令的地址压入栈顶,作为返回地址。- 处理器把执行位置切换到
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. 排查函数调用问题的顺序
- 记录架构、ABI、编译器版本和完整编译参数。
- 保留未 strip 的对应二进制、共享库、build ID 与调试符号。
- 用
objdump看真实指令,不从源码猜参数位置。 - 用 GDB 在函数入口检查参数寄存器、
rsp和返回地址。 - 同时比较
-O0与生产优化级别,确认内联、尾调用和帧指针变化。 - PIE 或共享库地址先转成模块相对地址,再交给
addr2line。 - 回溯异常时先排除版本不匹配和栈损坏,再怀疑 GDB。
理解函数调用最有效的方法,是把 ABI、反汇编和运行时状态放在一起验证。只背一张栈帧图,遇到优化后的 x86-64 程序很快就会失效。