UVM Virtual Sequence:在一个顶层序列里协同多个 agent 的激励
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」进阶层(intermediate) UVM 验证机制的一篇。单个 agent 的激励怎么从 sequence 流到 driver,基础链路见 sequence/driver/scoreboard;本篇往上走一层:当一个测试场景同时牵涉配置总线、输入数据流、输出响应等多个接口,谁来统一发号施令。答案就是 UVM virtual sequence(虚拟序列)——一个不挂任何 driver、专职做跨接口编排的顶层 sequence。
本文要回答的问题
- 一个测试要同时驱动 APB 配置口和两路 AXI-Stream 数据口,为什么三个普通 sequence 各跑各的不行?
- virtual sequencer 里面到底放什么?它自己挂不挂 driver?
- 子 sequencer 的句柄在哪里、用什么方式交给 virtual sequencer?
- virtual sequence 的 body 里,怎么把一个事务精确投递到指定接口?
uvm_do_on和start怎么选? - 并行编排用
fork...join时如何避免挂死?哪些事绝对不该在 virtual sequence 里做?
一、单 sequencer 装不下跨接口场景
先回顾普通 sequence 的工作方式:sequence 通过 start(sqr) 挂到一个 sequencer 上,body 里产生的事务经 sequencer 的仲裁交给唯一与它相连的 driver,driver 再按物理协议把事务拍打到接口上。一条链路、一个 sequencer、一个 driver,非常干净。
问题出在真实 DUT(待测设计)几乎都是多接口的。一个典型的数据通路 IP:先经 APB 配置寄存器打开通路,随后 AXI-Stream 输入口灌入数据包,另一个 AXI-Stream 输出口产出结果,还要随时可能通过中断口上报状态。要测「配置生效后两路输入并发、输出端检测到特定包型后再注入一帧错误数据」这样一个场景,时序关系跨越了四个 agent。
打个乐队的比方:每个 agent 的 sequence 像一名乐手,各自的乐谱(协议细节)都很熟,普通 sequence 机制解决的是「每名乐手怎么把自己的乐器奏准」;但一首合奏还需要一名指挥,决定第几小节谁进、谁停、听到某个乐句后谁接。没有指挥时,乐手只能互相看表、约死时间,谁快半拍慢半拍全凭运气。virtual sequence 扮演的就是这名指挥——它一件乐器都不碰,却让所有人的相对节奏确定下来。
直觉上有三种朴素做法,各有硬伤:
| 协同方式 | 做法 | 问题 |
|---|---|---|
| 各 test 各起一个 sequence | 在 test 里分别 start 多个普通 sequence | sequence 之间无法对话,「等 A 接口某事件再触发 B」无处表达,只能靠猜延时 |
| 协调逻辑塞进 driver | 让某个 driver 去控制别的 agent | driver 本该只懂一个物理协议,跨接口依赖让它无法复用、无法单独复用跑单接口测试 |
| 协调逻辑塞进 monitor/参考模型 | 用事件在组件间绕一圈再触发激励 | 验证组件承担了激励职责,层次倒置,调试时根本看不清「谁让这件事发生的」 |
virtual sequence 就是 UVM 为这块空缺给出的正规位置:把跨接口的时序剧本集中写在一个不直接驱动任何接口的顶层 sequence 里,每个 agent 该怎么发一个包的细节仍封装在各自的子 sequence 中。
值得停下来想清楚「协同」两个字到底难在哪。难的不是把三个 sequence 同时跑起来——test 里写三行 start 就能做到;难的是它们之间的相对时序:配置事务必须在数据流之前完成并等待其生效;两路数据要求真正并发而不是一前一后;错误注入的触发点要由输出端实际观察到的包型决定,而非由仿真时间决定。相对时序意味着 sequence 之间要能互相「看得见」:谁先返回、谁通知谁、谁等谁。这种「看得见」需要一个公共的上层上下文,让所有子 sequence 的启动、等待、通知都挂在同一个地方。virtual sequence 提供的就是这个上下文,所以阅读一个写得好的 virtual sequence,基本等于直接在读这个测试用例的剧本,不需要跳进任何 driver 就能还原测试意图。
二、virtual sequencer:只放句柄的「配电盘」
virtual sequence 自己不产生送往 driver 的事务,它要发号施令,就得先拿到各子 sequencer 的引用。承载这些引用的组件就是 virtual sequencer。它继承自 uvm_sequencer,但不与任何 driver 相连——注意,sequencer 与 driver 的连接是 env 里 connect_phase 手工连的,你不给它连 driver,它就是一个纯粹的句柄容器:
class chip_vsequencer extends uvm_sequencer;
`uvm_component_utils(chip_vsequencer)
apb_sequencer apb_sqr; // 配置总线 agent 的 sequencer
axis_in_sequencer in0_sqr; // 输入数据流 0
axis_in_sequencer in1_sqr; // 输入数据流 1
irq_sequencer irq_sqr; // 中断/响应侧
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
endclass这个类的全部内容就是几个句柄,这正是它该有的样子。可以把它理解成一块配电盘:盘上有通往各个房间的开关位(句柄),但配电盘自己不耗电(不驱动事务),电由真正的电器(子 sequencer/driver)消耗。子 sequencer 的类型必须和对应 agent 实际实例化的 sequencer 类型一致,句柄只是引用,对象本身仍归各 agent 所有。
新手常在这里多做两件不该做的事,都要忍住。一是往 virtual sequencer 里加配置字段、加记分板引用、加分析端口,把它写成一个小型 env;请记住它的唯一天职是「让 virtual sequence 能找到子 sequencer」,多放一样东西都会制造第二条隐蔽的耦合路径。二是给它 new 一个 driver 或者照猫画虎地写 drv.seq_item_port.connect(v_sqr.seq_item_export)——virtual sequencer 一旦连上 driver,就退化成了一个普通 sequencer,「不直接驱动」的隔离意义全部丧失,而且会和真正拥有该接口的 agent 产生双驱动。判断一个 virtual sequencer 写得对不对有个极简标准:除了构造函数,它不应该有任何 task 或方法实现。
三、env 里连接:在 connect_phase 赋值
句柄声明了只是 null,必须在环境搭建阶段指向真实对象。标准位置是 env 的 connect_phase——此时所有 agent 及其内部 sequencer 都已创建完毕,而激励尚未开始:
class chip_env extends uvm_env;
`uvm_component_utils(chip_env)
chip_vsequencer v_sqr;
apb_agent apb_agt;
axis_in_agent in0_agt;
axis_in_agent in1_agt;
irq_agent irq_agt;
function void build_phase(uvm_phase phase);
super.build_phase(phase);
v_sqr = chip_vsequencer::type_id::create("v_sqr", this);
apb_agt = apb_agent::type_id::create("apb_agt", this);
in0_agt = axis_in_agent::type_id::create("in0_agt", this);
in1_agt = axis_in_agent::type_id::create("in1_agt", this);
irq_agt = irq_agent::type_id::create("irq_agt", this);
endfunction
function void connect_phase(uvm_phase phase);
super.connect_phase(phase);
v_sqr.apb_sqr = apb_agt.sqr;
v_sqr.in0_sqr = in0_agt.sqr;
v_sqr.in1_sqr = in1_agt.sqr;
v_sqr.irq_sqr = irq_agt.sqr;
endfunction
endclass整个层次与连接关系如下图。四条 v_sqr.xxx_sqr = agent.sqr 的赋值,是 virtual sequence 能调度各接口的唯一物理基础:
读图时区分两种线:子 sequencer 到自己 driver 是实线直连(真的会有事务流过),virtual sequencer 到各子 sequencer 只是虚线引用。virtual sequencer 处在 env 的中央,但它跟 driver 之间没有任何通路——这决定了下一节的铁律。
为什么非要在 connect_phase 连,而不是 build_phase 或 test 里连?这与 UVM 的构建次序有关:build_phase 自顶向下执行,env 的 build 跑完时各 agent 内部的 sequencer 往往还没创建,此时赋值只会拿到 null;connect_phase 自底向上执行,走到 env 这一层时整棵组件树都已就位。另外把连接放在 env 内部而不是 test 里,是因为「哪些 agent 存在、各自 sequencer 叫什么」属于环境的结构性事实,env 自己最清楚;test 只负责选择并启动哪个 virtual sequence,不该知道这些布线细节。这样 env 换一套内部实现,只要 v_sqr 的句柄语义不变,所有历史 test 都不用改。
如果某些 agent 通过 config_db 配置成 passive(被动模式,不驱动激励),对应的 sequencer 要么不存在、要么不应再被投递事务。常见做法是在 connect_phase 里根据 agent 的 active/passive 配置决定是否赋值,并在 virtual sequence 里先判空再使用,让同一个环境既能跑全功能场景也能跑监听式场景。
四、virtual sequence:声明 p_sequencer,用 uvm_do_on 投递
virtual sequence 写法上和普通 sequence 只有两处不同:它通过宏 uvm_declare_p_sequencer(chip_vsequencer) 把内置的 m_sequencer 句柄向下转型为 p_sequencer(类型即 virtual sequencer),于是 body 里能直接写 p_sequencer.apb_sqr;投递事务时必须显式指定目标 sequencer。
投递有两种等价写法。发单个事务用 uvm_do_on(tr, target_sqr) 宏;启动一个完整子 sequence 用 subseq.start(target_sqr)。下面的例子把前面的测试剧本完整写出来:
class chip_vseq extends uvm_sequence;
`uvm_object_utils(chip_vseq)
`uvm_declare_p_sequencer(chip_vsequencer)
task body();
apb_cfg_seq cfg;
axis_burst_seq burst0, burst1;
axis_err_seq errf;
irq_resp_seq rsp;
// 第一段:串行——先经 APB 打开通路、使能两路输入
cfg = apb_cfg_seq::type_id::create("cfg");
cfg.start(p_sequencer.apb_sqr);
// 第二段:两路数据并发灌入
burst0 = axis_burst_seq::type_id::create("burst0");
burst1 = axis_burst_seq::type_id::create("burst1");
fork
burst0.start(p_sequencer.in0_sqr);
burst1.start(p_sequencer.in1_sqr);
join
// 第三段:等响应侧事件后,向 in0 注入一帧错误数据
rsp = irq_resp_seq::type_id::create("rsp");
rsp.start(p_sequencer.irq_sqr);
errf = axis_err_seq::type_id::create("errf");
`uvm_do_on(errf, p_sequencer.in0_sqr)
endtask
endclassuvm_declare_p_sequencer 宏展开后,除了声明 chip_vsequencer p_sequencer,还会在 pre_body 前完成一次安全的 $cast;如果你在 body 里访问 p_sequencer 却报 null,八成是 virtual sequence 被 start 到了普通 sequencer 上,而不是 virtual sequencer。启动位置在 test 中,通常只此一处:vseq.start(env.v_sqr)。
关于 uvm_do_on 家族,顺带把常用变体交代清楚,它们都是宏,区别只在自动化到哪一步:
| 宏 / 调用 | 自动完成的事 | 适用时机 |
|---|---|---|
uvm_do(item) | create、随机化、经默认 sequencer 发送 | 普通 sequence 内 |
uvm_do_on(item, sqr) | create、随机化、发往指定 sqr | virtual sequence 投递单事务 |
uvm_do_on_with(item, sqr, {约束}) | 同上,并附加内联约束 | 需要现场覆盖几个字段 |
subseq.start(sqr) | 启动一个已构造好的子 sequence | 投递成段的、可复用的激励 |
需要对随机化失败做处理、或要在发送前后打印调试信息时,宏就不够用了,应展开成手工三步:item = type_id::create(...)、if (!item.randomize() with {...}) uvm_error(...)、item.start_item(sqr) 加 item.finish_item()。宏只是这段样板的缩写,理解展开形式后调试会省很多时间。另外提醒一个命名误会:uvm_do_on 投递的仍然是普通事务 item,不会因为名字里有 on 就改变 sequencer 的行为,目标 sequencer 上的仲裁优先级、锁机制照常生效。
五、编排时序:串行段、并行段与事件同步
上面 body 的三段结构对应三种编排原语,它们组合起来就是任意复杂的跨接口剧本:
- 串行:顺序写
seq.start(sqr),前一个返回才执行下一个,用于「先配置、后传数」这种有严格先后的阶段; - 并行:
fork...join_none或fork...join启动多个 start,用于两路并发流量;需要「全部完成再继续」用join,需要「发完即走、后续统一等待」用join_none配合保存分支句柄; - 事件同步:子 sequence 之间通过 UVM 的
uvm_event或 config_db 传递的同步对象握手,实现「输出端看到特定包型才注入错误」,而不是靠#1000猜时间。
事件的一端通常由 monitor 在检测到目标行为时触发,另一端由某个并行分支阻塞等待,virtual sequence 因此可以「让激励响应 DUT 的真实行为」。事件对象建议在 env 里创建并经 config_db 下发,避免子 sequence 各自 new 出不是同一个的对象:
// env build_phase 中创建共享事件
uvm_event pkt_seen = new("pkt_seen");
uvm_config_db#(uvm_event)::set(this, "*", "pkt_seen", pkt_seen);
// virtual sequence body 中:一路继续灌包,一路等事件后注入错误
fork
begin
axis_burst_seq bg = axis_burst_seq::type_id::create("bg");
bg.repeat_n = 100;
bg.start(p_sequencer.in0_sqr);
end
begin
uvm_event ev;
axis_err_seq errf = axis_err_seq::type_id::create("errf");
uvm_config_db#(uvm_event)::get(null, get_full_name(),
"pkt_seen", ev);
ev.wait_trigger(); // 阻塞到 monitor 报告目标包型
`uvm_do_on(errf, p_sequencer.in0_sqr)
end
join这个 fork 的两个分支没有先后完成关系:背景流量可能先跑完,也可能事件先到,剧本都成立。这是固定延时永远写不出的弹性——同一套代码在快仿真、慢仿真、回归服务器上行为一致,因为触发依据是 DUT 实际吐出的事务。
三种 join 语义在这里值得分清,选错通常表现为偶发挂死或阶段串扰:join 等所有分支结束,适合「并发段必须整体完成才能进下一阶段」;join_any 只等最先结束的分支,适合竞速场景(如「正常响应」与「超时」谁先到),但剩余分支仍在后台运行,必须显式 disable fork 回收;join_none 立即继续,适合启动后台压力流量,最后在收尾处统一等待。无论用哪一种,给并行段外面套一个超时 fork 都是值得的:超时分支触发时报错并 disable fork,能把「静默挂到仿真极限」的故障变成一条带现场信息的明确失败。
事务的实际流向与时序如下图。注意所有事务都由子 sequence 在各自子 sequencer 上产生,virtual sequence 只决定「什么时候、在哪个 sequencer 上、跑哪个子 sequence」:
这张图里最容易看漏的一点是:uvm_do_on(errf, in0_sqr) 这一帧错误数据,物理上仍然走的是 in0 这条早已存在的 sequencer→driver 链路,virtual sequence 只是指定了投递目标。任何时刻都不存在「virtual sequence → 某 driver」的新通路。
普通 sequence 与 virtual sequence 的职责边界可以用下表收口:
| 普通 sequence | virtual sequence | |
|---|---|---|
| start 的目标 | 本接口真实 sequencer | virtual sequencer(无 driver) |
| body 里产生什么 | 具体事务 item | 子 sequence 的启动与同步 |
| 典型原语 | uvm_do | uvm_do_on / subseq.start(sqr) |
| 是否懂物理协议 | 懂,按协议构造字段 | 不该懂,只懂场景时序 |
| 数量 | 每个 agent 一套,可复用 | 每个跨接口测试场景一个 |
| objection | 一般不自行控制 | 通常由 test/顶层统一控制 |
六、基类 virtual sequence 与场景派生
用例数量一多,virtual sequence 自身也值得分层。常见组织是写一个 vseq_base:它只负责 uvm_declare_p_sequencer、在 pre_body 里把常用子 sequence 的工厂注册和公共前置流程(如拉到全局复位、读出 DUT 版本号)做好,不写任何具体场景;每个测试场景再派生子类,只覆盖 body。于是「正常通路」「背靠背并发」「错误注入」「溢出回压」各是一个薄薄的子类,共享部分不重复。
test 层的代码也随之变得极其机械:每个 test 只在 run_phase 里 raise objection、type_id::create 对应的场景 vseq、vseq.start(env.v_sqr)、drop objection。很多团队进一步把这四行也收进一个 test_base,具体 test 只通过 factory override 替换 virtual sequence 的类型——这也是 UVM factory 机制 与 virtual sequence 最经典的交汇点:环境代码一行不动,换一行类型名就换了完整测试意图。
分层时守住一条继承纪律:基类 body 可以留空或只放公共阶段,子类需要在公共阶段之间插入自己的内容时,用可覆盖的 hook task(如 configure_dut()、drive_stimulus()、check_response())而不是直接重写 body 再手工调 super.body()。hook 让场景作者只填空、不背流程,公共流程一旦改动(例如统一加上超时看门狗),所有场景自动继承。
七、常见坑
- 子 sequencer 句柄没连:忘了在 env 的 connect_phase 赋值,body 第一次解引用就是 null 句柄致命错误。开机自检方式就是通读四条赋值是否齐全。
- 在 virtual sequence 里直接
uvm_do发 item:它挂的 virtual sequencer 没有 driver,事务永远无人取走,仿真静默挂死。必须uvm_do_on(item, 具体子 sequencer)或 start 子 sequence——这就是「不直接驱动」铁律的两层含义:不连 driver、也不自己造接口事务。 - fork 分支失控:并行分支只
join_none不回收,子分支报错后主线程仍在等待,表现为整测挂死。建议给关键并行段包fork...join并在禁用 fork 时disable fork,配合超时看门狗。 - 用延时代替同步:
#2000换台机器、换个时钟频率就失效。跨接口等待一律用uvm_event、响应事务或 objection 机制表达,objection 的正确用法见 UVM objection 机制。 - 把子 sequencer 类型声明错:句柄类型与 agent 实际 sequencer 不匹配,connect_phase 里赋值编译不过或运行时类型错误。
- 过度使用:纯单接口测试套一层 virtual 纯属增加文件数和跳转层级,直接在 test 里 start 普通 sequence 更清楚。
- 跨接口误用 lock/grab:
lock锁的是单个 sequencer 的仲裁权,不会锁住「别的接口」。想独占某条总线可以 lock 对应子 sequencer,但别指望它能阻止另一路上的流量;跨接口独占只能靠编排本身保证。 - 在 pre_body 里用 p_sequencer:转型发生在宏注入的 pre_body 逻辑中,若自己再写 pre_body 且不调
super.pre_body(),转型被跳过,p_sequencer 仍是 null。需要 pre_body 时记得保留 super 调用。 - 响应取错 sequencer:带响应的事务用
get_response时,必须在发送它的那个子 sequencer 上下文里取回;virtual sequence 本身收不到响应。更常见的干净做法是响应检查交给 monitor 与记分板,sequence 只做事件级等待。 - 一个 vseq 承担所有场景:把几十个不相关场景塞进同一个 body 再靠开关位选择,等于放弃了剧本的可读性。一个场景一个派生类,公共部分放基类。
这一篇在知识体系里的位置
本文处于 UVM 激励机制的上层:底层的事务如何经 sequencer 仲裁到 driver,见 sequence/driver/scoreboard;virtual sequence 编排好的剧本何时允许仿真结束,由 UVM objection 机制 把关;virtual sequencer 与子 sequencer 之间的句柄查找、env 到 test 的配置下发,常配合 uvm_config_db 用法 完成;涉及寄存器模型的配置事务则经 UVM register adapter/predictor 接到总线 sequencer。再往工程方向走,不同测试场景之间靠 factory override 替换 virtual sequence 或子 sequence,见 UVM factory 机制。
总结
UVM virtual sequence 是只做编排、不碰驱动的顶层剧本:它 start 在一个不连任何 driver、只持有各子 sequencer 句柄的 virtual sequencer 上,借 uvm_declare_p_sequencer 拿到这些句柄,用 start/uvm_do_on 把事务定向投递到真实 sequencer,用串行段、fork 并行段和 uvm_event 同步点组合出任意跨接口时序;句柄必须在 env 的 connect_phase 连好,单接口场景则无需引入这一层。