拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Verilog Testbench入门与进阶:从仿真骨架到自动验证

Verilog Testbench入门与进阶:从仿真骨架到自动验证 1. 为什么说Testbench才是Verilog真正的起跑线很多初学者在学Verilog的时候都有一种错觉语法看完了、计数器写出来了、状态机也能跑通了就觉得自己会了。等到真正要验证一个模块功能对不对的时候才发现自己连怎么给信号赋初值都没想清楚更别说构造时序、检查输出了。事实上在FPGA开发这个行当里RTL写得再漂亮没有一套像样的Testbench它就是一坨无法自证的文本。模块能不能用、边界条件下会不会出错全得靠仿真说话。Testbench说白了就是一套专门用来考你设计代码的激励环境。它不需要综合成电路不关心资源占用唯一的职责就是产生输入、监控输出、判断对错。这一篇笔记我把自己写Testbench的经验沉淀了一下围绕最基础的写法、时钟复位处理、task/function封装、文件读写和自校验这几个点展开。对于刚学完Verilog语法、准备动手做第一个正经仿真的朋友来说这篇尤其适用可以直接当参考路子用。我强烈建议你把Testbench当成一个独立的设计来对待——它和RTL一样需要清晰的结构、可复用的封装、明确的结束条件。我见过太多人Testbench写了一两百行却连一个完整的信号赋值规范都没有结果仿真一跑出来全是红波浪线。问题往往不是RTL错了而是Testbench本身就是一盘散沙。下面我开始讲具体的东西。2. 从计数器开始搭第一个Testbench完整骨架2.1 一个最简单的被测模块先拿几乎所有Verilog教材都会出现的计数器模块开刀。这个模块功能很简单异步复位计数到最大值后自动清零同时输出一个进位标志。module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt, output reg carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; carry 0; end else if (cnt {WIDTH{1b1}}) begin cnt 0; carry 1; end else begin cnt cnt 1b1; carry 0; end end endmodule我相信对大部分读者来说这段代码没有任何理解难度。但问题来了怎么验证它是对的你用Modelsim或者Vivado仿真的时候手写上电、时钟、复位、观测波形这就算Testbench了。但一个认真写的Testbench不该只是有波形出来。2.2 第一个能跑的Testbench长什么样看下面这个基础版本的Testbenchtimescale 1ns / 1ps module tb_counter; reg clk; reg rst_n; wire [7:0] cnt; wire carry; // 被测模块例化 counter #( .WIDTH(8) ) uut ( .clk (clk), .rst_n(rst_n), .cnt (cnt), .carry(carry) ); // 时钟生成10ns一个周期 initial clk 0; always #5 clk ~clk; // 测试流程 initial begin rst_n 0; #20; rst_n 1; // 给药20个时钟周期让计数器跑一会 #200; $display(Simulation finished.); $finish; end endmodule这段代码应该是所有Verilog仿真器通用的最小骨架。timescale 1ns/1ps声明了时间单位和精度时钟周期定为10ns也就是100MHz。复位拉低20ns后释放然后跑200ns结束。整个流程非常简单但它已经包含了Testbench的四大核心要素激励产生、被测模块例化、时钟与复位生成、仿真结束控制。2.3 为什么每段Testbench都要例化被测模块很多新手不理解为什么Testbench里要写一遍端口例化。实际上这里的例化和在顶层模块里的例化完全没有区别它是把DUTDesign Under Test被测模块的引脚和Testbench里的reg/wire信号一一对应起来的关键步骤。这里有一个很重要的接线习惯所有需要由Testbench驱动的输入端口必须对应reg类型所有观察的输出端口对应wire类型。这属于Verilog仿真最基本的规则——initial块和always块里只能对reg变量赋值连续赋值语句assign只能驱动wire。我见过不少人把这个搞反然后困惑为什么仿真时候赋值根本没生效。信号类型声明这块是Testbench里第一道容易踩的坎。3. Testbench的核心机制initial与always的分工、时间尺度、阻塞非阻塞3.1 initial和always两个风格完全不同的并行世界Testbench里最核心的两类语句块就是initial和always。两者都以并行方式运行区别在于initial块只执行一次执行完就结束了always块则按触发事件不断重复执行。就好比一个是单次执行的任务脚本一个是一直在跑的实时循环。时钟生成就是always块最典型的应用。always #5 clk ~clk;这个写法不需要任何触发条件它自己每隔5ns翻转一次clk无限循环直到$finish。而initial块适合放复位序列、初始化语句、主测试流程这些一次性逻辑。这里我多说一句不要在always块里写多个无关的测试信号生成逻辑。如果两个always块同时驱动同一个信号仿真器会报多驱动冲突。我实际见过有人把时钟和复位写在了同一个always块里导致复位信号也跟着周期性翻转整个设计压根没法正常工作。保持一个信号只有一个驱动源的原则在Testbench里同样铁律一样存在。3.2 timescale仿真世界的时间度量衡timescale 1ns / 1ps这个宏的作用是声明本文件中时间单位的精度。前面的1ns表示所有不带单位的延时都以ns为单位后面的1ps表示仿真器内部的时间精度可以精细到1皮秒。为什么精度这么重要因为它决定了#5到底是不是精确的5ns。如果精度是1ns#5就是5ns如果精度是10ps#5就会四舍五入为5.0ns。我在用Icarus Verilog做仿真的时候经常看到有人因为timescale没写或者精度设置过大导致时钟周期误差累积最后波形对不上、时序错位。建议所有testbench文件都统一写1ns/1ps两个参数别乱改。还有一个容易忽略的坑多个文件编译在一起时timescale与文件所在位置有关。Icarus Verilog默认、Vivado/Modelsim的行为不完全一致。最稳妥的做法是每个testbench文件都自带timescale声明不要指望继承其他文件的设置。3.3 为什么Testbench里要用非阻塞赋值Testbench中给DUT输入信号赋值时我推荐全部使用非阻塞赋值尤其是需要构造一个稳定的、持续多周期有效的输入信号时。阻塞赋值在current time立即生效而非阻塞赋值在时间步结束之前不会更新左值。这可能导致一种有趣的现象你在initial块里用阻塞赋值连续写几个信号值可能只能维持极短时间甚至是0时刻的事件。这在某些情况下是有用的比如快速初始化信号但在模拟正常输入激励时容易产生和预期完全不符的毛刺。举个很常见的场景initial begin a 1b0; #10; a 1b1; #10; a 1b0; end这段代码看起来没毛病。但如果你同时用always块监控a的变化会在某些仿真器上看到a的更新时刻不是精确的10ns、20ns而是出现了delta cycle的偏移。正在做严格时序仿真的项目这种偏移会干扰你判断DUT的单拍时序是否符合预期。我个人的习惯是时钟生成用always块非阻塞赋值激励序列用initial块延迟#非阻塞赋值两者统一使用避免产生事件竞争。这不是绝对真理但实测下来这种风格能省掉很多莫名其妙的仿真bug。4. 时钟与复位仿真世界最容易翻车的地方4.1 分频时钟与相移时钟的生成方法很多时候被测模块需要的不是单一的系统时钟而是一个分频时钟、一个相位偏移90度的时钟甚至两个频率完全不同的时钟。这种情况如果在Testbench里用简单的always #5 clk ~clk就搞不定了需要更灵活的生成策略。一个最常用的分频时钟方案是使用always块配合计数器reg clk_out; reg [3:0] clk_cnt; always (posedge clk) begin if (clk_cnt 4d9) begin clk_cnt 0; clk_out ~clk_out; end else begin clk_cnt clk_cnt 1; end end这个例子就是把100MHz系统时钟分频成10MHz的时钟输出。但注意用寄存器生成时钟输出的clk_out会有组合逻辑寄存器延迟和系统时钟边沿不同步。对大多数功能仿真来说这种影响不大但在做跨时钟域仿真的时候要特别关注相位关系是否正确。相移时钟的生成方法更直接用延迟赋值就可以always #2.5 clk_shifted ~clk_shifted;不需要额外逻辑直接把相位偏移写进延时里。但要注意这种相移时钟是自由运行的它的频率必须和原始时钟保持一致偏移量是周期的一半时等价于反相时钟。4.2 复位释放时机的讲究复位信号虽然简单但释放时机直接决定了DUT从复位态跳转到工作态的瞬间是否正确。异步复位的DUT复位信号必须在时钟有效沿之前或之后足够远的地方释放不能正好落在时钟沿上否则会触发亚稳态——功能仿真不一定能反应出来因为功能仿真不考虑时序延迟但这个习惯如果从一开始就不建立后期做时序仿真一定会头疼。一个比较稳妥的复位写法是initial begin rst_n 0; #30; // 保证复位持续超过3个时钟周期 rst_n 1; // 在时钟上升沿后几ns释放避开竞争 #10; rst_n 1; end更讲究一点的做法是用(posedge clk)来同步释放复位initial begin rst_n 0; repeat(3) (posedge clk); rst_n 1; end这样释放瞬间必然在时钟上升沿之后不会出现复位释放和时钟沿严丝合缝撞上的情况。我强烈建议在正式项目里用这种同步释放的方式至少能排除一类奇怪的初始化问题。4.3 把时钟和复位封装成task假如一个Testbench里需要多次复位DUT、在多个阶段重新初始化环境每次都写一遍initial复位逻辑就很啰嗦。此时把它封装成task是更聪明的选择task reset_dut; begin rst_n 0; #50; rst_n 1; #10; end endtask调用时在测试流程里写一句reset_dut;就行。task的好处是它可以在initial块中被多次调用也可以用always块触发。你把复位操作看作一个可复用的指令包整个仿真流程就会清晰很多。5. 模块化改造task、function与自动比对5.1 把重复的激励写入task如果你要测试一个写FIFO、一个读FIFO、一个同时读写又或者给UART发送一帧数据这些操作本质上都是一组时序固定的信号波形。每次都手动写initial块会让代码冗余得没法看而task就是解决这个问题的天然工具。下面以一个简单的SPI写寄存器操作为例子。假设DUT接收一个8位寄存器地址和一个8位写入数据按SPI模式0时序操作task spi_write; input [7:0] addr; input [7:0] data; integer i; begin cs_n 1; sclk 0; #10; cs_n 0; // 发送地址MSB first for (i 7; i 0; i i - 1) begin sclk 0; mosi addr[i]; #10; sclk 1; #10; end // 发送数据MSB first for (i 7; i 0; i i - 1) begin sclk 0; mosi data[i]; #10; sclk 1; #10; end sclk 0; #10; cs_n 1; end endtask之后在测试流程里你只需要写spi_write(8h2A, 8hF0);就能完成一次完整的写寄存器操作。配合测试数据循环你可以在几十行内完成上百次不同地址的数据写入。可读性和复用性都提升了一个级别。5.2 function的用途场景task适合处理有时序关系的过程性激励function则适合做纯组合逻辑的计算比如校验和、求CRC、地址映射、数据比较。举一个典型场景我们要给DUT喂一个报文数据流每32bit数据后面附带一个CRC校验字段。测试过程中需要实时计算CRC值就可以写成functionfunction [7:0] crc8_calc; input [31:0] data; integer i; reg [7:0] crc; begin crc 8hFF; for (i 31; i 0; i i - 1) begin crc crc ^ {7b0, data[i]}; crc (crc 8h80) ? (crc 1) ^ 8h07 : (crc 1); end crc8_calc ~crc; end endfunction这样在task里生成激励时直接调用crc8_calc(transaction_data)就能拿到实时CRC。测试代码和算法逻辑分离后续CRC多项式如果改动只需要动function内部调用处零修改。注意function不能包含时序控制语句#延时、事件都不行只能做纯组合计算。如果你需要带延时的计算过程就老老实实用task。5.3 自动比对让Testbench自己报错真正有意义的Testbench绝不是跑完波形、人眼盯着看而是自动判断DUT的输出是否符合预期。最简单的自校验方法是内置一个黄金模型将DUT输出和理想输出做比较一旦不一致就主动报错。always (posedge clk) begin if (rst_n) begin if (cnt ! expected_cnt) begin $display(ERROR at time %0t: expected cnt %0d, actual cnt %0d, $time, expected_cnt, cnt); error_count error_count 1; end expected_cnt expected_cnt 1; if (expected_cnt 8hFF) expected_cnt 0; end end用error_count这个计数器统计总错误数在仿真结束前统一print出来initial begin #1000; if (error_count 0) $display(ALL TEST PASSED.); else $display(TEST FAILED: %0d errors., error_count); $finish; end这就是自动化测试的核心思路。每个驱动信号都由一个期望值伴随每个输出信号都被实时监控任何异常都会被记录到日志里。这在大型项目里几乎是唯一靠谱的验证方法——靠人眼在波形图里找bug对超过十几个信号的系统根本不现实。5.4 使用fork join构造并发场景为了测试DUT的并发能力比如同时处理读写请求、同时接收两路数据流你需要让多个激励流程同时推进。Verilog提供了fork join语句块实现并发执行。initial begin fork // 写通道激励 begin spi_write(8h10, 8hAA); spi_write(8h11, 8hBB); end // 读通道监控 begin wait(rx_valid); data_received rx_data; $display(Received: %h, data_received); end join endfork块内的所有语句并行执行直到最慢的一个完成join才会继续向下推进。这种写法在构造冲突访问同一个时钟沿发起多笔操作的场景时非常有用。不过不推荐过度使用嵌套层数太深会把可读性搞崩非必要不用。6. 波形、日志与文件读写让仿真结果变高效6.1 记录波形的标准三件套在不同仿真工具里记录波形的方式不太一样但大体逃不过以下三件事在Testbench顶部加initial begin $dumpfile(tb_counter.vcd); $dumpvars(0, tb_counter); end生成VCD波形文件。在Vivado里如果是仿真工程默认会打开wdb格式的波形数据库不需要额外配置但如果用命令行仿真就需要通过xsim的tcl命令添加信号到波形窗口。Icarus Verilog则更直接通过vvp执行编译产物时加上-lxt2生成LXT2格式波形再用GTKWave打开。我个人常用的是Icarus Verilog加GTKWave组合轻量、跨平台处理小规模Testbench效率特别高。用法很简单iverilog -o tb_counter.vvp tb_counter.v counter.v vvp tb_counter.vvp gtkwave tb_counter.vcdVCD文件本质上是文本格式记录所有信号的变化。如果信号很多、仿真时长很长VCD文件会膨胀到几百MB甚至几GB此时换成LXT2或FSDB格式会好很多但前提是仿真工具支持。Vivado自带的功能仿真走的是xsim用-log选项也能把log重定向到文件方便用脚本做批量回归。6.2 用文件输入驱动大批量测试数据手动在Testbench一行行列举测试数据只适合数量在十几个以内的用例。真正的大型模块测试比如DHCP包处理、图像滤波、傅里叶变换这类数据密集型任务测试数据必须放在外部文件里Testbench启动的时候批量加载。Verilog里读取文本文件的经典做法是使用$fopen、$fscanf和$fcloseinteger fp; reg [31:0] test_data; initial begin fp $fopen(stimulus.txt, r); while (!$feof(fp)) begin $fscanf(fp, %h\n, test_data); // 把test_data打进DUT的输入端 (posedge clk); din test_data; end $fclose(fp); end对应写入文件也同样简单。仿真过程中把DUT的输出实时记录到文本文件这个文件就可以直接丢给其他工具做分析比如用Python画时序曲线、和参考C模型输出做diff。integer fp_out; initial fp_out $fopen(output.txt, w); always (posedge clk) begin if (rst_n dout_valid) $fwrite(fp_out, %h\n, dout); end6.3 日志分级与断言的工程做法真正复杂的Testbench你不可能在$display里把每个信号的变化都打印出来——那会产生海量log重要信息反而淹没在噪声里。我的习惯是把日志分级关键事件复位释放、模式切换用$display输出。中间状态跟踪用$display加条件判断只有特定配置下才打印。详细信号追踪放到$monitor里并且默认注释掉需要调试时才启用。断言assertion是Verilog里一直被低估的功能。用assert语句可以把协议检查、时序检查直接写进Testbenchalways (posedge clk) begin assert (req grant) else $error(Protocol violation: grant must be high when req is high.); end这种写法比手动if判断要简洁而且工具会为每条断言生成独立的结果集成到回归报告里CI系统可以直接依据断言结果判断测试是否通过。对那些跑一次能出一堆波形、但没人知道对不对的项目断言几乎是救命稻草。7. 哪些信号要加时间约束哪些不能Testbench里时间的把控其实比RTL还讲究。RTL是逻辑设计时间是时钟沿决定的Testbench是物理世界的模拟器任何激励信号的持续时间、建立时间、保持时间都必须人为显式指定。但这里有个值得注意的度。如果你为每一个信号翻转都精确指定时间比如#3.7 data 8hA5; #2.3 data 8h3C;这种写法极其脆弱。一旦DUT的时钟频率调整或者某个内部时序参数修改所有时间点全部作废维护成本高到你不想再碰这个Testbench。更合理的做法是尽量以时钟沿为锚点用(posedge clk)来同步激励让时间偏移量只在必要的建立保持时间处出现。举个例子如果你想测试一个组合逻辑输出能否在时钟沿前稳定可以这样(posedge clk); #2; // 时钟沿后2ns模拟DUT输出延迟 expected_data dout;这样测试对时序延迟的敏感性就集中在#2这一处而不是散落在整个Testbench各处。尽量把时间数字控制在少数几个时间锚点上其他都跟着时钟走这是让Testbench具备可维护性的关键习惯。8. 总结几个写Testbench的长期习惯很多项目做完之后回头复盘最耗时间的往往不是RTL设计而是验证环境为什么又不跑了。结合我自己的调试经历这里沉淀几条长期有效的习惯每个Testbench都必须有明确的仿真结束机制。要么用$finish主动结束要么用有限次重复的always块自然终止。最怕那种仿真器跑了几小时还在原地打转的Testbench多半是某个always块没有结束条件。信号命名要有规律。Testbench里和DUT端口同名的信号建议加前缀tb_比如tb_clk、tb_rst_n区分来源避免波形窗口里混淆。尤其是多模块项目这点能省下大量定位时间。不要在一个initial块里把所有激励全部堆完。把一个完整测试流程拆成多个initial块模块之间用事件触发和wait协作。比如复位在一个块主激励在一个块自动比对在一个块日志汇总在一个块。这样任何一段逻辑出问题你都能快速定位。用宏定义提升复用性。比如define CLK_PERIOD 10然后时钟生成写成always #(CLK_PERIOD/2) clk ~clk;要改时钟频率就只改一处。同样总线位宽、数据深度、超时阈值这些常量能提成parameter或define就不要散落在数字里。仿真结束后务必检查error_count和断言结果。如果Testbench没有自动判断对错跑完的波形就算全绿也说明不了任何问题。自动比对应该是定义测试通过的唯一标准而不是波形看起来挺正常。Testbench这个主题看着不大实际水很深。从一个最简单的计数器开始到task封装、自动比对、文件读写每一步都是把你的验证能力提升一个层级的关键节点。往后做稍微复杂一点的模块比如异步FIFO、UART收发器、SPI控制器这套方法论完全可以直接平移过去。我最真实的体会是Testbench不是RTL的附属品它是一门独立的编程技术。你的验证环境有多健壮你对自己设计的信心就有多足。写Testbench这件事值得花时间而且要把它当成和写RTL一样认真对待的工程任务。
返回列表