从RTL代码到可流片版图

RTL仿真(Simulation) - 功能验证

先前我们分别实现了门电路和指令模拟的仿真,现在我们需要进一步的进行RTL的仿真。我们需要通过RTL仿真检查RTL代码所描述的电路功能是否符合预期。

RTL的本质就是通过软件程序来模拟硬件的行为,因此,要实现RTL仿真, 就是要考虑如何用C程序的状态机实现数字电路的状态机。

在讲义的这一部分,将了一个流水灯的例子。可以很好的帮我们理解C和Verilog的关系。接下来我们需要配置Verilog的环境。

E5.1.1 认识Verilator

你很可能是第一次听说过Verilator这个工具, 这是很正常的. 然后你就会想进一步了解Verilator的各种信息, 这也是很正常的. 但如果你的第一反应是去问人, 这就不恰当了. 事实上, Verilator这个工具在仿真领域已经非常有名, 以至于你可以很容易在互联网上搜索到它的相关信息. 你需要通过STFW找到它的官方网站, 然后阅读一下相关的介绍.

可以在官方文档中查看相关的信息Overview — Verilator 5.046 documentation

E5.1.2 安装Verilator

在官网中找到安装Verilator的步骤, 然后按照从git安装的相应步骤进行操作. 我们之所以不采用apt-get安装, 是因为其版本较老. 我们推荐安装stable版本. 为此, 你还需要进行一些简单的git操作, 如果你对此感到生疏, 你可能需要寻找一些git教程来学习. 另外, 你最好在ysyx-workbench/之外的目录进行这一操作, 否则git将会追踪到Verilator的源代码, 从而占用不必要的磁盘空间.

安装成功后, 运行以下命令来检查安装是否成功, 以及版本是否正确.

verilator --version

这个我选择自己从源代码编译软件,所以版本比较新:

[13:38:49] Ylin@Ylin /home/Ylin/applications/verilator
> verilator --version
Verilator 5.046 2026-02-28 rev v5.046-55-g1264184fb

E5.1.3 运行示例

Verilator手册中包含一个C++的示例, 你需要在手册中找到这个示例, 然后按照示例的步骤进行操作.

你已经学习过C语言, 为了使用Verilator, 你并不需要了解复杂的C++语法, 你只需要了解一些类(class)的基本使用方法就可以了. 单从这一点来看, 网上的很多资料都可以满足你的需求.

我们可以使用手册中的这个示例Example C++ Execution — Verilator 5.046 documentation

编写一个verilog文件:

module our;
    initial begin $display("Hello World"); $finish; end
endmodule

编写一个C++文件:

#include "Vour.h"
#include "verilated.h"

int main(int argc, char** argv) {
    VerilatedContext* contextp = new VerilatedContext;
    contextp->commandArgs(argc, argv);
    Vour* top = new Vour{contextp};
    while (!contextp->gotFinish()) { top->eval(); }
    delete top;
    delete contextp;
    return 0;
}

然后通过下面的方法编译:

verilator --cc --exe --build -j 0 -Wall sim_main.cpp our.v

最终运行得到:

[15:15:53] Ylin@Ylin /home/Ylin/programs/Verilog/test
> ./obj_dir/Vhello
Hello World
- hello.v:2: Verilog $finish

E5.1.4对双控开关模块进行仿真

我们现在编写一个真正的电路模块——双控开关,来测试:

module top(
  input a,
  input b,
  output f
);
  assign f = a ^ b;
endmodule

尝试在Verilator中对双控开关模块进行仿真. 由于顶层模块名与手册中的示例有所不同, 你还需要对C++文件进行一些相应的修改. 此外, 这个项目没有指示仿真结束的语句, 为了退出仿真, 你需要键入Ctrl+C.

我们可以在main.cpp中给这个模块传入上下文信息:

#include "Vtop.h"
#include "verilated.h"

int main(int argc, char** argv) {
    VerilatedContext* contextp = new VerilatedContext;
    contextp->commandArgs(argc, argv);
    Vtop* top = new Vtop{contextp};
    while (!contextp->gotFinish()) {
        top->a = rand() & 0x1;	// 传入随机的a
        top->b = rand() & 0x1; 	// 传入随机的b
        top->eval(); 
        printf("a=%d b=%d f=%d\n", top->a, top->b, top->f);
    }
    delete top;
    delete contextp;
    return 0;
}

我们可以观察到top->evol()是循环的核心,但是我们也可以在while的循环中修改/利用top模块的状态。最终得到不一样的运行结果:

