RISC-V 函数调用约定
调用函数前后存在旧数据与新数据两种状态,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 中的值是垃圾值! {warn}在调用函数后,不由被调用者负责寄存器中的值是否发生改变,实际取决于函数的实现,但作为负责编码的工程师,理应将这些寄存器中的值都视为垃圾,不应当依赖垃圾值执行程序。{end warn} 规避垃圾值问题的技巧是,在寄存器中的值变得不可靠前,预先将其中值保存下来: 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 寄存器。因为需要维护寄存器内容,在函数正式操作前都要把需要维护的内容存入栈内存,并在函数操作结束后从中还原。 References Calling Convention - CS 61C Fall 2024 Calling Convention - RISC-V