D4 用RTL实现迷你RISC-V处理器

有了AM, 我们就可以考虑用RTL实现一个更强大的处理器, 并在处理器上运行更多程序. 不过如F阶段所分析, sISA由于过于简单, 很难支撑更多程序的运行, 因此, 我们先考虑将E阶段用RTL实现的NPC"升级"为一个minirv处理器. 借助minirv的完备性, 我们就可以在NPC上运行更多程序了.

模块化的RTL设计

和sCPU不同, 将来我们还会不断改进NPC, 在其中添加更多的功能. 因此, 我们有必要维护好NPC项目, 为将来的改进做好准备. 维护代码的一个方式就是模块化.

回顾minirv的ISA规范:

  • PC初值为0
  • GPR数量与RV32E中定义的GPR数量一致
  • 支持如下8条指令: add, addi, lui, lw, lbu, sw, sb, jalr
  • 其他的ISA细节与RV32I相同

从指令类型来看, minirv的指令涵盖的功能包括加法, 位拼接, 访存和跳转. 我们可以根据这些功能, 结合处理器的工作流程给NPC划分模块:

  • IFU(Instruction Fetch Unit): 负责根据当前PC从存储器中取出一条指令
  • IDU(Instruction Decode Unit): 负责对当前指令进行译码, 准备执行阶段需要使用的数据和控制信号
  • EXU(EXecution Unit): 负责根据控制信号控制ALU, 对数据进行计算
  • LSU(Load-Store Unit): 负责根据控制信号控制存储器, 从存储器中读出数据, 或将数据写入存储器
  • WBU(WriteBack Unit): 将数据写入寄存器, 并更新PC

你需要自行梳理出模块之间的接口. 当然, 你也可以自行决定将哪些部件放置在哪一个模块中. 一个例外是存储器, 为了方便测试, 我们不打算通过RTL来实现这个存储器, 而是用C++来实现它. 当前, 我们先考虑一种最简单的实现方式: 将存储器访问接口的信号拉到顶层, 通过C++代码来访问存储器.

while (???) {
  ...
  top->inst = pmem_read(top->pc);
  top->eval();
  ...
}

你可以很容易地通过C++代码来实现一个简单的存储器.

只有两条指令的minirv处理器

接下来, 我们来实现一条最简单的指令: addi. 你已经在F阶段中用Logisim实现了minirv处理器, 因此你应该已经有一个相对完整的处理器架构图, 或者能够在心中想象指令如何在上述模块中执行. 有了架构图或者指令执行流程, 要用RTL描述相关的模块, 就很容易了.

在NPC中实现addi指令

具体地, 你需要注意以下事项:

  • 存储器中可以放置若干条addi指令的二进制编码(可以利用0号寄存器的特性来编写行为确定的指令)
  • 由于目前未实现跳转指令, 因此NPC只能顺序执行, 你可以在NPC执行若干指令之后停止仿真
  • 可以通过查看波形, 或者在RTL代码中打印通用寄存器的状态, 来检查addi指令是否被正确执行
  • 关于通用寄存器, 其电路本质是一个存储器. 为了避免选择Verilog的同学编写出不太合理的行为建模代码, 我们给出如下不完整的代码供大家补充(大家无需改动always代码块中的内容):
module RegisterFile #(ADDR_WIDTH = 1, DATA_WIDTH = 1) (
  input clk,
  input [DATA_WIDTH-1:0] wdata,
  input [ADDR_WIDTH-1:0] waddr,
  input wen
);
  reg [DATA_WIDTH-1:0] rf [2**ADDR_WIDTH-1:0];
  always @(posedge clk) begin
    if (wen) rf[waddr] <= wdata;
  end
endmodule
  • 你还需要思考如何实现0号寄存器的特性
  • 使用NVBoard需要RTL代码比较好地支持设备, 我们会在B阶段再讨论这个问题, 目前不必接入NVBoard

不知道如何下手?

你很可能会遇到以下问题:

  • 如何通过PC值正确地访问存储器?
  • 如何在存储器中放置addi指令?
  • 如何仅执行若干指令后结束仿真?
  • 通用寄存器模块的端口应该如何设计?

在预学习阶段搭建verilator框架的时候, 我们就已经提醒过大家: 项目里面的所有细节都是和大家有关系的. 每当你觉得没有思路的时候, 这很大概率是在提醒你, 你很可能在之前的学习中有什么没做好. 相比于询问同学, 你其实更应该回顾之前的实验内容, 并尽自己最大努力理解每一处细节, 从而找到上述问题的答案.

在NPC中实现jalr指令

实现后addijalr指令后, 让NPC运行之前在Logisim上运行过的那个两条指令的测试程序, 并检查NPC的运行结果是否符合预期.

让程序决定仿真何时结束

我们刚才是让仿真环境(C++代码)来决定执行多少条指令后结束仿真, 或者让NPC一直执行, 直到陷入一个预期的死循环, 来表示程序运行结束. 但这些做法并不具有很好的通用性: 你需要提前知道一个程序执行多少条指令才能结束. 有没有方法可以在程序执行结束的时候自动结束仿真呢?

事实上, NEMU已经给了一个很好的解决方案了: trap指令. NEMU实现了一条特殊的nemutrap指令, 用于指示客户程序的结束, 具体地, 在RISC-V中, NEMU选择了ebreak指令来作为nemutrap指令. 事实上在NPC中, 我们也可以实现类似的功能: 如果程序执行了ebreak指令, 就通知仿真环境结束仿真.

要实现这一功能并不困难, 你首先需要在NPC中添加ebreak指令的支持. 不过, 为了让NPC在执行ebreak指令的时候可以通知仿真环境, 你还需要实现一种RTL代码和C++代码之间的交互机制. 我们借用system verilog中的DPI-C机制来实现这一交互.

