温故而知新,可以为师矣。——《论语·为政》

0 回顾:《计算机组成原理》课程中的流水线

在《计算机组成原理》课程中,我们已经设计了一个 RV32I 指令集的五级流水线处理器。然而,该设计与真实的处理器仍有着较大差距,这主要体现在:

  • 流水线中的指令与数据存储器是分离的;
    真实的计算机是冯诺依曼架构,指令和数据存储在一起
    若将指令与数据存储分离,则不能通过访存指令修改指令段,导致无法加载用户程序。

  • 流水线中没有考虑访存延迟;
    真实计算机的存储空间非常大,访存延迟不可忽视。

  • 流水线中没有控制与状态寄存器,无法支持特权指令和异常处理。
    没有控制与状态寄存器则无法做到系统特权级别的转换,因此无法执行特权指令或进行异常处理;
    没有特权指令和异常处理功能是无法启动操作系统和给予用户程序调用接口的。

在 Lab2 中,我们已经实现了通过仿真环境来模拟访存的功能,这样的实现已经完全可以在不改动流水线的情况下支持内存映射输入/输出(MMIO)了。在本次实验中,我们将会对《计算机组成原理》中的流水线处理器做一个简单的改造,使其将访存的 DRAM 改变为 BRAM,这样可以在后续实验中支持 Cache 的融入。

本次实验的主要任务如下:

  • 了解并补全处理器设计的重要调试功能:difftest;
  • 补全 RISC-V 六级流水线的代码,使其可以通过要求的测试。

请获取 Lab3 所需的代码框架
请将工作目录切换到 Lab3,运行以下命令:
shell

$ ./zinit.sh

1 Difftest:处理器设计的制胜法宝

相信大家都接触过一种叫做“对拍”的调试方法:面对测试用例的时候,我们可以用一个已知正确结果的程序运行出一个正确的结果,再用自己的程序运行一个结果,比较结果是否一致。这在自己的程序已经近乎正确的情况下,是一种非常有效的调试方法。只不过,碍于算法的不同,我们无法通过这种方法进行“逐步对比”,只能通过结果反推错误。

但在处理器设计中,我们真的可以做到“逐步对比”:使用一个完全正确的 CPU,它执行一条指令,我执行一条指令,再比较两者的状态是否一致,这就是 difftest 的工作原理。只不过,我们不会真的使用一个“完全正确的硬件 CPU”做模拟,而是使用一个高级语言实现的 CPU 模拟器做标准参考——这样就可以更高效地仿真运行参考核的指令了。

NEMU 是一款使用 C 语言实现的 RISC-V 指令集模拟器,它会用软件来模拟执行每一条指令,然后将模拟器的状态与我们的 CPU 状态进行比较,如果不一致,就会输出错误信息。

在 simulator/nemu 目录下,我们已经提供了这样的模拟器库。如果你想构建它,只需要在该目录下执行 make 指令即可,最终会在 build 目录下生成一个名为 riscv32-nemu-interpreter-so 的库文件。这个文件中有各种函数,可以在外部链接这个库并调用这些函数,就可以使用 NEMU 来模拟执行指令了,主要过程如下:

  1. 使用 difftest_memcpy 函数,在开始运行模拟器时将需要执行的程序和数据都拷贝到 NEMU 用来模拟内存的大数组中。
  2. 处理器每“提交”一条指令,都检查一次寄存器和 PC 的值是否与 NEMU 相同,之后让 NEMU 和处理器都运行一个周期。
  3. 重复步骤 2,直至处理器提交了一条 ebreak 指令。

由于 simulator 的编译依赖于 NEMU 的库文件,因此在 simultaor 目录下执行 make 命令会自动地编译 NEMU 库文件。同时,在 simulator 目录下执行 make clean-all 命令也会清除掉 NEMU 库文件。

