☰
SystemVerilog对象拷贝:句柄复制、浅拷贝、深拷贝与clone函数详解
2026/10/1 19:32:52 网站建设 项目流程

做验证这行,每天打交道最多的就是 class、句柄、拷贝这一类基础话题。可越是基础的东西,越容易在关键时刻翻车。我在不同项目、好几轮代码评审里都见过类似的诡异现象:sequence 里构造好的对象,在 driver 里改了某个字段,scoreboard 那边的期望值跟着变;或者两个模块各持一个对象句柄,明明没做过赋值,数据却“心有灵犀”一起变。排查到最后,十有八九是句柄复制、浅拷贝、深拷贝和 clone 函数这条链路没理顺。

这篇文章我打算从内存层面把这几件事拆开讲透,再给出一套可以直接照抄的深浅拷贝代码和 clone 实现思路。适合两类读者:刚入行的验证工程师,想搞清楚“为什么 new 并不能复制对象内容”;以及已经用 UVM 写用例的老手,想弄明白 copy、do_copy、create、clone 之间到底是什么关系。先把结论放在前头:句柄复制复制的是“门牌号”;浅拷贝只复制最外层的普通成员;深拷贝会把嵌套对象和动态数组都复制成独立副本;clone 则是先按动态类型创建新对象、再执行拷贝机制的多态复制工具。搞清楚这几句话,后面再遇到对象互相污染的问题,基本一眼就能定位。

1. 先搞懂句柄复制:等号背后的内存真相

1.1 句柄是什么:对象的“门牌号”

SystemVerilog 里通过 class 创建对象,和 C++ 的 new 类似,对象本身存放在仿真器管理的内存堆中。你在代码里写pkt pa;的时候,其实只是声明了一个句柄变量,也就是一个能指向对象的引用变量。如果把对象比作酒店里的房间,句柄就是房卡的卡号。卡号本身很小,但你握住了卡号,就能打开对应房间,操作里面的所有家具。

这个类比很老套,但特别管用。很多同学总把句柄当成对象本身,其实两者差别很大:对象是实实在在的内存块,句柄只是存了那块内存的地址。SystemVerilog 不像 C/C++ 那样暴露裸指针,也不允许对句柄做任意的加减运算,但这种“引用”的本质是一样,底层全是指针语义。所以你在代码里传对象、存对象、比较对象,十有八九操作的都是句柄,而不是对象里的那一堆字段。

1.2 句柄复制的后果:两个名字指向同一间房

看一段最常见的操作:

pkt pa, pb; pa = new(); pa.id = 1; pb = pa; // 句柄复制

执行pb = pa之后,底层发生了什么?它没有把 pa 对象里的所有成员复制一份给 pb,而是把 pa 保存的内存地址抄给了 pb。此时 pa 和 pb 这两个不同名字,指向的是同一个对象。你可以理解为同一间房间办了两张房卡,两张卡都能开门,开门后看到的东西完全一样。

于是经典现象出现了:修改pa.id = 5,再去打印pb.id,结果也是 5。很多人第一步就在这里卡住,“我明明改的是 pa,为什么 pb 也变?”原因很简单,因为它们本来就是同一个对象,不存在两个副本。这个行为在验证环境里很容易引起连锁反应。比如你从 sequencer 拿了 item,分发给两个 driver,如果中途有人改了 item 里的字段,另一个 driver 看到的数据也会变。所以凡是涉及多模块共享对象,第一时间要问自己:我这里到底是在分享同一个对象,还是已经做了一份独立拷贝?

1.3 一个经典误区:new 并不是复制构造函数

SystemVerilog 和 C++ 有个特别容易踩的坑:很多人以为pkt p2 = new p1;会把 p1 的内容复制到 p2。我必须强调,SystemVerilog 语言本身没有提供 C++ 那样的复制构造函数语义。new关键字只是调用构造函数创建新对象,参数怎么解释,完全由你写的function new()决定。

假设你的类写的是:

class pkt; int id; function new(); id = -1; endfunction endclass

那么pkt p2 = new p1;要么因为实参列表和构造函数不匹配而编译报错,要么就算某个写法能编译过去,也绝不意味着语言帮你把 id 从 p1 复制到了 p2。你可以在类里手动实现一个“形参为 pkt 类型”的构造函数来模拟复制构造,但那属于自己写的初始化逻辑,不是语言自动行为。

