ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SystemVerilog类继承与句柄:从对象机制到UVM验证环境

SystemVerilog类继承与句柄:从对象机制到UVM验证环境

前面几篇把SV的基础数据类型、队列、关联数组、随机化这些内容梳理了一遍,从这一篇开始进入真正的验证语言核心区:类的继承与句柄的使用。如果你写验证环境跟写普通C代码一样,只用task和function堆逻辑,那后面接触UVM框架时基本会卡壳;反过来,一旦理解了继承和句柄,你再看验证平台里那些test、driver、monitor、sequence,就会觉得一切都顺理成章了。这篇的内容不算复杂,但概念比较密,我尽量用大白话把“类”“对象”“句柄”三者间的三角关系讲清楚,再拆解继承语法、构造函数、方法重写、类型转换这些高频考点,最后把我在实际项目里踩过的几个坑一并列出来。适合刚接触SV面向对象的同学,也适合已经能跑basic test、但对多态和深拷贝还一知半解的验证工程师。

1. 先记住:类、对象、句柄是三样东西

1.1 类是一张图纸,对象是按图纸造出来的实体

在SV里写一个class,本质上只是定义了一张“图纸”,它告诉仿真器这类数据包含哪些字段、有哪些方法。真正在内存里占据空间的是对象,对象是类被实例化后的产物。拿生活里的事类比:class就是盖楼的图纸,new()就是拿着图纸去施工现场盖了一栋楼,盖几栋、盖在哪里,都是你在运行期决定的。你写class packet;定义的是“包”这个类型的模板,但模板本身不存数据。只有执行了packet p = new();,仿真器才会在内存里给你分配一块区域,这块区域里才有addr、data这些字段的实体。

new()可以被调用很多次,每次调用都会造出一块新的对象内存。这也是class和struct最大的区别——struct只是数据集合,没有new、没有句柄,传递时按值复制;class则天然带引用语义。这个区别非常关键,后面讲拷贝和赋值的时候,几乎所有坑都从这里来。

1.2 句柄更像遥控器,它不是电视机本身

很多从Verilog转过来的同学,写packet p;时下意识把p当成一个对象容器,仿佛p里面装了addr。其实p是一个句柄,它只保存了一个指向对象的引用。用h.addr访问字段,本质上是“通过h这个遥控器,按下遥控器上的addr按钮,去操作它指向的那台电视机”。这个类比能解释后面一大堆坑。

两个句柄指向同一个对象,并不等于有两个独立对象:

packet p1 = new(); packet p2 = p1; p1.addr = 32'h1000; $display("%h", p2.addr); // 打出的是1000,不是0

p1和p2指向同一个对象,你通过p1修改字段,p2“看见”的也是同一个字段。这个特性用得好可以省掉很多复制动作,用不好就是调试地狱。所以每次看到句柄赋值,先问自己一句:我要的是共享,还是复制?

2. 继承解决的是验证环境里最现实的复用问题

2.1 把公共字段抽到基类,子类只需要写差异

既然类可以当作图纸,那继承就是“在已有图纸上改一版新图纸”。基类画公共的部分,子类extends基类,把基类的字段和方法全部继承过来,再往外加自己的东西。

验证环境里最常见的例子:不同协议的事务对象,十有八九都有一拍valid、一拍ready、一个地址、一个数据。如果每个事务类都单独写一遍,将来要统一改某个公共字段名,等于把所有类翻出来改一遍。抽了基类就舒服了:

class base_transaction; bit valid; bit ready; bit [31:0] addr; function void reset(); valid = 0; ready = 0; addr = 0; endfunction endclass class pcie_transaction extends base_transaction; bit [7:0] tag; endclass

pcie_transaction自动获得了base_transaction的valid、ready、addr和reset(),自己只需要写tag。以后要加公共字段,只动base_transaction一个文件,所有子类同步拥有新字段。这个收益在大型环境里非常明显。

2.2 父类句柄放子类对象:让一套代码吃下全部分支

继承的另一个核心应用是可替换原则:子类对象一定“是一种”父类对象,所以父类句柄可以指向子类对象。这个特性让“一套处理函数处理所有子类类型”成为可能。

比如你有一个scoreboard,要处理读事务、写事务、配置事务。如果每个类型都写一个process函数,代码会越来越臃肿。如果让所有事务都继承同一个基类,scoreboard只需要写一个process(base_transaction t),调用时不管传入的是读、写还是配置事务,都可以往里面塞。塞进去之后内部再根据多态行为做细节处理,这就是面向对象“对扩展开放、对修改封闭”的价值。

2.3 测试用例也是这样一层层扩出来的

继承不只是用在数据对象上,验证环境里的组件、测试用例同样是大规模继承的受益者。常规平台里通常先有一个base_test,负责环境创建、公共配置、默认序列这些工作;每个具体的测试用例extends base_test,只重写它能影响的那一小部分。这样加一个case的成本很低——写一个新类,extends,重写两个方法,完事。

这也是为什么大家整天说“UVM环境加case特别快”:快不是魔法,是继承把公共结构和差异逻辑拆开了。你只要理解基类里有哪些钩子方法,再在子类里定向覆盖就行。

