跳转至

Lab2 仿真环境构建

常制不可以待变化,一途不可以应无方,刻船不可以索遗剑。 ——《抱朴子·外篇·广譬》

在 Lab1 中,我们已经构建了一个符合我们 CPU 架构和约定的裸机环境。在本次实验中,我们将把目光转移到仿真环境中,通过搭建一个仿真环境,来测试我们自己的 CPU。

本次实验的主要内容包括:

  • 熟悉仿真环境框架,完成 single_cycle 时钟驱动函数;
  • 完成仿真环境的加载程序功能,实现从文件将机器码读入模拟内存的功能;
  • 为仿真环境添加调试命令;
  • 为仿真环境添加指令踪迹功能。
完成本实验需要安装 Verilator

请参考实验环境搭建中的步骤安装 Verilator。

请获取 Lab2 所需的代码框架

请将工作目录切换到 Lab2,运行以下命令:

shell
$ ./zinit.sh

1 仿真环境解读

这一部分需要先了解 Verilator 的原理

如果你还不清楚 Verilator 的基本功能和用法,那么请先阅读 Verilator 使用指南

我们需要使用 Verilator 对编写的处理器核进行仿真,以验证其正确性。然而,只有处理器核的代码是无法完成仿真的,我们还需要构建一个仿真环境,以产生时钟激励信号,模拟存储器、外设与 CPU 核进行交互,以及获取并输出一些调试信息。

simulator 目录下存放有 CPU 核和仿真环境的代码:

  • sim:仿真环境的主要代码,包括时钟驱动、调试命令、运行对比、模拟存储、模拟外设等;
  • nemu:一个通用的开源模拟器,用来支持运行对比,即 Difftest;
  • script:编译脚本;
  • IP:存放了若干处理器核,但只有 mycpu 目录下的核会被用于仿真。

其中,我们需要重点关注 sim 目录下的内容。

下面为大家简单介绍一下仿真的流程:

  • 仿真程序从 main 函数开始运行,执行一系列初始化任务,并将一个包含测试程序的 .img 二进制文件加载到了一个巨大数组中,用来模拟内存;
  • 初始化完成后,进入 sdb_mainloop 函数。若在运行时传入了 -b 选项,仿真程序会进入批处理模式,不响应任何命令并直接运行测试程序,直至程序运行完成;否则,仿真器会等待用户输入命令,然后执行对应的操作;
  • 仿真程序会反复调用 cpu_exec 函数,驱动 CPU 执行若干条指令。在这个函数中,我们会根据当前指令是否为 ebreak 来决定是否暂停运行;同时,我们也可以在其中插入一些监视 CPU 状态的函数,用来查看 CPU 的运行情况;
  • 当遇到 ebreak 指令或执行了预定数量的指令后,仿真程序会暂停运行,并做一些简单的统计,之后会回到 sdb_mainloop 函数中,等待下一条指令。
为什么要编写这么复杂的仿真环境?直接导出波形不就好了?

对比我们的仿真环境和 Vivado 的仿真环境,不难发现,相比起 Vivado 输出的大量杂乱无章的电平信号,我们的仿真环境可以使用直观的方式有重点地输出我们需要的一些调试信息,并记录处理器运行的一些重要过程信息,这无疑是非常有利于我们快速定位错误并进行调试的。

在后面的实验中,我们会实现一种类似于“对拍”的功能,它甚至能让我们在仿真过程中将待测试的 CPU 和另一个完全正确的模拟器逐拍进行对比,这是 Vivado 的仿真环境所不能提供的。

Task 1.1

这个任务不需要大家编写任何代码,只需要大家把模拟器从 main 函数开始运行,到进入 sdb_mainloop 函数等待命令,再到驱动 CPU 执行指令,最后到仿真结束的过程完整地看一遍。这个过程中,有任何的疑问可以立刻询问助教。

2 仿真环境的构建

醒醒!这不是 Lab1!