所以请记住:在 SystemVerilog 里,凡是需要真正的对象拷贝,都必须显式编写拷贝逻辑;要么自己写 copy 函数,要么借助 UVM 的do_copy字段机制。别指望new帮你完成复制,这一点想不通,后面几步全是坑。

2. 浅拷贝详解:只复制最外层,内部句柄一律共享

2.1 浅拷贝的标准写法

浅拷贝的意思,是把对象最外层的内建类型成员复制给新对象,但对成员里的类句柄、动态数组、队列、关联数组这些“引用类型”,只复制它们的句柄或引用,不复制内部数据。听起来有点抽象,直接看代码:

class config; int timeout; bit [31:0] base_addr; endclass class pkt; int id; string name; config cfg; byte data[]; function pkt copy_shallow(); pkt c; c = new(); c.id = this.id; c.name = this.name; c.cfg = this.cfg; // 只复制句柄,两个对象共享同一个config c.data = this.data; // 只复制数组句柄,两个对象共享同一块数组内存 return c; endfunction endclass

这样写出来的 copy_shallow,就是标准的浅拷贝。id、name 这类内建成员确实各复制了一份,但 cfg 和 data 这两个成员并没有真正复制。你可以验证:运行p2 = p1.copy_shallow(); p2.cfg.timeout = 3;之后再打印p1.cfg.timeout,得到的一定是 3。因为你改的本来就是同一个 config 对象。

为什么会这样?根本原因是类句柄和动态数组句柄本身只是一个地址,你把它从一个变量赋给另一个变量时,只是把这个“卡号”复制了一份,房间还是原来那间。浅拷贝的问题不在于代码不正确,而在于很多人误以为“我调了 copy,就应该拿到完全独立的对象”,结果内部成员被共享了还不知道。

2.2 浅拷贝的真正风险:嵌套对象和动态数组成员被共享

我在实际项目里见过的典型事故是这样的:driver 在发送前对pkt.cfg某个字段做了调整,或者在pkt.data里填充了 CRC 字节,导致 scoreboard 拿到的参考对象也被改掉。最终排查发现,sequence 里通过浅拷贝生成了发送对象,driver 直接改了内部字段,结果源对象和复制对象共享了 cfg/data,牵一发动全身。

人脑有个很自然的惰性:看到copy就默认“复制=独立”。真实世界不是这样。浅拷贝和深拷贝的差别,只有在对象包含“引用类成员”时才会暴露。最常见的引用类成员包括:

  • 类句柄成员,比如 config、sequence_item 的嵌套对象;
  • 动态数组byte data[],队列int q[$],关联数组int map[string];
  • 即使数组元素是内建类型,动态数组句柄本身也是引用,浅拷贝同样共享。

如果想避免这类问题,就不能只满足于写一个把普通 int/string 字段复制一遍的函数,必须对引用成员做额外处理。这也是浅拷贝和深拷贝最大的分水岭。

2.3 可以放心使用浅拷贝的场景

并不是说浅拷贝就一定危险。如果你的类根本没有嵌套对象和动态数组,所有成员都是 int、bit、enum、string 或者定长数组,那么浅拷贝和深拷贝的效果完全一样,写浅拷贝反而是最经济、最清晰的做法。

class simple_pkt; int id; bit valid; bit [7:0] payload[32]; // 定长数组,存在对象内部,复制时自动发生 endclass

关键在于bit [7:0] payload[32]是定长数组,它是对象内部的一份连续内存,不是独立的句柄。所以复制时它会作为对象的一部分被完整拷贝,不存在共享问题。很多老工程师常跟你讲“定长数组成员随便拷,动态数组成员小心拷”,这句话其实就是核心经验。

所以判断何时能用浅拷贝的方法很简单:把这个类的所有成员过一遍,凡是内建值类型和定长数组,浅拷贝安全;一旦出现句柄成员、动态数组、队列、关联数组,浅拷贝就要打问号。做验证环境的时候,尽量让事务类和配置类保持这种简单结构,会省掉大量拷贝上的麻烦。

3. 深拷贝详解:把每条引用都换成全新对象

3.1 深拷贝的核心思路:递归复制

深拷贝说白了,是把对象里每个成员都复制到位。普通内建成员直接赋值,类句柄成员要new一个同类型对象再逐个复制,动态数组要先new出长度再遍历赋值,如果数组元素本身还是类句柄,那就得一层一层继续复制下去。整个过程很像搬家:浅拷贝是把行李箱外壳搬走,深拷贝则是拆开箱子,把里面所有东西都打包搬进新家。

