1 介绍
在 Verilog 代码设计完成后,我们还需要进行一项重要的步骤:仿真。一般来说,仿真阶段花费的时间会比设计花费的时间更多,因为需要根据各种可能的应用场景设计各式各样的样例,对应的代码编写也更加复杂。
Testbench 的中文释义是试验台、测试架、试样、试验工作台,TestBench 是用于硬件设计中验证和测试设计模块的仿真环境,它可以生成输入信号并模拟设计模块的输出,进而测试设计电路的功能、性能与设计的预期是否相符。
那么,为什么在硬件设计领域会需要 Testbench 呢?
首先,我们需要区分测试和 Debug 的概念。
- Debug 是程序开发阶段中消除逻辑错误的过程,它侧重于让程序能够正常运行,简答说就是写代码并保证其能正确功能的过程就是 Debug;
- 测试是编程完成后,测试程序的正确性的过程,它侧重于模拟尽可能多的输入情况,保证不同情况下程序都可以输出正确的结果。
那为啥在软件开发过程中,我们很少涉及到测试的过程呢?但在硬件设计领域需要着重编写测试文件呢?
软件领域中:一方面,我们的需求并不需要考虑那些奇奇怪怪的输入情况;另一方面,便捷高效的调试过程可以让我们立即发现并修复程序中可能的漏洞,把漏洞留给用户去上报再进行修复也是一种可接受的选择。
在硬件设计领域,一切都不同了。『你只能编写出自己能测试的模块』,一个自己设计但无力进行测试的复杂模块,往往也很难按照自己的预期进行工作。此外,硬件电路的工作状态是很难被我们获知的,因为芯片上没有
printf函数,而为每一个元件连接一个显示器也不是什么好的选择。尽管一些芯片有内置的信息输出单元,但一方面其成本高昂,另一方面传输效率也十分低下。大多数情况下,摆在你面前的只有一个小小的、内部状态未知、工作不正常的芯片。所以,我们选择在设计完成后引入Testbench 进行测试,尽管编写 Testbench 的代价往往大于(甚至远大于)编写对应的待测试模块。如何编写高效的 Testbench 也成为了硬件开发中的重要一环。
2 Testbench 基本结构
Testbench 由不可综合的 Verilog 代码组成,这些代码用于生成待测模块的输入,并验证待测模块的输出是否正确(是否符合预期)。下图展示了一个 Testbench 的基本架构。

