共计 1457 个字符,预计需要花费 4 分钟才能阅读完成。
从段错误调试说起
上周排查一个崩溃问题时,GDB 给出了这样的回溯信息:

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7a3a in ?? ()
(gdb) bt
#0 0x00007ffff7a3a in ?? ()
#1 0x00005555555551a in foo(int) ()
#2 0x00005555555551e in main ()
这个残缺的调用栈告诉我们:程序在 foo() 函数内访问了非法内存。要准确定位问题,就必须理解函数调用时栈空间的运作机制。
栈帧:函数调用的舞台
每次函数调用都会在栈上分配一个栈帧(Stack Frame),包含以下关键部分:
+------------------+
| 参数 n | 高地址
| ... |
| 参数 1 |
+------------------+
| 返回地址 | ← 调用结束后跳转到这里
+------------------+
| 保存的 ebp | ← 当前栈帧基准
+------------------+
| 局部变量 1 |
| ... |
| 局部变量 n | 低地址
+------------------+ ← esp 始终指向栈顶
用寄存器来标记就是:
ebp(基址指针):固定指向当前栈帧的底部esp(栈指针):始终指向栈的顶部
调用约定的那些事儿
不同架构下参数传递方式各异,主要分为三类:
- cdecl(C 默认约定)
- 参数从右向左压栈
- 调用方负责清理栈
-
适合可变参数函数
-
stdcall(Win32 API 常用)
- 参数从右向左压栈
- 被调函数自己清栈
-
代码体积更小
-
fastcall(性能优先)
- 前两个参数通过寄存器传递
- 剩余参数压栈
- 需要编译器显式指定
看汇编如何传递参数
x86 架构示例
// 编译命令:clang -S -O0 -m32
int __attribute__((cdecl)) add(int a, int b) {return a + b;}
对应的关键汇编代码:
push ebp ; 保存旧帧指针
mov ebp, esp ; 建立新帧指针
mov eax, [ebp+8] ; 取第一个参数 a
add eax, [ebp+12] ; 加上第二个参数 b
pop ebp ; 恢复旧帧指针
ret ; 返回到调用处
ARM 架构差异
stp x29, x30, [sp, -16]! ; 保存帧指针和返回地址
mov x29, sp
add w0, w0, w1 ; 参数通过 w0/w1 寄存器传递
ldp x29, x30, [sp], 16 ; 恢复寄存器
ret
那些年踩过的坑
栈溢出经典案例
void recursive(int depth) {char buffer[1024]; // 每次递归消耗 1KB 栈空间
if (depth > 0) recursive(depth-1);
}
int main() {recursive(10000); // 瞬间爆栈
}
解决方法:改用迭代或动态分配内存
可变参数的风险
void risky_print(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
// 如果传入参数少于占位符...
vprintf(fmt, args); // 可能导致读取非法内存
va_end(args);
}
防御措施:始终使用类型安全的替代方案
进阶思考
- 在链接脚本中设置
__stack_chk_guard可以检测栈破坏 - 虚函数调用需要额外查虚表,通常多 2 - 3 条指令
调试工具推荐
layout asm // 显示汇编窗口
watch $esp // 监控栈指针
info registers // 查看所有寄存器
x/20xg $esp // 以 16 进制检查栈内容
理解这些底层机制后,下次再遇到神秘的段错误,你就能像侦探一样顺着调用栈找到真凶了!
正文完
