写在前面很多时候Verilog 的 testbench 不一定要自己从零手写完全可以先让 AI 生成一个版本再由我们来检查、修改和补强。所以这篇文章的目标不是把你训练成“手搓 testbench 大师”而是先解决一个更实际的问题当别人给你一份 testbench或者 AI 帮你生成了一份 testbench 时你至少要能看懂它每一部分在干什么知道它到底在测什么。如果你能做到这一点后面无论是改代码、补激励、加校验还是定位 bug都会轻松很多。一、先用一句话理解什么是 testbench可以把 testbench 理解成专门用来“喂数据、看结果、判断对错”的仿真测试脚本。如果把 DUTDesign Under Test被测设计比作一个学生那么 testbench 就像出题的人发卷子的人盯考试过程的人最后判卷的人也就是说testbench 本身不是我们要交付的硬件功能模块而是专门用来测试那个模块是否正常工作的。它的核心任务只有 3 件事给 DUT 输入数据观察 DUT 输出结果判断输出是否符合预期这三件事分别对应 testbench 里最常见的三个概念激励stimulus监视/观察monitor校验check / checker二、初学者最容易卡住的概念什么叫“激励”很多人第一次看 testbench 时会被“激励”这个词劝退感觉很抽象。其实一点都不玄。所谓激励说白了就是你主动喂给 DUT 的输入动作。比如给一个加法器输入a3,b5给一个串口模块输入一串字节给一个滤波器连续输入一段正弦波先拉低复位再拉高复位隔几个时钟切换一次配置参数这些都叫激励。你可以把它理解成“给模块出题”。一个更通俗的类比如果 DUT 是一个豆浆机你按下启动键是激励你倒入黄豆和水是激励你切换“米糊模式”也是激励而 testbench 的工作就是按顺序把这些动作做出来然后看机器的反应对不对。所以以后看到 testbench 里的initial、always、task里在给信号赋值不要慌它本质上就是在“模拟外部世界如何去操作你的硬件模块。”三、看 testbench 时先抓住这条主线你拿到任何一份 testbench都可以先别急着看细节而是先问自己这 4 个问题它在测哪个 DUT它给 DUT 喂了什么输入它希望 DUT 输出什么结果它是怎么判断“通过”还是“失败”的只要这四个问题能回答出来这份 testbench 你就已经看懂一大半了。四、testbench 的常见组成部分到底分别在干什么下面结合一份验证 CIC IP 动态切换抽取率的 testbench来讲 testbench 常见结构。一份典型的 testbench通常包括这些部分timescaleTB 模块定义信号声明时钟生成复位控制激励生成DUT 实例化输出监视与校验仿真结束控制五、timescale是干什么的timescale 1ns/1ps它定义的是仿真的时间单位和时间精度。1ns表示像#20这种延时默认按 20ns 理解1ps表示仿真能细到 1ps 的精度你可以把它理解成什么它就像一把尺子。你后面所有的#10、#20都得靠这把尺子来解释。如果没有这个定义延时语句的意义就会变得不明确读代码的人也很难快速理解时序关系。六、TB 模块为什么通常没有端口module tb_dual_cic_sync;testbench 一般不需要对外连接其他模块所以通常不带端口列表。因为它不是一个要被综合到 FPGA 里的功能模块而是仿真环境本身。可以这样理解DUT被测试的“产品”TB搭建出来的“实验室”实验室不需要再对外提供输入输出接口它只需要在内部把测试流程跑起来。七、信号声明区本质上是在准备“测试现场”比如reg clk_50m; reg rst_n;reg [7:0] cfg_tdata; reg cfg_tvalid; wire cfg_i_tready; wire cfg_q_tready;reg signed [15:0] i_in_sample; reg i_in_valid; wire i_in_ready;这里的本质不是“语法罗列”而是在提前把测试中会用到的角色准备好。一般可以分成几类看时钟/复位这是整个系统运行的基础节拍DUT 输入信号TB 负责驱动它们所以常常定义成regDUT 输出信号它们由 DUT 产生所以常常定义成wire统计变量用来计数、记阶段、判超时为什么很多输入写成reg因为 testbench 里经常会在initial或always中给这些信号赋值。谁来主动改这个值谁就更像“由 TB 控制”因此通常写成reg。八、时钟生成块其实就是在“造节拍器”initial clk_50m 1b0; always #10 clk_50m ~clk_50m;这两句的作用很简单第一行把时钟初始值设为 0第二行每隔 10ns 翻转一次所以整个时钟周期就是 20ns也就是 50MHz。通俗理解这就像你在 testbench 里手动放了一个电子节拍器滴答 - 滴答 - 滴答所有同步逻辑都跟着这个节拍走。如果没有这个时钟很多时序逻辑根本不会动。九、function 是什么为什么 testbench 里也会有函数原文里有一个正弦查找表函数function signed [15:0] sine_lut; input integer idx; begin ... end endfunction它的作用是根据索引idx返回一个正弦波采样值。为什么要搞这个函数因为 testbench 需要给 DUT 持续输入测试数据。这里选择的测试数据不是乱给而是给一组有规律的正弦波采样值。这样做有两个好处输入信号更接近真实场景输出结果更容易分析和校验function 可以怎么理解它本质上就是一个“小工具函数”。你给它一个输入它马上算出一个结果给你。在 testbench 里function 常用来做查表位宽转换简单计算期望值生成这里最关键的一点这份 testbench 里让I sinQ -sin这样理想情况下两路输出如果严格同步那么I_out Q_out 0这个思路非常重要。因为它告诉我们写 testbench 时不只是“喂点数据”就完了更重要的是设计一个容易验证对错的输入模式。十、DUT 实例化就是把“被测模块”接进实验台比如cic_compiler_0 u_cic_i ( .aclk (clk_50m), .s_axis_config_tdata (cfg_tdata), ... );这一步可以理解成把你真正想测试的硬件模块插到 testbench 这个实验平台里。看实例化时重点看什么不用一上来就盯所有端口细节先看这几件事DUT 用的时钟是谁提供的输入是谁在驱动输出接到了哪些观测信号有没有状态/告警信号被接出来这篇 testbench 里有两个 CIC 实例分别对应 I 路和 Q 路。它们共用同一个时钟共用同一套配置总线各自有独立的数据输入输出这就为“验证双通道同步性”打下了基础。十一、task 是什么为什么配置写入更适合放 task 里task automatic write_rate; input [7:0] new_rate; begin ... end endtask如果说 function 更像“立刻算个结果”的工具那么 task 更像“执行一段完整流程”的工具。这里的write_rate在干什么它负责完成一次“抽取率配置写入”动作把新配置放到总线上拉高valid等待对方ready握手成功后再结束为什么这很适合写成 task因为这不是一个单纯的计算而是一个带时序等待的动作流程。它里面会出现(posedge clk)while(...)等待握手这些都很适合封装进 task。通俗理解你可以把 task 看成“把一套固定操作流程打包成一个按钮。”以后要改抽取率时直接调用write_rate(8d4); write_rate(8d8);比你每次手写一遍 AXIS 握手流程清晰太多。十二、initial主流程其实就是“测试脚本的导演”在这份 testbench 里主initial块承担的是总控作用。它做了这些事初始化所有寄存器先保持复位释放复位配置第一阶段抽取率跑一段时间切换第二阶段抽取率再跑一段时间打印统计结果$finish结束仿真为什么这个块很重要因为它决定了整个测试是按什么顺序进行的。你完全可以把它当作一份“实验步骤清单”来看。看到一份 testbench 时如果你不知道作者到底想测什么先去看主initial通常就能看出来。十三、真正的“激励”通常藏在always或task里比如这段always (posedge clk_50m) begin if (!rst_n) begin ... end else if (!cfg_tvalid) begin ... end end这就是 testbench 中很典型的激励生成块。它在持续做的事情是复位时清空输入正常工作时持续给 I/Q 通道送入采样数据只有握手成功后才推进到下一组采样这一段为什么重要因为它体现了 testbench 的核心思想不是乱发数据而是按照 DUT 的接口规则发数据。尤其是 AXIS 这种握手接口不能你想发就发必须配合validready一个很重要的初学者认知很多人以为“激励”就是一串赋值语句。其实不是。真正有价值的激励应该满足三点有测试意图符合接口时序能覆盖你想验证的场景这篇 testbench 里的激励就不是随便乱写的它是在验证两路输入是否同步改变抽取率后模块是否还能稳定工作输出是否仍然保持预期关系十四、什么叫“观察输出”testbench 不是把数据扔给 DUT 就结束了。后半段更重要的事情是盯住 DUT 的输出看它有没有按预期响应。比如这份代码里会观察i_out_validq_out_validi_out_sampleq_out_sampleevent_i_haltedevent_q_halted这些信号就是 testbench 的“观察窗口”。你可以把它理解成医生在看监护仪心跳有没有数值是否正常是否出现报警十五、什么叫“校验”校验就是把你看到的输出结果和你心里预期的正确结果做比较。这份 testbench 的校验思想非常适合教学因为它很直观。它是怎么判断对错的它用了三层判断I/Q 同时输出时检查I Q是否等于 0检查 I 路和 Q 路是不是同步输出检查 IP 有没有出现 halt 告警为什么这是个好 testbench因为它不是只盯一个“最终结果”而是把常见错误拆成了几类结果值错了通道不同步内部状态异常这就是一个合格 checker 的思路。十六、为什么说 checker 才是 testbench 的灵魂很多新手写 testbench 时最容易犯的错误就是只会发激励不会做判断。最后仿真跑完了只能打开波形一拍一拍人工看非常痛苦。而 checker 的价值就是让 testbench 自动告诉你哪里错了。比如if (($signed(i_out_sample) $signed(q_out_sample)) ! 35sd0) begin sync_error_count sync_error_count 1; $display(...); end这段代码的意义不只是“加了个 if”而是把“人工看波形判断是否同步”变成了“程序自动报错”。这会极大提高调试效率。所以判断一份 testbench 有没有水平一个很重要的标准就是它的 checker 写得怎么样。十七、为什么还要加“超时保护”if (global_timer GLOBAL_TIMEOUT) begin timeout_count timeout_count 1; $display([%0t] global timeout reached, $time); $finish; end这部分很多初学者会忽略但实际上非常重要。为什么重要因为仿真里很容易出现这种情况某个握手永远等不到某个状态机卡住了while 循环一直不退出仿真一直跑不结束如果没有超时保护你的仿真可能会一直挂在那里。所以“超时退出”其实是一种自保护机制。你可以把它理解成给 testbench 装了一个保险丝。十八、怎么看一份 testbench 到底有没有测到点子上这也是很多人真正关心的问题。不是 testbench 写得长就代表写得好而是要看它是否真正验证了目标。以这篇 CIC 例子来说它真正验证的是动态切换抽取率时配置是否成功写入I/Q 两路输入是否保持同步I/Q 两路输出是否保持同步输出结果是否满足预期关系IP 是否出现 halt 异常所以你以后看 testbench 时不要只盯语法而要盯“它到底在验证哪个行为”十九、初学者看 testbench 的推荐顺序如果你现在还是觉得 testbench 很长、很乱可以按这个顺序看第一步先看 DUT 实例化先搞清楚在测谁、有哪些接口。第二步看主initial搞清楚整个测试流程是怎么安排的。第三步看激励块搞清楚输入数据是怎么来的。第四步看 checker搞清楚 testbench 是怎么判断对错的。第五步最后再看 function、task、计数器这些辅助结构这样阅读压力会小很多。二十、把 testbench 看成一句更完整的话到这里你可以把一份 testbench 理解为“我先搭好一个仿真环境然后按一定时序给 DUT 喂输入再持续观察输出最后自动判断 DUT 的行为是否符合预期。”如果你能带着这句话去看代码很多以前看起来零散的initial、always、task、function就都会串起来了。二十一、这份 testbench 给我们的一个很重要启发这篇代码真正值得学的不只是语法而是验证思路1. 输入不是乱给的而是有设计过的I 路给正弦Q 路给负正弦这样输出关系容易验证。2. 校验不是靠肉眼而是靠 checker 自动判断能自动报错的 testbench才是高效的 testbench。3. 验证不是只测单一场景而是分阶段测先测 rate4再测 rate8这样才能覆盖动态切换场景。4. testbench 也要防卡死超时保护是很有工程味道的一部分。二十二、给初学者的一个结论如果你现在还不会完整手写 testbench不用焦虑。你现阶段最值得先掌握的是这 4 件事知道 testbench 是干什么的知道什么叫激励、观察、校验拿到一份 testbench 能快速找出主流程能判断这份 testbench 究竟在验证什么先做到“看懂”再做到“会改”最后才是“会从零写”。这条学习路线会更顺。二十三、最后给你一个万能阅读模板以后再看到任何 testbench都先问自己1. DUT 是谁 2. 时钟和复位怎么来 3. 激励从哪里发 4. 输出看哪些信号 5. 通过/失败的判据是什么 6. 仿真什么时候结束如果这 6 个问题你都能答出来这份 testbench 基本就已经被你拿下了。结语testbench 不是“为了仿真而仿真”它本质上是在回答一个问题“我怎么证明这个设计真的按我想要的方式工作”而一份好的 testbench不只是能跑通更应该让你知道输入是什么知道输出为什么对知道出错时会错在哪里如果你正在学 Vivado、Verilog 或 FPGA 仿真希望这篇文章能帮你从“看不懂 testbench”迈到“至少能拆开看懂每一块在干什么”。完整代码timescale 1ns / 1ps module tb_dual_cic_sync; reg clk_50m; reg rst_n; reg [7:0] cfg_tdata; reg cfg_tvalid; wire cfg_i_tready; wire cfg_q_tready; reg signed [15:0] i_in_sample; reg i_in_valid; wire i_in_ready; reg signed [15:0] q_in_sample; reg q_in_valid; wire q_in_ready; wire [39:0] i_out_tdata_full; wire i_out_valid; reg i_out_ready; wire [39:0] q_out_tdata_full; wire q_out_valid; reg q_out_ready; wire signed [33:0] i_out_sample; wire signed [33:0] q_out_sample; assign i_out_sample i_out_tdata_full[33:0]; assign q_out_sample q_out_tdata_full[33:0]; wire event_i_halted; wire event_q_halted; integer sample_idx; integer accepted_input_count; integer paired_output_count; integer sync_error_count; integer phase_id; integer r4_pair_count; integer r8_pair_count; integer timeout_count; reg signed [34:0] iq_sum; reg [7:0] active_rate; reg [31:0] phase_timer; reg [31:0] global_timer; localparam integer PHASE0_CYCLES 1200; localparam integer PHASE1_CYCLES 1200; localparam integer GLOBAL_TIMEOUT 6000; initial clk_50m 1b0; always #10 clk_50m ~clk_50m; function signed [15:0] sine_lut; input integer idx; begin case (idx % 32) 0: sine_lut 16sd0; 1: sine_lut 16sd6393; 2: sine_lut 16sd12539; 3: sine_lut 16sd18205; 4: sine_lut 16sd23170; 5: sine_lut 16sd27245; 6: sine_lut 16sd30273; 7: sine_lut 16sd32137; 8: sine_lut 16sd32767; 9: sine_lut 16sd32137; 10: sine_lut 16sd30273; 11: sine_lut 16sd27245; 12: sine_lut 16sd23170; 13: sine_lut 16sd18205; 14: sine_lut 16sd12539; 15: sine_lut 16sd6393; 16: sine_lut 16sd0; 17: sine_lut -16sd6393; 18: sine_lut -16sd12539; 19: sine_lut -16sd18205; 20: sine_lut -16sd23170; 21: sine_lut -16sd27245; 22: sine_lut -16sd30273; 23: sine_lut -16sd32137; 24: sine_lut -16sd32767; 25: sine_lut -16sd32137; 26: sine_lut -16sd30273; 27: sine_lut -16sd27245; 28: sine_lut -16sd23170; 29: sine_lut -16sd18205; 30: sine_lut -16sd12539; default: sine_lut -16sd6393; endcase end endfunction cic_compiler_0 u_cic_i ( .aclk (clk_50m), .s_axis_config_tdata (cfg_tdata), .s_axis_config_tvalid(cfg_tvalid), .s_axis_config_tready(cfg_i_tready), .s_axis_data_tdata (i_in_sample), .s_axis_data_tvalid (i_in_valid), .s_axis_data_tready (i_in_ready), .m_axis_data_tdata (i_out_tdata_full), .m_axis_data_tvalid (i_out_valid), .m_axis_data_tready (i_out_ready), .event_halted (event_i_halted) ); cic_compiler_0 u_cic_q ( .aclk (clk_50m), .s_axis_config_tdata (cfg_tdata), .s_axis_config_tvalid(cfg_tvalid), .s_axis_config_tready(cfg_q_tready), .s_axis_data_tdata (q_in_sample), .s_axis_data_tvalid (q_in_valid), .s_axis_data_tready (q_in_ready), .m_axis_data_tdata (q_out_tdata_full), .m_axis_data_tvalid (q_out_valid), .m_axis_data_tready (q_out_ready), .event_halted (event_q_halted) ); task automatic write_rate; input [7:0] new_rate; begin cfg_tdata new_rate; cfg_tvalid 1b1; (posedge clk_50m); while (!(cfg_i_tready cfg_q_tready)) begin (posedge clk_50m); end (posedge clk_50m); cfg_tvalid 1b0; active_rate new_rate; $display([%0t] config accepted, rate%0d, $time, new_rate); end endtask initial begin rst_n 1b0; cfg_tdata 8d4; cfg_tvalid 1b0; i_in_sample 16sd0; i_in_valid 1b0; q_in_sample 16sd0; q_in_valid 1b0; i_out_ready 1b1; q_out_ready 1b1; sample_idx 0; accepted_input_count 0; paired_output_count 0; sync_error_count 0; phase_id 0; r4_pair_count 0; r8_pair_count 0; timeout_count 0; iq_sum 35sd0; active_rate 8d0; phase_timer 0; global_timer 0; #200; rst_n 1b1; phase_id 1; write_rate(8d4); phase_timer 0; while (phase_timer PHASE0_CYCLES) begin (posedge clk_50m); phase_timer phase_timer 1; end repeat (16) (posedge clk_50m); phase_id 2; write_rate(8d8); phase_timer 0; while (phase_timer PHASE1_CYCLES) begin (posedge clk_50m); phase_timer phase_timer 1; end repeat (40) (posedge clk_50m); $display(); $display(accepted_input_count %0d, accepted_input_count); $display(paired_output_count %0d, paired_output_count); $display(r4_pair_count %0d, r4_pair_count); $display(r8_pair_count %0d, r8_pair_count); $display(sync_error_count %0d, sync_error_count); $display(timeout_count %0d, timeout_count); $display(); $finish; end always (posedge clk_50m) begin if (!rst_n) begin i_in_valid 1b0; q_in_valid 1b0; i_in_sample 16sd0; q_in_sample 16sd0; sample_idx 0; end else if (!cfg_tvalid) begin if ((!i_in_valid || i_in_ready) (!q_in_valid || q_in_ready)) begin i_in_valid 1b1; q_in_valid 1b1; i_in_sample sine_lut(sample_idx); q_in_sample -sine_lut(sample_idx); end if (i_in_valid i_in_ready q_in_valid q_in_ready) begin sample_idx sample_idx 1; accepted_input_count accepted_input_count 1; end end end always (posedge clk_50m) begin if (!rst_n) begin iq_sum 35sd0; paired_output_count 0; sync_error_count 0; r4_pair_count 0; r8_pair_count 0; timeout_count 0; end else begin global_timer global_timer 1; if (global_timer GLOBAL_TIMEOUT) begin timeout_count timeout_count 1; $display([%0t] global timeout reached, $time); $finish; end if (i_out_valid i_out_ready q_out_valid q_out_ready) begin iq_sum $signed(i_out_sample) $signed(q_out_sample); paired_output_count paired_output_count 1; if (phase_id 1) begin r4_pair_count r4_pair_count 1; end else if (phase_id 2) begin r8_pair_count r8_pair_count 1; end if (($signed(i_out_sample) $signed(q_out_sample)) ! 35sd0) begin sync_error_count sync_error_count 1; $display([%0t] phase%0d rate%0d FAIL: I%0d Q%0d sum%0d, $time, phase_id, active_rate, $signed(i_out_sample), $signed(q_out_sample), $signed(i_out_sample) $signed(q_out_sample)); end else begin $display([%0t] phase%0d rate%0d OK: I%0d Q%0d sum%0d, $time, phase_id, active_rate, $signed(i_out_sample), $signed(q_out_sample), $signed(i_out_sample) $signed(q_out_sample)); end end if ((i_out_valid i_out_ready) ^ (q_out_valid q_out_ready)) begin sync_error_count sync_error_count 1; $display([%0t] phase%0d rate%0d skew: i_fire%0b q_fire%0b, $time, phase_id, active_rate, (i_out_valid i_out_ready), (q_out_valid q_out_ready)); end if (event_i_halted || event_q_halted) begin sync_error_count sync_error_count 1; $display([%0t] ERROR: halted_i%0b halted_q%0b, $time, event_i_halted, event_q_halted); end end end endmodule