Skip to content

函数约定(RV64)

本文主要探讨在 Riscv64架构上,函数与体系结构相关部分的约定。 我们会讨论以下内容:

  1. 调用者与被调用者保存;
  2. 函数调用;
  3. 函数返回;
  4. 特殊的返回 (中断返回);

注:手撕的汇编代码(不是编译器自动给的那种)可以通过不是很规范的做法,仍可以在满足ABI(Application Binary Interface)要求的条件下,最终实现一样的功能效果。但我们本文主要讨论一般情况下的,也就是作为一个“正儿八经”的编译器,为了通用,我们如何对接RISCV64的ABI约定。

1. 调用者与被调用者保存

首先我们来认识一下什么是调用者被调用者吧: 形如两个函数 ABA 函数中调用函数 B,那么此时我们就称A为调用者,B 为被调用者。简单代码的话:

c
A()
{
   	B();
}

代码运行,本质上就是操作寄存器。因此 AB 这两个函数执行流也就是对一堆寄存器进行操作。

当函数 A 调用函数 B 的时候,进入 B 后,目前的寄存器环境其实是函数 A 的环境。

这就类比于你向我借一件东西,但是最后是需要**完璧归赵**的,这个完璧归赵是大家约定的,不允许发生破璧归赵。也就是说,当从 B 返回函数 A 的时候呢,此时的运行环境应该是和调用 B 之前是一样的(不考虑保存和恢复步骤)。

那么一个很自然的想法就是,函数 A 在调用函数 B 之前,先把所有的相关寄存器都备份下来,返回 A 之后再回复, 伪代码如下:

assembly
A()
{
    store all regs;
   	B();
    restore all regs;
}

其实主要就是备份和恢复两个流程。

同理,假如函数 B->C,那么 B 也会在调用之前备份,C 返回的时候还原。

**

不过真实的情况不是这样。

以 A->B 而言,从执行开始, A 备份一部分,进入B,B 也备份一部分,B 在函数返回之前恢复自己备份的,再返回到 A 后,在 A 里面恢复 A 备份的那一部分的寄存器。

我们称 A 备份的那一部分为调用者保存寄存器,B 备份的那一部分为被调用者保存寄存器

**

下面我们给出在Riscv64架构下的调用者与被调用保存寄存器图:

注:图片来源 https://riscv.org/

image-20250621135533924

为了叙述方便,我们下文中称调用者保存寄存器集合为S1,被调用者保存寄存器集合为S2

S1 + S2 = 需要保存的寄存器

现在的为代码如下:

assembly
A()
{
    store S1;
   	B();
    restore S1;
}

B()
{
    store S2;
   	...
    restore S2;
}

但是基础的保存太消耗性能了。如果每个函数要在函数开头和结尾都要保存和恢复一堆东西,那带来的性能损失也太大了。。。。我们作为尚且还存有一点理智的编译器,肯定是要对这一部分进行优化,怎么优化呢?一个基础的方案是:

调用者A:

如果在调用 B 之后还需要使用 S1 内的某个寄存器。那么他必须要保存该寄存器,以免值可能被修改;

也就是说,如果他后面根本不用,他就不用保存,更谈不上后续恢复该寄存器;


被调用者B:

如果要修改 S2 中的某个寄存器,那么他需要保存并后续要恢复;

如果不修改,那就不需要保存和恢复;

2. 函数调用

函数跳转的本质就是在记录函数参数的情况下进行跳转地址。

2.1 函数传参

RISCV64中,函数传参优先使用寄存器进行传参。整型参数依次使用a0到a7,浮点参数依次使用fa0到fa7。如果参数个数超过8个(对于整型或浮点分别计算),则超出的部分会使用栈来传递。(这一部分由大聪明编译器实现,我们不必操心)

2.2 函数跳转

函数跳转主要涉及到汇编跳转指令的使用。

尽管我们当前是在64架构上,但是在默认的情况下,当我们的代码比较少的时候,大多数情况下,编译器会优先生成auipc + jalr的跳转方式。原因是这种方式占用的字节数适中,跳转范围也比较合适。auipc + jalr所占用的字节数为4 + 4 = 8 B。可以供跳转范围为 +-2G。既不像近跳jal那么过于狭隘(1M),也不像远跳那么所需空间太大。如果采用64位置随便跳的话,不说如何实现,就单单64位的这个数子就是8B了。

而且一条RISCV指令一般都是32位定长,若是压缩指令则占16位

注:

  • 32 位指令:必须 4 字节对齐(地址末 2 位 = 00
  • 16 位指令:只需 2 字节对齐(地址末 1 位 = 0

采用auipc + jalr的话每个刚刚好每个4字节。

下面给出常用跳转指令的信息表:

指令格式跳转距离说明
JALjal rd, offset±1MB (±220字节)跳转并链接(保存返回地址到 rd),常用于函数调用
JALRjalr rd, offset(rs1)±2KB (±211字节)间接跳转(基址+偏移),用于返回和函数指针
Bxx(族)条件跳转(常用于 if编译)
C.xx(族)压缩跳转指令(16 位/2 字节,需 C 扩展)
AUIPC + JALRauipc ra, offset_hi jalr ra, offset_lo(ra)±2GB (±231字节)大明星

3. 函数返回

大多数默认的返回地址是在 ra寄存器中,当执行 ret指令的时候,CPU会把ra存放的内容送到pc中,此时下一条语句执行就到了原ra指向的位置。

ra属于被调用者保存寄存器。当A->B的时候,B一般会把ra保存起来(除非编译器知道B不会返回的话那就另说),然后在函数结尾,ret之前把ra恢复,然后执行ret就回去了。

当然了,有些编译器可能会优化出其他的方式。具体环境要看上下文。

4. 特殊的返回 (中断返回)

本小节和函数无关,但是又和执行流改变有一定的关系。为了方便,顺便显示和函数返回的区别,我们放在这里。

我们以sret进行说明(mret大家类比即可):

sret是中断返回指令,用于返回之前程序的断点。而根据RISCV的中断架构(可以参考我的这个中断处理),程序断点是保存在sepc中。由此可见,我们只需要在sret之前设置好sepc即可。

当然了,设置sepcsret整个流程一定是在关中断的情况下喔。。。