ARTICLE DETAIL

资讯详情

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

C++转SystemVerilog:OOP概念与资源管理关键差异

C++转SystemVerilog:OOP概念与资源管理关键差异 做验证的工程师里尤其这两年从软件方向转过来的同事越来越多我经常看到有人拿 C 的思维写 SystemVerilog然后被仿真器锤得莫名其妙。我第一次接触 SystemVerilog 时也是这个状态看到 class、继承、virtual function心想这不就是我熟悉的 OOP 吗结果写出来的组件要么在仿真几十万周期后内存暴涨要么在基类句柄上调用的方法根本不是子类的实现。这些问题本质上不是语法问题而是没有搞清楚 C 和 SystemVerilog 里那些同名的 OOP 概念背后的设计目标和运行机制完全不一样。这篇文章我想把这两种语言里几个容易混淆的点逐项拆开类的定义与对象创建、句柄和指针、继承与虚方法、构造析构与资源管理、访问控制与代码组织、模板与参数化类。比较的意义不在于评判谁更好而在于让你从 C 转过来时能少踩坑从 SystemVerilog 学 C 时能更快看懂参考模型。1. 同样挂着 OOP 的名头两个语言的目标完全不同1.1 C 的 OOP系统性抽象还要保持零成本C 诞生的时候要解决的核心问题是在不牺牲性能的前提下给 C 语言加上抽象能力。Bjarne Stroustrup 最初做 C with Classes目的很直接让系统程序也能用上类、继承、封装但编译出来的代码不能比手写 C 慢太多。所以 C 的 OOP 是建立在“值语义”之上的对象可以放在栈上可以拷贝复制可以靠构造函数和析构函数精确管理资源。这种设计带来的结果是C 程序员普遍把“资源生命周期”当成一等公民。RAIIResource Acquisition Is Initialization是 C 社区最核心的实践构造函数里拿资源析构函数里释放资源作用域结束一切自动收尾。这个习惯在 C 里极其好用因为编译器保证析构函数一定执行。1.2 SystemVerilog 的 OOP为验证环境而生不是给 RTL 设计用的SystemVerilog 是从 Verilog 演化来的2005 年成为 IEEE 1800 标准整合了 Verilog 的硬件描述能力和一套面向验证的高级特性。它引入 class 的目的非常明确用来搭建 testbench 验证环境而不是用来写可综合的 RTL。你可以在 RTL 的 module 里用 always、assign、状态机但如果谁在 RTL 里写个 class 想综合成电路那基本是不可能的。所以 SystemVerilog 的 OOP 更像是一套“被裁剪过的 C 风格”的抽象工具面向的是仿真过程生成激励、搭建 agent、连接 monitor、实现 scoreboard、处理 sequence。UVM 的方法学就是建立在 SystemVerilog 这些 OOP 特性之上的最核心的用法是继承多态、参数化类和工厂模式。1.3 为什么这两个语言经常被放在一起比芯片验证圈子里参考模型经常用 C/C 写DUT 和验证环境用 SystemVerilog 搭两边必须协同工作。软件背景的人要读懂验证代码验证工程师要借用 C 的开源参考模型跨语言几乎是常态。问题就出在两边都以为自己懂对方的 OOP结果一深入就翻车。先把背景摆清楚后面逐项看差异就能对号入座。2. 类的声明与对象创建花括号和关键字的位置只是开始2.1 语法对照一眼看上去像写起来全不一样先看一个最简单的类定义。C 里这样写class Transaction { private: int id; public: Transaction(int i) : id(i) {} int get_id() const { return id; } };SystemVerilog 里对应这样写class Transaction; int id; // 默认就是 public function new(int i); id i; endfunction function int get_id(); return id; endfunction endclass差异从声明语法就开始了。C 用花括号分块SystemVerilog 有独立的 endclass、endfunction 关键字。C 的 class 默认访问级别是 privateSystemVerilog 的 class 成员默认是 public。C 的构造函数可以有多个重载也可以用初始化列表SystemVerilog 的构造函数只能叫 new而且不能重载想实现多种构造方式只能靠默认参数或者类里的静态工厂方法。2.2 对象创建栈对象、堆对象和句柄C 里创建对象有两种方式行为差别很大Transaction t_stack(1); // 栈对象作用域结束自动析构 Transaction* t_heap new Transaction(1); // 堆对象需要 deleteSystemVerilog 里根本没有“栈对象”这个概念Transaction t; // 这里只是声明一个句柄初始值是 null t new(1); // 在堆上创建对象把句柄赋给 tSystemVerilog 里所有的 class 对象都在堆上创建t 是一个句柄本质上像 C 的指针但用法更安全一些不需要解引用运算符直接用点号访问成员不能做指针算术可以赋成 null。你可以把 SV 的句柄理解成一个自动回收的弱化版 C 指针。这个差异是最容易被忽略但影响最深的一个。C 的局部对象会在离开作用域时自动析构SystemVerilog 的句柄变量离开作用域只是丢掉了引用对象本身还留在堆上等待垃圾回收。如果还有别的地方引用它它就一直不会消失。2.3 默认初始化的哲学差异C 里局部变量不初始化就是垃圾值类成员如果不在构造函数里赋值也是未定义行为。SystemVerilog 不一样所有变量都有确定的默认值int 是 0bit 是 0string 是空串class 句柄是 null。这件事看起来像是个便利功能实际上暗含了仿真世界的一个需求可预测性。验证环境要求每次跑仿真的初始状态都是确定且可复现的。我见过一些从 C 过来的同事写 SV 时靠默认值 0 省事结果后来某个字段实际没被赋值却在 scoreboard 里比较时通过了等到换数据才暴露排错很痛苦。默认值只是初始状态不是逻辑正确性的保证别把两者搞混。3. 继承、虚方法与多态最容易出问题的重灾区3.1 virtual 在两种语言里的分工C 的虚函数靠 virtual 关键字声明加 virtual 的成员函数在调用时走动态绑定不加 virtual 的按静态类型绑定。SystemVerilog 的设计思路非常接近但工程上很多人会漏写 virtual导致多态失效。看代码对比。Cclass Base { public: virtual void print() { printf(Base\n); } void not_virtual() { printf(Base non-virtual\n); } }; class Derived : public Base { public: void print() override { printf(Derived\n); } void not_virtual() { printf(Derived non-virtual\n); } }; Base* b new Derived(); b-print(); // Derived b-not_virtual(); // Base non-virtualSystemVerilogclass Base; virtual function void print(); $display(Base); endfunction function void not_virtual(); $display(Base non-virtual); endfunction endclass class Derived extends Base; function void print(); $display(Derived); endfunction function void not_virtual(); $display(Derived non-virtual); endfunction endclass Base b; b new Derived(); b.print(); // Derived因为 Base 里 print 声明为 virtual b.not_virtual(); // Base non-virtual静态绑定关键点在于如果子类要覆盖一个方法父类里对应的方法必须声明为 virtual否则即便你用一个基类句柄指向了派生类对象调用的还是基类版本。C 里不写 virtual 也会出现同样的情况但 C 程序员对 virtual 的敏感性通常更高因为在 C 里大家默认“需要多态就得加 virtual”。到了 SystemVerilog 里很多人写完基类方法忘了加 virtual回头查问题查半天最后发现只是一行关键字的事。3.2 抽象类与纯虚方法C 的纯虚函数用 0表示含有纯虚函数的类不能实例化class Interface { public: virtual void do_it() 0; virtual ~Interface() {} };SystemVerilog 的表达方式不同但概念一致virtual class Interface; pure virtual function void do_it(); endclass这里的 virtual class 是抽象类pure virtual 是纯虚方法声明。抽象类不能直接 new但可以声明句柄让它指向子类对象。这是 SystemVerilog 里模拟“接口契约”的基本方式UVM 里很多地方用这种模式做代码解耦。3.3 类型转换从 dynamic_cast 到 $castC 里向下转型用 dynamic_cast失败时指针得到 nullptr你可以安全判断。SystemVerilog 对应的是 $castDerived d; Base b; // b 指向一个 Derived 对象 if (!$cast(d, b)) uvm_fatal(CAST, type mismatch)$cast 作为函数调用时返回 1 表示成功0 表示失败。如果在语句里直接写$cast(d, b)不带返回值转型失败会直接报 error严重的话仿真停下来。我强烈建议在代码里一律用if (!$cast(...))包一层失败时显式处理这一点和 C 里检查 dynamic_cast 的返回值是同一个习惯。3.4 没有多重继承组合优先C 支持多重继承虽然容易玩出花但至少在语言层面允许一个类继承多个基类。SystemVerilog 不支持一个 class 只能 extends 一个父类。想实现类似“多接口”的效果一般用组合在一个类里持有另一个类的句柄把职责委托出去。同样C 设计模式里的策略模式、观察者模式在 SV 里也最好用接口类加组合来实现不要硬套多继承。4. 封装与代码组织访问控制、接口和包4.1 访问控制的差异比想象中更大SystemVerilog 支持 local 和 protected 两个访问修饰符local 接近 C 的 privateprotected 两个语言的含义也差不多。但有几个细节从 C 过来的人容易忽略。C 中 class 默认 privatestruct 默认 public。SystemVerilog 里 class 成员默认 public想限制访问要显式加修饰符没有 friend 这个机制C 里靠 friend 访问私有成员的写法在 SV 里行不通。再看 protected 的访问限制。C 里派生类可以通过基类指针或引用访问基类的 protected 成员吗其实不行但在当前派生类对象内部访问基类 protected 成员是可以的。SystemVerilog 里规则也类似子类访问基类 protected 成员时要通过自己的句柄访问不能通过基类句柄硬转。工程上更简单的建议是能封装成方法就封装成方法不要依赖跨类访问可见性。4.2 package 与 namespace组织代码的两种哲学C 用 namespace 组织代码SystemVerilog 用 package。表面上都起隔离作用实际使用差别不小。C 的 namespace 非常自由可以跨文件多次打开同一个 namespace可以嵌套。SystemVerilog 的 package 是编译单元里一个相对独立的块里面可以放 class、function、typedef、parameter 等然后通过 import 引入package my_pkg; class A; // ... endclass endpackage module tb; import my_pkg::A; A a; initial begin a new(); end endmoduleUVM 环境里基本一个组件一个 package包内部用反引号 include 把各个文件拼在一起。这个 include 的使用方式容易引发问题如果文件里写的东西不在预期的 package 作用域内编译顺序一变就可能出现符号找不到或者重复定义。我的经验是尽量保持 package 层级简单不要设计成 C 那种多层嵌套命名空间。4.3 SV 的 interface 和 OOP 的 interface 不是一回事这一点必须单独拿出来说因为软件背景的人听到 interface 第一反应是抽象接口SystemVerilog 里的 interface 却是一个硬件信号封装结构interface bus_if; logic clk; logic [31:0] addr; logic valid; endinterface它描述的是一组信号连接关系可以挂在 DUT 端口上同时通过 virtual interface 传给验证组件里的 class 对象。真正在 class 层面对应软件接口概念的是 virtual class 配合 pure virtual method。所以别一看 interface 就往纯虚类上想先看它出现在哪个上下文模块端口里的是硬件接口class 前面的 virtual class 才是抽象类。5. 构造、析构与内存管理C 靠 RAIISV 靠 GC5.1 new 的局限不能重载没有拷贝构造C 的构造函数体系非常丰富重载构造函数、拷贝构造函数、移动构造函数、委托构造。SystemVerilog 只有一个 new()而且没有拷贝构造函数。如果你写Transaction a new(1); Transaction b; b a; // 只是让 b 指向 a 指向的对象不是复制字段这里 b 和 a 是同一个对象的两个句柄改 b 的成员a 也会变。这在 C 里对应的是指针复制如果你写的是值类型对象Transaction b a;那是深拷贝。SV 里想复制对象内容得自己写 copy 函数逐个字段赋值或者用 UVM 提供的copy()和clone()机制。UVM 里这个操作极其常见。sequence item 在发起激励前通常要 clone 一份避免前后干扰。很多人从 C 转过来时下意识认为b a会复制内容结果 debug 半天最后发现两个句柄指向同一块内存这是非常典型的 SV 新手坑。5.2 没有析构函数RAII 不存在C 的析构函数和 RAII 是绑定在一起的作用域结束自动回收资源。SystemVerilog 没有析构函数对象的回收交给仿真器自带的垃圾回收具体回收时机是不确定的通常在没有句柄再引用它之后仿真器才会把它清掉。这意味着什么你在 C 里养的“作用域结束自动释放”的下意识动作在 SV 里完全不成立。举例来说如果你在类里 new 了一个 mailbox或者 fork 了一个进程即使这个类的对象已经不再被任何句柄引用mailbox 和进程资源也不会因为类对象被回收而自动清理需要手动调用 mailbox.delete()或者用 disable fork 结束进程。5.3 显式清理和内存膨胀SV 的 GC 机制比 C 的智能指针更不可控尤其是大型验证环境中对象经常被放进队列、关联数组、uvm_config_db 里只要有一个地方还持有句柄对象就不会释放。时间一长内存持续增长仿真速度越来越慢。我实际项目中遇到过这样的问题一个 sequence 在循环里不断创建新的 sequence_item每次发给 driver 后把 item 句柄存到一个队列里备用队列只增不减跑完几个百万周期的回归测试内存就涨了几 GB。排查后发现队列里的句柄从未清理。C 里你会想着析构或 resetSV 里你得自己记得在合适时机 delete 队列、置空句柄这是思维方式上的转换。6. 泛型与参数化类C 模板是重型武器SV 参数化类是轻量工具6.1 语法对照C 里写泛型类template typename T class Scoreboard { T ref_model; public: void set_ref(T r) { ref_model r; } };SystemVerilog 写参数化类class Scoreboard #(type T int); T ref_model; function void set_ref(T r); ref_model r; endfunction endclass实例化时的写法也不同。C 是ScoreboardMyRef sb;SV 是Scoreboard#(MyRef) sb new();。SV 支持类型参数也支持普通常量参数比如class FIFO #(type T int, int DEPTH 16)这一点和 C 的模板非类型参数类似。6.2 表达能力差距很大C 模板的能力已经被推到近乎图灵完备支持偏特化、特化、SFINAE、变参模板很多库通过模板做编译期计算。SV 参数化类不支持特化和偏特化也不能做模板模板参数本质上就是给类提供一个或几个“类型占位符”编译时替换成具体类型。这个能力用来写通用的 FIFO 模型、通用的比较器、通用的转换类完全够用但别指望把 C 的模板元编程技巧搬过来。6.3 UVM 里参数化类的实际用法UVM 里使用参数化类时通常配合宏来注册工厂比如class my_scoreboard #(type T int) extends uvm_scoreboard; uvm_component_param_utils(my_scoreboard#(T)) // ... endclass实例化时my_scoreboard#(my_transaction) scb; scb my_scoreboard#(my_transaction)::type_id::create(scb, this);宏展开的本质是生成了一个辅助类让参数化类能和 UVM 的 factory 机制兼容。这和 C 模板在编译期直接生成代码是两套逻辑。现实建议是参数化类适合“逻辑相同、类型不同”的复用场景但如果类型差异导致行为分支特别多不如直接写两个类别硬用一个参数化类里的 if-else 撑场面。7. 跨语言实践中的误区与我的建议7.1 误区一把 class 用在 RTL 设计里SystemVerilog 的 class 不可综合。很多学过软件的人刚接触 SV 时总觉得应该用 class 把模块封装得更好看实际上在可综合设计代码里用 class 只会产生一堆编译错误或综合失败。验证环境用 classRTL 设计用 module、interface、always、assign两者分工明确。7.2 误区二所有设计模式都往 SV 里搬C 的设计模式很多依赖析构、复制语义、模板特化这些机制SV 里没有对应能力。工厂模式因为 UVM 的 factory 机制天然支持所以很好用单例模式在 SV 里可以用 static 成员加 virtual class 模拟但其他依赖 RAII 或者深度模板技巧的模式普遍水土不服。拿 C 那套最复杂的抽象去写 SV最后代码量翻倍可读性还差。7.3 误区三不检查句柄是否为 nullC 里访问空指针是崩溃SV 里访问 null 句柄在仿真中会报 Null object access直接终止仿真但很多环境里这种错误被埋在海量日志里不容易发现。经验是从配置数据库取对象、从队列里取句柄或者接收来自 sequence 的 item 时先判断一下句柄是否为 null再继续操作。一个小判断能省掉大量查 log 的时间。7.4 建立两个语言之间的心智映射我的建议是不要死记语法建立概念映射表反而更有用常用对应关系大概是概念CSystemVerilog对象引用指针/智能指针句柄堆上创建new 返回指针new 返回句柄构造函数可重载支持初始化列表只有 new参数可带默认值析构函数有配合 RAII没有拷贝对象拷贝构造/赋值手写 copy() 或 UVM 的 clone()虚方法virtualvirtual抽象类纯虚函数 0virtual class pure virtual动态类型转换dynamic_cast$cast资源管理RAII垃圾回收需显式释放特殊资源泛型template支持特化/偏特化parameterized class轻量代码组织namespacepackage硬件接口抽象无直接对应interface virtual interface7.5 写 SV 时的几条实操经验第一new 完对象后先想一想这个对象会被谁长期持有如果它会被放进队列、关联数组或 uvm_config_db就必须设计对应的清空时机。第二基类方法只要可能被覆盖一律加 virtual别觉得当前只有一个子类就不加以后扩展时一定会有人踩坑。第三跨模块传递对象时优先用 virtual interface 和 factory 创建的句柄不要到处 new否则验证环境的层次关系会越来越乱。第四调试时善用$typename(handle)打印对象的动态类型比靠猜靠谱得多。最后说点个人体会。我刚从 C 转验证那阵子总想着把每一个类都设计得“很 C”每个对象都写深拷贝、写资源清理结果被同事 review 打回指出 SV 里很多 C 的习惯反而是多余且有害的。后来想明白一个道理SystemVerilog 的 OOP 不是 C 的下位替代也不是削弱版而是为仿真世界重新设计的一套工具集。它的垃圾回收、它的默认零初始化、它轻量级的参数化类都服务于验证环境对可预测性和快速迭代的诉求。理解了它为什么这样设计再回头看那些语法差异一切就顺理成章了。希望这篇比较能帮你少走点弯路。
返回列表