跳转至

5 Activation Records

活动记录 (Activation Record / Stack Frame) 是栈上的一块内存区域,在函数调用时创建、返回时销毁,用于存储该次调用的参数、局部变量、返回地址等运行时状态。整个机制的核心是调用约定 (Calling Convention / ABI)——一套二进制层面的协议,规定参数怎么传、寄存器怎么用、栈帧怎么布局。

5.1 Memory Layout

程序运行时地址空间从低到高依次为:

  • Code(代码段):只读,存放指令。
  • Static Data(静态数据段):全局变量、静态变量、字符串常量。
  • Heap(堆):动态分配 (malloc/new),向高地址增长。
  • Stack(栈):函数调用栈,向低地址增长

两个关键寄存器定位栈帧:

  • SP (Stack Pointer):指向栈顶,随 push/pop 变化。
  • FP (Frame Pointer):指向当前栈帧的基址(固定锚点),用于访问局部变量和参数。在 x86-64 中通常用 rbp

Calling Convention

调用约定由硬件平台和操作系统定义,以 x86-64 System V 为例:

参数传递:前 6 个整型/指针参数依次通过 rdi, rsi, rdx, rcx, r8, r9 传递,浮点参数通过 xmm0~xmm7 传递。超出部分通过栈传递,由调用者压入。

返回地址call 指令自动将下一条指令地址压入栈,ret 指令弹出并跳转回去。

返回值:整型/指针放在 rax,浮点放在 xmm0

寄存器保存约定

类型 寄存器 规则
Caller-Save rax, rcx, rdx, rsi, rdi, r8-r11 调用者若关心这些值,需在调用前自行保存
Callee-Save rbx, rbp, r12-r15 被调用者若使用这些寄存器,必须先保存原值、返回前恢复

叶子过程优化 (Leaf Procedure):不调用其他函数的函数称为叶子过程。若其只用 caller-save 寄存器且不超出寄存器数量,则无需保存任何寄存器,也无需建立完整栈帧——可省略 push rbp 等开销。

Frame-Resident Variables

以下情况变量必须分配在栈帧中,无法放入寄存器:

  1. 传引用 (Passed by reference):必须有内存地址指向它。
  2. 取地址 (Address taken):如 C 语言的 &a 操作。
  3. 嵌套过程访问 (Nested access):内部函数引用外部函数的变量。
  4. 数据过大 (Too large):超过寄存器宽度(如大结构体、大数组)。
  5. 数组 (Arrays):元素通过基址+偏移访问,需要地址运算。
  6. 寄存器溢出 (Spill):活跃变量数超过寄存器数,部分被迫写入内存。

满足 1、2、3 的变量称为逃逸变量 (Escape Variables)——它们"逃出"了寄存器的约束,必须驻留在栈帧中。

Static Scoping & Nested Functions

C 语言不允许嵌套函数,但 Pascal、Tiger 等语言支持。问题:内部函数如何访问外层函数的变量?

静态作用域 (Lexical Scoping):变量的可见性由代码的嵌套结构决定(写代码时就能确定),而非运行时调用链。

静态深度 (Static Depth):函数定义所在的嵌套层数。最外层为 0,每嵌套一层 +1。

每个栈帧包含一个静态链 (Static Link) 指针,指向其静态父函数(词法上直接包含它的函数)的栈帧。访问深度差为 \(d\) 的非局部变量时,沿静态链追踪 \(d\) 步到达目标帧。

  • 优点:实现简单,每帧多一个指针。
  • 缺点:访问时间为 \(O(d)\),每次访问需遍历链表。

Static Link vs Dynamic Link

动态链 (Dynamic Link) 指向调用者的栈帧(即上一帧,FP 通常保存它的值),用于函数返回时恢复栈。静态链指向定义者,用于变量访问。两者通常不同。

Static link

Display

维护全局数组 Display[i],指向当前深度为 \(i\) 的最新活跃栈帧。访问深度为 \(d\) 的变量时,直接取 Display[d] 即可定位目标帧。

  • 优点:访问 \(O(1)\),直接寻址。
  • 缺点:函数调用/返回时需保存和恢复 Display 数组项(深度为 \(d\) 的函数进入时覆盖 Display[d],退出时恢复),维护成本高。

Example — 沿用上面 P/Q/call_it 的嵌套结构(静态深度:P=0, Q=1, call_it=1)。Display 数组在调用过程中的演变:

初始 (main 调用 P 前):                 P 进入后:
  Display[0] = 空                       Display[0] = P的帧 ──→ 覆盖 0 号槽
  Display[1] = 空                       Display[1] = 空
  Display[2] = 空                       Display[2] = 空

call_it 进入后 (深度 1):               Q 进入后 (深度 1):
  Display[0] = P的帧                     Display[0] = P的帧
  Display[1] = call_it的帧 ──→ 覆盖      Display[1] = Q的帧 ──→ 覆盖 1 号槽
  Display[2] = 空                        Display[2] = 空

每次进入深度为 \(d\) 的函数时,先保存 Display[d] 的旧值到该函数的栈帧中,再覆盖写入当前帧地址。退出时从帧中恢复旧值。

           栈帧                        保存/恢复机制
           ────                        ────────────
  ┌─────────────────┐                  
  │  P 的帧 (d=0)    │                 进入 P:  save old Display[0] → P 的帧内
  │  [saved D[0]]   │                  Display[0] = P 的帧
  ├─────────────────┤                  
  │  call_it (d=1)  │                 进入 call_it: save old Display[1] → call_it 帧内
  │  [saved D[1]]   │                 Display[1] = call_it 的帧
  ├─────────────────┤                  
  │  Q 的帧 (d=1)    │                 进入 Q: save old Display[1] → Q 的帧内
  │  [saved D[1]]   │  ← call_it 的帧  Display[1] = Q 的帧
  └─────────────────┘                  
                                       Q 返回: Display[1] = saved (恢复为 call_it 的帧)
                                       call_it 返回: Display[1] = saved (恢复为空)
                                       P 返回: Display[0] = saved (恢复为空)

当 Q 执行 print(x) 访问深度为 0 的 x 时:直接取 Display[0] → P 的帧,一步到位,无需遍历链表。与 static link 需要沿链追踪 \(d\) 步相比,Display 将变量访问\(O(d)\) 降为 \(O(1)\),代价是函数调用/返回时需要额外维护。

Lambda Lifting

将嵌套函数"提升"到外层,所有非局部变量转换为显式的额外参数。

// 提升前
void outer(int x) {
    void inner() { print(x); }
}

// 提升后
void inner(int x) { print(x); }
void outer(int x) { /* x 显式传给 inner */ }
  • 优点:无需复杂栈帧结构,数据流显式。
  • 缺点:增加参数数量,调用开销增大;闭包转发复杂。

Stack Frame Layout

以 Tiger 编译器为例,典型栈帧从高地址(帧底)到低地址(栈顶)的布局:

FP 指向 Static Link 上方(即 Return Address 的下方),SP 随参数压栈/弹出动态移动。编译时,每个局部变量和参数通过 [FP + offset] 即可定址。

评论区

对你有帮助的话请给我个赞和 star => GitHub stars
欢迎跟我探讨!!!