SystemVerilog验证环境实战:从class到覆盖率闭环

SystemVerilog验证环境实战:从class到覆盖率闭环 1. 这篇笔记到底在聊什么拿到第9篇这个编号先说个实话SystemVerilog的学习曲线走到这里基本已经过了语法扫盲阶段剩下的是怎么用面向对象的思想去搭一个真正能跑的验证环境。前面几篇如果还在纠结always_ff和always_comb的区别、logic和reg的纠葛那从这一篇开始就该切换到验证工程师的日常视角了——思考的是怎么把DUTDesign Under Test测透、怎么让激励可复用、怎么在回归测试里一眼定位问题。这一篇我定位为验证环境骨架搭建的实战笔记。主题很聚焦用SystemVerilog的类class、约束随机constrained random、功能覆盖率functional coverage这三板斧去搭一个最小但五脏俱全的验证环境并完整跑通一个带AXI-Lite接口的DUT例子。它不是一个RTL设计教程也不是单纯讲语法而是告诉你这些语法在真实验证项目里是怎么被组织起来的。适合谁看已经懂Verilog基础、能写简单testbench、但对class和随机约束还停留在概念的验证初学者。如果你做过一两个小模块验证想从写TB跨到搭环境这一步这篇笔记应该能给你省点时间。看完之后你可以照着代码改一改直接用到自己手头的小模块验证里。2. 为什么非得用class来写TB2.1 从一个module干到底到对象分工协作老派的Verilog testbench风格通常是这样一个顶层module里面例化DUT用initial块给信号赋值用$display看波形完事。这种写法在模块很小、测试场景只有两三个的时候效率不低逻辑也清楚。但一旦DUT接口变复杂、测试用例到二三十个你会立刻撞上两个痛点——激励产生和结果检查全揉在一起改一个场景容易误伤另一个代码基本没法复用换一个DUT header就得大改TB。SystemVerilog的class解决的就是组织性问题。class把数据成员变量和操作这些数据的方法task/function绑定在一起形成一个独立的对象。验证环境里最典型的对象划分是这样的transaction描述一次总线操作包含地址、数据、读写方向、随机约束等。driver负责把transaction里的信息变成时序信号驱动到DUT的接口上。monitor反向操作从接口上采样信号还原成transaction。scoreboard拿到driver发送的激励和monitor采集的响应做数据比较。这样的拆分之后每一块的职责是单一的你可以在不改动其他部分的情况下单独升级某一层。比如DUT协议从AXI-Lite换成APB理论上你只需要替换driver里驱动和采样的代码transaction、scoreboard基本可以原样保留。2.2 为什么说句柄是理解class的关键很多从Verilog转过来的朋友第一次写class容易卡在这个对象到底在哪这个问题上。这很正常因为Verilog的module是静态的你一例化它就存在了而class对象是动态创建的你必须要new一下才真正存在。讲个类比module就像一个已经建好的实体商铺门牌号固定你随时可以进去class则像一张店铺设计图new一次就相当于照着图纸在某个地址开了一家实际店铺而句柄handle就是记录这家店地址的纸条。你可以new多个对象各自指向不同的地址互不干扰。这个思想在后面配置多个driver、多个agent时会反复用到。class axi_transaction; rand bit [31:0] addr; rand bit [31:0] data; rand bit we; constraint c_addr_range { addr inside {[32h0000_0000 : 32h0000_00FF]}; addr % 4 0; // AXI-Lite要求地址按4字节对齐 } function void display(); $display(addr0x%08h data0x%08h we%0b, addr, data, we); endfunction endclass上面的代码定义了一个类它和生成一个随机读写操作这个概念严格对应。rand关键字声明了addr、data、we这三个随机变量constraint限定了随机范围。你不需要手写随机算法SystemVerilog的randomize()机制会自动处理。关于随机约束的细节第3节会展开。类的定义本身只是图纸真正开始用时需要这样axi_transaction trans; trans new(); assert(trans.randomize()); trans.display();new()给对象分配内存randomize()根据约束生成随机值display()打印出来看结果。三个步骤对应到验证环境里就是transaction被构造、被随机化、被driver消费的过程。2.3 包package的作用域管理随着类数量变多直接把它们放在同一个编译单元里会出现命名冲突的风险。SystemVerilog的package就是干这个的类似于C里的namespace。把通用的transaction类、配置类都放进一个包里环境代码和测试用例代码再用import引入。package axi_pkg; class axi_transaction; // 类定义 endclass class env_config; int timeout_cycles 10000; endclass endpackage import axi_pkg::*;有一点值得注意package里最好只放类和函数不要放module。module的例化还是得靠顶层模块去完成package管不到这一层。另外如果你在一个package里定义了类又在另一个package里定义了同名类import顺序会严重影响编译结果所以包名要起得有辨识度别用pkg1、pkg2这种。3. 约束随机让验证环境自己找茬3.1 随机不是瞎随机约束随机验证CRV是SystemVerilog相对Verilog最大的优势之一也是UVM方法论的核心。它的意义在于用有限的代码生成海量的合法激励组合用机器代替人去穷举那些手写测试用例很难覆盖到的边界值。但随机不代表乱来。你想想如果地址范围是0x0000_0000到0xFFFF_FFFF而你随机出来的地址大部分落在高位区域那么低位地址上的寄存器配置逻辑可能整个项目周期都测不到。所以必须有constraint来控制随机分布。上面例子里的constraint做了三件事地址限制在低256字节范围内保证不出地址越界地址按4字节对齐符合AXI协议要求we没有约束所以会在0和1之间随机产生读写混合的操作。这种有条件的随机才是CRV的精髓。3.2 约束的几种常用写法约束语法在SystemVerilog里非常灵活我挑几个干活儿最常用的放在这里每个都会配上使用场景。第一种是inside操作符用于限定取值范围。典型场景是地址空间划分constraint c_addr_range { addr inside {[0:255], [1024:2047]}; addr % 4 0; }第二种是权重分布dist。典型场景是让某种特殊操作低概率出现比如写操作占80%读操作占20%。这里用:表示区间内每个值的权重用:/表示整个区间的权重两者差别很容易被忽略但影响很大。constraint c_we_dist { we dist {0 : 20, 1 : 80}; // 1写出现概率是0读的4倍 }第三种是条件约束if-else。典型场景是依赖关系比如只有写操作时数据才有效。注意-操作符是implies的意思和Verilog里的-完全不同不要搞混。constraint c_conditional { (we 1) - (data ! 0); }实际项目里约束会越叠越多管理起来也容易乱。我建议把约束按功能分组放在一起这样回头看代码时能快速理解这个transaction的随机策略是什么而不是散落在类定义的各个角落。3.3 使用randomize()时的几个坑第一个坑是randomize()的返回值。这个函数成功返回1失败返回0。如果约束本身有不可满足的地方比如把地址范围限定在一个不可能满足的区间就会返回0。所以调用处务必加assert或者至少打印错误信息。我自己习惯写成assert(trans.randomize()) else $fatal(randomize failed);这样保证随机失败时仿真能立刻停下来而不是带着悬空的数据继续跑最后排查半天查不出原因。第二个坑是约束之间的冲突。如果一个类里同时有两个constraint块对同一个变量做了互斥的限制randomize会失败。这时候SystemVerilog提供了constraint_mode()方法可以动态打开或关闭某个约束块运行时灵活调整随机策略比改代码重新编译高效得多。第三个坑是solve...before...的使用。SystemVerilog的随机求解器在约束复杂时求解顺序可能和你想的不一样。比如你希望addr先随机data再根据addr的值去随机可以用solve addr before data;语句明确顺序避免极端情况下解出来的组合分布不均匀。4. 从transaction到可运行环境完整实战前面铺垫了这么多这一节直接上硬货。我用一个带AXI-Lite接口的寄存器模块做DUT搭一个mini验证环境包含driver、monitor、scoreboard三件套然后用随机激励跑通它。代码风格尽量贴近实际项目但做了精简方便阅读。4.1 DUT与接口interface定义DUT的代码不在这里全贴功能很简单内部有16个32位寄存器支持AXI-Lite读写的地址范围是0x00到0x3C写入数据在写使能有效时存入对应寄存器读操作返回寄存器当前值。真实项目里这个DUT可能是寄存器配置块、FIFO控制器或者简单的桥接逻辑复杂程度不重要关键是接口信号足够标准。SystemVerilog的interface是连接DUT和TB的关键抽象。老式TB的信号连线方式信号名不一致或者位宽一改所有模块的端口列表都要跟着改非常痛苦。interface把一组相关信号打包在一起TB这边只要声明interface句柄就能复用整个信号组。代码长这样interface axi_lite_if(input logic clk, input logic rst_n); logic [31:0] awaddr; logic awvalid; logic awready; logic [31:0] wdata; logic wvalid; logic wready; logic [1:0] bresp; logic bvalid; logic bready; logic [31:0] araddr; logic arvalid; logic arready; logic [31:0] rdata; logic rvalid; logic rready; // 写通道 clocking drv_ck (posedge clk); output awaddr, awvalid, wdata, wvalid, bready; input awready, wready, bvalid, bresp; endclocking // 读通道 clocking mon_ck (posedge clk); input araddr, arvalid, awaddr, awvalid, wdata, wvalid, rdata, rvalid; endclocking endinterface这里的clocking block是另外一个容易被忽略但极其重要的语法。它把信号的驱动和采样时刻统一锁定了避免在不同模块里因为竞争产生时序偏差。drv_ck里output表示driver在这个时钟沿驱动信号input表示driver在这里采样DUT的响应mon_ck整体用于monitor采集接口上的活动。这样声明后在driver代码里用vif.drv_ck.awvalid 1b1;就可以在正确的时钟相位输出信号不用自己控制#1之类的延迟。4.2 driver从transaction到接口时序driver的核心任务将在前5分钟完成。它的逻辑可以用下面这段简化的代码来体现class axi_lite_driver; virtual axi_lite_if vif; mailbox #(axi_transaction) gen2drv; task run(); axi_transaction trans; forever begin gen2drv.get(trans); if (trans.we 1) drive_write(trans); else drive_read(trans); end endtask task drive_write(axi_transaction trans); (posedge vif.clk); vif.awvalid 1b1; vif.awaddr trans.addr; vif.wvalid 1b1; vif.wdata trans.data; wait (vif.awready vif.wready); (posedge vif.clk); vif.awvalid 1b0; vif.wvalid 1b0; vif.bready 1b1; (posedge vif.clk); vif.bready 1b0; endtask // drive_read的实现思路类似不再赘述 endclass这个类里用了mailbox来接收上游通常是sequencermini环境里可以用一个简单的generator过程代替送过来的transaction。mailbox是SystemVerilog内置的线程安全队列get()会阻塞等待直到有数据进来天然实现了激励生成和激励驱动两个线程之间的同步。值得留意的细节是wait (vif.awready vif.wready);这一行。AXI-Lite协议里write address和write data两个通道的ready可以是独立拉高的必须等两个都就绪了才能认为这一次写操作被接受了。很多刚写driver的人会在awready拉高后立刻结束写操作这会造成数据丢失。这个时序细节在实际波形调试时特别容易踩一定要记牢。4.3 monitor与scoreboard如何判断对与错monitor的作用和driver正好相反它从接口上采集信号并重组成transaction。mini环境里的monitor主要负责抓写操作和读操作以及它们的返回值然后把payload送给scoreboard。scoreboard的比较策略最简单的版本是reference model comparison。由于DUT是寄存器块它的行为一目了然写什么进去之后再读就能读回什么。那reference model就可以是一个关联数组associative array记录每个地址当前应该存的值。流程是这样的当monitor抓到一次写操作awvalid wvalid awready wready就更新数组reg_model[addr] data;当monitor抓到一次读操作把返回的rdata送给scoreboard。scoreboard拿rdata和reg_model里查到的期望值对比一致则pass不一致则报错。下面这是scoreboard里核心比较代码的示意class axi_scoreboard; bit [31:0] reg_model[int]; function void write_observed(input bit [31:0] addr, input bit [31:0] data); reg_model[addr] data; $display([SCB] write addr0x%08h data0x%08h, addr, data); endfunction function void read_observed(input bit [31:0] addr, input bit [31:0] rdata); bit [31:0] expected; if (reg_model.exists(addr)) begin expected reg_model[addr]; end else begin expected 32hDEAD_BEEF; // 未初始化寄存器打印警告 $warning([SCB] read from uninitialized addr 0x%08h, addr); end if (rdata ! expected) begin $error([SCB] mismatch! addr0x%08h rdata0x%08h expected0x%08h, addr, rdata, expected); end else begin $display([SCB] match! addr0x%08h data0x%08h, addr, rdata); end endfunction endclass这种reference model的方式看起来简单但它是scoreboard设计的基础。真实项目里如果DUT是一个FIFO那reference model就要模拟FIFO的读写指针行为如果是一个协议转换器那就要模拟协议转换的延迟和格式变化。核心思想是一致的找一个可信的参照物用它产生期望值再和DUT的实际响应做比较。懂了这一套后面各种复杂scoreboard都只是在此之上的扩展。4.4 顶层环境组装把各部件连起来跑环境组装这一步是很多自学者最头疼的——类都写好了怎么把它们串起来答案是用一个env类作为总装车间。它内部创建driver、scoreboard对象把interface句柄传进去再把mailbox都连接好。class axi_env; axi_lite_driver drv; axi_scoreboard scb; mailbox #(axi_transaction) gen2drv; mailbox #(axi_transaction) mon2scb; virtual axi_lite_if vif; function new(virtual axi_lite_if vif); this.vif vif; gen2drv new(); mon2scb new(); drv new(); scb new(); drv.vif vif; drv.gen2drv gen2drv; // monitor等其他成员同理初始化 endfunction task run(); fork drv.run(); // mon.run() 和 scb.run() 在此fork join_none endtask endclass这里有一个关键点gen2drv和mon2scb这两个mailbox必须在new drv对象之前先创建。因为drv的构造函数会把mailbox句柄赋值给drv内部的成员如果你创建drv之后再new mailboxdrv拿到的就是空指针后面调用get()会直接报空句柄错误。这类先创建共享资源再创建消费者的顺序问题在复杂环境里会经常遇到踩过一次就长记性了。顶层testbench部分则是传统Verilog风格的延续生成时钟、复位例化interface和DUT然后创建env对象并启动。一个简化版长的这样module tb_top; logic clk 0; logic rst_n 0; always #5 clk ~clk; axi_lite_if vif(clk, rst_n); dut u_dut( .aclk(clk), .aresetn(rst_n), .awaddr(vif.awaddr), .awvalid(vif.awvalid), .awready(vif.awready), // 其余端口省略 ); initial begin axi_env env; repeat(5) (posedge clk); rst_n 1b1; env new(vif); env.run(); // 通过generator往mailbox里塞transaction // 等待若干时间后 $finish end endmodule值得强调的是virtual interface的使用是这个架构的核心如果drv内部直接引用一个全局的interface代码就无法复用了也不可能同时跑多个相同接口的实例。5. 功能覆盖率怎么知道测够了5.1 覆盖率点的设计思路功能覆盖率是SystemVerilog相对传统TB的另一个核心能力。它的目的不是检查对不对而是回答另一个问题这一轮测试跑完之后到底哪些功能点被踩过了哪些还完全没有被cover到回到这个AXI-Lite寄存器模块的例子你要测的功能点至少应该包括每个地址0x00到0x3C是否都被写入过是否有读到过读写操作的比值分布是否合理是否覆盖了连续写、连续读、混合操作这些场景是否有访问未定义地址的操作这种行为DUT怎么处理你不需要把所有关心的事情都放到一个covergroup里更好的做法是按功能模块建独立covergroup。这个例子里我会建立两个一个covergroup覆盖地址区间一个covergroup覆盖操作模式。covergroup cg_addr (posedge vif.clk); coverpoint vif.awaddr { bins low_addr {[32h00:32h0F]}; bins mid_addr {[32h10:32h1F]}; bins high_addr {[32h20:32h3C]}; } endgroup covergroup cg_op (posedge vif.clk); coverpoint vif.awvalid vif.awready { bins write {1}; bins no_write {0}; } endgroup在monitor采到有效操作时调用这两个covergroup的sample()方法仿真结束时工具会自动汇总覆盖率报告。如果你的环境跑完一轮发现low_addr的覆盖率只有30%那就是说低地址段的寄存器配置逻辑基本没被触发测试场景还不够——这是一个非常直接的测够没有的量化信号。5.2 覆盖率与约束的配合一边跑一边调功能覆盖率是CRV的眼睛。覆盖率驱动验证coverage-driven verificationCDV的基本工作流是先用约束随机跑一批激励看覆盖率报告根据未覆盖的点调整约束或增加针对性用例再继续跑如此循环直到关键指标达标。举例来说如果你发现cg_addr里high_addr的覆盖率很低说明随机约束把大部分地址都压在了低地址段。这时候你不需要重写整个constraint只需要在同一transaction上临时打开一个更宽范围的约束块关掉原来那个窄范围的。这个操作在SV里就是用constraint_mode(0)和constraint_mode(1)来控制的。trans.c_addr_range.constraint_mode(0); trans.c_addr_wide.constraint_mode(1); assert(trans.randomize());实战中覆盖率-约束调整的这个闭环是验证效率提升的关键。经验是用一整块时间跑回归、看覆盖率、调整不要频繁小步改否则很难看出某个约束调整对整体覆盖率的影响。6. 编译仿真流程与常见报错6.1 命令行直接做这个mini环境没有依赖UVM库所以用什么仿真器都行。我用的是最常见的编译仿真命令语法和主流仿真器差异不大。# 假设源码按以下文件组织 # 1. axi_lite_if.sv # 2. axi_pkg.sv # 3. axi_transaction.sv # 4. axi_lite_driver.sv # 5. axi_monitor.sv # 6. axi_scoreboard.sv # 7. axi_env.sv # 8. tb_top.sv # 9. dut.sv vlog -sv axi_lite_if.sv axi_pkg.sv axi_transaction.sv \ axi_lite_driver.sv axi_monitor.sv axi_scoreboard.sv \ axi_env.sv tb_top.sv dut.sv vsim -c work.tb_top -do run -all; exit编译顺序上package和interface要放在使用它们的类之前这个顺序错了编译器会报cannot find type之类的错误。如果你用的是VCS或Questa命令略有不同但逻辑一致。6.2 编译报错的几个高频原因我在教同事和带实习生时遇到最多的是下面这几类错误列出来供你排查时参考错误类型典型报错信息原因与解决办法类型找不到Unknown type axi_transaction编译顺序问题或者忘记import axi_pkg::*;端口连接不匹配Cannot find port awaddrinterface信号名和DUT端口名不一致检查拼写空句柄访问Null object access创建mailbox/env对象之前就去调用方法检查new顺序随机化失败randomize() failed约束不可满足用constraint_mode()临时关闭冲突约束调试仿真卡死波形长时间无变化driver里wait条件没满足检查握手信号逻辑第五种情况尤其值得展开说一下。仿真卡死很多时候不是代码逻辑崩溃了而是某个wait(condition)条件永远不满足整个task就卡在那里不动了。排查方法是查看波形上握手信号的状态——比如你是不是在等awready但DUT因为复位没释放或状态机卡住awready一直没拉高。这时候用$time配合$display打印状态信息会比盲看波形快得多。6.3 关于随机种子和可复现性仿真器支持命令行设置随机种子比如QuestA里是-sv_seed 12345。这是个很实用的功能回归测试跑挂了拿到失败场景的seed号重跑相同的seed就能精确复现问题。如果没有这个机制一次随机激励挂了但重跑又好了排查起来会极其痛苦。所以我强烈建议在编译仿真脚本里把seed号加进日志文件名比如run_top_seed12345.log这样挂了之后随时能找到对应日志系统性地分析问题而不是靠运气复现。7. 几个我反复提醒自己的设计心得真正动手搭完这套环境后有一些体会是会上瘾的——以后写任何验证TB这些原则都会不自觉地带进去。第一接口尽量用interface不要用散信号。虽然初学阶段散信号看起来更直观但项目一旦规模上来散信号连线的维护成本会指数级上升。interface带来的封装性让自己在加信号位宽时不用到处找引用点。代价只是初期多写几行代码这笔投资非常值。第二mailbox的命名要带着数据流方向。比如gen2drv、mon2scb这种命名比mbx1、mbx2清晰太多了。很多时候排查问题第一件事就是梳理数据流如果类的成员变量和mailbox命名能直接反映流向会省掉非常多的时间。第三constraint的可读性比精确性更重要。有一次我为了一些概率分布上的问题把constraint写得极其复杂逻辑上完全正确但过了一周自己都看不懂了。后来调整思路把复杂约束拆成多个简单约束块虽然最终随机分布和以前略有差异但可维护性大幅提升。验证环境是长期演进的东西代码是写给人看的其次才是给工具跑的。第四别急着上UVM。很多朋友一开始就奔着UVM去结果被UVM的factory机制、config_db、sequence机制搞得头昏脑胀反而不理解最基本的driver/monitor/scoreboard到底在干嘛。我建议先用纯SystemVerilog把自己的mini环境搭一遍跑通之后你再看UVM源码会发现那些复杂机制不过是在解决你踩过的那些痛点——对象怎么创建、配置怎么传、sequence怎么控制。地基打牢了上层建筑学起来快很多。8. 这一篇笔记的最后补充关于SystemVerilog的学习如果说前8篇是在学词汇和句子那这第9篇就是第一次尝试写一段完整的段落。class解决了组织结构随机约束解决了激励生成功能覆盖率解决了测够没有的问题这三样东西组合起来已经构成了一套可以自洽运行的验证方法雏形。后续如果要继续深入值得探索的方向包括UVM的factory与sequence机制如何把这套mini环境标准化寄存器的RAL模型如何自动生成并维护reference model带中断和事件驱动的DUT该怎么在scoreboard里建模。这些方向都是从一个mini环境出发一步步长出来的。最后分享一个小技巧把你搭好的mini环境保存好不要轻易删。之后每学一个新概念都试着在当前环境里加一个小功能比如加一个beautiful display的波形打印、加一个协议违例检测的property。这样积累出来的环境才是真正属于你自己的验证百宝箱。我自己最初的那个寄存器验证环境到现在还在用只是已经从几十行长到了上千行但它是我理解SystemVerilog所有高级特性的原点。