程序跑起来后,变量到底住哪?谁分配、谁回收、函数调用时栈怎么动?这些「运行时」的问题是编译器后端的地基:不知道存储组织,就没法生成正确的代码。本章讲静态区、栈、堆三段结构,活动记录(栈帧)的布局,以及调用序列的生成。和 C 指针课第 2 章(五段地图)、计组课第 10 章(栈帧汇编)、操作系统课第 6 章(malloc 之下)三头握手,这一章是全课跨界最多的一章。
编译器给每个变量挑「住址」,按生命周期决策:
| 区域 | 谁住 | 分配/回收 | 大小 |
|---|---|---|---|
| 静态区(.data/.bss) | 全局变量、static 变量 | 程序加载时定,全程不变 | 编译期已知 |
| 栈 | 局部变量、参数、返回地址 | 函数进出,自动 | 随调用深度 |
| 堆 | malloc/new 的对象 | 手动(或 GC) | 运行时任意 |
「能不能编译期确定大小和生命周期」是判决依据:能且全程 → 静态;能确定大小但生命周期是动态的(函数调用)→ 栈;都不确定 → 堆。数据结构课第 12 章讲过同一个决策表(栈 vs 堆),这里补上「编译器如何执行它」:静态变量直接给绝对地址(汇编里的全局符号),局部变量给栈帧内偏移,堆调用运行时库。
变长数组(VLA)、alloca 这类「运行时才知道大小」的栈分配是特殊物种:移动 rsp 而不是 sub 定值,回收靠记录原 rsp,是栈与堆的灰色地带。
一次函数调用在栈上占的那块地叫活动记录(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 字节对齐,参数也各自对齐,布局算法要在「紧凑」与「对齐」间算细账。
一次 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。
递归为什么能工作:每次调用压一个新帧,局部变量是「帧内偏移」,不同调用的同名变量物理上不同地址。斐波那契的每一层都有自己的一对参数,这就是递归的全部秘密,也是「栈」这个发明对计算史最大的贡献之一。
第 7 章的 param/call 在语言层映射成三种传参(指针课第 10 章「值传递世界」的编译器视角):
编译器层面只有一种东西:把某个值放进参数槽。「引用」只是「编译器帮你取地址和解引用」的语法糖。返回值走 rax(计组课第 10 章),大结构体返回走隐藏参数(调用方传一块内存地址进来),这些都是 ABI 的合同条款。
堆的管理不属于编译器本体,属于运行时系统(runtime),但语言设计决定它的形态:
Java 课第 7 章的 GC 话题在此补上机制层:GC 需要知道「哪些地方存着指针」,这个知识来自编译器(栈帧里哪个偏移是指针,叫栈图/stack map),所以 GC 是编译器与运行时的合谋。JIT(第 1 章)能插桩、能换代码,同样依赖编译器在代码里埋的钩子。
gcc -S 看每层栈帧的序言,数一数每帧多少字节;gdb 里 info frame 对号(计组课第 10 章的 gdb 实验,这次从编译器视角看同一份输出)。int a[n] 放栈上意味着「大小运行时定、生命周期随函数」。查一下 GCC 对 VLA 的实现(rsp 动态移动 + 保存恢复),思考为什么 C11 把 VLA 变成可选特性(提示:安全,栈溢出风险;以及嵌入式编译器的实现负担)。