三、面向对象编程基础:类与对象、构造函数、封装、继承、多态
好,咱们进入验证工程师最核心的武器——面向对象编程(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() 就访问成员,仿真器会给你一个空指针错误。嗯,这个错误我至少犯了十次。
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(),结果父类的初始化代码没执行,地址一直为零。排查了整整一个下午。
super.new(),否则父类的初始化逻辑会被跳过。
3.3 封装:把秘密藏起来
封装,说白了就是“不该看的不让你看”。我们用 local 和 protected 关键字来控制访问权限。
- 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_item 和 uvm_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 都调用了基类的驱动方法,仿真结果全错。
3.6 实战中的组合使用
在实际验证环境中,这三个特性经常一起用。比如:
- 用 封装 把 monitor 的内部状态藏起来,只暴露
get_data()方法。 - 用 继承 创建不同类型的 sequence,共享基类的随机化代码。
- 用 多态 让 scoreboard 通过基类句柄处理所有类型的事务。
我曾经重构过一个项目,把原来 3000 行的 monolithic 代码拆成了 5 个类,用了继承和多态。结果呢?新增一个测试用例只需要写 50 行代码,而且 bug 率下降了 70%。
嗯,面向对象不是银弹,但它是验证工程师的必修课。你想想看,如果没有 OOP,你怎么管理几百个测试用例?怎么复用代码?怎么让团队协作?
下一章,咱们会深入随机化与约束,那是 SystemVerilog 验证的另一个杀手锏。