实验4:RISC-V调用约定
截止日期:2月24日星期二晚上11:59:59 PT
实验3:阶乘解释
作为对实验3中可选阶乘问题的快速复习,我们提供了以下演练。点击下拉菜单来检查您对RISC-V函数调用和寄存器约定的知识。
完整程序:
main:
la t0 n
lw a0 0(t0)
jal ra factorial
addi a1 a0 0
...
factorial:
addi t0 x0 1
addi t1 x0 1
loop:
blt a0 t0 done
mul t1 t1 t0
addi t0 t0 1
j loop
done:
add a0 t1 x0
jr ra
阶乘的类似C函数:
int
RISC-V版本使用寄存器而不是局部变量实现相同的逻辑!
复习问题:
什么指令调用factorial?
jal ra factorial
这会跳转到factorial并将返回地址(main的下一行)保存在ra中。
什么指令从factorial返回?
jr ra
这会跳回到ra中存储的地址。
哪个寄存器保存factorial的参数?
a0
调用函数之前,main将n的值加载到a0中。按照约定,a0保存第一个参数。
哪个寄存器保存factorial的返回值?
a0
返回之前,我们使用add a0 t1 x0将结果复制到a0中。
循环体内部发生什么?
循环体包含三个主要操作:
mul t1, t1, t0:我们将运行总数t1乘以当前计数器t0。addi t0, t0, 1:我们将计数器增加1(i = i + 1)。j loop:我们跳回到loop标签以重新检查退出条件。
哪个寄存器保存循环索引,退出条件是什么?
循环索引存储在t0中。
退出条件由blt a0 t0 done定义。
如果a0小于t0,控制跳转到done。用C语言来说,循环在i >= n时继续。
起始代码
您必须在本地机器上完成此实验。如果需要重新设置本地机器,请参阅实验0。
在本地机器的labs目录中,拉取您在过去实验中可能做过的任何更改:
仍然在本地机器的labs目录中,使用以下命令拉取本实验的文件:
如果您遇到以下错误:
fatal: 'starter' does not appear to be a git repository
fatal: Could not read from remote repository.
请按如下方式设置starter远程仓库:
然后再次运行原始命令。
仍然在labs目录中,运行以下命令下载我们可能需要的一些工具的最新版本:
如果遇到任何git错误,请查看常见错误页面。
Venus
与实验3一样,我们将使用Venus RISC-V模拟器。另外,当您需要复习Venus的任何功能时,请参考我们课程网站上的Venus参考。
像处理实验3一样挂载实验4的文件。如果遇到错误,请参考常见错误页面。
调用约定
这里有一个关于调用约定的附录。如果您觉得不太熟悉,我们建议阅读它,因为正确的调用约定对于RISC-V编程至关重要。
我们建议熟悉的其他优秀资源包括实验幻灯片和调用约定讲座,以了解调用约定背后的动机。
下面我们提供了两个调用约定(CC)的实际演练。我们知道这些示例很长,但它们将帮助您了解在编写RISC-V时如何遵循调用约定,并使本实验更容易解决。
示例:实践应用
假设我们有一个名为func1的函数的以下汇编代码结构。假设func1是一个被另一个函数(假设是main函数)调用的函数。从代码中,您还将看到func1也将调用另一个名为func2的函数。当func1被main函数调用时,func1是被调用者。然而,当func1调用func2时,func1成为调用者。因此,在遵循调用约定时,func1必须承担作为被调用者和调用者的职责。
func1: # 修改a0、t0和s0。ra指向`main`函数(modifies a0, t0, and s0. ra points to the `main` function)。
# 检查点1:在开始修改寄存器之前需要做什么?(Checkpoint 1: What do you need to do before you start modifying registers?)
# Some block of code using a0, t0, and s0
# Checkpoint 2: What do you need to do before you call another function?
# input argument at a0, return value at a0
jal ra, func2 # call func2
# Checkpoint 3: What do you need to do after a function call?
# Some block of code using a0, t0, and s0
# Checkpoint 4: What do you need to do before this function returns?
jr ra # function return
使用sw指令将值保存到栈中,存储到适当的栈指针(sp)位置。使用lw指令从相应的栈指针位置检索值。每当您要将值保存到栈中时,需要调整栈指针(通过减小栈指针)。每当您要从栈中检索值时,需要反向调整栈指针(通过增加栈指针)。执行这些步骤将确保栈指针在函数调用的开始和结束时保持相同。这就是函数如何利用栈内存作为临时存储的方式。
让我们尝试填写代码中每个检查点的代码。
对于检查点1,由于func1是被调用者(被main调用),它必须保存它将要修改的被调用者保存寄存器。代码说明我们将修改s0,这是一个保存寄存器。此外,由于func1还将调用另一个函数func2,它还需要保存返回地址(ra)。虽然ra寄存器被标记为调用者保存寄存器,但将ra保存到栈中通常在这一步完成。由于我们将保存2个寄存器,我们必须将sp寄存器调整2个字(8字节)。因此,我们有以下代码:
# 检查点1:在开始修改寄存器之前需要做什么?(Checkpoint 1: What do you need to do before you start modifying registers?)
addi sp sp -8 # 将栈指针向下推2个字(8字节)(Push the stack pointer down by 2 words (8 bytes))
sw ra 0(sp) # 保存返回地址寄存器(ra)(Save the return address register (ra))
sw s0 4(sp) # 保存被调用者保存寄存器(s0)(Save the saved register (s0))
对于检查点2,我们看到a0、t0、s0之前已经被修改,现在我们即将调用func2(func1现在成为调用者)。我们看到func2在a0处接收输入参数,并在同一寄存器中设置返回值。此外,我们看到在代码的后面(检查点3之后),我们将再次使用t0和s0寄存器。因此,从func1的角度来看,在对func2的函数调用之前和之后,这两个寄存器都应该保持不变。我们知道,如果func2遵循调用约定,它应该将s0寄存器保存到栈中,并在返回之前将其恢复到原始值。这正是我们想要的。然而,t0不会被func2保存,它可以在函数内自由更改。因此,作为func2的调用者,func1现在也负责将t0保存到栈中。因此,我们有以下代码:
# 检查点2:在调用另一个函数之前需要做什么?(Checkpoint 2: What do you need to do before you call another function?)
addi sp sp -4 # 将栈指针向下推1个字(4字节)(Push the stack pointer down by 1 word (4 bytes))
sw t0 0(sp) # 保存临时寄存器(t0)(Save the temporary register (t0))
对于检查点3,func2现在已经返回,返回值存储在a0中。func1现在将使用这个返回值(a0)以及t0和s0寄存器进行一些操作。同样,如果func2遵循了调用约定,那么从func1的角度来看,s0寄存器的值应该保持不变。然而,t0可能已被修改。好在我们在调用func2之前已经将其保存到栈中。现在我们只需要检索它即可。因此,我们有以下代码:
# 检查点3:函数调用后需要做什么?(Checkpoint 3: What do you need to do after a function call?)
lw t0 0(sp) # 从栈中恢复保存的临时寄存器(Retrieve the saved temporary register from the stack)
addi sp sp 4 # 将栈指针向上恢复1个字(4字节)(Return the stack pointer up by 1 word (4 bytes))
对于检查点4,我们现在处于func1完成其操作并将返回main的时刻。然而,在返回之前,它必须确保它也完成了作为被调用者的职责。在检查点1中,我们将ra和s0寄存器保存到栈中。现在是时候检索它们了。在func1的操作过程中,它修改了s0寄存器和ra寄存器(由于对func2的函数调用)。作为被调用者,它有责任将它们恢复到原始值,以便从main函数的角度来看,这些寄存器保持不变。因此,我们有以下代码:
# 检查点4:函数返回之前需要做什么?(Checkpoint 4: What do you need to do before this function returns?)
lw s0 4(sp) # 恢复原始保存的寄存器(s0)(Retrieve the original saved register (s0))
lw ra 0(sp) # 恢复原始返回地址(ra),它指回main函数(Retrieve the original return address (ra). This points back to the main function.)
addi sp sp 8 # 将栈指针向上恢复2个字(8字节)(Return the stack pointer up by 2 words (8 bytes))
通过这些操作,我们现在已经满足了func1的RISC-V调用约定。以下是完整代码:
func1: # 修改a0、t0和s0。ra指向`main`函数(modifies a0, t0, and s0. ra points to the `main` function)。
# 检查点1:在开始修改寄存器之前需要做什么?(Checkpoint 1: What do you need to do before you start modifying registers?)
addi sp sp -8 # Push the stack pointer down by 2 words (8 bytes)
sw ra 0(sp) # Save the return address register (ra)
sw s0 4(sp) # Save the saved register (s0)
# Some block of code using a0, t0, and s0
# Checkpoint 2: What do you need to do before you call another function?
addi sp sp -4 # Push the stack pointer down by 1 word (4 bytes)
sw t0 0(sp) # Save the temporary register (t0)
# input argument at a0, return value at a0
jal ra, func2 # call func2
# Checkpoint 3: What do you need to do after a function call?
lw t0 0(sp) # Retrieve the saved temporary register from the stack
addi sp sp 4 # Return the stack pointer up by 1 word (4 bytes)
# Some block of code using a0, t0, and s0
# Checkpoint 4: What do you need to do before this function returns?
lw s0 4(sp) # Retrieve the original saved register (s0)
lw ra 0(sp) # Retrieve the original return address (ra). This points back to the main function.
addi sp sp 8 # Return the stack pointer up by 2 words (8 bytes)
jr ra # function return
假设在func1调用开始时,寄存器sp从0x7FFFFFF0开始。
s0保存在什么内存地址?
栈指针被调整了-8,但s0存储在4 + sp处。因此,0x7FFFFFF0 - 8 + 4 = 0x7FFFFFEC
t0保存在什么内存地址?
栈指针之前被调整了-8,然后在保存t0之前,我们再次将其调整-4。因此,0x7FFFFFF0 - 8 - 4 = 0x7FFFFFE4
在func1结束时(在jr ra行),栈指针指向哪里?
它应该回到原始值,因为我们已经检索了保存的数据并随后恢复了栈指针。因此,0x7FFFFFF0。
如果我们没有实现检查点2和3中的代码,会出什么问题?
在检查点3之后,描述了func1将再次使用t0。由于t0是调用者保存寄存器,被调用者(即func2)可以自由更改t0,不需要返回原始值。如果t0确实被func2修改了,那么func1的计算将会出错,因为函数调用改变了它的一个临时变量。
如果我们根本不保存ra,会出什么问题?
ra指向调用函数的地址。在func1开始时,它指向main函数中的某个位置。每当您运行jal指令时,它会修改ra,使其指向该指令之后的下一行。这样做是为了当运行jr ra时,它将继续执行到ra指向的位置。func1有一个对func2的函数调用。这会改变ra,使其指向该调用之后的指令(lw t0 0(sp))。现在它指向func1本身的某个位置。如果ra根本没有保存,那么当func1在末尾执行jr ra指令时,它将跳回到那个指令(lw t0 0(sp)),这不是预期的执行流程。
示例:使用调用约定从C转换到RISC-V
在本练习中,您将被指导如何将下面的C程序转换为RISC-V。如果您想寻求额外的挑战,可以先翻译代码,然后再查看解决方案。
int source = ;
int dest;
int
int
让我们从初始化source和dest数组开始。就像我们在实验3中所做的那样,我们需要在.data部分声明我们的数组,如下所示:
.data
source:
.word 3
.word 1
.word 4
.word 1
.word 5
.word 9
.word 0
dest:
.word 0
.word 0
.word 0
.word 0
.word 0
.word 0
.word 0
.word 0
.word 0
.word 0
接下来,让我们编写fun。
int
调用约定规定:
- 我们可以在寄存器
a0中找到x。 - 我们必须将返回值放在寄存器
a0中
代码的其余部分在下面的注释中解释。
.text
fun:
addi t0, a0, 1 # t0 = x + 1
sub t1, x0, a0 # t1 = -x
mul a0, t0, t1 # a0 = (x + 1) * (-x)
jr ra # return
问问自己:为什么我们在使用t1之前不保存它?
t1是一个调用者保存寄存器。调用约定不保证调用者保存寄存器在函数调用后保持不变。因此,fun可以在不保存其旧值的情况下更改t1。如果调用fun的函数在t1中存储了一个值,并且想在fun返回后使用它,则需要在调用fun之前保存t1。
让我们继续看main函数。(我们暂时忽略调用约定)。
int
上面的代码变成如下:
main:
addi t0, x0, 0 # t0 = k = 0
addi s0, x0, 0 # s0 = sum = 0
We have to initialize k to 0 because there is no way to declare a variable in RISC-V and not set it.
Next, let's load in the address of the two arrays.
la s1, source
la s2, dest
Remember that la loads the address of a label. This is the only way that we can access the address of source and dest. s1 is now a pointer to the source array and s2 is now a pointer to the dest array.
Let's move on to the loop.
for
First, we'll construct the outer body of the loop.
loop:
#1 slli s3, t0, 2
#2 add t1, s1, s3
#3 lw t2, 0(t1)
#4 beq t2, x0, exit
...
#5 addi t0, t0, 1
#6 jal x0, loop
exit:
-
Lines 1-3 are needed to access
source[k]. First we want to compute the byte offset of the element. We are dealing withintarrays, so the size of each element is4 bytes. This means that we need to multiplyt0(k) by 4 to compute the byte offset. To multiply a value by 4, we can just shift it left by 2. -
Next, we need to add the offset to the array pointer to compute the address of
source[k]. -
Now that we have the address, we can load the value in from memory.
-
Then, we check to see if
source[k]is 0. If it is, we jump to the exit. -
At the end of the loop, we increment
kby 1 -
Finally, we loop back the to beginning
Now, Let's fill in the rest of the loop (ignoring calling convention at first)
loop:
slli s3, t0, 2
add t1, s1, s3
lw t2, 0(t1)
beq t2, x0, exit
#1 add a0, x0, t2 # 1
...
#2 jal fun # 2
...
#3 add t3, s2, s3 # 4
#4 sw a0, 0(t3) # 5
#5 add s0, s0, a0 # 6
addi t0, t0, 1
jal x0, loop
exit:
-
Fun takes in the argument
x. We must pass this argument througha0so thatfunwill know where to find it. -
Call
fun.jalautomatically saves the return address inra. -
Next, we want so store this value in
dest. First we need to compute the address of where we want to store the value indest. Remember that we can reuse theoffsetthat we computed earlier (this can be found ins3).s2is a pointer to the beginning ofdest. -
Store value at
dest[k]. Remember thatfunplaced the return value ina0. -
Increment
sumbydest[k]
现在,让我们在jal fun周围添加正确的调用约定。在向下滚动之前,问问自己我们需要添加什么代码来满足调用约定。
为了满足调用约定(从而使代码按预期运行),我们需要保存任何调用者保存寄存器,这些寄存器的值在调用fun后需要保持不变。在这种情况下,我们可以看到在main中使用了寄存器t0、t1、t2和t3。
我们需要保存和恢复所有这些寄存器吗?
不需要,我们只需要保存和恢复t0。我们在fun之前使用t1和t2,但之后不再使用它们。我们在fun之后写入t3,所以我们不关心它的旧值是什么。
t0是我们拥有的唯一一个调用者保存寄存器,其值在fun之前和之后必须保持不变。
让我们在jal fun周围添加正确的调用约定代码。
addi sp, sp, -4
sw t0, 0(sp)
jal fun
lw t0, 0(sp)
addi sp, sp, 4
接下来,让我们继续看exit(暂时不考虑调用约定)。
exit:
addi a0, x0, 1 # argument to ecall, 1 = execute print integer
addi a1, s0, 0 # argument to ecall, the value to be printed
ecall # print integer ecall
addi a0, x0, 10 # argument to ecall, 10 = terminate program
ecall # terminate program
最终的总和存储在s0中。要打印这个值,我们需要将其存储在a1中,这是ecall的参数,将被打印出来。
现在我们已经完成了程序的逻辑。接下来我们需要完成main的调用约定。
想一想,调用约定中缺少哪一部分?
我们正在覆盖被调用者保存寄存器而不保存它们!记住,被调用者的职责是确保被调用者保存寄存器在函数的开始和结束时具有相同的值。
我们需要保存哪些被调用者保存寄存器?
我们需要保存我们使用的任何被调用者保存寄存器。我们不知道调用我们的函数正在使用这些寄存器中的哪些,所以我们必须保存所有我们覆盖的寄存器。在这种情况下,那将是寄存器s0-s3和ra。
理解为什么我们需要保存ra可能有点棘手。记住另一个函数调用了main。当那个函数调用main时,它将返回地址存储在ra中,以便main知道在执行完成后返回哪里。当main调用fun时,它需要将返回地址存储在ra中,以便fun知道在执行完成后返回哪里。因此,main必须在覆盖ra之前保存它。
下面,您可以找到main的序言和结语:
main:
# BEGIN PROLOGUE
addi sp, sp, -20
sw s0, 0(sp)
sw s1, 4(sp)
sw s2, 8(sp)
sw s3, 12(sp)
sw ra, 16(sp)
# END PROLOGUE
...
...
exit:
addi a0, x0, 1 # argument to ecall, 1 = execute print integer
addi a1, s0, 0 # argument to ecall, the value to be printed
ecall # print integer ecall
# BEGIN EPILOGUE
lw s0, 0(sp)
lw s1, 4(sp)
lw s2, 8(sp)
lw s3, 12(sp)
lw ra, 16(sp)
addi sp, sp, 20
# END EPILOGUE
addi a0, x0, 10 # argument to ecall, 10 = terminate program
ecall # terminate program
您可以在example_c_to_riscv.s中找到完整的程序。
练习1:调用约定检查器
在尝试以下练习之前,请确保您理解调用约定(实验幻灯片是一个很好的资源!)。
调用约定错误可能导致代码中难以发现的bug。调用约定检查器用于检测代码中的调用约定违规。然而,它不是全面的。在本练习中,您将使用调用约定检查器来修复一些调用约定问题。
注意: Venus的调用约定检查器不会报告所有调用约定bug;它主要用作基本检查。最重要的是,它只会查找使用.globl指令导出的函数中的bug - .globl的含义在Venus参考中有更详细的解释。
- 要启用调用约定检查器,请点击页面顶部的Venus选项卡,并在"设置(Settings)"窗格下的"Calling Convention"行点击"Enable"(启用)。
- 您也可以使用
-cc标志在命令行中运行调用约定检查器。例如,java -jar tools/venus.jar -cc lab04/ex1.s。
- 您也可以使用
- 打开
ex1.s在模拟器选项卡中。 - 运行模拟器,您应该会看到一些类似于下面的错误。
[CC Violation]: (PC=0x0000004C) Setting of a saved register (s0) which has not been saved! ex1.s:54 li s0, 1
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000054) Setting of a saved register (s0) which has not been saved! ex1.s:57 mul s0, s0, a0
[CC Violation]: (PC=0x00000064) Save register s0 not correctly restored before return! Expected 0x00000000, Actual 0x00000080. ex1.s:65 jr ra
[CC Violation]: (PC=0x00000070) Setting of a saved register (s0) which has not been saved! ex1.s:79 mv s0, a0 # Copy start of array to saved register
[CC Violation]: (PC=0x00000074) Setting of a saved register (s1) which has not been saved! ex1.s:80 mv s1, a1 # Copy length of array to saved register
[CC Violation]: (PC=0x000000A4) Setting of a saved register (s0) which has not been saved! ex1.s:117 addi s0, t1, 1
Found 12 warnings!
--------------------
[ERROR] An error has occurred!
Error:
`StoreError: You are attempting to edit the text of the program though the program is set to immutable at address 0x00000006!
有关这些错误的更多信息可以在Venus参考中找到。
练习1.1:检查代码
阅读ex1.s。确保您对以下问题有答案。
next_test是函数吗?
不是,它只是一个用于跳过对failure调用的标签。如果一个标签是函数,我们会跳转到它并链接到它,因为我们总是希望函数返回到我们这里。在这种情况下,我们只是跳转到next_test。
为什么helper_fn中的调用约定错误没有被CC检查器报告?(提示:在练习说明中提到过。)
它没有声明.globl。调用约定检查器不会测试未声明.globl的函数。注意:helper_fn中的bug最初会被检查器报告,但当调用它的函数中的bug被修复后,这个bug就不再被报告了。
练习1.2:被调用者函数
使用打印的错误,修复 pow 和 helper_fn 函数的调用约定错误。
所有这些错误的修复(包括CC检查器报告的错误和它无法找到的错误)应该添加在起始代码中FIXME注释标记的行附近。
修复后,您应该会看到CC检查器报告的错误消息减少。另外,请确保您对以下问题有答案。
是什么导致了Venus CC检查器报告的pow中的错误?
缺少结语和序言。
在RISC-V中,我们通过跳转到函数并将返回地址存储在ra寄存器中来调用函数。调用约定是否适用于跳转到pow_loop或pow_end标签?
不适用,因为它们不是函数,我们不需要返回到函数被调用的位置。
练习1.3:调用者和被调用者函数
使用打印的错误,修复 inc_arr 函数的调用约定错误。
所有这些错误的修复(包括CC检查器报告的错误和它无法找到的错误)应该添加在起始代码中FIXME注释标记的行附近。
修复后,您的输出应该类似于:
Tests passed.
Found 0 warnings!
在继续下一个练习之前,请确保您对以下问题有答案。
是什么导致了Venus CC检查器报告的inc_arr中的错误?
inc_arr:未能在序言/结语中保存s0、s1,以及在调用helper_fn之前未能保存t0。
为什么我们需要在inc_arr的序言中存储ra,而不需要在其他函数中存储?
inc_arr本身调用另一个函数——ra保存返回后继续执行的指令地址,当我们调用另一个函数时,它会被覆盖,因为我们需要能够返回到inc_arr的主体。
练习2:修复CC错误:s版
在本练习中,起始代码包含一个递归的pow函数,该函数有一个或多个我们在过去学期中常见的调用约定错误。您的任务是修复这些错误,以便在编写RISC-V时能够避免这些错误。
警告:您只能向起始代码添加行。请不要修改或删除任何现有行。
- 打开
ex2.s并在Venus中运行。请注意,程序由于无限循环而无法退出。 - 阅读代码和注释。确保您已阅读以
Note开头的注释。思考为什么程序会有无限循环,并查找调用约定违规。 - 修复调用约定错误。
练习3:修复CC错误:t版
与上一个练习类似,本练习的起始代码包含一个递归的pow函数,该函数有一个或多个我们在过去学期中常见的调用约定错误。您的任务是修复这些错误,以便在编写RISC-V时能够避免这些错误。请注意,本练习中的pow函数实现方式略有不同。
警告:您只能向起始代码添加行。请不要修改或删除任何现有行。
- 打开
ex3.s并在Venus中运行。请注意,程序由于无限循环而永远不会停止。 - 阅读代码和注释。确保您已阅读两个以
Note开头的注释。思考为什么程序会有无限循环,并查找调用约定违规。这个pow函数与上一个练习中的pow函数有何不同? - 修复调用约定错误。
练习4:反思和反馈表
我们每周都在努力改进课程——请填写此调查告诉我们您到目前为止在讨论课和实验中的体验!
提交
保存、提交并推送您的工作,然后在Gradescope上提交到实验4作业。