RISC-V Calling Conventions

Prologue

今天看了 Cornell CS3410 的 Calling Conventions,大概花了两个多小时,但实际上并没有学到很多,更多地是 clarify 了一些以前了解过一点的知识以及一些细节。slide 大概分了三点:Transfer Control, Pass Arguments to/from the Routine, Manage Registers.(slide 上说 there is no one true RISC-V calling convention, 但找到了一个 RV psABI 的文档,不知道咋回事) 先上个总结图(from RV Manual: RISC-V Assembly Programmer’s Handbook

Transfer Control

考虑最简单的函数调用情况:在 asm 中调用函数的时候,除了跳转之外,还需要知道返回时应该跳转到什么位置,这就是 jal 指令的作用:在跳转的同时将返回地址(return address, ra)即 pc + 4 保存到 ra 寄存器中,这样在返回的时候就可以根据 ra 寄存器的值跳转返回。 在多层函数调用的情况下,因为 ra 寄存器只有一个,所以需要在新一轮的 jal 指令之前保存当前函数的 ra 值,而很自然的想法就是 push 到栈上。一般来说在为新 function call 分配栈空间时就会将对应的 ra 压栈,相应地在当前过程返回时从当前函数对应的 stack frame 上 lw 出正确的 ra 值。

Pass Arguments to/from the Routine

函数调用时传参数一般是通过指定的寄存器 (a0-a7 (x10-x17)),但当参数大小超过 8 words 的时候寄存器就放不下了,这时候的函数参数也是被保存在栈上的(列表中靠后的函数参数先被压栈,即参数列表对应的栈上地址递增)。 考虑到函数调用嵌套的情况,某些时候存在寄存器中的函数参数也需要被保存在栈上,因此虽然在进入函数前压栈时只保存了超出 8 words 的部分,但仍为 a0-a7 中的内容留下了对应的栈空间(如接收 9 个 int 作为参数的函数,在进函数前压栈时会为参数部分分配 9 个 int 的栈空间,但只有第九个参数会被保存在栈上,前八个参数对应的位置暂时空置,留待函数有需要时用以保存)。 在 RISC-V 中,char, short, int, void* 这几种数据类型被当成相同的类型进行处理。 关于全局变量,其存在于 ELF 文件的 .data section 中,由 gp (global pointer) 进行索引。值得注意的是,根据 slide,gp 指向 the middle of global data section,至于为何指向 middle 而非 top/bottom 尚未查知。 另外因为在函数运行的过程中经常会发生压栈,所以如果使用 sp(stack pointer) 来索引当前 stack frame 中的数据是很不方便的一件事。因此在这种情况下使用 frame pointer fp 指向当前 stack frame 的底部,使用 fp 来进行索引。

Registers Management

当函数被编译时,一般来说(不考虑 static analysis 的参与)函数之间是互相不知道对方使用了哪些 GPR (General Purpose Register) 的,所以在进行函数调用时就需要将所有保存了函数当前状态的寄存器都先压栈保存,在函数返回后再恢复。这无疑是很损耗效率的一件事,因此有了 calling conventions。calling conventions 约定了每个 GPR 的保存者(saver),分为 caller 和 callee 两种类型。 caller saved registers 顾名思义,由函数调用者在 invocation 发生之前压栈保存,在函数调用返回之后自行恢复。对该类型的寄存器,被调用的函数即 callee 可以认为其值都被 caller 保存了,可以像正常的 GPR 一样使用。 callee saved registers 是由被调用的函数保存的值,即 callee 在使用这一类寄存器之前必须先将其值压栈保存,在返回之前恢复。此时对 caller 来说,这些寄存器可视为没有在函数调用过程中被修改。

  • 一般来说如果某个变量的值在某个函数调用点之后是无关紧要的话(如 dead variable),可以选择将其分配到 caller saved register 上,这样在 invocation 的时候可以不需要保存这个寄存器,方便地给 callee 复用;
  • 如果某个函数中有较多的函数调用,那么相对不推荐将 live variable 分配到 caller saved register 上,因为这样就必定需要多次压栈保存该寄存器;如果将其分配给 callee saved register 的话,还有机会减少保存的开销(比如被调用的函数并没有使用该寄存器)。

Summary

需要注意的是,function call 对应的 stack frame 及其中的部分的 data 基本都是在 jal 之前分配完栈空间并保存完毕的(即一个 function 对应的 stack frame 是由其 callee 分配完成的),其中包括:

  • callee 需要的 args
    • 其中超过 8 words 的部分会保存在栈上
    • 8 words 以内的部分存在寄存器中,但是在栈上也有预留空间
  • callee 的 ra (用于保存返回地址)
  • caller 的 fp (用于在返回时恢复)
  • 为 callee saved registers 预留的空间 (caller saved registers 保存在 caller 对应的 stack frame 中)
  • 函数中的 local variables (该部分不包括在由 caller 分配的栈帧部分中,由 callee 本身在执行时压栈)

Epilogue

写的过程中又 clarify 了几个细节,对于知识的吸收和理解确实起到了一定的作用。 但是大概 3:45 开始写这篇 note,写完已经 5:15 了。。。还是需要多写写。 一些迷思:按照 slide 中所说的寄存器分配策略,似乎对于 dead variable,分配到 callee saved regs 总是更优的选择?TBC...