RISC-V 的调用约定
什么是调用约定
首先,我们需要理解什么是调用约定(Calling Convention)。它是一套规则,用于规定函数在调用时如何进行交互。这套规则确保了由不同程序员编写、甚至由不同编译器编译的函数能够正确地相互调用。
调用约定通常定义了以下内容:
参数传递:如何将参数传递给函数(使用寄存器还是栈)。
返回值:函数如何将返回值传回给调用者。
寄存器使用:调用者(Caller)和被调用者(Callee)如何划分和使用寄存器。
栈管理:如何分配和释放栈帧(Stack Frame)。
RISC-V 标准调用约定 (LP64)
RISC-V 的标准调用约定非常清晰和高效,主要依赖寄存器来传递参数。下面是其核心内容的总结,以标准的 64 位架构(RV64I)为例。
- 参数传递 (Argument Passing)
整型/指针参数:前 8 个整型或指针参数通过寄存器 a0 到 a7 (x10 - x17) 传递。
浮点参数:前 8 个浮点参数通过浮点寄存器 fa0 到 fa7 传递。
更多参数:如果参数超过 8 个,多余的参数将从右到左依次压入栈(Stack)中进行传递。
- 返回值 (Return Values)
整型/指针返回值:返回值通常放在 a0 (x10) 中。如果返回值需要 128 位(例如一个大的结构体),则高 64 位放在 a1 (x11) 中,低 64 位放在 a0 中。
浮点返回值:同样,浮点返回值使用 fa0 和 fa1。
- 寄存器用途表 RISC-V 共有 32 个通用整型寄存器 (x0 - x31),它们在调用约定中扮演着不同的角色。使用 ABI (Application Binary Interface) 名称可以更容易地记住它们的用途。
| 寄存器 (ABI 名称) | 寄存器编号 | 角色 | 保存策略 | 描述 |
|---|---|---|---|---|
| zero | x0 | 硬编码零 | - | 始终为 0,不可修改。 |
| ra | x1 | 返回地址 | Caller-Saved | 保存函数调用后的返回地址。由 jal 和 jalr 指令隐式修改。 |
| sp | x2 | 栈指针 | Callee-Saved | 指向当前栈帧的顶部。 |
| gp | x3 | 全局指针 | - | 指向全局数据区,由链接器设定。 |
| tp | x4 | 线程指针 | - | 指向当前线程的私有数据区。 |
| t0-t2 | x5-x7 | 临时寄存器 | Caller-Saved | 用于存放临时数据,函数可以随意使用。 |
| s0/fp | x8 | 保存寄存器/帧指针 | Callee-Saved | s0 是被调用者保存寄存器,也可作为帧指针(Frame Pointer)。 |
| s1 | x9 | 保存寄存器 | Callee-Saved | 被调用者保存寄存器。 |
| a0-a1 | x10-x11 | 函数参数/返回值 | Caller-Saved | 用于传递前两个参数和返回值。 |
| a2-a7 | x12-x17 | 函数参数 | Caller-Saved | 用于传递第 3 到第 8 个参数。 |
| s2-s11 | x18-x27 | 保存寄存器 | Callee-Saved | 被调用者保存寄存器。 |
| t3-t6 | x28-x31 | 临时寄存器 | Caller-Saved | 用于存放临时数据。 |
Caller-Saved 与 Callee-Saved 寄存器的区别
这是调用约定中关于寄存器管理的核心。这个策略旨在最小化内存访问(保存/恢复寄存器到栈),从而提高性能。
- 调用者保存寄存器 (Caller-Saved Registers)
也称为临时寄存器(Temporary Registers)或易变寄存器(Volatile Registers)。这些寄存器可以被被调用函数(Callee)自由地、无条件地修改。
工作流程:
调用前:如果一个调用者函数(例如 main)在一个 Caller-Saved 寄存器(例如 t0)中存放了一个重要的值,并且希望在被调用函数(例如 funcA)返回后继续使用这个值。
保存:那么 main 函数必须在执行 call funcA 指令之前,自己负责将 t0 的值保存到栈上。
调用后:funcA 返回后,main 函数再从栈上恢复 t0 的值。
为什么这么设计?funcA 可以无所顾忌地使用这些寄存器进行计算,而不需要执行任何保存/恢复操作,这使得那些简短的、不需要太多寄存器的叶子函数(leaf function)执行得非常快。例如RISC-V 中的ra, t0-t6, a0-a7等
- 被调用者保存寄存器 (Callee-Saved Registers)
也称为保存寄存器(Saved Registers)或非易变寄存器(Non-Volatile Registers)。这些寄存器的值在函数调用前后必须保持不变。
工作流程:
调用前:调用者函数(main)可以放心地将一个长期有效的值(例如一个循环计数器)存放在一个 Callee-Saved 寄存器(例如 s0)中,然后去调用 funcA。
被调用者内部:如果 funcA 需要使用 s0 寄存器,它必须在函数的开头(prologue)先把 s0 的原始值保存到自己的栈帧中。
返回前:在 funcA 返回之前(epilogue),它必须从栈中恢复 s0 的原始值。
调用后:这样,当 funcA 返回到 main 时,main 可以确信 s0 里的值和调用前一模一样。
为什么这么设计?调用者可以放心地使用这些寄存器来存储重要的局部变量,而不用在每次函数调用时都去保存和恢复它们,这减少了不必要的内存操作。在RISC-V 中的例子有sp, s0-s11。