3. 继承语法、构造函数与方法重写

3.1 extends语法和成员可见性

语法本身不复杂:

class parent; bit [7:0] val; endclass class child extends parent; bit [7:0] extra; endclass

需要注意的倒是成员可见性。SV的成员默认是public,子类能访问,类外部也能直接访问。如果一个字段你不想让外部随手改,就声明成protected,这样子类可以访问,但类外部访问会报编译错误。local是最严的,只有本类能访问,连子类都不行。三者的关系可以这样看:

可见性本类内部子类内部类外部
public可以可以可以
protected可以可以不可以
local可以不可以不可以

我自己的习惯是:验证环境里的公共配置字段,能用protected就不用public。全部public会导致环境各模块之间的耦合面太大,改一个字段名可能牵连一片,编译报错能淹没日志。

3.2 super.new()和构造函数调用顺序

子类构造函数里,第一句必须是super.new()。如果父类有无参构造,你不写也能编译过,仿真器会在子类构造开始时隐式调用;但如果父类的构造函数带了参数,子类就必须显式写super.new(arg),否则直接编译报错。

原因很好理解:造一辆能跑的车,得先把底盘、支架这些基础部件造好。父类字段的初始化应该由父类构造函数负责,子类构造函数只负责子类自己的字段。所以基类里最好把公共字段的默认值在new中设好:

class base_transaction; bit valid; bit ready; function new(); valid = 0; ready = 0; endfunction endclass class pcie_transaction extends base_transaction; bit [7:0] tag; function new(); super.new(); tag = 0; endfunction endclass

只要记住一个顺序:super.new()永远在第一行,且父类构造逻辑先执行。这个顺序决定了子类依赖的公共字段已经存在且已初始化。

3.3 virtual不是可选项,而是指定多态的关键

写继承最容易翻车的地方就是方法重写。SV里子类可以定义和父类同名同参的方法,这叫做覆盖/重写。问题在于:如果没有virtual修饰,调用哪个版本取决于句柄的声明类型,而不是对象的实际类型。看个例子:

class base_tr; function void display(); $display("base"); endfunction virtual function string kind(); return "base"; endfunction endclass class ext_tr extends base_tr; function void display(); $display("ext"); endfunction virtual function string kind(); return "ext"; endfunction endclass

然后这样调用:

base_tr h; ext_tr e = new(); h = e; h.display(); // 输出 base h.kind(); // 输出 ext

display不是virtual,所以仿真器只看句柄的静态类型是base_tr,调用base_tr::display;kind是virtual,仿真器会根据对象的实际类型动态分派到ext_tr::kind。这就是多态的关键。

很多从Java转过来的同学会很不适应,因为Java里方法默认就是虚方法,重写是默认行为。SV不一样,virtual必须显式声明。所以设计基类时,凡是你能想象到子类可能要改的方法,一律加上virtual。宁可多写,不可漏写。

4. 句柄的赋值、拷贝与生命周期

4.1 句柄赋值是共享,不是复制

第1节的类比还有后半段:p2 = p1这个操作,不会产生一个“内容相同的副本”,只是让p2也指向p1指向的那个对象。可以看到,验证环境里一个经典错误场景是:从generator拿到一个事务对象,把它存进队列,后来修改了这个对象,结果发现队列里的“旧”数据也变了。

原因就是队列里存的是句柄,不是对象。要避免这种问题,要么在入队前把对象复制一份,要么在逻辑上就约定对象存队之后不允许再被修改。前者靠copy方法,后者靠流程规范。很多新人在仿真调试时遇到“数据莫名变化”,其实都是这个原因。

4.2 自定义copy方法来实现深拷贝

SV没有内置的对象复制,new p2 = new p1这种写法根本不存在。想复制对象,老老实实写copy方法:

function packet copy(); packet c = new(); c.addr = addr; c.data = data; foreach (payload[i]) c.payload.push_back(payload[i]); return c; endfunction

如果类里还有嵌套的对象句柄、动态数组、关联数组,只复制顶层字段是不够的。浅拷贝会把内部对象的句柄原样复制过去,导致两个对象“内部还共享同一个子对象”。深拷贝需要把内部对象也new出来再逐层复制。

我在实际项目里就碰到过这个问题:两个对象从外面看字段完全独立,但里面挂着一个配置对象,改a.config.host,b.config.host也跟着变了。查了大半天才反应过来是浅拷贝造成的,打印两个config句柄值一看,一模一样。所以写copy时,动态数组和嵌套对象一定要逐层处理。

4.3 null、就地new与对象释放

句柄未new之前默认是null,对null句柄访问成员会直接报空指针。所以拿到外部传入的句柄,先判断if (h != null)是个好习惯。SV有自动内存管理,对象不再被任何句柄引用后,仿真器会回收,不需要也不会写free。但也正因为自动回收,你要特别小心“还有没有句柄在引用这个对象”。否则你随手new一个新的,旧对象还被队列里的句柄引用着,永远不回收,仿真内存就会持续增长。

