UVM Objection:控制仿真何时结束
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」进阶层(intermediate) 验证专题的一篇。跑 UVM 仿真的人都会遇到两个经典场面:要么回归用例跑了 0 纳秒就「PASS」收场,一看波形激励根本没发出去;要么测试台永远不退出,最后靠全局 timeout 被强杀。这两个看似相反的毛病,病根都在同一个东西——UVM objection(异议)机制。它是 phase 的刹车开关:phase 机制本身见 UVM 组件与 phase,sequence 与 driver 怎么握手见 UVM sequence、driver 与计分板,这一篇只拆「仿真何时结束」这一件事。
本文要回答的问题
- UVM 怎么知道一条用例「跑完了」?没有
$finish的激励为什么也能正常退出? raise_objection和drop_objection背后的计数规则是什么,不成对会怎样?- objection 到底该挂在 test、sequence 还是 driver/monitor 里?
starting_phase为什么不能再用? - 最后一次 drop 之后,流水线里还在飞的响应谁来等?drain time 设多少合适?
- 仿真过早结束(漏判 bug)和永远挂死,分别怎么定位?
一、objection 是什么:phase 上的引用计数闸门
UVM 的 phase 分两类:build、connect 这类函数 phase靠调用返回自然结束;而 run_phase(以及 12 个 task 子 phase)是耗时 task,里面的激励往往发在 fork 出去的并发进程里,phase 本体根本无法靠「返回」判断工作是否做完。于是 UVM 给每个 task phase 配了一个 uvm_objection 对象,用引用计数表达「还有人反对结束」:
phase.raise_objection(this):计数加一,意思是「我还在干活,别停」;phase.drop_objection(this):计数减一,意思是「我干完了」;- 只要计数大于 0,phase 就一直存活;当计数归零、并且越过下文要讲的 drain time,UVM 才结束这个 phase,向后进入 extract、check、report 等收尾 phase。
objection 可以带描述字符串,仿真报告里会直接打印谁持有、持有多少,这对排查泄漏非常有用:
phase.raise_objection(this, "main traffic still running");
// ...
phase.drop_objection(this, "traffic done");关键点是:UVM 不会替你判断激励有没有发完,它只认计数。激励线程还活着但计数已经归零,phase 照样结束、进程被强制收回;反过来计数不归零,哪怕总线早已空闲,仿真也会一直挂着。理解这一点,后面所有的坑都只是这条规则的不同表现。
再补三条常被忽略的细节。第一,每个 task phase 各自挂一个独立的 objection 对象:在 run_phase 上 raise 的计数,挡不住 reset_phase、main_phase 的结束;而且 run_phase 与 12 个 task 子 phase 是二选一的并行分支,同一套环境里不会同时跑,混用只会让计数归属更乱。第二,计数是跨组件汇总的——test、sequence、各组件可以各自 raise,UVM 沿层次结构向上汇总出一个总数,任何一处不归零都会拖住全局。第三,每次 raise/drop 都会沿着 component 树传播并触发回调,开销不算大,但绝不能放在逐事务的热循环里,几百万元素的流量流上每笔都 raise/drop 一次,仿真会明显变慢;需要计数多个时,用第三个参数一次性 raise_objection(this, "burst", N) 批量处理。同理,正常收尾时仿真报告末尾会列出每个 phase 的 objection 汇总,健康用例在这份汇总里应当清一色归零;养成扫一眼这份汇总的习惯,很多「碰巧通过」的用例会在正式进入回归之前就露馅,省下的是整条回归队列的时间。
二、标准写法:raise、start、drop
最简单、也最推荐入门时使用的写法,是在 test 的 run_phase 里用一对 raise/drop 把整个激励过程包起来:
task run_phase(uvm_phase phase);
my_traffic_seq seq = my_traffic_seq::type_id::create("seq");
phase.raise_objection(this, "start traffic");
seq.start(env.agent.sqr); // start 是阻塞调用,sequence body 返回后才继续
phase.drop_objection(this);
endtaskseq.start() 会阻塞到 sequence 的 body 执行完毕,所以这对 raise/drop 精确覆盖了「激励发送」的全程,计数不会提前归零。顺带把 start 的调用时序记牢:pre_start → pre_body → body → post_body → post_start,其中 pre/post_body 还会随 parent_seq 的调用深度做层级嵌套。这也是后面 sequence 自管 objection 时选择 pre_body/post_body 这对钩子的原因——它们恰好夹住 body,且 sequence 若被 seq.kill() 强杀,post_body 不会执行,这本身就是一个明确的泄漏信号,排查时一眼能对上。注意不要写成下面这样——fork ... join_none 让 start 在后台跑,run_phase 当场走到 drop,计数瞬间归零,phase 立刻结束,sequence 一个事务都来不及发:
// 错误写法:仿真跑 0 纳秒就结束
task run_phase(uvm_phase phase);
phase.raise_objection(this);
fork
seq.start(env.agent.sqr); // 后台并发,没人等它
join_none
phase.drop_objection(this); // 计数立刻归零,前功尽弃
endtask如果确实需要并发(比如一边发流量一边等中断),要用 wait fork 或显式事件保证 drop 之前所有分支都已收尾:
task run_phase(uvm_phase phase);
phase.raise_objection(this);
fork
traffic_seq.start(env.agent.sqr);
begin wait(env.intr_e.triggered); end
join // join 等两个分支全部完成
phase.drop_objection(this);
endtask如果并发分支是「谁先到都行」的竞争关系,join_any 之后必须用 disable fork 收掉残留分支,再 drop——否则残留分支里若还持有自己的计数,又是一个泄漏源。固定写法如下,建议原样背下来:
task run_phase(uvm_phase phase);
phase.raise_objection(this);
fork
begin seq1.start(env.agent.sqr); end
begin seq2.start(env.agent.sqr); end
join_any // 任一 sequence 完成即继续
disable fork; // 杀掉未完成的分支,计数责任随之收回本 task
phase.drop_objection(this);
endtask注意 disable fork 只是杀进程,不会替被杀进程 drop 它已有的计数,所以多 sequence 各自管计数的环境用这种写法要格外小心;更稳的工程惯例是并发只留在一层——要么 test 统一计数、sequence 不碰 objection,要么反过来。
三、时间线:从 raise 到 phase 真正结束
把一次完整用例的计数变化画成时间线,UVM objection 的判定过程一目了然。
读图抓住三段:**计数为正的「保护期」**覆盖激励;归零的瞬间不是结束的瞬间,后面还挂着一段 drain time;只有 drain 期内无人再 raise,phase 才真正关闭。图里的计数也可以是 3、可以是任意整数——多次 raise 只是多次加法,规则完全相同。UVM 还支持 phase.drop_objection(this, "done", 2) 一次减多个计数,本质上仍是这把引用计数算盘。
计数归零的那一刻,UVM 还会依次发出 dropped 和 all_dropped 两级回调(前者在每次 drop 时触发,后者只在总数真正归零时触发)。有些环境利用这对回调做自定义收尾,比如归零瞬间抓一次覆盖率快照、或在所有用例共享的基类里统一打印本 phase 的事务统计。对普通使用者来说,知道「归零是一个带回调的事件、而不是悄无声息的数字变化」即可——这也是挂自定义调试钩子、打印是谁最后松手的合法挂载点。
四、objection 挂在哪里:test、sequence 与 component 的抉择
除了挂在 test,也常看到挂在 sequence 里的写法。sequence 本身没有 phase 句柄,老代码用过 starting_phase,但它在 1800.2 标准中已被弃用(default sequence 机制也随之弱化)。现代写法是在 sequence 基类里放一个 phase 成员,由 test 显式注入,在 pre_body/post_body 中 raise/drop:
class base_traffic_seq extends uvm_sequence #(my_trans);
`uvm_object_utils(base_traffic_seq)
uvm_phase m_phase; // test 显式注入,替代已弃用的 starting_phase
task pre_body();
if (m_phase != null)
m_phase.raise_objection(this, {get_name(), " running"});
endtask
task post_body();
if (m_phase != null)
m_phase.drop_objection(this);
endtask
endclass
// test 中:只负责启动,objection 由 sequence 自管理
task run_phase(uvm_phase phase);
my_traffic_seq seq = my_traffic_seq::type_id::create("seq");
seq.m_phase = phase;
seq.start(env.agent.sqr);
endtask这种写法的好处是激励自包含:sequence 被复用到别的 test 或挂到 virtual sequence 下时,不用每个调用方都记得包 raise/drop。三种放置位置的取舍如下:
| 维度 | test 的 run_phase | 顶层 sequence(pre/post_body) | driver / monitor / 子组件 |
|---|---|---|---|
| phase 句柄来源 | 形参直接有 | 需显式注入(starting_phase 已弃用) | run_phase 形参有 |
| 推荐度 | 高,简单直观 | 高,激励自包含、可复用 | 不推荐 |
| 覆盖范围 | 包住所有 seq.start 与协调逻辑 | 只包本 sequence 的 body | 分散在各组件,难追踪 |
| 典型风险 | 多 sequence 协调时代码啰嗦 | 并行多 sequence 时各自计数要成对 | 异常分支漏 drop,计数泄漏 |
| drain time 管理 | 一处统一设置 | 各 sequence 容易重复设置 | 分散设置、互相干扰 |
| 适用场景 | 定向测试、快速用例 | 可复用激励库、virtual sequence | 仅临时实验,不进回归 |
结论是 objection 要集中在「最知道全局何时跑完」的那一层:单用例时放 test,激励要复用就放 sequence 基类,绝不在 monitor、driver 里随手 raise。成熟团队通常把 objection 管理沉到两层基类里:base_test 在 run_phase 外围包一对带描述的 raise/drop、设统一 drain time 与全局 timeout;base_sequence 预留可选的 phase 注入钩子,默认关闭、只有需要自包含的激励才打开。这样新写用例时绝大多数人根本不需要直接调用 objection API,从源头消灭了一半的挂死事故。
还要提醒一点:raise 和 drop 用的对象句柄(第一个参数 this)最好始终是同一个对象。虽然总数的加减与传入对象无关——用 A raise、用 B drop 一样能归零——但 UVM 内部按对象记录「谁持有多少」,句柄错位会让追踪报告里出现一个对象只 raise 不 drop、另一个只 drop 不 raise 的诡异画面,+UVM_OBJECTION_TRACE 排查时反而被带偏。正确与错误放置导致的行为差异如下图。
这张图把本文开头说的两大事故对齐到了具体写法:过早结束几乎都是「drop 没等阻塞」,挂死几乎都是「raise 之后存在没走到 drop 的路径」。
五、归零之后:drain time 与尾部排空
sequence body 返回,只代表激励发完了,不代表 DUT 的响应都比完了。流水线深处可能还压着十几个事务,计分板里还排着队等输出事务来比对,此刻 drop 又归零,phase 立即收尾,最后几拍的响应永远等不到比对——这类「回归全绿但漏了尾部 bug」非常隐蔽。标准对策是设置 drain time:最后一次 drop 之后,UVM 不立刻结束,而是再等一个排空窗口。
task run_phase(uvm_phase phase);
phase.phase_done.set_drain_time(this, 200); // 归零后再等 200 时间单位
phase.raise_objection(this);
seq.start(env.agent.sqr);
phase.drop_objection(this);
endtaskset_drain_time 是配在 phase 的 objection 对象(phase_done)上的属性,不属于某次 raise/drop,所以放在 run_phase 开头即可。它在内部按调用对象分别记录:多个对象各自设置了不同 drain time 时,最终生效的是最后一次 drop 的那个对象所设置的值。所以团队协作的环境里,drain time 最好只在基类 test 里设一处,避免「明明设了 500,生效的却是子 sequence 里的 50」这类对不上号的问题。
200 这个数字不能拍脑袋:按 DUT 的最大流水线延迟和输出队列排空时间估算,留一点裕量。太短等于没设,尾部仍漏;太长每条用例白等,几千条回归叠起来是实打实的机器时。如果 DUT 的延迟本身随配置变化(比如深度可配的 FIFO、动态生效的低功耗模式),固定值怎么设都是错——这正是下一种「等空闲」写法存在的理由。
比固定时长更可靠的做法,是让计分板主动报告「真的比完了」——给计分板加一个等待方法,内部比对队列清空、in-order 计数对齐时才返回。计分板的比对与追踪机制见 UVM 计分板的 trace 对齐。这个等待方法的实现通常很简单:在计分板里维护入队、出队两个计数,靠一个事件在每次比对完成时通知等待方:
class my_scoreboard extends uvm_scoreboard;
int m_put_cnt, m_cmp_cnt;
event m_cmp_done;
task wait_queue_empty();
forever begin
wait (m_put_cnt == m_cmp_cnt); // 收、比数量对齐
-> m_cmp_done;
break;
end
endtask
// write 侧:收到 expect 时 m_put_cnt++,完成一次比对后 m_cmp_cnt++ 并触发事件
endclass
task run_phase(uvm_phase phase);
phase.raise_objection(this);
seq.start(env.agent.sqr);
env.sb.wait_queue_empty(); // 自定义:等到 expect/actual 队列全部清空
phase.drop_objection(this); // 此时 drop,drain time 只需很小的裕量
endtask这里有个必须强调的前提:「数量相等」不等于「最后一笔已经比完」。比对动作本身也消耗仿真时间,若 expect 与 actual 在同一时刻到达、计数对齐发生在比对函数执行之前,等待方仍可能提前放行。稳妥写法是把计数更新放在比对完成之后,并给 wait 加一个时钟周期级的余量;对乱序协议(如 AXI),还要确认等待条件里包含「所有 outstanding ID 都已闭环」,不能只看队列长度。这些判据各协议不同,本质同一件事:让「空闲」的定义来自 DUT 的真实状态,而不是墙上的时钟。
实践中常见组合是「计分板空闲为主、短 drain time 兜底」:既不空等,又防住个别没进计分板的延迟路径。复位中断测试是个典型例外——复位会清掉 DUT 流水线,复位前在飞的事务注定收不到响应,这时不能等队列清空,而应在复位后同步清空计分板的待比对队列,objection 的收尾判据也要相应切换,否则用例必然挂死。
六、挂死与过早结束:按症状排查
把两类故障的排查套路固化下来,遇到时按图索骥即可。
过早结束(仿真时长接近 0 或明显偏短):先查 drop 的位置是不是在 seq.start 阻塞返回之前——典型是 fork-join_none、事件没等、或 drop 被错放在 start 之前;再查是不是多个 sequence 里只有一个在管计数,另一个 sequence 的尾部没人保。打开波形确认最后一个事务有没有走完、计分板 compare 次数和发送次数是否相等。
永远挂死(只能等 timeout):逐条枚举「raise 之后可能提前离开的路径」——异常分支的提前 return、复位重配导致 sequence 被 kill、fork...join_any 后没 disable fork 但某分支里的 drop 已失效、多个 raise 只 drop 了一次。命令行加上 +UVM_OBJECTION_TRACE,UVM 会打印每次 raise/drop 的对象、描述和剩余计数,泄漏点直接现形;report 阶段的 objection 统计也会列出谁还持有计数。还有一种偶发挂死来自时序竞争:sequence 在 body 里临时 raise 做子流程,而 test 的 drop 与它交错,总计数短暂归零又被拉起——本身合法,却可能让 drain time 的计时反复重置。看到「仿真时走时停、最后才挂」的现象,就要怀疑这种重叠,统一收拢到一层管理后通常自然消失。
另外建议在基类 test 里设全局超时兜底,让挂死用例在固定时间内带着报错失败,而不是把整个回归挂墙:
function void build_phase(uvm_phase phase);
super.build_phase(phase);
uvm_top.set_timeout(10ms, 0); // 0 = 不允许用例自行放大超时
endfunction超时触发时 UVM 会打印当时的 objection 持有情况,这份报告往往就是定位线索本身。读这份超时报告有个小技巧:先看总数最大的 phase 是不是你预期的 run_phase,再沿着列出的 component 路径逐层往下找——持有计数挂在哪个层次,问题十有八九就出在该层次发起的 sequence 或 fork 块上。如果报告里连一个持有者都没有却还是超时,那通常不是 objection 的问题,而是某个零延迟的死循环(时钟发生器写错、forever 里没有时延)把调度器占满了,方向要换到激励生成逻辑上。更多 phase 层面的细节(jump、子 phase 与 objection 的交互)见 UVM phase 机制深入。
这一篇在知识体系里的位置
本篇属于芯片设计进阶层的 UVM 验证专题,回答「仿真在什么时刻才算跑完」。向前读,phase 的完整生命周期见 UVM 组件与 phase,激励从 sequence 到 driver 的握手见 UVM sequence、driver 与计分板;横向配套,多 agent 协调场景见 virtual sequence,计分板如何追踪乱序事务见 UVM 计分板的 trace 对齐,同主题还可对照阅读 phase、objection 与结束控制;向后读,objection 引发的零星失败要放进回归体系里统计和复现,见 回归测试与用例自动化。
总结
UVM objection 是 task phase 上的引用计数闸门:raise_objection 加一表示「还在干活」、drop_objection 减一表示「干完」,计数归零并经过 drain time 排空后 run_phase 才结束;正确写法是让最清楚全局进度的一层(test 或 sequence 基类)用成对的 raise/drop 包住阻塞式 seq.start,sequence 自管时通过显式注入的 phase 句柄替代已弃用的 starting_phase,尾部要么用按流水线延迟估算的 drain time、要么等计分板队列真正清空;drop 早于阻塞返回必然过早结束、漏判尾部 bug,raise 之后存在漏 drop 的异常分支必然计数泄漏、仿真挂死,排查时用 +UVM_OBJECTION_TRACE 看计数去向、用 set_timeout 兜底挂死用例。