RISC-V 的调用约定

ZaynPei Lv6

什么是调用约定

首先,我们需要理解什么是调用约定(Calling Convention)。它是一套规则,用于规定函数在调用时如何进行交互。这套规则确保了由不同程序员编写、甚至由不同编译器编译的函数能够正确地相互调用。

调用约定通常定义了以下内容:

  • 参数传递:如何将参数传递给函数(使用寄存器还是栈)。

  • 返回值:函数如何将返回值传回给调用者。

  • 寄存器使用:调用者(Caller)和被调用者(Callee)如何划分和使用寄存器。

  • 栈管理:如何分配和释放栈帧(Stack Frame)。

RISC-V 标准调用约定 (LP64)

RISC-V 的标准调用约定非常清晰和高效,主要依赖寄存器来传递参数。下面是其核心内容的总结,以标准的 64 位架构(RV64I)为例。

  1. 参数传递 (Argument Passing)
  • 整型/指针参数:前 8 个整型或指针参数通过寄存器 a0 到 a7 (x10 - x17) 传递。

  • 浮点参数:前 8 个浮点参数通过浮点寄存器 fa0 到 fa7 传递。

  • 更多参数:如果参数超过 8 个,多余的参数将从右到左依次压入栈(Stack)中进行传递。

  1. 返回值 (Return Values)
  • 整型/指针返回值:返回值通常放在 a0 (x10) 中。如果返回值需要 128 位(例如一个大的结构体),则高 64 位放在 a1 (x11) 中,低 64 位放在 a0 中。

  • 浮点返回值:同样,浮点返回值使用 fa0 和 fa1。

  1. 寄存器用途表 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 寄存器的区别

这是调用约定中关于寄存器管理的核心。这个策略旨在最小化内存访问(保存/恢复寄存器到栈),从而提高性能。

  1. 调用者保存寄存器 (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等

  1. 被调用者保存寄存器 (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。