- 激励(Stimulus Block)是专门为待测模块生成的输入。我们需要尽可能产生全面的测试场景,包括合法的和不合法的。
- 输出校验(Output Checker)用于检查被测模块的输出是否符合预期。
- 被测模块(Design Under Test, DUT。也称 Unit Under Test, UUT)是我们编写的 Verilog 模块,Testbench 的主要目的就是对其进行验证,以确保在特定输入下其输出均与预期一致。
编写 Testbench 的第一步是创建一个测试用的顶层模块。与正常设计时的 Verilog module 不同,用于测试的模块应当没有输入和输出,这是因为 Testbench 模块应当是完全独立的,不受外部信号的干扰。接下来第二步,我们需要例化待测模块,将信号连接到待测模块以允许激励代码运行。这些信号包括时钟信号和复位信号,以及传入 Testbench 的测试数据。
3 时序控制
与我们正常的设计代码不同,Testbench 中的代码并不需要被综合成实际电路,为此可以使用一些不可综合的语句,例如时序控制语句。
为什么会有不可综合语句呢?下面是另一个有趣的笑话:
《硬件延迟》:某段程序需要延迟 10s 执行。硬件工程师表示:这个需求很简单,只需要一段 75 倍赤道周长的导线就可以完成了。因为c*10s=75*4*10的7次方。
75 倍赤道周长的导线显然是一个笑话,但这也带给我们一个问题:如何用硬件电路实现长时间的延迟呢?如果让信号通过多个逻辑门,一方面会带来巨量的资源开销,另一方面综合时也极有可能被优化掉。一种比较好的办法是使用计数器搭配时钟,每个时钟上升沿更新计数器,直到达到一定数值后再进行信号传递。
综合是指将 HDL 转换成较低层次电路结构的过程,包括查找表 LUT、触发器、RAM 等。有一些 Verilog 语法结构无法与这些电路结构对应,因此就产生了不可综合语句。例如,除了延迟语句外,循环次数不确定的循环语句也是不可综合的,
Verilog 中允许我们模拟两种不同的延时:惯性延时和传输延时。
- 惯性延迟:逻辑门或电路由于其物理特性而可能经历的延迟;
- 传输延迟:电路中信号的『飞行』时间。
Verilog 使用 # 字符加上时间单位来模拟延时。例如 #10;表示延迟 10 个时间单位后再执行之后的语句,对应着传输延迟。
惯性延迟将延时语句写在与赋值相同的代码行中,这代表信号在延迟时间之后开始变化。例如:
wire Z, A, B;
assign #10 Z = A & B; // A&B 的计算结果延时 10 个时间单位后赋值给 Z
上面的说法 比 “延时10个时间单位后进行A&B 的计算并将结果赋值给Z” 这样的说话更加合理。
因为上面代码体现的是惯性延迟,定义是电信号经过逻辑门的延迟。
如果换成错误的说法,那么它体现的就是传输延迟了,因为在A和B在进入与门之前进行了延时,进入与门后的计算结果又立马赋值给了Z,这种说法与传输延时相对应。
上面的代码中,A 或 B 任意一个变量发生变化,都会让 Z 在 10 个时间单位的延迟后得到新的值。如果在这 10 个时间单位内,A 或 B 中的任意一个又发生了变化,那么最终 Z 的新值会取 A 或 B 当前的新值进行计算。
惯性延迟的特性:
-
当输入信号变化时,输出变化会被调度在延迟时间后生效。
-
如果在延迟时间内输入信号再次变化,则之前调度的输出变化会被取消,只保留最后一次输入变化对应的输出事件。(逻辑电路没有存储功能,当前的输出由当前输入决定。这里只是延迟,并不是存储)
用一个具体的例子向阐述惯性延时的概念。考虑下面这条语句(假定时间单位为 ns):
assign #2 z = ~a;这条语句描述了一个延迟为 2ns 的非门,从输入端输入到输出端输出结果之间间隔了 2ns,且任何小于 2ns 的信号脉冲都会被滤除。后面那句话怎么理解呢?考虑如下的仿真语句:
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得到的波形图如下图所示:
我们发现:变量
a第二次变为 0 时,由于仅持续了 1ns 就变回了 1,变量z并没有捕捉到这一变化。
0ns:
a = 0→ 调度z在 2ns时变为1。3ns:
a = 1→ 调度z在 5ns时变为0。7ns:
a = 0→ 调度z在 9ns时变为1。8ns:
a = 1→ 调度z在 10ns时变为0。分析:上述3和4时,在8ns时(仅1ns后),
a又变为1。由于1ns < 2ns的延迟:原调度事件(9ns的z = 1)被取消。新调度事件(10ns的z = 0)生效。我们可以从多个角度分析该现象的逻辑合理性:
从功能角度:
抗噪声:这类似于实际电路中信号的“去抖动”机制,过滤由信号抖动、开关噪声引起的短脉冲(毛刺),确保短暂的噪声或毛刺不会影响输出。
从实际电路角度:
物理本质:能量传递的惰性
- 电容充放电:电路中的导线和逻辑门存在寄生电容(例如 CMOS 门电路的输入电容)。当输入信号变化时,电容需要一定时间充电或放电,才能达到稳定的电压阈值。
- RC 时间常数:信号变化的速度由电阻(R)和电容(C)的乘积(RC 时间常数)决定。若输入脉冲宽度小于 RC 时间常数,电容无法完成充放电,导致输出无法响应。
鄙人的简单看法,电容充电时间过段,还没来得及充完电,就反向电压要输出了。输入变化太快,输出可能来不及反应,从而过滤掉短脉冲。
从代码逻辑角度:
原因是:
assign #2 z = ~a;可以拆成两部分:
- 首先是立即执行的
assign temp = ~a ;- 其次是延时两个时间单位执行的
assign z = temp 。在这两个时间单位内,对变量
a的改变将直接反应在变量temp上而非输出信号z上(temp会被新的值替换掉),所以上面的波形图中没有显示,z并不会察觉到这一变化,也就不会做出相应的改变。我们可以用如下的 always 语句进行替代:
always @(a) begin temp = ~a; #2; z = temp; end这也体现出连续赋值与组合逻辑电路没有记忆性的特点。还记得 assign 语句的特点吗?答:assign语句是连续赋值,意味着当右边的表达式中的任何信号变化时,左边会被重新计算并赋值。这里的#2表示当a发生变化时,z的变化会在2ns之后生效。也就是说,当a变化后,z不会立即变化,而是等待2ns才更新。
除了直接控制延迟,Verilog 还支持基于事件的时间控制,这就是 @ 符号的作用。Verilog 有以下三种常用的事件控制方式:
@+ 信号名,表示当信号发生逻辑变化时执行后面的内容。例如@ (in) out = in;@+posedge+ 信号名,表示当信号从低电平变化到高电平时执行后面的内容。例如@ (podedge in) out = in;@+negedge+ 信号名,表示当信号从高电平变化到低电平时执行后面的内容。例如@ (negedge in) out = in;
这也印证了 always 语句的敏感变量语法。
4 initial 与 always
initial 块中编写的任何代码都会在开始时执行,但仅执行一次,而 always 块则会循环执行内部的代码。与 always 块不同,在 initial 块中编写的 Verilog 代码几乎都是不可综合的,因此基本上只被用于仿真与初始化信号。
为了更好地理解 initial 块与 always 块在 Testbench 中的使用,我们来看一个例子。
例子:两输入与门的测试
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 块之间都是并行执行的。
5 循环
Verilog 中常见的循环语句分别是:while、for、repeat 和 forever 循环。
循环语句只能在 always 或 initial 块中使用,其内部也可以包含延迟表达式。不定次数的循环:在仿真中我们可以使用不可综合语句,也就是不确定循环次数的循环语句。
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。
For
for (initial_assignment; condition; step_assignment) begin
......
end
其中,initial_assignment 为初始条件。condition 为循环条件,为假时立即跳出循环。step_assignment 为改变控制变量的过程赋值语句,通常为增加或减少循环变量的值。一般来说,因为初始条件和自加操作等过程都已经包含在 for 循环的头部,所以 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中,
integer类型变量的默认位宽是32位,且默认是有符号数(采用二进制补码表示)。其取值范围为 -2³¹ 到 2³¹–1(即 -2147483648 到 2147483647)。integer主要用于仿真环境中的数学计算(如循环计数器、临时变量等),而非直接映射到硬件逻辑,32位宽度可满足大多数仿真场景的整数运算需求。(根据标准,integer类型是预定义的,不允许用户修改其位宽)。如果需要短位宽的有符号数,可以使用
reg或wire类型,并显式声明位宽和符号属性:module example; reg signed [7:0] a = -8'd10; // 赋值为 -10 reg signed [7:0] b = 8'd100; // 赋值为 100 reg signed [8:0] sum; // 扩展1位防止溢出 initial begin sum = a + b; // 结果为 90(二进制补码计算) $display("sum = %d", sum); // 输出 sum = 90 end endmodule
Repeat
repeat (loop_times) begin
......
end
repeat 语句的功能是执行固定次数的循环,它不能像 while 循环那样用一个逻辑表达式来确定循环是否继续执行。
repeat 循环的次数必须是一个常量、变量或信号。如果循环次数是变量信号,那么循环次数是开始执行 repeat 循环时变量信号的值。即便执行期间循环次数代表的变量信号值发生了变化,repeat 循环的执行次数也不会改变。
reg [3:0] counter;
initial begin
counter = 'b0;
repeat (11) begin
#10;
counter = counter + 1'b1;
end
end
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
注意:除非你清楚自己在做什么,否则请不要轻易地在设计文件中使用循环语句。
6 系统任务
在 Verilog 中编写 Testbench 时,有一些内置的任务和函数可以为我们提供帮助。它们总是以美元符号 $ 开头,被统称为系统任务或系统函数。其中,下面三个是最常用的系统函数:$display、$monitor 和 $time。
$display
它允许我们在控制台上输出一条消息。该函数的使用方式与 C 语言中的 printf 函数非常类似,这意味着我们可以轻松地在 Testbench 中创建文本语句,并使用它们来显示有关仿真状态的信息。
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 中的特定信号。这些信号中的任何一个改变状态,都会在终端打印一条消息。
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 一起使用,以便在打印的消息中显示具体仿真时间。
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
这段代码的输出结果为: //其中时间参数的设定为 `timescale 1ns / 1ps。
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
在本小节中,我们介绍了仿真文件的作用与编写方式。仿真文件是一种特殊的文件,它允许我们使用不可综合语句以更方便地模拟电路的运行状态,例如延时语句、事件控制语句、循环语句、系统函数等等。熟练使用这些语句可以帮助我们高效地进行模块测试,从而发现潜在的逻辑错误。


2万+

被折叠的 条评论
为什么被折叠?



