首页 / 资讯中心 / 文章详情

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

SystemVerilog类继承与句柄:从对象机制到UVM验证环境 ★ FEATURED ARTICLE
前面几篇把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 32h1000; $display(%h, p2.addr); // 打出的是1000不是0p1和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; endclasspcie_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(); // 输出 extdisplay不是virtual所以仿真器只看句柄的静态类型是base_tr调用base_tr::displaykind是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.hostb.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从总线上抓到事务向上转型成基类句柄后传给scoreboardscoreboard的check函数参数声明为基类句柄处理时通过基类里定义的virtual method判断真实类型。比如基类定义virtual function string kind()读事务返回READ写事务返回WRITEscoreboard里根据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的时候不妨停下来想一想背后的继承结构往往比直接背源码更能打通思路。
阅读完成 · 觉得有帮助?
咨询建站