编写测试文件
在 Verilog 代码设计完成后,我们还需要进行一项重要的步骤:仿真。TestBench 是用于验证和测试设计模块的仿真环境,它可以生成输入信号并模拟设计模块的输出,进而测试设计电路的功能、性能与设计的预期是否相符。
一般来说,仿真阶段花费的时间会比设计花费的时间更多,因为需要根据各种可能的应用场景设计各式各样的样例,对应的代码编写也更加复杂。
程序员与酒吧
一个测试工程师走进一家酒吧,要了一杯啤酒;
一个测试工程师走进一家酒吧,要了一杯咖啡;
一个测试工程师走进一家酒吧,要了 0.7 杯啤酒;
一个测试工程师走进一家酒吧,要了 -1 杯啤酒;
一个测试工程师走进一家酒吧,要了 \(2^{32}\) 杯啤酒;
一个测试工程师走进一家酒吧,要了一杯洗脚水;
一个测试工程师走进一家酒吧,要了一杯蜥蜴;
一个测试工程师走进一家酒吧,要了一份 asdfQwer@24dg!&*(@;
一个测试工程师走进一家酒吧,什么也没要;
一个测试工程师走进一家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来;
一个测试工程师走进一家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿;
一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷;
一个测试工程师走进一家酒吧,要了 NaN 杯 Null;
1T 测试工程师冲进一家酒吧,要了 500T 啤酒咖啡洗脚水野猫狼牙棒奶茶;
1T 测试工程师把酒吧拆了;
一个测试工程师化装成老板走进一家酒吧,要了 500 杯啤酒并且不付钱;
一万个测试工程师在酒吧门外呼啸而过;
一个测试工程师走进一家酒吧,要了一杯啤酒 ';DROP TABLE 酒吧;
一个测试工程师跳进一家酒吧;
一个测试工程师蒙着眼睛,倒退着走进一家酒吧;
一个测试工程师走进一家酒吧,要了一杯美国啤酒,一杯德国啤酒,一杯比利时啤酒,一杯青岛啤酒;
一个体重五百吨的测试工程师走进一家酒吧;
一个酒量五百吨的测试工程师走进一家酒吧;
一个酒量为零的测试工程师走进一家酒吧;
一个测试工程师走进一家酒吧,点了一杯啤酒,一边喝一边用指尖把啤酒逼出体内;
一个测试工程师来到一家酒吧门口,拿出电脑,敲了几个命令,随后 \(2^{32} - 1\) 个测试工程师走进一家酒吧;
一个测试工程师戴着墨镜,手持两把 Uzi 冲进一家酒吧,对着室内一顿扫射,然后要了一杯啤酒;
两个测试工程师走进一家酒吧;
个测试工程师走进一家酒吧;
两个测试工程师走进家酒吧;
两个测试工程师走进一家吧;
两个测试工程师走进一家酒;
两个测试工程师进一家酒吧;
两个测试工程师走一家酒吧;
两个酒吧走进一家测试工程师;
一个测试工程师走进一家酒吧,要了一杯 Nil,一杯 Null 和一杯 None;
一个名叫 exception 的测试工程师走进一家酒吧,被丢了出来;
一个测试工程师走进酒吧,另个一师程工试测走也进吧酒;
我走进酒吧要了一杯;
我盗用老板身份走进了酒吧,进了后台放了一瓶我自己的酒;
我走进酒吧在吧台放了一杯 ' or 1=1;
测试工程师们满意地离开了酒吧。
然后一名顾客点了一份炒饭,酒吧炸了。
这是广为流行的一个关于测试的笑话,其核心思想是:你永远无法把所有问题都充分测试。尽管如此,我们依然需要尽可能地编写覆盖全面的测试文件,不能被『要了 -1 杯啤酒』的需求就弄炸了自己的酒吧。
补充介绍:为什么需要 Testbench
Testbench 的中文释义是试验台、测试架、试样、试验工作台,是硬件设计中用于测试设计模块的环境。那么,为什么在硬件设计领域会需要 Testbench 呢?我们引用这篇文章中的一些内容作为答案。
首先,我们需要区分测试和 Debug 的概念。Debug 是程序开发阶段中消除逻辑错误的过程,它侧重于让程序能够正常运行;测试是编程完成后,测试程序的正确性的过程,它侧重于模拟尽可能多的输入情况,保证不同情况下程序都可以输出正确的结果。
例如:现在我们想用 C 语言编写一个冒泡排序程序。你可能会写出下面的代码:
Bubble_sort | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
写代码并保证其能正确排序的过程就是 Debug,而测试过程则对应着如下的思考:
len
的值为 -1 时能否正常运行;- 传入的 arr 大小和 len 不匹配时能否正常运行;
- 传入的 arr 是空指针时能否正常运行;
- ......
然而,在软件开发的过程中,我们很少涉及到测试的过程。一方面,我们的需求并不需要考虑这些奇奇怪怪的输入情况;另一方面,便捷高效的调试过程可以让我们立即发现并修复程序中可能的漏洞,把漏洞留给用户去上报再进行修复也是一种可接受的选择。
但是在硬件设计领域,一切都不同了。『你只能编写出自己能测试的模块』,一个自己设计但无力进行测试的复杂模块,往往也很难按照自己的预期进行工作。此外,硬件电路的工作状态是很难被我们获知的,因为芯片上没有 printf
函数,而为每一个元件连接一个显示器也不是什么好的选择。尽管一些芯片有内置的信息输出单元,但一方面其成本高昂,另一方面传输效率也十分低下。大多数情况下,摆在你面前的只有一个小小的、内部状态未知、工作不正常的芯片。
所以,我们选择在设计完成后引入 Testbench 进行测试,尽管编写 Testbench 的代价往往大于(甚至远大于)编写对应的待测试模块。如何编写高效的 Testbench 也成为了硬件开发中的重要一环。
2.1 ★ Testbench 基本结构
Testbench 由不可综合的 Verilog 代码组成,这些代码用于生成待测模块的输入,并验证待测模块的输出是否正确(是否符合预期)。下图展示了一个 Testbench 的基本架构。
其中:
- 激励(Stimulus Block)是专门为待测模块生成的输入。我们需要尽可能产生全面的测试场景,包括合法的和不合法的。
- 输出校验(Output Checker)用于检查被测模块的输出是否符合预期。
- 被测模块(Design Under Test, DUT。也称 Unit Under Test, UUT)是我们编写的 Verilog 模块,Testbench 的主要目的就是对其进行验证,以确保在特定输入下其输出均与预期一致。
编写 Testbench 的第一步是创建一个 Verilog 模块作为测试的顶层模块。与正常设计时的 Verilog module 不同,用于测试的模块应当没有输入和输出,这是因为 Testbench 模块应当是完全独立的,不受外部信号的干扰。
接下来,我们需要例化待测模块,将信号连接到待测模块以允许激励代码运行。这些信号包括时钟信号和复位信号,以及传入 Testbench 的测试数据。下面的代码片段展示了一个 Testbench 的基本框架。
Testbench | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
2.2 ★ 时序控制
与我们正常的设计代码不同,Testbench 中的代码并不需要被综合成实际电路,为此可以使用一些不可综合的语句,例如时序控制语句。
思考:不可综合语句
为什么会有不可综合语句呢?下面是另一个有趣的笑话。
硬件延迟
某段程序需要延迟 10s 执行。硬件工程师表示:这个需求很简单,只需要一段 75 倍赤道周长的导线就可以完成了。因为 \(3\times10^8\times10=75\times4\times10^7\)
75 倍赤道周长的导线固然不是什么好办法,但这也带给我们一个问题:如何用硬件电路实现长时间的延迟呢?如果让信号通过多个逻辑门,一方面会带来巨量的资源开销,另一方面综合时也极有可能被优化掉。一种比较好的办法是使用计数器搭配时钟,每个时钟上升沿更新计数器,直到达到一定数值后再进行信号传递。
在实验介绍部分我们提到:综合是指将 HDL 转换成较低层次电路结构的过程,包括查找表 LUT、触发器、RAM 等。有一些 Verilog 语法结构无法与这些电路结构对应,因此就产生了不可综合语句。例如,除了延迟语句外,循环次数不确定的循环语句也是不可综合的,
Verilog 中允许我们模拟两种不同的延时:惯性延时和传输延时。惯性延迟是逻辑门或电路由于其物理特性而可能经历的延迟,而传输延迟是电路中信号的『飞行』时间。
Verilog 使用 #
字符加上时间单位来模拟延时。例如 #10;
表示延迟 10 个时间单位后再执行之后的语句,对应着传输延迟。惯性延迟将延时语句写在与赋值相同的代码行中,这代表信号在延迟时间之后开始变化。例如:
wire Z, A, B;
assign #10 Z = A & B;
// A&B 的计算结果延时 10 个时间单位赋值给 Z
上面的代码中,A 或 B 任意一个变量发生变化,都会让 Z 在 10 个时间单位的延迟后得到新的值。如果在这 10 个时间单位内,A 或 B 中的任意一个又发生了变化,那么最终 Z 的新值会取 A 或 B 当前的新值。
Tips:惯性延时
我们用一个具体的例子向大家阐述惯性延时的概念。考虑下面这条语句(假定时间单位为 ns):
assign #2 z = ~a;
这条语句描述了一个延迟为 2ns 的非门,从输入端输入到输出端输出结果之间间隔了 2ns,且任何小于 2ns 的信号脉冲都会被滤除。后面那句话怎么理解呢?考虑如下的仿真语句:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
得到的波形图如下图所示:
我们发现:变量 a
第二次变为 0 时,由于仅持续了 1ns 就变回了 1,变量 z
并没有捕捉到这一变化。
原因是:assign #2 z = ~a;
可以拆成两部分:首先是立即执行的 assign temp = ~a;
,其次是延时两个时间单位执行的 assign z = temp;
。在这两个时间单位内,对变量 a
的改变将直接反应在变量 temp
上(还记得 assign 语句的特点吗?),而变量 z
并不会察觉到这一变化,也就不会做出相应的改变。
我们可以用如下的 always 语句进行替代:
1 2 3 4 5 |
|
这也体现出连续赋值与组合逻辑电路没有记忆性的特点。
为了明确在仿真期间所使用的时间单位,我们需要使用 `timescale
指令。其格式为:
`timescale <unit_time> / <resolution>
其中, <unit_time> 指定时间的单位,<resolution> 指定时间的精度,例如我们常常使用的
`timescale 1ns / 1ps
代表仿真的一个时间单位是 1ns,最小时间精度为 1ps。如果使用了 #1.1111;
指令,则最终的延迟为 1.111ns(四舍五入)。
注意
仿真时间单位和时间精度的数字只能是 1、10、100,不能为其它的数字。此外,时间精度应当不超过时间单位的大小。
除了直接控制延迟,Verilog 还支持基于事件的时间控制,这就是 @
符号的作用。Verilog 有以下三种常用的事件控制方式:
@
+ 信号名,表示当信号发生逻辑变化时执行后面的内容。例如@ (in) out = in;
@
+posedge
+ 信号名,表示当信号从低电平变化到高电平时执行后面的内容。例如@ (podedge in) out = in;
@
+negedge
+ 信号名,表示当信号从高电平变化到低电平时执行后面的内容。例如@ (negedge in) out = in;
这也印证了 always 语句的敏感变量语法。
2.3 ★ initial 与 always
在 Lab1 中我们介绍过,initial 块中编写的任何代码都会在开始时执行,但仅执行一次,而 always 块则会循环执行内部的代码。与 always 块不同,在 initial 块中编写的 Verilog 代码几乎都是不可综合的,因此基本上只被用于仿真与初始化信号。
为了更好地理解 initial 块与 always 块在 Testbench 中的使用,我们来看一个例子。
例子:两输入与门的测试
假定我们想要测试下面的 Verilog 代码:
1b_AND | |
---|---|
1 2 3 4 5 6 |
|
位宽为 1 的两输入与门一共只有 \(2^2=4\) 种可能的输入,因此我们可以直接枚举所有可能的情况。此外,我们还需要使用延时运算符在不同的输入之间增加一段延迟,便于我们观察到结果的变化。
下面的 Verilog 代码展示了使用 initial 块编写 Testbench 的方法。
initial begin
// 每隔 10 个时间单位就生成一个输入
a = 0; b = 0;
#10 a = 0; b = 1;
#10 a = 1; b = 0;
#10 a = 1; b = 1;
end
这段代码完全等价于
initial begin
a = 0; b = 0;
#10;
a = 0; b = 1;
#10;
a = 1; b = 0;
#10;
a = 1; b = 1;
end
如果想要模拟一个时钟信号,则可以使用 always 语句。例如:
always begin
clk = 1;
#20;
clk = 0;
#20;
end
这段代码也可以写为
initial clk = 0;
always #20 clk = ~clk;
提醒
再次提醒:多个 initial 块和 always 块之间都是并行执行的。
2.4 循环
除了 Lab1 中介绍的赋值语句和分支语句,Verilog 中也有循环语句。它们分别是 while、for、repeat 和 forever 循环。循环语句只能在 always 或 initial 块中使用,其内部可以包含延迟表达式。
Tips:不定次数的循环
在仿真中我们可以使用不可综合语句,也就是不确定循环次数的循环语句。
这四种循环的介绍如下。
While
while 循环的基本格式为:
while (condition) begin
......
end
reg [3:0] counter;
initial begin
counter = 'b0;
while (counter <= 10) begin
#10;
counter = counter + 1'b1;
end
end
这段代码让 counter 从 0 开始,每 10 个时间单位增加 1。
思考
上在面这段代码执行完后,counter 的值是多少呢?
For
for 循环的基本格式为:
for (initial_assignment; condition; step_assignment) begin
......
end
其中,initial_assignment 为初始条件。condition 为循环条件,为假时立即跳出循环。step_assignment 为改变控制变量的过程赋值语句,通常为增加或减少循环变量的值。
一般来说,因为初始条件和自加操作等过程都已经包含在 for 循环的头部,所以 for 循环写法比 while 循环更为紧凑,但也不是所有的情况下都能使用 for 循环来代替 while 循环。
下面是一个 for 循环的例子,实现了与之前 while 循环例子一样的效果。
integer i;
reg [3:0] counter;
initial begin
counter = 'b0;
for (i = 0; i <= 10; i = i + 1) begin
#10;
counter = counter + 1'b1;
end
end
这里我们定义了一个 integer 类型的变量。integer 类型实际上是有符号的 reg 类型,一般用于描述循环变量或计算。通常来说,integer 类型的变量是 32 位的。
注意
在 Verilog 语言里,i = i + 1
不能像 C 语言那样写成 i++
的形式,i = i - 1
也不能写成 i--
的形式。
Repeat
repeat 循环的基本格式为:
repeat (loop_times) begin
......
end
repeat 语句的功能是执行固定次数的循环,它不能像 while 循环那样用一个逻辑表达式来确定循环是否继续执行。repeat 循环的次数必须是一个常量、变量或信号。如果循环次数是变量信号,那么循环次数是开始执行 repeat 循环时变量信号的值。即便执行期间循环次数代表的变量信号值发生了变化,repeat 循环的执行次数也不会改变。
下面是一个 repeat 循环的例子。
reg [3:0] counter;
initial begin
counter = 'b0;
repeat (11) begin
#10;
counter = counter + 1'b1;
end
end
Forever
forever 循环的基本结构为:
forever begin
......
end
forever 语句表示永久循环,不包含任何条件表达式,一旦执行便永久执行下去。使用系统函数 $finish
可退出 forever 循环。
通常,forever 循环是和时序控制配合使用的。例如下面是使用 forever 语句产生一个时钟信号的 Verilog 代码:
reg clk;
initial begin
clk = 0;
forever begin
clk = ~clk;
#5;
end
end
这段代码等价于
reg clk;
initial clk = 0;
always begin
clk = ~clk;
#5;
end
注意
除非你清楚自己在做什么,否则请不要轻易地在设计文件中使用循环语句。
2.5 系统任务
在 Verilog 中编写 Testbench 时,有一些内置的任务和函数可以为我们提供帮助。它们总是以美元符号 $
开头,被统称为系统任务或系统函数。其中,下面三个是最常用的系统函数:$display
、$monitor
和 $time
。
$display
$display
允许我们在控制台上输出一条消息。该函数的使用方式与 C 语言中的 printf 函数非常类似,这意味着我们可以轻松地在 Testbench 中创建文本语句,并使用它们来显示有关仿真状态的信息。
此外,我们还可以在字符串中使用特殊字符 %
来规范化显示信号数值。我们需要使用一个格式字母来决定以何种格式显示变量数值,在格式代码前面加上一个数字来确定要显示的位数。最常用的格式是 b(二进制)、d(十进制)和 h(十六进制)。
下面是使用 $display
函数的一个例子:
reg [4:0] x;
initial begin
x = 0;
repeat (10) begin
// 分别用 2 进制、16 进制和 10 进制来打印 x 的值
$display("x(bin) = %b, x(hex) = %h, x(decimal) = %d\n", x, x, x);
#10;
x = x + 2;
end
end
这段代码的输出结果为
x(bin) = 00000, x(hex) = 00, x(decimal) = 0
x(bin) = 00010, x(hex) = 02, x(decimal) = 2
x(bin) = 00100, x(hex) = 04, x(decimal) = 4
x(bin) = 00110, x(hex) = 06, x(decimal) = 6
x(bin) = 01000, x(hex) = 08, x(decimal) = 8
x(bin) = 01010, x(hex) = 0a, x(decimal) = 10
x(bin) = 01100, x(hex) = 0c, x(decimal) = 12
x(bin) = 01110, x(hex) = 0e, x(decimal) = 14
x(bin) = 10000, x(hex) = 10, x(decimal) = 16
x(bin) = 10010, x(hex) = 12, x(decimal) = 18
$monitor
$monitor
函数与 $display
函数非常相似,但它一般被用来监视 Testbench 中的特定信号。这些信号中的任何一个改变状态,都会在终端打印一条消息。
下面是使用 $monitor
函数的一个例子:
reg [4:0] a, b;
initial begin
a = 0;
b = 20;
repeat (10) begin
#10;
a = a + 2;
b = b - 2;
end
end
initial begin
$monitor("now a = %d, b = %d\n", a, b);
end
这段代码的输出结果为
now a = 0, b = 20
now a = 2, b = 18
now a = 4, b = 16
now a = 6, b = 14
now a = 8, b = 12
now a = 10, b = 10
now a = 12, b = 8
now a = 14, b = 6
now a = 16, b = 4
now a = 18, b = 2
now a = 20, b = 0
$time
最后一个常用的系统任务是 $time
,它可以用来获取当前的仿真时间。在 Testbench 中,我们通常将 $time
与 $display
或 $monitor
一起使用,以便在打印的消息中显示具体仿真时间。
下面是使用 $monitor
函数和 $time
的一个例子:
reg [4:0] a, b;
initial begin
a = 0;
b = 20;
repeat (10) begin
#10;
a = a + 2;
b = b - 2;
end
end
initial begin
$monitor("Time %0t: a = %d, b = %d\n", $time, a, b);
end
这段代码的输出结果为
Time 0: a = 0, b = 20
Time 10000: a = 2, b = 18
Time 20000: a = 4, b = 16
Time 30000: a = 6, b = 14
Time 40000: a = 8, b = 12
Time 50000: a = 10, b = 10
Time 60000: a = 12, b = 8
Time 70000: a = 14, b = 6
Time 80000: a = 16, b = 4
Time 90000: a = 18, b = 2
Time 100000: a = 20, b = 0
其中时间参数的设定为 `timescale 1ns / 1ps
。
休息一会儿
本部分内容到此结束!你理解了多少呢?
在本小节中,我们介绍了仿真文件的作用与编写方式。仿真文件是一种特殊的文件,它允许我们使用不可综合语句以更方便地模拟电路的运行状态,例如延时语句、事件控制语句、循环语句、系统函数等等。熟练使用这些语句可以帮助我们高效地进行模块测试,从而发现潜在的逻辑错误。