编写Testbench

HDLBits链接


前言

恭喜!我们终于来到了HDLBits系列的最后一章——编写Testbench!这也是非常重要的一章,因为在实际的工程开发中,写完代码只是第一步,更重要的是通过仿真来验证代码的功能是否正确。

什么是Testbench? Testbench(测试平台)就是一段专门用来测试设计代码的程序。它会给我们的设计输入激励信号,然后观察输出是否符合预期。

为什么我们需要Testbench? 想象一下,如果你设计了一个复杂的CPU芯片,送去工厂流片后,发现芯片有BUG,那损失可就太大了!所以在流片之前,我们必须通过仿真来确保设计的正确性。Testbench就是我们进行仿真验证的工具。

生活实例类比:Testbench就像是一个产品测试员。设计代码是生产线上下来的产品,Testbench会给产品输入各种测试信号(比如按按键、通电、断电),然后检查产品的输出是否符合预期(比如屏幕亮不亮、声音对不对)。

这一章我们会从简单的时钟生成开始,逐步学习如何编写完整的Testbench。让我们开始吧!


题库

Clock

题目理解

首先我们来看第一道题:生成一个周期为10ps的时钟,初始值为0。

题目提供了一个待测试的模块(DUT,Device Under Test):

1
module dut ( input clk ) ;

我们需要做的就是在Testbench中生成一个符合要求的时钟信号,然后连接到这个dut模块。

什么是时钟信号? 时钟信号就像电路的心跳,它以固定的频率在0和1之间跳变。所有的时序电路都是在时钟的节拍下工作的。

时钟周期:时钟信号从0变1,再变0,所需要的时间就是一个时钟周期。题目要求周期是10ps,也就是说:

  • 从0到1:5ps
  • 从1到0:5ps
  • 整个周期:10ps

1

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module top_module ();

// 声明时钟信号,类型是reg(因为我们要在initial或always块中给它赋值)
reg clk;

// initial块:只执行一次,用来初始化信号
initial begin
clk = 1'b0; // 初始值为0
end

// always块:循环执行,用来生成时钟
// #5表示延迟5个时间单位
always #5 clk = ~clk;

// 实例化待测试模块(DUT)
dut u0 (
.clk(clk)
);

endmodule

要点小结

  • Testbench通常是一个没有输入输出端口的模块(顶层模块)
  • 待测试的输入信号声明为reg类型,因为我们要在always或initial块中给它赋值
  • 待测试的输出信号声明为wire类型,因为它是由DUT驱动的
  • initial块只在仿真开始时执行一次,通常用来初始化信号
  • always块会循环执行,通常用来生成时钟
  • #n表示延迟n个时间单位

Testbench 1

题目理解

第二道题:生成如下图所示的A、B激励信号。

2

让我们分析一下波形:

  • 时间0:A=0,B=0
  • 时间10:A=1,B=0
  • 时间15:A=1,B=1
  • 时间20:A=0,B=1
  • 时间40:A=0,B=0

注意:这里的时间单位是任意的,因为题目没有指定,我们只要按比例写对就行。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
module top_module (
output reg A, // 注意:这里A和B是输出,因为这是一个产生激励的模块
output reg B
);

// initial块:生成激励波形
initial begin
// 时间0:初始化
A = 1'b0;
B = 1'b0;

// 延迟10个时间单位
#10;
A = 1'b1; // 时间10:A变1

// 延迟5个时间单位
#5;
B = 1'b1; // 时间15:B变1

// 延迟5个时间单位
#5;
A = 1'b0; // 时间20:A变0

// 延迟20个时间单位
#20;
B = 1'b0; // 时间40:B变0
end

endmodule

要点小结

  • 生成激励波形主要使用initial块和延迟控制#n
  • 信号的变化是按顺序执行的:先执行上面的,再执行下面的
  • 延迟是相对于上一条语句的时间,不是绝对时间
  • 可以把多个操作写在同一个延迟后面:
    1
    #10 A = 1'b1;  // 延迟10,然后A变1

AND gate

题目理解

第三道题:写测试激励来测试一个与门模块。

3

提供的AND模块声明如下:

1
2
3
4
module andgate (
input [1:0] in, // 2位输入
output out // 输出
);

注意输入是2位的in,不是两个单独的输入。我们需要生成in的激励波形,然后观察out是否符合与门的行为。

从波形图可以看出:

  • 时间0:in[0]=0, in[1]=0 → out=0
  • 时间10:in[0]=1, in[1]=0 → out=0
  • 时间20:in[0]=0, in[1]=1 → out=0
  • 时间30:in[0]=1, in[1]=1 → out=1

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
module top_module();

