菜单

Dau
Dau
发布于 2025-01-21 / 52 阅读
0
0

理解RISC-V调用约定

今天不想写Project,昨天做Lab的时候发现一篇说RISC-V约定的文档很有学习的价值,就计划今天总结翻译一下。

原文在此

RISC-V指令参考

RISC-V约定

在C等高级语言中,约定的作用更多体现在提高(合作)工作效率上,而对于程序的正常运行没有任何影响。而在RISC-V中,若没有遵守相应的约定,则会导致程序无法正确运行。

约定主要体现在三个方面:

寄存器

函数调用

进入/退出函数(Prologue/Epilogue)

在我看来,这些方面的本质都是如何正确理解寄存器的功能,以及如何正确处理寄存器的保存和读取

寄存器Register

RISC-V共有32个寄存器(X0-X31),每个寄存器都有其符号名称(Symbolic Name),表示其预期用途。

其中常用的有以下几个:

x0/zero:总是0

ra:return address,函数的返回地址

sp:stack pointer,栈的指针(grows down)

t0-t6:temporaries,函数调用后不保证一致

s0-s11:saved,函数调用后保证一致

a0-a7:arguments,其中a0,a1也可用来保存返回值

接下来是一些详细介绍。ra存储返回地址,指向函数return后要执行的下一行代码的内存地址。

  def foo():
    x=1
    bar()
    z=2
  def bar():
    y=7

观察以上伪代码,假设我们正在foo函数中,准备执行bar函数,并且希望bar函数执行完之后会回到foo函数,并继续执行原先bar所在的地方下面的代码,即z=2,因此我们需要把z=2这句代码所对应的内存地址存储到ra中,这样bar函数执行完之后就会根据ra的值,重新回到foo函数中我们想让他回到的位置。

sp是指向内存中栈顶的指针(向下增长),在RISC-V中,当我们需要给栈上分配更多空间时,我们会递减sp的值。当我们退出函数的时候,我们会递增sp来把它恢复到我们刚进入函数时的状态。注意:在不必要的情况下,我们应该避免向栈中读取或者存储数据,因为对内存的读写操作要比对寄存器的读写操作慢得多。

ts寄存器作用相似,但t在函数调用后不保证一致,s则由于我们在Prologue/Epilogue中的操作,在函数调用后保持一致。

最后,a寄存器用于在函数调用之间传递值,最多可以有8个参数(a0-a7)和2个返回值(a0-a1)。如果需要更多参数或返回值的话,就需要使用栈了。

函数调用

我们使用以下指令来进行函数跳转:

jal label

jalr rd(offset=0)

但这是一种简写,他们的原型是:

jal ra,label

jalr ra,rd,offset

他们所做的事就是跳转到label或者rd+offset,并且将PC+4(即函数调用后下一条指令的地址)存在ra中。

有时我们也会使用:

j label

jr rd(offset=0)

他们的原型则是:

jal x0,label

jalr x0,rd,offset

可以看到与上面唯一的差异就是ra变成了x0,由于x0始终为0,所以将x0设置成PC+4是无效操作,这样就实现了不存储返回地址而只是跳转。

当我们进行递归调用时,其实和普通的函数调用无异,使用jal label即可,不过要记得保存ra。

调用函数时,我们要把参数传给a寄存器;返回时,我们要把返回值存在a0-1中。这意味着a寄存器在函数调用之间不会被保留。

Prologue/Epilogue

实现约定的关键步骤就是在函数中实现Prologue/Epilogue,具体有以下几点:

sp在退出函数的时候应和进入函数的时候保持相同的位置(除非有返回值存储在栈上)

s寄存器在退出函数的时候应和进入函数的时候保持相同的值

返回时所使用的ra值也应该和进入函数时相同(函数调用有可能会改变ra值)

为了实现这一点,我们在函数实际操作之前添加一个Prologue,用来存储要保存的数据到栈上:

  def prologue():
    decrement sp by num s registers + local var space
    Store any saved registers used
    Store ra if a function call is made

并且在函数返回之前添加一个Epilogue,用来从栈上读取我们先前保存的数据:

  def epilogue ():
    Reload any saved registers used
    Reload ra ( if necessary)
    Increment sp back to previous value
    Jump back to return address

遵循这个流程,我们可以很好地贯彻我们的约定,代码也可以和别人的程序正确交互。

下面是一个函数sum_squares(n)的示例,该函数求从1到n的数的平方和:

  sum_squares:
  prologue:
    addi sp sp 16
    sw s0 0(sp)
    sw s1 4(sp)
    sw s2 8(sp)
    sw ra 12(sp)
    li s0 1
    mv s1 a0
    mv s2 0
  loop_start:
    bge s0 s1 loop_end
    mv a0 s0
    jal square
    add s2 s2 a0
    addi s0 s0 1
    j loop_start
  loop_end:
    mv a0 s2
  epilogue:
    lw s0 0(sp)
    lw s1 4(sp)
    lw s2 8(sp)
    lw ra 12(sp)
    addi sp sp 16
    jr ra

注意:由于我们在函数主体中使用了s0-2,故需要将其存储到栈中;我们又在函数里调用了其他函数,故我们也需要将ra存到栈里。

为什么选择这种约定?

首先,sp和ra的概念是必须满足的一般编程假设;x0是为了效率,因为固定的0有很多用处;始终保存所有寄存器会导致空间浪费,因为有一些寄存器可能永远不会改变,有一些寄存器可能完全没有保存的必要(如a寄存器,传入的参数后面可能不再需要),因此我们使用s寄存器指定我们要保存的值;我们永远不能假设a寄存器会一直存在,因此我们始终可以使用它们,使得专门指定一个寄存器用于返回值的做法变得浪费。

违反约定

可能能运行,但不建议。

使用约定来调试

我们可以通过检查代码是否遵循约定来debug(并不保证能找到所有bug)。

检查是否正确存储了ra。对于递归,要正确链接ra。

不要使用未初始化的t寄存器。

检查sp进入函数和退出函数时的值相同。

检查进入Prologue和Epilogue的次数相等。


评论