RISC-V 函数调用约定

Category 碎碎念

调用函数前后存在旧数据与新数据两种状态,RISC-V 对使用哪些寄存器存储前后状态做了人为规定,这样的一系列规定称为调用约定(Calling Convention)。CS 61C 的补充资料在这方面描述得最为明了,本文主要根据这篇文章对 RISC-V 调用约定的要点做了总结。

基本定义

首先将发起调用的函数称为调用者(caller),将被调用的函数称为被调用者(callee)。注意,一个函数是调用者或被调用者是由其行为决定,当它被其他函数调用时是被调用者,当它调用其他函数时是调用者,两个身份可以先后存在。

其次 RISC-V 约定在一部分寄存器中的内容在调用函数后不会被改变,称为由被调用者保存的寄存器(callee-saved registers),包括 s0 - s11(保存寄存器,saved registers)和 sp。调用函数可能更改另一部分寄存器中的内容,这些寄存器称为由调用者保存的寄存器(caller-saved registers ),包括 a0 - a7(参数寄存器,argument registers)、t0 - t6(临时寄存器,temporary registers)和 ra(返回地址,return address)。

寄存器的功能和约定可以总结如下表:

编号 寄存器 ABI 名称 描述 保存方
0 x0 zero 常数 0 -
1 x1 ra 返回地址 caller
2 x2 sp 栈指针 callee
3 x3 gp 全局指针 -
4 x4 tp 线程指针 -
5 ~ 7 x6 ~ x7 t0 ~ t2 临时 caller
8 x8 s0 / fp 保存 / 帧指针 callee
9 x9 s1 保存 callee
10 ~ 11 x10 ~ x11 a0 ~ a1 函数参数 / 返回值 caller
12 ~ 17 x12 ~ x17 a2 ~ a7 函数参数 caller
18 ~ 27 x18 ~ x27 s2 ~ s11 保存 callee
28 ~ 31 x28 ~ x31 t3 ~ t6 临时 caller

实际案例

从抽象的概念来看比较难以理解,接下来将以上概念代入到几个具体的案例中理解 RISC-V 的调用约定。

调用者的视角

当我们调用一个函数时,被调用的函数对由其保存的寄存器负责,也就是说由被调用者保存的寄存器内容在该函数调用前后不变。但「不变」并不是指该函数无法使用这些寄存器,实际上函数可以使用任何一个寄存器,RISC-V 中的寄存器并没有「使用权限」的概念,只是在函数结束前必须将修改值恢复原状——这个过程我形象地称其为对寄存器中的值负责(preserve)。

将以上过程抽象为黑盒,从调用者的视角来看,当然可以认为被调用的函数不会修改由被调用者保存寄存器中的值。

上述过程可以通过以下代码理解:

addi s0, x0, 5     # 寄存器 s0 的值为 5
jal ra, func       # 调用 func
addi s0, s0, 0     # 不论 func 是什么,s0 的值还是 5

从反面来思考,就会意识到被调用的函数不对由调用者保存的寄存器负责,也就是说在该函数结束后,由调用者保存寄存器中的值是不可靠的垃圾值:

addi t0, x0, 5     # 寄存器 s0 的值为 5
jal ra, func       # 调用 func
addi t0, t0, 0     # t0 中的值是垃圾值!

 Warning 在调用函数后,不由被调用者负责寄存器中的值是否发生改变,实际取决于函数的实现,但作为负责编码的工程师,理应将这些寄存器中的值都视为垃圾,不应当依赖垃圾值执行程序。

规避垃圾值问题的技巧是,在寄存器中的值变得不可靠前,预先将其中值保存下来:

addi t0, x0, 5     # t0 中的值为 5
addi a0, t0, 10    # a0 中的值为 15,a0 是函数参数

# 调用函数前,调用者需要做的事
addi sp, sp, -8    # 栈指针向下移动
sw t0, 0(sp)       # 将 t0 的值压入栈帧
sw a0, 4(sp)       # 将 a0 的值压入栈帧

jal ra, func       # 调用函数 func
mv s0, a0          # 将函数返回值 a0 中的值存入 s0
mv s1, a1          # 将函数返回值 a1 中的值存入 s1

# 调用函数后,调用者需要做的事
lw t0, 0(sp)       # 从栈帧中弹出原先 t0 的值,并把值写入 t0
lw a0, 4(sp)       # 从栈帧中弹出原先 a0 的值,并把值写入 a0
addi sp, sp, 8     # 栈指针向上移动

# 目前 t0 与 a0 的值都是可靠的,因为它们的值是预先存入栈帧并从中还原的

从上面的代码中可以观察出 2 点,也是调用者视角下的调用约定:

  1. 调用者函数内,在调用函数前后,必须通过栈内存手动维护由调用者保存的寄存器前后一致(如 a0t0);
  2. 调用者函数内,可以任意修改由被调用者保存寄存器而不用担心副作用(如 s0s1)。

被调用者视角

理解调用者视角下的调用约定后,就不难理解被调用者视角下的操作了。直接观察下例:

# 函数正式操作前,被调用者需要做的事
addi sp, sp, -12   # 栈指针向下移动
sw ra, 0(sp)       # 将 ra 的值压入栈帧
sw s0, 4(sp)       # 将 s0 的值压入栈帧
sw s1, 8(sp)       # 将 s1 的值压入栈帧

# 函数正式操作

# 函数正式操作后,被调用者需要做的事
lw ra, 0(sp)       # 从栈帧中弹出原先 ra 的值,并把值写入 ra
lw s0, 4(sp)       # 从栈帧中弹出原先 s0 的值,并把值写入 s0
lw s1, 8(sp)       # 从栈帧中弹出原先 s1 的值,并把值写入 s1
addi sp, sp, 12    # 栈指针向上移动

ret                # 从函数中返回

可以得出类似的 2 点调用约定:

  1. 被调用者函数内,在函数正式操作前后,必须通过栈内存手动维护由被调用者保存的寄存器前后一致(如 ras0s1);
  2. 被调用者函数内,可以任意修改由调用者保存寄存器而不用担心副作用。

总而言之,RISC-V 的调用约定中规定了寄存器的保存方(saver),函数(caller / callee)对其相应寄存器中的内容负责,caller 维护 caller-saved 寄存器,callee 维护 callee-saved 寄存器。因为需要维护寄存器内容,在函数正式操作前都要把需要维护的内容存入栈内存,并在函数操作结束后从中还原。


References