UVM factory:override 类型替换机制
更新时间:2026-09-24。本文是「集成电路大类 · 芯片设计」进阶层(intermediate) UVM 验证机制深挖的一篇。组件与 phase 基础见 UVM 组件与 phase,参数下发见 UVM config_db 用法,行为注入的另一条路见 UVM callback 回调注入。
本文要回答的问题
- 为什么验证平台里几乎所有对象都不许直接
new(),非要绕一道type_id::create()? uvm_component_utils/uvm_object_utils这两个宏到底替你登记了什么?set_type_override和set_inst_override都叫替换,作用域差在哪,查找时谁先谁后?- 派生类满足什么条件才能合法 override?不满足时仿真器会怎么报错?
- override 为什么必须在 build_phase 做、做在
super.build_phase前还是后?替换不生效时怎么用 factory 自带打印排查?
一、直接 new 的死结:为什么需要工厂
先看一个没有工厂的世界。假设 agent 里这样创建 driver:
function void build_phase(uvm_phase phase);
drv = new("drv", this); // 类型被焊死在代码里
endfunction这行代码看似无害,却把「这里实例化的是 my_driver」这件事焊死在了平台主干里。现在 error_test 想换一个故意驱动坏数据的 driver,timeout_test 想换一个慢速 driver——除了回去改 agent 源码加 if 分支,别无他法。十几个测试下来,平台代码里会塞满 test_name == "xxx" 的条件分支,主干不再稳定,复用也无从谈起。
UVM factory(工厂)要解决的就是这个矛盾:把「创建什么类型」从编译期决定推迟到运行期查表决定。平台代码只声明「我要一个 driver」,至于这个 driver 到底是基准版还是某个测试的定制版,由工厂在创建那一刻查覆盖表回答。于是公共 env/agent/scoreboard 只写一遍,每个测试通过注册一张覆盖关系就能裁剪行为。这是 UVM 推荐的两大行为裁剪手段之一,另一种是 UVM callback 回调注入,文末会对比二者。
这里要把两类「测试间差异」分清楚。一类是数据差异:阈值是多少、跑多少个包、超时设多长,这些应该走 config_db 或 sequence 的随机约束,平台类型一个都不用换;另一类是行为差异:driver 的驱动协议要变形、monitor 要多采一组信号、scoreboard 的比对算法整个换掉,这些才是工厂替换的用武之地。新手最常见的误用是拿工厂去换一个只是想改两个参数的类——派生一个类只为加一条约束,其实在 sequence 里 req.randomize() with 就地约束就够了,不必动用全局机制。判断标准很朴素:如果差异能用「值」表达,就别动「类型」。
二、注册与创建:两个宏与 type_id::create
要让工厂管理一个类,必须做两件事:用宏注册、用工厂创建。
第一步,注册。组件(uvm_component 一族,有 parent、挂在组件树上、参与 phase)用 uvm_component_utils;事务、配置等普通对象(uvm_object 一族,靠句柄引用、不进组件树)用 uvm_object_utils:
class txn extends uvm_sequence_item;
`uvm_object_utils(txn)
rand bit [7:0] data;
function new(string name = "txn");
super.new(name);
endfunction
endclass
class my_driver extends uvm_driver #(txn);
`uvm_component_utils(my_driver)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
virtual task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive(req);
seq_item_port.item_done();
end
endtask
virtual task drive(txn tr);
`uvm_info("DRV", $sformatf("drive data=0x%0h", tr.data), UVM_LOW)
endtask
endclass宏展开后大致生成两样东西:一个类型代理(type proxy,uvm_object_wrapper),以及静态的 type_id 句柄。仿真在零时刻之前会把代理登记进全局唯一的 uvm_factory 单例,相当于这个类型在工厂那里「报了户口」,留下名字和「如何 new 出一个实例」的入口。
顺带解释两个宏为什么分开。组件的构造签名带 parent、创建后要登记进组件树并参与整套 phase 调度,工厂创建组件时走的是 create_component 路径;对象没有 parent、不进树、生命周期由句柄决定,走 create_object 路径。用错宏最典型的表现是给 sequence item 套了 uvm_component_utils,工厂按组件路径构造,少了 parent 就直接报构造错误。另外注意对象的 new 给了默认名字而组件没有——对象经常被工厂只凭名字批量造出来,组件则永远需要调用方显式提供名字和 parent,这个差异不是风格问题,是工厂调用约定的一部分。参数化类注册时宏写在类体内即可,但 type_id 的每一次引用都要带上完整特化参数,少写一组就被当成另一个类型。
第二步,用工厂创建,而不是自己 new:
class my_agent extends uvm_agent;
my_driver drv;
my_sequencer sqr;
function void build_phase(uvm_phase phase);
super.build_phase(phase);
drv = my_driver::type_id::create("drv", this);
sqr = my_sequencer::type_id::create("sqr", this);
endfunction
endclass这行 create 并不是简单地 new 一个 my_driver,而是拿「请求类型 + 即将挂上去的实例路径」去问工厂。下图是一次 create 在工厂内部走过的完整查表流程。
读这张图抓住两个要点。其一,查表顺序是先实例覆盖、后类型覆盖,路径命中的 inst override 优先级最高;其二,工厂返回的永远是「最终类型」的代理,调用方拿到的却是基类 my_driver 句柄——替换之所以能表现出新行为,靠的是 SystemVerilog 的虚方法多态,这也是后面第四节约束的根源。如果没有任何覆盖,查一圈落回原类型,行为和直接 new 完全一致,工厂不增加任何运行期负担之外的语义差异。
三、两种覆盖:set_type_override 与 set_inst_override
注册和创建的规矩立好之后,替换只需要测试里一行调用。
类型覆盖(set_type_override)是全局的:工厂里凡是有人请求「原类型」,一律给「替换类型」,不区分实例路径。典型用途是注入坏事务——sequence 照常随机 txn,工厂在背后把它换成约束更狠的派生类:
class err_txn extends txn;
`uvm_object_utils(err_txn)
constraint err_c { data == 8'hFF; }
function new(string name = "err_txn");
super.new(name);
endfunction
endclass
class error_test extends base_test;
`uvm_component_utils(error_test)
function void build_phase(uvm_phase phase);
txn::type_id::set_type_override(err_txn::get_type());
super.build_phase(phase); // env 在这之后才创建,拿到的全是 err_txn
endfunction
endclass也可以直接拿工厂句柄按类型或按类名字符串设置:uvm_factory::get().set_type_override_by_type(txn::get_type(), err_txn::get_type()),按名字的版本在字符串化的回归脚本里很常用。
这次替换对上层完全透明,值得把整条数据流走一遍。sequence 里写的照旧是基类型:
class my_seq extends uvm_sequence #(txn);
task body();
repeat (100) begin
req = txn::type_id::create("req"); // 工厂在背后给的是 err_txn
start_item(req);
if (!req.randomize()) `uvm_error("SEQ", "randomize failed")
finish_item(req);
end
endtask
endclasssequence 对 err_txn 的存在一无所知:它 create 的是 txn,工厂返回的却是 err_txn 实例,随后的 randomize() 在基类约束之上再叠加 err_c,driver 收到的每一笔数据都是 0xFF。这就是「在最靠近源头的地方动手」的威力——不用改 sequence、不用改 driver,错误就注入了整条通路。反过来要恢复正常,跑一个不做覆盖的测试即可,没有任何残留开关需要清理。
实例覆盖(set_inst_override)只认路径:只有完整层次路径匹配的那一个实例被替换,其余同名组件纹丝不动。比如平台里例化了两个 agent,只想给 0 号换快 driver:
function void build_phase(uvm_phase phase);
my_driver::type_id::set_inst_override(
fast_driver::get_type(), "env.agent[0].drv", this);
super.build_phase(phase);
endfunction路径是相对第三个参数 this 拼出的,写错一个层级就静默不命中。路径支持 *、? 通配,例如 "env.agent[*].drv" 可以一次命中数组里所有 agent 的 driver。下图对比两种覆盖在组件树上的作用域。
这张图把作用域差异讲透了:类型覆盖是一张「普惠名单」,挂在类型上;实例覆盖是一张「定点名单」,挂在层次路径上,且定点优先于普惠。两类覆盖还能形成覆盖链——若 A 被替换成 B、B 又被替换成 C,工厂会顺着链条解析到 C。两种覆盖的决策差异汇总如下。
| 维度 | set_type_override | set_inst_override |
|---|---|---|
| 作用域 | 全局,所有请求原类型处 | 单条层次路径,支持通配 |
| 匹配依据 | 类型(wrapper 或类名字符串) | 相对 context 拼出的实例路径 |
| 优先级 | 低,inst 未命中才查它 | 高,路径命中立即采用 |
| 典型用途 | 全局注入坏事务、换 scoreboard 策略 | 只改多实例环境中的某一个 agent/driver |
| 易错点 | 忘了它影响面是全部实例 | 路径拼写/相对 context 错误,静默不命中 |
四、override 的多态基类约束
工厂替换不是任意两个类之间都能做,它有几条硬约束,背后都是同一句话:调用方手里握的是基类句柄,替换类必须能被这个句柄接住、并通过虚方法表现差异。
第一,替换类必须是请求类型本身或其派生类。type_id::create 拿到工厂产物后会做一次 $cast 到基类类型,若 err_txn 和 txn 毫无继承关系,赋值在运行期失败,UVM 报出工厂返回对象与请求类型不兼容的错误,句柄为 null,后续访问直接崩。工厂只认继承这一条兼容性,不检查方法签名。
第二,想改掉的方法必须声明为 virtual。注意第二节的例子里 drive 特意加了 virtual。如果替换类覆写了一个非虚方法,对象虽然确实是派生类型,基类句柄调用时仍绑定到基类版本——替换「成功」了,行为却没变,这是最迷惑人的一类坑。看下面这组对照:
class base_sb extends uvm_scoreboard;
task check(txn tr); // 非 virtual:调度在编译期绑定
endtask
endclass
class fast_sb extends base_sb;
`uvm_component_utils(fast_sb)
task check(txn tr); // 覆写,但基类句柄调不到这里
`uvm_info("SB", "fast check", UVM_LOW)
endtask
endclass
base_sb sb; // 句柄类型是基类
sb = base_sb::type_id::create("sb", this); // 即便工厂给的是 fast_sb
sb.check(tr); // 执行的仍是 base_sb.check排查这类问题的信号很明确:factory 打印显示覆盖已命中、对象类型也是派生类,但日志里的行为始终是基类版本。一旦看到这个组合,直接去查被覆写的方法有没有 virtual。实践中凡是预留 override 的任务/函数都应声明 virtual,scoreboard 的 compare、monitor 的 collect、driver 的 drive 是三个最容易漏的地方。
第三,构造函数签名必须守约定。组件是 function new(string name, uvm_component parent),对象是 function new(string name = "..."),因为工厂只按这两个固定入口构造。派生类乱加无默认值的参数,工厂无法代填;需要的配置应走 UVM config_db 用法,而不是加构造参数。
第四,两个类都要注册。不仅原类型要有 uvm_*_utils,替换类型同样要注册,否则 get_type() 拿不到代理,工厂无据可查。少注册的报错通常不在设置覆盖的那一刻,而是迟至 create 时才冒出「类型未登记」的提示,所以看到这类报错先回头数两个类的宏,而不是怀疑覆盖语句本身。参数化类还要注意特化版本的注册与覆盖要在同一组特化参数上进行,driver #(txn) 和 driver #(wide_txn) 在工厂表里是互不相干的两行,覆盖一边影响不到另一边。
五、覆盖时机与 factory debug 打印
override 必须发生在对象被 create 之前。UVM 的 build_phase 自顶向下执行:test 的 build_phase 最先跑,然后才是 env、agent。所以测试里的标准写法是先覆盖、后 super.build_phase(phase)——因为父类的 build_phase 正是创建 env 的地方,顺序反了,env 已经按原类型建好,覆盖形同虚设。对于 run_phase 才不断产生的 sequence item,由于其 create 发生在 build 之后,build_phase 里设置的类型覆盖自然赶得上,这正是事务注入不需要操心时机的原因。
覆盖链的解析也值得知道。若测试里先设 txn -> err_txn、又设 err_txn -> fatal_txn,工厂对 txn 的请求会一路追到 fatal_txn,中间类型不需要被单独 create。链条靠类型代理串联,顺序以最后设置的覆盖关系为准;同一对类型反复覆盖时,新关系顶掉旧关系,而不是叠加。这给基类回归留了一个干净的套路:base_test 可以放一条默认覆盖,具体测试再追加或改写,每层只声明自己的差异。
覆盖不生效时,不要靠猜,工厂自带三组打印:
uvm_factory f = uvm_factory::get();
f.print(1); // 打印所有已注册类型,1 表示只列有覆盖的
my_driver::type_id::print_override_info("env.agent[0].drv");
// 直接回答:这条路径最终会实例化谁
f.debug_create_by_type(my_driver::get_type(), "env", "agent[0].drv");
// UVM 1.2 起的等价调试接口,打印完整查找过程print(1) 适合总览:确认替换类已注册、覆盖关系确实挂上;print_override_info 适合定点排查:把你以为的路径原样传进去,看工厂解析出的类型和你预期是否一致——大多数「不生效」问题到这一步就会现形,通常是路径里少写了一层、相对的 context 搞错,或者覆盖加在了 super.build_phase 之后。
六、factory override、callback 与直接改类怎么选
UVM 给行为裁剪留了不止一条路,选型时先想清楚你要改的是「整个类的类型」还是「类里某几个钩子的行为」。
| 维度 | factory override | callback | 直接改类 |
|---|---|---|---|
| 改动粒度 | 整体替换类型 | 在预留钩子点挂载回调函数 | 手改平台源码 |
| 是否需要派生新类 | 需要,且要注册 | 不需要,写一个 callback 对象即可 | 不需要 |
| 作用域控制 | 全局或按实例路径 | 按组件实例 add,路径天然精确 | 影响所有使用者 |
| 平台侵入性 | 零侵入,但要求 create 走工厂 | 平台需预先声明钩子并 do_* 调用 | 高,破坏主干稳定 |
| 适用阶段 | 换整套 driver/monitor/scoreboard、注入事务 | 小范围、可叠加的行为增减 | 仅调试或永久需求 |
| 组合性 | 一条路径最终一个类型 | 可挂多个回调,按序执行 | 差 |
经验法则:要换掉的是「一整个角色」——比如整个计分板换成延迟模型、所有事务换成错误事务——用 factory override;要动的只是原有流程里的几个点,且希望多个测试各加各的、互不冲突,用 callback,机制细节见 UVM callback 回调注入;直接改平台类只适用于你确认这是永久行为变更的场合,绝不要为单个测试开这个口子。
拿一个典型回归列表感受三者边界。错误注入测试派生 err_txn 做类型覆盖,异常驱动测试派生 stall_driver 只对 env.agent[1].drv 做实例覆盖,而覆盖率收集测试只是想让 monitor 在正常协议解析之外多发一个空操作——这种「加法」性质的差异,派生整个 monitor 替换代价过大,在预留的钩子上挂一个 callback 最划算。三个测试跑在同一份 env 源码上,互不共享改动、也不需要任何条件编译开关。如果某天团队确认空操作不再是测试专属、而是协议的永久组成,那时才把它合入 monitor 主干、撤掉回调。先临时、后永久,先派生、后合并,这个顺序保证了主干在大部分时间里都是稳定的。
这一篇在知识体系里的位置
本篇属于芯片设计进阶层的 UVM 机制深挖,回答「平台对象如何做到不改主干、按测试替换类型」。向前读,组件树与 build_phase 的自顶向下顺序是理解覆盖时机的前提,见 UVM 组件与 phase;driver、sequencer、scoreboard 的协作骨架见 UVM sequence/driver/scoreboard。横向配套,替换后需要差异化的参数走 UVM config_db 用法,钩子式行为注入走 UVM callback 回调注入,sequence 侧的调度与复用见 UVM virtual sequence。向后读,这些机制在完整环境里的组装可参考 AMBA AXI 的 UVM 环境 与低功耗验证场景。
总结
一句话总结:UVM factory override 是一张运行期类型查表——类先用 uvm_component_utils/uvm_object_utils 把类型代理登记进全局工厂、再一律用 type_id::create(name, parent) 创建,create 时工厂按「先实例覆盖、后类型覆盖」的顺序查表,set_type_override 全局替换所有请求点、set_inst_override 只替换层次路径匹配的单个实例(支持通配、定点优先);替换类必须是请求类型的注册过的派生类、靠 virtual 方法才能真正表现差异,并守住组件 new(name, parent)、对象 new(name) 的构造约定;覆盖务必赶在 create 之前,测试里先覆盖再 super.build_phase,不生效就用 factory.print 和 print_override_info 让工厂自己说出解析结果——整体换角色用 override、局部挂钩子用 callback,平台主干一次也不用动。