前言
这篇文章不讲空概念,直接按工程里最常见的写法,带你从 0 写一个 Verilog testbench。重点包括:
testbench 的基本结构
时钟
clk怎么产生复位
rst_n怎么写输入激励怎么对齐时钟
怎么用 task 简化重复输入
怎么检查输出结果
新人常见 testbench 坑点
适合 FPGA 应届生、研二研三学生、入行 1 年以内的新人工程师阅读。
一、testbench 是什么?
简单说,testbench 就是用来“喂输入、看输出”的仿真文件。
你写了一个 Verilog 模块,比如计数器、状态机、FIFO 控制逻辑、SPI 接收模块。
这个模块本身不能自己跑起来,因为它需要时钟、复位和输入信号。
testbench 的作用就是:
产生时钟;
产生复位;
给 DUT 输入激励;
等待 DUT 运行;
观察输出波形;
必要时自动判断结果对不对。
这里的 DUT 是 Design Under Test,也就是“被测试模块”。
比如你写了一个模块叫acc8,那 testbench 就负责实例化它,然后给它输入clk、rst_n、en、din,最后看它的输出sum是否符合预期。
二、一个 testbench 通常长什么样?
一个最基础的 Verilog testbench 一般包含下面几部分:
`timescale 1ns/1ps module tb_xxx; // 1. 定义参数 parameter CLK_PERIOD = 10; // 2. 声明 testbench 内部信号 reg clk; reg rst_n; reg input_signal; wire output_signal; // 3. 例化 DUT xxx u_xxx ( .clk(clk), .rst_n(rst_n), .input_signal(input_signal), .output_signal(output_signal) ); // 4. 产生时钟 initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk; // 5. 产生复位和输入激励 initial begin rst_n = 1'b0; input_signal = 1'b0; #(CLK_PERIOD * 5); rst_n = 1'b1; // 后面写输入激励 #(CLK_PERIOD * 20); $finish; end endmodule新人先记住一句话:
testbench 只能用于仿真,不要把 initial、$display 等写入 RTL 模块。
所以 testbench 里面可以写initial、#10、$display、$finish这些语句。
这些语句在 RTL 设计模块里通常不能乱写,但在 testbench 里很常见。
三、先准备一个被测试模块 DUT
为了方便讲清楚,我们先写一个很简单的模块:acc8。
功能是:
低电平复位;
当
en = 1时,把输入din累加到sum;当
en = 0时,sum保持不变。
代码如下:
module acc8 ( input clk, input rst_n, input en, input [7:0] din, output reg [15:0] sum ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sum <= 16'd0; end else if (en) begin sum <= sum + din; end else begin sum <= sum; end end endmodule这个模块很简单,但足够说明 testbench 的核心写法。
四、第一步:写 testbench 外壳
testbench 本身不需要输入输出端口,所以模块声明一般是空的:
module tb_acc8; endmodule然后加上仿真时间单位:
`timescale 1ns/1ps完整开头如下:
`timescale 1ns/1ps module tb_acc8; endmoduletimescale 1ns/1ps的意思是:
时间单位是 1ns;
时间精度是 1ps。
也就是说,代码里的#10表示延时 10ns。
五、第二步:声明 testbench 里的信号
DUT 的输入,在 testbench 里一般声明为reg。
DUT 的输出,在 testbench 里一般声明为wire。
因为输入信号要在 testbench 里被赋值,比如clk = 0、rst_n = 1、din = 8'h12,所以用reg。
输出信号由 DUT 驱动,所以用wire。
parameter CLK_PERIOD = 10; // 10ns,对应 100MHz 时钟 reg clk; reg rst_n; reg en; reg [7:0] din; wire [15:0] sum;这里CLK_PERIOD = 10,表示一个时钟周期 10ns,也就是 100MHz。
六、第三步:例化 DUT
testbench 要把被测模块接进来:
acc8 u_acc8 ( .clk (clk), .rst_n (rst_n), .en (en), .din (din), .sum (sum) );这里推荐使用“按端口名连接”的方式,也就是.clk(clk)这种写法。
不建议新人用下面这种位置连接方式:
acc8 u_acc8 ( clk, rst_n, en, din, sum );位置连接虽然代码短,但模块端口一多,很容易接错,而且接错后不一定马上报错。
工程里更推荐端口名连接。
七、第四步:产生时钟
时钟是 testbench 里最基础的东西。
常见写法如下:
initial begin clk = 1'b0; end always #(CLK_PERIOD/2) begin clk = ~clk; end也可以写得更短:
initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk;如果CLK_PERIOD = 10,那么:
always #(CLK_PERIOD/2) clk = ~clk;等价于:
always #5 clk = ~clk;意思是每 5ns 翻转一次,完整周期就是 10ns。
八、第五步:产生复位
复位一般有两类:
高电平复位:
rst = 1表示复位;低电平复位:
rst_n = 0表示复位。
工程里rst_n这个名字通常表示低电平有效复位。
后缀_n可以理解为 negative,表示低有效。
本文的 DUT 使用低电平复位:
always @(posedge clk or negedge rst_n)所以 testbench 里要先把rst_n拉低,然后过几个时钟周期后释放:
initial begin rst_n = 1'b0; en = 1'b0; din = 8'd0; #(CLK_PERIOD * 5); rst_n = 1'b1; end这段代码表示:
仿真一开始处于复位状态;
复位保持 5 个时钟周期;
然后释放复位,模块开始正常工作。
工程里不建议复位刚开始就释放,比如:
rst_n = 1'b0; #1; rst_n = 1'b1;这太短了,很多问题看不出来。
一般至少保持几个时钟周期,写清楚一点更稳。
九、第六步:输入激励不要乱给,最好对齐时钟
很多新人写输入激励喜欢这样写:
#13 din = 8'd5; #7 en = 1'b1; #9 en = 1'b0;这种写法不是不能跑,但很容易把自己绕晕。
因为输入信号没有对齐时钟边沿,波形看起来会很乱。
对于同步逻辑,推荐在时钟的负边沿改变输入,在下一个正边沿让 DUT 采样。
例如:
@(negedge clk); en = 1'b1; din = 8'd5; @(negedge clk); en = 1'b0; din = 8'd0;为什么选择负边沿改输入?
因为 DUT 一般在正边沿采样:
always @(posedge clk)testbench 在负边沿改变输入,等到下一个正边沿时,输入信号已经稳定了半个周期。
这样波形清晰,也更接近真实工程中的时序习惯。
十、先写一个最简单的 testbench
下面是第一版完整 testbench:
`timescale 1ns/1ps module tb_acc8; parameter CLK_PERIOD = 10; reg clk; reg rst_n; reg en; reg [7:0] din; wire [15:0] sum; acc8 u_acc8 ( .clk (clk), .rst_n (rst_n), .en (en), .din (din), .sum (sum) ); // 产生时钟 initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk; // 产生复位和输入激励 initial begin rst_n = 1'b0; en = 1'b0; din = 8'd0; // 复位保持 5 个周期 #(CLK_PERIOD * 5); rst_n = 1'b1; // 等待 2 个周期 #(CLK_PERIOD * 2); // 第一次输入:sum = 0 + 5 @(negedge clk); en = 1'b1; din = 8'd5; @(negedge clk); en = 1'b0; din = 8'd0; // 第二次输入:sum = 5 + 10 @(negedge clk); en = 1'b1; din = 8'd10; @(negedge clk); en = 1'b0; din = 8'd0; // 第三次输入:sum = 15 + 20 @(negedge clk); en = 1'b1; din = 8'd20; @(negedge clk); en = 1'b0; din = 8'd0; #(CLK_PERIOD * 10); $finish; end endmodule这份代码已经可以仿真。
仿真时应该看到:
复位期间
sum = 0;第一次
en = 1后,sum = 5;第二次
en = 1后,sum = 15;第三次
en = 1后,sum = 35。
十一、加入波形导出
如果用 Icarus Verilog、GTKWave 这类工具,可以在 testbench 里加入 VCD 波形导出:
initial begin $dumpfile("tb_acc8.vcd"); $dumpvars(0, tb_acc8); end完整写法如下:
initial begin $dumpfile("tb_acc8.vcd"); $dumpvars(0, tb_acc8); end其中:
$dumpfile指定波形文件名;$dumpvars指定导出哪些层级的信号;0表示从指定模块开始,往下所有层级都导出。
如果使用 Vivado 或 ModelSim,不一定需要手写$dumpfile,工具本身也可以直接打开波形。
但对于新人来说,知道这两个系统任务很有用。
十二、用 task 简化输入激励
上面的 testbench 虽然能用,但重复代码太多。
每次输入数据都要写:
@(negedge clk); en = 1'b1; din = 某个值; @(negedge clk); en = 1'b0; din = 8'd0;如果要输入 100 组数据,这样写就很难维护。
这时可以用task封装。
task send_one; input [7:0] data; begin @(negedge clk); en = 1'b1; din = data; @(negedge clk); en = 1'b0; din = 8'd0; end endtask有了这个 task 后,输入激励可以写成:
send_one(8'd5); send_one(8'd10); send_one(8'd20);这样代码更像是在描述测试流程,而不是堆时序细节。
十三、加入自动检查,不只看波形
新人刚开始写 testbench,一般只会看波形。
看波形没问题,但工程里不能只靠肉眼看。
至少可以加一些简单的自动检查。
比如我们在 testbench 里维护一个期望值exp_sum:
reg [15:0] exp_sum;每发送一个数据,就更新期望值:
exp_sum = exp_sum + data;然后比较 DUT 的输出:
if (sum !== exp_sum) begin $display("ERROR: sum = %d, expected = %d", sum, exp_sum); end else begin $display("PASS: sum = %d", sum); end这里注意一个细节:比较输出时,最好在时钟正边沿后延时一点点。
因为 DUT 里的寄存器通常是非阻塞赋值:
sum <= sum + din;在仿真调度里,正边沿刚到的时候,输出不会立刻在同一个语句区间变成新值。
所以 testbench 里常见写法是:
@(posedge clk); #1;意思是:等 DUT 在正边沿采样并更新后,再检查结果。
十四、工程版完整 testbench
下面给出一份更完整、更像工程里会用的 testbench。
`timescale 1ns/1ps module tb_acc8; parameter CLK_PERIOD = 10; reg clk; reg rst_n; reg en; reg [7:0] din; wire [15:0] sum; reg [15:0] exp_sum; // ============================== // DUT instance // ============================== acc8 u_acc8 ( .clk (clk), .rst_n (rst_n), .en (en), .din (din), .sum (sum) ); // ============================== // Clock generation // ============================== initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk; // ============================== // Wave dump // ============================== initial begin $dumpfile("tb_acc8.vcd"); $dumpvars(0, tb_acc8); end // ============================== // Reset task // ============================== task apply_reset; begin rst_n = 1'b0; en = 1'b0; din = 8'd0; exp_sum = 16'd0; repeat (5) @(posedge clk); rst_n = 1'b1; repeat (2) @(posedge clk); end endtask // ============================== // Send one data task // ============================== task send_one; input [7:0] data; begin @(negedge clk); en = 1'b1; din = data; @(posedge clk); #1; exp_sum = exp_sum + data; if (sum !== exp_sum) begin $display("[%0t] ERROR: din = %0d, sum = %0d, expected = %0d", $time, data, sum, exp_sum); end else begin $display("[%0t] PASS : din = %0d, sum = %0d", $time, data, sum); end @(negedge clk); en = 1'b0; din = 8'd0; end endtask // ============================== // Main test process // ============================== initial begin apply_reset(); send_one(8'd5); send_one(8'd10); send_one(8'd20); send_one(8'd1); send_one(8'd255); repeat (10) @(posedge clk); $display("Simulation finished."); $finish; end endmodule这份 testbench 做了几件事:
自动产生 100MHz 时钟;
自动复位 DUT;
使用
task发送输入数据;每发送一次数据就检查输出;
出错时打印
ERROR;正确时打印
PASS;仿真结束后调用
$finish。
这比单纯看波形更可靠。
十五、为什么输入要在 negedge 改?
这一点新人很容易忽略。
DUT 里面通常写:
always @(posedge clk) begin ... end也就是说,DUT 在posedge clk采样输入。
如果 testbench 也在posedge clk改输入,比如:
@(posedge clk); din = 8'd10; en = 1'b1;那么就容易出现一个问题:
DUT 和 testbench 都在同一个时刻动作,到底 DUT 先采样,还是 testbench 先改输入?
仿真器有自己的事件调度规则,但新人没必要一开始就陷进去。
工程上更稳的办法是:
@(negedge clk); din = 8'd10; en = 1'b1;这样输入信号在下一个posedge clk到来之前已经稳定了半个周期,波形也更好看。
简单记:
同步模块正边沿采样,testbench 尽量在负边沿改输入。
十六、阻塞赋值和非阻塞赋值怎么选?
在 RTL 设计里,一般规则是:
时序逻辑用非阻塞赋值
<=;组合逻辑用阻塞赋值
=。
例如 DUT 里:
always @(posedge clk or negedge rst_n) begin if (!rst_n) sum <= 16'd0; else if (en) sum <= sum + din; endtestbench 里通常大量使用阻塞赋值=:
en = 1'b1; din = data;原因是 testbench 更像是在描述一个按顺序执行的测试流程。
比如:
rst_n = 1'b0; en = 1'b0; din = 8'd0;这种写法直观、可控。
但有一点要注意:
如果 testbench 在时钟边沿附近检查 DUT 的寄存器输出,不要忘了加一点延时:
@(posedge clk); #1; if (sum !== exp_sum) begin ... end否则可能检查到旧值。
十七、repeat比#100更适合同步等待
新人常写:
#100;这表示等 100ns。
但如果你写的是同步电路,更推荐写:
repeat (10) @(posedge clk);这表示等 10 个时钟周期。
这样做的好处是:
以后你把CLK_PERIOD从 10ns 改成 20ns,测试流程仍然是“等待 10 个周期”,而不是固定等 100ns。
工程里推荐:
repeat (5) @(posedge clk);而不是:
#50;尤其是复位、等待状态机跳转、等待数据处理完成时,用周期来描述更清晰。
十八、testbench 常用系统任务
1.$display
打印信息,类似 C 语言里的printf。
$display("sum = %0d", sum);带时间打印:
$display("[%0t] sum = %0d", $time, sum);2.$finish
结束仿真。
$finish;如果不写$finish,有些仿真会一直跑下去,尤其是 testbench 里有永远翻转的时钟:
always #5 clk = ~clk;3.$stop
暂停仿真。
$stop;一般调试时可用,但批量跑仿真更常用$finish。
4.$dumpfile和$dumpvars
导出 VCD 波形文件:
$dumpfile("tb_acc8.vcd"); $dumpvars(0, tb_acc8);十九、testbench 里常见的新人错误
1. 忘记初始化输入信号
错误写法:
initial begin rst_n = 1'b0; #50; rst_n = 1'b1; end但en和din没有初始化,仿真开始可能是x。
建议写法:
initial begin rst_n = 1'b0; en = 1'b0; din = 8'd0; end在 testbench 开始时,把所有输入信号都给一个确定值。
2. 复位时间太短
错误写法:
rst_n = 1'b0; #1; rst_n = 1'b1;这类复位太短,不利于观察,也可能掩盖问题。
建议:
repeat (5) @(posedge clk); rst_n = 1'b1;用几个完整时钟周期来复位。
3. 输入激励不对齐时钟
不推荐:
#13 din = 8'd10; #7 en = 1'b1;推荐:
@(negedge clk); din = 8'd10; en = 1'b1;同步逻辑的输入最好围绕时钟写。
4. 没有结束仿真
如果 testbench 有:
always #5 clk = ~clk;但没有:
$finish;仿真可能一直跑。
建议在主测试流程最后写:
repeat (10) @(posedge clk); $finish;5. 只看波形,不做自动检查
只看波形的问题是:
测试数据一多,人眼很容易漏。
建议至少加简单比较:
if (sum !== exp_sum) begin $display("ERROR"); end else begin $display("PASS"); end哪怕只是几个关键输出,也比纯手动看波形强。
6. 用==比较含 x 的信号
在 testbench 检查信号时,建议用:
if (sum !== exp_sum)而不是:
if (sum != exp_sum)!==可以检查x和z,更适合仿真检查。
二十、一个新人可以直接套用的 testbench 模板
下面这个模板可以直接复制,然后把模块名和端口改掉。
`timescale 1ns/1ps module tb_template; parameter CLK_PERIOD = 10; // input signals of DUT reg clk; reg rst_n; // add your input regs here // reg en; // reg [7:0] din; // output signals of DUT // wire [15:0] sum; // ============================== // DUT instance // ============================== // your_module u_your_module ( // .clk (clk), // .rst_n (rst_n) // ); // ============================== // Clock generation // ============================== initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk; // ============================== // Wave dump // ============================== initial begin $dumpfile("tb_template.vcd"); $dumpvars(0, tb_template); end // ============================== // Reset task // ============================== task apply_reset; begin rst_n = 1'b0; // initialize your input signals here // en = 1'b0; // din = 8'd0; repeat (5) @(posedge clk); rst_n = 1'b1; repeat (2) @(posedge clk); end endtask // ============================== // Main test // ============================== initial begin apply_reset(); // write your stimulus here // @(negedge clk); // en = 1'b1; // din = 8'd10; repeat (20) @(posedge clk); $display("Simulation finished."); $finish; end endmodule这个模板适合大部分入门模块,例如:
计数器;
分频器;
边沿检测;
状态机;
简单数据通路;
串口发送控制;
SPI/I2C 简化控制模块;
FIFO 读写控制逻辑。
二十一、写 testbench 的推荐流程
新人不要一上来就想写很复杂的 testbench。
推荐按下面顺序来:
第一步:先让仿真跑起来
只写:
时钟;
复位;
DUT 例化;
$finish。
目标是没有编译错误,能看到波形。
第二步:加最简单输入
只测一个场景,比如:
send_one(8'd5);确认 DUT 有反应。
第三步:加多个正常输入
比如:
send_one(8'd5); send_one(8'd10); send_one(8'd20);确认连续输入没问题。
第四步:加边界输入
比如:
send_one(8'd0); send_one(8'd255);边界值经常能测出问题(在实际设计中也要注意位宽溢出问题)。
第五步:加自动检查
不要只看波形,加入$display和结果比较。
第六步:整理成 task
重复的激励逻辑用task封装。
这样后面测试用例变多时,代码不会乱。
二十二、testbench 写得好不好,看这几个点
一个新人写 testbench,可以先用下面几个标准自查:
输入信号有没有初始化?
复位有没有保持几个时钟周期?
时钟周期有没有用参数定义?
输入激励有没有尽量对齐时钟?
仿真最后有没有
$finish?有没有波形导出?
有没有打印关键信息?
有没有至少做简单的自动结果检查?
重复激励有没有用
task封装?测试用例里有没有正常值和边界值?
如果这 10 点都做到了,你的 testbench 已经比很多“只会拉波形”的入门写法强很多。
二十三、完整工程代码汇总
1. DUT:acc8.v
module acc8 ( input clk, input rst_n, input en, input [7:0] din, output reg [15:0] sum ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sum <= 16'd0; end else if (en) begin sum <= sum + din; end else begin sum <= sum; end end endmodule2. Testbench:tb_acc8.v
`timescale 1ns/1ps module tb_acc8; parameter CLK_PERIOD = 10; reg clk; reg rst_n; reg en; reg [7:0] din; wire [15:0] sum; reg [15:0] exp_sum; acc8 u_acc8 ( .clk (clk), .rst_n (rst_n), .en (en), .din (din), .sum (sum) ); initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk; initial begin $dumpfile("tb_acc8.vcd"); $dumpvars(0, tb_acc8); end task apply_reset; begin rst_n = 1'b0; en = 1'b0; din = 8'd0; exp_sum = 16'd0; repeat (5) @(posedge clk); rst_n = 1'b1; repeat (2) @(posedge clk); end endtask task send_one; input [7:0] data; begin @(negedge clk); en = 1'b1; din = data; @(posedge clk); #1; exp_sum = exp_sum + data; if (sum !== exp_sum) begin $display("[%0t] ERROR: din = %0d, sum = %0d, expected = %0d", $time, data, sum, exp_sum); end else begin $display("[%0t] PASS : din = %0d, sum = %0d", $time, data, sum); end @(negedge clk); en = 1'b0; din = 8'd0; end endtask initial begin apply_reset(); send_one(8'd5); send_one(8'd10); send_one(8'd20); send_one(8'd0); send_one(8'd255); repeat (10) @(posedge clk); $display("Simulation finished."); $finish; end endmodule二十四、总结
Verilog testbench 不难,难的是刚开始没有结构感。
新人写 testbench,先抓住四件事:
造时钟;
拉复位;
给输入激励;
检查输出结果。
时钟一般这样写:
initial clk = 1'b0; always #(CLK_PERIOD/2) clk = ~clk;复位一般保持几个时钟周期:
rst_n = 1'b0; repeat (5) @(posedge clk); rst_n = 1'b1;输入激励尽量对齐时钟:
@(negedge clk); en = 1'b1; din = data;输出检查尽量放在时钟正边沿后:
@(posedge clk); #1; if (sum !== exp_sum) begin $display("ERROR"); end最后记住一点:
testbench 的目标不是把代码写复杂,而是用清晰、可重复、可检查的方式证明你的 RTL 在关键场景下能正常工作。
对于 FPGA 新人来说,能写出结构清楚的 testbench,说明你已经不只是“会写 Verilog 语法”,而是开始具备基本的工程验证意识了。