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。如果在需要自行设置栈的裸机环境中运行,还需要先初始化栈指针。
备注:内容仅供参考。
