跳转至

编写测试文件

在 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
void Bubble_Sort (int *arr, int len) {
    int t;
    for (int i = 0; i < len - 1; ++i) {
        for (int j = 0; j < len - 1 - i; ++j) {
            if (arr[j] > arr[j + 1]) {
                t = arr[j + 1];
                arr[j + 1] = arr[j];
                arr[j] = t;
            }
        }
    }
}

写代码并保证其能正确排序的过程就是 Debug,而测试过程则对应着如下的思考:

  • len 的值为 -1 时能否正常运行;
  • 传入的 arr 大小和 len 不匹配时能否正常运行;
  • 传入的 arr 是空指针时能否正常运行;
  • ......

然而,在软件开发的过程中,我们很少涉及到测试的过程。一方面,我们的需求并不需要考虑这些奇奇怪怪的输入情况;另一方面,便捷高效的调试过程可以让我们立即发现并修复程序中可能的漏洞,把漏洞留给用户去上报再进行修复也是一种可接受的选择。

但是在硬件设计领域,一切都不同了。『你只能编写出自己能测试的模块』,一个自己设计但无力进行测试的复杂模块,往往也很难按照自己的预期进行工作。此外,硬件电路的工作状态是很难被我们获知的,因为芯片上没有 printf 函数,而为每一个元件连接一个显示器也不是什么好的选择。尽管一些芯片有内置的信息输出单元,但一方面其成本高昂,另一方面传输效率也十分低下。大多数情况下,摆在你面前的只有一个小小的、内部状态未知、工作不正常的芯片。

所以,我们选择在设计完成后引入 Testbench 进行测试,尽管编写 Testbench 的代价往往大于(甚至远大于)编写对应的待测试模块。如何编写高效的 Testbench 也成为了硬件开发中的重要一环。


2.1 ★ Testbench 基本结构

Testbench 由不可综合的 Verilog 代码组成,这些代码用于生成待测模块的输入,并验证待测模块的输出是否正确(是否符合预期)。下图展示了一个 Testbench 的基本架构。

Image title

其中:

  • 激励(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
module Module_tb ();

// 定义并产生激励信号
// ......

Test_module #(
    // 参数接口
) test (
    // 待测模块端口
);
endmodule

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
module test_tb ();
wire z;
reg a;
assign #2 z = ~a;

initial begin
    a = 1'b0;
    #3;
    a = 1'b1;
    #4;
    a = 1'b0;
    #1;
    a = 1'b1;
end
endmodule

得到的波形图如下图所示:

Image title

我们发现:变量 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
always @(a) begin
    temp = ~a;
    #2;
    z = temp;
end

这也体现出连续赋值与组合逻辑电路没有记忆性的特点。

为了明确在仿真期间所使用的时间单位,我们需要使用 `timescale 指令。其格式为:

`timescale <unit_time> / <resolution>

其中, <unit_time> 指定时间的单位,<resolution> 指定时间的精度,例如我们常常使用的

`timescale 1ns / 1ps

代表仿真的一个时间单位是 1ns,最小时间精度为 1ps。如果使用了 #1.1111; 指令,则最终的延迟为 1.111ns(四舍五入)。

注意

仿真时间单位和时间精度的数字只能是 1、10、100,不能为其它的数字。此外,时间精度应当不超过时间单位的大小。

除了直接控制延迟,Verilog 还支持基于事件的时间控制,这就是 @ 符号的作用。Verilog 有以下三种常用的事件控制方式:

  1. @ + 信号名,表示当信号发生逻辑变化时执行后面的内容。例如 @ (in) out = in;
  2. @ + posedge + 信号名,表示当信号从低电平变化到高电平时执行后面的内容。例如 @ (podedge in) out = in;
  3. @ + 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
module MyAND (
    input               a, b,
    output              o
);
assign o = a & b;
endmodule

位宽为 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
while 循环的中止条件是 condition 为假。如果一开始 condition 已经为假,那么循环内的语句将一次也不会执行。一个简单的例子如下:

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(十六进制)。

Image title

$display 函数支持的格式

下面是使用 $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


休息一会儿

本部分内容到此结束!你理解了多少呢?

在本小节中,我们介绍了仿真文件的作用与编写方式。仿真文件是一种特殊的文件,它允许我们使用不可综合语句以更方便地模拟电路的运行状态,例如延时语句、事件控制语句、循环语句、系统函数等等。熟练使用这些语句可以帮助我们高效地进行模块测试,从而发现潜在的逻辑错误。


最后更新: October 9, 2023

评论

Authors: wintermelon008