今天不想写Project,昨天做Lab的时候发现一篇说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来把它恢复到我们刚进入函数时的状态。注意:在不必要的情况下,我们应该避免向栈中读取或者存储数据,因为对内存的读写操作要比对寄存器的读写操作慢得多。
t和s寄存器作用相似,但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的次数相等。