什么是“提交”?
“提交”是处理器设计的一个重要概念:当 CPU 的一条指令运行到 WB 段,下个周期就要写回寄存器的时候,就认为这条指令已经提交了。提交意味着这条指令即将稳定地执行完毕,也就是说这条指令的执行效果即将可见。
之所以有这个概念,是因为流水线中会有 flush 信号,flush 会导致 WB 段进入一些气泡指令,这些指令是不应该引起仿真环境的操作的,例如不应该让 NEMU 运行并进行 difftest。

Difftest 比较的并不是结果!
在提交一条指令的时候,difftest 比较的并不是这条指令的执行效果,而是这条指令执行之前的状态,也就是寄存器堆、PC 还没有更新时的状态。
这样做的好处是:可以比较没有执行指令前处理器状态是否与 NEMU 一致,同时在后期的流水线中,由于获取当前指令执行完毕的 PC 比较困难(PC 有可能在提交之前就被跳转指令修改),因此也只能比较当前指令执行前的状态。

为了支持这种“逐步对比”的操作,我们在 simulator/sim/difftest/difftest.cpp 中封装了一个 difftest_step 函数,该函数会首先将 NEMU 内部的寄存器和 PC 值拷贝到一个结构体中,再与从处理器中拷贝出的寄存器和 PC 值进行比较。比较后会使用 difftest_exec 函数令 NEMU 运行一步。这样,我们就可以在处理器的每一步都与 NEMU 进行比较了。

获取 CPU 的状态信息
请关注 sim_cpu 结构体,这是一个 CPU_state 型结构体,这个结构体内可以存储 CPU 的寄存器堆值、PC和运行状态等内容。
每当 CPU 提交一条指令,仿真器都会使用 DPI-C 机制或者顶端接口获取这些值,并将它们拷贝到 sim_cpu 中。这样我们就可以获取到CPU的状态了。

NEMU 是单周期模拟器,每次运行一步能够稳定执行完一条指令。但流水线无法每周期稳定执行完一条指令,因此我们需要一个 commit 信号来向仿真环境通知什么时候提交了一条指令。只不过,在当前的单周期 CPU 中,commit 信号被恒定置为 1。

如果在执行 ebreak 之前,所有的寄存器和 PC 值都与 NEMU 一致,就可以认为这个程序是正确的了。如果某一步的比较不正确,则会输出 ABORT。

我们的裸机环境提供了一个 halt 函数,可以向其中传入一个 code 参数。halt 函数中有一条 ebreak 指令,在收到 ebreak 指令时,仿真环境会检查 a0 寄存器的值,如果为 0,则认为程序正确,并输出 HIT GOOD TRAP;如果不为 0,则认为程序错误,并输出 HIT BAD TRAP。请大家阅读 spftware/base-port/base/src/trm.c 中的 call_main 函数,了解这个函数是何时被调用的。

Task 1.1
请完成 difftest 的功能,并在 cpu_exec 函数中使用这些功能,完成对比仿真。你需要完成的内容有:

  • simulator/sim/difftest/difftest.cpp 中的 isa_difftest_checkregs 函数,这个函数的两个参数分别是 NEMU 模拟器中的寄存器堆和 PC 值,你需要将这些值与 sim_cpu 中的寄存器和 PC 比较。若两者的内容一致,返回 true;若不一致,则输出错误信息,并返回 false;
  • 在 cpu_exec 函数的合适位置调用 difftest_step 函数,完成一步的对比。并能成功运行 software/functest 目录下的任一测试用例,输出 HIT GOOD TRAP;
  • 将单周期 CPU 中 ALU 的减法改为加法,运行 sub-longlong 测试,观察仿真环境能否找到这个错误。

    在当前阶段,你不需要关注 dut->commit_wb 信号,因为单周期 CPU 稳定一周期就能提交一条指令,所以这个信号恒定置为 1。但在下一个任务中,我们将需要使用到该信号,所以你最好在本任务中就利用其来控制 difftest 和指令踪迹更新。

    我们并不要求大家精确地定位错误,如果你的仿真器可以在这条指令一个周期之后发现执行错误,那么也认为你的 difftest 是正确的。毕竟,只要你能自己看得懂自己的仿真输出信息,那么这个仿真环境就是有效的。

