SystemVerilog 基础

第 11 章 · 共 16 章

面向对象进阶:继承、多态与虚方法

用 extends 建立继承关系,理解为什么不加 virtual 时多态并不会像你以为的那样生效,学会用 $cast 安全地把父类句柄转回子类句柄,以及这一切如何为下一个系列 UVM 的 factory 机制做铺垫。

继承、多态这些概念,你多半已经从别的语言里熟悉了。这一章直接对应到 SystemVerilog 的语法,并且重点讲清楚一个几乎所有新手都会踩的坑:SystemVerilog 里的方法默认不是虚函数(virtual),不加 virtual 关键字,多态并不会像你以为的那样工作

继承:extends

class packet;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
 
  function void print();
    $display("packet: addr=%0h data=%0h", addr, data);
  endfunction
endclass
 
class error_packet extends packet;
  string error_msg;
 
  function new(int addr = 0, int data = 0, string error_msg = "");
    super.new(addr, data);   // 调用父类的构造函数
    this.error_msg = error_msg;
  endfunction
 
  function void print();
    super.print();           // 复用父类已经写好的行为
    $display("  error: %s", error_msg);
  endfunction
endclass

error_packet extends packeterror_packet 继承 packet 的所有属性和方法,并新增了自己的 error_msg 字段。几个要点:

  • super.new(...):子类的构造函数必须(通常作为第一条语句)调用父类的构造函数,负责初始化从父类继承来的那部分属性。
  • super.方法名(...):在子类的方法里调用父类对应方法的原始实现,避免把父类逻辑重复写一遍。
  • 子类重新定义了 print()——这叫方法重写(override),子类的版本会替代父类的版本(至少表面上看是这样,下一节会看到真相没这么简单)。

多态:用父类句柄操作子类对象

因为 error_packetpacket,一个 packet 类型的句柄可以指向一个 error_packet 对象:

packet p;
error_packet ep = new(32'h1000, 32'hDEAD, "parity error");
 
p = ep;      // 合法:父类句柄可以指向子类对象
p.print();   // 这里调用的是哪个 print()?

如果你熟悉 Java,可能会理所当然地认为 p.print() 会调用 error_packetprint()(因为 p 实际指向的对象是 error_packet)——但在 SystemVerilog 里,上面这段代码实际调用的是 packetprint(),只会打印 packet: addr=1000 data=dead,完全看不到 error_msg

这是因为 SystemVerilog(和 C++ 一样)默认按句柄声明的类型(这里是 packet)来决定调用哪个方法,而不是按对象的实际类型error_packet)——这和 Java"所有方法默认都是虚函数"的行为完全不同,是很多人从其他语言转过来时最容易踩的坑。

virtual 方法:让多态真正生效

要让"根据实际对象类型调用对应方法"这个直觉真正成立,父类和子类里的方法都要声明成 virtual

class packet;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
 
  virtual function void print();
    $display("packet: addr=%0h data=%0h", addr, data);
  endfunction
endclass
 
class error_packet extends packet;
  string error_msg;
 
  function new(int addr = 0, int data = 0, string error_msg = "");
    super.new(addr, data);
    this.error_msg = error_msg;
  endfunction
 
  virtual function void print();
    super.print();
    $display("  error: %s", error_msg);
  endfunction
endclass

加上 virtual 之后,同样的调用方式:

packet p;
error_packet ep = new(32'h1000, 32'hDEAD, "parity error");
 
p = ep;
p.print();
// packet: addr=1000 data=dead
//   error: parity error

这次 p.print() 正确地调用了 error_packetprint()——因为方法调用会在运行时根据 p 实际指向的对象类型去查找,而不是按 p 声明的类型。经验法则:只要一个方法有可能被子类重写,就应该显式声明成 virtual——这和第 9 章"几乎总应该给 task/function 加 automatic"是同一种"显式好过隐式默认值"的建议。

为什么这对验证代码特别重要

想象你在写一段通用的验证代码,操作的是一个 packet 类型的句柄——但运行时,这个句柄实际指向的可能是一个特化过的子类(比如带错误注入的 error_packet,或者某个具体协议的 packet)。virtual 方法让同一段通用代码能自动调用到每个子类各自特化过的行为,完全不需要提前知道有哪些子类存在。

这正是下一个系列 UVM 的核心机制之一——factory——能够工作的基础:factory 允许你在不修改 testbench 结构代码的前提下,把某个组件替换成它的子类实现;而这一切之所以"替换后行为也跟着变",本质上就是这一节讲的虚方法多态在起作用。

