实验3:RISC-V, Venus¶
MOLI:本次实验你将会学到什么
- Venus 模拟器使用
- C 到 RISC-V 汇编的转换
- 用 Venus 调试汇编程序
汇编语言简介¶
在本课程中,到目前为止我们主要处理的是 C 程序(以 .c
为文件扩展名),使用 gcc
将它们编译为机器码,然后直接在 Hive 机群上执行。现在,我们将注意力转向 RISC-V 汇编语言,这是一种更底层、与机器码更接近的语言。由于你的计算机和 Hive 机群是为运行其他汇编语言(最常见的是 x86 或 ARM)的机器码而设计的,因此不能直接执行 RISC-V 代码。
在本次实验和下次实验中,我们会使用若干 RISC-V 汇编文件,这些文件的扩展名均为 .s
。要运行这些文件,我们将使用 Venus —— 一个面向教育的 RISC-V 汇编器与模拟器。你可以在本地终端运行 Venus,也可以使用 Venus 网站。以下说明将指导你完成设置步骤。尽管在本次实验中你可能会觉得使用网页版编辑器更方便,但仍请按照以下步骤完成本地环境的搭建:这些步骤还将配置其他未来项目与实验所需的基础设施。
Venus:入门指南¶
要开始使用 Venus,请查看 Venus 参考文档中的“编辑器选项卡”(The Editor Tab)和“模拟器选项卡”(The Simulator Tab)。我们建议你有时间时阅读整页内容,但这两个部分足以让你入门。
注意
在下面的练习中,请确保你完成的代码已保存在本地机器上的文件中。否则,我们将无法证明你完成了实验练习。
练习 1:Venus 基础¶
你可以将本地设备上的某个文件夹“挂载”到 Venus 的 Web 界面,这样你在浏览器 Venus 编辑器中所做的编辑就会同步到本地文件系统,反之亦然。如果不进行此步骤,每次关闭浏览器标签页时在 Venus 中创建或编辑的文件都会丢失——除非你手动复制/粘贴到本地文件。
本练习将引导你完成将本地文件系统连接到 Venus 的过程,这将大大减少在本地驱动器和 Venus 编辑器之间复制/粘贴文件的麻烦。
-
在你的
labs
文件夹中,运行:java -jar tools/venus.jar . -dm
这会将你的实验目录通过网络端口暴露给 Venus。- 你应该看到一个大的 “Javalin” 图标。
- 如果看到类似 “port unable to be bound” 的提示信息,可以通过在命令后添加
--port PORT_NUM
显式指定其他端口(例如,java -jar tools/venus.jar . -dm --port 6162
会通过端口 6162 暴露文件系统)。
-
在浏览器中打开: https://venus.cs61c.org (推荐使用 Chrome 或 Firefox)。
-
在 Venus 的 Web 终端中运行:
mount local labs
(如果你指定了其他端口,请将 local 替换为完整 URL,例如 http://localhost:6162)。该命令将 Venus 连接到你的本地文件系统。- 可能会弹出提示:
Key has been shown in the Venus mount server! Please copy and paste it into here.
这时在本地终端的最新输出行中可以看到一个 “key”,复制并粘贴到弹出的对话框中即可。
- 可能会弹出提示:
-
切换到 “Files” 选项卡。你现在应该能在
labs
文件夹下看到你的实验目录。 -
导航到
lab03
,点击ex1_hello.s
旁边的Edit
按钮。- 你会在编辑器中看到
ex1_hello.s
的内容。该编辑器类似于大多数普通文本编辑器,但功能相对简单。
- 你会在编辑器中看到
-
要汇编编辑器中打开的程序,点击 “Simulator” 选项卡,然后点击
Assemble & Simulate from Editor
。- 将来如果模拟器中已有程序打开,点击
Re-assemble from Editor
。注意,这会覆盖模拟器选项卡中的所有内容(例如已有的调试会话)。
- 将来如果模拟器中已有程序打开,点击
-
要运行程序,点击
Run
。- 你会看到其他按钮,如
Step
、Prev
和Reset
。你将在本实验后续和作业中使用这些按钮。
- 你会看到其他按钮,如
-
返回编辑器选项卡,将
ex1_hello.s
修改为输出2025
。- 提示: 当
ecall
执行时,会打印出a1
寄存器中的值。ecall
的具体功能超出本课程范围。
- 提示: 当
-
在 macOS 上按
⌘ + S
,在 Windows/Linux 上按Ctrl + S
保存更改。这样会更新本地文件副本。 -
在本地机器上使用任意文本编辑器打开该文件,检查并确保它与 Web 编辑器中的内容一致。
- 注意: 如果你在本地编辑了文件,而 Venus 编辑器中也打开着同一文件,需要从 “Files” 菜单重新打开它,才能看到最新更改。
-
再次运行程序,如果你正确修改了源文件,现在应该能看到
2025
。
从 C 语言到 RISC-V汇编¶
在本例中,我们将演示如何将一个 C 程序逐步翻译成 RISC-V 程序。下面的程序会打印第 n 个斐波那契数。虽然本节内容较长,但请务必阅读!
#include <stdio.h>
int n = 12;
// Function to find the nth Fibonacci number
int main(void) {
int curr_fib = 0, next_fib = 1;
int new_fib;
for (int i = n; i > 0; i--) {
new_fib = curr_fib + next_fib;
curr_fib = next_fib;
next_fib = new_fib;
}
printf("%d\n", curr_fib);
return 0;
}
下面我们分步骤说明如何翻译。首先在 Venus 编辑器中打开 fib.s
。我们需要定义全局变量 n
。在 RISC-V 中,全局变量在 .data
指令下声明。这代表数据段。它看起来像这样:
.data
n: .word 12
n
是变量的名称.word
表示数据的大小为一个字- 12是分配给
n
的数值
继续初始化 curr_fib
和 next_fib
两个变量
.text
main:
add t0, x0, x0 # curr_fib = 0
addi t1, x0, 1 # next_fib = 1
在这里我们添加了 .text
指令。该指令之下的所有内容都是我们的代码。
请记住,寄存器 x0
始终保存数值 0。
对于 new_fib
,我们无需任何声明(在 RISC‑V 中不需要显式声明变量)。
接下来,让我们进入循环部分。首先设置循环变量:以下代码会将 i
设为 n
。
la t3, n # 将标签 n 的地址存到寄存器t3中
lw t3, 0(t3) # 从寄存器 t3 指向的内存地址读取一个字(32 位)到 t3
你可以将上面的代码理解为:
t3 = &n; //取变量 n 的地址
t3 = *t3;
这里我们引入了一个新的伪指令 la
,它用于加载标签地址。第一行将 n
的地址加载到寄存器 t3
,相当于将 t3
设为指向 n
的指针。接着,我们使用 lw
指令对 t3
进行解引用,将 n
存储的值加载到 t3
。
你可能会想,为什么不能直接把 t3
设为 n
?在 .text
段中无法直接访问符号 n
(比如无法写 add t3, n, x0
,因为 add
的操作数必须是寄存器,而 n
不是寄存器)。我们唯一能访问它的方式是先获取 n
的地址,然后使用 lw
在该地址处读取值。在本例中,我们对 n
的地址加了 0
的偏移。
现在我们进入循环部分,首先,创建以下的循环结构:
fib:
beq t3, x0, finish # exit loop once we have completed n iterations
...
...
addi t3, t3, -1 # decrement counter
j fib # loop
finish:
第一行 fib:
是一个标签,用于跳回循环开始处。
下一行 beq t3, x0, finish
指定了终止条件:当 t3
(即循环变量 i
)变为 0 时,跳转到另一个标签 finish
。
再下一行 addi t3, t3, -1
在循环体末尾对 i
进行递减。必须放在循环体最后,因为在循环体中还要使用 i
,如果在 beq
之后立刻更新,循环体内就无法获取正确的 i
值。
接下来的指令 j fib
无条件跳转回循环开头。
现在,让我们把循环体内容添加进来。
fib:
beq t3, x0, finish # 当完成 n 次迭代后跳转到 finish 处,退出循环。
add t2, t1, t0 # new_fib = curr_fib + next_fib;
mv t0, t1 # curr_fib = next_fib;
mv t1, t2 # next_fib = new_fib;
addi t3, t3, -1 # decrement counter
j fib # loop
finish:
这里没有特别之处。相应的 C 代码已写在注释中。
现在,让我们打印出第 n 个斐波那契数!
finish:
addi a0, x0, 1 # argument to ecall to execute print integer
addi a1, t0, 0 # argument to ecall, the value to be printed
ecall # print integer ecall
打印操作是一次系统调用。稍后你会在本学期更深入地学习系统调用,但它本质上是程序与操作系统交互的一种方式。
在 RISC‑V 中发起系统调用使用特殊指令 ecall
。要打印一个整数,需要向 ecall
传递两个参数:第一个参数指定 ecall
要执行的操作(此处为打印整数,用 1
表示),第二个参数则是要打印的整数值。
在 C 语言中,我们习惯写成 ecall(1, t0)
的形式。但在 RISC‑V 汇编里不能这样传参,必须将参数放入约定好的寄存器:第一个参数放在 a0
,第二个参数放在 a1
,依此类推。
因此,我们把 1
放入 a0
,将要打印的整数(存于 t0
)放入 a1
,然后执行 ecall
即可打印该整数。
接下来,我们需要终止程序,这同样需要调用 ecall
。
addi a0, x0, 10 # argument to ecall to terminate
ecall # terminate ecall
这种情况下,ecall
只需要一个参数。将 a0
设置为 10
表示我们要终止程序。
大功告成!下面是完整的 RISC‑V 程序:
.data
n: .word 12
.text
main:
add t0, x0, x0 # curr_fib = 0
addi t1, x0, 1 # next_fib = 1
la t3, n # load the address of the label n
lw t3, 0(t3) # get the value that is stored at the adddress denoted by the label n
fib:
beq t3, x0, finish # exit loop once we have completed n iterations
add t2, t1, t0 # new_fib = curr_fib + next_fib;
mv t0, t1 # curr_fib = next_fib;
mv t1, t2 # next_fib = new_fib;
addi t3, t3, -1 # decrement counter
j fib # loop
finish:
addi a0, x0, 1 # argument to ecall to execute print integer
addi a1, t0, 0 # argument to ecall, the value to be printed
ecall # print integer ecall
addi a0, x0, 10 # argument to ecall to terminate
ecall # terminate ecall
练习2:使用 Venus 调试器¶
在 Venus 调试器中打开文件有两种方式:
-
通过编辑器
-
在 Venus 编辑器中打开
fib.s
。 -
点击 Simulator 选项卡,然后点击 Assemble & Simulate from Editor(或 Re-assemble from Editor)按钮。当前未执行但即将执行的指令会以浅蓝色高亮显示。
-
-
通过 “Files” 选项卡
-
点击 Venus 标签,然后点击 Files 选项卡。
-
在
labs/lab03
目录下找到fib.s
文件。 -
点击文件名旁边的 VDB 按钮。
-
本次练习要求在 ex2_answers.txt
文件中写下答案。题号可能与下述步骤号不一致,请注意!
-
使用上述任一方式在 Venus 调试器中打开
fib.s
。 -
问题 1:高亮指令的机器码是什么?(32 位十六进制,带
0x
前缀) -
问题 2:地址
0x34
处的指令机器码是什么?(32 位十六进制,带0x
前缀) -
点击 Step 按钮单步执行到下一条指令,此时第二条指令应被高亮。
-
点击 Prev 按钮撤销上一次执行。注意,撤销可能无法回退
ecall
等系统调用的效果(例如程序退出或打印)。 -
在屏幕右侧点击 Registers 选项卡,查看所有 32 个寄存器的值。请确保查看的是整数寄存器,而非浮点寄存器。
- 问题 3:
sp
寄存器的值是多少?(32 位十六进制,带0x
前缀)
- 问题 3:
-
继续单步执行直到
t1
的值发生变化。 -
问题 4:
t1
寄存器的新值是多少?(32 位十六进制,带0x
前缀) -
问题 5:此时当前指令的机器码是多少?(32 位十六进制,带
0x
前缀) -
单步执行直到程序计数达到
0x10
,此时t3
的值已经更新。- 问题 6:
t3
寄存器的值是多少?(32 位十六进制,带0x
前缀)
- 问题 6:
-
切换到 Memory 选项卡,在地址输入框填入问题 6 的值,然后点击 Go,查看该地址处的内存内容。
- 问题 7:
t3
指向的内存字节是多少?(8 位十六进制,带0x
前缀)
- 问题 7:
-
在地址
0x28
所在行点击以设置断点(行会变浅红色并出现断点符号)。 -
点击 Run 继续执行直到命中断点。
- 问题 8:此时
t0
寄存器的值是多少?(32 位十六进制,带0x
前缀)
- 问题 8:此时
-
再次点击 Run 或 Step 6 次。
- 问题 9:现在
t0
寄存器的新值是多少?(32 位十六进制,带0x
前缀)
- 问题 9:现在
-
在寄存器选项卡底部的下拉菜单中将显示切换为 “Decimal”,内存选项卡同理。
- 问题 10:切换到十进制显示后,
t0
寄存器的值是多少?(十进制,不带前缀)
- 问题 10:切换到十进制显示后,
-
再次点击地址
0x28
以取消断点。 -
点击 Run 完成程序执行,因为已无其他断点。
- 问题 11:程序的输出是什么?(十进制,不带前缀)
Venus: 内存检查¶
在 Project 1(以及 C 编程中),Valgrind
是调试内存访问错误(如 “Segmentation fault (core dumped)”)的首选工具。在 Venus 中,我们提供了一个名为 memcheck 的功能,用于类似地检测内存读写错误。其错误信息设计上模仿 Valgrind。
注意:该功能于 2022 年春季开发,2022 年秋季首次引入,如遇任何 Bug 请及时反馈!
Memcheck 有两种模式:
- 普通模式(或仅 “memcheck”):
- 检测并报告所有无效的内存读写。
- 程序退出时,如果有未释放内存,还会输出未释放字节数。
- 详细模式(或 “memcheck verbose”):
- 除了普通模式功能外,还会在运行时打印出每一次内存读写操作。
- 程序退出时,列出所有未释放的内存块列表。
在 Venus 选项卡中可启用这两种模式:
1. 仅选中 “Enable Memcheck?” 即运行普通模式。
2. 同时选中 “Enable Memcheck?” 和 “Enable Memcheck Verbose?” 即运行详细模式。
注意
启用或禁用 Memcheck 后,必须在 VDB 中重新打开正在调试的文件。
练习3:使用内存检查¶
本练习和前一个练习一样,需将答案写在 ex3_answers.txt
中。题号可能与步骤号不同,请注意!
-
在 Venus 编辑器中打开
ex3_memcheck.s
,通读程序,了解其功能。 -
运行程序。哎呀,程序报错了!查看错误信息。
问题 1:程序尝试访问哪个地址导致错误?(32 位十六进制,带
0x
前缀)问题 2:程序试图访问多少字节?(十进制,不带前缀)
-
看起来是内存错误,启用 Memcheck(普通模式),并在 VDB 中重新打开
ex3_memcheck.s
。 -
运行程序。现在 Memcheck 给出了更详细的错误信息!仔细阅读。
问题 3:程序尝试访问哪个地址导致错误?(32 位十六进制,带
0x
前缀)问题 4:与该错误相关的内存块分配了多少字节?(整数,不带单位)
问题 5:源文件的哪一行导致了此错误?(行号,整数)
-
对比问题 2 和问题 4 的答案。注意,Memcheck 可能改变
malloc
返回的地址。 -
让我们调试此错误。回想
t1
寄存器保存循环计数器的值。问题 6:根据 Memcheck 错误信息,
t1
的值是多少?(十进制) -
在源代码中修复此错误并保存文件。
-
再次运行程序。程序执行完成且未出现无效访问错误,但会提示存在未释放的内存。
问题 7:程序退出时有多少字节未被释放?(十进制,不带单位)
-
在详细模式下重新运行 Memcheck。记得在 VDB 中重新打开文件。
问题 8:未释放的内存块地址是多少?(32 位十六进制,带
0x
前缀) -
通过调用
free
修复此问题。 -
在接下来的两个练习中请 禁用 Memcheck。
提示
将指向数组开头的指针存入 a0,然后使用 jal
指令调用 free
。
练习4:数组实践¶
考虑定义在整数集合 {-3, -2, -1, 0, 1, 2, 3}
上的离散值函数 f
,其定义如下:
f(-3) = 6
f(-2) = 61
f(-1) = 17
f(0) = -38
f(1) = 19
f(2) = 42
f(3) = 5
在 ex4_discrete_fn.s
中使用 RISC‑V 汇编实现该函数,不得使用任何分支或跳转指令。请确保你的代码已保存在本地。我们提供了一些提示以防卡壳。
函数 f 把所有可能的输出值都预先放在一个数组 output
里,在函数 f 中通过 a1 寄存器访问数组 output
,你不用自己去申请或定义这个数组,只要根据输入值 (a0
) 算出对应的下标,然后直接从这个数组里取出对应的值即可。
请务必**只使用** t
和 a
寄存器。如果使用其他寄存器,可能会导致意想不到的问题(稍后会解释原因)。
提示1
你可以通过使用 lw
指令来访问存放在数组里的值。
提示2
lw
指令要求偏移量必须是一个立即数(不能是寄存器里的值)。在这个问题里,我们把要用的偏移量先算到一个寄存器里,但又不能把寄存器当偏移量来用。解决办法就是先把寄存器里的偏移量,加到数组首地址上,得到元素的地址,然后用 lw 0(...)
来读取该地址里的值。
在下面这个例子中,索引存放在寄存器 t0
,数组首地址存放在寄存器 t1
,每个元素占 4 字节。在 RISC‑V 中,我们必须自己实现指针运算。因此,我们需要做三步:
- 算偏移量:用
t0 * 4
(把索引乘以元素大小) - 算元素地址:把这个偏移量加到
t1
上 - 读取元素:对新地址执行
lw t2, 0(t1)
(假设把结果放到t2
)
slli t2, t0, 2 # step 1 (see above)
add t2, t2, t1 # step 2 (see above)
lw t3, 0(t2) # step 3 (see above)
提示3
f(-3)
的结果存放在数组的第 0 个位置,将 f(-2)
的结果存放在第 1 个位置,以此类推。
练习5:阶乘¶
注意
本练习请确保已在 Venus 中禁用 Memcheck。
在本练习中,你需要用 RISC‑V 实现阶乘函数 factorial
,接受一个整数参数 n
,返回 n!
。我们在文件 ex5_factorial.s
中提供了函数框架。
传入函数的参数位于标签 n
。你可以修改 n
来测试不同的阶乘。要实现该功能,你需要在 factorial
标签下添加相应的指令。虽然可以使用递归方案,但我们建议你实现迭代方案。你可以假设 factorial
只会在正整数上调用,且结果不会溢出 32 位带符号整数。
在调用 factorial
时,寄存器 a0
中已经存放了要计算阶乘的数字 n
。计算完成后,将结果(n!
)放回寄存器 a0
,然后从函数返回。
请确保只写入 t
寄存器和 a
寄存器。如果你使用其他寄存器,可能会导致意外的行为(稍后你会了解到原因)。
此外,请务必初始化你将使用的寄存器!Venus 可能显示寄存器初始值为 0,但在真实硬件中它们可能含有垃圾数据。请在使用前,为将要使用的寄存器设置一个确定的初始值。
完成后,请保存 ex5_factorial.s
并在 Venus 中运行验证结果。
测试¶
要测试你的代码,可以确认函数返回正确结果。例如:0! = 1
,3! = 6
,7! = 5040
,8! = 40320
。
- 打开
ex5_factorial.s
,在 Venus 模拟器中运行并验证输出。这也是自动评分器测试你的方式,请务必在本地通过测试后再提交。 - 你也可以在命令行中使用以下命令测试:
java -jar tools/venus.jar lab03/ex5_factorial.s
过渡到更复杂的 RISC‑V 程序¶
在后续课程中,我们将使用多个汇编文件来实现更复杂的 RISC‑V 程序。为此,建议你提前查阅 Venus 参考文档,以便了解如何配置和调试多文件项目。