UVM phase 深入:phasing 为什么能统一仿真顺序
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」进阶层(intermediate) 验证方法学的一篇。组件全景先读 UVM 组件与 phase;本文只回答一件事:UVM 凭什么让一棵有几十个组件的验证环境,永远以确定的顺序被创建、连接、驱动和收尾。配套机制见 UVM objection 机制与 UVM config_db 用法。
本文要回答的问题
- 没有 phasing 时,大型 testbench 为什么必然出现「先连后建」的竞态?
- build/connect/end_of_elaboration/start_of_simulation/run 各自该放什么操作?
- run_phase 和 reset/config/main/shutdown 这 12 个 sub-phase 是并行还是串行?能否同时用?
- 为什么 build 自顶向下、connect 自底向上?组件树之间在哪里同步?
- objection 怎样决定一个 phase 何时结束?phase jump 什么时候才用?
一、UVM 为什么需要 phasing
一个朴素的 testbench 里,激励、监视器、参考模型、计分板之间靠手写 initial 块配合,块与块之间用事件或延时约定先后。组件少时还能管住,组件一多,问题立刻暴露:agent 还没 new 出来,env 就试图拿它的 sequencer 句柄;monitor 的 TLM analysis port 还没实例化,scoreboard 就来 connect;时钟还没起,driver 已经在 @(posedge clk)。每个块内部都对,整体顺序却错。更麻烦的是这类错误往往不是每次都炸:句柄偶尔先被赋值、事件偶尔先触发,仿真就在「有时通过、有时空指针」之间飘忽,根因全是初始化次序没有保证。
UVM 的答案是 phasing:不让每个组件自己决定「什么时候做什么」,而是由 uvm_domain 统一调度。所有组件实现同一组回调(build_phase、connect_phase、run_phase……),调度器保证全树先做完 build 再做 connect,全部 connect 完才进入 run。组件作者只需回答「我的创建逻辑放哪、连接逻辑放哪、运行逻辑放哪」,顺序问题被框架收走。哪怕换一个测试、换一套 factory override,phase 次序也不变,变的只是每个回调里具体创建了谁。
这带来两个直接好处。一是确定的全局次序:同一套环境跑一万次,组件创建和连接的次序完全一致,消除了初始化竞态。二是结构与行为分离:build/connect 负责把环境「搭起来」,run 负责让它「动起来」,两者用 function 与 task 在语言层面硬性区分,想在搭建阶段消耗仿真时间都做不到。理解 phasing 的关键心智模型是:phase 不属于某个组件,而属于贯穿整棵组件树的一条公共时间轴,每个组件只是往这条轴的各个槽位里挂自己的回调。
补一个术语:这条时间轴在 UVM 里由 domain(域) 持有。默认所有组件都挂在 common domain 上,所以全环境步调一致;多时钟域、多独立 DUT 的特殊环境可以另建 domain,让几条 phase 时间轴并行运转。框架还允许插入自定义 phase,但那属于高级用法,99% 的项目用内置这套调度表已经足够,乱加 phase 只会让后来者读不懂环境的时序。
可以用一棵真实的组件树感受调度规模:test 下挂 env,env 下挂两个 agent、一个记分板、一个虚拟 sequencer,每个 agent 又各含 driver、monitor、sequencer,再算上寄存器模型和预测器,粗算已有十五六个组件。手写次序意味着要给这十五六个对象排出一张全局创建/连接表,还要保证每个测试用例都不破例;phasing 把这张表固化进框架,组件数从十五个涨到一百五十个,规则依然不变。这才是它在大型环境里真正不可替代的原因——不是省几个 initial,而是让复杂度增长时次序管理不跟着失控。
二、function phase:不耗仿真时间的搭建链
phasing 的前半段全部是 function phase。function 在 SystemVerilog 里不能阻塞、不能 wait、不能吃时钟沿,因此这几个阶段在同一仿真时刻内瞬间完成,次序如下:
读图抓住一个分界:前四个方框都是 function,在零仿真时间内串行流过;进入 run 才开始真正耗时。各阶段的典型操作如下:
class env extends uvm_env;
agent agt;
scoreboard sb;
bit en_sb;
virtual function void build_phase(uvm_phase phase);
super.build_phase(phase);
// 1. 先取配置,配置可能影响「要不要创建」
if (!uvm_config_db#(bit)::get(this, "", "en_sb", en_sb))
`uvm_fatal("NOCFG", "en_sb not set")
// 2. 再创建子组件
agt = agent::type_id::create("agt", this);
if (en_sb) sb = scoreboard::type_id::create("sb", this);
endfunction
virtual function void connect_phase(uvm_phase phase);
super.connect_phase(phase);
// 此刻所有子组件均已 build 完,端口可安全连接
agt.mon.ap.connect(sb.imp);
agt.sqr = vseqr; // 绑定 virtual sequencer 句柄
endfunction
virtual function void end_of_elaboration_phase(uvm_phase phase);
super.end_of_elaboration_phase(phase);
`uvm_info("TOPO", this.sprint(), UVM_NONE) // 打印整棵树
endfunction
virtual function void start_of_simulation_phase(uvm_phase phase);
super.start_of_simulation_phase(phase);
`uvm_info("BANNER", "==== simulation start ====", UVM_LOW)
endfunction
endclass为什么要在 connect 和 run 之间再放 end_of_elaboration 与 start_of_simulation 两个阶段?因为有些操作既不能在 build 做(子组件还没建完),也不能在 connect 做(兄弟组件的连接还没全部完成),却必须在仿真时间开始前定稿。end_of_elaboration 的含义就是「精雕结束」:此刻整棵树已经完整连通,可以安全地遍历它——打印拓扑、核对 factory 实际创建的类型、检查必填句柄非空、根据最终配置修正某些参数。start_of_simulation 则是时间开始前的最后一个钩子,适合打印本次测试的配置摘要、给覆盖率采样器置位、向记分板预置复位后的期待值。两个阶段的分工是:前者检查「结构对不对」,后者准备「开局做什么」。
run 结束后还有三个收尾 function phase:extract_phase 从 DUT 寄存器模型和覆盖率库收集数据,check_phase 做不依赖时序的最终比对(比如期待队列是否清空、寄存器回读是否一致),report_phase 统一整理统计、决定仿真退出码。想在记分板自比对之外再加一道全局自检,check_phase 是标准位置;这三个阶段同样是零耗时 function,区别只在于调用时机。
virtual function void extract_phase(uvm_phase phase);
super.extract_phase(phase);
uvm_config_db#(int)::set(null, "", "final_cov_pct", cov.cg.get_inst_coverage());
endfunction
virtual function void check_phase(uvm_phase phase);
super.check_phase(phase);
if (sb.exp_q.size() != 0)
`uvm_error("LEFTOVER", $sformatf("expect queue not empty: %0d", sb.exp_q.size()))
endfunction
virtual function void report_phase(uvm_phase phase);
uvm_report_server svr = uvm_report_server::get_server();
super.report_phase(phase);
if (svr.get_severity_count(UVM_ERROR) + svr.get_severity_count(UVM_FATAL) > 0)
`uvm_info("RESULT", "TEST FAILED", UVM_NONE)
else
`uvm_info("RESULT", "TEST PASSED", UVM_NONE)
endfunction这三个回调的存在让「判定」也有固定位置:不要在最后一个 sequence 结束的时刻随手打 PASSED,那时在途事务可能还没比完;统一在 report_phase 依据错误计数下结论,结论才可信。
三、task phase:run_phase 与 12 个 sub-phase
唯一默认耗仿真时间的是 task phase。run_phase 从 start_of_simulation 之后启动,所有组件的 run_phase 并行运行——这正是它和 function phase 最大的不同:function phase 按组件树逐个调完,run_phase 是全树同时 fork,driver 在发激励的同时 monitor 在采样、scoreboard 在比对,谁也不阻塞谁。
UVM 又在 run_phase 内部定义了一套更细的 task sub-phase,按时间严格串行:
每个大阶段拆成 entry、exec(即 reset_phase 本体)、exit 三个小 phase,语义是「进阶段前准备 → 阶段主体 → 出阶段清理」。entry/exit 适合放跨组件的仪式性动作:entry 里把本阶段的事件清零、把监测器切到对应模式,exit 里等本阶段事务排空、锁存阶段性结论。
为什么框架要细到 entry/exit,而不是让组件在主体 task 首尾自己处理?原因仍是「并行」:reset_phase 内各组件并行,你无法保证 A 的准备动作先于 B 的主体动作;而 entry 是一个独立 phase,框架保证所有组件的 entry 全部结束后才进入 exec,exec 全部结束后才统一 exit。于是「全体先准备 → 全体执行 → 全体清理」这条次序由框架兜底,不靠组件间互相猜。这也是 function phase 同步栅栏思想在 task 域的延续。最常用的写法是只实现主体四个:
class my_test extends uvm_test;
virtual task reset_phase(uvm_phase phase);
phase.raise_objection(this);
vif.rst_n <= 1'b0;
repeat (20) @(posedge vif.clk);
vif.rst_n <= 1'b1;
phase.drop_objection(this);
endtask
virtual task configure_phase(uvm_phase phase);
phase.raise_objection(this);
configure_seq.start(env.vseqr); // 先配寄存器
phase.drop_objection(this);
endtask
virtual task main_phase(uvm_phase phase);
phase.raise_objection(this);
traffic_seq.start(env.vseqr); // 主体激励
phase.drop_objection(this);
endtask
virtual task shutdown_phase(uvm_phase phase);
phase.raise_objection(this);
wait(env.sb.drain_cnt == 0); // 等事务排空
phase.drop_objection(this);
endtask
endclass要强调一点:sub-phase 不是只有 test 能实现,组件树里任何组件都可以挂。比如 driver 在 reset_phase 里把接口拉回空闲,monitor 在 configure_entry_phase 里切换解码模式,各组件的同一 sub-phase 并行执行、全部结束后才进下一个:
virtual task driver::reset_phase(uvm_phase phase);
phase.raise_objection(this);
vif.cb.req <= 1'b0;
vif.cb.addr <= '0;
@(posedge vif.rst_n); // 等复位释放
phase.drop_objection(this);
endtask
virtual task monitor::configure_entry_phase(uvm_phase phase);
phase.raise_objection(this);
check_mode = 1'b1; // 配置阶段只监听,不比对
phase.drop_objection(this);
endtaskrun_phase 与这套 sub-phase 的关系必须讲清:两者在时间轴上并行,sub-phase 是嵌套在 run_phase 总时长之内的一条平行轨道,而不是 run_phase 内部调用的子例程。UVM 规定,一个组件若 override 了任一 sub-phase,它的 run_phase 就不应再承载有实质语义的时序逻辑,否则两套时间组织方式在同一组件里并行推进、互相覆盖,行为难以预测。团队要统一选一种风格:简单测试用 run_phase 一个 task 跑完整条序列;正式环境用 sub-phase 把复位、配置、主体、收尾交给框架分段。
| 对比项 | run_phase | 12 个 sub-phase |
|---|---|---|
| 时间组织 | 一整段,自己内部分段 | 框架强制 reset→configure→main→shutdown |
| 相互关系 | 与 sub-phase 并行(嵌套) | 组与组之间严格串行 |
| objection | 自己 raise/drop | 每个 sub-phase 各自 raise/drop |
| 适用场景 | 流程简单、单序列跑完 | 复位/配置/测试/收尾界限清晰的正式环境 |
| 同组件混用 | 不允许 | 与 run_phase 二选一,全团队统一 |
function phase 与 task phase 的分界同样值得列清:
| 对比项 | function phase | task phase |
|---|---|---|
| 代表 | build/connect/end_of_elaboration/start_of_simulation | run 及 12 个 sub-phase |
| 能否耗时 | 不能,零仿真时间完成 | 能,可等时钟、wait、收响应 |
| 调度方式 | 按方向逐个调用 | 全树并行 fork |
| 典型动作 | 创建、配置、连接、检查 | 复位、发激励、等待中断 |
四、执行方向:自顶向下与自底向上
function phase 不是平的,它们在组件树上有明确方向。build 自顶向下,connect 自底向上:
build 自顶向下是 config_db 成立的前提:父组件在自己的 build_phase 里 set,随后子组件才执行 build_phase 去 get,配置永远早于消费方;同时父在 build 中 create 出子组件,子才存在。如果 build 反过来走,子组件 get 时配置还没下发,「要不要创建、按什么位宽创建」这类决策就必然错。connect 自底向上则保证:env 连接两个 agent 的 TLM 端口时,agent 内部 monitor 的端口早已在更深一层 connect 完毕,父组件拿到的永远是就绪句柄,不会连到一个 null port 上。
两个典型报错可以帮助定位方向错误。在 build 里访问 agt.mon.ap,通常炸成 null instance pointer——因为 build 是父先执行,agent 内部的 monitor 此刻还没 new。在 build 里直接做 TLM connect,则报 Cannot call connect() ... port is not initialized,连接动作必须等子组件端口实例化之后。看到这两类信息,不用怀疑组件写错,十有八九是把操作放错了 phase 或没理解执行方向。
还有一道容易被忽略的全局同步栅栏:调度器不是让某个组件一路 build→connect→run 冲到底,而是让整棵树先全部完成 build(一个同步点),再全部完成 connect(下一个同步点),最后才一起进入 run。所以「自顶向下」描述的是单个 phase 内部跨层级的次序,phase 与 phase 之间有全树栅栏隔开。哪怕 agent A 的 build 早就做完,它也不能提前 connect,必须等同层所有分支都 build 完。这正兑现了第一节的承诺:先全部建好、再全部连好、再一起跑。
| 对比项 | 自顶向下(build) | 自底向上(connect) |
|---|---|---|
| 执行次序 | 父 → 子 → 叶 | 叶 → 子 → 父 |
| 保证的前提 | 父的 set/create 先于子 | 子的端口先于父就绪 |
| 典型操作 | config get、new 子组件 | TLM connect、绑定句柄 |
| phase 间关系 | 全树 build 完才统一进 connect | 全树 connect 完才统一进 run |
五、phase 同步与 objection
task phase 是并行的,框架必须知道「什么时候这一阶段算结束」。答案是 objection(异议机制):组件在 phase 内还要干活时 raise_objection,干完 drop_objection;当某 phase 的所有 objection 都被撤销、且没有新的 raise,调度器才结束该 phase,进入下一个。没有任何组件 raise 的 sub-phase 会立即结束,这也是为什么前面每个 task 回调都要成对 raise/drop——漏 raise,阶段可能在序列刚启动时就被收尾;漏 drop,仿真会挂到全局超时才停。
task main_phase(uvm_phase phase);
phase.raise_objection(this, "traffic running");
fork
begin : watchdog
repeat (100000) @(vif.clk);
`uvm_error("TO", "main phase watchdog timeout")
end : watchdog
join_none
traffic_seq.start(env.vseqr);
disable fork;
phase.drop_objection(this, "traffic done");
endtaskobjection 的计数是按 phase 独立维护的:main_phase 里 raise 不会阻止 configure_phase 的推进,在错误的 phase 对象上 drop 也同样无效。框架还提供两个精细钩子:所有 objection 撤掉后、phase 真正结束前会回调 phase_ready_to_end,组件若发现自己还想补几个时钟节拍,可以在这里重新 raise;drop 之后框架还会留一段 drain time(排空时间),等在途事务流进记分板,避免「激励发完立刻收尾、最后一笔没比对」。
还要区分两类容易混淆的 objection。sequence 内部的 starting_phase objection 是老写法:sequence 拿一个 phase 句柄,在 pre_body raise、post_body drop,适合序列被多处复用、不想让调用方操心的场合。phase 回调里直接 raise/drop 是新写法,也是本文示例的风格,归属清晰、一眼能看出谁在占用 phase。两种写法不要对同一段激励叠加使用,否则计数翻倍,漏一处 drop 就挂死。完整的 raise/drop 配对、超时兜底与 drain time 细节见 UVM objection 机制;配置为何必须在 build 取、set 与 get 的路径匹配规则见 UVM config_db 用法。
六、phase jump:非标准的跳转
正常流程里 phase 严格按序推进,但 UVM 预留了 phase.jump(...),允许从当前 task phase 直接跳到指定 phase。典型动机是故障恢复测试:main_phase 跑到一半强制触发一次致命错误,希望立刻退回 reset_phase 把复位、配置、主测试整条流程重跑一遍,而不是等 main/shutdown 自然走完再另开测试。
task main_phase(uvm_phase phase);
phase.raise_objection(this);
fork
begin
wait(vif.fatal_err);
`uvm_warning("JMP", "fatal error -> jump back to reset")
phase.jump(uvm_reset_phase::get()); // 跳回 reset_phase
end
join_none
traffic_seq.start(env.vseqr);
phase.drop_objection(this);
endtaskjump 会强行中断当前正在执行的所有 task phase 回调,被跳过的 phase 里没 drop 的 objection 由框架统一清理,目标 phase 从头执行。代价同样明显:跳转路径上各组件的 exit 清理逻辑可能被绕过,组件内部状态机未必能承受「半路杀回复位」,多组件并行时谁先被打断、谁留下了半成品状态也不直观,调试调用栈时看到的是一次非局部转移。因此 jump 只用于明确的恢复、重试、错误注入场景,并且要保证被跳回的 reset_phase 能把全环境真正复位干净;常规激励组织仍应靠 sub-phase 的正常次序,不能把 jump 当 goto 滥用。
实操中使用 jump 还有两条纪律。一是跳转目标只能是 task phase,function phase 早已在零仿真时间执行完毕、无法重入,试图跳回 build/connect 会直接报错。二是 jump 之前由谁触发、触发条件如何撤销要设计清楚:触发用的故障信号若一直保持有效,跳回 reset 后可能立刻再次触发 jump,形成复位与跳转之间的死循环。通常的做法是在 reset 流程里清除故障标志,让重跑从一个干净状态开始。
七、高频踩坑与正确做法
把全篇的坑集中成一张速查表,调试时按「表现」反查:
| 坑 | 典型表现 | 正确做法 |
|---|---|---|
| 在 build 里连接端口 | null instance pointer | 连接放 connect_phase |
在 function phase 里 wait/等时钟 | 编译报错或立即返回 | 时序逻辑只放 task phase |
| config get 放到 connect | 配置拿不到,创建决策已错 | get 一律放 build 前半段 |
| run_phase 与 main_phase 同组件都写 | 两套时序互相覆盖 | 全团队统一只选一种 |
| 漏 raise/drop objection | phase 秒结束或挂到超时 | 成对调用,配看门狗 |
| 父 build 里假设子已存在 | 取子句柄得到 null | 需要遍历/检查放到 end_of_elaboration |
| jump 当常规流程控制 | 清理被绕过、状态混乱 | 仅用于故障恢复与重试 |
读这张表的方式不是背诵,而是建立一个反射动作:报错信息里出现 null 句柄、端口未初始化,先怀疑 phase 放错;仿真不结束或瞬间结束,先数 objection;流程诡异跳转,先查有没有 jump。绝大多数 phasing 问题都逃不出这三类。
这一篇在知识体系里的位置
本篇属于芯片设计进阶层的 UVM 运行机制,回答「框架怎样保证几十个组件按确定次序构建和运行」。向前读,组件各自的职责见 UVM 组件与 phase,激励如何在 phase 中被驱动见 UVM sequence/driver/scoreboard;横向配套,phase 何时结束由 UVM objection 机制决定,build 阶段的配置传递见 UVM config_db 用法,多 agent 协同的 virtual sequence 见 UVM virtual sequence;向后读,phase 回调也是 UVM callback 注入和故障注入的挂载点。
总结
UVM phasing 用一组统一回调把仿真生命周期切成 build/connect/end_of_elaboration/start_of_simulation/run 及收尾链:前四个 function phase 零耗时、build 自顶向下(保证父的 set/create 先于子)、connect 自底向上(保证子端口先就绪),phase 之间有全树同步栅栏,做到「先全部建好、再全部连好、再一起跑」;run_phase 是全树并行的 task phase,其内可按 reset/configure/main/shutdown 各带 entry/exit 的 12 个 sub-phase 串行组织激励,二者在同一组件内只能选一种风格;task phase 靠按 phase 独立计数的 objection 判定结束,辅以 phase_ready_to_end 与 drain time 收尾,phase jump 仅在故障恢复这类非标准场景下跳回指定阶段,不能当常规流程控制。