C2 支持RV32E的单周期NPC

你已经实现了minirv指令集的NPC, 但由于minirv的核心指令只有8条, 这意味着, 将程序编译到minirv上, 会得到更多的指令, 从而使得程序的运行时间变慢. 要提升程序运行的性能, 一个方向是在NPC中实现更多的常用指令, 让程序编译到完整的RV32E, 从而降低程序的指令数. 因此, 我们需要将NPC采用的ISA从minirv"升级"为RV32E. 在A阶段, 我们会再次将NPC继续"升级"为RV32IMAC.

需要说明的是, 虽然NEMU采用RV32IM, 与NPC采用的RV32E有所不同, 但RV32E所要求的功能, RV32IM也具备, 故RV32E的程序可以直接运行在RV32IM的处理器上. 因此, 只要我们确保已经将程序编译到RV32E, 即使我们把RV32IM的NEMU作为DiffTest的REF, 也可以正常工作.

为NPC搭建基础设施

通过完成PA, 你应该意识到基础设施的重要性了. 在PA中有四大基础设施: sdb, trace, native, DiffTest. 除了native属于AM之外, 其余三大基础设施都可以在NPC中搭建.

我都能看波形了, 为什么还需要这些基础设施?

这是希望大家不要沦为工具人, 从而浪费生命.

波形确实包含了电路中所有信号每个周期的信息, 但这些信息太底层了, 它们无法携带任何高层的语义, 导致需要用户自己从这些海量信息之中寻找错误.

事实上, bug引发的错误, 在不同的抽象层次中都是有表现的, 例如RTL的实现中连错了一个信号, 反映到程序运行中, 可能是取到了错误的指令, 访问了非法内存, 或者是从函数返回到一个不正确的位置... 虽然你最终也能从0和1变化的波形中分析出这些错误, 但如果你可以直接从itrace/mtrace/ftrace的输出中发现问题, 不是更香吗? 为什么你要浪费这么多时间来做这些工具本来就做得很好的事情呢? 况且, 如果这个bug是软件层次的问题, 看波形来调试不是自找麻烦吗?

科学的调试过程首先需要理解程序如何在计算机上运行, 此外还需要理解各种工具的优缺点, 根据不同的场景选择正确的工具来分析问题. 根据计算机系统的抽象层视角, 我们可以从不同的层次观察程序运行的行为:

程序 -> 模块 -> 函数 -> 指令 -> 访存 -> 总线 -> 信号

层次越高, 行为越容易理解, 但细节也越来越模糊; 而层次越低, 细节会越精确, 但行为也越来越难理解. 因此, 科学的调试方法应该是:

  • 先使用正确的软件工具帮助你迅速定位bug产生的大概位置
  • 然后再结合波形在很小的范围内进行更细粒度的诊断, 找到bug的精确位置

为NPC搭建sdb

你需要为NPC实现单步执行, 打印寄存器和扫描内存的功能, 而表达式求值和监视点都是基于打印寄存器和扫描内存实现的. 单步执行和扫描内存都很容易实现.

为了打印寄存器, 你需要访问RTL中的通用寄存器. 有如下两种方式进行访问, 你可以自行选择:

  1. 通过DPI-C进行访问.
  2. 通过Verilator编译出的C++文件来访问通用寄存器, 例如top->rootp->NPC__DOT__isu__DOT__R_ext__DOT__Memory, 具体的C++变量名与Verilog中的模块名和变量名有关, 可阅读编译出的C++头文件得知. 不过C++变量名可能会在修改RTL代码或更改Verilator版本时发生变化, 需要手动同步修改.

为NPC添加trace支持

你已经在NEMU中实现itrace, mtrace和ftrace了, 尝试在NPC中实现它们. 其中, 实现itrace需要注意两点:

  • 需要通过DPI-C获取当前执行的指令
  • 需要链接capstone库, 具体可以参考nemu/src/utils/filelist.mk

在仿真环境获取到当前执行的指令和访存信息之后, mtrace和ftrace也就不难实现了.

为NPC添加DiffTest支持

DiffTest是处理器调试的一大杀手锏, 在为NPC实现更多指令之前, 为其搭建DiffTest是一个明智的选择. 在这里, DUT是NPC, 而REF则选择NEMU. 为此, 你需要

  • nemu/src/cpu/difftest/ref.c中实现DiffTest的API, 包括difftest_memcpy(), difftest_regcpy()difftest_exec(). 此外difftest_raise_intr()是为中断准备的, 目前暂不使用
  • 在NEMU的menuconfig中选择共享库作为编译的目标
Build target (Executable on Linux Native)  --->
  (X) Shared object (used as REF for differential testing)
  • 重新编译NEMU, 成功后将会生成动态库文件nemu/build/riscv32-nemu-interpreter-so
  • 在NPC的仿真环境中通过动态链接方式链接上述的动态库文件, 通过其中的API来实现DiffTest的功能, 具体可以参考NEMU的相关代码

尝试在打开DiffTest机制的情况下在NPC中正确运行dummy程序. 为了检查DiffTest机制是否生效, 你可以为NPC中addi指令的实现注入一个错误, 观察DiffTest是否能够按照预期地报告这一错误.

注意, 为了再次将NEMU编译成ELF, 你还需要修改NEMU中menuconfig的编译目标.

我可以选择Spike作为REF吗?

考虑到NEMU比Spike的实现更简单, 而且大家也更熟悉, 我们还是优先推荐大家把自己的NEMU作为REF. 总有一天你需要在REF中添加一些个性化的功能来帮助你调试, 我们不希望大家觉得REF的代码跟自己没有关系. 因此, 如果你具备了阅读开源软件代码的能力, 是可以把Spike作为REF的.

实现RV32E指令集

