Verilog testbench 怎么写:从时钟、复位到输入激励 前言这篇文章不讲空概念直接按工程里最常见的写法带你从 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 1b0; always #(CLK_PERIOD/2) clk ~clk; // 5. 产生复位和输入激励 initial begin rst_n 1b0; input_signal 1b0; #(CLK_PERIOD * 5); rst_n 1b1; // 后面写输入激励 #(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 16d0; 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 8h12所以用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。六、第三步例化 DUTtestbench 要把被测模块接进来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 1b0; end always #(CLK_PERIOD/2) begin clk ~clk; end也可以写得更短initial clk 1b0; 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 1b0; en 1b0; din 8d0; #(CLK_PERIOD * 5); rst_n 1b1; end这段代码表示仿真一开始处于复位状态复位保持 5 个时钟周期然后释放复位模块开始正常工作。工程里不建议复位刚开始就释放比如rst_n 1b0; #1; rst_n 1b1;这太短了很多问题看不出来。一般至少保持几个时钟周期写清楚一点更稳。九、第六步输入激励不要乱给最好对齐时钟很多新人写输入激励喜欢这样写#13 din 8d5; #7 en 1b1; #9 en 1b0;这种写法不是不能跑但很容易把自己绕晕。因为输入信号没有对齐时钟边沿波形看起来会很乱。对于同步逻辑推荐在时钟的负边沿改变输入在下一个正边沿让 DUT 采样。例如(negedge clk); en 1b1; din 8d5; (negedge clk); en 1b0; din 8d0;为什么选择负边沿改输入因为 DUT 一般在正边沿采样always (posedge clk)testbench 在负边沿改变输入等到下一个正边沿时输入信号已经稳定了半个周期。这样波形清晰也更接近真实工程中的时序习惯。十、先写一个最简单的 testbench下面是第一版完整 testbenchtimescale 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 1b0; always #(CLK_PERIOD/2) clk ~clk; // 产生复位和输入激励 initial begin rst_n 1b0; en 1b0; din 8d0; // 复位保持 5 个周期 #(CLK_PERIOD * 5); rst_n 1b1; // 等待 2 个周期 #(CLK_PERIOD * 2); // 第一次输入sum 0 5 (negedge clk); en 1b1; din 8d5; (negedge clk); en 1b0; din 8d0; // 第二次输入sum 5 10 (negedge clk); en 1b1; din 8d10; (negedge clk); en 1b0; din 8d0; // 第三次输入sum 15 20 (negedge clk); en 1b1; din 8d20; (negedge clk); en 1b0; din 8d0; #(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 1b1; din 某个值; (negedge clk); en 1b0; din 8d0;如果要输入 100 组数据这样写就很难维护。这时可以用task封装。task send_one; input [7:0] data; begin (negedge clk); en 1b1; din data; (negedge clk); en 1b0; din 8d0; end endtask有了这个 task 后输入激励可以写成send_one(8d5); send_one(8d10); send_one(8d20);这样代码更像是在描述测试流程而不是堆时序细节。十三、加入自动检查不只看波形新人刚开始写 testbench一般只会看波形。看波形没问题但工程里不能只靠肉眼看。至少可以加一些简单的自动检查。比如我们在 testbench 里维护一个期望值exp_sumreg [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 1b0; 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 1b0; en 1b0; din 8d0; exp_sum 16d0; repeat (5) (posedge clk); rst_n 1b1; repeat (2) (posedge clk); end endtask // // Send one data task // task send_one; input [7:0] data; begin (negedge clk); en 1b1; 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 1b0; din 8d0; end endtask // // Main test process // initial begin apply_reset(); send_one(8d5); send_one(8d10); send_one(8d20); send_one(8d1); send_one(8d255); 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 8d10; en 1b1;那么就容易出现一个问题DUT 和 testbench 都在同一个时刻动作到底 DUT 先采样还是 testbench 先改输入仿真器有自己的事件调度规则但新人没必要一开始就陷进去。工程上更稳的办法是(negedge clk); din 8d10; en 1b1;这样输入信号在下一个posedge clk到来之前已经稳定了半个周期波形也更好看。简单记同步模块正边沿采样testbench 尽量在负边沿改输入。十六、阻塞赋值和非阻塞赋值怎么选在 RTL 设计里一般规则是时序逻辑用非阻塞赋值组合逻辑用阻塞赋值。例如 DUT 里always (posedge clk or negedge rst_n) begin if (!rst_n) sum 16d0; else if (en) sum sum din; endtestbench 里通常大量使用阻塞赋值en 1b1; din data;原因是 testbench 更像是在描述一个按顺序执行的测试流程。比如rst_n 1b0; en 1b0; din 8d0;这种写法直观、可控。但有一点要注意如果 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 1b0; #50; rst_n 1b1; end但en和din没有初始化仿真开始可能是x。建议写法initial begin rst_n 1b0; en 1b0; din 8d0; end在 testbench 开始时把所有输入信号都给一个确定值。2. 复位时间太短错误写法rst_n 1b0; #1; rst_n 1b1;这类复位太短不利于观察也可能掩盖问题。建议repeat (5) (posedge clk); rst_n 1b1;用几个完整时钟周期来复位。3. 输入激励不对齐时钟不推荐#13 din 8d10; #7 en 1b1;推荐(negedge clk); din 8d10; en 1b1;同步逻辑的输入最好围绕时钟写。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 1b0; 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 1b0; // initialize your input signals here // en 1b0; // din 8d0; repeat (5) (posedge clk); rst_n 1b1; repeat (2) (posedge clk); end endtask // // Main test // initial begin apply_reset(); // write your stimulus here // (negedge clk); // en 1b1; // din 8d10; repeat (20) (posedge clk); $display(Simulation finished.); $finish; end endmodule这个模板适合大部分入门模块例如计数器分频器边沿检测状态机简单数据通路串口发送控制SPI/I2C 简化控制模块FIFO 读写控制逻辑。二十一、写 testbench 的推荐流程新人不要一上来就想写很复杂的 testbench。推荐按下面顺序来第一步先让仿真跑起来只写时钟复位DUT 例化$finish。目标是没有编译错误能看到波形。第二步加最简单输入只测一个场景比如send_one(8d5);确认 DUT 有反应。第三步加多个正常输入比如send_one(8d5); send_one(8d10); send_one(8d20);确认连续输入没问题。第四步加边界输入比如send_one(8d0); send_one(8d255);边界值经常能测出问题在实际设计中也要注意位宽溢出问题。第五步加自动检查不要只看波形加入$display和结果比较。第六步整理成 task重复的激励逻辑用task封装。这样后面测试用例变多时代码不会乱。二十二、testbench 写得好不好看这几个点一个新人写 testbench可以先用下面几个标准自查输入信号有没有初始化复位有没有保持几个时钟周期时钟周期有没有用参数定义输入激励有没有尽量对齐时钟仿真最后有没有$finish有没有波形导出有没有打印关键信息有没有至少做简单的自动结果检查重复激励有没有用task封装测试用例里有没有正常值和边界值如果这 10 点都做到了你的 testbench 已经比很多“只会拉波形”的入门写法强很多。二十三、完整工程代码汇总1. DUTacc8.vmodule 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 16d0; end else if (en) begin sum sum din; end else begin sum sum; end end endmodule2. Testbenchtb_acc8.vtimescale 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 1b0; always #(CLK_PERIOD/2) clk ~clk; initial begin $dumpfile(tb_acc8.vcd); $dumpvars(0, tb_acc8); end task apply_reset; begin rst_n 1b0; en 1b0; din 8d0; exp_sum 16d0; repeat (5) (posedge clk); rst_n 1b1; repeat (2) (posedge clk); end endtask task send_one; input [7:0] data; begin (negedge clk); en 1b1; 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 1b0; din 8d0; end endtask initial begin apply_reset(); send_one(8d5); send_one(8d10); send_one(8d20); send_one(8d0); send_one(8d255); repeat (10) (posedge clk); $display(Simulation finished.); $finish; end endmodule二十四、总结Verilog testbench 不难难的是刚开始没有结构感。新人写 testbench先抓住四件事造时钟拉复位给输入激励检查输出结果。时钟一般这样写initial clk 1b0; always #(CLK_PERIOD/2) clk ~clk;复位一般保持几个时钟周期rst_n 1b0; repeat (5) (posedge clk); rst_n 1b1;输入激励尽量对齐时钟(negedge clk); en 1b1; din data;输出检查尽量放在时钟正边沿后(posedge clk); #1; if (sum ! exp_sum) begin $display(ERROR); end最后记住一点testbench 的目标不是把代码写复杂而是用清晰、可重复、可检查的方式证明你的 RTL 在关键场景下能正常工作。对于 FPGA 新人来说能写出结构清楚的 testbench说明你已经不只是“会写 Verilog 语法”而是开始具备基本的工程验证意识了。