Skip to content

实验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 编辑器之间复制/粘贴文件的麻烦。

  1. 在你的 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 暴露文件系统)。
  2. 在浏览器中打开: https://venus.cs61c.org (推荐使用 Chrome 或 Firefox)。

  3. 在 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”,复制并粘贴到弹出的对话框中即可。
  4. 切换到 “Files” 选项卡。你现在应该能在 labs 文件夹下看到你的实验目录。

  5. 导航到 lab03 ,点击 ex1_hello.s 旁边的 Edit 按钮。

    • 你会在编辑器中看到 ex1_hello.s 的内容。该编辑器类似于大多数普通文本编辑器,但功能相对简单。
  6. 要汇编编辑器中打开的程序,点击 “Simulator” 选项卡,然后点击 Assemble & Simulate from Editor

    • 将来如果模拟器中已有程序打开,点击 Re-assemble from Editor。注意,这会覆盖模拟器选项卡中的所有内容(例如已有的调试会话)。
  7. 要运行程序,点击 Run

    • 你会看到其他按钮,如 StepPrevReset。你将在本实验后续和作业中使用这些按钮。
  8. 返回编辑器选项卡,将 ex1_hello.s 修改为输出 2025

    • 提示: 当 ecall 执行时,会打印出 a1 寄存器中的值。ecall 的具体功能超出本课程范围。
  9. 在 macOS 上按 ⌘ + S,在 Windows/Linux 上按 Ctrl + S 保存更改。这样会更新本地文件副本。

  10. 在本地机器上使用任意文本编辑器打开该文件,检查并确保它与 Web 编辑器中的内容一致。

    • 注意: 如果你在本地编辑了文件,而 Venus 编辑器中也打开着同一文件,需要从 “Files” 菜单重新打开它,才能看到最新更改。
  11. 再次运行程序,如果你正确修改了源文件,现在应该能看到 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_fibnext_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 调试器中打开文件有两种方式:

  • 通过编辑器

    1. 在 Venus 编辑器中打开 fib.s

    2. 点击 Simulator 选项卡,然后点击 Assemble & Simulate from Editor(或 Re-assemble from Editor)按钮。当前未执行但即将执行的指令会以浅蓝色高亮显示。

  • 通过 “Files” 选项卡

    1. 点击 Venus 标签,然后点击 Files 选项卡。

    2. labs/lab03 目录下找到 fib.s 文件。

    3. 点击文件名旁边的 VDB 按钮。

本次练习要求在 ex2_answers.txt 文件中写下答案。题号可能与下述步骤号不一致,请注意!

  1. 使用上述任一方式在 Venus 调试器中打开 fib.s

  2. 问题 1:高亮指令的机器码是什么?(32 位十六进制,带 0x 前缀)

  3. 问题 2:地址 0x34 处的指令机器码是什么?(32 位十六进制,带 0x 前缀)

  4. 点击 Step 按钮单步执行到下一条指令,此时第二条指令应被高亮。

  5. 点击 Prev 按钮撤销上一次执行。注意,撤销可能无法回退 ecall 等系统调用的效果(例如程序退出或打印)。

  6. 在屏幕右侧点击 Registers 选项卡,查看所有 32 个寄存器的值。请确保查看的是整数寄存器,而非浮点寄存器。

    • 问题 3sp 寄存器的值是多少?(32 位十六进制,带 0x 前缀)
  7. 继续单步执行直到 t1 的值发生变化。

  8. 问题 4t1 寄存器的新值是多少?(32 位十六进制,带 0x 前缀)

  9. 问题 5:此时当前指令的机器码是多少?(32 位十六进制,带 0x 前缀)

  10. 单步执行直到程序计数达到 0x10,此时 t3 的值已经更新。

    • 问题 6t3 寄存器的值是多少?(32 位十六进制,带 0x 前缀)
  11. 切换到 Memory 选项卡,在地址输入框填入问题 6 的值,然后点击 Go,查看该地址处的内存内容。

    • 问题 7t3 指向的内存字节是多少?(8 位十六进制,带 0x 前缀)
  12. 在地址 0x28 所在行点击以设置断点(行会变浅红色并出现断点符号)。

  13. 点击 Run 继续执行直到命中断点。

    • 问题 8:此时 t0 寄存器的值是多少?(32 位十六进制,带 0x 前缀)
  14. 再次点击 RunStep 6 次。

    • 问题 9:现在 t0 寄存器的新值是多少?(32 位十六进制,带 0x 前缀)
  15. 在寄存器选项卡底部的下拉菜单中将显示切换为 “Decimal”,内存选项卡同理。

    • 问题 10:切换到十进制显示后,t0 寄存器的值是多少?(十进制,不带前缀)
  16. 再次点击地址 0x28 以取消断点。

  17. 点击 Run 完成程序执行,因为已无其他断点。

    • 问题 11:程序的输出是什么?(十进制,不带前缀)

