三、面向对象编程基础:类与对象、构造函数、封装、继承、多态

好,咱们进入验证工程师最核心的武器——面向对象编程(OOP)。

说实话,我刚从C语言转过来时,觉得“类”这东西太抽象了。但后来发现,没有它,写验证环境简直是在自虐。你想想看,一个测试用例要控制几百个信号,如果全用变量堆在一起,改一个地方得翻遍整个文件。

面向对象就是帮我们把“数据”和“操作数据的方法”打包在一起。就像你手里有个遥控器,按一个键,电视就换台。你不用关心遥控器内部怎么编码红外信号,对吧?

3.1 类与对象:蓝图与实例

类(class) 就是一张蓝图。它定义了数据长什么样,以及能做什么操作。对象(object) 是根据这张蓝图造出来的具体东西。

举个例子:

class packet;
    bit [31:0] addr;
    bit [7:0]  data;
    bit        valid;

    function void display();
        $display("addr=%h, data=%h, valid=%b", addr, data, valid);
    endfunction
endclass

packet pkt;          // 声明一个句柄(类似指针)
pkt = new();         // 创建对象(分配内存)
pkt.addr = 32'hA5;   // 访问成员变量
pkt.display();       // 调用成员方法

这里有个坑,我刚开始总搞混:packet pkt; 只是声明了一个“遥控器”,new() 才是把电池装进去。没有 new() 就访问成员,仿真器会给你一个空指针错误。嗯,这个错误我至少犯了十次。

小技巧: 我习惯在声明句柄的同时就 new(),比如 packet pkt = new();,省得后面忘了。

3.2 构造函数:new() 的秘密

每个类都有一个默认的构造函数 new()。它的任务就是给对象分配内存,顺便把变量初始化成默认值(比如 bit 类型是 0,string 是 "")。

但很多时候,我们需要自定义构造函数。比如,我想在创建 packet 时就指定地址:

class packet;
    bit [31:0] addr;
    bit [7:0]  data;

    function new(bit [31:0] a = 32'h0);
        addr = a;
        data = 8'hFF;   // 默认数据
        $display("packet created with addr=%h", addr);
    endfunction
endclass

packet pkt1 = new(32'hA5);   // addr = 0xA5
packet pkt2 = new();         // addr = 0x0

注意,SystemVerilog 的构造函数不能有返回值,也不能被继承。我曾经在项目中遇到一个 bug:子类忘了调用 super.new(),结果父类的初始化代码没执行,地址一直为零。排查了整整一个下午。

避坑指南: 如果你在子类中重写了 new(),一定要在第一行调用 super.new(),否则父类的初始化逻辑会被跳过。

3.3 封装:把秘密藏起来

封装,说白了就是“不该看的不让你看”。我们用 localprotected 关键字来控制访问权限。

  • local:只有本类能访问,子类也不行。
  • protected:本类和子类能访问,外部不行。
  • 默认(public):谁都能访问。

举个例子:

class fifo;
    local bit [7:0] buffer [256];   // 外部不能直接读写
    protected int   wr_ptr;         // 子类可以访问

    function void write(bit [7:0] d);
        buffer[wr_ptr] = d;
        wr_ptr++;
    endfunction
endclass

为什么需要封装?我在项目中见过一个同事,直接修改了 FIFO 的 wr_ptr,导致读写指针错乱,整个验证环境崩溃。封装就是逼着大家走正规方法,别乱翻内部变量。

3.4 继承:站在巨人的肩膀上

继承允许你创建一个新类,它自动拥有父类的所有成员和方法。你只需要添加或修改差异部分。

class packet;
    bit [31:0] addr;
    function void display();
        $display("addr=%h", addr);
    endfunction
endclass

class bad_packet extends packet;
    bit error_flag;   // 新增成员

    function void display();   // 重写方法
        $display("addr=%h, error=%b", addr, error_flag);
    endfunction
endclass

这里有个关键点:子类对象可以赋值给父类句柄,但反过来不行。这叫“向上转型”。

packet     p;
bad_packet bp = new();
p = bp;              // 合法,子类句柄赋给父类
// bp = p;           // 非法,父类句柄不能赋给子类

我记得第一次写 UVM 时,看到 uvm_sequence_itemuvm_monitor 的继承关系,觉得好复杂。后来发现,其实就是把公共的代码抽到父类里,子类只写自己特有的部分。代码量直接减少一半。

3.5 多态:同一个接口,不同的行为

多态是 OOP 最酷的特性。它允许你通过父类句柄调用子类的方法,而且调用的是子类重写后的版本。

class base;
    virtual function void do_something();
        $display("base version");
    endfunction
endclass

class ext extends base;
    function void do_something();
        $display("ext version");
    endfunction
endclass

base b;
ext  e = new();
b = e;
b.do_something();   // 输出 "ext version"

注意,这里 do_something() 必须声明为 virtual。如果不加 virtual,SystemVerilog 会调用父类的方法,而不是子类的。这个细节我吃过亏——有一次写 driver,忘了加 virtual,结果所有 sequence 都调用了基类的驱动方法,仿真结果全错。

核心要点: 多态依赖于虚方法(virtual function)。没有 virtual,就没有多态。在 UVM 中,几乎所有回调方法都是 virtual 的,就是为了让你能重写它们。

3.6 实战中的组合使用

在实际验证环境中,这三个特性经常一起用。比如:

  1. 封装 把 monitor 的内部状态藏起来,只暴露 get_data() 方法。
  2. 继承 创建不同类型的 sequence,共享基类的随机化代码。
  3. 多态 让 scoreboard 通过基类句柄处理所有类型的事务。

我曾经重构过一个项目,把原来 3000 行的 monolithic 代码拆成了 5 个类,用了继承和多态。结果呢?新增一个测试用例只需要写 50 行代码,而且 bug 率下降了 70%。

嗯,面向对象不是银弹,但它是验证工程师的必修课。你想想看,如果没有 OOP,你怎么管理几百个测试用例?怎么复用代码?怎么让团队协作?

下一章,咱们会深入随机化与约束,那是 SystemVerilog 验证的另一个杀手锏。