干验证的兄弟们对UVM的Sequence机制都不会陌生,但真要说到Virtual Sequence和Virtual Sequencer,很多刚入行的朋友总觉得这层纱捅不破。项目一复杂,接口一多,光靠单个agent里的sequencer各自为战,协调工作马上变成灾难。这篇文章我就结合自己这几年在多个SoC项目里用virtual sequence和virtual sequencer的实践经验,把这一对组合彻底讲透:它们到底解决什么问题、内部怎么搭、sequence怎么写、踩过的坑在哪,一次说清楚。适合正在用UVM写验证环境、想理顺跨接口激励协调的DV工程师,也适合刚学UVM想绕过弯路的同学。
1. 为什么需要Virtual Sequence机制
1.1 经典Sequence/Sequencer机制回顾
先花点篇幅把底层铺垫一下。UVM的sequence机制核心就是:一个sequence对象在里面生成一系列transaction(激励item),通过start方法挂载到一个sequencer上,sequencer再通过driver把item转化为接口上的时序。整个链路是典型的“生成-仲裁-驱动”三层结构。
一个标准的sequence长这样:
class my_seq extends uvm_sequence #(my_item); `uvm_object_utils(my_seq) function new(string name = "my_seq"); super.new(name); endfunction task body(); my_item it; repeat (10) begin it = my_item::type_id::create("it"); start_item(it); // 对it做随机化或者约束 it.addr.constraint_mode(0); it.randomize() with {addr inside {[0:127]};}; finish_item(it); end endtask endclass而sequencer的作用就是提供仲裁和分发。如果把一个agent比作一个专门的接口“部门”,sequencer就是部门的“任务分发员”,只接收这个部门的活,也只发给这个部门的driver去干。
这种模式单接口下非常好用,每个agent内部自己管理自己的激励流。但现实项目里,验证场景几乎从来不是单接口独立跑。比如一个DUT带APB配置接口、AXI总线接口、还有一组GPIO控制引脚。测试要求是:先把APB寄存器配好,然后AXI发一组数据,期间GPIO拉高一个电平作为握手信号。这三个操作分属三个agent,如果还让每个sequencer各自跑自己的sequence,协调问题就来了。
1.2 跨Agent协同的痛点
一旦需要多个agent协同激励,直接用普通sequence会遇到几个非常难受的问题:
第一,时序同步全靠延时猜测。最常见的土办法就是在一个sequence里发完一组item,然后#200ns,希望另一个agent的sequence在这个时间片内干完活。仿真一跑,延时不稳,环境一换,时序就崩,改起来极其痛苦。
第二,跨agent的数据传递没有干净通道。AXI sequence需要知道APB配置出来的寄存器地址,如果两个sequence各自独立创建,数据往哪放?要么塞进全局静态变量,要么通过config_db搭一条非正式的路径小心翼翼传,协作关系散落在环境各处,完全谈不上封装。
第三,复用的撕扯。每个sequence绑定在特定sequencer上,想在不同test里组合复用激励片段,只能复制粘贴。验证环境做大之后,这种复制粘贴直接让维护成本爆炸。
说实话,这一阶段很多项目是靠“约定”在撑着:谁先跑、跑多久、靠什么信号打招呼,都是人肉约定。可一旦验证的复杂度上了量级,这套约定就撑不住了。
1.3 Virtual Sequencer和Virtual Sequence的定位
所以UVM引入了virtual sequencer和virtual sequence这对机制。理解起来一句话:虚拟sequencer本身不直接连接任何driver,它只作为一个“调度中心”,持有其他真实sequencer的句柄;而virtual sequence就在这个调度中心里编排多个子sequence,把不同agent的激励按业务场景组合起来。
为什么要加这样一层?核心目的是分离“写激励的逻辑”和“跑激励的通道”。普通sequence把激励内容和对特定sequencer的引用绑死,virtual sequence则可以只关心“我需要让APB agent干什么、让AXI agent干什么”,至于这些agent的sequencer具体在哪、怎么连,由virtual sequencer句柄来解析。这样写出来的激励是面向业务的,而不是面向底层结构。
打个比方:普通sequence像一个只会在固定产线上干活的工人,virtual sequence则是工段长,手下的工人(子sequence)各干各的专业活,工段长负责编排顺序和节奏,自己并不亲手加工零件。
所以virtual sequencer其实是个“空的sequencer外壳”,virtual sequence是“编排脚本”。两者搭配才能完成跨agent激励组合,单拿出哪一个都跑不起来。
2. Virtual Sequencer的构建细节
2.1 定义Virtual Sequencer:持有子sequencer句柄
virtual sequencer本身代码量非常少,核心就是定义多个子sequencer的句柄。建一个新类,继承uvm_sequencer,参数化类型可以直接给一个默认的sequence item基类类型(因为virtual sequencer自己并不产生item,所以参数类型无所谓),然后声明所有子sequencer句柄。
class my_virtual_sequencer extends uvm_sequencer #(uvm_sequence_item); `uvm_component_utils(my_virtual_sequencer) apb_sequencer apb_sqr; axi_sequencer axi_sqr; gpio_sequencer gpio_sqr; function new(string name, uvm_component parent); super.new(name, parent); endfunction endclass注意一点:这些句柄不需要在virtual sequencer内部做任何创建,因为子sequencer是env里真实存在的组件,virtual sequencer只负责“引用”。这跟持有对象句柄的普通对象不一样,更接近“指针”或者说“遥控器”。界面上它好像能指挥,实际上还是得按到真实组件上才能按出反应。
2.2 在testbench中创建与连接virtual sequencer
virtual sequencer作为一个component,自有生命周期,需要被创建并挂在组件树里。通常在env里声明、build_phase里创建,同时把env里各个agent的sequencer句柄赋值给它。
class my_env extends uvm_env; my_virtual_sequencer v_sqr; apb_agent apb_agt; axi_agent axi_agt; gpio_agent gpio_agt; `uvm_component_utils(my_env) function new(string name, uvm_component parent); super.new(name, parent); endfunction function void build_phase(uvm_phase phase); super.build_phase(phase); v_sqr = my_virtual_sequencer::type_id::create("v_sqr", this); apb_agt = apb_agent::type_id::create("apb_agt", this); axi_agt = axi_agent::type_id::create("axi_agt", this); gpio_agt = gpio_agent::type_id::create("gpio_agt", this); endfunction function void connect_phase(uvm_phase phase); super.connect_phase(phase); v_sqr.apb_sqr = apb_agt.sqr; v_sqr.axi_sqr = axi_agt.sqr; v_sqr.gpio_sqr = gpio_agt.sqr; endfunction endclass这个connect_phase是整套机制的关键。如果漏了这三行赋值,后面在virtual sequence里访问p_sequencer.apb_sqr拿到的就是null,一旦去start子sequence就会崩。新手最常见的报错就在这里。
2.3 为什么virtual sequencer本身不产生item
接触virtual sequencer的时候大家都会有一个疑问:它毕竟是个uvm_sequencer,参数又给了一个item类型,为什么不在它上面跑一个普通sequence、然后接一个driver?
因为设计意图就不是让virtual sequencer走“底层通道”。一个正常sequencer需要和driver通过seq_item_export连接,有真实的item流交互;virtual sequencer位于这一层之上,它向下不接driver,唯一的对外连接就是“持有其他sequencer句柄”。如果强行给它接driver,反而会让机制变形:virtual sequencer发的item没有对应的物理接口可执行,时序就无从谈起。
我见过有同事试图在virtual sequencer上挂driver去转发item到多个agent,结果把整个connect关系绕得乱七八糟,最后不得不推翻重来。合适的设计永远是:virtual sequencer就是一层编排调度层,永远不要让它参与具体接口时序。
3. Virtual Sequence的编写与核心实践
3.1 Virtual Sequence的基本写法
virtual sequence本质上就是一个普通sequence,只是它通常在body()里通过p_sequencer访问virtual sequencer的子句柄,再创建子sequence并start()。写法上的“灵魂”就是p_sequencer这个宏引用。
class my_virtual_seq extends uvm_sequence #(uvm_sequence_item); `uvm_object_utils(my_virtual_seq) `uvm_declare_p_sequencer(my_virtual_sequencer) apb_config_seq apb_cfg_seq; axi_write_seq axi_w_seq; gpio_set_seq gpio_s_seq; function new(string name = "my_virtual_seq"); super.new(name); endfunction task body(); apb_cfg_seq = apb_config_seq::type_id::create("apb_cfg_seq"); axi_w_seq = axi_write_seq::type_id::create("axi_w_seq"); gpio_s_seq = gpio_set_seq::type_id::create("gpio_s_seq"); // 先配置APB寄存器 apb_cfg_seq.start(p_sequencer.apb_sqr); // 拉高GPIO gpio_s_seq.start(p_sequencer.gpio_sqr); // 再发AXI写突发 axi_w_seq.start(p_sequencer.axi_sqr); endtask endclass这里intuitively有个注意点:uvm_declare_p_sequencer里的类型必须和virtual sequence实际挂载到的sequencer类型严格一致。如果类型不一致,编译器或者运行时会去找对应的sequencer句柄,结果不匹配,轻则编译不过,重则运行期null访问。类型一致这一点绝对不要放松。
3.2 跨sequencer的sequence启动流程(fork-join控制)
上面的示例是严格串行,很多场景还需要并行协调。比如“配置寄存器之后,AXI开始写数据,同时GPIO在数据写了一半时翻转”这种场景,就需要fork/join来控制并行度。
task body(); apb_cfg_seq = apb_config_seq::type_id::create("apb_cfg_seq"); axi_w_seq = axi_write_seq::type_id::create("axi_w_seq"); gpio_s_seq = gpio_set_seq::type_id::create("gpio_s_seq"); apb_cfg_seq.start(p_sequencer.apb_sqr); fork begin axi_w_seq.start(p_sequencer.axi_sqr); end begin // 故意等待一段时间后拉高GPIO #500ns; gpio_s_seq.start(p_sequencer.gpio_sqr); end join endtask这里join的语义要清楚:它等待所有分支都完成才结束。如果axi_w_seq是持续高频写,而gpio_s_seq很快完成,用join会导致整个virtual sequence被AXI这条分支拖住。更合理的做法是按业务需要选择join_any或者join_none,并在合适的时机用wait等条件。
我自己的习惯是:需要等待某一路完成后才做下一步就用join_any,然后通过flag或者共享事件通知主流程;如果并行任务之间没有明确的完成依赖,就用fork ... join_none让它们互为背景,但最后必须在virtual sequence结束前统一同步一次,避免整个test提前退出而子sequence还没跑完。这种调度细节看起来琐碎,实际验证中至少一半的“不定时死锁”都是这里设计不严谨导致的。
3.3 参数传递与数据同步的几种方式
跨agent协作的一大诉求是数据传递。比如AXI写地址依赖APB配置出来的寄存器基地址,两个子sequence之间怎么共享这个值?
第一招,也是最实用的:在virtual sequence里声明一个句柄或变量,创建子sequence时直接把自己的变量赋给子sequence的字段。
class my_virtual_seq extends uvm_sequence #(uvm_sequence_item); `uvm_object_utils(my_virtual_seq) `uvm_declare_p_sequencer(my_virtual_sequencer) rand bit [31:0] base_addr; task body(); apb_cfg_seq = apb_config_seq::type_id::create("apb_cfg_seq"); axi_w_seq = axi_write_seq::type_id::create("axi_w_seq"); apb_cfg_seq.base_addr = base_addr; axi_w_seq.base_addr = base_addr; apb_cfg_seq.start(p_sequencer.apb_sqr); axi_w_seq.start(p_sequencer.axi_sqr); endtask endclass这种方式简单直接,序列之间耦合降到最低,子sequence字段声明好就行。如果数据是要动态产生并且回传给virtual sequence的,可以在子sequence里声明一个非rand的output字段,start完成之后virtual sequence再读取这个字段。
第二招,用uvm_config_db或者共享资源对象。适合需要跨多个virtual sequence、甚至跨test传递的配置。比如建立一个配置对象,base_addr放在对象里,virtual sequence里通过config_db获取。好处是全局可见,坏处是路径字符串写错就静默拿不到,排查起来费劲。
第三招,用UVM事件(uvm_event)做同步握手。一个子sequence在关键时刻event.trigger(),另一个子sequenceevent.wait_trigger()。这种机制比裸延时靠谱得多,也比盲目使用fork更精确,适合精确握手场景。缺点是全局事件对象需要管理好,否则多个sequence同时等待时容易触发混乱。
我在第三招上吃过亏。两个sequence都用同一个共享uvm_event,A sequence一边trigger,B sequence一边wait_trigger,看起来正确,但C sequence也在同一个事件上wait,结果被误唤醒,逻辑全乱。后来规范就是:每个同步点建立独立命名的事件对象,绝不共用一个全局事件做多个不同语义的同步。
3.4 寄存器模型与virtual sequence的结合
多数项目的APB配置路径都会接入UVM寄存器模型(reg model)。经典做法是:APB的sequence内部通过reg model的reg.write(status, value)来驱动,virtual sequence负责调度reg model sequence和业务sequence。
这里有一个常见坑:reg model走的是它自己的seq(通常挂到APB sequencer上),而virtual sequence直接在APB sequencer上启动一个reg model sequence要小心路径。更稳妥的方式是让virtual sequence启动一个专门的“配置sequence”,配置sequence内部按照reg操作序列,把寄存器模型镜像和物理时序都处理好。
寄存器模型的镜像值(mirror value)在virtual sequence里也常被用来做参数同步。比如AXI sequence要读某个寄存器的最新镜像值作为突发长度,用reg_model.reg_name.get_mirrored_value()可以直接拿到,而不需要真正发起一次读操作。这个技巧在性能敏感的大型回归测试里非常实用,能省掉大量冗余总线访问。不过要注意镜像值只在配置路径走reg model时才可信,如果有人在序列里绕过reg model直接驱动APB物理接口,镜像可能就失真了。
4. 常见错误、调试技巧与避坑实录
4.1 常见编译/连接错误
针对virtual sequence/virtual sequencer,最典型的运行期错误是空句柄访问。我整理一下出错规律:
| 典型报错 | 根因 | 解决办法 |
|---|---|---|
p_sequencer.apb_sqr为null,start时报null access | virtual sequencer没有在env的connect_phase赋值,或者子agent的sqr本身是null | 检查env构建,确认agent内sequencer真正创建并connect |
uvm_declare_p_sequencer类型与挂载sequencer不符 | 类型不匹配,编译器可能不报错但运行期行为诡异 | 确保virtual sequence声明类型和start()挂载的sequencer类型一致 |
子sequence的body()里访问某个config_db路径为空 | sequence内使用config_db路径时没有加uvm_sequence_base的层级前缀,路径解析到错误作用域 | 在sequence内用get_full_name()或parent的路径构造key |
| 编译时报macro相关错误 | uvm_declare_p_sequencer宏展开失败,通常是重复声明或不匹配基类 | 检查是否在sequence类内且只声明一次,并确保p_sequencer类型可见 |
空句柄问题多出现在我前面说的connect_phase遗漏。这个错误非常隐蔽,因为编译期不报,运行期只在特定sequence跑起来才炸。调试的办法是在virtual sequencer的end_of_elaboration_phase里加一层断言检查:遍历所有子sequencer句柄,如果有null直接用uvm_fatal炸出来,早点暴露问题。
4.2 调度时序与phase的使用陷阱
很多人喜欢在main_phase里直接seq.start(v_sqr),然后就不管了。问题是run_phase和main_phase的并行语义一旦理解不到位,就会出现“test已经结束,sequence还没跑完”的诡异现象。UVM的phase机制里头,run_phase和main_phase是同时开始的,main_phase结束时如果sequence还在跑,常规的objection机制处理不好就会提前退出。
我的做法是:在启动virtual sequence前一定要raise objection,sequence结束之后drop objection。而且最好在virtual sequence的body()内部完成raise和drop,这样调用方不需要关心objection的细节,复用性也更高。
task body(); uvm_phase phase = get_starting_phase(); if (phase != null) begin phase.raise_objection(this); end // ... 子sequence调度 ... if (phase != null) begin phase.drop_objection(this); end endtask还有一个phase陷阱:不要在build_phase或connect_phase里去启动sequence。那些phase是function phase,仿真时间没有推进,sequence里的delay和握手根本没法执行。你要是这么干了,大概率看到一堆warning,然后sequence卡在某个wait上永远等不到。
4.3 调试技巧:打印、跟踪与波形定位
virtual sequence的调试比普通sequence难,因为一个virtual sequence会同时调动几个sequencer,报错和时序都交织在一起。我调试时有几个固定动作:
第一步,把uvm的verbosity调高,或者直接在virtual sequence的body入口和出口打印标识。打印信息里带上get_full_name(),因为不同test里可能启动同一个virtual sequence,不带路径根本对不上是哪个实例。
第二步,看波形时不要只看最终总线上的信号。强烈建议把每个子sequence的start/stop时间点打印出来,然后到波形上对齐driver的时序。很多时候问题出在“两个sequence实际起跑的时间差”和“你想让它们起跑的相对时间差”不一致,光看总线波形容易误判成driver问题。
第三步,如果怀疑死锁,打开UVM的UVM_DEBUG或者针对sequencer的打印,观察request和item_done的握手。尤其当子sequence在start_item里卡住,往往是因为同一个sequencer上有其他sequence在竞争,仲裁又没配好。
4.4 字符串转换和宏使用的小坑
写sequence的时候,很多人喜欢用$sformatf拼接字符串去做打印和路由。SystemVerilog字符串处理本身有类型转换的坑,比如把byte数组转string,或者string转int,一旦出现非法字节序列,可能在仿真器里报类似string conversion error的错。这类问题多出在从文件读取配置、或者从总线上捕获ASCII数据再转字符串的场景。
避免办法:统一用string类型存储,少用byte数组交叉转换;必须转换时,先做合法性过滤,非法字符直接剔除,不要留给仿真器去判断。我曾经在一个项目里从AXI写数据中截取了一段ASCII做日志,因为数据里混着非ASCII字节,仿真器当场抛异常,整个回归中断。后来加了过滤代码,问题彻底消失。
另外一个常见错误就是不规范使用uvm_define这样的宏。UVM有很多基于宏的注册、声明机制,用错宏名或者漏掉分号,编译器给出一堆看不出头绪的报错。这里没有太多捷径,只能建议保持每个类里宏的顺序一致,比如uvm_object_utils和uvm_declare_p_sequencer的摆放位置固定住,团队协作时照着模板写能少踩很多坑。
5. 高级用法与替代方案比较
5.1 多个virtual sequencer的分层用法
大型SoC验证环境经常有多个virtual sequencer,比如一个负责“系统级配置+启动流程”,另一个负责“性能压力场景”。此时virtual sequence还可以嵌套:某个virtual sequence在它的body里启动另一个virtual sequence,实现分层编排。
举个例子,系统级virtual sequence负责上电、时钟初始化和PLL配置,这些做完之后,它再启动一个业务级的virtual sequence去发批量业务激励。这种嵌套要注意的是,内层virtual sequence的p_sequencer依然是外层virtual sequencer类型,只要类型对得上就行。其他没有任何魔法。
分层的核心收益是可以让“场景”和“动作”解耦。系统级场景是固定的动作序列,业务级场景是可替换的动作模块。不同test只要挂不同的业务级virtual sequence,就能在同样的系统初始化流程上跑完全不同的验证用例。我倾向于把系统级virtual sequence写得足够稳定,而把业务virtual sequence做成可配置参数丰富的类,test里通过约束来决定行为。
5.2 与其他跨agent方案对比
除了virtual sequence,项目里常见的跨agent协作方案还有几种,各有适用场景。
一种是直接在test的run_phase里创建两个sequence,按顺序start在各自sequencer上。这种方式写起来最快,但没有任何封装性,test里堆满了业务逻辑,一旦场景多起来就没法维护。
一种是通过uvm_config_db在全局传配置对象,子sequence从config_db里读取参数,并按事件同步。这个方案某种程度上也能解耦,但config_db本质是共享全局存储,路径字符串写错全靠运行期排查,类型信息丢失,代码可读性也差。只适合少量静态参数的传递。
还有极端一些的方案,用UVM的uvm_event或者SV的mailbox、semaphore在sequence之间做同步。这些原语更底层,灵活但需要自己管理生命周期,容易出现资源泄漏或者多路sequence误同步。
对比下来,virtual sequence/virtual sequencer的优势是有UVM框架级的树形结构支持,子sequencer的连接关系在env里显式可见,运行期检查也更顺滑。可以说在“跨agent业务编排”场景下,它是最符合UVM设计哲学的标准方案。
5.3 性能与可复用性考量
性能方面,virtual sequence本身几乎没有额外开销。它不产生item,不经过仲裁,只负责启动子sequence。启动一个sequence的代价主要是对象创建和handshake的时间,远小于一次总线事务的仿真时间。所以不用担心加一层virtual sequence会拖慢仿真。
可复用性上有两个建议。第一,virtual sequence的类字段尽量保持可配置,比如地址、次数、突发长度都用rand声明或者来自config_db,不要写死。第二,子sequence本身不要依赖virtual sequence特有的字段,保持子sequence在独立使用和virtual sequence调用两种场景下都能工作。这样将来复用子sequence到其他测试环境时,不会因为缺少virtual sequence上下文而编译失败。
我在实践中一个很重要的经验是:虚拟sequence的命名和注释一定要说清楚“这个场景在干什么业务”,而不是“它调用了哪些sequence”。因为维护验证环境的人看到vseq_apb_cfg_axi_wr_gpio_high大概率心里发怵,而看到vseq_memory_initialization或者vseq_performance_stream就清楚这个场景是为了什么设计的。这个差异对团队协作和后期维护的体验影响巨大。
写在最后的小建议
回头再看,virtual sequence和virtual sequencer真正解决的是验证环境中“复杂场景编排”的工程化问题。它把跨agent的协作从人肉约定升级成了有结构、可检查、可复用的框架支撑。我自己用过几年之后最大的体会是:设计验证场景时,先想清楚业务的时序依赖是串行还是并行、数据从哪里来又到哪里去,再落笔写virtual sequence的调度框架,最后才去填子sequence的细节。如果一上来就埋头写调度代码,后面八成要推翻重来。
最后分享一个小技巧:给virtual sequencer的每个子sequencer句柄加一个注释标注agent的物理接口名,比如// APB_CFG interface。团队协作时,别人用你的virtual sequencer一目了然,不用去翻env结构。这个习惯我保持了好几个项目,省下的沟通成本相当可观。