过程调用与栈帧 / 调用约定
CPU 只有 32 个寄存器,但一段递归可以轻松嵌套一万层,每一层都有自己的一份局部变量、都记得该回到哪里去。这两件事摆在一起是有矛盾的:32 个格子,怎么装下一万层的现场?
答案不在硬件里。RV32I 里没有一条叫「call」的指令,也没有任何一条指令知道「函数」是什么。函数是约定造出来的幻觉——一份编译器之间互相签署的合同,加一块叫做栈的内存。这一篇就把这份合同逐条拆开。
1. 一次函数调用要解决四件事
先别看汇编,想想 C 里这一行发生了什么:
1 | int y = f(x) + 1; |
拆成机器视角,有四个独立的问题需要各自解决:
| 问题 | 说白了 | 谁来解决 |
|---|---|---|
| 去哪儿 | PC 得跳到 f 的第一条指令 |
jal 指令 |
| 怎么回来 | f 结束时得知道跳回哪一条 |
返回地址寄存器 ra |
| 参数怎么递 | x 得让 f 看得见 |
ABI 约定:a0–a7 |
| 寄存器归谁 | 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 | jalr rd, offset(rs1) # rd ← PC + 4 ;PC ← (rs1 + offset) & ~1 |
ret 的展开值得多看一眼:目标是 ra 里存的地址,而 rd 写的是 x0——写 x0 等于丢弃,因为「从函数返回」这个动作不需要再记录返回地址。同一条 jalr,rd 填 ra 就是间接调用(函数指针、虚函数分派),rd 填 x0 就是返回。硬件只有一条指令,两种用法全靠 rd 的选择区分。
先看最简单的情况——叶子函数(自己不再调用别人):
1 | # int add3(int a, int b, int c) { return a + b + c; } |
三条指令,没有栈,没有序言。叶子函数是最省的:只要不调用别人,ra 就不会被覆盖,也就不必存。
3. 只有寄存器,就撑不住第二层
把 add3 改成非叶子的,问题立刻炸开:
1 | f: |
三重矛盾同时出现:
- 返回地址只有一份。
ra是寄存器,第二次jal写进去,第一次的值就没了。 - 寄存器不够分。
f想在调用g前后都用同一个值,但g内部也要用寄存器。 - 递归要求「每层一份」。
sumrec(5)同时存在 5 个n,寄存器给不出 5 个独立副本。
三条都指向同一个需求:需要一块能按调用深度自动增长、后进先出的内存。这就是栈。
4. 栈:sp、栈帧与地址方向
RISC-V 用 x2(别名 sp)指向栈顶。栈从高地址往低地址生长——开辟空间是 sp 减法,释放是加法:
1 | addi sp, sp, -16 # 开 16 字节 |
一次调用在栈上占的那一段,叫这个函数的栈帧(stack frame / activation record)。画出来是这样(注意地址方向:自下而上递增):
栈帧不是硬件概念,硬件只知道 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,就必须在序言里存、在尾声里恢复。
为什么要分两类、不干脆全都 callee-saved?因为存取寄存器是要花指令的。分成两类,编译器可以做局部优化:短命的中间值放 t*(调用前没必要保留,一条存指令都省了),跨调用还要活的值放 s*(存一次,之后随便调多少次都不用管)。两边各占一半,总的存取次数最少。
6. 序言与尾声:一个模板背下来
非叶子函数的标准骨架,几乎是机械的:
1 | f: |
顺序上有个细节值得记住:尾声必须先 lw 恢复、再 addi sp 抬高。反过来写(先抬 sp,再从 sp 下方读)在单线程里似乎也「能跑」,但那块内存已经名义上不属于你了——信号处理函数、中断上下文都可能立刻拿它去用,读回来是垃圾。这类 bug 只在高负载时偶发,最难查。
7. 实跑:递归求和的完整汇编
把上面所有约定用起来,写 sumrec(n) = n + sumrec(n-1),sumrec(0) = 0:
1 | main: |
这段汇编我在一个自己写的 RV32I 迷你解释器上真跑了一遍(在上一课那个解释器基础上补了 jal/jalr/sw/lw 和一块 4096 字节的栈内存)。核心执行循环:
1 | elif op == 'jal': |
跑出来:
1 | === 正确版本(有序言/尾声) === |
5+4+3+2+1 = 15,对。更值得注意的是第二行:最终 sp 回到了初始值 4096。这是判断调用约定写对了没有的最快自检——所有帧都被正确收回,一个字节都没漏。
前 14 步的 trace,可以看着 sp 一层层往下掉:
1 | pc=0 li a0, 5 sp=4096 ra=0 a0=0 |
4096 → 4080 → 4064,每层 16 字节。第二次进入 sumrec 时 ra 从 2 变成了 11——ra 确实被覆盖了,而第一层的返回地址 2 安安稳稳躺在地址 4092 上。
8. 漏掉一条 sw ra 会怎样
把序言尾声全删掉,其余逻辑不动:
1 | bad: |
同一个解释器,bad(3):
1 | === 忘记保存 ra 的版本 === |
不是「结果算错」,是根本回不来。递归到底层执行 ret,ra 里存的是「最深一层 jal 之后的地址」,于是跳回自己函数体中间,再往下走又是一次 jal……原地打转。
栈帧链画出来就一目了然——有帧的版本,每层的返回地址各有各的家;没帧的版本,所有层共用一个 ra 格子:
9. 帧指针与调用栈回溯
上面模板里第 ④ 步 addi s0, sp, 32 是可选的,它建立帧指针 fp(就是 s0)。有了它,局部变量的地址可以写成 fp - 12 这种固定偏移,即使函数体中间又动了 sp(比如变长数组)也不受影响。
更重要的用途是回溯。因为每一帧都在固定位置存了「上一层的 ra」和「上一层的 fp」,调试器只要拿到当前 fp 就能顺着链条一路往上爬:
1 | // 调用栈回溯的原理(示意;真实实现要处理无 fp 的帧和信号帧) |
这也解释了两个日常现象:
- 开了
-O2之后 backtrace 变得残缺不全。因为优化会打开-fomit-frame-pointer——fp不再维护,那个格子省下来当普通寄存器用了。调试器只能靠.eh_frame里的 unwind 表来推算,信息缺失时就断链。 - 尾调用优化(tail call)之后,栈上根本不存在中间那一层帧,所以 backtrace 里那个函数「凭空消失」。它不是没被调用,是被复用成了同一个帧。
10. 从 C 一侧能观察到什么
调用约定不是汇编专属知识,它决定了一批 C 语言层面的行为边界。
1 |
|
depth_probe(3) 打印出的地址会逐层递减(每层差一个帧的大小,典型 16–48 字节,随编译选项变化);这正是栈向低地址生长的直接证据。而 dangling() 返回的指针,在 ret 执行完的那一刻就已经指向「不属于任何人」的区域——下一次函数调用会把那块内存重新分配出去,值被悄悄改写。编译器一般会给 warning,但它不是错误,因为语法完全合法。
同一份约定还解释了这些:
| C 现象 | 调用约定层面的原因 |
|---|---|
栈上开 int buf[1000000] 直接崩 |
栈总大小有限(常见 8MB),一帧要不下 |
| 递归太深 SIGSEGV | sp 撞到守护页(guard page) |
| 传结构体比传指针慢 | 超过 2 个寄存器宽度的结构体要走内存拷贝 |
| 参数超过 8 个后变慢 | 第 9 个起走栈上的参数溢出区,要存要读 |
setjmp/longjmp 能跨函数跳 |
它保存/恢复的正是 sp、ra 和一批 callee-saved 寄存器 |
11. 坑合集
| 坑 | 现象 | 原因 / 正确做法 |
|---|---|---|
非叶子函数漏存 ra |
死循环或跳到奇怪地方 | 只要函数体里有 jal,序言必须 sw ra |
| 帧大小不是 16 的倍数 | 调用库函数时崩,浮点指令异常 | 帧大小向上取整;sp 全程 16 字节对齐 |
跨调用用了 t0–t6 |
调用回来值变了 | 换 s0–s11,或调用前自己压栈 |
改了 s* 但没恢复 |
调用者的变量莫名其妙变化 | 序言存、尾声恢复,成对出现 |
| 尾声顺序写反 | 偶发读到垃圾值(高负载下才出现) | 先 lw 恢复,再 addi sp 抬高 |
| 返回局部变量地址 | 用了一会儿才出错,难复现 | 返回值拷贝,或改用堆/由调用者传入缓冲区 |
| 多个出口只在一处写尾声 | 某条路径上 sp 没收回 |
所有 return 路径统一跳到同一个 epi 标签 |
| 手写汇编与 C 混调时参数错位 | 收到垃圾参数 | 严格按 a0–a7 顺序;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*) |
操作数栈(字节码层面) |
| 参数怎么传 | a0–a7,超出走栈 |
压入被调用方法的局部变量表 |
| 返回地址 | ra 寄存器 + 帧内备份 |
JVM 内部记录,字节码里不可见 |
| 栈大小 | 线程栈,常见 8MB,撞守护页即 SIGSEGV | -Xss 控制,溢出抛 StackOverflowError |
| 溢出可恢复吗 | 一般不可,进程直接死 | 是个 Error,语法上可 catch(但不建议) |
| 谁保存寄存器 | 源码/编译器按 caller/callee saved 划分 | 字节码不管;JIT 编译后仍要遵守宿主机 ABI |
关键在最后一行:JVM 的栈帧是一层抽象,它让字节码不必知道目标机有几个寄存器。但 JIT 把热方法编译成机器码之后,那份机器码同样要开真实栈帧、同样要遵守 a0–a7 和 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 的价值 |
固定偏移访问局部变量 + 让调试器能顺着链条回溯调用栈 |
| 最大的收获 | 「调用栈」不是抽象名词,它是一段真实内存加一份人为约定 |

