从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(): 初始化NVBoardvoid nvboard_quit(): 退出NVBoardvoid 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个区域, 分别用于处理对应种类的事件:
- 激活事件(active event)区域, 记为, 存放发生在当前仿真时刻, 且能被处理的事件.
- 未激活事件(inactive event)区域, 记为, 存放发生在当前仿真时刻, 但不能立即处理的事件, 需要在为空时, 才能处理这类事件.
- 非阻塞赋值更新事件(nonblocking assign update event)区域, 记为, 存放在之前的仿真时刻已经完成求值, 但需要在当前仿真时刻结束时才能进行赋值的事件, 需要在和均为空时, 才能处理这类事件.
- 监控事件(monitor event)区域, 记为, 存放监控操作相关的事件, 需要在, 和均为空时, 才能处理这类事件.
- 未来事件(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,则
这是因为要处理B,必须先完成的处理A - Rule2:如果在某一时刻有
,则 ,因为只有处理完Ri中的事件才会处理Rj的事件 - Rule3:
begin-end语句块中的语句按语句顺序执行 - Rule4:非阻塞赋值操作需要按语句的执行顺序来进行,即若
,若相应的求值操作顺序为 ,则赋值操作应该为
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的编码建议或描述, 但其中有一些是不正确的. 请尝试找出它们, 并分析它们为什么不正确:
- 使用
#0可以将赋值操作强制延迟到当前仿真时刻的末尾.- 在同一个
begin-end语句块中对同一个变量进行多次非阻塞赋值, 结果是未定义的.- 用
always块描述组合逻辑元件时, 不能使用非阻塞赋值.- 不能在多个
always块中对同一个变量进行赋值.- 不建议使用
$display系统任务, 因为有时候它无法正确输出变量的值.$display无法输出非阻塞赋值语句的结果.
- 不正确,
#0只是将操作延迟到了incative事件区域。仿真时刻的末尾是monitor - 不正确,只有最后一次变量赋值会生效,因为连续的右值求值,会覆盖NBA队列
- 正确
- 正确
- 不正确,display可以用来输出当前的值,可以用于调试
- 正确