Verilator 使用指南
Verilator 是一款开源的 Verilog 仿真工具,可以将 Verilog 代码转化为 SystemC 或 C++ 代码,继而编译成可执行文件,从而实现 Verilog 代码的仿真。Verilator 功能十分丰富,其完整手册在这里。
本文将基于实验需求简单介绍 Verilator 的原理与用法。
1 Verilator 的工作原理
Verilator 的工作原理如下图所示:
Verilator 的主要功能就是将 Verilog 代码转化为 SystemC 或 C++ 代码。以处理器的仿真为例:
- 首先,Verilator 将 Verilog 代码中并行的各个逻辑部件以合适的顺序串行化,使硬件设计转化为一个类似于处理器模拟器的软件;
- 接着,我们需要使用 C/C++ 编写激励文件。Verilator 为我们提供了顶层模块输入/输出引脚的接口,使我们得以对顶层模块的输入信号赋值或读取其输出信号。与使用 Vivado 仿真不同的是,我们可以实时地输出一些调试信息;要查看波形图时,需要将波形图导出到文件;
- 最后,Verilator 会生成一个 Makefile 脚本,利用 GCC 等编译器将生成的 C/C++ 文件和我们编写的激励文件编译成成用于仿真的可执行文件。
仿真中的陷阱
一个常见的陷阱是,仿真通过与上板能正确运行并无必然联系。因为仿真会将时钟更新的事件插到所有组合逻辑计算完后;而真实上板的情况是时钟不管组合逻辑是否全部处理完毕,都会在时钟周期结束后直接发生变化。因此,使用行为仿真无法检测出时序问题;实验框架中的 CPU 就是一个很好的例子:该 CPU 的算术运算部件使用 /
实现除法运算,这在仿真中可以正确运行,但上板时单周期进行 32 位除法运算是几乎无法实现的。
另一个常见的陷阱是,处理器设计得更高效并不意味着仿真速度更快。以流水线技术为例,该技术使用大量段间寄存器来将指令分为多个阶段并行执行,从而提高逻辑部件的利用率,达到提升 CPU 效率的效果。对各个寄存器赋值的操作在开发板上是并行完成的,而仿真时则需要依次计算每一个寄存器的值,这导致仿真的“时钟周期”大大延长。最终表现为,在仿真环境中,流水线 CPU 的处理速度比单周期 CPU 还要慢。
2 Verilator 的编译命令
结合实际来学习
在这一部分的学习中,你可以结合 simulator/build.mk 中的 Verilator 编译命令来进行学习。
Verilator 的编译命令如下:
其中,<verilog_file>
是需要仿真的 Verilog 文件,<cpp_file>
是手动编写的 C++ 激励文件。<options>
是一些编译选项,这里列出一些常用的编译选项:
--cc
:指定将 Verilog 代码转化为 C++ 代码;--exe
:指定生成目标为可执行文件;--build
:直接编译生成目标文件;--trace
:导出波形文件时需要添加此选项;--top-module <top-module>
:指定 Verilog 顶层模块;--Mdir <build-dir>
:指定生成文件的目录;-CFLAGS <c-flags>
:指定一个 GCC 的编译选项;-I <include-path>
:可以指定一个包含路径。
3 自定义编译规则
Verilator 会生成一个 Makefile 文件用于指导编译,通常名字为 V<top-module>.mk
。在指定 --build
选项时,直接使用 make
命令进行编译。
有时候,我们需要自定义编译的规则。这时我们就不能使用 --build
选项自动编译,而是应当重写一个 Makefile,在其中使用 include 命令包含 Verilator 生成的 Makefile 文件。在执行时也需要先使用 Verilator 生成文件,再使用 Make 手动编译。
框架中的编译流程
simulator 目录下,除 nemu 目录外,一共提供了 3 个 Makefile 脚本,分别是 build.mk, rewrite.mk 和 Makefile。请思考这三个脚本的作用,以及他们之间的关系。
4 使用 C++ 访问 Verilog 信号
Verilator 为用户提供了两种使用 C/C++ 访问 Verilog 信号的方法:直接访问顶层输入/输出信号和 DPI-C 机制。下面我们将一一介绍。
4.1 直接访问顶层输入输出信号
在 Verilator 生成的 C++ 代码中,顶层模块会被转化为一个类,通常命名为 V<top-module>
。其输入/输出信号会被定义为公有的成员变量,因此我们可以直接访问这些信号。对于顶层的输入信号,我们可以对其进行赋值;对于顶层的输出信号,我们可以直接读取其值。
例如,对于如下的 Verilog 顶层模块:
Verilator 会产生类似如下的 C++ 代码:
class Vtop {
public:
// PORTS
// The application code writes and reads these signals to
// propagate new values into/out from the Verilated model.
VL_IN8(&clk,0,0);
}
其中 VL_IN8(&clk,0,0)
定义了一个公有成员 clk
,并表明其位宽为 [0 : 0]
,使我们得以直接在 C++ 代码中访问。这种方法的优点是简单直接,缺点是不够灵活,因为我们只能访问顶层模块的信号,而不能访问其他模块的信号。
在生成的类里,还有一个重要的方法:eval()
。调用这个方法会让程序重新计算电路的状态,否则电路的状态会一直保持不变。因此,我们在每次对输入信号赋值之后,都需要调用这个方法,来让电路状态发生变化。
Verilator 中的时钟事件
Verilator 生成的程序是不会自动生成时钟的,我们需要在激励文件中设定时钟的行为,并通过顶层模块的输入传给要仿真的逻辑电路。
这进一步解释了仿真不能检测出时序问题的原因:仿真程序的执行流是设定时钟与调用 eval()
方法的交替。在调用 eval()
方法计算出新的电路状态之前,时钟将不会被更新。
4.2 DPI-C
DPI-C 是 Verilator 提供的一种机制,可以在 Verilog 代码中调用 C/C++ 中定义的 C 语言函数。在我们前期的实验中,将存储器读写值、寄存器值与预测值相比较是基于这个机制实现的。
DPI-C 的使用方法如下:
- 在 Verilog 代码中,使用
import "DPI-C" function <return_type> <function_name>(<argument_list>);
来声明一个 C 语言函数; - 在 Verilog 代码中,使用
<function_name>(<argument_list>);
来调用这个函数; - 在 C/C++ 代码中,实现这个函数。
这段说明可能有些抽象,我们以框架中的 pmem_read
函数为例进一步说明:
在 simulator/pipeline-BRAM/Icache.sv 中,有如下声明:
import "DPI-C" function void pmem_read(input bit re, input int addr, input int mask, output int rword);
在同一个文件中,我们调用了这个函数:
在 simulator/sim/memory/pmem.cpp 中,对于这个函数有如下实现:
extern "C" void pmem_read(bool re, uint32_t raddr, uint32_t mask, uint32_t *rword) {
// printf("pmem read: raddr = %x\n", raddr);
if (in_pmem(raddr)) {
uint32_t byte_addr = raddr;
*rword = host_read(guest_to_host(raddr), 1 << mask);
return;
}
*rword = mmio_read(raddr, 1 << mask);
}
那么,在每次在激励文件中调用 eval()
方法时,这个函数就会被调用一次,从而实现了在 Verilog 代码中调用 C 语言函数的功能。
不要误用 C++ 函数
C++ 中的函数与 C 语言中的函数在定义上有诸多相似之处,人们容易将其混为一谈,然而在此处需要严格区分。这是由于,同样定义的 C++ 函数与 C 语言函数,其链接符号是不同的。如果使用 DPI-C 调用了 C++ 函数,就会在链接时产生找不到符号的错误。