刚开始啃《UVM实战》卷I的时候我一直有个执念要跑通一个完整的验证平台才算入门。结果翻到第二章第一个正经例子发现作者只写了一个driver孤零零地挂在平台上连monitor都没有。当时我第一反应是——这也能叫验证平台DUT被驱动之后发生了什么根本没人管。但等我亲手把这十几行代码敲进工程、编译、跑仿真、看波形再回过头理解UVM的组件关系时才明白这个极简平台的分量driver是整个UVM平台上第一块真正有实际动作的砖它握向DUT的那只手。后面那些调度、监测、比对全都要从这个最基本的驱动行为上长出来。这篇文章是我的学习笔记第一篇围绕只有driver的UVM验证平台把代码结构、运行机制、仿真过程和我踩过的几个坑都摊开讲一遍。如果你也在啃这本册子或者正打算入门UVM验证这篇文章应该能让你少绕几个弯。1. 为什么UVM入门的第一课偏偏是driver1.1 验证平台的本质职责一个验证平台要做的事说白了就三件把激励送进DUT、观察DUT的输出、判断结果是否符合预期。第三件事又可以拆成参考模型预测期望值和比较器比对实际值和期望值。这三件事听着很多但如果你仔细看它们的依赖关系会发现第一件事是整个链路的源头——没有激励进去后面两件事根本无从谈起。UVM把这套职责拆成了明确的组件类。送激励这件事落在driver身上观察输出落在monitor身上比对落在scoreboard身上预测期望值落在reference model身上。再加上负责组织数据的sequence、负责装配的agent和env就构成了一套完整的UVM验证平台。很多人一上来看到这么多组件头都大了但你要记住一个核心整个平台的所有组件里真正跟DUT输入引脚直接打交道的就是driver。monitor可能也会去碰DUT输出引脚但那是看不是推。1.2 driver那个离DUT最近的组件为什么要先学driver因为它是验证平台里最靠近DUT、也最容易产生直观感受的组件。你写一个简单的驱动任务给DUT的输入端口赋一组值然后在波形里看到DUT的输出跟着变化这种正反馈在UVM学习初期非常宝贵。《UVM实战》的作者显然也这么想所以他把只有driver的验证平台放在了最简单的例子里。有些初学者会疑惑只有driver的验证平台根本没有一个完整验证平台的样子它既不能自动比对也不能大量产生随机激励学它干嘛我的理解是这个例子的目的不是让你用它去验证什么复杂设计而是让你先建立起三个最基本的UVM操作直觉第一类怎么注册、怎么被创建第二组件里哪些代码在build_phase里做、哪些在run_phase里做第三UVM的run_test到底把整个平台的骨架搭起来之后代码是怎么跑起来的。这三个直觉后面会反复用到比一上来就背一堆组件关系图有用得多。1.3 从传统testbench到UVM driver的思维转变如果你是直接从Verilog testbench转过来的对driver的第一反应可能是这不就是initial块里那句din xxx吗对本质上是但表达方式完全变了。传统testbench里你在initial块里用时间控制语句#10、(posedge clk)去驱动信号整个testbench是扁平的、过程式的。而UVM里driver是一个类它有自己的生命周期通过phase机制管理、有自己的层次位置通过parent参数挂在组件树上、有可配置的通信方式从sequence拿数据、把数据送给DUT。这个转变最关键的一点是从写一段时序过程到实现一个组件行为。你不再关心这个driver在仿真时间轴上的绝对位置而只关心在对应phase里它应该做什么。至于什么时候被调用、调用几次UVM的调度器会帮你安排。刚开始可能不太适应但这种思维正是UVM想带给验证工程师的——把验证平台从一段脚本变成一套分工明确、可复用的组件系统。2. 先搭骨架DUT、interface与顶层tb三方如何配合2.1 DUT端与interface给DUT造一条整洁的信号通道要让一个driver跑起来得先有一个目标DUT。这里我用的DUT非常简单逻辑只有一个把输入din在时钟上升沿打一拍输出到dout异步复位有效时清零。这种DUT虽然没有任何验证难度但当学习用的目标刚刚好因为你完全知道它应该输出什么可以随时验证driver的行为是否正确。module dut( input logic clk, input logic rst_n, input logic [7:0] din, output logic [7:0] dout ); always_ff (posedge clk or negedge rst_n) begin if (!rst_n) dout 8h0; else dout din; end endmodule接下来要解决一个问题driver是SystemVerilog的classclass在仿真器里是动态对象它怎么访问DUT端口这种静态信号答案是通过interface。interface是SystemVerilog里专门用来封装信号的机制它既能在顶层例化又能通过virtual interface句柄进入class内部。interface里可以放一组相关的信号比如把DUT的输入输出都集中起来让driver和monitor都通过它和DUT打交道。interface my_if(input logic clk, input logic rst_n); logic [7:0] din; logic [7:0] dout; endinterface我把clk和rst_n做成interface的端口这样一来顶层tb只要把时钟和复位接到interface上DUT的clk、rst_n也从这个interface的端口取din和dout则通过interface内部信号传递。这样做的思路是时钟复位属于全局资源在顶层统一产生数据通道才是driver真正要操作的对象。2.2 顶层tb时钟、复位与run_test有了DUT和interface再写顶层tb。顶层tb里要做三件事产生时钟、产生复位、调用run_test启动整个UVM环境。不要小看这个顶层模块UVM的整个树形组件结构就是从run_test这句话开始长得出来的。module top_tb; logic clk; logic rst_n; initial begin clk 1b0; forever #10 clk ~clk; end initial begin rst_n 1b0; #100 rst_n 1b1; end my_if u_if( .clk (clk), .rst_n(rst_n) ); dut u_dut( .clk (clk), .rst_n(rst_n), .din (u_if.din), .dout (u_if.dout) ); initial begin uvm_config_db#(virtual my_if)::set(null, uvm_test_top.drv, vif, u_if); run_test(my_test); end endmodule这段代码有个很关键的地方run_test(my_test)这一句话会自动创建一个名为my_test的类实例放在uvm_test_top这个位置。也就是说UVM不需要你在顶层手写my_test test new(test)这种代码所有组件的创建都交给UVM的工厂机制去完成。这也是初学者容易懵的地方很多类你根本没有显式new过它们怎么就存在了答案就是run_test以及后续讲到的build_phase和factory create机制。至于uvm_config_db那行set它的作用是把刚才那个vif的句柄存进一个全局配置数据库路径指向uvm_test_top.drv。等my_test创建完毕、开始build_phase时它又会创建my_driver而my_driver在build_phase时会去这个数据库里用同一个路径取回vif。这样动态class世界和静态硬件信号世界就打通了。2.3 为什么class里访问硬件信号必须用virtual interface我当初在看interface相关代码时心里有个大大的疑问既然top_tb里已经有了u_if这个实例为什么不能直接把u_if传进driver类里非要弄一个virtual interface原因在于SystemVerilog的编译模型interface作为一个实例是静态硬件层次上的东西它在elaboration阶段就确定了。而class是动态对象是仿真运行后才new出来的。你可以把一个interface的实例引用赋给一个virtual interface类型的句柄但不能把一个interface类型直接塞进class的字段。这就像你不能把整个舞台搬进演员的脑海里但可以给演员一张舞台地图让他知道去哪里干活。这张地图就是virtual interface。另外从复用角度讲virtual interface可以指向任何满足同样接口定义的interface实例。以后你有多个DUT或者多组配置只要换上不同的interface句柄driver的代码一行都不用改。这正是类封装带来的好处。3. 孤零零的driver类代码逐段拆开看3.1 类声明、工厂注册与new函数下面就是这节课的主角driver类。整个类只有几十行但每一行都值得细看。它几乎用上了UVM组件类最核心的几个概念工厂注册、phase机制、config_db配置、objection控制。对初学者来说看懂这几十行等于一只脚已经踏进了UVM的大门。include uvm_macros.svh import uvm_pkg::*; class my_driver extends uvm_driver; virtual my_if vif; uvm_component_utils(my_driver) function new(string name my_driver, uvm_component parent null); super.new(name, parent); endfunction virtual function void build_phase(uvm_phase phase); super.build_phase(phase); if (!uvm_config_db#(virtual my_if)::get(this, , vif, vif)) begin uvm_fatal(my_driver, get vif failed, check config_db set path) end endfunction virtual task run_phase(uvm_phase phase); phase.raise_objection(this); uvm_info(my_driver, run_phase is called, UVM_LOW) wait(!vif.rst_n); wait(vif.rst_n); repeat (8) begin (posedge vif.clk); vif.din $urandom_range(0, 255); uvm_info(my_driver, $sformatf(drive din %0h, vif.din), UVM_MEDIUM) end uvm_info(my_driver, run_phase is finished, UVM_LOW) phase.drop_objection(this); endtask endclass类声明是class my_driver extends uvm_driver这里继承了uvm_driver。uvm_driver是UVM内置的一个组件类它本身又是uvm_component的子类。在同一个包里uvm_driver还有个重要的兄弟是uvm_sequencer后面学sequence机制时driver就是通过uvm_driver内建的seq_item_port和sequencer通信。但在这个最简例子里我们没有sequence、没有transactiondriver就用自己的逻辑直接驱动信号所以完全用不到这些内建端口。紧接着类声明的是virtual my_if vif;这个句柄就是driver通向外部硬件的唯一通道。uvm_component_utils(my_driver)这个宏是UVM的工厂注册机制它会在类里插入一堆静态方法让UVM之后能用my_driver::type_id::create(...)这种形式创建实例而不需要直接用new。为什么不用new因为工厂机制让你可以在不改代码的前提下通过命令行参数或者配置去替换某个类的具体实现这是高级用法。现阶段你只需要记住凡是UVM组件类都要用这个宏注册否则factory创建时会报错。new函数没啥特别的就是调用父类的构造把组件名和父节点记下来。在这里name默认是my_driverparent默认是null。但用create创建时实际传进去的name和parent由创建位置决定比如在my_test的build_phase里create时parent传的就是thisname传的是drv所以它在UVM组件树里的完整路径就是uvm_test_top.drv。3.2 通过config_db把virtual interface送进driverbuild_phase中用uvm_config_db#(virtual my_if)::get(this, , vif, vif)取刚才顶层set进来的vif。注意get的几个关键参数第一个this表示查询起点是当前组件第二个空字符串表示在当前组件的路径下直接查vif这个字段第三个是字段名。如果没查到直接uvm_fatal报错并终止仿真。我在第一次搭这个平台时就因为set和get的路径没对上卡在这行上很久后面专门写一节踩坑记录。这里顺带说下build_phase为什么是function而不是task。因为build_phase是UVM在正式仿真开始前用来构建组件层次、配置参数的地方它不应该消耗仿真时间。也就是说你不能在build_phase里写#10、(posedge clk)这种语句。UVM中每个phase都有明确的任务分工这一点从函数类型上就给你约束好了。初学时容易犯的错就是想把一些激励逻辑塞进build_phase编译直接报task与function的语法错误其实就是对phase定位没理解透。3.3 run_phasedriver真正干活的地方build_phase负责搭架子而driver真正的驱动行为在run_phase里。run_phase是task它可以消耗仿真时间可以等时钟、等复位、打数据。从UVM调度的角度来说run_phase和所有12个小phase如reset_phase、main_phase等是并行运行的但在这个最简例子里我们只用了run_phase所以不用纠结它们的细分顺序。run_phase开头先phase.raise_objection(this)这是UVM里极其重要的一行。它告诉UVM仲裁机制我这个组件在run_phase里还有活要干请不要结束仿真。UVM在所有组件的run_phase结束后会检查objection计数只有当计数为0时才真正退出run phase进入后续的report等阶段。如果没有任何组件raiserun_phase会立刻结束仿真在0时刻就会跑完你什么都看不到。所以raise和drop之间就是driver实际干活的区间。之后是复位等待。wait(!vif.rst_n)和wait(vif.rst_n)这两行很直白先等复位信号有效再等复位释放。为什么要等复位因为很多DUT在复位未释放时输入无效而且通常我们要让验证平台先等DUT进入一个确定状态再开始灌数据不然第一拍数据很可能会被复位覆盖掉。这里我们假设复位持续100ns。然后repeat(8)循环里做的事是driver最典型的行为模式等时钟上升沿到来再把数据放到总线上。注意顺序很重要一定是先(posedge vif.clk)再vif.din ...在时钟沿之后再改变输入保证DUT采样到的是稳定可靠的数据。我看到过不少初学者把这两句写反结果仿真时数据总是比预期晚一拍甚至完全错乱。驱动时序这件事在真实项目里还会用clocking block或者#1延迟来做得更严谨那是后话但这个沿到之后改变信号的直觉现在就要建立起来。3.4 objection机制防止活没干完裁判先吹哨很多人初学UVM时对objection理解得不够透彻。我打个比方UVM的run_phase就像一个考试场次objection则是考生举手的我还有题没做完示意。调度器看到还有手举着就不会吹哨收卷当所有举手的人都放下手它才宣布这一场结束进入下一阶段。在实际操作中raise和drop必须成对出现才能保证计数最终归零。如果只raise不dropUVM会进入死等状态——它会一直等到timeout然后报fatal如果既不raise也不droprun_phase瞬间结束仿真在0时刻就退出了。在这个最简例子里objection逻辑放在driver的run_phase中。但后面当你有了test、env、sequence等更多组件时一个常见的问题是到底谁该raise我个人的经验是最稳妥的方式是在产生激励的源头组件里raise比如后续学sequence时在sequence的body里raise。但要记住objection属于phase机制的一部分可以跨组件共享计数所以关键是让每个消耗时间的驱动行为都有对应的raise/drop计数平衡即可。4. 从编译到波形让driver跑一次完整实验4.1 文件组织与编译顺序代码写好后接下来要把它编译起来跑仿真。我先说明一下我习惯的工程文件组织方式这个顺序也是后来写大型验证环境时常用的思路先DUT和interface这类纯信号层再UVM组件类最后顶层tb。# filelist.f 的内容 dut.sv my_if.sv my_driver.sv my_test.sv top_tb.sv这个顺序有讲究。dut和my_if是模块/接口它们可以在任何文件里被例化或引用但my_driver类要用到uvm_driver和my_if所以要先确保UVM库被加载同时my_if要在此之前编译好my_test又依赖my_driver最后top_tb引用前面所有东西。虽然现代仿真器对SystemVerilog的编译顺序有一定容错但作为工程习惯依赖关系前置编译是最稳的避免在大型工程里踩到class not found这种低级错误。另外提醒一点如果你用的仿真器不是VCS或者Questa而是某些轻量级仿真器它可能没有内建UVM库需要你自己指定UVM的源码路径。这种情况下首先要确认UVM库版本然后在编译命令里把UVM的src目录加进去。这一步环境配置的坑不少很多人代码写得没问题卡在编译环境的UVM路径上白白浪费一下午。4.2 仿真命令与日志解读以VCS为例我用的编译和运行命令是这样的vcs -sverilog -ntb_opts uvm -timescale1ns/1ps \ -f filelist.f -l compile.log ./simv UVM_TESTNAMEmy_test -l run.log如果你的环境是MentorSiemens家的QuestaSim或ModelSim对应命令是vlog -sv -f filelist.f -l compile.log vsim -c UVM_TESTNAMEmy_test -do run -all; quit -l run.log跑完之后run.log里应该能看到driver打印的关键日志大致像这样UVM_INFO 0: uvm_test_top.drv [my_driver] run_phase is called UVM_INFO 110: uvm_test_top.drv [my_driver] drive din 7f UVM_INFO 130: uvm_test_top.drv [my_driver] drive din 2a UVM_INFO 150: uvm_test_top.drv [my_driver] drive din c3 ... UVM_INFO 270: uvm_test_top.drv [my_driver] run_phase is finished看到这些日志说明整个平台已经正常运转了run_test成功创建了my_testmy_test的build_phase创建了my_drivermy_driver拿到了vif并在复位结束后开始打数据。如果你连run_phase is called都没看到那问题大概率出在编译阶段或者run_test传参上。如果只看到这一句、后面什么都没有那多半是objection的问题我待会在踩坑部分细说。4.3 波形里看到的driver行为日志只是间接证据我更推荐打开波形确认。波形窗口里你会看到时钟clk在20ns一个周期地翻转rst_n在100ns处拉高din在110ns附近第一次变成随机值之后每隔20ns变化一次共变化8次dout比din延迟一拍从130ns开始跟随前一个时钟沿后的din值变化。到270ns左右din不再变化run_phase结束。这个波形其实是检验driver是否正确的最直接证据。如果din的变化不在时钟上升沿附近或者变化后立即使DUT输出异常那就要回头检查driver里的时序问题比如是不是漏了(posedge vif.clk)或者是不是在时钟沿之前就改了数据。我见过不少初学者把vif.din ...放在了(posedge vif.clk)之前结果DUT采到的永远是旧值输出比预期慢了一拍。这类问题在波形里一眼就能看出来所以养成开波形的习惯非常值得。5. 我在这节笔记上踩过的坑与理解误区5.1 忘记raise_objection导致仿真提前结束我第一次跑这个例子时犯的就是最经典的错误把raise_objection和drop_objection都注释掉了。结果仿真日志最多到run_phase is called就停了run.log末尾直接提示NO OBJECTIONS然后仿真结束。当时我盯着日志看了半天还以为代码编译出问题了后来才想起书里反复强调的objection机制。解决方法是把这两个调用加回来并且确认它们之间的代码覆盖了整个耗时段落。还有一个容易混淆的点如果你的driver类里没有objection但它所属的test里有objection仿真一样不会提前结束因为objection计数是全局的。这解释了为什么很多UVM例子中objection放在sequence的body里而不是driver里。所以学到这里别死记driver一定要raise而要理解objection的本质是让phase知道还有人没干完活。5.2 config_db路径不对导致uvm_fatal第二个坑在config_db的路径上。我在顶层set时写的路径是uvm_test_top.drv但在driver的get里字段名写成了v_if结果仿真一跑到build_phase就报uvm_fatal: get vif failed。排查这个问题时我一步步打印路径才发现是字段名不一致。这里有个小技巧如果get失败先用uvm_config_db#(virtual my_if)::exists(null, uvm_test_top.drv, vif)这类检查函数去确认数据库里到底有没有这个条目再确认字段名是否完全一致。注意路径字符串区分大小写而且字段名要和set时的第三个参数完全一致一个字母都不能差。另外如果你在my_test的build_phase里把create时取的名字改成了别的比如my_drv_0那么顶层set的路径也要对应改成uvm_test_top.my_drv_0。这个坑在代码里复制粘贴时特别容易犯。5.3 把产生时钟和沿时钟驱动数据搞混还有一个理解误区值得单独拎出来说。初学者很容易认为driver连时钟都要自己产生于是把forever #10 clk ~clk写进了driver类里结果导致interface的clk被多处驱动仿真报竞争或者直接编译不过。正确的分工是时钟这类全局时序基准应该放在顶层tb或者一个专门的clock generator模块里产生driver要做的是等待时钟沿到来然后在沿附近改变数据。driver只是时钟的使用者不是生产者。如果你把clock也放进driver那当你有多个test运行时每个test都会创建一个driver时钟就乱了。这也是为什么interface端口把clk和rst_n暴露出来、由顶层统一驱动的原因之一。5.4 版本差异从main_phase到run_phase最后提醒一个环境相关的坑。市面上部分老教程和《UVM实战》第一版早期的代码用的是UVM-1.1d时代的概念比如main_phase、uvm_sequence_item的使用方式。如果你的仿真器带的是UVM-1.2或UVM-1800.2标准库uvm_driver等类依然存在但12个phase的细节和运行机制有一些调整新代码通常建议直接用run_phase不需要显式写main_phase。如果你在编译时遇到和phase相关的方法或宏对不上的错误先确认一下你用的UVM库版本以及环境变量里UVM_HOME指向的是不是你要的版本。这个检查说起来简单但真的能帮你省下不少排查时间。学完这个只有driver的平台我已经能感觉到UVM底层那套调度机制的轮廓了。下一节笔记我会继续往后啃等引入transaction和sequence之后driver就不再是自己造数据自己打的孤胆英雄了它会变成整个UVM激励流水线上真正执行命令的那个人。到时候再看这个最简单的driver你会更清楚它当初为什么这么设计。