我们在 Lab1 中强调的裸机环境是针对在我们自己设计的硬件上运行的程序,而我们的 simulator 是在实验平台上运行的!所以不要再“什么都不敢用了”!

我直接上来就是一个 #include <bits/stdc++.h>

大家在阅读了仿真环境代码之后,肯定发现了其中有大量的空白。我们逐一来看:

2.1 时钟驱动函数

还记得你是如何在 Vivado 的测试激励文件中模拟时钟的吗?

Verilog
reg clk

initial begin
    clk = 0;
end

always #1 clk = ~clk;

在我们的仿真环境中,也需要模拟一个时钟。我们使用 sim.cpp 中的 single_cycle 函数来模拟单个时钟周期,用来驱动 CPU 的运行,这可以通过两次改变 CPU 顶层模块输入中的时钟信号的值并刷新电路来实现。该函数需要使用 main.cpp 中定义的 VCPU 类的全局指针 dut 实现。

Task 2.1

请完成 single_cycle 函数,你可以参考如下步骤:

  • dut->clk 的值置为 1,执行一次 dut->eval(),产生时钟的上升沿;
  • dut->clk 的值置为 0,执行一次 dut->eval(),产生时钟的下降沿。

2.2 加载执行程序

在 Vivado 中,存储器一般是使用 IP 核实现的,在仿真时 Vivado 会生成若干 Verilog 文件模拟存储器的行为。在我们的仿真环境中,则是直接通过软件来模拟存储器。通过定义一个大数组,我们可以模拟出一个很大的内存空间,然后通过 DPI-C 机制或者将访存信号拉到顶层直接读/写完成访存。

实验框架下的访存方式

在前面的几次实验中,我们会使用 DPI-C 机制来完成访存。而在涉及到总线后,我们会将访存信号拉到顶层,然后在顶层直接读/写访存信号。

我们使用 AXI 宏定义来选择访存的方式,请阅读 simulator/sim/memory/paddr.cpp,查看我们是如何实现这两种访存方式的,以及是如何区分访问内存和方位外设的。

在仿真开始之前,我们必须将一个可执行程序加载到这个模拟的内存中,我们使用 simulator/sim/build.cpp 中的 load_img 函数完成此任务。这个函数会读取一个 .img 文件,将其中的指令和数据加载到 pmem 这个大数组中。

Task 2.2

请完成 load_img 函数,这个函数会把指定文件中的内容加载到 pmem 从起始地址开始的空间里,并返回这个文件的大小。

在这个过程中你可能需要复习 C 语言如何读取二进制文件,包括 fread, fseek, fteel 等函数。当然你也可以使用 C++ 的文件流来完成此任务。

完成这个任务后,你需要阅读 simulator/script/build.mk 中的 run 规则,其中 IMG 变量就是你要加载的文件路径,请找出它的默认值。接着,直接在 simulator 目录下运行 make run 命令,如果你看到了若干输出的“Hello”信息,那么恭喜你,你已经完成了这个任务!

来玩玩其他应用程序!

我们在 simulator/testcase/app 目录下提供了许多应用程序,其中甚至包括超级马里奥(vmario-riscv32-nemu.bin)。修改 simulator/script/build.mk 中的 APP 变量(不要忘了去掉文件的后缀名),然后重新编译运行,看看这些应用程序的运行效果吧!

注意:

  1. 程序会创建一个窗口,只有在这个窗口内用鼠标点击一下后,键盘响应才会有效;

  2. 请关闭波形生成功能,否则你的电脑存储空间将很快崩溃!只需要在 single_cycle 函数中注释掉这一行:

CPP
m_trace->dump(sim_time++);

2.3 添加调试命令

在《计算机组成原理》课程中,我们使用硬件(PDU)来调试硬件(CPU),这个过程是非常不方便的。而在我们的仿真环境中,我们可以使用软件来调试硬件,这使得调试过程变得简单得多。我们定义了如下的调试命令:

  • c:连续运行,直到遇到 ebreak 指令;
  • si <cycles>:运行 <cycles> 周期。若未指定周期数,则运行一个周期;
  • info r:打印寄存器堆的值;
  • q:退出仿真环境;
  • x <num> <addr>:打印从 addr 开始的 <nums> 个字的内容。

