实验3:RISC-V,Venus

截止日期:2月12日星期四晚上11:59:59 PT

实验幻灯片

设置

您必须在本地机器上完成此实验。如果需要重新设置本地机器,请参阅实验0

在您本地机器的labs目录中,拉取您在过去实验中可能做的任何更改:

git pull origin main

仍然在您本地机器的labs目录中,使用以下命令拉取此实验的文件:

git pull starter main

如果您收到如下错误:

fatal: 'starter' does not appear to be a git repository
fatal: Could not read from remote repository.

请确保按如下方式设置starter远程仓库:

git remote add starter https://github.com/61c-teach/sp26-lab-starter.git

然后再次运行原始命令。

仍然在您的labs目录中,运行以下命令下载我们可能需要的一些工具的最新版本:

bash tools/download_tools.sh

如果遇到任何git错误,请查看常见错误页面。


汇编语言简介

到目前为止,在本课程中,我们主要处理C程序(带有.c文件扩展名),使用gcc程序将它们编译成机器码,然后直接在hive机器上执行。现在,我们将重点转移到RISC-V汇编语言,这是一种更接近机器码的低级语言。我们无法直接执行RISC-V代码,因为您的计算机和hive机器是为运行其他汇编语言(最可能是x86或ARM)的机器码而构建的。

在本次实验和下次实验中,我们将处理几个RISC-V汇编文件,每个文件都有.s文件扩展名。要运行这些文件,我们将使用Venus,这是一个教育用途的RISC-V汇编器和模拟器。您可以从自己的终端本地运行Venus,也可以在Venus网站上运行,以下说明将指导您完成设置步骤。虽然您可能发现在本实验中使用Web编辑器更方便,但无论如何请完成这些本地设置说明:这些步骤还将设置未来项目和实验所需的其他基础设施。

Venus:入门

要开始使用Venus,请查看Venus参考文档中的"编辑器选项卡"和"模拟器选项卡"。我们建议您在某个时候阅读整个页面,但这些部分足以让您入门。

警告:对于以下练习,请确保您完成的代码保存在本地机器上的文件中。否则,我们将无法证明您完成了实验练习。


