第四天:在电脑上验证你的“硬件描述语言”设计是否正确


第四天:在电脑上验证你的“硬件描述语言”设计是否正确

纸上得来终觉浅,绝知此事要躬行。

——陆游

在上一篇中,我们重新“发明”了Verilog硬件描述语言,学会了用文字来描述电路。

在第二天,我们在Falstad里用元器件和连线画了一个报警器电路,然后点击“运行”按钮,亲眼看着电流(小黄点)在导线上奔跑,验证了方案A有Bug、方案B能自锁。

我们还可以点击任意一根导线,用示波器看它的波形——一切都很直观。

改用代码设计电路后,我们面对的是一行行代码,我该如何检查我写的代码对不对?

代码不是图形,我们没法用鼠标去点开关、去看LED闪烁。怎么办?

答案是:也用代码

我们要通过代码,把激励信号加到模块的输入端,观察模块输出波形,以及模块内部感兴趣点的波形。这段用来测试的代码,叫做testbench(测试平台)。

一、Testbench是什么?

回忆在Falstad中,我们给模块加上时钟源,然后用鼠标点击改变输入值,观察输出的变化,还通过鼠标右键查看感兴趣的信号波形

testbench是一个特殊的verilog模块(module),它本身没有输入输出,把我们要验证的模块实例化,然后通过代码的方式,对这个实例进行我们在Falstad中进行的所有操作。

就好像我们把要验证的电路板(待验证module的实例)放在测试台上,然后我们给这块电路板(实例)加上时钟源、信号源、再从电路板上的测试点拉线到示波器看波形。

Testbench起的作用就跟这个测试台类似,testbench翻译成中文也正是“测试台”。

下面我们就介绍Testbench如何做到这些功能:

  1. 定义testbench模块
  2. 如何改变输入
  3. 时钟产生
  4. 如何查看输出和测试点的波形

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声明时就赋值为0 always begin        #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;            else                   sig_out <= 0end

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赋初值0 end always #(CLK_PERIOD/2) clk = ~clk; // 将clk信号不断周期性翻转


解释一下:

  1. always表示这个语句会永远循环执行,它没有像常规的语法那样,比如always@(*)或者always@(posedge clk),表示它无条件永远执行
  2. ~是逻辑“非”运算,clk = ~clk表示把clk取反(0变1,1变0)
  3. #10表示要等待10个时间单位后才赋值,=右边的值“取反”后,不马上给左边赋值,要等上10个时间单位(比如10ns)后才会赋值。
  4. 定义一个局部常数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"$timereset, switch, sensor, led);          // ... 更多打印 end

格式说明

$time:当前仿真时间

%0t:打印时间(不带多余空格)

%0b:打印二进制值(不带多余空格)

\t:制表符(对齐用的)

方式三:看波形图

波形图比打印更直观,就像Falstad里的示波器。我们需要一个支持波形显示的仿真工具。

有很多EDA厂家专门开发Verilog HDL的仿真和综合工具(如Vivado、ModelSim),可以将testbench和待仿真模块中的任意一个信号加到波形显示图中去,观察波形非常方便。

我们马上要介绍的这款网页版仿真工具,也提供了显示波形的方法。

5. 要注意仿真语法的局限性

上面介绍的这些仿真时写Testbench新增的语法仅仅是为了仿真,在正式的电路设计中大多数不能用。

在仿真以外的正式设计不要使用。

二、HDLBits在线仿真

推荐一个在线仿真工具用于入门学习:HDLBitshttps://hdlbits.01xz.net

理解了verilog语法和仿真原理后,切换到专业的工具如Modelsim, Vivado,Quartus等都不难,网上都有详细的操作说明。

HDLbits是个verilog语法学习网站,通过学习我们上一篇的verilog基本语法后,上HDLbits网站可以系统的将所有的知识点快速的过一遍。

打开网站后首页显示如下,开门见山的说明正是一个verilog学习网站,页面罗列了verilog语言、组合逻辑、时序逻辑等内容。 

通过上一篇的verilog基本语法学习后,上HDLbits网站可以系统的将所有的知识点快速的过一遍(其实就是刷题),能快速的系统的全面掌握verilog语法。

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

填上代码如下并点击Submit:

结果出来了,成功!

网站是英文的,英文基础好直接用,不好也没有关系,安装一个在线翻译软件搞定。

点击菜单栏SimulationRun a Simulation (Icarus Verilog),进入仿真页面。

会弹出一个页面,将你的代码拷贝进到文本框里,即可进行仿真。如果代码太大,可以放在一个文件里,然后再上传上去进行仿真,上传文件的连接在文本框的下面“Upload a source file…”。

通过HDLbits网页在线仿真,有几个地方需要:

  1. Testbench的module名必须叫top_module
  2. 因为它只有一个文本框输入代码,因此Testbench和待仿真/测试的module代码都要放在一起;
  3. HDLBits提供了特殊的语法来添加波形:

在代码里添加如下语句,启动波形图

initial `probe_start;      // 启动波形图,显示波形前必须最先使用

在代码里添加如下语句,将感兴趣的信号加入波形图

`probe(clk);               // 把clk信号添加到波形图 `probe(switch);            // 把switch添加到波形图 `probe(sensor); `probe(reset); `probe(led);

注意probe_startprobe是HDLBits网站特有的,不是标准Verilog语法。但它不影响学习,就像Falstad里右键看波形也是Falstad特有的功能。

三、HDLBits在线仿真“可控报警器”

现在,我们把之前写的报警器代码放到HDLBits中仿真,并查看波形。

3.1 准备代码

在HDLBits仿真页面中,我们需要把设计代码testbench写在同一个文件里。注意:HDLBits要求顶层模块名必须是top_module

// ============================================ // 设计代码:报警器模块 // ============================================ `timescale 1 ns / 1 ps  module alarm_system (     input clk, switch, sensor, reset,     output reg led );   wire next_state = (switch & sensor) | led;          always @(posedge clk) begin            if (reset)  led <= 0;            else        led <= next_state;     end endmodule   // ============================================ // 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);     end  endmodule


3.2 运行步骤

  1. 打开https://hdlbits.01xz.net
  2. 点击SimulationRun a Simulation (Icarus Verilog)
  3. 把上面的代码完整复制到编辑框中
  4. 点击Submit按钮
  5. 等待仿真完成

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仿真的本质。