// 声明激励信号
reg in_0; // 对应in[0]
reg in_1; // 对应in[1]
// 声明输出信号,用来观察DUT的输出
wire out;

// initial块:生成激励
initial begin
// 时间0
in_0 = 1'b0;
in_1 = 1'b0;

// 时间10
#10;
in_0 = 1'b1;
in_1 = 1'b0;

// 时间20
#10;
in_0 = 1'b0;
in_1 = 1'b1;

// 时间30
#10;
in_0 = 1'b1;
in_1 = 1'b1;
end

// 实例化与门模块
// 注意:使用拼接运算符{}把in_1和in_0拼成2位信号
andgate u0 (
.in({in_1, in_0}), // 高位在前,低位在后
.out(out)
);

endmodule

要点小结

  • 拼接运算符{}可以把多个信号拼在一起:{in_1, in_0}表示把in_1放在高位,in_0放在低位
  • Testbench的结构通常是:
    1. 声明激励信号(reg类型)
    2. 声明观察信号(wire类型)
    3. 在initial块中生成激励
    4. 实例化DUT,连接信号

Testbench 2

题目理解

第四道题:生成如下图波形图所示的激励信号,来测试模块q7。

4

模块q7的描述如下:

1
2
3
4
5
6
module q7 (
input clk,
input in,
input [2:0] s,
output out
);

这道题稍微复杂一点,因为它有时钟信号。我们需要:

  1. 生成时钟信号
  2. 生成ins的激励信号
  3. 观察out的输出

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
module top_module();

// 声明激励信号
reg clk;
reg in;
reg [2:0] s;
// 声明观察信号
wire out;

// 1. 生成时钟信号
initial begin
clk = 1'b0;
end
always #5 clk = ~clk; // 周期10个时间单位

// 2. 生成其他激励信号
initial begin
// 时间0
in = 1'b0;
s = 3'd2;

// 时间10
#10;
s = 3'd6;

// 时间20
#10;
s = 3'd2;
in = 1'b1;

// 时间30
#10;
s = 3'd7;
in = 1'b0;

// 时间40
#10;
s = 3'd0;
in = 1'b1;

// 时间70
#30;
in = 1'b0;
end

// 3. 实例化DUT
q7 u0 (
.clk(clk),
.in(in),
.s(s),
.out(out)
);

endmodule

要点小结

  • 时钟信号的生成和其他激励信号是并行执行
  • initial块和always块是并行的,它们同时开始执行
  • 多个initial块也是并行的
  • 生成时钟用单独的initial+always块,生成其他激励用另一个initial

T flip-flop

题目理解

最后一道题:测试一个T触发器。题目给出了T触发器的模块,我们需要:

  1. 生成时钟信号
  2. 生成复位信号,先复位,然后释放复位
  3. 生成T信号,让触发器翻转
  4. 观察输出q

什么是T触发器? T触发器是翻转触发器:

  • 当T=1时,每个时钟沿输出都会翻转(0变1,1变0)
  • 当T=0时,输出保持不变

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
module top_module ();

// 声明激励信号
reg clk;
reg reset;
reg t;
// 声明观察信号
wire q;

// 实例化T触发器
tff u0 (
.clk(clk),
.reset(reset),
.t(t),
.q(q)
);

// 1. 生成时钟
initial begin
clk = 1'b0;
end
always #5 clk = ~clk; // 周期10个时间单位

// 2. 生成复位和T信号
initial begin
// 时间0
reset = 1'b0;
t = 1'b0;

// 时间3
#3;
reset = 1'b1; // 复位拉高

// 时间13
#10;
reset = 1'b0; // 复位拉低,释放复位
end

// 3. 生成T信号:复位释放后,T保持为1
always @(posedge clk) begin
if (reset) begin
t <= 1'b0;
end
else begin
t <= 1'b1; // 复位释放后,T一直为1,让q持续翻转
end
end

endmodule

要点小结

  • 这道题使用了三种不同的过程块:
    1. initial块生成时钟
    2. initial块生成复位信号
    3. always @(posedge clk)块生成T信号
  • 这些块都是并行执行的
  • 实际的Testbench中,我们通常还会加入$display$monitor等系统任务来打印信息,方便观察

入门者避坑指南

编写Testbench时,初学者很容易犯一些错误。下面我总结了最常见的5个问题:

坑点1:信号类型声明错误

错误表现

1
2
3
4
5
// 错误示例
wire clk; // 应该是reg
initial begin
clk = 0; // wire类型不能在initial块中赋值!
end

错误原因

  • 混淆了reg和wire的用法
  • reg类型才能在initial或always块中赋值
  • wire类型只能由连续赋值assign或模块输出驱动