组合起来:一个多态队列

把第 6 章的队列和这一章的继承、多态放在一起——用一个 packet 类型的队列,存放不同的子类对象,遍历时自动调用各自正确的 print()

packet pkts[$];
packet       p;
error_packet ep;
 
p = new(32'h1000, 32'hAAAA);
pkts.push_back(p);
 
ep = new(32'h2000, 32'hBBBB, "timeout");
pkts.push_back(ep);
 
foreach (pkts[i]) begin
  pkts[i].print();  // 多态:每个元素都调用它实际类型对应的 print()
end
// packet: addr=1000 data=aaaa
// packet: addr=2000 data=bbbb
//   error: timeout

队列本身只知道"这是一堆 packet",但因为 print()virtual,实际打印出来的内容会根据每个元素真正的对象类型而不同。

$cast:把父类句柄转回子类句柄

前面 p = ep; 这种"子类对象赋给父类句柄"的操作叫向上转型(upcast)——因为 error_packet "是一个" packet,这个赋值永远安全,SystemVerilog 允许隐式完成。反过来,向下转型(downcast)——把一个父类句柄转回某个具体子类句柄——就没这么理所当然了:句柄声明成 packet 类型,但它实际指向的对象到底是不是 error_packet,编译期根本无法确定,直接赋值会编译报错:

error_packet ep2;
ep2 = p;   // 编译错误:不能把 packet 句柄直接赋给 error_packet 句柄

这时候需要 $cast:一个在运行时检查"这次转型到底安不安全"的系统任务,成功就完成转型并返回 1,失败就保持目标句柄不变并返回 0(而不是直接崩溃):

packet p;
error_packet ep, ep2;
 
ep = new(32'h1000, 32'hDEAD, "parity error");
p  = ep;   // 向上转型:始终安全,隐式完成
 
if ($cast(ep2, p)) begin
  $display("cast succeeded, ep2 now points to the same error_packet object");
  ep2.print();
end else begin
  $error("cast failed: p does not actually point to an error_packet object");
end

$cast(ep2, p) 尝试把 p 实际指向的对象转型成 ep2 声明的类型:如果 p 确实指向一个 error_packet(或它的子类)对象,转型成功,ep2 也指向同一个对象,返回 1;如果 p 实际指向的是别的类型(比如一个不带错误信息的普通 packet 对象),转型失败,ep2 保持不变,返回 0——用 if 包一层就能安全地检查转型是否成功,而不是让程序直接出错。

$cast 在 UVM 里几乎无处不在:factory 创建组件时返回的是基类句柄,具体测试代码经常需要把它转型回自己关心的那个具体子类,才能访问子类新增的属性和方法——这正是本章开头"为什么这对验证代码特别重要"里提到的那套机制的另一半:向上转型让通用代码能操作任意子类对象,$cast 则是在需要的时候,安全地把句柄转回具体类型。

小结

  • extends 建立继承关系;子类构造函数需要用 super.new(...) 调用父类构造函数;super.方法名(...) 可以复用父类的方法实现。
  • 方法默认不是虚函数:不加 virtual 时,通过父类句柄调用方法,实际执行的是句柄声明类型对应的版本,而不是对象实际类型对应的版本——这和 Java 的默认行为不同。
  • 给方法加上 virtual,才能让"通过父类句柄调用方法,实际执行子类重写的版本"这种多态行为真正生效。
  • 经验法则:任何可能被子类重写的方法都应该显式声明成 virtual
  • 这正是 UVM factory 机制得以工作的基础——通用代码操作基类句柄,运行时自动执行到具体子类的行为。
  • 子类对象赋给父类句柄(向上转型)永远安全、隐式完成;反过来把父类句柄转回子类句柄(向下转型)需要用 $cast,它在运行时检查转型是否安全,成功返回 1、失败返回 0 而不是让句柄状态出错。

packet p = ep;(ep 是一个 error_packet 对象,print() 方法没有加 virtual)之后调用 p.print(),实际执行的是哪个版本?

把 packet 和 error_packet 里的 print() 都声明为 virtual 之后,再执行 packet p = ep; p.print();,会调用哪个版本?

在子类的构造函数里,用哪个关键字调用父类的构造函数(如 xxx.new(...))?(英文小写)

为什么 error_packet ep2 = p;(p 是 packet 类型的句柄)不能直接编译通过,需要改用 $cast(ep2, p)?