发布于 

详解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

在函数调用中我们需要用到两条关键的指令:jaljalr。我们通过jal发起函数调用,通过jalr来从函数中返回。

  1. jump and link,jal rd, Label:UJ型指令,指令格式:

    immediate[20,10:1,11,19:12]rdopcode

    使用jal指令时需要指定一个寄存器rd和一个标签Label(会在汇编阶段被翻译成立即数immediate),该指令会将PC+4保存到rd寄存器中,并且跳转到标签位置(PC + immediate)继续执行指令

  2. jump and link register,jalr rd, imm(r1):I型指令,指令格式:

    immediate[11:0]rs1funct3rdopcode

    使用jalr指令时会将PC+4保存到rd寄存器中,并且跳转到地址rs1 + immediate继续执行指令

因为在汇编中我们用标签来表示后续的指令属于同一个函数,所以我们用可以跳转到标签的jal指令来发起函数调用。又因为jal将函数执行完毕后要继续执行的下一条指令的地址保存到了ra寄存器中,所以我们用可以跳转到寄存器中地址的jalr指令来从函数中返回。

使用jaljalr的组合来调用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 指令		  	  # 指令的地址
addi s0, x0, 1 # 0x0000 0000
jal ra, funct1 # 0x0000 0004
addi s1, x0, 2 # 0x0000 0008
jal ra, exit # 0x0000 000c

funct1:
sub t0, t1, t2 # 0x0000 0010
jalr x0, 0(ra) # 0x0000 0014

funct2:
addi x0, x0, 0 # 0x0000 0018
jalr x0, 0(ra) # 0x0000 001c

exit:
# 使用系统调用退出程序,在此不展示细节
执行过程

jalr x0, 0(ra)的解释:在该条指令中我们使用x0寄存器作为rd寄存器来写入,由于x0寄存器的值始终为0,所以相当于丢弃掉了pc+4的地址,丢弃该地址是因为我们不需要使用该地址,我们从函数中跳转回来,"return语句"之后的指令地址是没有意义的。

同样,我们可以使用jal x0, Label来进行跳转,由于没有将pc+4保存,因此后续没办法再使用jalr指令跳转回来,相当于使用了一条C语言中的goto语句。

jal指令和jalr指令的错误用法:

不要混淆两者进而写出jal x0, ra或者jalr x0, Label这样的指令!给jal指令提供跳转地址必须使用标签而不能通过寄存器,即使寄存器中存有一条指令的地址。而jalr是I型指令,意味着你必须提供两个寄存器和一个立即数,其中第二个寄存器中应该保存一条指令的地址。

3. 函数调用的过程

