CPU 只有 32 个寄存器,但一段递归可以轻松嵌套一万层,每一层都有自己的一份局部变量、都记得该回到哪里去。这两件事摆在一起是有矛盾的:32 个格子,怎么装下一万层的现场?

答案不在硬件里。RV32I 里没有一条叫「call」的指令,也没有任何一条指令知道「函数」是什么。函数是约定造出来的幻觉——一份编译器之间互相签署的合同,加一块叫做栈的内存。这一篇就把这份合同逐条拆开。

1. 一次函数调用要解决四件事

先别看汇编,想想 C 里这一行发生了什么:

1
int y = f(x) + 1;

拆成机器视角,有四个独立的问题需要各自解决:

问题 说白了 谁来解决
去哪儿 PC 得跳到 f 的第一条指令 jal 指令
怎么回来 f 结束时得知道跳回哪一条 返回地址寄存器 ra
参数怎么递 x 得让 f 看得见 ABI 约定:a0a7
寄存器归谁 f 里也要用寄存器,会不会把我的值踩了 ABI 约定:caller/callee saved

前两个由硬件(指令)解决,后两个纯靠约定——硬件完全不管你有没有遵守。这就是为什么手写汇编最容易死在后两条上。

2. jal 与 jalr:跳转并「留个记号」

jal(jump and link)干两件事,顺序很重要:

1
jal  rd, label      # rd ← PC + 4 ;然后 PC ← label

先把「下一条指令的地址」写进 rd,再跳走。这个被记下来的地址就是返回地址。ABI 约定用 x1(别名 ra)来接,所以我们几乎总写 jal ra, f,伪指令写法 call f

回来靠 jalr(jump and link register):

1
2
jalr rd, offset(rs1)   # rd ← PC + 4 ;PC ← (rs1 + offset) & ~1
ret # 伪指令 = jalr x0, 0(ra)

ret 的展开值得多看一眼:目标是 ra 里存的地址,而 rd 写的是 x0——写 x0 等于丢弃,因为「从函数返回」这个动作不需要再记录返回地址。同一条 jalrrdra 就是间接调用(函数指针、虚函数分派),rdx0 就是返回。硬件只有一条指令,两种用法全靠 rd 的选择区分。

先看最简单的情况——叶子函数(自己不再调用别人):

1
2
3
4
5
# int add3(int a, int b, int c) { return a + b + c; }
add3:
add a0, a0, a1 # 参数在 a0,a1,a2;返回值也放 a0
add a0, a0, a2
ret # ra 从进来到现在没被动过,直接跳回

三条指令,没有栈,没有序言。叶子函数是最省的:只要不调用别人,ra 就不会被覆盖,也就不必存。

3. 只有寄存器,就撑不住第二层

add3 改成非叶子的,问题立刻炸开:

1
2
3
f:
jal ra, g # 这一条把 ra 覆盖成「f 内部的下一条地址」
ret # 于是 ret 跳回 f 自己 —— 死循环

三重矛盾同时出现:

  1. 返回地址只有一份ra 是寄存器,第二次 jal 写进去,第一次的值就没了。
  2. 寄存器不够分f 想在调用 g 前后都用同一个值,但 g 内部也要用寄存器。
  3. 递归要求「每层一份」sumrec(5) 同时存在 5 个 n,寄存器给不出 5 个独立副本。

三条都指向同一个需求:需要一块能按调用深度自动增长、后进先出的内存。这就是栈。

4. 栈:sp、栈帧与地址方向

RISC-V 用 x2(别名 sp)指向栈顶。栈从高地址往低地址生长——开辟空间是 sp 减法,释放是加法

1
2
3
addi sp, sp, -16     # 开 16 字节
...
addi sp, sp, 16 # 还回去

一次调用在栈上占的那一段,叫这个函数的栈帧(stack frame / activation record)。画出来是这样(注意地址方向:自下而上递增):

