跳转至

Verilator 使用指南

Verilator 是一款开源的 Verilog 仿真工具,可以将 Verilog 代码转化为 SystemC 或 C++ 代码,继而编译成可执行文件,从而实现 Verilog 代码的仿真。Verilator 功能十分丰富,其完整手册在这里

本文将基于实验需求简单介绍 Verilator 的原理与用法。

1 Verilator 的工作原理

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 的编译命令如下:

shell
$ verilator <options> <verilog-file> <cpp-file>

其中,<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 顶层模块:

Verilog
module top (
        input clk
    );

endmodule

Verilator 会产生类似如下的 C++ 代码:

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 的使用方法如下:

  1. 在 Verilog 代码中,使用 import "DPI-C" function <return_type> <function_name>(<argument_list>); 来声明一个 C 语言函数;
  2. 在 Verilog 代码中,使用 <function_name>(<argument_list>); 来调用这个函数;
  3. 在 C/C++ 代码中,实现这个函数。

这段说明可能有些抽象,我们以框架中的 pmem_read 函数为例进一步说明:

在 simulator/pipeline-BRAM/Icache.sv 中,有如下声明:

Verilog
import "DPI-C" function void pmem_read(input bit re, input int addr, input int mask, output int rword);

在同一个文件中,我们调用了这个函数:

Verilog
always@(*) begin
    pmem_read(1'h1, addr, 32'H3, inst);
end

在 simulator/sim/memory/pmem.cpp 中,对于这个函数有如下实现:

C++
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++ 函数,就会在链接时产生找不到符号的错误。