练习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 在您的Web浏览器中(推荐使用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.。您应该能够在本地终端输出的最新一行中看到一个密钥;只需将其复制并粘贴到对话框中即可。
  4. 转到"Files"选项卡。您现在应该能够在labs文件夹下看到您的labs目录。
  5. 导航lab03,并通过点击ex1_hello.s旁边的Edit按钮确保它正常工作。这应该会在Editor选项卡中打开。
    • 您应该在编辑器中看到ex1_hello.s的内容。这个编辑器的行为类似于大多数其他文本编辑器,尽管没有许多更高级的功能。
  6. 要汇编编辑器中打开的程序,点击"Simulator"(模拟器)选项卡,然后点击"Assemble & Simulate from Editor"(从编辑器汇编并模拟)。
    • 将来,如果您已经在模拟器中打开了一个程序,请改为点击"Re-assemble from Editor"(重新从编辑器汇编)。请注意,这将覆盖模拟器选项卡中的所有内容,例如现有的调试会话。
  7. 要运行程序,点击"Run"(运行)。
    • 您可以看到其他按钮,如"Step"(单步执行)、"Prev"(上一步)和"Reset"(重置)。您将在实验后期和作业期间使用这些按钮。
  8. 返回编辑器选项卡,并编辑 ex1_hello.s 使其输出打印2026
    • 提示:当ecall执行时,会打印a1中的值。不过ecall的具体作用不在本课程的范围内。
  9. 在macOS上按Cmd + s,在Windows/Linux上按Ctrl + s保存您刚刚所做的更改。这将更新您的本地文件副本。
  10. 打开本地机器上的文件(使用您选择的文本编辑器),检查并确保它与Web编辑器中的内容匹配。
    • 注意:如果您使用文本编辑器在本地机器上对文件进行任何更改,如果您在Venus编辑器中打开了同一个文件,则需要从"Files"菜单重新打开它以获取新更改。
  11. 再次运行(Run)程序,如果您正确修改了源文件,现在应该会看到2026

从C语言转换到RISC-V

在这个例子中,我们将逐步讲解如何将一个C程序转换为RISC-V程序。下面的程序将打印出第n个斐波那契数。尽管这部分有点长,请仔细阅读!

#include <stdio.h>

int n = 12;

//函数:查找第n个斐波那契数
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(当前斐波那契数 = 0)
    addi t1, x0, 1 # next_fib = 1(下一个斐波那契数 = 1)

这里我们添加了.text指令。此指令下的所有内容都是我们的代码。

记住x0始终保存值0。

我们不需要做任何事情来声明new_fib(在RISC-V中我们不声明变量)。

接下来,让我们进入循环。我们将从设置循环变量开始。下面的代码将把i设置为n

la t3, n # 加载标签n的地址(load the address of the label n)
lw t3, 0(t3) # 获取存储在标签n所表示地址处的值(get the value that is stored at the address denoted by the label n)

您可以将上面的代码理解为执行以下操作

t3 = &n;
t3 = *t3;

这里我们有一个新指令la。此指令加载标签的地址。第一行本质上将t3设置为指向n的指针。接下来,我们使用lw解引用t3,这将把t3设置为存储在n处的值。

现在,您可能会想:"为什么我们不能直接将t3设置为n?" 在.text部分,我们无法直接访问n。(想想看,我们不能说add t3, n, x0add的参数必须是寄存器,而n不是寄存器。)我们唯一能访问它的方法是获取n的地址。一旦我们获得了n的地址,我们需要使用lw来解引用它。lw将访问您指定的地址处的内存,并加载存储在该地址的值。在这种情况下,我们指定了n的地址并添加了0的偏移量。

现在让我们开始循环部分。首先,我们将创建下面的外层结构:

fib:
    beq t3, x0, finish # 完成n次迭代后退出循环(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之后立即更新它,那么它在循环体中就不会有正确的值。

下一条指令跳回循环的开头。

现在,让我们添加循环体。

fib:
    beq t3, x0, finish # 完成n次迭代后退出循环(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:

这里没什么特别的。相应的C语言代码行写在注释中。

让我们打印出第n个斐波那契数!

finish:
    addi a0, x0, 1 # ecall参数:执行打印整数(argument to ecall to execute print integer)
    addi a1, t0, 0 # ecall参数,要打印的值(argument to ecall, the value to be printed)
    ecall # 打印整数ecall(print integer ecall)

打印是一个系统调用。您将在本学期晚些时候学到更多关于这些的知识,但系统调用本质上是程序与操作系统交互的一种方式。要在RISC-V中进行系统调用,我们使用一个特殊的指令ecall。要打印一个整数,我们需要向ecall传递两个参数。第一个参数指定我们希望ecall做什么(在这种情况下,打印一个整数)。要指定我们要打印整数,我们传递1。第二个参数是我们要打印的整数。

在C语言中,我们习惯函数看起来像ecall(1, t0)。在RISC-V中,我们不能以这种方式传递参数。要传递参数,我们需要将其放置在参数寄存器(a0-a7)中。当函数执行时,它会在这些寄存器中查找参数。(如果您还没有在课堂上看到这一点,很快就会看到)。第一个参数应该放在a0中,第二个放在a1中,依此类推。

为了设置参数,我们在a0中放置了1,在a1中放置了我们要打印的整数。

接下来,让我们终止程序!这也需要ecall

addi a0, x0, 10 # ecall参数:终止程序(argument to ecall to terminate)
ecall # 终止ecall(terminate ecall)

在这种情况下,ecall只需要一个参数。将a0设置为10指定我们要终止程序。

就是这样!这就是我们完整的程序!

.data
n: .word 12

.text
main:
    add t0, x0, x0 # curr_fib = 0(当前斐波那契数 = 0)
    addi t1, x0, 1 # next_fib = 1(下一个斐波那契数 = 1)
    la t3, n # 加载标签n的地址(load the address of the label n)
    lw t3, 0(t3) # 获取存储在标签n所表示地址处的值(get the value that is stored at the address denoted by the label n)
fib:
    beq t3, x0, finish # 完成n次迭代后退出循环(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 # ecall参数:执行打印整数(argument to ecall to execute print integer)
    addi a1, t0, 0 # ecall参数,要打印的值(argument to ecall, the value to be printed)
    ecall # 打印整数ecall(print integer ecall)
    addi a0, x0, 10 # ecall参数:终止程序(argument to ecall to terminate)
    ecall # 终止ecall(terminate ecall)

练习2:使用Venus调试器

在Venus调试器中打开文件有两种方式:

  1. 通过编辑器
    1. 打开 fib.s 到Venus编辑器中。
    2. 点击"Simulator"(模拟器)选项卡,然后点击"Assemble & Simulate from Editor"(从编辑器汇编并模拟)(或"Re-assemble from Editor"(重新从编辑器汇编))按钮。当前指令以浅蓝色高亮显示。与cgdb类似,当前指令是尚未执行但即将执行的指令。
  2. 通过"Files"选项卡
    1. 点击"Venus"选项卡,然后点击"Files"(文件)选项卡。
    2. 导航fib.s文件,它应该位于lab03下的labs文件夹中。
    3. 点击文件名旁边的"VDB"(Venus调试器)按钮。

本练习要求您将答案写在ex2_answers.txt中。问题编号可能与步骤编号不同,请注意!

  1. 打开 fib.s 在Venus调试器中,使用上面列出的两种方式之一。
    • 问题1:高亮显示的指令的机器码是什么?答案应该是一个32位十六进制数,带有0x前缀。
    • 问题2:地址0x34处的指令的机器码是什么?答案应该是一个32位十六进制数,带有0x前缀。
  2. 点击"step"(单步执行)按钮前进到下一条指令。第二条指令现在应该被高亮显示。
  3. 点击"prev"(上一步)按钮撤销最后执行的指令。请注意,撤销可能会也可能不会撤销由ecall执行的操作,例如退出程序或打印到控制台。
  4. 在屏幕右侧,点击"Registers"(寄存器)选项卡查看所有32个寄存器的值。如果标题以黄色高亮显示,则此选项卡可能已被选中。确保您正在查看整数寄存器,而不是浮点寄存器。
    • 问题3:sp寄存器的值是什么?答案应该是一个32位十六进制数,带有0x前缀。
  5. 继续单步执行直到t1中的值改变。
    • 问题4:t1寄存器的新值是什么?答案应该是一个32位十六进制数,带有0x前缀。
    • 问题5:当前指令的机器码是什么?答案应该是一个32位十六进制数,带有0x前缀。
  6. 单步执行直到您到达地址0x10。此时,t3的值已更新。
    • 问题6:t3寄存器的值是什么?答案应该是一个32位十六进制数,带有0x前缀。
  7. 如果我们查看当前指令,我们正在从t3寄存器加载。使用"Memory"选项卡("Registers"选项卡旁边),并将问题6的答案(t3的值)输入到"Address"框中。您可能需要在memory选项卡上向下滚动才能看到它。"Go"转到该内存地址
    • 问题7:t3指向的字节是什么?答案应该是一个8位(1字节)十六进制数,带有0x前缀。
  8. 设置断点在地址0x28处,方法是点击该地址所在的行。该行应变为浅红色,并出现断点符号。
  9. 继续执行直到断点,按"Run"(运行)。
    • 问题8:t0寄存器的值是什么?答案应该是一个32位十六进制数,带有0x前缀。
  10. 继续执行再执行6次。
    • 问题9:t0寄存器的新值是什么?答案应该是一个32位十六进制数,带有0x前缀。
  11. 有时,读取十六进制值不是很有帮助。设置显示设置为"Decimal",使用寄存器选项卡底部的下拉菜单。这也可以在内存选项卡上完成。
    • 问题10:t0寄存器的十进制值是什么?答案应该是一个无前缀的十进制数。
  12. 再次点击地址0x28处的指令以取消断点。
  13. 点击运行以完成程序运行,因为没有更多断点了。
    • 问题11:程序的输出是什么?答案应该是一个无前缀的十进制数。

Venus:内存检查

在项目1(以及一般的C编程)中,valgrind是调试内存访问错误(如Segmentation fault (core dumped))的首选工具。对于Venus,我们有一个名为"memcheck"的功能,可以实现类似的功能。memcheck错误消息旨在模仿valgrind错误消息。注意:此功能是在2022年春季开发的,我们在2022年秋季首次引入,因此如果您遇到任何bug,请告诉我们!

Memcheck有两种模式:

  • 正常模式(或简称"memcheck"):此模式将显示任何对内存的无效读取或写入。如果程序退出时有未释放的内存,它还将打印未释放内存的字节数。
  • 详细模式(或"memcheck verbose"):除了正常模式外,此模式还会打印每次内存读取/写入操作,以及程序退出时未释放的块列表。

您可以在Venus选项卡下启用这些模式。如果同时选择了"启用Memcheck?(Enable Memcheck?)"和"启用Memcheck详细模式?(Enable Memcheck Verbose?)",memcheck将以详细模式运行。

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

练习3:使用内存检查

与上一个练习类似,本练习要求您将答案写在ex3_answers.txt中。问题编号可能与步骤编号不同,请注意!

  1. 打开 ex3_memcheck.s 在Venus编辑器中,并阅读整个程序以了解它的作用。
  2. 运行程序。哦不,程序出错了!让我们看一下错误消息。
    • 问题1:程序尝试访问哪个地址但导致了错误?答案应该是一个32位十六进制数,带有0x前缀。
    • 问题2:程序尝试访问多少字节?答案应该是一个无前缀的十进制数。
  3. 这看起来像是内存错误,所以让我们试试memcheck。启用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

在RISC-V中实现ex4_discrete_fn.s中的函数,条件是您的代码不能使用任何分支和/或跳转指令!确保您的代码保存在本地。如果您遇到困难,我们提供了一些提示。

所有输出值存储在输出数组中,该数组通过寄存器a1传递给f。您不需要自己创建数组。您可以索引到该数组以获取与输入对应的输出。

确保您只写入ta寄存器。如果您使用其他寄存器,可能会发生奇怪的事情(您很快就会了解原因)。

提示1

您可以使用lw访问数组的值。

提示2

lw要求偏移量是一个立即值。当我们计算此问题的偏移量时,它将存储在寄存器中。由于我们不能使用寄存器作为偏移量,我们可以将寄存器中存储的值添加到基地址以计算我们感兴趣的索引的地址。然后我们可以使用偏移量0执行lw

在以下示例中,索引存储在t0中,数组指针存储在t1中。每个元素的大小是4字节。在RISC-V中,我们必须自己进行指针运算,所以:

  1. 我们需要将索引乘以数组元素的大小。
  2. 然后我们将此偏移量添加到数组的地址以获取我们要读取的元素的地址。
  3. 读取元素。
slli t2, t0, 2 # 步骤1(见上文)
add t2, t2, t1 # 步骤2(见上文)
lw t3, 0(t2)   # 步骤3(见上文)

提示3

f(-3)应存储在偏移量0处,f(-2)应存储在偏移量1处,依此类推

测试

要测试您的函数,请打开ex4_discrete_fn_tester.s并通过模拟器运行它。这将是我们在自动评分器上使用的测试,因此请确保在提交之前测试在本地通过。

您也可以使用以下命令通过命令行测试您的代码。

java -jar tools/venus.jar lab03/ex4_discrete_fn_tester.s

[可选(OPTIONAL)] 练习5:阶乘(Factorial)

确保在本练习中禁用memcheck。

在本练习中,您将在RISC-V中实现factorial函数。此函数接受一个整数参数n并返回n!。此函数的存根可以在文件ex5_factorial.s中找到。

传递给函数的参数位于标签n处。您可以修改n以测试不同的阶乘。要实现,您需要在factorial标签下添加指令。有一个递归解决方案,但我们建议您实现迭代解决方案。您可以假设factorial函数只会对正数调用,且结果不会溢出32位二进制补码整数。

在阶乘调用开始时,寄存器a0包含我们要计算阶乘的数字。然后,在从函数返回之前,将返回值放入寄存器a0中。

确保您只写入ta寄存器。如果您使用其他寄存器,可能会发生奇怪的事情(您很快就会了解原因)。

另外,请确保初始化您正在使用的寄存器! Venus可能显示寄存器最初为0,但在现实生活中它们可能包含垃圾数据。请确保在使用寄存器之前将它们设置为某个定义好的数字。

测试

要测试您的代码,可以确保您的函数正确返回正确的输出。一些示例是0! = 13! = 67! = 50408! = 40320

要测试您的函数,请打开ex5_factorial.s并通过模拟器运行它。这将是我们在自动评分器上测试您的函数的方式,因此请确保在提交之前测试在本地通过。

您也可以使用以下命令通过命令行测试您的代码。

java -jar tools/venus.jar lab03/ex5_factorial.s

练习6:反思与反馈表

我们每周都在努力改进课程——请填写此调查问卷,告诉我们您到目前为止在讨论课和实验中的体验!


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

未来,我们将处理更复杂的RISC-V程序,这些程序需要多个汇编代码文件。为为此做准备,我们建议查看Venus参考


提交

保存、提交并推送您的工作,然后在Gradescope上提交到实验3作业。