正确做法

1
2
3
4
5
6
7
8
// 正确示例
reg clk; // Testbench中的激励信号声明为reg
initial begin
clk = 1'b0;
end

wire out; // DUT的输出声明为wire
andgate u0 (.out(out), ...);

调试技巧

  • 记住这个简单的规则:
    • Testbench中,你要赋值的信号(激励)→ reg
    • Testbench中,被DUT驱动的信号(观察)→ wire

坑点2:忘记生成时钟或时钟周期错误

错误表现

1
2
3
4
5
6
7
8
// 错误示例1:只初始化了时钟,没有翻转
initial begin
clk = 0;
end
// 缺少always块来翻转时钟!

// 错误示例2:时钟周期不对
always #10 clk = ~clk; // 题目要求周期10ps,这样写周期是20ps!

错误原因

  • 忘记写always块来翻转时钟
  • 没有计算好延迟时间:#n是半周期,整个周期是2*n

正确做法

1
2
3
4
5
// 正确示例:周期10ps(半周期5ps)
initial begin
clk = 1'b0;
end
always #5 clk = ~clk; // 半周期5ps,整个周期10ps

调试技巧

  • 时钟周期 = 2 × 半周期延迟
  • 写完代码后,在仿真中看一下时钟波形,确认周期是否正确

坑点3:激励信号的时间顺序错误

错误表现

1
2
3
4
5
6
// 错误示例:时间顺序搞反了
initial begin
#10 a = 1; // 先延迟10
a = 0; // 然后立即变0(没有延迟!)
#5 b = 1;
end

错误原因

  • 没有理解延迟的含义:#n是延迟n个时间单位再执行后面的语句
  • 如果没有延迟,语句会立即执行

正确做法

1
2
3
4
5
6
7
8
9
// 正确示例
initial begin
a = 0; // 时间0
b = 0;
#10; // 延迟10
a = 1; // 时间10
#5; // 延迟5
b = 1; // 时间15
end

调试技巧

  • 每写一个激励变化,就加一个延迟
  • 可以画一个简单的时间轴,标注每个信号变化的时间点

坑点4:在initial块中使用非阻塞赋值

错误表现

1
2
3
4
5
6
7
// 错误示例
initial begin
clk <= 0; // 应该用阻塞赋值=
a <= 0;
#10;
a <= 1;
end

错误原因

  • 混淆了阻塞赋值和非阻塞赋值的使用场景
  • Testbench的initial块中通常使用阻塞赋值=
  • 非阻塞赋值<=主要用在设计代码的时序逻辑中

正确做法

1
2
3
4
5
6
7
// 正确示例
initial begin
clk = 1'b0; // 阻塞赋值
a = 1'b0;
#10;
a = 1'b1;
end

调试技巧

  • Testbench的initial块中:用=
  • 设计代码的时序逻辑中:用<=
  • 设计代码的组合逻辑中:用=

坑点5:忘记实例化DUT或连接错误

错误表现

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误示例1:忘记实例化DUT!
reg clk;
initial begin
clk = 0;
end
always #5 clk = ~clk;
// 缺少dut的实例化!

// 错误示例2:端口连接错误
dut u0 (
.clk(a), // 连接错了,应该是clk
.a(clk)
);

错误原因

  • 忘记写实例化语句
  • 实例化时端口连接错误

正确做法

1
2
3
4
5
6
7
// 正确示例
dut u0 (
.clk(clk), // 使用命名连接,不容易出错
.a(a),
.b(b),
.out(out)
);

调试技巧

  • 实例化时使用命名连接.port(signal)),不要使用位置连接
  • 写完后检查一下:每个DUT的输入是否都连接到了Testbench的reg信号
  • 检查一下:每个DUT的输出是否都连接到了Testbench的wire信号

结语

恭喜!HDLBits系列总算是更新结束了!非常感谢HDLBits网站的作者,为我们提供了这么好的学习资源。

回顾整个HDLBits系列,我们学习了:

  1. Verilog语言基础(向量、模块、过程块等)
  2. 组合逻辑电路(基本门、多路选择器、加法器等)
  3. 时序逻辑电路(触发器、计数器、移位寄存器等)
  4. 有限状态机FSM
  5. 调试和找BUG
  6. 从波形图反推电路
  7. 编写Testbench进行仿真验证

笔者认为,学习Verilog和数字电路设计,关键在于多练习。HDLBits就是一个非常好的练习平台,它的题目由浅入深,覆盖了数字电路设计的各个方面。

最后,感谢大家的阅读!如果代码有错误的地方,欢迎大家在评论区指正。祝大家学习进步!