ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SystemVerilog对象复制:句柄、浅拷贝、深拷贝与clone实战指南

SystemVerilog对象复制:句柄、浅拷贝、深拷贝与clone实战指南 1. 从一次诡异的数据错乱说起monitor 复用事务对象的代价有一次我在调一个 DMA 控制器的验证环境scoreboard 里收了一整轮总线事务等参考模型算完输出回来比对发现队列里所有事务的地址字段全都变成了同一个值。第一反应是 scoreboard 的入队逻辑写错了或者比较代码有 bug。排查了半天最后定位到根因根本不在 scoreboard而在 monitor——为了减少动态分配monitor 复用了同一个 transaction 对象scoreboard 的队列里保存的只是这个对象的句柄。这不是什么冷门边角问题。SystemVerilog 里 class 对象的赋值和拷贝语义几乎是每个验证工程师都会踩一遍的坑。句柄复制、浅拷贝、深拷贝、clone 函数这四个概念如果不彻底搞清楚在搭 UVM 环境的时候迟早会以各种方式给你制造麻烦。本文就结合我在实际项目中踩过的坑把这几个概念和背后的原理一次讲透并且给出一套可以直接抄的深拷贝和 clone 写法。先解释一下问题为什么会出现。transaction 对象的本质是 new 出来的堆空间句柄只是指向这块空间的引用。把句柄 push 到队列里队列里存的是引用不是数据快照。monitor 下一次复用同一块对象空间时之前所有入队的“事务”都指向同一块正在被改写的内存读出来的自然全是最后一个值。从那次之后我对任何跨模块传递的 transaction 对象都多留了一个心眼你要传的到底是一份独立的数据副本还是仅仅想共享同一个对象想清楚这个问题就不会被句柄复制坑到。2. 句柄复制SystemVerilog 中 class 赋值不是复制对象而是复制路径2.1 句柄、对象与 new 的关系先看最基础的定义。SystemVerilog 中new()完成两件事在堆上分配一块对象内存然后执行构造函数里的初始化语句。句柄本身只是一块“门牌号”指向堆上那块对象内存。它跟 C 的指针非常像但又有自己的语法规则。class trans; int addr; int data; endclass trans a; a new(); // 堆上创建 trans 对象句柄 a 指向它这里a是句柄new()创建的对象本身没有名字只有通过句柄才能访问。这个概念说起来简单但很多问题恰恰出在下一步。2.2 一句b a;带来什么后果trans a, b; a new(); a.addr h1000; a.data habcd; b a; // 句柄复制b 和 a 指向同一个对象 b.addr h2000; $display(a.addr %0h, a.addr); // 输出 2000不是 1000 $display(a b ? %0d, a b); // 输出 1第二行b a;并没有创建新对象它只是把 a 的“门牌号”抄给了 b。此后通过 b 修改对象的任何字段a 看一眼也会发现变了因为两个句柄指的就是同一个对象。比较的是句柄是否相同而非内容是否相同。这和内建类型完全不同。int b a;是把数值复制一份改 b 不影响 a而 class 的赋值永远走句柄复制这条路。有一个细节值得注意SystemVerilog 里你不能用去判断两个对象的内容是否一致只能用 UVM 的compare()或者自己写比对逻辑。很多人刚上手时用if (a b)判断两个事务是否相等结果是永远只在比较“是不是同一个对象”。2.3 句柄复制什么时候反而是有用的说了这么多句柄复制的坑但它并不是一无是处。验证环境里有两类场景需要有意引用同一个对象第一类是配置对象。UVM 的uvm_config_db默认就是按句柄传递的整个环境共享同一个 config 对象任何组件改了配置其他组件立刻能看到这正是我们想要的。如果这时候搞一个深拷贝传下去反而会出现配置不同步的疑难杂症。第二类是带共用资源的模型。比如一个 scoreboard 的预期计算模型内部有个全局统计计数器多个事务对象共享同一个计数器对象希望所有事务产生的统计结果都累加到同一个地方这属于设计意愿上的共享。换句话说句柄复制本身不是错误错误在于没有分清楚什么时候需要共享、什么时候需要独立。后面讲的浅拷贝和深拷贝本质就是解决“独立”这个需求。3. 浅拷贝copy() 方法的默认行为和隐藏陷阱3.1 UVM 的 copy() 到底做了什么UVM 的uvm_object基类给所有派生类提供了一个虚方法copy()。它的语义是把源对象的内容复制到目标对象。但关键点在于这个复制是“逐字段的、非递归的”也就是说对普通内建类型字段int、bit、enum 等直接做值复制对对象类型字段只复制句柄不创建新对象。class eth_header; int dst_mac; int src_mac; endclass class eth_frame; int frame_len; eth_header header; endclass假设eth_frame的 copy 方法实现如下function void copy(eth_frame src); frame_len src.frame_len; header src.header; // 浅拷贝只复制句柄 endfunction这之后目标对象的header字段和源对象的header字段指向同一个eth_header对象。任何一方修改header.dst_mac另一方立即可见。3.2 嵌套对象是最容易踩的位置为什么说嵌套对象最容易踩因为顶层看代码时copy()方法里每一行都像是在复制数据只有当你追究到“这个字段本身是不是又一个 class”时才会发现问题。我见过不少同事写 transaction 类内部嵌套了一个 bus_attr 对象copy 方法写完以后自己单测过了觉得没问题。直到两个组件同时访问transaction.bus_attr里的字段才出现数据被莫名修改的现象。更隐蔽的是嵌套层数多了之后排查成本指数上升。比如 transaction A 内部有 header Bheader B 内部又有 addr_info C。如果每一层的 copy 都是浅的那 A.copy(B) 之后多个 A 对象之间的 C 对象全部共享。改一个全部变。所以判断一个拷贝是不是“深”不能只看最外层要沿着所有对象类型的字段递归往下看每一层都必须创建独立的新对象。3.3 动态数组和队列在浅拷贝中其实是值拷贝这里有个很多人不知道的 SystemVerilog 特性动态数组、队列、字符串这些类型的直接赋值语义上是深拷贝不是引用共享。byte pa[]; byte pb[]; pa new[4]; foreach (pa[i]) pa[i] i; pb pa; // 内容复制 pb[0] 100; $display(pa[0] %0d, pa[0]); // 输出 0不受 pb 影响同理int qa[$]; int qb[$]; qa {1, 2, 3}; qb qa; qb.pop_back(); $display(qa.size() %0d, qa.size()); // 输出 3不受 qb 影响这意味着如果你在 copy 方法里直接写this.payload src.payload;这一次赋值本身就是“深”的目标对象会得到一份独立的动态数组副本。但注意这只是指数组容器本身。如果动态数组里存的是 class 句柄比如trans arr[]那么dst.arr src.arr;只是把句柄数组复制了一份数组中每个元素指向的 class 对象仍然是共享的。这是嵌套对象问题在数组场景下的变种同样需要逐元素 new。4. 深拷贝把嵌套对象、动态数组、队列一次复制到位4.1 深拷贝的本质是什么深拷贝的目标只有一个源对象和目标对象完全独立修改任何一个都不影响另一个。要做到这一点必须递归地复制所有层级上的 class 对象并用值拷贝的方式复制动态数组、队列、关联数组等内容。从操作上讲深拷贝和浅拷贝的区别就是每遇到一个对象类型字段是否执行了new()并逐字段赋值。实践中最稳妥的做法是给每个嵌套的 class 都各自实现一个 copy 方法然后在外层类的 copy 方法里调用它。这样责任划分清晰每一层都只管自己这层的数据。4.2 一个可以直接照抄的深拷贝模板以一个完整的 transaction 为例class eth_header; int dst_mac; int src_mac; int ether_type; function void copy(input eth_header src); dst_mac src.dst_mac; src_mac src.src_mac; ether_type src.ether_type; endfunction endclass class eth_frame; int frame_len; eth_header header; byte payload[]; int q[$]; function new(); header new(); endfunction // 深拷贝 function void copy(input eth_frame src); frame_len src.frame_len; // 对象字段先 new再逐字段复制 header new(); header.dst_mac src.header.dst_mac; header.src_mac src.header.src_mac; header.ether_type src.header.ether_type; // 或者更简洁header.copy(src.header); // 动态数组简洁写法直接构造新数组并初始化 payload new[src.payload.size()](src.payload); // 队列直接赋值本身就是值拷贝 q src.q; endfunction endclass这个模板里的关键点对象字段header必须先new()再复制内部字段。顺序不能反否则就是句柄覆盖。动态数组payloadnew[size](src_array)是 SystemVerilog 支持的数组构造语法一步完成分配和拷贝比foreach循环更简洁也少写一行。队列q直接不需要额外处理。如果嵌套对象也有自己的 copy 方法更推荐写成header.copy(src.header);这样源对象的内部细节对当前类透明后续嵌套类结构变更时外层 copy 方法不需要跟着改。4.3 关联数组和动态数组里的 class 元素如何处理关联数组的直接赋值也是整体拷贝但如果关联数组的值是 class 类型你得到的是一个新的关联数组里头装的却还是同一个对象的句柄。class score_entry; int score; endclass class monitor_result; score_entry map[string]; function void copy(input monitor_result src); map.delete(); foreach (src.map[i]) begin score_entry e new(); e.score src.map[i].score; map[i] e; end endfunction endclass这里必须遍历源关联数组为每个键创建新的score_entry对象再逐个字段复制。同理如果动态数组的元素是 class也要用 foreach 或 for 循环做同样的处理。判断标准就一条只要元素是 class就要 new只要字段是 class就要 new。5. clone 函数从工厂创建到拷贝的一体化设计5.1 clone() 与 copy() 的分工UVM 里copy()和clone()是一对搭档。copy()负责把源对象的内容填进目标对象难点在“怎么复制”clone()负责凭空制造出一个完整的新对象难点在“制造出什么类型、怎么初始化”。clone()的语义是创建一个与当前对象同类型的新对象把当前对象的内容完整复制过去最后返回这个新对象。它一般等价于“create copy”两步virtual function uvm_object clone(); uvm_object tmp; tmp create(); // 通过工厂创建 tmp.copy(this); // 当前对象内容复制到新对象 return tmp; endfunctioncreate()是关键。它不是普通new()而是经过 UVM factory 注册机制创建的这意味着如果环境中配置了 factory overridecreate()返回的可能是被覆写后的子类对象。这就是 clone 和普通的“先 new 再 copy”最本质的区别它保持了多态性。5.2 使用 clone() 的必经之路$castclone()的返回值类型是uvm_object不是具体的事务类型。如果直接把它赋给具体类型句柄编译会报错。必须用$cast做一次类型向下转换eth_frame src, dst; src new(); src.frame_len 100; // 编译错误无法把 uvm_object 赋给 eth_frame // dst src.clone(); // 正确写法 if (!$cast(dst, src.clone())) begin uvm_fatal(CAST, clone 返回类型不是 eth_frame) end$cast失败时返回 0所以一定要判断返回值。虽然大多数情况下 clone 出来的就是目标类型但如果 factory override 把类型换成了别的子类返回值类型不匹配$cast会失败这时候不判断就会出错。5.3 factory 覆写时 clone 返回的到底是什么一个容易搞混的问题是在父类句柄上调用 clone()工厂里配置了子类的 overrideclone 出来的是什么类型答案是create()被 factory override 影响所以 clone 出来的是 override 后的类型对象它的字段内容又是从原来的对象复制过来的。举个例子class base_pkt extends uvm_object; int addr; uvm_object_utils(base_pkt) function new(string name base_pkt); super.new(name); endfunction endclass class ext_pkt extends base_pkt; int crc; uvm_object_utils(ext_pkt) function new(string name ext_pkt); super.new(name); endfunction endclass如果环境中配置了base_pkt到ext_pkt的 override那么base_pkt pkt, cloned_pkt; pkt base_pkt::type_id::create(pkt); // 即使 pkt 的静态类型是 base_pktclone 出来的对象也可能是 ext_pkt if (!$cast(cloned_pkt, pkt.clone())) begin uvm_fatal(CAST, cloned 类型不是 base_pkt) end此时$cast到base_pkt是可以成功的因为 ext_pkt 是 base_pkt 的子类。但如果把 clone 结果$cast到ext_pkt也能成功因为对象实际类型就是 ext_pkt。测试get_type_name()会返回 ext_pkt。这正是动态类型的威力。在验证环境里当所有组件都通过工厂拿对象时clone 保持了同一条 override 链上的一致性不会因为你从父类句柄调用就退化成父类对象。5.4 默认 clone 的坑字段自动化和 get_name()UVM 的uvm_object_utils宏会自动生成 create但 clone 是在uvm_object基类里定义的虚函数。默认实现确实调用了create()和copy()。问题就在这里——如果你的类没有注册字段自动化默认的 copy() 什么都不会复制。很多人写class my_pkt extends uvm_object; int addr; int data; uvm_object_utils(my_pkt) ... endclass然后直接调clone()发现返回的对象里addr和data全是 0。原因就是 defaultcopy()依赖uvm_field_*宏注册的自动化信息而这个类只用了uvm_object_utils没有用uvm_object_utils_begin以及uvm_field_int注册字段所以默认 copy 等于空操作。解决办法有两个按字段自动化方式注册字段uvm_object_utils_beginuvm_field_int等宏让默认 copy 生效。或者更推荐的做法自行覆写copy()和clone()不依赖默认实现。我个人的偏好是覆写。字段自动化宏会增加编译开销而且对于内部有动态数组、队列、关联数组的复杂事务类宏的递归复制行为往往需要额外调试不如手写来得直观可控。另一个让不少人摸不着头脑的细节是clone()之后对象的名字。默认 clone 调用create()时不传 name 参数所以 clone 出来的对象get_name()常常是空字符串。如果你依赖名字做日志区分需要再调一次set_name()或者干脆在覆写 clone 时自己指定名字。这一点用默认实现的时候特别容易忽略。6. 验证环境中最容易踩的拷贝误区和实际使用场景6.1 scoreboard 暂存事务先 clone 再入队回到开头那个 DMA 的场景。monitor 采集到事务后如果把同一个对象句柄反复 push 进队列最终读出来的全是同一个对象的最新状态。正确做法有两种。第一种monitor 每次采集都new()一个新对象但这是最占用资源的方案。第二种monitor 复用同一个对象但入队之前先 clone 一份class monitor extends uvm_monitor; trans tr; trans tr_q[$]; virtual task run_phase(uvm_phase phase); tr new(); forever begin // 采集信号、填充 tr 的字段... sample_bus(tr); // 入队前深拷贝保证队列里是独立快照 trans tr_copy; if (!$cast(tr_copy, tr.clone())) begin uvm_fatal(MON, clone 类型错误) end tr_q.push_back(tr_copy); end endtask endclass这里必须用 clone 而不是直接把 tr 入队。clone 出来的是独立对象后续 tr 再怎么复用更新都不影响队列里已经保存的数据。6.2 reference model 的输入保护复制完之后随便改参考模型是拷贝问题的高发区。输入事务是原始激励参考模型要在事务基础上计算预期值有时不可避免要修改某些字段。如果直接修改输入事务scoreboard 拿原始激励做比对时就会发现数据对不上。我的习惯是reference model 入口处先 clone 一份输入事务拿这份副本来计算原始事务保持不动。这样 scoreboard 里比对时激励侧和预期侧各自持有完整独立的数据排错也容易。class ref_model extends uvm_component; virtual function void compute(trans input_tr); trans work_tr; if (!$cast(work_tr, input_tr.clone())) begin uvm_fatal(REF, clone 失败) end // 随便改 work_tr 的字段不影响 input_tr work_tr.expected_addr input_tr.addr LOCAL_OFFSET; // 后续比较用 input_tr 和 work_tr endfunction endclass6.3 常见拷贝误用对照表误用写法现象正确做法dst src;两个句柄指向同一个对象修改互相影响需要独立副本时用 copy 或 clonedst.copy(src);但类没覆写 copy也没注册字段自动化调用后听起来成功了实际什么都没复制手动覆写 copy逐字段赋值嵌套对象字段直接dst.header src.header;内层对象被共享修改 header 影响所有副本先 new 再逐字段复制或调用 header.copy(src.header)clone()返回结果直接赋值给子类句柄编译报错类型不匹配用 $$cast$ 做向下转换动态数组元素是 class 类型却只做dst.arr src.arr;数组本身独立了但每个元素仍是同一个对象句柄foreach 遍历逐个 new copy只 register 了 uvm_object_utils 就依赖默认 cloneclone 出来的对象字段全为空覆写 copy/clone或改用字段自动化宏这张表基本覆盖了我在代码评审里最常见的几类问题。每次遇到类似 bug我都会先按表里的顺序快速排查一遍多数情况能直接命中。6.4 一个关于“写在哪一层”的经验建议最后分享一个实践经验。给事务类设计拷贝逻辑时我建议把 copy 方法写在每个具体类里而不是只设计一个通用的 clone 函数。原因很简单深拷贝的粒度要跟着类内部的数据结构走。比如 eth_frame 内部有 eth_headereth_header 内部又有一个 buf_info。最好的设计是三层各自实现 copy然后外层 copy 里调用内层 copy。这样修改内层结构时只需要改内层类的 copy 方法外层代码完全不用动。如果反过来把复制逻辑全部堆在最外层类的 copy 方法里一旦内层结构增加字段所有上层拷贝代码都要跟着改而且很容易漏改漏改的结果就是“表面深拷贝、局部浅拷贝”这种 bug 最难查。另一个建议是在写完 copy 方法之后不要只看代码建议在 testbench 的 random test 里加一条断言专门验证拷贝后的对象和源对象之间完全独立。比如 random 出若干字段值copy 之后修改目标对象所有字段再断言源对象没有被改动。这条断言虽然简单但每次跑回归都能帮你提前发现被人不小心改坏的拷贝逻辑非常划算。这些看似琐碎的细节恰恰是验证环境稳定性和可维护性的根基。对象拷贝这一关过了后续 scoreboard 比对、reference model 计算、sequence 变体生成都会顺畅得多。
返回列表