a=1 b=1 f=0
a=1 b=0 f=1
a=1 b=0 f=1
a=0 b=0 f=0
a=0 b=1 f=1
a=0 b=1 f=1
...

E5.1.5 生成波形并查看

Verilator手册中已经介绍了波形生成的方法, 你需要阅读手册找到相关内容, 然后按照手册中的步骤生成波形文件, 并通过

apt-get install gtkwave

安装GTKWave来查看波形.

暂时没有能产生波形文件的程序。

E5.1.6 一键仿真

反复键入编译运行的命令是很不方便的, 尝试为npc/Makefile编写规则sim, 实现一键仿真, 如键入make sim即可进行上述仿真.

我们可以编写以下makefile:

VSRCDIR := vsrc
CSRCDIR := csrc
OBJDIR  := build

VERILOG_SRCS := $(wildcard $(VSRCDIR)/*.v)
CPP_SRCS   := $(wildcard $(CSRCDIR)/*.cpp)

VERILATOR_FLAGS := --cc --exe -O2 -j $(nproc) -Wall --trace-fst -Mdir $(OBJDIR)
TOP_MODULE := top
EXECUTABLE := V$(TOP_MODULE)

all: run

run: build
	./$(OBJDIR)/$(EXECUTABLE)	

build: $(OBJDIR)/$(EXECUTABLE).mk
	$(MAKE) -C $(OBJDIR) -f $(EXECUTABLE).mk

$(OBJDIR)/$(EXECUTABLE).mk: $(VERILOG_SRCS) $(CPP_SRCS)
	verilator $(VERILATOR_FLAGS) --top-module $(TOP_MODULE) $(VERILOG_SRCS) $(CPP_SRCS)

clean:
	rm -rf $(OBJDIR)

sim:
	$(MAKE) run 
	$(call git_commit, "sim RTL") # DO NOT REMOVE THIS LINE!!!
	

include /home/Ylin/projects/ysyx-workbench/Makefile

.PHONY: all run build clean sim

E5.1.7 接入NVBoard

阅读NVBoard项目的介绍, 尝试运行NVBoard项目中提供的示例.

NVBoard是一个虚拟的FPGA板卡项目,可以在RTL仿真环境中提供一个虚拟板卡的界面, 支持拨码开关, LED灯, VGA显示等功能。

E5.1.8 在NVBoard上实现双控开关

阅读NVBoard项目的说明, 然后仿照该示例下的C++文件和Makefile, 修改你的C++文件, 为双控开关的输入输出分配引脚, 并修改npc/Makefile, 使其连接到NVBoard上的开关和LED灯.

首先我们需要阅读README理解这个项目的用法和接口

    .
    ├── board                   # 引脚说明文件
    │   └── ...
    ├── example                 # 示例项目
    │   └── ...
    ├── include                 # 用于NVboard项目内部包含的头文件
    │   └── ...
    ├── resources			   # 图片文本资源
    │   └── ...                
    ├── scripts
    │   ├── auto_pin_bind.py    # 生成引脚绑定代码的脚本
    │   └── nvboard.mk          # NVBoard构建规则
    ├── src                     # NVBoard源码
    │   └── ...
    ├── usr
    │   └── include           # 用于给外部项目包含的头文件
    │       ├── nvboard.h
    │       └── pins...
    └── ...

我们可以通过包含头文件nvboard.h来调用NVBOARD的API:

  • void nvboard_init() : 初始化NVBoard
  • void nvboard_quit() : 退出NVBoard
  • void nvboard_bind_pin(void *signal, int len, ...) :将HDL的信号signal连接到NVBoard里的引脚上,len为信号的长度,大于1时为向量信号
  • void nvboard_update() :更新NVBoard中各组件的状态,每当电路状态发生改变时都需要调用该函数

关于引脚的绑定规则可以详细参考README。总之使用这个的虚拟板卡的流程就是:

  • 在进入verilator仿真的循环前,先对引脚进行绑定,然后对NVBoard进行初始化
  • 在verilator仿真的循环中更新NVBoard各组件的状态
  • 退出verilator仿真的循环后,销毁NVBoard的相关资源

然后记得在编译链接时需要在Makefile中:

  • 将生成的上述引脚绑定的C++文件加入源文件列表(自动绑定引脚)
  • 添加include $(NVBOARD_HOME)/scripts/nvboard.mk
  • 通过 make nvboard-archive 生成NVBoard的库文件
  • 在生成verilator仿真可执行文件(即 $(NVBOARD_ARCHIVE))将这个库文件加入链接过程,并添加链接选项 -lSDL2 -lSDL2_image

现在我们可以利用API写出程序:

#include "Vtop.h"
#include "verilated.h"
#include <nvboard.h>

int main(int argc, char** argv) {
    VerilatedContext* contextp = new VerilatedContext;
    contextp->commandArgs(argc, argv);
    Vtop* top = new Vtop{contextp};

    nvboard_bind_pin(&top->a, 1, BTNU);
    nvboard_bind_pin(&top->b, 1, BTND);
    nvboard_bind_pin(&top->f, 1, LD0);

    nvboard_init();

    while (!contextp->gotFinish()) {
        top->eval();
        nvboard_update(); 
    }

    nvboard_quit();
    delete top;
    delete contextp;
    return 0;
}

得到运行结果符合预期:

同时我们更新编译文件:

VSRCDIR ?= vsrc
CSRCDIR ?= csrc
OBJDIR  ?= build
VERILATOR ?= verilator
NPROC ?= $(shell nproc)

VERILOG_SRCS := $(wildcard $(VSRCDIR)/*.v)
CPP_SRCS := $(wildcard $(CSRCDIR)/*.cpp)
INC_FLAGS := -I$(NVBOARD_HOME)/usr/include
NVBOARD_LIB_DIR := $(NVBOARD_HOME)/build
LINKER_FLAGS := -L$(NVBOARD_LIB_DIR) -l:nvboard.a -lSDL2 -lSDL2_image -lSDL2_ttf

VERILATOR_FLAGS := --cc --exe -O2 -j $(NPROC) -Wall --trace-fst -Mdir $(OBJDIR) $(INC_FLAGS)

TOP_MODULE ?= top
EXECUTABLE := V$(TOP_MODULE)

all: run

run: build
	./$(OBJDIR)/$(EXECUTABLE)

build: $(OBJDIR)/$(EXECUTABLE)

$(OBJDIR)/$(EXECUTABLE): $(OBJDIR)/$(EXECUTABLE).mk
	$(MAKE) -C $(OBJDIR) -f $(EXECUTABLE).mk LIBS="$(LINKER_FLAGS)" CXXFLAGS="$(INC_FLAGS)"

$(OBJDIR)/$(EXECUTABLE).mk: $(VERILOG_SRCS) $(CPP_SRCS) | $(OBJDIR)
	$(VERILATOR) $(VERILATOR_FLAGS) --top-module $(TOP_MODULE) $(VERILOG_SRCS) $(CPP_SRCS)

$(OBJDIR):
	mkdir -p $@

clean:
	$(RM) -r $(OBJDIR)

sim:
	$(MAKE) run 
	$(call git_commit, "sim RTL") # DO NOT REMOVE THIS LINE!!!
	
include ../Makefile
include $(NVBOARD_HOME)/scripts/nvboard.mk

.PHONY: all run build clean sim

E5.1.8 将流水灯接入NVBoard

编写流水灯模块, 然后接入NVBoard并分配引脚. 如果你的实现正确, 你将看到灯从右端往左端依次亮起并熄灭.

我们根据这个Verilog的流水灯生成仿真程序并接入nvboard

module light(
  input clk,
  input rst,
  output reg [15:0] led
);
  reg [31:0] count;
  always @(posedge clk) begin
    if (rst) begin led <= 1; count <= 0; end
    else begin
      if (count == 0) led <= {led[14:0], led[15]};
      count <= (count >= 5000000 ? 32'b0 : count + 1);
    end
  end
endmodule

然后需要在cpp文件中手动模拟时钟的步进:

#include "Vlight.h"
#include "verilated.h"
#include <nvboard.h>

void nvboard_bind_all_pins(Vlight* top){
    nvboard_bind_pin(&top->rst, 1, SW0);
    nvboard_bind_pin(&top->led, 16, LD15, LD14, LD13, LD12, LD11, LD10, LD9, LD8, LD7, LD6, LD5, LD4, LD3, LD2, LD1, LD0);
}

int main(int argc, char** argv) {
    VerilatedContext* contextp = new VerilatedContext;
    contextp->commandArgs(argc, argv);
    Vlight* top = new Vlight{contextp};

    nvboard_bind_all_pins(top);
    nvboard_init();

    top->clk = 0;
    while (!contextp->gotFinish()) {
        top->clk = 0;
        top->eval();
        nvboard_update();
        top->clk = 1;
        top->eval();
        nvboard_update();
        contextp->timeInc(1);
    }

    nvboard_quit();
    delete top;
    delete contextp;
    return 0;
}

然后编译运行,最终得到可以运行的流水灯。

E5.1.10 通过Verilator进行静态代码检查

尝试使用Verilator检查你的代码, 并尽最大可能修复所有警告.

我们建议你将来总是开启Verilator的静态代码检查功能. 一方面, 这有助于你养成良好的编码习惯, 从而编写出更高质量的代码. 另一方面, 尽早发现代码中潜在问题, 也有利于节省不必要的调试工作: 随着代码规模的增加, 将来你很可能因为某个信号的位宽错误而调试好几天, 而Verilator的警告可以让你马上注意到这个问题, 从而轻松地排除相应的错误.

这一部分基本都没问题,我的编译选项中一直默认开着-Wall

Verilog的仿真行为和编码风格

层次化事件队列

Verilog本质上是基于事件的仿真,事件实际上就是信号值的变化。当任何一个信号值发生变化时,就会产生事件,然后仿真器会记录这个事件并触发相应的过程。

而过程本质上就是执行的行为代码块,过程被事件触发,然后又处理事件的响应。这个流程就像下面这样:

事件 → 触发过程 → 执行代码 → 产生新事件 → 触发其他过程 → ...

在不同的仿真时刻,都会发生事件,为了保证能按正确的顺序处理事件,我们需要按仿真时刻的顺序,将事件存储在一个事件队列中,然后按照一定的规则对事件进行调度。

根据Verilog标准手册, 事件队列在逻辑上包含以下5个区域, 分别用于处理对应种类的事件:

  1. 激活事件(active event)区域, 记为, 存放发生在当前仿真时刻, 且能被处理的事件.
  2. 未激活事件(inactive event)区域, 记为, 存放发生在当前仿真时刻, 但不能立即处理的事件, 需要在为空时, 才能处理这类事件.
  3. 非阻塞赋值更新事件(nonblocking assign update event)区域, 记为, 存放在之前的仿真时刻已经完成求值, 但需要在当前仿真时刻结束时才能进行赋值的事件, 需要在和均为空时, 才能处理这类事件.
  4. 监控事件(monitor event)区域, 记为, 存放监控操作相关的事件, 需要在, 和均为空时, 才能处理这类事件.
  5. 未来事件(future event)区域, 记为, 存放在未来仿真时刻才处理的事件.

(上面这一部分我也没太理解,但是大概了解?)

将同一时刻的事件,分配不同的事件种类之后,仿真器按以下的顺序执行这些事件:

while (there are events) {
  if (no active events) {
    if (there are inactive events) {	
      activate all inactive events;	// R2加入R1
    } else if (there are nonblocking assign update events) {
      activate all nonblocking assign update events;	// R3加入R1
    } else if (there are monitor events) {					  
      activate all monitor events;	// R4加入R1
    } else {	
      advance T to the next event time;			// 将仿真时刻前进一个单位
      activate all inactive events for time T;   // 将R5中事件转移到R1/R3
    }
  }
  E = any active event;		// R1
  if (E is an update event) {	
    update the modified object;	// 添加更新事件
    // 将对该事件敏感的过程的求值作为求值事件添加到事件队列中
    add evaluation events for sensitive processes to event queue;
  } else { /* shall be an evaluation event */	
    evaluate the process;	// 对过程求值
    // 将赋值行为作为更新事件添加到事件队列中
    add update events to the event queue;
  }
}