就地new的写法是h = new h;,很多人初看会愣住。它表示在h原来引用的对象基础上,重新new出一个对象,并把新对象句柄赋给h。关键是,SV里的就地new会先把原对象的内容做一份浅拷贝到新对象里,再完成替换。这个技巧在UVM的sequence中有应用,用来“重置”一个transaction对象继续复用,避免每次都定义一个新的局部变量。理解它,等于把new的语义又加深了一层。

5. 类型转换与多态的实际用法

5.1 向上转型直接赋值,向下转型必须$cast

父类句柄接子类对象是安全的,直接赋值即可:

base_tr h; ext_tr e = new(); h = e;

这叫向上转型,也常叫宽化。因为子类一定“is-a”父类,子类对象的成员一定是父类成员的超集,所以不会丢失信息。

反过来,把一个父类句柄转回子类句柄就不一样了。h的静态类型是base_tr,但它运行时实际指向的对象可能是任何一个子类。你不能保证它就是ext_tr,所以SV要求用$cast做动态类型转换。$cast成功返回1,失败返回0;失败时目标句柄会被清为null,所以必须判断返回结果:

ext_tr e2; if (!$cast(e2, h)) begin $error("h实际指向的不是ext_tr,转换失败"); end else begin // 转换成功,e2和h指向同一个对象 end

注意$cast成功后,e2和h指向的是同一个对象,不是复制。想改对象内容,两个句柄看到的仍然是一份数据。

5.2 多态在scoreboard中的落地

把前面这些概念连起来,就是典型的验证场景。monitor从总线上抓到事务,向上转型成基类句柄后传给scoreboard;scoreboard的check函数参数声明为基类句柄;处理时通过基类里定义的virtual method判断真实类型。比如基类定义virtual function string kind(),读事务返回"READ",写事务返回"WRITE",scoreboard里根据kind的结果再去做对应的比对。

这样每新增一种事务类型,只要保证新的子类实现了kind(),scoreboard主体逻辑几乎不用改。这就是“写一次、到处跑”的本质。多态不是炫技,是验证平台能横向扩展的基础。

5.3 转换失败不是bug,是设计信号

写$cast失败时,很多人第一反应是查类型。其实转换失败往往暴露的是设计层面的问题:说明当前逻辑拿到的事务类型超出了预期。与其用一连串$cast去猜类型,不如在基类里把类型判断方法定义清楚,让每个子类返回一个明确标识。这样把“猜类型”变成“问类型”,代码更稳,调试也更直接。

就我自己的经验,一个设计良好的基类,一定会在顶部预留几个virtual method用来返回类型名、打印内容、做copy。后续每加一个子类,第一件事就是实现这几个方法。这比到处$cast干净得多。

6. 踩坑实录与排查思路

6.1 父类句柄访问子类字段编译不过

这种报错信息通常是member not found。原因不是对象里没有这个字段,而是编译器只认句柄的静态类型。要访问子类独有的字段,先把句柄$cast到正确的子类类型,再访问。注意$cast之后得到的句柄和原句柄指向同一个对象,不会产生副本。

6.2 子类构造函数忘了super.new()或顺序写错

如果父类new带参,而子类没写super.new,编译直接报错。如果写了super.new但放在某些语句之后,仿真器也会报错:SV要求super.new()必须是子类构造函数的第一句。这个语法限制也是提醒你构造顺序:父类先初始化,子类再初始化。不要试图在super.new()之前用父类的字段。

6.3 方法被覆盖了却不生效

“我在子类里明明重写了这个方法,为什么跑的还是父类代码?”十有八九是父类方法没加virtual。SV不像Java有@Override强制校验,就算你重写意图很明显,编译器也不提醒。排查方法很简单:在父类和子类方法里各加一行$display,看仿真日志里执行到哪个版本。根治办法还是那句话:设计基类时,凡是要扩展的方法一律提前声明为virtual。

6.4 深拷贝漏了内部对象

浅拷贝后,两个副本顶层字段互不干扰,但内部数组或对象还是共享同一个地址。验证时可以通过打印句柄值来确认,比如$display("%h", obj.handle),两个副本打印出来一样,基本就是共享了。解决办法是写copy时一层一层new内部对象,并逐个复制内部字段。一开始觉得麻烦,但环境一复杂就能省下大量排查时间。

还有一个很容易忽略的细节:队列复制。SV里直接数组队列赋值会做一次成员级复制,这没问题;但如果队列里的元素本身是class句柄,那复制出来的队列只是复制了句柄列表,元素对象还是共享的。所以“队列赋值”不等于“深拷贝队列”。

写到这里,SV里继承和句柄这层窗户纸算是捅破了。我自己的体会是,刚开始学SV的时候最怕的不是语法复杂,而是脑子里总把句柄当成C语言的指针,动不动就想“解引用”“移动指针”。其实SV有自己的一套语义:句柄引用、继承多态、自动回收。只要把“句柄只是遥控器”“父类句柄能指向子类对象”“virtual决定运行时调谁的代码”这三句话记牢,后面写验证环境、读UVM源码都会顺畅很多。如果你正在搭UVM环境,看到那么多extends和virtual method的时候,不妨停下来想一想背后的继承结构,往往比直接背源码更能打通思路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进