编译原理
从语义合法的 typed AST 走向运行时:函数调用如何在栈上铺开、frame 与 register 如何分配、嵌套作用域与变量访问的实现。
Chapter 5 解决了"AST 是否语义合法"——我们现在有了 typed AST + symbol tables。Chapter 6 紧接着回答:
函数调用在机器上到底是怎么跑起来的?局部变量、参数、返回地址都放在哪?
答案是 Activation Record(活动记录),也叫 Stack Frame(栈帧)——它是编译器前端与机器层面真正接轨的第一个概念。
Chapter 6 的两大主题:
Frame 模块的抽象接口和 Translate 层从编译器作者的视角看,目标程序运行在自己的 逻辑地址空间 里,每个程序值都有一个位置。一个典型的运行时内存布局:
Low ┌──────────────┐
│ Code │ 可执行目标代码
├──────────────┤
│ Static │ 编译期大小已知:全局常量、编译器生成的数据
├──────────────┤
│ Heap │ 程序控制的动态分配 (C: malloc/free) ↓
├──────────────┤
│ Free Memory │
├──────────────┤
│ Stack │ 函数调用期间生成的 activation records ↑
High └──────────────┘
按习惯,stack 从高地址向低地址生长。
考虑 Tiger 代码:
function f(x: int): int =
let var y := x + x
in if y < 10 then f(y) else y - 1
endf,都会 新建一个 x 的实例(由 caller 初始化)x 同时存在函数调用天然是 LIFO(Last In, First Out,后进先出):最后调用的最先返回。所以用 stack 这个 LIFO 数据结构来放局部变量再自然不过。
main() 调 f() 调 g() 调 h()
调用顺序: main → f → g → h (依次 push)
返回顺序: h → g → f → main (依次 pop)
每个活着的 activation 在 control stack 上都有一个 activation record(也叫 frame)。
理论上 stack 只要 push / pop 就够了,但实际上:
所以实际实现是:把 stack 当成一个大数组,用 Stack Pointer (SP) 这个寄存器指向某个位置(栈向低地址生长,SP 指栈顶,也就是当前最低的已用地址):
高地址 ↑
┌──────────────┐
│ 已分配 │
│ (in use) │
SP →├──────────────┤ ← 栈顶
│ garbage │
│ (free) │
低地址 ↓
操作语义:
SP ← SP − size(SP 往下走,新空间纳入"已分配")SP ← SP + size(SP 往上走,腾出的空间变回 garbage)MEM[SP + offset](offset > 0,往上数)Stack 通常 只在进入函数时增长,一次增加足以容纳该函数所有局部变量的大小;退出前再收缩回去。
一个函数的 activation record / stack frame = stack 上分给这个函数的那一片区域,装着:
核心问题:frame 怎么布局,才能让 caller 和 callee 正确通信?
┌─────────────────┐ ↑ 高地址
│ argument n │ ↖
│ ... │ │ incoming args
│ argument 2 │ │ (caller 压的,但属于
│ argument 1 │ │ current function 使用)
frame pointer →│ static link │ ↙
├─────────────────┤ ← current frame 起点
│ local variables │ ↖
│ │ │
│ return address │ │
│ temporaries │ │ current function 自用
│ saved registers │ │
│ │ │
│ argument m │ │ outgoing args
│ ... │ │ (current 作为 caller,
│ argument 2 │ │ 给下一层函数准备)
│ argument 1 │ │
stack pointer →│ static link │ ↙
├─────────────────┤
│ │ next frame 将在这里展开
│ │
└─────────────────┘ ↓ 低地址
⚠️ 图里上方的
argument 1..n其实是 current function 的 incoming args——虽然物理上是 caller 压的(所以画在 previous frame 区),但它们正是"传给我"的参数。最下面那段argument 1..m才是 current function 作为 caller 要传给 下一个 函数的 outgoing args。
为什么 argument 在高地址、local vars 在低地址? 因为 stack 向下生长,push 得越早,地址越高:
① caller 先 push incoming args(最高) → ② call 指令 push return address → ③ current 进入后 SP 下移开 local(更低) → ④ current 要调下一层时 push outgoing args(最低)
从 FP 看出去天然对称:
MEM[FP + 正 offset] → 访问 incoming arguments(上方)MEM[FP − 正 offset] → 访问 local variables / saved regs(下方)各字段含义:
| 字段 | 谁放的 | 作用 |
|---|---|---|
| incoming arguments | caller 放,callee 看到 | 传进来的参数 |
| local variables | callee | 有些在 frame,有些在 reg |
| return address | CALL 指令自动 | 函数返回时跳回的地方 |
| temporaries | callee | 中间结果 |
| saved registers | callee 或 caller | 保护寄存器内容 |
| outgoing arguments | 当前函数作为 caller | 传给被叫函数 |
| static link | caller | 支持嵌套函数访问外层变量 |
设 g 调用 f(a1, ..., an),g 是 caller,f 是 callee:
进入 f 时:
FP ← SPSP ← SP − framesizef 退出时:
SP ← FPBefore call: After call:
┌──────────┐ ┌──────────┐
│ frame of │← FP │ frame of │← FP (old)
│ g │ │ g │
│ │← SP ├──────────┤← FP (new)
└──────────┘ │ frame of │
│ f │
│ │← SP
└──────────┘
当 frame size 可变或 frame 不连续时,FP 才真正需要。如果 frame size 固定,FP = SP + framesize,FP 就是一个"虚构"的寄存器——编译器知道,不需要真寄存器保存。
访问 register 比访问 memory 快,但寄存器数量有限(一般 32 个),所有函数都要用。
场景: f 在 reg r 里存了局部变量,然后调用了 g,g 也要用 r——必须有人 save / restore r。
| 类型 | 谁来 save / restore | 何时用 |
|---|---|---|
| caller-save | 调用方 f | 跨 call 要用的值放这里 |
| callee-save | 被调方 g | 保存 frame pointer 等 |
由调用约定决定哪些寄存器是哪种。
Tiger 用 pass-by-value:传实参的值,修改形参不影响实参。
如果参数 只放 stack,每次函数调用都要写内存、读内存,开销很大。现代机器的调用约定通常:
前 k 个参数(k = 4 或 6)放寄存器 ,剩下的放内存。
void f(int a) { // 假设 a 在 r1
int z = ...;
h(z); // h 的第一个参数也要放 r1 → 先 save a
int t = a + 2; // 调用后还要 restore a
}此时 f 不得不把 r1 save 到 frame 再 restore——省下的内存访问又吐出去了。
a 在 h(z) 之后已经 dead,f 直接覆盖 r1 即可,不用 save如果
g中调用指令的地址是a,那么f返回后应继续从a + 1执行——这就是 return address。
现代机器的 call 指令 一般把 return address 放到一个 专用寄存器(MIPS 的 $ra),而不是写入栈里:
$ra 不会被覆盖,无需写 frame$ra 存到 frame(除非做了 inter-procedural 寄存器分配)现代调用约定:参数、返回地址、返回值都在寄存器里,很多局部变量和中间值也放寄存器。
那什么时候一个值必须写到 frame?只有必要的时候:
一个变量 escapes,如果它 pass-by-reference、被取地址(
&)、或被嵌套函数访问。
Escape 的变量必须放 frame(有地址),非 escape 才有资格进 register。
| Registers | Stack Frame |
|---|---|
| 部分参数 | 按引用传递 / 取过地址的变量 |
| return address | 被嵌套函数访问的变量 |
| return value | 太大放不下 register 的值 |
| 部分局部变量与临时值 | 数组变量 |
| spilled registers |
在允许嵌套函数声明的语言(Pascal、ML、Tiger)里,内层函数可以用外层函数的变量:
function prettyprint(tree: tree) : string =
let var output := ""
function write(s: string) =
output := concat(output, s) (* 访问 prettyprint 的 output *)
function show(n: int, t: tree) =
let function indent(s: string) =
( for i := 1 to n do write(" "); (* 访问 show 的 n、调 write *)
output := concat(output, s) ) (* 访问 pp 的 output *)
in ... end
in show(0, tree); output end通过 FP 能访问 当前函数自己 的局部变量(每个变量相对 FP 的 offset 编译期就定了)。
但嵌套函数怎么访问非本地变量?
每次调用 f,都把 "在程序文本中直接包围 f 的函数 g 的最近一次 activation record 的指针" 作为一个额外参数传进去。这个指针就是 static link。
int g(int x) {
int f(int y) { ... } // f 的 static link 指向 g 的 frame
return f(x) + 1;
}Frame of f: ... → Frame of g
static link ────────────────┘
...
在 prettyprint 例子里:
pp's frame show's frame indent's frame
┌───────────┐ ┌───────────┐ ┌───────────┐
│static link│←─────│static link│←──────│static link│
│ output │ │ n │ │ │
└───────────┘ └───────────┘ └───────────┘
show 调用时,传 pp 的 FP 作 static link(pp 是 show 的直接外层)show 递归调 show:传自己的 static link(不是自己的 FP!因为两个 show 同层,共同的外层是 pp——caller show 的 static link 本来就指向 pp,原样转发即可)规则提炼(设 callee 的文本外层为
P):
- callee 嵌在 caller 内部(caller = P)→ 传 caller 自己的 FP
- callee 和 caller 同层(兄弟 / 递归)→ 传 caller 自己的 static link
- callee 在 caller 外层 → caller 沿 static link 向上走相应层数
indent 里访问 output:先拿自己的 static link(指向 show),再拿 show 的 static link(指向 pp),再取 outputint f(link, int x, int y) {
int m;
int g(link, int z) {
int h(link) {
return link->prev->m + link->z; // 沿 link 链上去
}
return 1;
}
return 0;
}全局数组 d[],d[i] 指向 当前最近进入的、嵌套深度为 i 的函数的 frame。
nesting depths: d[] array:
main 1 d[2] ────→ prettyprint's frame
pp 2 d[3] ────→ show's frame
write 3 d[4] ────→ indent's frame
show 3
indent 4
访问非本地变量时,直接 d[depth] 一步到位,不用沿链找。
注意 write 和 show 都在深度 3,是兄弟函数——pp 既可能调 write,也可能调 show。所以 d[3] 是 动态变化 的:
pp 调 write: d[3] ← write.FP (write 执行期间 d[3] 指向 write)
write 返回: d[3] 恢复
pp 调 show: d[3] ← show.FP (show 执行期间 d[3] 指向 show)
进入同深度函数时覆盖,返回时恢复(所以函数 prologue 要 save old d[i],epilogue 要 restore)。
d[i] 的严谨语义:当前调用栈上、最深的那个深度为 i 的函数的 frame——永远反映此刻"可见的"那一层。indent 运行时 d[3] 一定指向 show(它的直接外层),绝不会是 write,因为 write 根本不在当前调用栈上。
d[](多两次内存操作)重写程序:把每个非本地变量改写成显式参数。
// 原始 // Lambda-lifted
int f(int x, int y) { int f(int x, int y) {
int m; int m;
int g(int z) { int g(int &m, int z) {
int h() { int h(int &m, int &z) {
return m + z; return m + z;
} }
return 1; return 1;
} }
return 0; return 0;
} }从最内层开始往外变换,把每层用到的外层变量都加成引用参数。
fun f(x) =
let fun g(y) = x + y
in g
end
val h = f(3)
val z = h(5) (* 这里 f 已经返回,但 g 还引用 f 的 x! *)g 被 返回出去,f 的 frame 不能随着 f 的返回而销毁——x 还活着栈式分配依赖一个前提:局部变量的生命周期 = 函数调用的生命周期(LIFO)。函数返回时 frame 销毁,里面的变量一起收回。
但这里 g 带着 x 逃出了 f:
调 f(3): f 返回后: 调 h(5):
┌─────────┐ ┌─────────┐ ┌─────────┐
│ x = 3 │ │garbage │ │garbage │ ← g 想读 x,读到垃圾!
└─────────┘ └─────────┘ └─────────┘
把 g 打包成一个 closure 对象放在堆上,里面包含:
x = 3)堆上: h ──→ ┌─────────────┐
│ code: g │
│ x: 3 │ ← 由 GC 管理
└─────────────┘
堆对象的生命周期由 GC 决定——只要还有人引用就不回收,与函数调用无关。
标题"栈用不完了"的含义:Tiger/Pascal 里栈上变量"用完就收"(LIFO),而支持 higher-order function 的语言,变量可能"带出去",栈上收不回——必须挪到堆上。
| 语言 | 嵌套函数 | 函数作为返回值 | 能全用 stack? |
|---|---|---|---|
| Pascal | ✅ | ❌ | ✅ |
| C | ❌ | ✅ | ✅ |
| ML / Scheme | ✅ | ✅ | ❌ (需要 heap-allocated closures) |
higher-order function = nested functions + functions as returnable values。这类语言 不能用栈保存所有局部变量,必须有 closure + heap。
不同机器的调用约定不同(参数位置、寄存器编号、对齐方式……)。Tiger 把机器细节封装在 Frame 模块里,前端(Semant / Translate)根本不需要知道是 MIPS 还是 Pentium。
┌────────────────────────────┐
│ semant.c │
├────────────────────────────┤
│ translate.h │
│ translate.c │ (管嵌套 scope / static link)
├─────────────┬──────────────┤
│ frame.h │ temp.h │ (机器独立接口)
├─────────────┼──────────────┤
│ mipsframe.c │ temp.c │ (机器相关实现)
└─────────────┴──────────────┘
两层抽象:
| 层 | 文件 | 职责 | 机器相关? |
|---|---|---|---|
| 语义分析 | semant.c | 类型检查 + 符号表(Chapter 5) | 否 |
| 翻译层 | translate.h/c | AST → IR,管嵌套 scope / static link | 否 |
| 抽象接口 | frame.h / temp.h | 栈帧和临时量的机器无关接口 | 否 |
| 具体实现 | mipsframe.c / temp.c | 某架构(如 MIPS)的具体栈帧布局、寄存器约定 | 是 |
各层负责什么:
semant.c:只关心"程序语义对不对",不知道目标机器translate.c:把 AST 翻译成 IR,处理 Tiger 的嵌套函数(static link),仍然机器无关frame.h:声明"一个函数的栈帧长什么样"的抽象接口——参数、局部变量、返回地址在哪里temp.h:声明抽象的"寄存器"(Temp_temp,数量无限)和"代码标签"(Temp_label)mipsframe.c:MIPS 架构的具体实现——参数前 4 个放 $a0-$a3,其余放栈;按 MIPS 约定对齐等核心思想:接口与实现分离。
translate.c 调用 frame.h 的接口(机器无关)
↓
frame.h 定义抽象接口
↓
mipsframe.c 提供具体实现(机器相关)
好处:
armframe.c,上层代码完全不改semant.c 和 translate.c 完全不碰机器细节类比:就像操作系统的驱动模型——应用程序(semant)不关心硬件细节,通过系统调用(translate + frame.h)访问,具体硬件差异藏在驱动里(mipsframe/x86frame)。
frame.h:Frame 抽象接口/* frame.h */
typedef struct F_frame_ *F_frame;
typedef struct F_access_ *F_access;
typedef struct F_accessList_ *F_accessList;
F_frame F_newFrame(Temp_label name, U_boolList formals);
Temp_label F_name(F_frame f);
F_accessList F_formals(F_frame f);
F_access F_allocLocal(F_frame f, bool escape);F_frame:保存该函数所有 formal / local 信息F_access:描述一个变量 在 frame 里 还是 在 register 里(是抽象数据类型)F_newFrame(name, l):新建 frame,l 是 k 个 bool——每个 formal 是否 escapeF_allocLocal(f, escape):为新局部变量分配位置F_access 的具体实现(对 Frame 内部可见)/* mipsframe.c */
struct F_access_ {
enum {inFrame, inReg} kind;
union {
int offset; /* InFrame */
Temp_temp reg; /* InReg */
} u;
};比如 InFrame(8) 表示 frame pointer + 8 处;InReg(t84) 表示抽象寄存器 t84。
同一个参数,caller 和 callee 看到的位置 不一样:
| 传递方式 | caller 看到 | callee 看到 |
|---|---|---|
| stack | offset from SP | offset from FP |
| register | 例如 r6 | 例如 r13 |
F_formals返回的是 callee 视角 的 access list。
F_newFrame 为每个 formal parameter 要算两样东西:
g 有 3 个参数,第一个 escape| Pentium | MIPS | Sparc | |
|---|---|---|---|
| Formal 1 | InFrame(8) | InFrame(0) | InFrame(68) |
| Formal 2 | InFrame(12) | InReg(t₁₅₇) | InReg(t₁₅₇) |
| Formal 3 | InFrame(16) | InReg(t₁₅₈) | InReg(t₁₅₈) |
| View Shift | M[sp+0]←fp; fp←sp; sp←sp−K | sp←sp−K; M[sp+K+0]←r2; t₁₅₇←r4; t₁₅₈←r5 | save; M[fp+68]←i0; t₁₅₇←i1; t₁₅₈←i2 |
MIPS 版本里,为什么要把 r4 / r5 move 到 t157 / t158 而不是直接用?
function m(x: int, y: int) = (h(y,y); h(x,x))m 内部调用 h 两次,调用 h 时要把参数放到 r4 / r5,会 覆盖 m 的入参。所以先把入参搬到临时寄存器,register allocator 最后再决定 t157 / t158 真正落在哪个机器寄存器。
F_allocLocal?每次遇到 variable declaration,就调
F_allocLocal分一个位置(temp or frame slot);遇到end/}时 忘掉名字绑定,但空间仍然保留。
也就是:整个函数内,每个变量声明都分配一个独立的位置,即使名字复用。
function f() =
let var v := 6 in (* v1 *)
(print(v);
let var v := 7 (* v2,独立于 v1 *)
in print(v) end;
print(v); (* 这里访问的是 v1,print 6 *)
let var v := 8 in (* v3 *)
print(v)
end;
print(v)) (* 仍然是 v1 *)
end
(* 输出:6 7 6 8 6 *)因为此时 Semant 还不知道 liveness / scope 重叠 信息。后续 register allocator 会发现 v2 / v3 同时活不起来,把它们塞同一个寄存器或 frame slot。职责分离。
escape.cF_allocLocal(f, escape) 的第二个参数从哪来?需要一个 pre-pass:
/* escape.h */
void Esc_findEscape(A_exp exp);遍历整棵 AST,对每个变量声明记 "它是否 escape"。判断标准前面讲过(pass-by-ref / 取地址 / 嵌套函数访问)。
实现上用的是环境:
static void traverseExp(S_table env, int depth, A_exp e);
static void traverseDec(S_table env, int depth, A_dec d);
static void traverseVar(S_table env, int depth, A_var v);若某变量在比它声明 深 的函数里被用到,就标记 escape。
语义分析阶段要给参数/局部变量分配寄存器,要给函数体选地址——但此刻 太早了,我们还不知道真实的机器寄存器编号和代码地址。
解决办法:抽象
/* temp.h */
typedef struct Temp_temp_ *Temp_temp;
Temp_temp Temp_newtemp(void); // 无限分发临时寄存器
typedef S_symbol Temp_label;
Temp_label Temp_newlabel(void); // 无限分发 label
Temp_label Temp_namedlabel(string name); // 指定汇编名
string Temp_labelstring(Temp_label s);
Temp_namedlabel要小心——不同 scope 里可能有同名函数,直接用名字可能撞。
为什么不把 static link 塞进 Frame 模块?
因为很多源语言(C、Java)没有嵌套函数。Frame 模块应当独立于源语言。
Static link 的做法:当作一个隐藏的参数——Translate 对每个函数的 formals 列表偷偷加一个 static link 参数。
translate.htypedef struct Tr_access_ *Tr_access;
typedef ... Tr_accessList ...
Tr_level Tr_outermost(void);
Tr_level Tr_newLevel(Tr_level parent, Temp_label name,
U_boolList formals);
Tr_accessList Tr_formals(Tr_level level);
Tr_access Tr_allocLocal(Tr_level level, bool escape);Tr_level:每个函数一个 level,记录其静态嵌套层次与父 levelTr_outermost():最外层,Tiger main 所在;所有库函数在这层,没有 frameTr_newLevel:进入新函数时调,由 transDec 调用Tr_allocLocal(lev, esc):Semant 处理局部变量声明时调用Tr_formals(level):拿到形参的 access 列表(已经剥掉 static link 的那种)Tr_access = level + F_access/* 在 translate.c 内部 */
struct Tr_access_ { Tr_level level; F_access access; };为什么要带 level?因为访问一个变量时,要比较"当前函数的 level"和"变量所在 level",决定要沿多少层 static link 爬上去。
struct E_enventry_ {
enum {E_varEntry, E_funEntry} kind;
union {
struct { Tr_access access; Ty_ty ty; } var;
struct { Tr_level level; Temp_label label;
Ty_tyList formals; Ty_ty result; } fun;
} u;
};每个 variable / function 除了类型,还多了 level / access / label——后续 IR 生成才能真正产出访问代码。
function f(x: int) =
let
function g() = print_int(x) (* g 的 level.parent = f 的 level *)
in g() endSemant 处理 print_int(x) 时:
x 的 E_VarEntry 里记了 Tr_access = { level=f, access=InReg/InFrame(...) }g 的 levelg.depth - f.depth 层MEM(FP + x_offset),其中 FP 是经过若干次 MEM(staticLink) 解引用后得到的 f 的 frame pointer这正是 Chapter 7 IR 生成要做的事——Chapter 6 只是 把接口摆好。
因为语言允许 递归 / 嵌套 / 动态调用,局部变量的生命周期跟 函数调用 绑定。Stack 是天然的 LIFO,每次调用对应一个 frame。
参数、返回地址、saved registers、local、temporary、outgoing args、static link 在 frame 里各有其位——这些布局由调用约定决定,是 caller 和 callee 之间的通信契约。
现代调用约定的核心思想:能用寄存器就用寄存器——参数、返回地址、返回值、局部变量都先考虑 register,只在 escape / 溢出 / 冲突时才落 frame。
三种方法实现嵌套作用域。Tiger 采用 static link——简单,对 register 压力小,代价是访问非本地变量要沿链多跳。
嵌套函数 + 函数作为返回值 = 栈存不下所有局部变量。这类语言必须用 closure + heap。Tiger 不支持 first-class functions,因此可以全用 stack。
F_access 把"变量在 frame 里还是在 reg 里"抽象掉;Temp 把"具体哪个机器寄存器"抽象掉;Label 把"具体什么地址"抽象掉。前端完全不碰机器细节。
Tr_level / Tr_access 在 Frame 之上加一层,处理 static link——让 Frame 保持源语言无关。
在 Semant 真正 F_allocLocal 之前,要先扫一遍 AST 标记哪些变量 escape。escape → 必须 InFrame;非 escape → 可 InReg。
Caller 和 callee 对参数位置的 "看法" 不同——F_newFrame 要同时产出 callee 视角的 access list 和实现 view-shift 的指令。
Semant 阶段对每个声明都分配新位置;register allocator 阶段才决定物理上能否复用同一寄存器/slot。职责分离,各做一层。
| Chapter | 问 | 答 |
|---|---|---|
| 3 | How do we parse? | LR / LL + grammar |
| 4 | What should parser produce? | AST |
| 5 | How to check AST's static correctness? | Symbol table + type checking |
| 6 | How do we model runtime layout for calls? | Activation record + Frame / Temp abstraction |
| 7 | How to translate typed AST to IR? | IR trees + Translate |
到这里,编译器视角下的 执行模型 初具雏形:
source → tokens → CST → AST → typed AST → typed AST + frame info
下一章 Chapter 7 会接着把 typed AST 真正翻译成 IR——那时候所有前几章铺好的 Tr_exp / Tr_access / F_access 才开始产出真正的代码。
Written by
Comments