SystemVerilog 基础

第 10 章 · 共 16 章

面向对象入门:SystemVerilog 中的类与对象

把你已经熟悉的类和对象概念对应到 SystemVerilog 的语法上,重点弄清楚一个和 struct 完全不同的关键点:对象是句柄(引用),不是值;再学会写 copy() 方法、用 local/protected 限制访问范围、用 static 成员跨对象共享数据,以及用 #(type T) 参数化类。

第 1 章说过,SystemVerilog 是 Verilog 加上"一整套面向验证的语言能力",这一章正式踏入这一半——类(class)。如果你写过 Python、Java、C++ 或者任何一门面向对象语言,类、对象、方法这些概念对你并不陌生;这一章不会从头讲"什么是面向对象",而是直接对应到 SystemVerilog 的具体语法,并重点讲清楚一个新手很容易踩的坑:类的对象是句柄(handle),不是像 struct 那样的值

这一章要搭的东西,将来会用在哪

接下来这一章到第 12 章(类、继承与多态、随机化),讲的都是纯软件、不碰任何硬件信号的内容——读的时候很容易觉得"这还是在学 SystemVerilog 吗,怎么像是在学另一门面向对象语言"。提前看一眼一个经典 testbench 的组成,能帮你把这些零件安进正确的位置:

生成器驱动器DUT监测器记分板(class)(class)(硬件)(class)(class)第 10–12 章搭的类、继承、随机化,就是用来实现这些方框里的软件组件

除了 DUT,图里每一个方框都会是一个(或一组协作的)class 对象:生成器负责产生激励、驱动器负责把激励真正加到 DUT 的信号上、监测器负责观察 DUT 的输出、记分板负责判断输出对不对。这一章的 class 语法、下一章的继承与多态、再下一章的约束随机化,都是在为搭出这几个方框做准备——完整的方框图和一个真正跑起来的例子要到本系列最后一章才会出现,但提前知道"这些零件最终会拼成什么",能让接下来几章读起来更有方向感。

为什么验证代码需要类:比 struct 更进一步

第 5 章的 struct 能把相关的数据字段打包在一起,但 struct 只能装数据,装不下行为(方法)。验证代码里经常需要的"事务"(transaction)——比如一次总线传输——不只是几个字段的组合,往往还需要"打印自己"“随机化自己”“和另一个事务比较"这类行为。把数据(属性)和行为(方法)绑定在一起,这正是验证代码大量使用面向对象的原因。

声明一个类:属性与方法

class transaction;
  int addr;
  int data;
 
  function void print();
    $display("addr=%0h data=%0h", addr, data);
  endfunction
endclass

addrdata 是这个类的属性(property),print() 是它的方法(method)——语法上和之前章节里声明变量、声明函数几乎一样,只是都写在了 class...endclass 内部。

对象是"句柄",这和 struct 不一样

这是本章最重要的一点。声明一个类类型的变量,并不会自动创建一个对象——它只是声明了一个"句柄"(handle),初始值是 null,此时并没有真正的对象存在:

transaction tr;   // 只是声明了一个句柄,此时 tr 是 null,还没有对象
tr = new();       // 调用构造函数,真正创建一个对象,tr 指向它
 
tr.addr = 32'h1000;
tr.data = 32'hDEAD_BEEF;
tr.print();

必须显式调用 new() 才会真正分配一个对象,tr 这个句柄才会指向它。这一点和第 5 章的 struct 完全不同——声明一个 struct 变量会立刻拥有存储空间,可以直接赋值;声明一个类变量得到的只是一个"还没指向任何东西"的句柄。

更关键的是赋值语义:把一个句柄赋值给另一个句柄,并不会复制对象,而是让两个句柄指向同一个对象

transaction tr1, tr2;
tr1 = new();
tr1.addr = 100;
 
tr2 = tr1;         // tr2 和 tr1 现在指向同一个对象,不是复制!
tr2.addr = 200;
 