一个栈帧的内部布局(RISC-V,fp 指向帧顶) 高地址 ↑ 低地址 ↓ 调用者(caller)的栈帧 …… 第 9 个及以后的参数(溢出区,调用者写入) saved ra ← fp - 4 saved fp (s0) ← fp - 8 被调用者保存的 s1…s11(按需) 本函数的局部变量、数组、临时空间 为「我要调用的人」准备的参数溢出区 fp = 进入时 的 sp sp(当前) 帧大小必须向上取整到 16 的倍数 —— sp 时刻保持 16 字节对齐

栈帧不是硬件概念,硬件只知道 sp 是个普通寄存器。是编译器统一按这个格式摆放,才让「函数」和「调试器能打出调用栈」成为可能。

5. 调用约定:第二份合同

上一课说 ISA 是硬件与编译器之间的合同。ABI(Application Binary Interface)是编译器与编译器之间的合同:你编译的 .o 和我编译的 .o 要能链接起来互相调用,就必须对「参数放哪、返回值放哪、寄存器谁负责」达成一致。

RISC-V 整数调用约定的核心表:

寄存器 别名 用途 谁负责保存
x0 zero 恒为 0 ——
x1 ra 返回地址 调用者(caller-saved)
x2 sp 栈指针 被调用者(必须原样还回)
x5–x7, x28–x31 t0–t6 临时值 调用者(caller-saved)
x10–x11 a0–a1 第 1、2 个参数;返回值 调用者(caller-saved)
x12–x17 a2–a7 第 3–8 个参数 调用者(caller-saved)
x8 s0 / fp 帧指针 被调用者(callee-saved)
x9, x18–x27 s1–s11 长期变量 被调用者(callee-saved)

caller-saved 与 callee-saved 的区别,是「谁倒霉」的区别,不是「谁更安全」的区别:

  • t0(caller-saved):被调用者可以随便改。所以调用者如果调用后还要用 t0,得自己先存起来。
  • s1(callee-saved):调用者可以放心地假设调用前后 s1 不变。代价转移给被调用者——它想用 s1,就必须在序言里存、在尾声里恢复。
同一次调用,两类寄存器的责任落在不同人身上 调用者 caller 调用前: 存 t0–t6 / a0–a7 存 ra(若自己也 是被调用者) 调用后: s0–s11 可直接信 被调用者 callee 序言: 存要用的 s0–s11 正文: t0–t6 随便用 尾声: 恢复 s*,还原 sp jal ra, callee (参数在 a0–a7) ret (返回值在 a0/a1)

为什么要分两类、不干脆全都 callee-saved?因为存取寄存器是要花指令的。分成两类,编译器可以做局部优化:短命的中间值放 t*(调用前没必要保留,一条存指令都省了),跨调用还要活的值放 s*(存一次,之后随便调多少次都不用管)。两边各占一半,总的存取次数最少。

6. 序言与尾声:一个模板背下来

非叶子函数的标准骨架,几乎是机械的:

1
2
3
4
5
6
7
8
9
10
f:
addi sp, sp, -32 # ① 开帧(大小取 16 的倍数)
sw ra, 28(sp) # ② 存返回地址
sw s0, 24(sp) # ③ 存要用的 callee-saved
addi s0, sp, 32 # ④ 建帧指针 fp = 进入时的 sp(可选)
# ---------- 函数体 ----------
lw s0, 24(sp) # ⑤ 恢复
lw ra, 28(sp)
addi sp, sp, 32 # ⑥ 收帧
ret

顺序上有个细节值得记住:尾声必须先 lw 恢复、再 addi sp 抬高。反过来写(先抬 sp,再从 sp 下方读)在单线程里似乎也「能跑」,但那块内存已经名义上不属于你了——信号处理函数、中断上下文都可能立刻拿它去用,读回来是垃圾。这类 bug 只在高负载时偶发,最难查。

7. 实跑:递归求和的完整汇编

把上面所有约定用起来,写 sumrec(n) = n + sumrec(n-1)sumrec(0) = 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
main:
li a0, 5
jal ra, sumrec
ecall # 打印 a0
j halt