总结上述内容,可以将函数调用的过程概述如下:

  1. 将参数放到函数可以访问到的位置(参数寄存器a0-a7
  2. 将控制转交给函数(使用jal指令)
  3. 函数获取任何其需要的存储资源
  4. 函数执行其功能
  5. 函数将返回值放在调用者(Caller)可以访问的位置(寄存器a0, a1
  6. 将控制交还给调用者(使用jalr指令)

4. 函数调用约定(Calling convention)

在函数调用前,某些寄存器中可能存储了重要的数值,这些数值可能在我们完成函数调用后仍需使用,所以我们希望这些值在函数调用后仍然保持不变,因此我们需要遵守一些约定来达到这个目的。

最简单的方法是,当我们调用函数时,函数先将32个寄存器中的值全部保存到内存中,完成函数功能后再将内存中的值恢复到32个寄存器中。

然而这个方法不仅麻烦而且浪费,因此为了减少寄存器的换出,RISC-V将19个寄存器分成了两组:临时寄存器保存寄存器,并遵守以下约定来维持保存寄存器在函数调用前后的不变性:

对于被调用者,其必须保存并恢复保存寄存器的内容,而不用保存临时寄存器中的内容;对于调用者,除了保存寄存器以外的寄存器的值如果在函数调用完成后还要使用,那么要由调用者自行保存(比如ra寄存器)。调用结束后,栈指针、保存寄存器的值和函数调用之前的值一样

实现了这个约定的函数可以分成三个部分:

  1. “序言”部分:如果该函数的主体需要使用到某个保存寄存器,则在这个阶段将需要使用的保存寄存器的值保存至栈上。
  2. 主体部分:执行函数的功能。
  3. “结语”部分:按照后进先出的顺序将栈上的值恢复到相应的保存寄存器中并恢复栈指针,然后return。

5. 举例详解

假设我们要使用RISC-V指令实现下面的c语言代码

1
2
3
4
5
6
7
8
9
10
int addTwo(int a, int b) {
return a + b;
}

int main() {
int a = 1, b = 2;
int c = addTwo(a, b);
int d = a + c;
return 0;
}

main函数的实现比较简单,main函数作为addTwo函数的调用者,会假定addTwo函数会为其保存保存寄存器中的内容,因此对于函数调用后仍需要使用到的a变量,main函数可以将其保存在保存寄存器中,完成函数调用后仍然可以直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main:
# 1.变量初始化
addi s0, x0, 1 # s0 = 1
addi t0, x0, 2 # t0 = 2
# 2.将函数参数放进参数寄存器中
addi a0, s0, 0
addi a1, t0, 0
# 3.函数调用
jal ra, addTwo
# 4.取得函数返回值
addi s1, a0, 0 # s1 = addTwo(a, b)
# 5.继续执行剩下指令
add s1, s1, s0 # s1 = s1 + s0
jal x0, exit

对于addTwo函数,根据约定,其在执行功能前要保存其要使用的保存寄存器的值,完成功能后要恢复其值。同时要注意的是,a0寄存器既起到了传递参数的作用也起到了传递返回值的作用,八个参数寄存器中只有a0a1寄存器可以用来保存返回值,不过C语言中只能返回一个值。(实际上实现这个函数并不一定需要使用保存寄存器,在此仅仅是为了举例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#================================================#
# Function: add two numbers
# Arguments:
# a0 (int) is the first number to be added
# a1 (int) is the second number to be added
# Returns:
# a0 (int) is the sum of the two numbers above
#================================================#
addTwo:
# 序言,压栈,保存s0中的值
addi sp, sp, -4
sw s0, 0(sp)
# 主体,执行函数功能
add s0, a0, a1
# 将返回值放入a0寄存器中
addi a0, s0, 0
# 结语,恢复保存寄存器的值并弹栈
lw s0, 0(sp)
addi sp, sp, 4
# 将控制交还给调用者
jalr x0, 0(ra)

假设我们将main函数中的int d = a + c;改为int d = b + c,由于我们的变量b是存储在临时寄存器t0中,所以被调用的函数不保证临时寄存器的值不发生改变,被调用函数是可以任意使用临时寄存器的。因此,我们要么将变量b存进另一个保存寄存器中,要么我们在调用函数前将其值保存至栈上,在调用函数后将其值恢复。下面是改变后的main函数片段(addTwo函数无需改动):

1
2
3
4
5
6
7
8
# 将临时变量压栈
addi sp, sp, -4
sw t0, 0(sp)
# 调用函数
jal ra, addTwo
# 恢复临时变量值
lw t0, 0(sp)
addi sp, sp, 4

当然,在这个例子中即使不保存t0的值,最后也能得到正确的结果,但是这样的情况是没有保证的,被调用者即使使用了临时寄存器也不用负责恢复其值。为了100%得到正确的结果,在调用函数前一定要保存之后要使用的临时寄存器,这样就无需担心被调用者是否使用了临时寄存器。

栈变化

6. 进阶:递归

更复杂的情况就是递归调用,其难点在于ra寄存器的管理,由于ra寄存器不属于保存寄存器,因此每次发起递归调用时都需要保存ra寄存器,并且在调用完毕后恢复ra寄存器的值以返回到上一级调用。这里以计算阶乘为例。

1
2
3
4
5
6
7
int fact(int n) {
if (n == 1) return 1;
// 为了让汇编代码步骤分明,故这里的代码写得啰嗦一些
int x = fact(n - 1);
int x = n * x;
return x;
}

计算阶乘的递归函数是一个有去有回的递归过程,当满足递归结束条件时需要从最后被调用的fact函数层层返回,每层fact函数的返回地址都是不同的,但是我们却只有一个ra寄存器,因此在我们调用下一层函数时,我们需要先保存ra寄存器中的值(ra寄存器不是保存寄存器,需要由调用者自行保存)。

调用过程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#================================================#
# Function: compute n factorial
# Arguments:
# a0 (int): n
# Returns:
# a0 (int): factorial(n)
#================================================#
factorial:
# if a0 == 1 return
addi t0, x0, 1
beq a0, t0, return

addi sp, sp, -8
sw a0, 0(sp) # save n
sw ra, 4(sp) # save ra
addi a0, a0, -1 # a0 = n - 1
jal ra factorial # fact(n-1)
add t0, x0, a0 # t0 = fact(n-1)
lw a0, 0(sp) # a0 = n;
lw ra, 4(sp)
addi sp, sp, 8
mul a0, a0, t0 # a0 = n * fact(n-1)
jalr x0, 0(ra) # return n * fact(n-1)
return:
jalr x0, 0(ra)

本站由 @Eumendies 使用 Stellar 主题创建。 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。