第四天:在电脑上验证你的“硬件描述语言”设计是否正确
纸上得来终觉浅,绝知此事要躬行。
——陆游
在上一篇中,我们重新“发明”了Verilog硬件描述语言,学会了用文字来描述电路。
在第二天,我们在Falstad里用元器件和连线画了一个报警器电路,然后点击“运行”按钮,亲眼看着电流(小黄点)在导线上奔跑,验证了方案A有Bug、方案B能自锁。
我们还可以点击任意一根导线,用示波器看它的波形——一切都很直观。
改用代码设计电路后,我们面对的是一行行代码,我该如何检查我写的代码对不对?
代码不是图形,我们没法用鼠标去点开关、去看LED闪烁。怎么办?
答案是:也用代码。
我们要通过代码,把激励信号加到模块的输入端,观察模块输出波形,以及模块内部感兴趣点的波形。这段用来测试的代码,叫做testbench(测试平台)。
一、Testbench是什么?
回忆在Falstad中,我们给模块加上时钟源,然后用鼠标点击改变输入值,观察输出的变化,还通过鼠标右键查看感兴趣的信号波形。
testbench是一个特殊的verilog模块(module),它本身没有输入输出,把我们要验证的模块实例化,然后通过代码的方式,对这个实例进行我们在Falstad中进行的所有操作。
就好像我们把要验证的电路板(待验证module的实例)放在测试台上,然后我们给这块电路板(实例)加上时钟源、信号源、再从电路板上的测试点拉线到示波器看波形。
Testbench起的作用就跟这个测试台类似,testbench翻译成中文也正是“测试台”。
下面我们就介绍Testbench如何做到这些功能:
-
定义testbench模块 -
如何改变输入 -
时钟产生 -
如何查看输出和测试点的波形
1. 定义testbench模块
Testbench是一个特殊的Verilog模块,它的特点是:没有输入输出端口。一般会在文件的最前面加上仿真时间单位和分辨率。
`timescale 1 ns / 1 ps // 仿真时间单位是1ns,语句中的延时数字以ns为单位;分辨率是1ps,即波形显示上以ps为单位module test(); // 括号里什么都没有,没有input也没有output// 产生时钟的语句// 改变输入的语句// 检查输出的语句// 将待测试模块实例化,从而可以对它操作endmodule
timescale:指定仿真的时间单位(1ns)和精度(1ps)。不写的话仿真器会用默认值。
module test();:testbench模块,没有输入输出端口。
2. 如何改变输入
改变输入有两种方式。
方式一:按时间顺序改变一次
我们引入一个新的关键词,叫initial,中文翻译过来就是“一次性”的意思。initial块里的代码从仿真时间0开始执行,且只执行一次。
首先,声明寄存器来产生输入信号(用reg类型,因为我们要赋值):
然后用initial块按时间顺序改变它们:
reg switch, sensor, reset; // 输入信号initial begin // 初始状态switch = 0;sensor = 0;reset = 1; // 按时间顺序改变输入#20 reset = 0; // 20个时间单位后,释放复位#30 switch = 1; // 再30个单位后,按下开关#10 sensor = 1; // 再10个单位后,传感器触发#20 sensor = 0; // 再20个单位后,传感器松开(小偷逃跑)#30 switch = 0; // 再30个单位后,开关松开#50 reset = 1; // 再50个单位后,按复位#10 $finish; // 结束仿真end
理解这段代码:
initial表示这个块里的代码只执行一次(从仿真开始到结束)。可以写多个initial语句块,它们都是从时间零点并行执行的
#20表示等待20个时间单位,#30表示再等待30个时间单位(加上前面20,一共是30+20=50)
代码是顺序执行的,就像你按照时间顺序去点开关一样
$finish是系统任务,表示结束仿真
有时候reg变量在申明时就赋初始值,仿真器或者编译器看到这条语句后,会在系统上电时将这些寄存器赋初值。
reg switch = 0, sensor = 0, reset = 1; // 输入信号
方式二:不断改变
用always语句配合#延时,可以让信号周期性地变化。比如产生一个周期为40的方波信号:
reg signal = 0; // signal声明时就赋值为0alwaysbegin#20 signal = 1;#20 signal = 0;end
always关键字后面没有接@(*)或者@(posedge clk)等指明条件,说明是无条件一直执行。
当然我们也可以使用有条件的always,例如:
reg sig_out;initial sig_out = 0; // 将sig_out赋初值0,等同于声明时赋初值always @(posedge signal) begin // 将signal当作时钟信号,上升沿触发if (switch==1)sig_out <= sensor;elsesig_out <= 0;end
3. 时钟产生
时钟是周期性改变的信号,首先我们定义一个时钟信号并赋初值:
reg clk; // 时钟信号用reg类型,因为我们要在always块里给它赋值initial clk = 0; // 将clk赋初值0
然后用always语句让它周期性地翻转:
always #10 clk = ~clk; // 每10个时间单位翻转一次
一般常见的作法如下:
localparam CLK_PERIOD = 20; // 50MHz,定义一个本地参数,可以根据需要修改reg clk; // 时钟信号用reg类型,因为我们要在always块里给它赋值initial begin clk = 0; // 将clk赋初值0endalways #(CLK_PERIOD/2) clk = ~clk; // 将clk信号不断周期性翻转
解释一下:
-
always表示这个语句会永远循环执行,它没有像常规的语法那样,比如always@(*)或者always@(posedge clk),表示它无条件永远执行 -
~是逻辑“非”运算,clk = ~clk表示把clk取反(0变1,1变0) -
#10表示要等待10个时间单位后才赋值,=右边的值“取反”后,不马上给左边赋值,要等上10个时间单位(比如10ns)后才会赋值。 -
定义一个局部常数CLK_PERIOD等于20,CLK_PERIOD/2就等于10,因此这两种写法等效。
执行过程:翻转 → 等10 → 翻转 → 等10 → 翻转……无限循环。这样clk就会变成0、1、0、1……的周期信号。
周期是多少?每个电平持续10个时间单位,一个完整的周期(0→1→0)是20个时间单位。
4. 如何查看输出和测试点的波形
在Testbench中,有两种方式查看结果:打印文字和看波形图。
方式一:用$display打印
$display是Verilog语言中的专门用于仿真的任务,用来打印信息。
initial begin // 打印表头$display("时间\t复位\t开关\t传感器\tLED");$display("----------------------------------");#20 reset = 0; // 打印当前状态($time是当前仿真时间)$display("%0t\t%0b\t%0b\t%0b\t%0b", $time, reset, switch, sensor, led);#30 switch = 1;$display("%0t\t%0b\t%0b\t%0b\t%0b", $time, reset, switch, sensor, led);// ... 更多打印end
格式说明:
$time:当前仿真时间
%0t:打印时间(不带多余空格)
%0b:打印二进制值(不带多余空格)
\t:制表符(对齐用的)
方式二:用$monitor打印
$monitor是Verilog语言中的专门用于仿真的任务,用来监控信息。它于display的区别是: display调用一次就打印一次,不调用就不打印。monitor只需要调用一次,之后只要参数列表中的任何一个信号发生变化,就会自动打印一行。
initial begin // 打印当前状态($time是当前仿真时间)$monitor("%0t\t%0b\t%0b\t%0b\t%0b", $time, reset, switch, sensor, led);// ... 更多打印end
格式说明:
$time:当前仿真时间
%0t:打印时间(不带多余空格)
%0b:打印二进制值(不带多余空格)
\t:制表符(对齐用的)
方式三:看波形图
波形图比打印更直观,就像Falstad里的示波器。我们需要一个支持波形显示的仿真工具。
有很多EDA厂家专门开发Verilog HDL的仿真和综合工具(如Vivado、ModelSim),可以将testbench和待仿真模块中的任意一个信号加到波形显示图中去,观察波形非常方便。
我们马上要介绍的这款网页版仿真工具,也提供了显示波形的方法。
5. 要注意仿真语法的局限性
上面介绍的这些仿真时写Testbench新增的语法仅仅是为了仿真,在正式的电路设计中大多数不能用。
在仿真以外的正式设计不要使用。
二、HDLBits在线仿真
推荐一个在线仿真工具用于入门学习:HDLBits(https://hdlbits.01xz.net)
理解了verilog语法和仿真原理后,切换到专业的工具如Modelsim, Vivado,Quartus等都不难,网上都有详细的操作说明。
HDLbits是个verilog语法学习网站,通过学习我们上一篇的verilog基本语法后,上HDLbits网站可以系统的将所有的知识点快速的过一遍。
打开网站后首页显示如下,开门见山的说明正是一个verilog学习网站,页面罗列了verilog语言、组合逻辑、时序逻辑等内容。
通过上一篇的verilog基本语法学习后,上HDLbits网站可以系统的将所有的知识点快速的过一遍(其实就是刷题),能快速的系统的全面掌握verilog语法。

可以进入到任何你感兴趣的内容,比如verilog语言的基础部分,选择AND gate“与门”,会进入到“与门”页面,里面要求你用assing语句来描述AND操作,让你在下面的编辑框里填空。

填上代码如下并点击Submit:
结果出来了,成功!

网站是英文的,英文基础好直接用,不好也没有关系,安装一个在线翻译软件搞定。
点击菜单栏Simulation→Run a Simulation (Icarus Verilog),进入仿真页面。
会弹出一个页面,将你的代码拷贝进到文本框里,即可进行仿真。如果代码太大,可以放在一个文件里,然后再上传上去进行仿真,上传文件的连接在文本框的下面“Upload a source file…”。

通过HDLbits网页在线仿真,有几个地方需要:
-
Testbench的module名必须叫top_module -
因为它只有一个文本框输入代码,因此Testbench和待仿真/测试的module代码都要放在一起; -
HDLBits提供了特殊的语法来添加波形:
在代码里添加如下语句,启动波形图
initial `probe_start; // 启动波形图,显示波形前必须最先使用
在代码里添加如下语句,将感兴趣的信号加入波形图
`probe(clk); // 把clk信号添加到波形图`probe(switch); // 把switch添加到波形图`probe(sensor);`probe(reset);`probe(led);
注意:probe_start和probe是HDLBits网站特有的,不是标准Verilog语法。但它不影响学习,就像Falstad里右键看波形也是Falstad特有的功能。
三、HDLBits在线仿真“可控报警器”
现在,我们把之前写的报警器代码放到HDLBits中仿真,并查看波形。
3.1 准备代码
在HDLBits仿真页面中,我们需要把设计代码和testbench写在同一个文件里。注意:HDLBits要求顶层模块名必须是top_module。
// ============================================// 设计代码:报警器模块// ============================================`timescale 1 ns / 1 psmodule alarm_system (input clk, switch, sensor, reset,output reg led );wire next_state = (switch & sensor) | led;always @(posedge clk) beginif (reset) led <= 0;else led <= next_state;endendmodule// ============================================// Testbench(HDLBits要求顶层模块名为top_module)// ============================================moduletop_module();// 1. 声明信号reg clk, switch, sensor, reset;wire led;// 2. 启动波形图,添加要观察的信号,这种用法是HDLbits网站的特殊显示波形的操作要求,其它EDA工具显示方法各有不同initial `probe_start;`probe(clk);`probe(switch);`probe(sensor);`probe(reset);`probe(led);// 3. 例化报警器alarm_system uut(.clk(clk),.switch(switch),.sensor(sensor),.reset(reset),.led(led) );// 4. 产生时钟(周期20ns,频率50MHz)always#10 clk = ~clk;// 5. 施加激励initial begin// 初始状态clk = 0;switch = 0;sensor = 0;reset = 1;#20 reset = 0; // 释放复位#30 switch = 1; // 按下开关#10 sensor = 1; // 传感器触发#20 sensor = 0; // 传感器松开(小偷逃跑)#30 switch = 0; // 开关松开#50 reset = 1; // 按复位#10 $finish; // $finish也是专门用于仿真的任务,会结束仿真,通常放在所有激励施加完毕之后。end// 6. 打印输出(可选,看波形更直观)initial begin$monitor("时间=%0t, led=%b", $time, led);endendmodule
3.2 运行步骤
-
打开https://hdlbits.01xz.net -
点击Simulation→Run a Simulation (Icarus Verilog) -
把上面的代码完整复制到编辑框中 -
点击Submit按钮 -
等待仿真完成

