PAYATHON 2026

RISC-V 为什么常用 12(sp) 保存返回地址,可以改用 0(sp) 吗?

数据阿明

可以改用 0(sp)。对于题目中的 RV32 代码,只要先分配栈空间,确保该位置没有其他用途,并在返回前从同一位置恢复 ra,就可以把 ra 保存到 0(sp)。标准 calling convention 要求 sp 保持 16 字节对齐,但没有统一规定返回地址必须保存在 12(sp)。

16 字节栈帧与偏移量是两件事

这两条指令各自完成一项操作:

addi sp, sp, -16
sw ra, 12(sp)

第一条将 sp 减去 16,为当前函数分配一个 16 字节的栈帧,并不会初始化整个栈。如果原来的 sp 满足 16 字节对齐,减去 16 后仍然满足要求。

第二条将 ra 的 4 字节值写入地址 sp + 12,不会改变 sp。

以调整后的 sp 为基准,栈帧布局如下:

地址范围含义
sp + 0 到 sp + 3可以用于保存一个 4 字节值
sp + 4 到 sp + 7当前栈帧中的空间
sp + 8 到 sp + 11当前栈帧中的空间
sp + 12 到 sp + 15示例中保存 ra 的位置
sp + 16调整前的 sp

0(sp) 和 12(sp) 都在新分配的栈帧内,也都满足 4 字节访问的自然对齐要求。

为什么示例经常选择 12(sp)

12(sp) 对应这个 16 字节栈帧中地址最高的 4 字节槽位。将返回地址放在栈帧靠近入口 sp 的位置,是一种常见的布局方式。

这也与使用 frame pointer 的栈帧记录布局有关。在 RV32 中,如果采用标准定义的 frame record,返回地址保存在 fp - 4,前一个 frame pointer 保存在 fp - 8。当 fp 指向函数入口时的 sp,且当前栈帧大小为 16 字节时,fp - 4 就等于调整后的 sp + 12。

题目中的函数没有建立 frame pointer,也没有保存前一个 frame pointer,因此不能仅凭 sw ra, 12(sp) 就认为它建立了完整的 frame record。这个偏移量也不是必须遵守的固定位置。

如何改成 0(sp)

同时修改保存和恢复的位置后,fun1 可以写成:

fun1:
    add a0, a0, a1

    addi sp, sp, -16
    sw ra, 0(sp)

    jal ra, fun2

    lw ra, 0(sp)
    addi sp, sp, 16

    jalr zero, 0(ra)

这里仍然分配 16 字节,是为了在调用 fun2 时保持标准 ABI 要求的栈对齐。即使只需要保存 4 字节的 ra,也不应直接改成 addi sp, sp, -4,否则原本对齐的 sp 会失去 16 字节对齐。

保存和恢复必须使用同一个位置,并且要在释放栈帧之前执行 lw。

为什么 fun1 需要保存 ra

执行下面的调用时:

jal ra, fun1

ra 保存的是 _start 中调用之后那条指令的地址。

随后,fun1 执行:

jal ra, fun2

ra 会被覆盖为 fun1 中该调用之后的地址。因此,fun1 需要先保存原来的 ra,等 fun2 返回后再恢复,才能返回 _start。

题目中的 fun2 没有继续调用其他函数,也没有修改 ra,所以可以直接使用收到的返回地址返回。

使用时需要注意

  • 0(sp) 必须指调整后的 sp。应先分配栈帧,再向其中写入数据。
  • 如果以后需要在栈上传递参数或保存其他数据,应检查槽位是否重叠。0(sp) 不能同时承担两个仍然有效的用途。
  • 这里按 RV32 讨论,sw 和 lw 可以保存、恢复完整的 32 位返回地址。RV64 通常需要使用 sd 和 ld,并为返回地址预留 8 字节。
  • 这段代码假设进入 _start 时,运行环境已经提供有效且对齐的 sp。如果在需要自行设置栈的裸机环境中运行,还需要先初始化栈指针。

备注:内容仅供参考。