4. Testbench 编写入门:结构、时钟复位与 initial/always
说实话,很多刚入行的朋友觉得 Testbench 就是随便写点激励,跑个波形看看。嗯,我当年也这么想。直到有一次,我写了一个自以为完美的 Testbench,结果仿真跑了两天,发现 bug 是因为时钟没对齐——那种感觉,真的不想再体验第二次。
Testbench 说白了,就是给 DUT(Design Under Test)搭一个“测试台”。你想想看,芯片设计就像造一块手表,你不能直接把手表扔水里测防水吧?你得先做个测试环境。Testbench 就是这个环境。
4.1 Testbench 的基本结构
一个标准的 Testbench 长什么样?我习惯把它分成几个模块:
| 组成部分 | 作用 | 我的建议 |
|---|---|---|
| 信号声明 | 定义 DUT 的输入/输出信号 | 尽量用 reg 和 wire 区分清楚 |
| DUT 实例化 | 把被测模块“挂”到 Testbench 上 | 用名字连接,别用位置连接 |
| 时钟生成 | 产生系统时钟 | 用 always 块,别用 initial 循环 |
| 复位生成 | 产生复位信号 | 同步复位和异步复位要区分 |
| 激励生成 | 产生测试输入 | 用 initial 块组织测试序列 |
| 结果检查 | 自动比对输出 | 能用 $display 就别手动看波形 |
来看一个最简单的例子:
module tb_counter();
// 1. 信号声明
reg clk;
reg rst_n;
reg en;
wire [3:0] count;
// 2. DUT 实例化
counter u_counter (
.clk (clk),
.rst_n (rst_n),
.en (en),
.count (count)
);
// 3. 时钟生成
always #5 clk = ~clk;
// 4. 复位生成
initial begin
clk = 0;
rst_n = 0;
en = 0;
#20;
rst_n = 1;
#10;
en = 1;
end
// 5. 结果检查
always @(posedge clk) begin
if (en)
$display("Time=%0t, count=%d", $time, count);
end
endmodule
4.2 时钟生成——别小看这个 always
时钟生成是 Testbench 里最基础也最容易出错的地方。我见过有人用 initial 加 forever 循环来生成时钟:
// 不推荐的做法
initial begin
clk = 0;
forever #5 clk = ~clk;
end
这样写其实也能跑,但有个问题——如果你在同一个 initial 块里还有其他逻辑,forever 会卡住后面的代码。我个人习惯用 always 块:
// 推荐的做法
always #5 clk = ~clk;
为什么?因为 always 块天生就是循环执行的,而且不会阻塞其他 initial 块。你想想看,一个 always 块只干一件事——翻转时钟,多清爽。
如果你需要多个时钟域,比如一个 100MHz 和一个 50MHz:
reg clk_100m, clk_50m;
always #5 clk_100m = ~clk_100m; // 100MHz
always #10 clk_50m = ~clk_50m; // 50MHz
4.3 复位生成——同步还是异步?
复位信号怎么生成?这取决于你的 DUT 用的是同步复位还是异步复位。我建议 Testbench 里两种都支持:
// 异步复位
initial begin
rst_n = 0;
#100;
rst_n = 1;
end
// 同步复位(需要时钟配合)
initial begin
rst_n = 0;
repeat (5) @(posedge clk);
rst_n = 1;
end
我个人更倾向于异步复位。为什么?因为仿真刚开始时,时钟可能还没稳定。异步复位能保证 DUT 在时钟稳定前就进入复位状态。你想想看,如果时钟还没起来,同步复位根本没法生效。
还有一个细节:复位释放的时间点。我习惯在时钟上升沿之后释放复位,避免建立时间问题:
initial begin
rst_n = 0;
#100;
@(posedge clk); // 等一个时钟沿
#1; // 再等 1ns,避开时钟沿
rst_n = 1;
end
4.4 initial 与 always——两个核心块的区别
initial 和 always 是 Verilog 里最常用的两个过程块。它们的区别其实很简单:
| 特性 | initial | always |
|---|---|---|
| 执行次数 | 只执行一次 | 循环执行 |
| 典型用途 | 初始化、测试序列 | 时钟生成、组合逻辑、时序逻辑 |
| 能否综合 | 不能 | 可以(部分写法) |
| 仿真行为 | 0 时刻开始,执行完就结束 | 0 时刻开始,永远不结束 |
在 Testbench 里,我通常这样分配:
- initial 块:用来写测试序列。比如先复位,再等一段时间,然后发激励,最后检查结果。
- always 块:用来生成时钟,或者监控某些信号的变化。
来看一个完整的例子:
module tb_fsm();
reg clk, rst_n, in;
wire out;
fsm u_fsm (.clk(clk), .rst_n(rst_n), .in(in), .out(out));
// 时钟生成 —— always 块
always #5 clk = ~clk;
// 测试序列 —— initial 块
initial begin
clk = 0;
rst_n = 0;
in = 0;
#20;
rst_n = 1;
#10;
in = 1;
#10;
in = 0;
#10;
in = 1;
#20;
$finish;
end
// 监控输出 —— always 块
always @(posedge clk) begin
if (out)
$display("Time=%0t: out goes high", $time);
end
endmodule
4.5 知识结构图
下面这张图总结了 Testbench 的核心结构:
4.6 实战小技巧
最后分享几个我在项目中积累的小技巧:
- 用 `timescale 指定时间单位:在 Testbench 文件开头加上
`timescale 1ns/1ps,这样 #5 就是 5ns。 - 用 $time 打印仿真时间:调试时特别有用,能精确知道每个事件发生的时间点。
- 用 $finish 结束仿真:别让仿真一直跑下去,在测试序列最后加上
$finish。 - 用 $display 和 $monitor:$display 在事件发生时打印,$monitor 在信号变化时自动打印。
好了,Testbench 的基础结构就讲到这里。记住:时钟用 always,复位用 initial,激励用 initial,检查用 always。这个套路我用了十年,从来没出过问题。