sumrec:
addi sp, sp, -16 # 序言:开 16 字节帧
sw ra, 12(sp) # 存返回地址(非叶子,必须)
sw s0, 8(sp) # 存 s0(我要用它跨调用)
beq a0, x0, base
mv s0, a0 # s0 = n,跨调用后还要用它做加法
addi a0, a0, -1 # 参数 = n-1
jal ra, sumrec # ra 被覆盖,但栈上有备份
add a0, a0, s0 # 返回值 = sumrec(n-1) + n
j epi
base:
li a0, 0
epi:
lw s0, 8(sp) # 尾声:先恢复
lw ra, 12(sp)
addi sp, sp, 16 # 再收帧
ret
halt:
nop

这段汇编我在一个自己写的 RV32I 迷你解释器上真跑了一遍(在上一课那个解释器基础上补了 jal/jalr/sw/lw 和一块 4096 字节的栈内存)。核心执行循环:

1
2
3
4
5
6
7
8
9
10
11
elif op == 'jal':
if len(a) == 2: self.x[rn(a[0])] = pc + 1; nxt = self.L(a[1]) # jal rd, label
else: self.x[1] = pc + 1; nxt = self.L(a[0]) # jal label
elif op == 'jalr':
t = self.x[rn(a[0])]; self.x[1] = pc + 1; nxt = t
elif op == 'ret':
nxt = self.x[1] # jalr x0, 0(ra)
elif op == 'sw':
off, r = self.memoff(a[1]); self.st(self.x[r] + off, self.x[rn(a[0])])
elif op == 'lw':
off, r = self.memoff(a[1]); self.x[rn(a[0])] = self.ld(self.x[r] + off)

跑出来:

1
2
3
=== 正确版本(有序言/尾声) ===
[输出] a0 = 15
执行指令数 = 79, 最终 sp = 4096

5+4+3+2+1 = 15,对。更值得注意的是第二行:最终 sp 回到了初始值 4096。这是判断调用约定写对了没有的最快自检——所有帧都被正确收回,一个字节都没漏。

前 14 步的 trace,可以看着 sp 一层层往下掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pc=0   li   a0, 5             sp=4096  ra=0    a0=0
pc=1 jal ra, sumrec sp=4096 ra=0 a0=5
pc=4 addi sp, sp, -16 sp=4096 ra=2 a0=5
pc=5 sw ra, 12(sp) sp=4080 ra=2 a0=5
pc=6 sw s0, 8(sp) sp=4080 ra=2 a0=5
pc=7 beq a0, x0, base sp=4080 ra=2 a0=5
pc=8 mv s0, a0 sp=4080 ra=2 a0=5
pc=9 addi a0, a0, -1 sp=4080 ra=2 a0=5
pc=10 jal ra, sumrec sp=4080 ra=2 a0=4
pc=4 addi sp, sp, -16 sp=4080 ra=11 a0=4
pc=5 sw ra, 12(sp) sp=4064 ra=11 a0=4
pc=6 sw s0, 8(sp) sp=4064 ra=11 a0=4
pc=7 beq a0, x0, base sp=4064 ra=11 a0=4
pc=8 mv s0, a0 sp=4064 ra=11 a0=4

4096 → 4080 → 4064,每层 16 字节。第二次进入 sumrecra 从 2 变成了 11——ra 确实被覆盖了,而第一层的返回地址 2 安安稳稳躺在地址 4092 上。

8. 漏掉一条 sw ra 会怎样

把序言尾声全删掉,其余逻辑不动:

1
2
3
4
5
6
7
8
9
10
bad:
beq a0, x0, bbase
mv s0, a0
addi a0, a0, -1
jal ra, bad # ra 被覆盖,且没有备份
add a0, a0, s0
ret
bbase:
li a0, 0
ret

同一个解释器,bad(3)

1
2
3
=== 忘记保存 ra 的版本 ===
!! 超过 3000 条指令,判定为死循环
执行指令数 = 3001