赋值操作的事件调度

关键在于认识阻塞赋值=和非阻塞赋值<=,我们可以用一张表来认识他们之间的不同:

阻塞赋值= 非阻塞赋值<=
执行方式 顺序执行,立即完成 并行执行,延迟更新
执行顺序 阻塞后续的语句(完成之后才会执行下一句) 不会阻塞后续指令
赋值时机 立即生效 当前事件槽结束时生效
事件区域 Active事件R1 NBA事件R3
用途 组合逻辑 时序逻辑

你可以想象成先前Logisim中的寄存器/内存中的值,只有每次时钟变化的上升沿才会触发值的变化,非阻塞赋值就是如此。而阻塞赋值就像是一个线路的两端,值的变化是同步的。

E5.2.1 用事件模型分析Verilog代码的行为

考虑以下代码, 假设在t时刻有a = 1, b = 2, c = 3, d = 4, e = 5. 尝试利用事件模型分析在t+1时刻, 变量的值各为多少.

always @(posedge clk) begin
  b  = a;
  c <= b;
  d  = c;
  e <= d;
  a  = e;
end
  • 一开始,在Active区域中执行对阻塞赋值和非阻塞赋值的右值求值
  • 然后接着按序执行阻塞赋值:得到b = 1, d = 3, a = 5
  • 最后执行非阻塞赋值:得到c = 2, e = 4