为了编译出RV32E的程序, 我们还要在AM中搭建相应的AM运行时环境. AM项目已经提供了riscv32e-npc的基本框架, 你需要在这个基础上进行一些完善工作. 不过你之前已经搭建过minirv-npc的AM了, 因此这对你来说并不困难.

搭建riscv32e-npc的AM

你需要完成如下内容:

  • riscv32e-npc提供run目标, 从而支持一键编译程序并仿真.
  • 实现riscv32e-npc中的halt()函数, 从而通知NPC的仿真环境结束仿真, 并让其得知程序的运行结果是否正确.

准备好这些基础设施之后, 你就可以方便地在NPC中实现更多的RV32E指令了. 这些指令你已经在NEMU中都实现过了, 但在RTL中实现它们, 还需要注意一些细节:

  • 计算指令: 这部分指令的执行主要是ALU单元完成的, 你已经在数字电路实验接触过它们了. 具体地
    • 加减法运算 - 在之前实现addi指令的时候, 你已经实现补码加法了. 在电路中, 补码减法可以通过补码加法来实现. 在RISC-V中, 加减法指令都无需判断进位和溢出
    • 逻辑运算 - 这个很简单
    • 移位运算 - 这个也不难, 直接用运算符就可以实现
    • 比较运算 - 可以归约到减法运算, 通过判断减法运算的结果来得出比较运算的结果
  • 分支指令: 可以通过ALU中的减法运算来计算分支是否跳转

硬件如何区分有符号数和无符号数?

尝试编写以下程序:

#include <stdint.h>
int32_t fun1(int32_t a, int32_t b) { return a + b; }
uint32_t fun2(uint32_t a, uint32_t b) { return a + b; }

然后编译并查看反汇编代码:

riscv64-linux-gnu-gcc -c -march=rv32g -mabi=ilp32 -O2 test.c
riscv64-linux-gnu-objdump -d test.o

这两个函数有什么不同? 思考一下这是为什么?

如果你是初学者, 尝试自己画出架构图

如果你是处理器设计的初学者, 尝试画出一个完整的单周期处理器架构图.

观察ALU的综合结果

尝试使用yosys-sta项目对ALU进行综合, 观察综合结果, 回答如下问题:

  1. 我们知道, 补码减法可以用加法器来实现, 而比较指令和分支指令本质上也需要通过补码减法来实现. 如果我们在RTL代码中直接编写-<等各种运算符, yosys能否自动将它们的减法功能合并为同一个加法器?
  2. 移位运算符<<>>被yosys综合成什么电路?
  3. yosys从运算符直接综合出电路是否有改进的空间?

Hint: 如果你觉得32位数据的综合结果难以阅读, 可以考虑先观察并分析16位, 8位甚至4位数据的综合结果.

在NPC中正确运行之前的所有测试

有了基础设施的强大帮助, 你应该可以很容易地正确实现支持RV32E的NPC了. 实现后, 尝试将之前运行的所有测试重新编译到riscv32e-npc, 然后在NPC上运行它们.

RV32E不包含乘除指令, NPC如何正确运行包含乘除法操作的C程序?

这是因为RISC-V指令集是模块化的, gcc可以根据指令集是否包含M扩展决定如何编译乘除法操作. 若指令集中不包含M扩展, gcc将会把乘除法操作编译成形如__mulsi3()的函数调用, 这些函数用于提供整数算术运算操作的软件模拟版本, 即用加减操作计算出乘除法的结果. 这些函数声明可参考这个页面在新窗口中打开, 它们的函数体在函数库libgcc中, 通常在链接过程会将libgcc链接到ELF可执行文件中.

我们将libgcc中一些常见的整数乘除运算操作对应的软件模拟函数移植到riscv32e-npc中, 因此可以编译出不包含乘除法指令也能正确进行乘除法操作的ELF可执行文件.

解放思想, 使用正确的工具做事情

有同学提出这样的疑问: 为什么要自己折腾verilator和Makefile这些, 明明用modelsim点一个按钮就好了. 这是因为, 仅仅使用波形进行调试, 并不是科学的方法. 对于cpu-tests这些规模很小的程序, 即使你坚持用波形调试, 你也可以存活下来; 但随着程序的规模越来越大, 调试效率将会越来越低: 如果仿真1亿个周期后出错, 你将要如何在波形中找到错误?

但大部分同学之前并未思考过如何提升调试的效率. 事实上, 这并不是因为大家没有能力(例如trace本质上就是一行printf()), 而是因为各种不专业的想法束缚了大家:

  • 我不是计算机系的, 软件跟我没关系
  • 我来写硬件的, 软件的部分随便糊弄一下就行
  • 现在公司都用quartus/vivado, "一生一芯"用verilator已经不符合时代发展的潮流

这些想法会让大家本能地拒绝接触软件领域的思想. 例如, 在龙芯杯比赛中, 成功启动Linux是系统展示环节的巅峰成果, 然而从比赛结果来看, 并不是每一支参赛队伍都能成功攀登这一巅峰. 但我们相信, 只要学会使用正确的工具, 人人都可以在合理的时间内在自己设计的CPU上成功启动Linux. 例如, 在时长为3个月的第三期"一生一芯"中, 就有一位之前从未设计过CPU的电子系同学, 以一人之力成功在自己的CPU上启动了Linux Debian. 事实上, 即使是写一个很小的脚本, 有时候都会大幅提升你的工作效率. 相比于坚持传统方法, 了解, 借鉴并吸收其它领域的先进方法, 可以让你变得更强大.

如果你是初学者, 现在可以来看看教科书上的架构图了

如果你是处理器设计的初学者, 尝试对比自己画的单周期处理器架构图与书上架构图的异同. 思考一下, 两者的架构孰优孰劣? 为什么?

最近更新时间:
贡献者: Zihao Yu