SV 对象拷贝:new 浅拷贝、copy/do_copy 深拷贝与常见坑
更新时间:2026-09-28。前一篇 SystemVerilog OOP:类、继承与多态 讲了 class 的构造与多态,这一篇只解决一个具体而高频的问题:把一个 transaction 对象「复制一份」时,到底拷了什么、漏了什么。句柄的语义与对象回收规则在 SystemVerilog 内存管理与垃圾回收 里有完整论述,建议对照看。
本文要回答的问题
t2 = new t1;看起来是「复制一个新对象」,为什么改了t2的子对象,t1也跟着变?- 浅拷贝和深拷贝的分界线画在哪一层?嵌套对象、动态数组、队列分别怎么处理?
- UVM 的
copy()/do_copy()钩子怎么分工,什么时候必须自己重写?
一、先弄清:变量里存的是句柄,不是对象
SystemVerilog 的 class 变量从来不是对象本体,而是指向堆上对象的句柄(handle)。所以下面这行代码完全不产生任何拷贝:
axi_txn t1, t2;
t1 = new();
t2 = t1; // 只是两个句柄指向同一个对象
t2.addr = 'h100;
$display(t1.addr); // 也是 'h100 —— 根本只有一个对象t2 = t1 之后,t1 和 t2 是同一对象的两个名字。这不是 bug,是 SV 句柄语义的本意——mailbox 传 transaction、scoreboard 收 reference,靠的正是这种「传句柄不传对象」的低开销机制。真正的坑出现在你以为「我要一份独立副本」、却用了传句柄的写法。
二、new 拷贝构造:浅拷贝的真实含义
想复制对象,第一反应是拷贝构造:
class axi_txn;
rand bit [31:0] addr;
rand bit [31:0] data;
axi_txn next; // 嵌套的子对象句柄
int q[$]; // 队列
function new(axi_txn t = null); endfunction
endclass
axi_txn t2 = new t1; // 浅拷贝new t1 会分配一个新对象,并把 t1 的所有成员逐字段赋值过去。听起来很完整,但「赋值」对不同类型的成员含义完全不同:
- 普通数据(
bit、int、枚举、定宽数组、结构体):值被复制,两对象互不影响。 - 句柄成员(
next):复制的是句柄值——t2.next与t1.next指向同一个子对象。 - 动态数组/队列:规则因仿真器与场景而异,但即使容器本身被复制,容器里装的句柄元素仍指向原对象。
也就是说,new t1 只复制了「第一层」。改 t2.addr 没事,改 t2.next.addr 就把 t1 的子对象也改了——两个对象在深层面仍纠缠在一起,这就是浅拷贝。
读图:左半边浅拷贝后,两个父对象的 next 句柄都落到同一个子对象上——共享点是事故源;右半边深拷贝里子对象也被重新 new,两棵对象树从此井水不犯河水。
三、深拷贝:自定义 copy() 逐层 new
SV 没有内建深拷贝,惯例是自己写一个 copy 方法,对句柄成员递归复制:
class axi_txn;
rand bit [31:0] addr;
rand bit [31:0] data;
axi_txn next;
int q[$];
function void copy(axi_txn t);
this.addr = t.addr;
this.data = t.data;
// 关键:句柄成员要 new 出新的子对象,再递归拷
if (t.next != null) begin
this.next = new();
this.next.copy(t.next);
end
this.q = t.q; // int 队列是值语义,整体复制即可
endfunction
function axi_txn clone(); // 方便调用方一行拿到副本
clone = new();
clone.copy(this);
endfunction
endclass要点三条:
- 判空再递归。
t.next为 null 时不能.copy,否则空指针解引用。 - 值类型容器整体赋值。
int q[$]这种元素是值类型的队列/动态数组,直接this.q = t.q就是真复制;只有元素是句柄的容器(axi_txn q[$])才需要循环逐元素clone。 - clone/copy 分离。
copy负责填字段,clone负责分配新对象——调用方t2 = t1.clone()语义一目了然。
四、UVM 的 do_copy:为什么不让直接重写 copy
UVM 把这条惯例制度化:uvm_object 提供 final 的 copy(),内部调用可重写的 do_copy() 钩子。你不重写 copy,只重写 do_copy:
class axi_txn extends uvm_sequence_item;
rand bit [31:0] addr;
axi_txn next;
`uvm_object_utils_begin(axi_txn)
`uvm_field_int(addr, UVM_ALL_ON)
`uvm_field_object(next, UVM_ALL_ON) // 宏自动处理递归深拷贝
`uvm_object_utils_end
// 不用宏时的手工版:
function void do_copy(uvm_object rhs);
axi_txn t;
super.do_copy(rhs);
if (!$cast(t, rhs)) `uvm_fatal("CAST", "type mismatch")
addr = t.addr;
if (t.next != null) begin
next = axi_txn::type_id::create("next");
next.copy(t.next);
end
endfunction
endclass分工逻辑是:copy() 里 UVM 框架先做类型检查等通用工作,再调你的 do_copy() 填字段;字段自动化宏(uvm_field_*)展开后就是替你生成 do_copy 的字段赋值代码。用了宏且成员都登记过,do_copy 常常一行不用写——uvm_field_object 宏会自动对子对象做深拷贝。
| 场景 | 推荐做法 |
|---|---|
| 纯数据字段 | 宏登记即可,零手写 |
| 有句柄成员 | uvm_field_object 宏,或手工 do_copy 递归 |
句柄元素容器(txn q[$]) | 宏管不到,必须手工循环 clone |
| 不想深拷贝某成员(刻意共享) | 宏加 UVM_NOCOPY,do_copy 里跳过 |
五、典型事故:句柄共享引发的幽灵修改
深浅拷贝搞混的 bug 有一个共同特征:改 A 对象,B 对象的数据莫名其妙跟着变。高发场景:
- monitor 广播句柄:monitor 把采样到的 transaction 直接
analysis_port.write(tr)发给多个订阅者,下游有人就地改了tr,其他订阅者看到的就是被改过的数据。规范做法是每发一份clone一份,或约定订阅者只读。 - scoreboard 缓存期望值:把输入 txn 句柄存进队列,等 DUT 输出到了再比对——但驱动侧复用了同一个 transaction 对象做下一次激励,队列里「存下的期望值」已被改写。入队前必须
clone。 - sequence 里
start_item(req); req = old_req;:看似换了对象,实际可能仍在共享子结构。
排查口诀:发现「对象数据自己变了」,先问一句——这个句柄还有谁拿着?用仿真器的句柄追踪或在该对象里加唯一 id 打印,通常很快定位共享点。
这一篇在知识体系里的位置
- 上游:SystemVerilog OOP:类、继承与多态 是 class 语法的总入口。
- 句柄与回收:SystemVerilog 内存管理与垃圾回收 解释句柄计数与对象何时消亡——理解「为什么拷贝必须显式 new」的底层。
- 应用侧:transaction 在组件间流动靠 TLM 端口,见 SystemVerilog TLM:port/export/imp 与组件通信;
do_copy常与do_compare、convert2string成套出现在 sequence item 里,配置与克隆的配合见 UVM config_db / resource_db:配置与资源的存取。
总结
SV 对象拷贝的关键是分清两层:class 变量只是句柄,new t1 的浅拷贝只复制第一层字段、句柄成员仍共享子对象;要真正独立副本必须自定义 copy/clone 逐层 new 递归,在 UVM 里则通过重写 do_copy(或字段宏)实现。守住「共享靠传句柄、隔离必须 clone、句柄容器手工循环」三条线,「改 A 坏 B」的幽灵 bug 就不会再出现。