$display("tr1.addr = %0d", tr1.addr);  // 200 —— 被 tr2 的修改“连带”影响了

tr2 = tr1; 之后,tr1tr2 是同一个对象的两个名字——通过 tr2 改属性,tr1 看到的也是改过的值。如果你需要的是"复制一份独立的对象",需要显式拷贝字段(或者自己写一个 copy() 方法),单纯的赋值只是让两个句柄指向同一份数据,这一点会在下一章讨论继承时再展开。

想要真正的副本:自己写一个 copy() 方法

既然赋值不会复制对象,"复制一份独立对象"就得显式地把每个字段抄一遍,通常包装成一个 copy() 方法,返回一个新对象:

class transaction;
  int addr;
  int data;
 
  function transaction copy();
    transaction t = new();
    t.addr = this.addr;
    t.data = this.data;
    return t;
  endfunction
endclass
 
transaction tr1 = new();
tr1.addr = 100;
 
transaction tr2 = tr1.copy();  // tr2 是一个独立的新对象,字段值相同
tr2.addr = 200;
 
$display("tr1.addr = %0d", tr1.addr);  // 100 —— 不受 tr2 修改影响

copy() 内部用 new() 创建一个全新对象,把每个字段的值分别抄过去,再返回这个新对象——tr1tr2 之后是两个完全独立的对象,互不影响。字段不多的时候手写 copy() 很直接;UVM 里的 uvm_object::copy()/clone() 就是这个思路的标准化版本。

构造函数:new()

如果类里没有显式定义 new(),SystemVerilog 会提供一个默认的构造函数,把每个属性初始化成它类型的默认值(回忆第 5 章:4 态类型默认是 x,2 态类型默认是 0)。你也可以自己定义 new(),在创建对象的同时完成初始化:

class transaction;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
endclass
 
initial begin
  transaction tr = new(32'h2000, 32'hCAFE_BABE);
  tr.print();
end

注意 this.addr = addr; 里的 this 关键字——当构造函数的参数名(addr)和属性名(addr)相同时,单写 addr 在函数体内指的是参数,需要用 this.addr 才能明确指的是"当前对象的 addr 属性"。this 就是"当前对象"的意思,在任何方法内部都可以使用。

localprotected:限制属性/方法的访问范围

到目前为止,transaction/packet 的属性都能被类外部直接读写(比如 tr.addr = 100;)。真实的验证代码经常希望某些内部状态不被外部随意修改,这时候可以用访问修饰符限制可见范围:

class packet;
  local     int checksum;  // 只有 packet 类自己的方法能访问
  protected int addr;      // packet 类自己,以及它的子类都能访问
 
  function new(int addr);
    this.addr     = addr;
    this.checksum = addr ^ 32'hFFFF_FFFF;  // 内部推导出来的校验值
  endfunction
endclass
  • local:只有这个类自己的方法能访问,连子类都不行——外部代码里写 p.checksum 会直接编译报错。
  • protected:这个类自己和它的子类都能访问,但类外部的代码不行——下一章讲继承时会用到这个区别。
  • 不加任何修饰符(前面例子里的 addr/data)默认是 public:类内部、子类、类外部都能自由访问。

和很多面向对象语言一样,这是"封装"(encapsulation)的具体手段:把不希望外部直接改动的状态标成 local/protected,只通过方法暴露该暴露的部分,减少外部代码意外破坏内部状态一致性的风险。

static 成员:跨对象共享的属性

static 属性不属于某一个对象,而是同一个类的所有对象共享同一份存储——常见场景是给每个新建的对象自动分配一个递增的 ID:

class packet;
  static int next_id = 0;   // 属于类本身,所有 packet 对象共享
  int id;
 
  function new();
    id = next_id;
    next_id++;
  endfunction
endclass
 
initial begin
  packet p0 = new();
  packet p1 = new();
  packet p2 = new();
  $display("p0.id=%0d p1.id=%0d p2.id=%0d", p0.id, p1.id, p2.id);  // 0 1 2
end