不是「结果算错」,是根本回不来。递归到底层执行 retra 里存的是「最深一层 jal 之后的地址」,于是跳回自己函数体中间,再往下走又是一次 jal……原地打转。

栈帧链画出来就一目了然——有帧的版本,每层的返回地址各有各的家;没帧的版本,所有层共用一个 ra 格子:

递归三层:有栈帧 vs 无栈帧✔ 有序言/尾声 ✘ 无栈帧 高 ↑ 低 ↓ main 的帧 sumrec(5) ra→main s0=5 局部:n=5 sumrec(4) ra→(5) s0=4 局部:n=4 sumrec(3) ra→(4) s0=3 局部:n=3 sp ↓ 每层减 16,返回时逐层加回 bad(3) ra = ? (无帧,无处存) bad(2) ra 被覆盖 上一层的 ra 已丢失 bad(1) ra 又被覆盖 ret → 跳回自己体内 三层共用同一个 ra 寄存器 → 原地打转

9. 帧指针与调用栈回溯

上面模板里第 ④ 步 addi s0, sp, 32 是可选的,它建立帧指针 fp(就是 s0)。有了它,局部变量的地址可以写成 fp - 12 这种固定偏移,即使函数体中间又动了 sp(比如变长数组)也不受影响。

更重要的用途是回溯。因为每一帧都在固定位置存了「上一层的 ra」和「上一层的 fp」,调试器只要拿到当前 fp 就能顺着链条一路往上爬:

1
2
3
4
5
6
7
8
9
10
11
// 调用栈回溯的原理(示意;真实实现要处理无 fp 的帧和信号帧)
void backtrace(void) {
void **fp = __builtin_frame_address(0);
for (int depth = 0; depth < 32 && fp; depth++) {
void *ret_addr = fp[-1]; // fp - 4:上一层的返回地址
void *caller_fp = fp[-2]; // fp - 8:上一层的 fp
printf("#%d %p\n", depth, ret_addr);
if (caller_fp <= (void *)fp) break; // 地址必须递增,否则链断了
fp = caller_fp;
}
}

这也解释了两个日常现象:

  • 开了 -O2 之后 backtrace 变得残缺不全。因为优化会打开 -fomit-frame-pointer——fp 不再维护,那个格子省下来当普通寄存器用了。调试器只能靠 .eh_frame 里的 unwind 表来推算,信息缺失时就断链。
  • 尾调用优化(tail call)之后,栈上根本不存在中间那一层帧,所以 backtrace 里那个函数「凭空消失」。它不是没被调用,是被复用成了同一个帧。

10. 从 C 一侧能观察到什么

调用约定不是汇编专属知识,它决定了一批 C 语言层面的行为边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int depth_probe(int n) {
int local = n; // 分配在本层栈帧里
printf("depth=%d &local=%p\n", n, (void *)&local);
if (n == 0) return 0;
return local + depth_probe(n - 1);
}

int *dangling(void) {
int tmp = 42;
return &tmp; // 返回一个即将被回收的帧里的地址
}

depth_probe(3) 打印出的地址会逐层递减(每层差一个帧的大小,典型 16–48 字节,随编译选项变化);这正是栈向低地址生长的直接证据。而 dangling() 返回的指针,在 ret 执行完的那一刻就已经指向「不属于任何人」的区域——下一次函数调用会把那块内存重新分配出去,值被悄悄改写。编译器一般会给 warning,但它不是错误,因为语法完全合法。

同一份约定还解释了这些:

C 现象 调用约定层面的原因
栈上开 int buf[1000000] 直接崩 栈总大小有限(常见 8MB),一帧要不下
递归太深 SIGSEGV sp 撞到守护页(guard page)
传结构体比传指针慢 超过 2 个寄存器宽度的结构体要走内存拷贝
参数超过 8 个后变慢 第 9 个起走栈上的参数溢出区,要存要读
setjmp/longjmp 能跨函数跳 它保存/恢复的正是 spra 和一批 callee-saved 寄存器

11. 坑合集

