详解RISC-V函数调用
RISC-V函数调用时应该遵守什么规定,在函数调用的过程中发生了什么变化?
1. 符号约定
本文使用32位RISC-V作为示例
寄存器:
ra
:返回地址(Return address)寄存器,即x1
寄存器sp
:栈指针寄存器,即x2
寄存器t0-t2, t3-t6
:临时寄存器,即x5-x7, x28-x31
寄存器s0-s1, s2-s11
:保存寄存器,即x8-x9, x18-x27
寄存器a0-a7
:函数参数寄存器,即x10-x17
寄存器
2. jal和jalr
在函数调用中我们需要用到两条关键的指令:jal
和jalr
。我们通过jal
发起函数调用,通过jalr
来从函数中返回。
jump and link,
jal rd, Label
:UJ型指令,指令格式:immediate[20,10:1,11,19:12]rdopcode
使用
jal
指令时需要指定一个寄存器rd和一个标签Label(会在汇编阶段被翻译成立即数immediate),该指令会将PC+4保存到rd寄存器中,并且跳转到标签位置(PC + immediate)继续执行指令jump and link register,
jalr rd, imm(r1)
:I型指令,指令格式:immediate[11:0]rs1funct3rdopcode
使用
jalr
指令时会将PC+4保存到rd寄存器中,并且跳转到地址rs1 + immediate继续执行指令
因为在汇编中我们用标签来表示后续的指令属于同一个函数,所以我们用可以跳转到标签的jal
指令来发起函数调用。又因为jal
将函数执行完毕后要继续执行的下一条指令的地址保存到了ra
寄存器中,所以我们用可以跳转到寄存器中地址的jalr
指令来从函数中返回。
使用jal
和jalr
的组合来调用函数:
1 | # 指令 # 指令的地址 |
对jalr x0, 0(ra)
的解释:在该条指令中我们使用x0
寄存器作为rd
寄存器来写入,由于x0
寄存器的值始终为0,所以相当于丢弃掉了pc+4的地址,丢弃该地址是因为我们不需要使用该地址,我们从函数中跳转回来,"return语句"之后的指令地址是没有意义的。
同样,我们可以使用jal x0, Label
来进行跳转,由于没有将pc+4保存,因此后续没办法再使用jalr
指令跳转回来,相当于使用了一条C语言中的goto
语句。
不要混淆两者进而写出jal x0, ra或者jalr x0, Label这样的指令!给jal指令提供跳转地址必须使用标签而不能通过寄存器,即使寄存器中存有一条指令的地址。而jalr是I型指令,意味着你必须提供两个寄存器和一个立即数,其中第二个寄存器中应该保存一条指令的地址。
3. 函数调用的过程
总结上述内容,可以将函数调用的过程概述如下:
- 将参数放到函数可以访问到的位置(参数寄存器
a0-a7
) - 将控制转交给函数(使用
jal
指令) - 函数获取任何其需要的存储资源
- 函数执行其功能
- 函数将返回值放在调用者(Caller)可以访问的位置(寄存器
a0, a1
) - 将控制交还给调用者(使用
jalr
指令)
4. 函数调用约定(Calling convention)
在函数调用前,某些寄存器中可能存储了重要的数值,这些数值可能在我们完成函数调用后仍需使用,所以我们希望这些值在函数调用后仍然保持不变,因此我们需要遵守一些约定来达到这个目的。
最简单的方法是,当我们调用函数时,函数先将32个寄存器中的值全部保存到内存中,完成函数功能后再将内存中的值恢复到32个寄存器中。
然而这个方法不仅麻烦而且浪费,因此为了减少寄存器的换出,RISC-V将19个寄存器分成了两组:临时寄存器和保存寄存器,并遵守以下约定来维持保存寄存器在函数调用前后的不变性:
对于被调用者,其必须保存并恢复保存寄存器的内容,而不用保存临时寄存器中的内容;对于调用者,除了保存寄存器以外的寄存器的值如果在函数调用完成后还要使用,那么要由调用者自行保存(比如ra寄存器)。调用结束后,栈指针、保存寄存器的值和函数调用之前的值一样
实现了这个约定的函数可以分成三个部分:
- “序言”部分:如果该函数的主体需要使用到某个保存寄存器,则在这个阶段将需要使用的保存寄存器的值保存至栈上。
- 主体部分:执行函数的功能。
- “结语”部分:按照后进先出的顺序将栈上的值恢复到相应的保存寄存器中并恢复栈指针,然后return。
5. 举例详解
假设我们要使用RISC-V指令实现下面的c语言代码
1 | int addTwo(int a, int b) { |
main
函数的实现比较简单,main
函数作为addTwo
函数的调用者,会假定addTwo
函数会为其保存保存寄存器中的内容,因此对于函数调用后仍需要使用到的a
变量,main函数可以将其保存在保存寄存器中,完成函数调用后仍然可以直接使用。
1 | main: |
对于addTwo
函数,根据约定,其在执行功能前要保存其要使用的保存寄存器的值,完成功能后要恢复其值。同时要注意的是,a0
寄存器既起到了传递参数的作用也起到了传递返回值的作用,八个参数寄存器中只有a0
和a1
寄存器可以用来保存返回值,不过C语言中只能返回一个值。(实际上实现这个函数并不一定需要使用保存寄存器,在此仅仅是为了举例)
1 | #================================================# |
假设我们将main
函数中的int d = a + c;
改为int d = b + c
,由于我们的变量b
是存储在临时寄存器t0
中,所以被调用的函数不保证临时寄存器的值不发生改变,被调用函数是可以任意使用临时寄存器的。因此,我们要么将变量b
存进另一个保存寄存器中,要么我们在调用函数前将其值保存至栈上,在调用函数后将其值恢复。下面是改变后的main
函数片段(addTwo
函数无需改动):
1 | # 将临时变量压栈 |
当然,在这个例子中即使不保存t0
的值,最后也能得到正确的结果,但是这样的情况是没有保证的,被调用者即使使用了临时寄存器也不用负责恢复其值。为了100%得到正确的结果,在调用函数前一定要保存之后要使用的临时寄存器,这样就无需担心被调用者是否使用了临时寄存器。
6. 进阶:递归
更复杂的情况就是递归调用,其难点在于ra
寄存器的管理,由于ra
寄存器不属于保存寄存器,因此每次发起递归调用时都需要保存ra
寄存器,并且在调用完毕后恢复ra
寄存器的值以返回到上一级调用。这里以计算阶乘为例。
1 | int fact(int n) { |
计算阶乘的递归函数是一个有去有回的递归过程,当满足递归结束条件时需要从最后被调用的fact
函数层层返回,每层fact
函数的返回地址都是不同的,但是我们却只有一个ra
寄存器,因此在我们调用下一层函数时,我们需要先保存ra
寄存器中的值(ra
寄存器不是保存寄存器,需要由调用者自行保存)。
1 | #================================================# |