深拷贝最怕遇到“循环引用”。比如对象 A 里有 B 的句柄,B 里又有 A 的句柄,如果写复制函数不做访问标记,递归会无限下去,最终撑爆仿真栈。好在绝大多数验证用的事务对象不会设计成循环引用,一般只是一棵树状结构,从根出发逐层复制即可。

3.2 动态数组、队列、关联数组分别怎么复制

先说动态数组。SystemVerilog 的动态数组本身是一个对象,它保存了长度信息,也保存了指向堆内存的指针。直接用=复制数组句柄,只是复制了指向堆内存的指针,并没有把每个元素复制一遍。想深拷贝,必须这样:

c.data = new[this.data.size()]; foreach (this.data[i]) begin c.data[i] = this.data[i]; end

如果数组元素是类句柄,则要在 foreach 内部再对元素做一次new并复制元素内容。

队列的情况需要细微区分。队列本身是内建结构,你用q2 = q1复制队列时,编译器会复制队列容器和其中的元素。但如果元素是类句柄,复制的仍然是句柄,两个队列里放着同一个对象引用。所以队列深拷贝的关键不是“拷贝队列”,而是“把队列里的每个句柄元素也换掉”。

关联数组和队列类似,需要逐 key 遍历复制:

foreach (map_source[k]) begin map_dest[k] = map_source[k]; end

关联数组只是解决了“键值对”有没有复制的问题,如果值是类句柄,那还是要对值做深拷贝。

为了把整块逻辑说清楚,我写一个完整的 container 类示例:

class nested_obj; int val; endclass class container; int id; nested_obj obj; nested_obj arr[]; int map[string]; function container copy_deep(); container c = new(); c.id = this.id; if (this.obj != null) begin c.obj = new(); c.obj.val = this.obj.val; end c.arr = new[this.arr.size()]; foreach (this.arr[i]) begin c.arr[i] = new(); c.arr[i].val = this.arr[i].val; end foreach (this.map[k]) begin c.map[k] = this.map[k]; end return c; endfunction endclass

这段代码就是深拷贝的模板。注意我在复制前先判断句柄是否为 null,这是很多新手容易漏掉的细节。如果this.obj是 null,直接new()就会把空对象变成一个“存在但字段为默认值”的对象,行为就变了。

3.3 深拷贝的代码范式:用分层 copy 替代巨型函数

一旦类开始继承,深拷贝就容易变成一场灾难。子类写一个 copy 函数,很可能把父类字段和子类字段都堆在同一个函数里,成员一多就几百行。更合理的方法是分层复制:基类定义虚方法copy_from,子类覆写它时先调用super.copy_from(),再复制自己新增的字段。

class base_obj; int common; virtual function void copy_from(base_obj rhs); if (rhs == null) return; common = rhs.common; endfunction endclass class sub_obj extends base_obj; int specific; sub_obj fn; virtual function void copy_from(base_obj rhs); sub_obj r; super.copy_from(rhs); if (!$cast(r, rhs)) return; specific = r.specific; if (r.fn != null) begin fn = new(); fn.copy_from(r.fn); end endfunction endclass

这种写法有两个好处:一是每个类只需要维护属于自己的字段,继承链上父类字段由父类方法负责;二是利用$cast做类型检查,如果传入的对象类型不对,直接返回,不会误伤数据。UVM 的 do_copy 机制本质上就是这个套路,但很多人只记住了“要写 do_copy”,却忘了 do_copy 里必须先 cast 再复制,导致类型不匹配时静默出错。

4. clone函数:多态场景下的“克隆机”

4.1 clone 和 copy 的区别

copy 和 clone 经常被混着说,但它俩定位完全不同。copy 是“把内容复制到目标对象里”,使用前提是目标对象已经存在。clone 是“创建一个和源对象同类型的新对象,并把内容复制过去”。所以 clone 天然适合这样的场景:我只拿到一个基类句柄,但实际上指向的是某个子类对象,我想要一份新副本,却不想在代码里写死具体类型。

这里有一个非常值得强调的细节:SystemVerilog 类句柄有“静态类型”和“动态类型”之分。你声明base_obj b;时,b 的静态类型是 base_obj;如果运行时给它赋了一个 sub_obj 对象,那么 b 的动态类型是 sub_obj。clone 的“多态”正体现在这里:调用b.clone(),即使 b 的静态类型是 base_obj,它仍然会根据动态类型创建出一个 sub_obj 对象。这一点对验证环境的事务复制极其重要,因为 sequence、driver 之间经常用基类句柄传递对象。