注意这里的非阻塞赋值的右值由于是一开始就计算出来的,所以使用的是右边的旧值。

事件处理顺序

尽管我们将事件分成了不同的种类,然后按规则执行,可是我们还是会遇到一些问题。同一种类的事件之间也应该有先后执行的顺序。

为了全面地理解Verilog代码的行为, 我们还要考虑事件处理顺序的确定性。实际上在上文的事件处理引擎其实隐含了一些顺序要求:

  • Rule1:如果在处理A的过程中生成了B,则ABA \to B 这是因为要处理B,必须先完成的处理A
  • Rule2:如果在某一时刻有ARi,BRj,i<jA \in R_i,B \in R_j, i<j,则ABA \to B,因为只有处理完Ri中的事件才会处理Rj的事件
  • Rule3begin-end语句块中的语句按语句顺序执行
  • Rule4:非阻塞赋值操作需要按语句的执行顺序来进行,即若A,BR3A,B \in R_3,若相应的求值操作顺序为ABA' \to B',则赋值操作应该为ABA \to B

E5.2.2 理解Verilator生成的仿真程序的行为

用Verilator编译流水灯电路, 尝试理解生成的C++代码的行为.

可以在编译文件夹中找到文件Vlight__024root__0.cpp

我用AI分析了一下这个文件,这个程序实现了一个完整的事件处理流程,很符合先前对仿真器的认识:

1. 时钟变化事件
        ↓
2. 检测触发条件 (eval_triggers_vec__act)
   ├─ 检测 clk 上升沿
   └─ 设置 __VactTriggered
        ↓
3. Active阶段 (eval_phase__act)
   ├─ 执行触发逻辑
   └─ 传递到 __VnbaTriggered
        ↓
4. NBA阶段 (eval_phase__nba)
   ├─ 执行 nba_sequent__TOP__0
   └─ 更新 led 和 count
        ↓
5. 检查是否需要继续
   ├─ 如果信号再次变化 → 重复步骤2-4
   └─ 如果稳定 → 结束本次eval

E5.2.3 用事件模型分析Verilog代码的行为(2)

将上述代码中的阻塞赋值改成非阻塞赋值, 尝试重新分析可能的事件处理顺序及其结果. 修改后的代码还存在数据竞争吗? 为什么?

always @(posedge clk or negedge rstn) begin
  if (!rstn) a <= 1'b0;
  else a <= b;
end

always @(posedge clk or negedge rstn) begin
  if (!rstn) b <= 1'b1;
  else b <= a;
end

分解为以下事件:

  • E1: eval(a)
  • E2: eval(b)
  • E3: update(a)
  • E4: update(b)

其中E1和E2同步发生,E3和E4同步发生。我们最终看到的结果是a,b的值按时刻交换。

E5.2.4 用事件模型分析Verilog代码的行为(3)

将上述代码中的$display改成$strobe, 尝试重新分析可能的事件处理顺序及其结果. 修改后的代码还存在数据竞争吗? 为什么?

always @(posedge clk or negedge rstn) begin
  if (!rstn) a = 1'b0;
  else a = 1;
end

always @(posedge clk) begin
  $strobe("a = %d", a);
end

首先要区分$display$strobe的区别:

$display $strobe
执行时机 Active事件区域 Monitor事件区域
显示的值 当前时刻的当前值 当前时刻的最终值
执行顺序 立即执行 所有赋值完成后执行

但是注意这个程序,这里的数据竞争关系,本质上是读写竞争。这两个块被调用总会有先后顺序,所以代码的行为并不是一定。

E5.2.5 我会写Verilog不就行了吗? 为什么要知道这些?

在这一小节的最开始提到了若干条Verilog的编码建议或描述, 但其中有一些是不正确的. 请尝试找出它们, 并分析它们为什么不正确:

  1. 使用#0可以将赋值操作强制延迟到当前仿真时刻的末尾.
  2. 在同一个begin-end语句块中对同一个变量进行多次非阻塞赋值, 结果是未定义的.
  3. always块描述组合逻辑元件时, 不能使用非阻塞赋值.
  4. 不能在多个always块中对同一个变量进行赋值.
  5. 不建议使用$display系统任务, 因为有时候它无法正确输出变量的值.
  6. $display无法输出非阻塞赋值语句的结果.
  1. 不正确,#0只是将操作延迟到了incative事件区域。仿真时刻的末尾是monitor
  2. 不正确,只有最后一次变量赋值会生效,因为连续的右值求值,会覆盖NBA队列
  3. 正确
  4. 正确
  5. 不正确,display可以用来输出当前的值,可以用于调试
  6. 正确