调用函数前后存在旧数据与新数据两种状态,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 点,也是调用者视角下的调用约定:
- 在调用者函数内,在调用函数前后,必须通过栈内存手动维护由调用者保存的寄存器前后一致(如
a0
和t0
); - 在调用者函数内,可以任意修改由被调用者保存寄存器而不用担心副作用(如
s0
和s1
)。
被调用者视角
理解调用者视角下的调用约定后,就不难理解被调用者视角下的操作了。直接观察下例:
# 函数正式操作前,被调用者需要做的事
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 点调用约定:
- 在被调用者函数内,在函数正式操作前后,必须通过栈内存手动维护由被调用者保存的寄存器前后一致(如
ra
、s0
和s1
); - 在被调用者函数内,可以任意修改由调用者保存寄存器而不用担心副作用。
总而言之,RISC-V 的调用约定中规定了寄存器的保存方(saver),函数(caller / callee)对其相应寄存器中的内容负责,caller 维护 caller-saved 寄存器,callee 维护 callee-saved 寄存器。因为需要维护寄存器内容,在函数正式操作前都要把需要维护的内容存入栈内存,并在函数操作结束后从中还原。