4.2 UVM 默认 clone 的运作机制

UVM 里uvm_object提供了一个默认的 clone 虚方法,核心逻辑可以理解为:

virtual function uvm_object clone(); uvm_object tmp; tmp = create("clone"); tmp.copy(this); return tmp; endfunction

也就是说,clone = create + copy。create 通过工厂机制创建新对象,因为工厂知道源对象的实际动态类型,所以创建出来的对象类型和源对象一致。copy 方法会调用 do_copy,所以真正控制“复制哪些字段、怎么复制”的逻辑在 do_copy 里。

常见的 UVM 深拷贝习惯是:在 do_copy 里处理所有需要深拷贝的字段。比如:

class my_pkt extends uvm_sequence_item; `uvm_object_utils(my_pkt) int id; byte data[]; my_cfg cfg; function new(string name = "my_pkt"); super.new(name); endfunction virtual function void do_copy(uvm_object rhs); my_pkt rhs_pkt; super.do_copy(rhs); if (!$cast(rhs_pkt, rhs)) begin `uvm_fatal("TYPE_MISMATCH", "do_copy type mismatch"); return; end id = rhs_pkt.id; data = new[rhs_pkt.data.size()]; foreach (rhs_pkt.data[i]) begin data[i] = rhs_pkt.data[i]; end if (rhs_pkt.cfg != null) begin cfg = new(); cfg.timeout = rhs_pkt.cfg.timeout; end endfunction endclass

这样写完之后,直接调用my_pkt::type_id::create("xxx")创建对象,然后调用 copy 或 clone,都能拿到深拷贝结果。关键点在于:clone 的默认实现已经替你把 create 和 copy 接起来了,你不需要覆写 clone,只需要覆写 do_copy。

4.3 自定义 clone 函数与 $cast 类型转换

如果不用 UVM,而是纯 SystemVerilog,想实现 clone 的多态效果,可以自己在基类里定义虚函数:

class trans; int id; virtual function trans clone(); trans t = new(); t.id = this.id; return t; endfunction endclass class eth_trans extends trans; int eth_type; virtual function trans clone(); eth_trans t = new(); t.id = this.id; t.eth_type = this.eth_type; return t; endfunction endclass

这里有个容易混淆的地方:子类 clone 的返回类型仍然是 trans,不是 eth_trans。你调用一个trans句柄的 clone,得到的新对象确实是 eth_trans,但它的静态类型还是 trans。想把它赋给 eth_trans 句柄,就必须用$cast:

eth_trans e_src, e_dst; if (!$cast(e_dst, e_src.clone())) begin $error("clone type mismatch"); end

为什么要用 $cast 而不是直接赋值?因为直接赋值在编译阶段就会被拒绝,编译器不知道运行时这个 trans 句柄到底指向什么对象。$cast 是运行时类型检查,它先判断对象的真实类型是不是 eth_trans,如果是,才赋值;如果不是,返回 0,目标对象保持不变。这样既避免了类型风险,也给错误处理留了机会。

这里我单独提醒一句:>$cast 失败返回 0 时,目标对象不会被清空。如果你的 e_dst 本来指向一个有用的对象,$cast 失败后它还是指向原来的对象,不能认为它变成了 null。所以最好配合 if 语句使用,并且在失败时明确处理,而不是直接忽略返回值。

4.4 clone 后对象名字丢失的问题

UVM 默认 clone 实现里,create 时传入的名字是字符串常量"clone",所以克隆出来的对象get_name()一般会返回"clone",而不是源对象的名字。如果你的打印、日志、sequence 里依赖get_name()来区分对象,这一步可能会造成困扰。

解决办法有两种:一是覆写 clone:

virtual function uvm_object clone(); uvm_object tmp; tmp = create(get_name()); tmp.copy(this); return tmp; endfunction

二是在 clone 之后手动设置名字:

if (!$cast(dst, src.clone())) return; dst.set_name(src.get_name());

这个问题在项目里不常遇到,但一旦遇到就会让人摸不着头脑,因为用例明明能跑,只是打印出来的名字不对。知道原因之后,处理起来非常快。

5. 实战排坑:句柄复制、深浅拷贝与 clone 的典型问题

5.1 句柄共享导致“牵一发动全身”

最常见的排坑场景还是发生在 sequence 和 driver 之间。很多人在 sequence 里这样写:

task body(); my_item item; item = my_item::type_id::create("it"); item.addr = 32'h1000; start_item(item); finish_item(item); endtask

