这一章在干嘛?
全书收尾:掀开「程序运行时」的地板——函数调用的栈帧布局、判断目标环境特征的方法、C 与汇编的接口约定,以及影响运行时效率的因素。读懂它,调试和逆向都上一台阶。
18.1 栈帧:函数调用的内存账本
每次函数调用,运行时在栈上压一个栈帧(stack frame),装着这个函数的家当:
void inner(int x) /* 被调者 */
void outer(void)
{
int a = 1; /* outer 的帧:a 在栈上 */
inner(a); /* 压 inner 的帧:返回地址+形参x */
} /* 返回时两个帧依次弹出 */
- 调用前:调用者把实参压栈/传寄存器,执行 call(压返回地址);
- 函数内:被调者调整栈指针、给局部变量腾地;
- 返回前:恢复寄存器、栈指针,ret 弹返回地址——帧弹走后其内存并不擦除,只是失效,这就是「返回局部变量地址」能暂时看起来正常的原因(最阴险的未定义行为)。
18.2 C 与汇编的接口
C 编译好也是汇编,两者能互相调用——前提是遵守同一份调用约定:参数怎么传(寄存器还是栈、从左还是从右压)、返回值放哪、哪些寄存器调用者保存、哪些被调者保存。用 gcc -S 看 C 对应的汇编,是理解约定最直接的办法:
/* add.c: int add(int a, int b) { return a + b; } → x86-64 SysV 约定 */
/* a 在 edi,b 在 esi,返回值放 eax */
add:
lea eax, [rdi + rsi] /* eax = a + b */
ret
在 C 里调用汇编函数:按约定在汇编里实现同名标号即可(裸机/embedded 里 extern void startup(void); 直通 .s 文件)。反过来,汇编里调 C 也一样:把参数按约定放好再 call。中断服务函数、启动代码、性能关键内核(FFT、AES)是这门手艺的主战场。
为什么默认别手写汇编:
编译器会做指令调度、寄存器分配、循环展开,普通代码手写汇编很难更快,还丧失可移植性。先测量,确认瓶颈存在再动手。
18.3 运行时效率:钱花在刀刃上
| 因素 | 影响 | 工程建议 |
|---|---|---|
| 指针 vs 下标 | 老机器指针遍历更快;现代编译器对两者生成的代码基本一样 | 写更清晰的下标版,交给优化器 |
| 寄存器变量 | register 是提示;编译器自己会做寄存器分配且更聪明 | 几乎不用手写 |
| 函数调用开销 | 压栈/跳转有成本,小函数频繁调用可建议内联 | inline / 编译器 O2 自动内联 |
| 副作用与优化 | 未定义行为给了优化器「自由」,可能删掉你以为必须的检查 | 远离 UB,优化才可预测 |
全书总结一句话:
C 的一切开销都摊在明面上——栈帧、拷贝、指针运算、库调用。理解运行时模型,就能预判代码的代价;预判了代价,性能不过是顺手的副产品。至此,从第一行 hello 到栈帧布局的整幅地图你已经走完。
1. 栈帧里通常包含哪些内容?由谁负责压栈?
返回地址、保存的寄存器、形参、局部变量。实参与返回地址由调用者通过 call 压入/传递;局部变量由被调函数调整栈指针腾出;返回前被调者恢复寄存器与栈指针,ret 弹返回地址。
2. 为什么「返回局部变量的地址」危险但有时看起来正常?
栈帧弹出后内存不擦除、只是失效,立刻读取可能还拿到旧值——看起来正常;一旦后续有函数调用覆盖同一栈区,读到的就是垃圾。属于未定义行为,必须返回堆内存、静态区或由调用方传入缓冲区。
3. 现代编译器下还值得手写汇编优化吗?
绝大多数情况不值得:编译器 O2/O3 的指令调度、寄存器分配、内联、向量化通常优于手写,且可移植、可维护。仅在确认瓶颈、且编译器确实无能为力的极少数热点(加解密内核、DSP 原语)才考虑,并先用 -S 验证。