next_id 只有一份存储,每 new() 一个对象就自增一次,天然适合做"给每个事务分配唯一序号"这类需求。static 方法(用 static 修饰的 function/task)也可以在没有任何对象的情况下调用,通常用来操作 static 属性。

类是验证专属:不能被综合

呼应第 1 章的核心心智模型:class 是纯粹的验证语言特性,不可综合,只存在于 testbench 代码里,永远不会变成任何硬件电路。这也是为什么本章之后(面向对象、随机化、断言等)的内容都属于第 1 章说的"验证的那一半"语言能力。

把前面章节的 struct 升级成类

回忆第 6 章末尾那个 pkt_t struct——它只有数据,没有行为。现在用类重写一遍,顺便加上一个 print() 方法:

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
 
initial begin
  packet p = new(32'h3000, 32'hFEED_FACE);
  p.print();  // packet: addr=3000 data=feedface
end

同样是"打包相关字段",packet 类比 pkt_t struct 多了行为,也多了两个必须记住的语义差异:需要 new() 才能创建对象,赋值是共享句柄而不是复制数据。

类的参数化:#(type T)

第 2 章讲过用 parameter/#(...)模块在例化时可以配置宽度、深度这类编译期常量;也可以用类似的语法参数化,只是参数通常是一个类型,用来表示"这个类可以装什么类型的数据":

class fifo_model #(type T = int);
  T queue[$];
 
  function void push(T item);
    queue.push_back(item);
  endfunction
endclass
 
fifo_model #(int)    int_fifo    = new();
fifo_model #(string) string_fifo = new();

#(type T = int) 声明了一个类型参数 T,默认是 intfifo_model #(int) 例化出的对象,queue/push() 处理的都是 int 类型的数据,fifo_model #(string) 则处理 string。这正是后面章节里 mailbox #(int) 这种写法的含义——mailbox 本身就是 SystemVerilog 内置的一个参数化类,#(int) 把它的类型参数指定为 int,表示这个 mailbox 只能传递 int 类型的数据。

小结

  • 类把数据(属性)和行为(方法)绑定在一起,是 struct 的进一步扩展。
  • 声明一个类变量只是声明了一个句柄,初始值为 null;必须调用 new() 才会真正创建对象。
  • 句柄赋值不会复制对象——两个句柄会指向同一个对象,通过任意一个句柄修改属性,另一个句柄看到的也是修改后的值。想要独立副本,需要显式写一个 copy() 方法逐个字段复制。
  • 没有显式定义 new() 时,SystemVerilog 提供默认构造函数,把属性初始化为各自类型的默认值;也可以自定义 new() 在创建时完成初始化。
  • this 指代当前对象,常用于构造函数参数名和属性名相同的场景。
  • local 限制成员只能被本类访问,protected 额外允许子类访问,不加修饰符默认是 public
  • static 属性/方法属于类本身,被所有对象共享,常用于跨对象计数(比如自动分配的 ID)。
  • class 是纯验证语言特性,不可综合。
  • 类也能用 #(type T) 参数化,表示"这个类可以装什么类型的数据",mailbox #(int) 就是这个机制的实例。

执行完 transaction tr; 这一行(还没有调用 new())之后,tr 处于什么状态?

执行 tr2 = tr1;(tr1、tr2 都是同一个类的句柄,tr1 已经指向一个对象)之后,再修改 tr2 的某个属性,会发生什么?

在方法内部,用哪个关键字表示“当前对象”,常用于区分同名的参数和属性?(英文小写)

想要得到一个字段值相同、但完全独立于原对象的新对象,应该怎么做?

关于 local 和 protected 访问修饰符,以下哪种说法正确?

一个类里有 static int next_id = 0; 这样一个属性,每次 new() 一个新对象时都执行 id = next_id++;。这个 static 属性的行为是什么?

fifo_model #(int) int_fifo = new(); 这行代码里,#(int) 的作用是把类声明里的哪种参数指定为 int?(英文单词,全小写)