共计 1502 个字符,预计需要花费 4 分钟才能阅读完成。
从段错误开始的思考
最近调试一个 C ++ 项目时遇到诡异的段错误,GDB 显示崩溃点在某个看似无害的函数返回时:

Program received signal SIGSEGV, Segmentation fault.
0x08048456 in foo (param=5) at demo.cpp:8
8 }
(gdb) bt
#0 0x08048456 in foo (param=5) at demo.cpp:8
#1 0x41414141 in ?? ()
注意到返回地址变成了 0x41414141(即 ’A’ 的 ASCII 码),这明显是栈被破坏的典型表现。这促使我去深入了解函数调用的底层机制。
栈帧结构:函数调用的舞台
每个函数调用都会在栈上创建一个栈帧(Stack Frame),x86 架构下关键寄存器作用:
- ESP:栈指针,始终指向栈顶
- EBP:基址指针,标记当前栈帧起始位置
典型的栈帧结构如下(小端序):
高地址
+----------------+
| 调用者 EBP | <-- 旧 EBP 值
+----------------+
| 返回地址 | <-- 函数结束后跳转的位置
+----------------+
| 参数 1 |
+----------------+
| 参数 2 |
+----------------+
| 局部变量 | <-- EBP- 4 等
+----------------+
| ... |
低地址 <-- ESP
调用约定的战争
不同调用约定决定了参数如何传递:
- cdecl (C 默认约定)
- 参数从右向左压栈
- 调用方负责平衡栈
-
适合可变参数函数
-
stdcall (Win32 API 常用)
- 参数从右向左压栈
- 被调函数自己清栈
-
函数名自动加后缀 @参数大小
-
fastcall (性能优化)
- 前两个参数通过 ECX/EDX 传递
- 剩余参数压栈
- 需要编译器特殊支持
反汇编实战:x86 vs ARM
测试代码:
__attribute__((fastcall))
int fast_add(int a, int b) {return a + b;}
x86 汇编输出(clang -O1 -S):
_fast_add: # @fast_add
leal (%ecx,%edx), %eax # 直接使用寄存器相加
retl
ARM 汇编输出(arm-linux-gnueabi-g++):
fast_add:
add r0, r0, r1 @ ARM 前 4 个参数用 R0-R3 传递
bx lr @ 返回
危险的递归与栈溢出
演示代码:
void recursive(int depth) {char buffer[1024];
if (depth > 0) recursive(depth-1);
}
int main() {recursive(10000); // 触发栈溢出
}
运行时会崩溃并产生核心转储,通过 ulimit -a 可查看默认栈大小(通常 8MB)。
避坑指南
可变参数的风险:
void unsafe_printf(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
// 如果实际传入参数少于 fmt 所需...boom!
vprintf(fmt, args);
va_end(args);
}
尾调用优化条件:
1. 函数最后一步是调用自身或其他函数
2. 调用后没有额外操作
3. 返回值直接传递
延伸思考
- 链接脚本中可通过修改
.text段属性实现返回地址保护 - 虚函数调用需要额外查虚表(vtable),典型多两次内存访问
调试技巧
GDB 常用命令:
(gdb) info frame # 查看当前栈帧
(gdb) x/10x $esp # 检查栈内存
(gdb) disas /m # 带源码的反汇编
(gdb) watch *(int*)0x1234 # 监控特定内存
理解这些底层机制后,再遇到诡异崩溃时就能快速定位问题所在。下次看到汇编代码也不再是天文符号,而是讲述程序运行故事的线索。
正文完
