Verilog testbench 怎么写:从时钟、复位到输入激励
2026/8/23 9:48:53 网站建设 项目流程

前言

这篇文章不讲空概念,直接按工程里最常见的写法,带你从 0 写一个 Verilog testbench。重点包括:

  • testbench 的基本结构

  • 时钟clk怎么产生

  • 复位rst_n怎么写

  • 输入激励怎么对齐时钟

  • 怎么用 task 简化重复输入

  • 怎么检查输出结果

  • 新人常见 testbench 坑点

适合 FPGA 应届生、研二研三学生、入行 1 年以内的新人工程师阅读。


一、testbench 是什么?

简单说,testbench 就是用来“喂输入、看输出”的仿真文件。

你写了一个 Verilog 模块,比如计数器、状态机、FIFO 控制逻辑、SPI 接收模块。
这个模块本身不能自己跑起来,因为它需要时钟、复位和输入信号。

testbench 的作用就是:

  1. 产生时钟;

  2. 产生复位;

  3. 给 DUT 输入激励;

  4. 等待 DUT 运行;

  5. 观察输出波形;

  6. 必要时自动判断结果对不对。

这里的 DUT 是 Design Under Test,也就是“被测试模块”。

比如你写了一个模块叫acc8,那 testbench 就负责实例化它,然后给它输入clkrst_nendin,最后看它的输出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; endmodule

timescale 1ns/1ps的意思是:

  • 时间单位是 1ns;

  • 时间精度是 1ps。

也就是说,代码里的#10表示延时 10ns。


五、第二步:声明 testbench 里的信号

DUT 的输入,在 testbench 里一般声明为reg
DUT 的输出,在 testbench 里一般声明为wire

因为输入信号要在 testbench 里被赋值,比如clk = 0rst_n = 1din = 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。


八、第五步:产生复位

复位一般有两类:

  1. 高电平复位:rst = 1表示复位;

  2. 低电平复位: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 做了几件事:

  1. 自动产生 100MHz 时钟;

  2. 自动复位 DUT;

  3. 使用task发送输入数据;

  4. 每发送一次数据就检查输出;

  5. 出错时打印ERROR

  6. 正确时打印PASS

  7. 仿真结束后调用$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; end

testbench 里通常大量使用阻塞赋值=

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

endin没有初始化,仿真开始可能是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)

!==可以检查xz,更适合仿真检查。


二十、一个新人可以直接套用的 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,可以先用下面几个标准自查:

  1. 输入信号有没有初始化?

  2. 复位有没有保持几个时钟周期?

  3. 时钟周期有没有用参数定义?

  4. 输入激励有没有尽量对齐时钟?

  5. 仿真最后有没有$finish

  6. 有没有波形导出?

  7. 有没有打印关键信息?

  8. 有没有至少做简单的自动结果检查?

  9. 重复激励有没有用task封装?

  10. 测试用例里有没有正常值和边界值?

如果这 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 endmodule

2. 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,先抓住四件事:

  1. 造时钟;

  2. 拉复位;

  3. 给输入激励;

  4. 检查输出结果。

时钟一般这样写:

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 语法”,而是开始具备基本的工程验证意识了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询