在 functest 中一键运行
如果你想在 functest 中使用 make run 命令一键运行,那么你会发现只定义 NAMES, SRCS, BASE_PORT 三个变量是不够的,因为 base-port/Makefile 的 run 规则中依赖了 SIM_PATH 变量。这个变量是 simulator 文件夹的绝对路径。对于 functest,你需要定义:
Makefile

SIM_PATH = $(abspath "../../simulator")

这样,你就可以在 functest 中使用 make run 命令一键运行了。事实上,今后对于所有的测试用例,你都需要定义这个变量。

关于程序运行的提示
细心的同学可能发现了,我们在 software/base-port/Makefile 中提供了一个 run 规则,这个规则可以直接调用仿真器来执行当前程序。所以,你可以在 functest 文件夹下直接使用如下命令来让仿真器运行 sub-longlong 程序:

make run NAMES=sub-longlong

当然,对于完成了 Lab1 中选做的同学,这里的 run 就是对你的一个挑战了。如果实现的足够好,你甚至可以一键 make run 执行全部测试用例。这里给出一个简单的方案:

  • 使用 make 的返回值,判断是否成功执行测试用例。这个返回值就是仿真器 main 函数的返回值,请你阅读代码,了解 main 函数的返回值是如何被设置的。
  • 为了能够选择是只编译目标程序还是编译目标程序并运行,你可以了解 $(MAKECMDGOALS) 变量,使用这个变量帮你在原有的 make 命令中加入脚本目标。
  • 使用 makefile 中的 if 语句来判断返回值,并将结果写入一个文件中。
  • 在 functest 的 Makefile 中新建一个 run 规则,其依赖是 all。此 run 规则可以用 cat 命令来输出结果,并删除这个文件。

    虽然这并不是一个选做,但如果你在Lab1的基础上完成了这个选做,你可以获得额外的少量加分。

2 RISC-V 六级流水线的设计

请下载最新的流水线框架代码
目前我们的 IP 目录下只有一个 mycpu 单周期 CPU,当你运行 Lab2 目录下的 zinit.sh 脚本后,将会在 simulator/IP 目录下获取到一个 pipeline-BRAM 文件夹,这就是我们的流水线框架。请将这个目录更名为 mycpu。

我们的开发使用System Verilog语言
请参考本年度数电实验提高班主页对 System Verilog 进行学习。
不过不用担心,System Verilog 支持所有 Verilog 代码,且框架中并没有使用和 Verilog 区别很大的 System Verilog 特性。你只需要能看懂 System Verilog 中的几个语法符号即可。

Task 2.1
请补全 RISCV 六级流水线的代码,使其可以通过 functest 和 picotest 中的所有测试用例。你需要完成的内容有:

  • Decode 模块:请参考已经给出的指令类型译码方式和 config.sv,补全 Decode 模块的代码。
  • ALU 模块:补全移位运算等未实现的运算操作。
  • Hazard 模块:补全 load-use 冲突的处理以及各个段间寄存器和 PC 寄存器的 stall 和 flush 信号生成逻辑。
  • Branch 模块:补全 branch 类指令的比较逻辑。

    对于 picotest 测试,你只需要在 picotest 目录下使用 make run 命令即可;而对于 functest 测试,你需要根据你之前的实现,使用合适的命令进行测试。

3 实验报告

在本次实验的报告中,你需要回答或阐述以下问题:

  1. rd 为 0 的算数指令可以前递数据吗?为什么 Hazard 中的前递逻辑里没有考虑这种情况?
  2. 在我们的框架中,为什么 ALU 的 rs1 会有0这个选项?如果没有这个选项,那么原来借助这个选项实现的指令的 ALU 运算应该如何实现?
  3. 你对本次实验有什么建议或意见?