“基础调用约定与栈帧给出一套可复算的参数、帧偏移与保存恢复轨迹;GC根映射进一步要求在移动收集后更新或重载仍要使用的指针位置。这些实现接口与本页的活跃值保持义务配合,但不能由合法图着色单独保证。”
形式陈述
先固定一个教学 ABI
以下不是 x86-64、RISC-V 或 LLVM 的完整 ABI。我们定义字长 8 字节、地址按字节计、栈向低地址增长,SP 始终保持 8 字节对齐。没有可变长局部对象、异常展开、可变参数、红区和尾调用优化;栈边界由运行时检查。
寄存器 E 传显式闭包环境,A0,A1 传前两个普通参数,R 返回一个字的结果,T 是临时寄存器。这五类寄存器都允许被被调用者改写。S 是被调用者保存寄存器:若函数修改它,正常返回时必须恢复调用前的值。FP 指向当前帧的固定基址,返回时也必须恢复。
第 3 个及以后的普通参数由调用者先从右向左压栈。教学 call L 再压入“call 后一条指令”的地址,并跳到 L;不是压入 call 自身地址。被调用者的序言继续压入旧 FP,令 FP=SP,再减去按 8 对齐的局部区大小 F。
因此帧偏移由动作顺序推出:[FP] 是旧 FP,[FP+8] 是返回地址,第 3 个参数在 [FP+16],第 4 个在 [FP+24]。局部与保存区在负偏移。本文固定样例 F=32,[FP−8] 保存 S,[FP−16]、[FP−24]、[FP−32] 是三个独立局部槽。不是每个函数都必须使用全部槽。
调用和返回的步骤
调用者先按语言顺序求完函数与实参,并将跨调用仍要使用的 E,A0,A1,R,T 值保存到自己的独立槽。随后布置栈参数与寄存器参数,执行 call。若参数表达式自己包含调用,必须等它们的结果稳定后再安排最终参数位置,否则后一次实参求值可能覆盖前一个实参。
被调用者按约定建帧,保存将要改写的 S,执行函数体。结束时先把返回值放入 R,恢复 S,执行 SP=FP,弹出旧 FP,再弹出返回地址并跳转。调用者最后回收自己压入的额外参数,恢复仍需的调用者保存值。恢复时不能把旧 R 覆盖新返回值,除非已先把新结果搬到别处。
核心不变量是:各活动调用的局部槽不重叠;每份返回地址与它所属的调用配对;每个仍活跃的调用者值都在未被此次调用破坏的位置。序言与尾声互为逆过程,函数体只要不越界覆盖保存区,返回就恢复调用前的 SP,FP,S,并把结果交到 R。这个条件是经验证寄存器分配已使用的义务,本页把它落实成地址轨迹。
直觉
把IR中的调用降成机器调用时,本页契约规定如何安排参数和恢复状态。
一次调用不仅要跳到另一段代码,还要约定实参在哪、结果放哪,以及回来后哪些旧值必须仍然有效。调用约定是这份跨函数契约;栈帧是一次动态调用为履行契约而使用的存储区。同一函数递归调用三次,可以有三份不同的帧。
例子与边界
两层帧的完整地址账本
使用闭包转换中的计数器。make(4) 已返回,调用者 main 保存 b=peek,接着调用 inc(3)。假定 main 的 FP=8192、SP=8160,b 放在 8176=FP−16,值为闭包地址 1072;S=55。
调用前设置 E=1016(inc 的环境)、A0=3。call 在 8152 保存 main.after_inc;序言在 8144 保存旧 FP=8192,令新 FP=8144;分配 32 字节后 SP=8112。保存旧 S=55 于 8136,环境参数存入 8128=8144−16。此时两层帧如下。
| 所属帧 | 地址 | 当前内容 | 作用 |
|---|---|---|---|
| main | 8176 | 1072 | 返回后还要调用的 b |
| inc | 8152 | main.after_inc | 继续执行位置 |
| inc | 8144 | 8192 | 调用者 FP |
| inc | 8136 | 55 | 保存的 S |
| inc | 8128 | 1016 | inc 环境 |
| inc | 8120、8112 | 临时量或空槽 | 不与保存区共用 |
inc 把共享单元从 4 改为 7,把结果放入 R=7。恢复 S=55 后,令 SP=8144;弹出旧 FP 后 FP=8192, SP=8152;再弹返回地址后 SP=8160。调用者的 b 槽仍在 8176,可以继续调用 peek。这次没有额外栈参数,所以调用者无需再调整 SP。
若 inc 调用另一个函数,必须先保护仍要使用的环境。单纯把环境放在 E 不够,因为 E 是调用者保存寄存器。同样,若回收器会移动对象,保存了旧地址也不等于足够;保存位置还需进入根映射并在返回后重新读取。
参数溢出与失败反例
从同样的 SP=8160 调用 f(10,20,30):先将 30 压到 8152;call 把返回地址压到 8144;序言把旧 FP 压到 8136,并令 FP=8136。于是 FP+16=8152,恰好指向 30。尾声返回后 SP=8152,调用者再加 8,恢复 8160。若误按无栈参数情形读取 FP+8,拿到的是代码地址而非第三个参数。
另一种错误是调用者把仍要用的 b 仅留在 T,然后 inc 合法地将 T 改为整数 7。返回时对 T.code 的读取已经不是函数调用。这里被调用者没有违反契约,错误在调用者漏保存。反过来,inc 若改写 S 却未恢复,则是被调用者违约。
栈帧只服从动态调用次序,词法环境服从源程序绑定。返回闭包若仍指向刚退出的帧,下一次调用复用该区域就会覆盖捕获值。不能靠“FP 链还能找到旧地址”修复:旧帧已没有存活承诺,捕获对象必须采用足够长的存储期。
推论与应用
成本、边界与迁移
若调用有 k 个栈参数、s 个需保存的寄存器,纯进入与离开开销为 O(1+k+s);修改 SP 预留固定大小区域是常数工作,但逐槽初始化大小为 F 的区域需要 O(F/8)。最大栈空间是同一时刻活动帧与实参区大小之和,不能用累计调用次数估算。
递归深度无界或单帧过大都可能触发栈耗尽;这是明确失败,不是自动转成堆分配。尾位置调用仍按本 ABI 建新帧,所以源程序尾递归不保证常数栈。真实 ABI 可能有不同寄存器集合、更强对齐、结构体分类与展开规则,链接外部库时必须遵守对应规范。[1][2]
迁移题:若 main 还要在 inc 返回后使用旧 T=9,该把 9 放在哪里?可放在 main 的另一个独立局部槽,如 8168,并在返回后重载。不能放 8176 覆盖 b,也不能放到即将分配给 inc 的 8128。若再增加第 4 个参数,按先压第 4、再压第 3 的顺序,检查被调用者的 FP+16、FP+24 分别对应谁。
尾调用帧复用另行扩展本教学 ABI:只对同尺寸、无溢出参数、没有未完成清理且栈对象不逃逸的尾边保留原返回槽并转移到目标体入口。它不改变本页普通 call 的建帧过程,也不使任意外部 ABI 或任意尾递归自动满足常数栈。
参考资料
[1] Hakim Weatherspoon,Cornell CS 3410,Calling Conventions,2012,幻灯片 7 “Call Stacks”、15–16 “Many Arguments”、27–35 “Frame Pointer / Register Usage / Frame Layout”。用于核对调用协议的组成,不采用其中的 MIPS 字长与寄存器分配。
[2] RISC-V International,RISC-V ABIs Specification,§1.1 “Integer Register Convention”、§1.2 “Frame Pointer Convention”、§2.1 “Integer Calling Convention”,2026-10-08 核查。引用只用于说明契约的实现相关性,不表示本文的寄存器名称或偏移来自该 ABI。