会写循环和分支,你已经能写出任何计算。但要组织出有规模的程序,必须学会函数——把一段代码打包、随处调用。函数调用是汇编的成人礼:这一讲之后,你读的将不再是「指令」,而是「程序」。

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,闯关模式会逐关检查你的栈帧写法。

本讲三句话

  1. jal ra, func 跳转同时记录返回地址;ret 就是跳回 ra
  2. 栈帧 = 序言保存(ra、s 寄存器、局部变量)+ 尾声还原,sp 进出对齐
  3. 调用约定是软件法律:参数走 a0-a7,t 随便用、s 用了必还

下一讲补上工具箱里的精密工具:逻辑与移位指令,位操作从此上手。