摘要
在这些笔记中,我将尝试解释RISC-V调用约定,并试图让您理解为什么我们使用这种约定。希望通过理解这些笔记,您能更好地理解RISC-V程序,从而使调试工作更加容易。
1 RISC-V约定
正如你现在可能知道的那样,汇编级编程与你之前在高级语言如Java和C中所做的编程非常不同。一个关键细节是,汇编编程没有像参数检查这样的东西,一切都是“约定”的结果。当你想到“约定”时,你可能会想到诸如函数命名方式、每行代码的长度等问题,基本上是一堆选择,使你的代码保持一致性,但对功能而言并非必要。然而,汇编完全基于约定,所以如果你不严格遵循约定,你的代码将无法正常工作(除非你写了自己使用的所有汇编代码…呃)。了解RISC-V中的约定包括三个重要部分:寄存器、函数调用和进入/退出函数(序言/尾声)。
1.1 Registers
在RISC-V中,32个寄存器中的每个都有一个不同的名称来表示它的预期用途。我们不会在这些笔记中涵盖每个寄存器的确切含义,但以下是本课程的重要内容:
寄存器名称 | 用法 |
---|---|
x0/zerp | 总是保持0 |
ra | 保持返回地址 |
sp | 保留堆栈的边界的地址 |
t0-t6 | 保留在函数调用后不持续存在的临时值 |
s0-s11 | 保存在函数调用后持续存在的值 |
a0-a1 | 保留函数或返回值的前两个参数 |
a2-a7 | 保留任何剩余的参数 |
现在让我们更详细地讨论其中的一些。ra持有返回地址。这是代码区域中的一个内存值。它对函数调用特别有用。让我们通过查看一些伪代码来想象一下这意味着什么:
def foo( ) :
x = 1
bar ( )
z = 2
def bar( ) :
y = 7
想象一下,我们在bar里的y = 7行。当bar结束时,我们想要恢复在foo内部的执行,并完成下一条指令,z = 2。我们通过存储代码应该返回的指令的地址来实现这一点。在这种情况下,ra将保留z = 2的地址,因此在函数调用后按预期继续执行。
sp寄存器保存着当前栈的基地址。在C语言内存管理课程的部分中,我们讨论了每次函数调用时栈是向下增长的。在RISC-V中,当我们需要在栈上增加更多的空间时,我们会减小sp寄存器的值(因为栈是向下增长的),这样我们就可以获得更多用于存储数据的地址。然后,当我们退出一个函数时,我们会增加sp寄存器的值,以恢复栈的状态回到进入函数时的状态。一个不太清楚的细节是局部变量存储在哪里。在内存管理部分,我们说它们存储在栈上,但是频繁地进行内存访问是非常昂贵的,因此,如果我们不需要直接使用地址或将一个值存储在内存中,我们会避免这样做。
t和s寄存器具有非常相似的用途,但在与函数交互时行为上有着重要的不同。在调用函数后,不能保证t寄存器中的值仍然存在,事实上,假设它们存在是错误的。相反,s寄存器应该用于函数调用后需要使用的值。在序言/尾声部分,我们将解释如何实现这个约定。
最后,a寄存器用于在函数调用之间传递值。可以通过寄存器传递最多两个返回值和八个参数(如果需要更多,则使用栈,但在本课程中我们不涉及如何使用栈
1.2 Function Calls
我们使用jal指令到一个标签lable或jalr指令到一个寄存器rd,进行函数调用。具体来说,这些指令应该是jal ra label或jalr ra rd imm,但有时我们会使用伪指令jal label或jalr rd(当imm为0时)。jal指令的作用是将PC + 4的值存储在ra寄存器中,这是函数调用后要执行的下一条指令的地址,并通过偏移量增加PC到标签的地址。jalr指令类似,只是它将PC的值设置为rd + imm。
这与在循环中使用的标准跳转指令有些不同,但类似。在不进行函数调用的情况下跳转到一个标签,可以使用jal x0 label和jalr x0 rd imm指令,有时可以使用伪指令j label和jr rd(当imm为0时)。我们利用了x0寄存器始终为0的特性来实现这一点。该指令将尝试将PC + 4的值存储在x0中,但由于x0始终为0,所以不会存储任何值。通过这种方式,我们可以跳转到程序的其他部分。该代码不提供要返回的位置,这就是它与函数调用的区别。
需要注意的是,在递归调用时,这与调用任何其他函数的方式完全相同,因此我们将使用jal label指令。从技术上讲,如果我们足够聪明,可以使用跳转指令来实现一些尾递归函数(如果你还记得在61a中学到的知识的话)。我们不会涵盖如何实现尾递归,但这是一个很好的练习,用来测试您是否理解了调用约定以及为什么要执行每个步骤。
在调用函数时,我们将参数传递给a寄存器。然后,在返回时,我们会查找返回值是否保存在a0-a1寄存器中。重要的是,这意味着a寄存器在函数调用之间不会被保留(否则我们如何返回值)。
1.3 Prologue/Epilogue
Prologue和Epilogue是汇编语言中常用的两个术语,通常用于描述函数的进入和退出过程。
Prologue指函数进入时需要执行的指令序列。这些指令通常包括保存函数调用前需要保护的寄存器、为栈帧分配空间、将返回地址保存在栈上、以及其他一些必要的设置。
Epilogue则是函数退出时需要执行的指令序列。这些指令通常包括恢复之前被保存的寄存器值、释放栈帧占用的内存、将返回值保存在a寄存器中,并且跳转回调用函数的位置。
总的来说,Prologue和Epilogue可以确保函数在进入和退出时能够正确地保存和恢复状态,从而避免了由于不正确的状态处理而导致的问题
实现我们的召唤约定的最后一个关键步骤是介绍函数的进入和退出过程。这就是我们满足我们的保证的地方。即:
- sp在退出时将具有与它所输入的函数相同的值(除非我们在堆栈上存储返回值)。
- 所有的寄存器都将具有与它们进入的函数相同的值。
- 该函数将返回到ra中存储的值,假设没有异常执行。
为了实现这一点,我们在函数之前添加一个称为prologue的部分,在一个称为epilogue的部分。prologue通常是这样的:
def prologue( ) :
将SP寄存器减去num,其中num是需要保留给寄存器和局部变量的栈空间大小。
保存使用的任何已保存寄存器。
如果进行函数调用,保存ra寄存器。
epilogue看起来是这样的:
defepilogue():
重新加载使用过的任何已保存寄存器。
如果需要,重新加载ra寄存器。
将SP寄存器增加回先前的值。
跳转回返回地址。
遵循这一总体流程,我们可以始终满足我们的保证,并编写能够正确与他人的程序进行交互的代码。
让我们来看一个示例,函数sum_squares(n)对从1到n的每个值调用一个名为square的函数进行求平方,并将所有结果求和。
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
loopstart:
bge s0 s1 loopend
mv a0 s0
jal square
add s2 s2 a0
addi s0 s0 1
j loopstart
loopend:
mv a0 s2
epilogue:
lw s0 0 ( sp )
lw s1 4 ( sp )
lw s2 8 ( sp )
lw ra 1 2 ( sp )
addi sp sp 16
jr ra
请注意,我们将值存储在s寄存器中,因为我们需要在函数调用之后使用这些值。我们为每个s寄存器和ra寄存器存储足够的栈空间,因为我们要调用一个函数。
2.为什么要选择这个约定?
当面临约定时,一个合理的问题是为什么我们要这样做。我们使用SP和RA的概念是因为它们是需要满足的通用编程假设。X0是为了提高效率,因为始终拥有一个源为0的寄存器非常有用。总的来说,我们希望避免保存我们使用的每个寄存器的模式。这样做既繁琐,也可能是浪费的,因为我们可能最终保存了从未更改过的寄存器。此外,始终保存值也是不必要的,取而代之的是我们使用S寄存器指定要保存的确切值。这意味着我们不应该总是保存A寄存器(毕竟,我们可能不会始终需要这些参数)。这正是为什么我们也使用A寄存器作为返回值的关键所在。我们永远不能假设A寄存器会一直保留下来,因此我们可以随时使用它们,使得其他专用的返回寄存器变得多余。
3 违反约定
不要这样做。有时候,即使不遵循约定,你可能发现你的代码可以正确运行。当然,这是有可能的,但并不能提供任何保证。特别是许多学生会在调用的函数中查找未使用的T寄存器,并将其作为S寄存器使用不同的T寄存器。然而,这直接违反了我们的抽象边界,并阻止我们随后修改该函数。所以我再强调一次,不要这样做。
4 使用约定调试
现在我们知道了这个约定,我们如何使用它来进行调试。汇编代码可能会变得复杂和冗长。因此,当您可以逐步完成每一条指令时,可能不希望这样做。这里有一些技巧可以帮助您进行调试。
- 确保你正确保存了RA寄存器。对于递归调用,请确保为每个递归调用链接RA寄存器。你可以在回收代码的结尾设置一个断点,并观察返回的位置来测试这一点。
- 检查您在函数调用后不使用任何t寄存器。
- 检查sp是否以相同的值进入和退出。
- 检查输入前记的次数等于输入后记的次数。
- 确保您还原了您修改的每个寄存器。
这些检查并不能帮助解决每一个调试问题,但可以发现由于违反我们程序的假设而导致的困难的情况。
理解
之前一直困惑,riscv中ret和在循环中调用间距跳转指令有什么区别?根据调用约定函数的返回值被储存在ra寄存器中,因此jr ra 可以认为是函数返回值。而jr rd,使用其他寄存器进行跳转可以认为是一般的间接跳转。