在一生一芯的学习中,写完
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 的初始化代码。