现象 原因 / 正确做法
非叶子函数漏存 ra 死循环或跳到奇怪地方 只要函数体里有 jal,序言必须 sw ra
帧大小不是 16 的倍数 调用库函数时崩,浮点指令异常 帧大小向上取整;sp 全程 16 字节对齐
跨调用用了 t0t6 调用回来值变了 s0s11,或调用前自己压栈
改了 s* 但没恢复 调用者的变量莫名其妙变化 序言存、尾声恢复,成对出现
尾声顺序写反 偶发读到垃圾值(高负载下才出现) lw 恢复,再 addi sp 抬高
返回局部变量地址 用了一会儿才出错,难复现 返回值拷贝,或改用堆/由调用者传入缓冲区
多个出口只在一处写尾声 某条路径上 sp 没收回 所有 return 路径统一跳到同一个 epi 标签
手写汇编与 C 混调时参数错位 收到垃圾参数 严格按 a0a7 顺序;long long 在 RV32 占两个寄存器

第七条是我自己写这篇的汇编时踩到的:sumrec 有 base case 和一般情况两个出口,一开始在 base case 里直接 ret 了,sp 没收回去——sp 每次少还 16 字节,跑几层就漂了。把所有出口都 j epi 收拢到同一段尾声,这类错误从结构上就不会发生。

12. 机器栈帧与 JVM 栈帧:一个中性对照

C 和 Java 都有「调用栈」,但两边的栈帧是两种东西,各自解决各自的问题:

机器栈帧(C / RISC-V) JVM 栈帧
由谁布局 编译器按 ABI 摆放 JVM 按 class 文件里的 max_locals / max_stack 分配
局部变量在哪 帧内某个 fp 偏移,或干脆在寄存器里 局部变量表,按槽位索引(iload_1
中间结果在哪 寄存器(t*/s* 操作数栈(字节码层面)
参数怎么传 a0a7,超出走栈 压入被调用方法的局部变量表
返回地址 ra 寄存器 + 帧内备份 JVM 内部记录,字节码里不可见
栈大小 线程栈,常见 8MB,撞守护页即 SIGSEGV -Xss 控制,溢出抛 StackOverflowError
溢出可恢复吗 一般不可,进程直接死 是个 Error,语法上可 catch(但不建议)
谁保存寄存器 源码/编译器按 caller/callee saved 划分 字节码不管;JIT 编译后仍要遵守宿主机 ABI

关键在最后一行:JVM 的栈帧是一层抽象,它让字节码不必知道目标机有几个寄存器。但 JIT 把热方法编译成机器码之后,那份机器码同样要开真实栈帧、同样要遵守 a0a7 和 caller/callee saved 的划分。抽象只是推迟了这份合同的生效时间,没有取消它。所以 Java 里一个深递归最终也会撞到真实的机器栈——StackOverflowError 的物理基础,和 C 的 SIGSEGV 是同一堵墙,只是 JVM 提前一步替你检查了。

13. 🐾 小结

要点 一句话
函数是什么 硬件里没有函数,只有 jal/jalr 加一份约定造出的幻觉
jal 干什么 先把 PC+4 存进 rd,再跳走;ret = jalr x0, 0(ra)
叶子函数 不调用别人,就不必存 ra,也可以不开帧
为什么需要栈 返回地址只有一份、寄存器不够、递归要求每层一份现场
栈的方向 向低地址生长:开帧 sp 减,收帧 sp
栈帧装什么 saved ra、saved fp、callee-saved 备份、局部变量、参数溢出区
caller/callee saved 不是安全等级之分,是「谁花指令去存」的责任划分
两类寄存器的意义 短命值放 t*、跨调用值放 s*,总存取次数最少
序言尾声铁律 成对出现;恢复先于抬 sp;多出口统一收拢到一处
自检最快的一招 函数返回后 sp 必须回到进入前的值,一个字节都不能差
fp 的价值 固定偏移访问局部变量 + 让调试器能顺着链条回溯调用栈
最大的收获 「调用栈」不是抽象名词,它是一段真实内存加一份人为约定