SystemVerilog中浅拷贝与深拷贝的全面解析与UVM实践指南
1. 从一次“诡异”的仿真结果说起最近在带一个新人做验证环境搭建他负责写一个简单的记分板scoreboard。环境跑起来后数据比对总是间歇性出错有时对有时错毫无规律。他排查了一整天从接口时序到数据生成都没发现问题。最后他把怀疑的目光投向了记分板内部用于缓存期望数据的动态数组。他信誓旦旦地跟我说“师父我每次收到事务transaction后都把它push_back到数组里存起来了比对的时候绝对没错”我让他把代码发给我看核心部分大概是这样的class Scoreboard; MyTransaction exp_q[$]; // 期望队列 task run(); forever begin MyTransaction tr; // 从monitor接收事务 monitor2scb.get(tr); // 将事务存入期望队列 exp_q.push_back(tr); // ... 后续比对逻辑 end endtask endclass问题就出在exp_q.push_back(tr);这一行。在SystemVerilogSV中对于类对象class的赋值和传递默认是引用reference而不是拷贝copy。这意味着push_back(tr)仅仅是把指向tr这个对象实例的“指针”或者说句柄存进了队列。后续如果tr这个变量被修改比如下一个时钟周期monitor又收到了新数据并写入了同一个tr对象那么队列里存的那个“指针”所指向的内容也跟着变了。这就好比你在笔记本上记下了一个储物柜的号码引用而不是把储物柜里的东西对象内容拿出来自己保管。别人用钥匙打开储物柜把东西换了你记的号码没变但东西已经不是你当初看到的了。这个问题的本质就是没有理解SV中对象的浅拷贝Shallow Copy和深拷贝Deep Copy。这是SV面向对象编程中一个非常核心且容易踩坑的概念直接关系到数据的正确性、独立性和内存管理。无论是构建UVM验证环境还是编写复杂的测试用例这个概念都无处不在。今天我们就来彻底拆解它让你不仅知道区别更能理解背后的原理并在实际项目中游刃有余地应用。2. 对象、句柄与内存理解拷贝的底层基础在深入浅/深拷贝之前我们必须夯实基础SV中类对象是如何存在和被操作的。这不同于int、bit这些内置数据类型它们直接存储在变量位置也不同于结构体struct在SV中除非声明为packed否则struct也是引用语义。2.1 类对象活在堆上的“居民”当你声明一个类变量时例如MyTransaction tr;你实际上只是创建了一个句柄handle或者通俗地理解一个可以为null的“遥控器”或“指针”。此时tr是null它不指向任何实际的对象。MyTransaction tr; // tr是一个句柄初始值为null真正的对象实例需要通过new()函数在堆Heap内存中创建。new()操作符会向仿真器申请一块内存按照类的蓝图构造出对象并返回这个对象在内存中的地址即句柄值。tr new(); // 在堆上创建对象并将对象的地址赋给句柄tr现在tr这个句柄就“指向”了堆上的那个对象实体。你可以通过tr.data、tr.addr来访问和修改这个对象的成员。2.2 赋值操作复制的是“遥控器”还是“电视机”这是关键所在。当我们进行句柄赋值时发生了什么MyTransaction tr1, tr2; tr1 new(); tr1.data 8hAA; tr2 tr1; // 注意这里发生了什么tr2 tr1;这条语句复制的是句柄地址值而不是堆上的对象本身。执行后tr1和tr2这两个句柄存储了相同的内存地址它们指向同一个对象实例。这就好比你有两个遥控器tr1,tr2都配对到了同一台电视机堆上的对象。无论你用哪个遥控器换台控制的都是同一台电视机。tr2.data 8h55; // 通过tr2修改对象 $display(“tr1.data %h”, tr1.data); // 输出tr1.data 55你会发现通过tr2修改了data通过tr1看到的data也变了。因为它们指向同一个地方。这就是引用语义。文章开头新人的错误正是把这种引用直接push_back到了队列里导致队列里所有的条目都指向monitor那个不断被重用的tr对象数据自然全乱套了。注意内置数据类型如int,bit,logic和打包结构体packed struct是值语义。赋值时是真正的值拷贝会创建独立的副本。int a 10; int b a; // b是a值的一个独立副本 b 20; // 修改ba不变 $display(a); // 输出10理解了引用语义我们就可以正式探讨如何打破这种“共享”创建对象的独立副本也就是拷贝。3. 浅拷贝Shallow Copy创建新壳共享内核浅拷贝是SV中new操作符的一个内置功能。语法是destination_handle new source_handle;或者更常见的是在自定义new函数构造函数中调用function new (MyTransaction src); super.new(); // 如果有父类先调用父类构造函数 this.copy(src); // 调用自定义的copy函数内部通常用浅拷贝或深拷贝逻辑 endfunction但直接使用new src就是浅拷贝。它的行为可以概括为为新句柄分配新的内存创建目标对象的新实例然后递归地将其所有属性properties的值从源对象复制到目标对象。这里“递归地复制所有属性的值”需要仔细理解对于内置数据类型int,bit,string等复制的是值本身。新对象拥有独立的int变量。对于类对象句柄复制的是句柄的值即内存地址。这意味着新对象和源对象中的对应句柄将指向同一个子对象。让我们用一个例子来可视化这个过程class Packet; int id; int data_q[$]; // 动态数组注意它是内置类型 endclass class Transaction; int addr; Packet pkt; // 类句柄成员 endclass // 创建源对象并初始化 Transaction src new(); src.addr 32h1000; src.pkt new(); src.pkt.id 1; src.pkt.data_q {10, 20, 30}; // 执行浅拷贝 Transaction dst new src;此时的内存模型如下图所示概念示意源对象 src (在堆上) 浅拷贝生成的目标对象 dst (在堆上) ------------------ ------------------ | addr: 0x1000 | | addr: 0x1000 | // 值拷贝独立 ------------------ ------------------ | pkt: -----------------------| pkt: ------- ------------------ ------------------ | 指向同一个Packet对象 | v ------------------ | Packet对象 | | id: 1 | | data_q: {10,20,30}| // 动态数组值独立 ------------------关键点分析dst是一个全新的Transaction对象拥有独立的内存空间。dst.addr直接从src.addr复制了值32‘h1000两者独立。dst.pkt句柄被复制了。它和src.pkt指向同一个Packet对象。这就是“浅”的含义——只拷贝了一层对于嵌套的对象只拷贝了引用。一个重要的特例Packet里的data_q。data_q是一个动态数组int队列。在SV中动态数组是内置类型虽然它在堆上管理存储空间但赋值操作包括浅拷贝中的成员赋值默认是值拷贝。所以dst.pkt.data_q会是src.pkt.data_q的一个独立副本。修改其中一个队列不会影响另一个。这有时会造成迷惑让人误以为浅拷贝对动态数组也是“引用”其实不然。让我们验证一下// 修改dst的addr不影响src dst.addr 32h2000; $display(“src.addr %h, dst.addr %h”, src.addr, dst.addr); // src1000, dst2000 // 通过dst修改共享的Packet对象 dst.pkt.id 99; $display(“src.pkt.id %0d, dst.pkt.id %0d”, src.pkt.id, dst.pkt.id); // 都是99因为指向同一个对象 // 修改dst的data_q独立副本 dst.pkt.data_q.push_front(999); $display(“src.pkt.data_q %p”, src.pkt.data_q); // {10, 20, 30} $display(“dst.pkt.data_q %p”, dst.pkt.data_q); // {999, 10, 20, 30} // 独立浅拷贝的适用场景与陷阱适用当你的类结构简单没有嵌套的类对象成员或者你故意希望共享某些子对象时例如共享一个配置类对象。陷阱最常见的坑就是无意中共享了嵌套对象。比如一个Transaction里有一个DataPacket句柄浅拷贝后两个Transaction操作的是同一个DataPacket极易导致数据污染。这也是UVM中uvm_object的copy()方法默认是浅拷贝但强烈建议重写为深拷贝的原因。4. 深拷贝Deep Copy彻底的“克隆”深拷贝的目标是创建一个完全独立的对象副本包括它所有嵌套的子对象以及子对象嵌套的子对象一直递归到最底层的基本数据类型。在SV中没有内置的深拷贝操作符。你必须自己实现。实现深拷贝通常有两种方式自定义copy()或clone()方法这是最标准、最推荐的做法尤其是在UVM框架中。重载new构造函数在构造函数中接收源对象并完成深度复制。我们重点看第一种。通常我们会定义一个copy(MyClass src)函数或者像UVM那样定义一个do_copy(uvm_object rhs)虚函数。4.1 实现一个深拷贝函数接上面的Transaction和Packet例子我们来实现深拷贝。class Packet; int id; int data_q[$]; // Packet类的深拷贝函数 function Packet copy(); Packet p new(); p.id this.id; // 拷贝基本类型 p.data_q this.data_q; // 动态数组赋值是值拷贝自动创建独立副本 return p; endfunction endclass class Transaction; int addr; Packet pkt; // Transaction类的深拷贝函数 function Transaction copy(); Transaction t new(); t.addr this.addr; // 拷贝基本类型 if (this.pkt ! null) begin t.pkt this.pkt.copy(); // 关键调用子对象的copy()创建新的Packet对象 end else begin t.pkt null; end return t; endfunction endclass现在当我们调用dst src.copy();时内存模型如下源对象 src 深拷贝生成的目标对象 dst ------------------ ------------------ | addr: 0x1000 | | addr: 0x1000 | ------------------ ------------------ | pkt: --------- | pkt: --------- ------------------ ------------------ | | | | v v | ------------------ ------------------ | | Packet对象 A | | Packet对象 B | | // 全新的对象 | id: 1 | | id: 1 | | | data_q: {10,20,30}| | data_q: {10,20,30}| | // 动态数组也是独立副本 ------------------ ------------------ |关键点分析dst是一个全新的Transaction对象。dst.addr复制了值。dst.pkt指向了一个全新的Packet对象对象B它是通过调用src.pkt.copy()创建的。这个新的Packet对象B拥有独立的内存其id和data_q都是对象A对应值的副本。此时修改dst.pkt.id只会影响对象B完全不影响源对象src及其子对象A。Transaction src new(); src.addr 32‘h1000; src.pkt new(); src.pkt.id 1; Transaction dst; dst src.copy(); // 深拷贝 dst.addr 32’h2000; dst.pkt.id 99; $display(“src.addr%h, src.pkt.id%0d”, src.addr, src.pkt.id); // 1000, 1 $display(“dst.addr%h, dst.pkt.id%0d”, dst.addr, dst.pkt.id); // 2000, 99完美隔离这才是我们通常想要的“复制”效果。4.2 处理更复杂的嵌套和循环引用现实中的类可能更复杂包含关联数组、对象数组、甚至循环引用A中有B的句柄B中又有A的句柄。实现一个健壮的深拷贝需要仔细处理这些情况。对象数组/队列需要遍历数组对每个元素调用其copy()方法。class ComplexObj; Packet pkt_list[$]; function ComplexObj copy(); ComplexObj c new(); foreach (this.pkt_list[i]) begin c.pkt_list.push_back(this.pkt_list[i].copy()); end // ... 拷贝其他成员 return c; endfunction endclass关联数组同样需要遍历并复制值。如果值是对象则需要深拷贝。int obj_map [string]; // 对于int等基本类型直接赋值即可值拷贝 copy_obj_map this.obj_map; // 如果值是对象句柄 MyClass obj_map [string]; foreach (this.obj_map[key]) begin copy_obj_map[key] this.obj_map[key].copy(); end循环引用这是深拷贝的难点。简单的递归copy()会导致无限循环。通常的解决方案是使用“映射表”例如uvm_coreservice_t中的全局表或自定义的static关联数组记录已经拷贝过的原始对象到新对象的映射。当再次遇到同一个原始对象时直接返回映射表中对应的新对象句柄而不是再次拷贝。UVM的copy()方法在底层通过uvm_coreservice_t在一定程度上处理了这个问题但对于特别复杂的自定义结构仍需注意。5. UVM中的拷贝实践copy()、clone()与do_copy()在UVM验证方法学中所有继承自uvm_object的类如uvm_sequence_item,uvm_transaction都自带拷贝机制。理解并正确使用它是构建可重用验证环境的关键。5.1copy()与clone()的区别copy(dst_rhs)这是一个虚函数用于将当前对象this的内容复制到一个已存在的目标对象中。目标对象必须已经通过new创建。MyTransaction src new(“src”); MyTransaction dst new(“dst”); src.copy(dst); // 将src的内容拷贝到已存在的dst中它的内部流程是检查类型兼容性 - 调用do_copy(rhs)- 调用子类重载的do_copy执行实际拷贝。clone()这是一个函数它会创建一个新的对象并将当前对象的内容拷贝到这个新对象中最后返回这个新对象的句柄。你不需要预先创建目标对象。MyTransaction src new(“src”); MyTransaction dst; dst src.clone(); // dst被赋值了一个全新的、内容与src相同的对象 $cast(dst, src.clone()); // 更安全的做法因为clone()返回uvm_object句柄clone()的内部实现大致是obj create(...); obj.copy(this); return obj;。它结合了create和copy。实操心得在UVM验证中clone()用得更频繁特别是在monitor将事务传递给scoreboard或reference model时。为了确保数据的独立性必须克隆事务而不是传递引用。文章开头的那个bug正确的修复方法就是monitor2scb.get(tr); exp_q.push_back(tr.clone()); // 关键克隆一个副本存起来5.2 如何重写do_copy()实现深拷贝UVM将拷贝的实际工作交给了do_copy(uvm_object rhs)虚函数。当你需要自定义拷贝行为尤其是实现深拷贝时必须重写这个函数。步骤首先调用super.do_copy(rhs);以确保父类的属性被正确拷贝。将参数rhs通过$cast转换为你的具体类型。逐一拷贝你的类中定义的成员变量。对于基本类型和动态数组直接赋值。对于嵌套的uvm_object调用其copy()或clone()方法。class MyTransaction extends uvm_sequence_item; uvm_object_utils(MyTransaction) int addr; int data; Packet pkt; // 自定义子对象 int dyn_arr[$]; function new(string name “MyTransaction”); super.new(name); endfunction // 重写do_copy实现深拷贝 virtual function void do_copy(uvm_object rhs); MyTransaction rhs_; super.do_copy(rhs); // 拷贝父类属性 if (!$cast(rhs_, rhs)) begin uvm_error(“DO_COPY”, “Cast failed”) return; end // 拷贝基本类型 this.addr rhs_.addr; this.data rhs_.data; // 拷贝动态数组值拷贝 this.dyn_arr rhs_.dyn_arr; // 深拷贝嵌套对象 if (rhs_.pkt ! null) begin // 方法1: 如果目标pkt已存在用copy if (this.pkt null) this.pkt new(); rhs_.pkt.copy(this.pkt); // 方法2: 更简洁直接用clone创建新对象 // this.pkt rhs_.pkt.clone(); end else begin this.pkt null; end endfunction endclass注意事项在do_copy中this是目标对象rhs_是源对象。方向是rhs_ - this。务必不要搞反。UVM的copy(dst)函数内部调用dst.do_copy(this)所以do_copy中的rhs参数实际上是源对象。5.3 uvm_field_* 宏与自动拷贝UVM提供了uvm_field_*系列宏如uvm_field_int,uvm_field_object在uvm_object_utils_begin/end块中注册成员这些宏会自动实现copy、compare、print等操作。uvm_object_utils_begin(MyTransaction) uvm_field_int(addr, UVM_ALL_ON) uvm_field_int(data, UVM_ALL_ON) uvm_field_object(pkt, UVM_ALL_ON) // 对于对象默认是浅拷贝 uvm_field_queue_int(dyn_arr, UVM_ALL_ON) uvm_object_utils_end重要警告uvm_field_object宏默认实现的是浅拷贝这意味着如果你用宏注册了对象成员调用自动生成的copy()时嵌套的对象不会被深拷贝。这常常是另一个隐蔽的Bug来源。最佳实践建议对于需要深拷贝的嵌套对象不要使用uvm_field_object宏注册它。手动重写do_copy()函数来实现深拷贝逻辑如上节所示。或者如果你坚持使用宏并且希望深拷贝需要设置宏的标记flag。uvm_field_object有一个UVM_DEEP标志但它的行为可能不符合所有深拷贝场景特别是循环引用且文档不清晰不推荐依赖。最稳妥、最可控的方式就是手动实现do_copy。6. 实战场景与避坑指南理解了原理我们来看看在哪些具体场景下必须谨慎处理拷贝问题以及如何避开常见的坑。6.1 场景一Monitor到Scoreboard的数据传递这是最经典的场景也是开篇案例的所在。Monitor捕获到总线事务需要送给Scoreboard做比对。错误做法传递引用// 在Scoreboard的run_phase中 forever begin MyTransaction tr; mon_aport.get(tr); // 从analysis port获取 exp_fifo.push(tr); // 直接存入FIFO end问题tr是monitor内部的一个对象每次get拿到的是它的引用。monitor在下一个周期会重用这个对象填充新数据导致exp_fifo里存的所有“旧事务”实际上都变成了最新的数据。正确做法深拷贝/克隆forever begin MyTransaction tr, tr_clone; mon_aport.get(tr); $cast(tr_clone, tr.clone()); // 克隆一个独立副本 exp_fifo.push(tr_clone); // 存入副本 end或者如果事务类正确实现了do_copy也可以forever begin MyTransaction tr; MyTransaction tr_copy new(); mon_aport.get(tr); tr.copy(tr_copy); // 拷贝到新创建的对象中 exp_fifo.push(tr_copy); end6.2 场景二Sequence Item的随机化与发送在Sequence中生成并随机化事务然后发送给Driver。task body(); MyTransaction tr new(“tr”); repeat(10) begin assert(tr.randomize()); // 随机化 uvm_send(tr) // 发送给driver end endtask这里有问题吗uvm_send或start_item/finish_item机制内部UVM Sequencer会负责在将事务交给Driver之前调用clone()方法创建副本。所以即使你在循环中重用同一个tr对象Driver收到的是克隆体因此是安全的。但是这依赖于UVM框架的行为。如果你在自己的通信机制中比如直接通过mailbox传递就必须自己处理克隆。6.3 场景三配置对象uvm_object的共享与隔离配置对象如env_cfg通常在整个环境中共享。但有时某个组件如某个agent需要基于全局配置进行一些本地修改而不希望影响其他组件。错误做法直接赋值class MyAgent extends uvm_agent; MyConfig global_cfg; MyConfig local_cfg; function void build_phase(uvm_phase phase); super.build_phase(phase); // 获取全局配置 if (!uvm_config_db#(MyConfig)::get(this, “”, “cfg”, global_cfg)) ... // 想基于全局配置做一些本地调整 local_cfg global_cfg; // 危险这只是句柄拷贝 local_cfg.some_setting SPECIAL_VALUE; // 这会修改全局配置 endfunction endclass正确做法深拷贝配置function void build_phase(uvm_phase phase); super.build_phase(phase); if (!uvm_config_db#(MyConfig)::get(this, “”, “cfg”, global_cfg)) ... // 创建本地配置副本 local_cfg MyConfig::type_id::create(“local_cfg”); global_cfg.copy(local_cfg); // 将全局配置拷贝到本地副本 // 现在可以安全修改本地副本了 local_cfg.some_setting SPECIAL_VALUE; endfunction6.4 常见陷阱总结误以为new操作是深拷贝new src是浅拷贝这是很多错误的根源。忘记拷贝动态数组/队列中的对象如果你的动态数组里存的是对象句柄arr src_arr只会复制句柄数组数组内的对象仍然是共享的。需要遍历数组进行深拷贝。在do_copy中忘记调用super.do_copy(rhs)导致父类的属性没有被拷贝。uvm_field_object宏的浅拷贝陷阱如前所述这是UVM新手常踩的大坑。循环引用导致栈溢出在实现自定义深拷贝时如果对象图中有环A引用BB引用A递归拷贝会无限循环。必须引入“已拷贝对象映射表”来检测环。拷贝性能深拷贝涉及递归创建对象和复制数据对于大型、复杂的对象树比如一个包含大量数据包的链表频繁深拷贝可能有性能开销。在设计数据结构时需权衡。有时使用“不可变对象”immutable object模式一旦创建就不修改只传递引用是更好的选择。7. 数据类型转换与拷贝的微妙关系搜索热词中提到了“sv中的数据类型转换”这与拷贝也有关联。$cast是SV中用于类型安全转换的操作它经常与拷贝/克隆配合使用。当clone()一个父类句柄时返回的是uvm_object基类句柄。为了将其赋值给子类句柄必须使用$cast进行动态类型检查。uvm_object obj_h; MyTransaction src new(), dst; src.addr 100; obj_h src.clone(); // clone()返回uvm_object句柄 // dst obj_h; // 直接赋值编译会报错类型不兼容 if (!$cast(dst, obj_h)) begin // 必须使用$cast uvm_error(“CAST”, “Type cast failed”) end$cast在这里执行了两个动作1) 检查obj_h指向的对象是否与MyTransaction类型兼容即是否是MyTransaction或其子类2) 如果兼容将句柄赋值给dst。$cast本身不执行任何拷贝操作它只是安全地转换了句柄的类型。另一个相关的概念是“打包packing”与“解包unpacking”即将对象序列化为比特流pack或从比特流恢复为对象unpack。这个过程本质上也是一种深“拷贝”通过中间格式常用于将事务通过物理接口发送出去或者存储到文件。UVM的uvm_field_*宏也支持pack/unpack操作。8. 仿真调度与对象操作的时序“sv仿真调度”虽然不直接等同于拷贝但理解调度区域对理解对象在何时被修改、何时被拷贝至关重要。SV的仿真区域Preponed, Active, Inactive, NBA, Observed, Reactive, Postponed决定了赋值语句的效果何时可见。考虑一个多线程操作同一对象的场景class Shared; int val; endclass Shared s new(); initial begin s.val 10; end initial begin #1; Shared s_copy new s; // 浅拷贝 s.val 20; #0; // 进入NBA区域 $display(s_copy.val); // 输出多少 end由于#1延迟第一个initial块在new s之前就已经执行完毕s.val10。浅拷贝s_copy获得了s在时刻1的状态。然后s.val被改为20。这个修改发生在Active区域而$display在下一个仿真时间片time slot的Active区域因为#0会重新调度到当前时间片的末尾。所以s_copy.val显示的是拷贝时的值10。但如果拷贝和修改发生在同一个仿真区域且没有时序控制结果就可能不同。核心要点在多线程环境中如果你希望某个时刻的对象状态快照并且后续修改不影响这个快照那么在那个时刻进行深拷贝是必要的。同时要注意拷贝操作本身发生的仿真区域它决定了你捕获到的是哪个“瞬间”的状态。理解浅拷贝与深拷贝是掌握SystemVerilog面向对象编程和构建健壮UVM验证环境的基石。它关乎数据的生命线——独立性与正确性。下次当你需要在组件间传递事务、复制配置或者保存状态时先停下来想一想我需要的是另一个“遥控器”还是另一台独立的“电视机”想清楚这个问题就能避开大多数因拷贝不当引发的诡异Bug。在实践中对于需要独立性的数据默认使用深拷贝通过clone()或正确实现的copy()是一个更安全的选择虽然会牺牲一点性能但换来的是代码的清晰和数据的可靠。