3.3 观察打印和波形
仿真完成后,你会看到打印的信息和波形图。
在打印信息中:
Running Icarus Verilog simulator...
VCD info: dumping is suppressed.
时间=0, led=x
时间=10000, led=0
时间=70000, led=1
./top_module.v:63: $finish called at 170000 (1ps)
时间=170000, led=0
Hint: Total mismatched samples is 0 out of 0 samples
Simulation finished at 170000 ps
Mismatches: 0 in 0 samples
时间0时led显示x(不定态)。不定态,仿真器不知道这个信号是0还是1。通常是因为寄存器没有初始化。在我们的设计中,第一个时钟沿到来后,reset将led清零,所以之后led就是确定的值了。
时间10000ps(即10ns)时,第一个时钟上升沿到来,reset为高,led被清零。
时间70000ps(即70ns)时,时钟上升沿到来,switch=1且sensor=1,led变为1。
此后电路自锁,led一直保持为1,直到时间170000ps(170ns)时reset变高,led被清零。

在波形图中可以观察到的现象与打印完全一致。以下时间单位均为ns,为方便阅读将ps换算为ns(1000ps = 1ns)。
|
时间点 |
事件 |
LED状态 |
说明 |
|
0 |
reset有效 |
不定 |
时钟沿尚未到来led还没有更新 |
|
10 |
第1个时钟上升沿到来 |
0 |
复位生效 |
|
20 |
reset释放 |
0 |
LED继续保持上个状态0 |
|
30 |
第2个时钟上升沿到来 |
0 |
因为此时采样的所有输入均为0 |
|
40 |
第2个时钟下降沿到来 |
0 |
下降沿不更新数据 |
|
50 |
第3个时钟上升沿到来,控制开关打开 |
0 |
因为此时采样的所有输入均为0 |
|
60 |
传感器触发 |
0 |
还没到时钟沿 |
|
70 |
时钟上升沿 |
1 |
LED亮!此时输入switch和sensor同为高 |
|
80 |
传感器松开 |
1 |
保持亮(自锁生效) |
|
110 |
开关松开 |
1 |
仍然亮 |
|
170 |
按复位 |
0 |
复位成功 |
这和我们在Falstad里右键看波形是一回事。
四、验证方案A的Bug
还记得第二天在Falstad里发现的Bug吗?我们可以在这里用代码验证一下。
修改设计代码,去掉反馈线:
wire next_state = (switch & sensor); // 注意:去掉了 | led
重新运行仿真,观察波形图——你会发现传感器松开后,LED在下一个时钟周期也随之变低了。这就是方案A的Bug!

在我们的设计要真正做到硬件上之前(芯片流片或FPGA综合布线),仿真是必须的,能发现绝大多数问题。
Testbench是用代码描述我们在Falstad里手动做的所有操作——加时钟、点开关、看输出、看波形。
其它的任何EDA仿真工具指示具体操作不一样,原理没有任何不同。
理解了这一点,你就明白了Verilog仿真的本质。