尝试DPI-C机制

阅读verilator手册, 找到DPI-C机制的相关内容, 并尝试运行手册中的例子.

通过DPI-C实现ebreak

在RTL代码中利用DPI-C机制, 使得在NPC执行ebreak指令的时候通知仿真环境结束仿真. 实现后, 在上述程序中halt()函数的位置放置一条ebreak指令来进行测试. 如果你的实现正确, 仿真环境就无需关心程序何时结束仿真了, 它只需要不停地进行仿真, 直到程序执行ebreak指令为止.

如果你使用Chisel, 你可以借助Chisel中的BlackBox机制调用Verilog代码, 然后让Verilog代码通过DPI-C机制与仿真环境交互. 关于BlackBox的使用方式, 请查阅相关资料.

实现完整的minirv处理器

你需要实现剩下的6条minirv指令, 包括add, lui, lw, lbu, sw, sb. 其中, 前两条都是整数计算指令, 它们和sISA中的addli指令非常类似. 你已经在E阶段中实现过sISA的这两条指令了, 因此这对你来说并不困难.

为了实现剩下的4条访存指令, 我们需要进行一些额外的考量. 访存指令需要访问存储器, 与取指不同, 访存指令还可能需要将数据写入存储器. 我们之前把取指的接口拉到顶层的简单实现方式, 并不能正确实现访存指令, 这是因为访存接口的信号会依赖于当前取到的指令, 而仿真环境无法正确地处理这个依赖关系. 为了解决这个问题, 我们可以通过DPI-C机制来实现访存:

import "DPI-C" function int pmem_read(input int raddr);
import "DPI-C" function void pmem_write(
  input int waddr, input int wdata, input byte wmask);
reg [31:0] rdata;
always @(*) begin
  if (valid) begin // 有读写请求时
    rdata = pmem_read(raddr);
    if (wen) begin // 有写请求时
      pmem_write(waddr, wdata, wmask);
    end
  end
  else begin
    rdata = 0;
  end
end
extern "C" int pmem_read(int raddr) {
  // 总是读取地址为`raddr & ~0x3u`的4字节返回
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
  // 总是往地址为`waddr & ~0x3u`的4字节按写掩码`wmask`写入`wdata`
  // `wmask`中每比特表示`wdata`中1个字节的掩码,
  // 如`wmask = 0x3`代表只写入最低2个字节, 内存中的其它字节保持不变
}

我们在这两个内存读写函数中模拟了32位总线的行为: 它们只支持地址按4字节对齐的读写, 其中读操作总是返回按4字节对齐读出的数据, 需要由RTL代码根据读地址选择出需要的部分. 这样是为了将来在实现总线的时候不必改动太多的代码. 你需要在Verilog代码中为这两个函数的调用传入正确的参数, 并在C++代码中实现这两个函数的功能. 对于取指, 你需要删除之前把信号拉到顶层的实现, 然后额外调用一次pmem_read()来实现它.

和实现minirvEMU的时候一样, 为了运行更大的程序, 手动对存储器进行初始化是很低效的. 为了提高效率, 我们可以让仿真环境从命令行中读入程序的路径, 然后把程序内容放置到存储器中.

实现完整的minirv处理器

为NPC添加剩余的6条minirv指令, 并通过更新加载程序的方式, 然后在NPC上运行之前在Logisim上运行过的summem两个程序. 为了判断程序是否成功结束运行, 你可以在NPC开始仿真之前, 在存储器中halt()函数对应的位置放置一条ebreak指令.

搭建面向minirv的AM运行时环境

你已经可以通过AM项目将方便地将程序编译到riscv32-nemu上. 类似地, 我们也可以通过类似的方式, 快速地将程序编译到minirv-npc上, 从而通过更多的程序来测试NPC的实现是否正确.

AM项目已经提供了minirv-npc的基本框架, 你只需要在am-kernels/tests/cpu-tests/目录下执行

make ARCH=minirv-npc ALL=xxx

即可将名为xxx的测试编译到minirv-npc的运行时环境中. 不过, 为了兼容加接下来的设备功能, minirv-npc约定程序从0x80000000开始, 你需要修改PC的初值, 从而满足minirv-npc这个运行时环境的约定.

为了熟悉流程, 我们先尝试在NPC中运行dummy程序.

一键编译并在NPC上运行AM程序

在AM项目中, Makefile并没有为minirv-npc提供run目标. 尝试为minirv-npc提供run目标, 使得键入make ARCH=minirv-npc ALL=dummy run即可把AM程序编译并在NPC上运行. 不过目前minirv-npchalt()函数是一个死循环, 你可以通过查看波形来检查NPC是否成功进入了halt()函数.

实现minirv-npc中的halt()函数

为了可以自动地结束程序, 你需要在minirv-npc中实现TRM的halt()函数, 在其中添加一条ebreak指令. 这样以后, 在NPC上运行的AM程序在结束的时候就会执行ebreak指令, 从而通知NPC的仿真环境结束仿真.

实现之后, 你就可以通过一条命令自动在NPC上运行AM程序并自动结束仿真了.

为NPC实现HIT GOOD/BAD TRAP

NEMU可以输出"程序是否成功结束执行"的信息, 尝试在NPC中实现相似的功能, 这样以后, 你就可以快速了解程序在NPC上是否成功结束了.

得益于minirv指令集的完备性, 之前你在riscv32-nemu上能运行的程序, 都能通过重新编译到minirv-npc, 从而运行在NPC上. 你无需为了运行它们而在NPC上实现更多的指令.

在NPC上运行更多程序

minirv-npc上运行cpu-tests, riscv-testsriscv-arch-test, 来测试NPC的实现是否正确.

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