你需要在 simulator/sim/sdb/sdb.cpp 文件中补全这些命令对应的函数,用来监测 CPU 的运行状态。各命令对应的函数名均由命令名加上 cmd_ 前缀组成。

Task 2.3

请完成上述命令的实现函数(其中 cmd_xcmd_c 函数已经实现)。我们给予大家一些提示:

  • cmd_si:为了获取命令的参数,你需要使用 strtok 函数来完成对参数的读取;你可以参考已经给出的 cmd_x 函数实现;
  • cmd_info:可以调用 isa_reg_display 函数来获取寄存器的值;
  • cmd_q:参考 sdb_mainloop 函数中对于命令返回值的处理,返回一个特定的数字即可。

在完成这些内容后,请在 simulator/script/build.mk 中找到以下内容:

ARGS ?= -b
去掉 -b,启动仿真器后就可以输入命令。使用上面的命令来试一试吧!

觉得输出的信息行数太多?

你可以自行修改isa_reg_display函数的实现,让两个寄存器输出在同一行。这样会便于查看寄存器的值。

2.4 指令踪迹

在仿真环境中,我们往往要记录程序执行的过程,而 CPU 执行的指令是最重要的指标。在这个任务中,你需要添加一个指令踪迹的功能,来记录 CPU 执行的每一条指令。然而,由于 CPU 可能会执行很多指令,其中大部分并不是我们所关心的内容,我们没有必要把每一条指令的信息都打印出来。因此,我们往往只记录近期执行的若干指令信息,当执行遇到问题或者结束时将其输出。

循环队列的结构非常适用于此场景,因此我们推荐大家使用循环队列来记录每一条执行过的指令的 PC 和指令码。当需要输出时,我们只需要从队列头开始输出即可。

什么时候需要输出指令踪迹?

在仿真器内部,我们维护了一个 npc_state 结构体,其中 state 成员记录了 CPU 的状态。一旦 CPU 的状态是 QUIT, ABORTENDcpu_exec 函数就会跳出循环,对当前状态进行分析,在这里我们就可以输出指令踪迹了。

Task 2.4

请在 cpu_exec 函数中实现指令踪迹。你需要维护一个长度为 16 的循环队列来记录指令的 PC 和指令码。

当你需要输出的时候,可以调用我们提供的 disassemble 函数。这个函数可以将一条二进制指令码翻译为 RV32M 指令集的汇编代码。其声明如下:

CPP
void disassemble(char *str, int size, uint64_t pc, uint8_t *code, int nbyte);
  • str:一个字符串缓冲区,用来存放翻译后的汇编代码;
  • size:缓冲区的大小;
  • pc:指令的 PC;
  • code:指令码;
  • nbyte:指令码的长度(以字节为单位,一般为 4)。

至于指令踪迹的输出格式,当然是怎么方便自己怎么来啦!

Advanced Task

指令踪迹对于我们调试 CPU 是非常重要的。对于 si 命令而言,我们当然希望能够在执行一次后能立刻看到执行了什么指令。因此,请你设计一种方法,能够让 si 指令在执行完毕后立刻输出当前执行的指令踪迹。

当然,si 后面的参数可以很大,我们不希望能看到过多的指令踪迹。因此,最好能为这种输出静态指定一个上限。

3 实验报告

在本次实验的报告中,请你回答以下问题:

  1. 需要被我们的 CPU 执行的程序是如何被指定的?build.mk 中哪个参数规定了这个程序的路径?main 函数是通过什么方法获得这个参数的?
  2. 请结合寄存器堆的实现代码和仿真环境代码,简述仿真环境是如何访问到寄存器堆中的值的。
  3. 你是如何设计指令踪迹的输出格式的?你觉得踪迹中哪些信息对于你后续的 debug 是有意义的?
  4. 你对本次实验有什么意见或建议?