在一生一芯的学习中,写完
NPC处理器之后,需要搭建仿真环境。第六期一生一芯讲义首先就提出为NPC搭建sdb调试器。在此之前呢,学生都已经完成了PA 1实验,即NEMU,在NEMU中已经搭建了所需要的sdb。所以,学生的普遍做法是想移植NEMU,为NPC搭建仿真环境。另外一种情况是,verilator仿真使用的是C++代码,而NEMU使用的是C代码,故想直接重新写一个C++版本的仿真环境。以上这两种做法无疑是繁琐的工作,增加出错和调试的概率。本文的做法是,直接在原有的NEMU的代码框架基础上,添加对NPC仿真的适配代码,以可以在NEMU上运行NPC的仿真,并且使用NEMU已有的调试工具。该做法只需要添加250+行左右的代码,就能完成仿真,而且执行的正确性由原有实现的NEMU保证。
1 引入
1.1 动机
初学阶段实现的 NPC,不带内存,不带外设,需要仿真环境来模拟实现这些部分的行为。NEMU 是一个支持多种体系结构的指令模拟,也实现了对内存、外设以及异常的模拟。已有的两种做法:1. 移植 NEMU 代码。2. 基于 NEMU 的实现思路重写仿真环境。
如果采用移植的方式来完成 NPC 的仿真环境的搭建,需要移除 NEMU 中原有支持其他指令集的部分(NPC 只支持一种指令集)。删除代码的过程就有可能引入很多新的错误(删多了缺少标识符的 error,删少了,就是未使用的标识符的 warning);NEMU 的 C 代码和仿真的 C++ 代码如何友好地合在一起编译,需要一些工作来完成脚本的编写;此外,仍然需要考虑,如何友好地引入 DPI-C?如果对 NEMU 的代码没有深刻的理解,后面的调试也会有一定的阻力。
如果选择重构,可以比较自由地编写自己所需要的部分,甚至可以直接使用 C++,这样和 verilator 结合非常的方便。前期可能感觉到,重构才是工作量最小的选择,而且自己也非常清楚的了解自己的仿真是如何工作的,也方便后面的调试。一生一芯的学习,学生们来自各个专业,可能对软件架构不是特别的了解,习惯性地基于过程编程。后期会发现,NEMU 的模块抽象是实现地非常好,也可以根据实际的使用需求,对这些模块的功能的开和关。这些内容,对后面的调试选择性关闭功能提供了便利。当然,前期可能比较难有这样的编程考虑,后期只有对这些部分进行重构了,工作量反而上升了。
1.2 NSIM
我们将基于 NEMU 框架开发搭建的 NPC 仿真环境命名为 NSIM。NSIM 提供 NPC 的指令执行接口,供 NEMU 的处理器执行模块使用。此外,提供仿真环境 DPI-C 的接口,供 NEMU 的寄存器堆、内存获取必要的指令执行信息。该方式实现有以下优势:
- 使用原有的
NEMU各种功能,包括difftest,而无需对这些代码进行修改(配置文件需要修改)。 - 只需添加少量的仿真环境接口,新增代码只有
200+SLoC。

2 NSIM 架构
先来看看 NEMU 的架构:

既然 NSIM 是基于 NEMU 实现的,那我们讲讲 NEMU 处理器执行这一部分是怎么实现的。
2.1 NEMU 的指令执行
NEMU 的执行模块的架构如下:

每一个 ISA 都有一套自己指令集相关的寄存器堆,并且实现自己的指令执行引擎(Exec Engine),完成指令集约束的取指和执行操作,会对寄存器或者是内存状态进行修改。执行引擎部分对外提供 isa_exec_once 的接口。Difftest 模块提供架构相关的寄存器对比功能,对外提供 isa_difftest_checkregs 接口。NEMU 的执行模块根据配置需求,选择对应的 ISA 模块的接口。
2.2 NPC ISA
类似的,我们也可以实现一个 NPC ISA,如下图所示:

其中 Difftest 和其他 ISA 相同,不同的事执行引擎。在仿真环境中,NPC 会提供激励执行的函数接口,但是并不表示执行完一次,就表示一次指令执行完成。此时需要 DPI-C 来通知一条指令的执行完成。由于 DPI-C 和仿真强相关,我们不考虑在这个位置实现 DPI-C。
这样,我们的 NSIM 的仿真架构图如下:

2.3 NSIM 的思路
NSIM 的实现的构件图如下:

在 NEMU 的原有框架中,我们仅提供回调函数供仿真环境调用。这部分仍然由 C 语言实现,参与到 NEMU 的编译中。仿真环境由 C++ 语言实现,当指令执行完成后通过回调函数更新 NEMU 中的执行状态。这部分参与到 verilator 的编译中,最终生成一个归档(archives)供 NEMU 链接使用。
这种做法解耦了仿真环境和 NEMU,两者编译互不影响。
3 NSIM 实现
3.1 初始化
第一次尝试,为了不影响自己原有实现的 NEMU 代码,故复制了一份原有代码的基础上进行修改。目录名命名为 nsim。使用命令行工具,将目录中所有带 nemu 以及 NEMU 的单词全部替换为 nsim 或者 NSIM。
这部分做法问
ChatGPT也非常有用,几行命令就可以解决而且不会改漏。
添加系统 PATH 路径,新增 NSIM_HOME。
Kconfig 中要将
ISA_npc标记为RV64和ISA64;头文件isa_def.h中添加ISA_npc的识别;monitor.c添加对CONFIG_ISA为npc的情况识别,在这里,直接识别成riscv64。
3.2 NPC ISA 实现
在 NEMU 的 ISA 中,有以下几个部分需要实现:
- ISA 相关的头文件:定义 ISA 相关的寄存器信息。
init.c:初始化相关寄存器,加载默认程序。inst.c:指令执行相关的实现,按照 2.2 节的描述,这里我们需要实现通知指令执行完成的回调函数。reg.c:寄存器相关的操作。dut.c:difftest的测试设备相关。nmem.c:接受NPC的访问内存请求。
3.3 NPC 仿真实现
这部分代码不妨实现在
$(NSIM_HOME)/src/verilator的目录中。(亦可以其他目录)需要修改.gitignore丢弃一些编译中间的代码。
首先需要的是 NPC 的 RTL 代码,可以将 Chisel 生成的 RTL 代码直接连接到 NSIM 目录下。使用 Verilator 将这些 RTL 代码生成 C++ 代码文件,方便后面编程的语言服务。按照仿真需求,实现以下基本的函数:
step():执行一个时钟周期的函数。restart():重置NPC的函数。NEMU中的init.c调用该函数可以完成对处理器的初始化。exec_once():执行一条指令。- 以及其他各个
DPI-C调用函数的实现。
如何知道
NPC执行完了一条指令?在 2.2 节中我们提到,
NPC通过DPI-C调用来通知指令的完成。然而,在实际的实现中,DPI-C函数可能有很多个,而且执行顺序不太能自己确定(跟信号传递的先后顺序有关),所以,通知指令完成的调用不一定是真正意义上的“指令完成”。为了解决这个问题,我们设置一个全局变量,表示一条指令的结束。当发生指令完成的
DPI-C调用时,修改这个变量为true,表示已经完成。然后结束这个时钟周期的仿真之后,检查这个变量。如果为true,这个时候才是表示一条指令真正意义上执行完成了(所有的NPC的状态已经更新)。此时,再触发NEMU的回调函数,更新相应执行信息。类似于异步和同步了。DPI-C 的一些类型对应表
SystemVerilog C++ logic svLogic int int longint long long byte char logic [63:0] regs[] svOpenArrayHandler 对于最后寄存器数组的读取,参考代码如下:
仿真部分:
void output_gprs(const svOpenArrayHandle gprs) { update_gprs((uint64_t*)(((VerilatedDpiOpenVar *)gprs)->datap())); }SV 部分:
module( // ... ); // ... import "DPI-C" function void output_gprs(output logic [63:0] gprs[]); // ... endmodule
3.4 编译链接
修改原有
NEMU的 Makefile 脚本,以支持一下编译工作。
NEMU 仍然使用原有的 makefile 脚本。对于仿真代码,使用 verilator 进行编译。此处我们并不生成可执行文件,而是生成一个归档供后续 NEMU 链接使用。
如何生成归档文件?
RTFM,少
--exe编译参数就可以了。同时,verilator 提供了一个--lib-create的选项,通过这个选项,可以生成一个.a静态链接文件和一个.so的动态链接文件。
最后将 .a 静态链接文件和 NEMU 生成的二进制对象文件一起链接即可。
如何开关波形?
NPC仿真少不了使用波形进行 Debug 的时候。但是波形追踪的开关是在NEMU中设置,verilator编译看不到这个变量,就非常的棘手,因为根据 2.3 节的描述,两者相互独立。为了方便编译,我们还是会在
NEMU的 Makefile 脚本中调用verialtor进行编译。Makefile 还是可以看到自己配置的追踪开关的,在这里根据追踪的开关选项向verilator传递--CFLAGS运行参数,例如-DCONFIG_WTRACE。这里还有个小坑,
--CFLAGS必须在 verilator 命令的合适位置进行设置。
4 总结
通过阅读了 Verilator 的全局 Makefile 脚本,了解了 Verilator 编译当中的细节,为 NSIM 成功实现提供了可能。NSIM 便利了仿真环境的开发,减少了出错的可能性,也利于仿真环境代码的维护(基础代码仅 200 行左右);同时,可以使用 NEMU 上的各种功能,新功能的开发也可以直接在 NEMU 上开发,不需要对仿真环境单独进行移植。差分测试的时候,NSIM 也可以直接使用 NEMU 已有的 Spike 和 QEMU 接口进行测试。
NSIM 也留下了一些没有完成的工作。例如,Makefile 任务的前后依赖关系,并使其检测到 NPC 的 RTL 发生变化时,完成 RTL 的编译相关任务。
NSIM,NPC以及NEMU三者是相互独立又具有联系的,但是目前没有将NPC的 Makefile 和NSIM结合起来。当 NPC 的仿真代码发生变化的时候,仍然需要自己手动编译一次。
此外,NEMU 作为参考的差分测试并没有适配在其中,需要后面的完善。
博主你好,我是第六期一生一芯的同学。最近在将npc接入验证环境的时候遇到了一些,问题。我现在知道am可以通过不同的编译方式,将测试程序调试到对应的platform下。无奈本人能力不够,始终不能理解,编译出来的riscv指令是如何直接放入到仿真环境的代码下的。特此询问博主,应该看哪一部分的代码,来了解这个过程。谢谢。
抱歉这么晚回复(因为我这边没有配置邮件通知)。
简化一下您的问题:经过 AM 编译的文件是如何加载到 NPC 的仿真环境的?
其实这个过程和 NEMU 是相同的,仿真环境也会提供一个 「内存数组」,在环境初始化的时候,通过 load_img 函数将要运行的程序拷贝到内存数组中。这部分可以看看 NEMU 的初始化代码。