start_item 到 finish_item 期间,item 对象会在 sequencer、driver、monitor 之间流转。如果你把 item 交出去之后,又在其他地方通过同一个句柄修改了 item 的字段,driver 看到的自然也是被改过的数据。这其实不是“bug”,而是句柄共享的必然结果。排查思路也很简单:先确认各个模块操作的是不是同一个句柄,如果是,再决定是改成“副本传递”还是“只读传递”。

我见过的一种低效排查方法是怀疑 protocol 逻辑、怀疑时序,折腾两天最后发现就是浅拷贝没做。所以我的建议是,在任何对象跨模块传递的入口统一加打印,打印对象句柄地址或唯一 id,一下子就能看出是不是同一个对象在到处传。SystemVerilog 里可以给对象加一个static int object_count,每次new时递增并赋一个唯一实例号,调试时这个号比句柄字符串好用得多。

5.2 拷贝函数漏掉动态数组或关联数组

很多第一次写 copy 函数的验证工程师,会把普通字段复制得很整齐,但把动态数组和关联数组漏掉。漏掉的症状也很隐蔽:id 对了、name 对了,但数组长度永远和源对象一样,或者改源数组时副本也跟着变。

排查时可以写一个测试脚本:创建对象 A,填充字段,执行拷贝得到 B,然后修改 B 的动态数组长度和元素,再检查 A。如果 A 跟着变,说明数组共享了,即 copy 函数里没有new[this.size()]再加 foreach。如果数组长度一样但内容不对,说明你只new了长度,没有逐个元素赋值。

这个测试脚本建议固化到自测用例里。每次改类定义、加字段,都要重新检查 copy 函数是否同步更新,否则很容易出现“新字段忘了复制”的隐藏问题。在 UVM 环境中,如果用了字段宏,新增字段时宏会自动处理,但这也只对简单类型安全,类句柄字段依旧要自己写。所以“字段宏=全自动深拷贝”这个想法不能有。

5.3 clone 返回类型不匹配问题

clone 返回的是uvm_object,想要具体类型必须$cast,这一点前面已经反复强调。这里补充一个容易被忽略的坑:如果使用 UVM factory override,clone 创建的动态类型可能不是源对象声明的类型,而是 override 后的类型。比如你把 my_item override 成了 my_item_ext,那么对 my_item 调 clone,得到的可能是 my_item_ext 对象。如果 downstream 只用 my_item 句柄去接,也许没问题;如果用 my_item_ext 去接,反而可能失败。

这类问题的定位方式是打类型名:

$display("src type = %s, clone type = %s", src.get_type_name(), dst.get_type_name());

一看类型对不上,思路就清晰了。多态在验证环境里是双刃剑,用好了能拿到各种扩展对象,用不好就会让类型检查变得扑朔迷离。所以我在写通用复制逻辑时,始终坚持:在 clone 结果后做 $cast 并检查,如果失败就报错,绝不静默吞掉。

5.4 拷贝策略选择速查表

复制方式实际行为风险适用场景
句柄赋值b = a两个句柄绑定同一对象修改一方,另一方全变临时引用、多个句柄观察同一对象
浅拷贝 copy_shallow复制内建成员,句柄/数组共享嵌套对象和动态数组被共享对象无嵌套引用,或允许内部共享
深拷贝 copy_deep嵌套对象、动态数组全部独立复制实现复杂,循环引用会爆栈对象要独立下发、下游可能修改
UVM clonecreate + copy,按动态类型创建对象$cast 不检查会出错,名字可能丢sequence、driver、scoreboard 间传独立数据

这条速查表看起来简单,但信息密度很高。我在评审代码时,一般只看两个地方就知道这个人的对象处理水平:一是复制函数里有没有处理动态数组和类句柄成员;二是在使用 clone 返回结果时有没有做 $cast 检查。这两点做到位,90% 的对象拷贝坑都能避开。

这些操作听起来不难,真正写起来最容易翻车的还是“以为自己写了深拷贝,其实是浅拷贝”。我个人的习惯是:凡是向外传递对象,默认走 clone 或深拷贝;凡是临时内部引用,才考虑句柄赋值。另外,每实现一个拷贝函数,就写一个自测用例:先拷贝,改副本,比对原始对象,确认没有影响,再用断言把它固化下来。这样比事后翻日志排查高效得多。如果项目里已经统一用 UVM,建议把 do_copy、clone、$cast 这三种套路沿用到所有事务类上,代码风格一致,团队协作时能少踩一半的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询