会写循环和分支,你已经能写出任何计算。但要组织出有规模的程序,必须学会函数——把一段代码打包、随处调用。函数调用是汇编的成人礼:这一讲之后,你读的将不再是「指令」,而是「程序」。
jal:跳过去,顺便记路
函数调用的核心难题:跳出去容易,怎么知道回来? RISC-V 的答案是一条指令解决:
jal ra, func # 跳到 func,并把返回地址(下一条指令的 PC+4)写进 ra
jal(jump and link)在跳转的同时把回来的路记进寄存器。不需要返回值的场景(比如 j)把 rd 写成 zero 就等于丢弃。对应的返回指令:
ret # 伪指令,本质是 jalr zero, ra, 0 —— 跳回 ra 指向的地址
注意一个常见误区:返回地址是 jal 的下一条指令地址(PC+4),不是调用者函数的任何地方。这个精确性是第 08 讲理解延迟槽不存在、以及中断处理的基础。
栈:被调者的「记事本」
调用约定规定 t0-t6 是调用者保存(call 头也可能被覆盖),s0-s11 是被调者保存(谁用谁负责还原)。如果一个函数要用 s 寄存器、或者它自己还要调别人(ra 会被覆盖),它必须先把这些值存进栈:
func:
addi sp, sp, -16 # 栈向下生长,先开 16 字节空间
sw ra, 12(sp) # 保存返回地址(因为函数内还要调别人)
sw s0, 8(sp) # 保存要用的 s0
# ... 函数体 ...
lw ra, 12(sp) # 离场前还原
lw s0, 8(sp)
addi sp, sp, 16 # 收回空间
ret # 跳回 ra
这三段固定的开场和收尾叫序言(prologue)和尾声(epilogue)。编译器给每个 C 函数生成的汇编,开头结尾都是它们。栈从高地址向低地址生长,sp 永远指向栈顶——这就是为什么启动代码第一件事是初始化 sp。
调用约定:一张默契表
参数怎么传、结果放哪、谁保存谁——这些没有硬件强制,全靠软件约定(calling convention),但它就是所有编译器和操作系统共同遵守的法律:
| 寄存器 | 角色 | 保存责任 |
|---|---|---|
a0–a7 |
传参数(多于 8 个走栈);a0/a1 传返回值 |
调用者保存 |
ra |
返回地址 | 调用者保存 |
t0–t6 |
临时值,跨调用不可靠 | 调用者保存 |
s0–s11 |
跨调用存活的值 | 被调者保存 |
sp |
栈指针 | 专用(进出对齐还原) |
所以调用一个函数前,调用者把参数放进 a0-a7;被调者随便用 t 寄存器、但动了 s 寄存器就必须还原。C 编译器帮你做了这一切——但你现在知道了它做的是什么。
串起来:递归阶乘
# int fact(int n) —— n 从 a0 进,结果从 a0 出
fact:
addi sp, sp, -16
sw ra, 12(sp) # 递归会覆盖 ra,必须保存
sw s0, 8(sp)
mv s0, a0 # s0 = n
addi t0, zero, 1
bge t0, a0, base # n <= 1 走基线
addi a0, a0, -1
jal ra, fact # fact(n-1),ra 被覆盖(所以才保存)
mul a0, a0, s0 # 结果 × n(mul 来自 M 扩展)
j epilog
base:
addi a0, zero, 1
epilog:
lw ra, 12(sp)
lw s0, 8(sp)
addi sp, sp, 16
ret
每一层递归都在栈上留下一帧(返回地址 + n),展开时逐层还原——调试器看到的「调用栈」就是这个结构。栈溢出也一样:递归太深,sp 一路下探撞进别的内存区,就是事故现场。
动手验证
把阶乘敲进模拟器,先调 fact(5) 验证得 120;然后单步观察:每次 jal 进下一层,sp 减 16、栈里压入一帧;ret 弹出。再看 ra 如何在第 2 层被覆盖——理解了它为什么必须在序言里保存,你就理解了栈帧的全部动机。
免费下载 HI RISC-V App,闯关模式会逐关检查你的栈帧写法。
本讲三句话
jal ra, func跳转同时记录返回地址;ret就是跳回ra- 栈帧 = 序言保存(ra、s 寄存器、局部变量)+ 尾声还原,
sp进出对齐 - 调用约定是软件法律:参数走
a0-a7,t随便用、s用了必还
下一讲补上工具箱里的精密工具:逻辑与移位指令,位操作从此上手。