函数约定(RV64)
本文主要探讨在 Riscv64架构上,函数与体系结构相关部分的约定。 我们会讨论以下内容:
- 调用者与被调用者保存;
- 函数调用;
- 函数返回;
- 特殊的返回 (中断返回);
注:手撕的汇编代码(不是编译器自动给的那种)可以通过不是很规范的做法,仍可以在满足
ABI(Application Binary Interface)要求的条件下,最终实现一样的功能效果。但我们本文主要讨论一般情况下的,也就是作为一个“正儿八经”的编译器,为了通用,我们如何对接RISCV64的ABI约定。
1. 调用者与被调用者保存
首先我们来认识一下什么是调用者与被调用者吧: 形如两个函数 A,B,A 函数中调用函数 B,那么此时我们就称A为调用者,B 为被调用者。简单代码的话:
A()
{
B();
}代码运行,本质上就是操作寄存器。因此 A 和 B 这两个函数执行流也就是对一堆寄存器进行操作。
当函数 A 调用函数 B 的时候,进入 B 后,目前的寄存器环境其实是函数 A 的环境。
这就类比于你向我借一件东西,但是最后是需要**完璧归赵**的,这个完璧归赵是大家约定的,不允许发生破璧归赵。也就是说,当从 B 返回函数 A 的时候呢,此时的运行环境应该是和调用 B 之前是一样的(不考虑保存和恢复步骤)。
那么一个很自然的想法就是,函数 A 在调用函数 B 之前,先把所有的相关寄存器都备份下来,返回 A 之后再回复, 伪代码如下:
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/

为了叙述方便,我们下文中称调用者保存寄存器集合为S1,被调用者保存寄存器集合为S2。
S1 + S2 = 需要保存的寄存器
现在的为代码如下:
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字节。
下面给出常用跳转指令的信息表:
| 指令 | 格式 | 跳转距离 | 说明 |
|---|---|---|---|
| JAL | jal rd, offset | ±1MB (±220字节) | 跳转并链接(保存返回地址到 rd),常用于函数调用 |
| JALR | jalr rd, offset(rs1) | ±2KB (±211字节) | 间接跳转(基址+偏移),用于返回和函数指针 |
| Bxx(族) | 条件跳转(常用于 if编译) | ||
| C.xx(族) | 压缩跳转指令(16 位/2 字节,需 C 扩展) | ||
| AUIPC + JALR | auipc 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即可。
当然了,设置
sepc和sret整个流程一定是在关中断的情况下喔。。。