Venus: 内存检查

在 Project 1(以及 C 编程中),Valgrind 是调试内存访问错误(如 “Segmentation fault (core dumped)”)的首选工具。在 Venus 中,我们提供了一个名为 memcheck 的功能,用于类似地检测内存读写错误。其错误信息设计上模仿 Valgrind。

注意:该功能于 2022 年春季开发,2022 年秋季首次引入,如遇任何 Bug 请及时反馈!

Memcheck 有两种模式:

  1. 普通模式(或仅 “memcheck”):
    • 检测并报告所有无效的内存读写。
    • 程序退出时,如果有未释放内存,还会输出未释放字节数。
  2. 详细模式(或 “memcheck verbose”):
    • 除了普通模式功能外,还会在运行时打印出每一次内存读写操作。
    • 程序退出时,列出所有未释放的内存块列表。

Venus 选项卡中可启用这两种模式:

1. 仅选中 “Enable Memcheck?” 即运行普通模式。
2. 同时选中 “Enable Memcheck?” 和 “Enable Memcheck Verbose?” 即运行详细模式。

注意

启用或禁用 Memcheck 后,必须在 VDB 中重新打开正在调试的文件。

练习3:使用内存检查

本练习和前一个练习一样,需将答案写在 ex3_answers.txt 中。题号可能与步骤号不同,请注意!

  1. 在 Venus 编辑器中打开 ex3_memcheck.s,通读程序,了解其功能。

  2. 运行程序。哎呀,程序报错了!查看错误信息。

    问题 1:程序尝试访问哪个地址导致错误?(32 位十六进制,带 0x 前缀)

    问题 2:程序试图访问多少字节?(十进制,不带前缀)

  3. 看起来是内存错误,启用 Memcheck(普通模式),并在 VDB 中重新打开 ex3_memcheck.s

  4. 运行程序。现在 Memcheck 给出了更详细的错误信息!仔细阅读。

    问题 3:程序尝试访问哪个地址导致错误?(32 位十六进制,带 0x 前缀)

    问题 4:与该错误相关的内存块分配了多少字节?(整数,不带单位)

    问题 5:源文件的哪一行导致了此错误?(行号,整数)

  5. 对比问题 2 和问题 4 的答案。注意,Memcheck 可能改变 malloc 返回的地址。

  6. 让我们调试此错误。回想 t1 寄存器保存循环计数器的值。

    问题 6:根据 Memcheck 错误信息,t1 的值是多少?(十进制)

  7. 在源代码中修复此错误并保存文件。

  8. 再次运行程序。程序执行完成且未出现无效访问错误,但会提示存在未释放的内存。

    问题 7:程序退出时有多少字节未被释放?(十进制,不带单位)

  9. 在详细模式下重新运行 Memcheck。记得在 VDB 中重新打开文件。

    问题 8:未释放的内存块地址是多少?(32 位十六进制,带 0x 前缀)

  10. 通过调用 free 修复此问题。

  11. 在接下来的两个练习中请 禁用 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) 算出对应的下标,然后直接从这个数组里取出对应的值即可。

请务必**只使用** ta 寄存器。如果使用其他寄存器,可能会导致意想不到的问题(稍后会解释原因)。

提示1

你可以通过使用 lw 指令来访问存放在数组里的值。

提示2

lw 指令要求偏移量必须是一个立即数(不能是寄存器里的值)。在这个问题里,我们把要用的偏移量先算到一个寄存器里,但又不能把寄存器当偏移量来用。解决办法就是先把寄存器里的偏移量,加到数组首地址上,得到元素的地址,然后用 lw 0(...) 来读取该地址里的值。

在下面这个例子中,索引存放在寄存器 t0,数组首地址存放在寄存器 t1,每个元素占 4 字节。在 RISC‑V 中,我们必须自己实现指针运算。因此,我们需要做三步:

  1. 算偏移量:用 t0 * 4(把索引乘以元素大小)
  2. 算元素地址:把这个偏移量加到 t1
  3. 读取元素:对新地址执行 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! = 13! = 67! = 50408! = 40320

  1. 打开 ex5_factorial.s,在 Venus 模拟器中运行并验证输出。这也是自动评分器测试你的方式,请务必在本地通过测试后再提交。
  2. 你也可以在命令行中使用以下命令测试:
java -jar tools/venus.jar lab03/ex5_factorial.s

过渡到更复杂的 RISC‑V 程序

在后续课程中,我们将使用多个汇编文件来实现更复杂的 RISC‑V 程序。为此,建议你提前查阅 Venus 参考文档,以便了解如何配置和调试多文件项目。