第 8 章 · 运行时存储组织

程序跑起来后,变量到底住哪?谁分配、谁回收、函数调用时栈怎么动?这些「运行时」的问题是编译器后端的地基:不知道存储组织,就没法生成正确的代码。本章讲静态区、栈、堆三段结构,活动记录(栈帧)的布局,以及调用序列的生成。和 C 指针课第 2 章(五段地图)、计组课第 10 章(栈帧汇编)、操作系统课第 6 章(malloc 之下)三头握手,这一章是全课跨界最多的一章。

8.1 三块地皮:静态区、栈、堆

编译器给每个变量挑「住址」,按生命周期决策:

区域谁住分配/回收大小
静态区(.data/.bss)全局变量、static 变量程序加载时定,全程不变编译期已知
栈局部变量、参数、返回地址函数进出,自动随调用深度
堆malloc/new 的对象手动(或 GC)运行时任意

「能不能编译期确定大小和生命周期」是判决依据:能且全程 → 静态;能确定大小但生命周期是动态的(函数调用)→ 栈;都不确定 → 堆。数据结构课第 12 章讲过同一个决策表(栈 vs 堆),这里补上「编译器如何执行它」:静态变量直接给绝对地址(汇编里的全局符号),局部变量给栈帧内偏移,堆调用运行时库。

变长数组(VLA)、alloca 这类「运行时才知道大小」的栈分配是特殊物种:移动 rsp 而不是 sub 定值,回收靠记录原 rsp,是栈与堆的灰色地带。

8.2 活动记录:栈帧的完整布局

一次函数调用在栈上占的那块地叫活动记录(activation record)或栈帧,标准布局:

高地址
│ 调用者的栈帧
│ 实际参数(超过寄存器容纳的部分)
│ 返回地址(call 自动压)        [rbp+8]
│ 保存的旧 rbp                 ← 当前 rbp
│ 局部变量                     [rbp-4] ...
│ 临时变量(表达式求值的 t1...)
│ 保存的寄存器(callee-saved)
│ ...                          ← rsp
低地址

每个字段都有明确的「谁负责」:调用方压参数(超出寄存器的)、call 指令自动压返回地址;被调方序言压旧 rbp、立新 rbp、sub rsp 开局部区;尾声反向。临时变量(t1、t2)也住在栈帧里,表达式求值的中间结果有了落脚点,这就是第 7 章 TAC 里的 t 到汇编的最终归宿。

对齐和补位是布局的现实约束(计组课第 12 章 struct 对齐):rsp 常要求 16 字节对齐,参数也各自对齐,布局算法要在「紧凑」与「对齐」间算细账。

8.3 调用序列与寄存器约定

一次 call 的完整动作序列(调用序列,calling sequence):

调用方:参数装寄存器(rdi, rsi...) → (多余参数压栈) → call
被调方:push rbp → mov rbp, rsp → sub rsp, N → ...函数体... → leave → ret

寄存器是稀缺资源,冲突规则靠调用约定(计组课第 10 章 System V ABI):caller-saved 寄存器调用方自存自恢复(crossing call 的活值),callee-saved 被调方要用先存走后还回。编译器的寄存器分配器(第 10 章)根据这个约定分配:跨调用的值优先放 callee-saved,短命临时放 caller-saved。

递归为什么能工作:每次调用压一个新帧,局部变量是「帧内偏移」,不同调用的同名变量物理上不同地址。斐波那契的每一层都有自己的一对参数,这就是递归的全部秘密,也是「栈」这个发明对计算史最大的贡献之一。

8.4 传参的语义层:值、引用、地址

第 7 章的 param/call 在语言层映射成三种传参(指针课第 10 章「值传递世界」的编译器视角):

  • 按值:复制实参进形参(小对象高效,大对象复制开销大);
  • 按引用(C++ 的 &):编译器自动传地址,语法上像值;
  • 按指针(C):传值语义,值恰好是地址。

编译器层面只有一种东西:把某个值放进参数槽。「引用」只是「编译器帮你取地址和解引用」的语法糖。返回值走 rax(计组课第 10 章),大结构体返回走隐藏参数(调用方传一块内存地址进来),这些都是 ABI 的合同条款。

8.5 堆与 GC:自动化的代价

堆的管理不属于编译器本体,属于运行时系统(runtime),但语言设计决定它的形态:

  • 手动(C):malloc/free,指针课第 9 章的四大案型;
  • 引用计数(Python/Swift):每个对象记被指次数,归零即回收;循环引用要辅助机制;
  • 追踪 GC(Java/Go):从根(栈、全局)出发扫可达对象,不可达回收;分代 GC 靠「大部分对象朝生暮死」的统计规律优化。

Java 课第 7 章的 GC 话题在此补上机制层:GC 需要知道「哪些地方存着指针」,这个知识来自编译器(栈帧里哪个偏移是指针,叫栈图/stack map),所以 GC 是编译器与运行时的合谋。JIT(第 1 章)能插桩、能换代码,同样依赖编译器在代码里埋的钩子。

常见错误

  • 栈帧大小算错。局部变量布局的偏移表和实际 sub rsp 不一致,访问串门;布局算法生成偏移表后要自查「最大偏移 = 帧大小」。
  • 忘了保存 callee-saved 寄存器。被调方改了 rbx 没还,调用方拿到的是脏值,故障点远在别处,极难排查。
  • 参数压栈顺序错。ABI 规定从右到左压(C 的可变参数 printf 靠它),顺序错则第一个参数拿到垃圾。
  • 临时变量的生存期超出帧。把帧内临时地址返回(悬空指针,C 课第 10 章),编译器应该警告;真实世界的 -Wreturn-local-addr 就在做这事。

练习

  1. 动手:写一个三层递归函数,gcc -S 看每层栈帧的序言,数一数每帧多少字节;gdb 里 info frame 对号(计组课第 10 章的 gdb 实验,这次从编译器视角看同一份输出)。
  2. 动手:给第 7 章的 IR 加上函数调用(param/call),生成对递归阶乘的完整 TAC(含参数与返回值约定)。
  3. 手算:一个函数有 3 个 int 局部变量、2 个 8 字节临时、调用约定要求 rsp 16 对齐,画栈帧布局图,算 sub rsp 的字节数。
  4. 思考:变长数组 int a[n] 放栈上意味着「大小运行时定、生命周期随函数」。查一下 GCC 对 VLA 的实现(rsp 动态移动 + 保存恢复),思考为什么 C11 把 VLA 变成可选特性(提示:安全,栈溢出风险;以及嵌入式编译器的实现负担)。