📖 第 18 章:运行时环境(Runtime Environment)

🔤 Kenneth Reek《Pointers on C》(C和指针)· Chapter 18
🗨️ 本章金句:「代码跑起来之后的世界:栈帧怎么叠、寄存器谁保存、C 怎么和汇编握手。」
这一章在干嘛? 全书收尾:掀开「程序运行时」的地板——函数调用的栈帧布局、判断目标环境特征的方法、C 与汇编的接口约定,以及影响运行时效率的因素。读懂它,调试和逆向都上一台阶。
18.1 栈帧:函数调用的内存账本18.2 C 与汇编的接口18.3 运行时效率:钱花在刀刃上

18.1 栈帧:函数调用的内存账本

每次函数调用,运行时在栈上压一个栈帧(stack frame),装着这个函数的家当:

$$\text{帧} = \underbrace{\text{返回地址}}_{\text{回哪儿}} + \underbrace{\text{保存的寄存器}}_{\text{恢复现场}} + \underbrace{\text{形参}}_{\text{调用者放的}} + \underbrace{\text{局部变量}}_{\text{自己用的}}$$
void inner(int x)   /* 被调者 */
void outer(void)
{
    int a = 1;                  /* outer 的帧:a 在栈上 */
    inner(a);                   /* 压 inner 的帧:返回地址+形参x */
}                               /* 返回时两个帧依次弹出 */

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 验证。
← 上一篇🏠 顶层目录下一篇 →