类和对象概述
类和对象
-
面向对象的编程语言更符号人对自然语言的理解(属性property和功能function)。
-
这个世界由无数的类(class)和对象(object)构成的。 类是将相同的个体抽象出来的描述方式, 对象是实体,其具备有独立行为能力 。
-
具有相同属性和功能的对象属于同一类,而不同的类之间可能有关系(继承关系)或没有关系。
-
在C语言中,编程基于过程方法(function);在verilog中,提供了笨拙的“类对象编程”可能性,也就是在module中定义方法(function/task/always/initial),而后调用module实例中的方法。
-
verilog中module+method的方式与sv中class定义有本质的区别,即面向对象的三要素: 封装(encapsulation)、继承(inheritance)、多态(polymorphism) 。本节只阐述类的封装和继承,多态在后续高级章节介绍。
-
类的定义 核心是属性声明和方法定义 ,所以类 既可以保存数据,也可以处理数据 。这与struct结构体的重要区别就是,结构体只是数据的集合,而类不仅保存数据,还可以进行数据处理。
为了直观说明类的属性和方法,举例如下:
- 类名: 鸟
- 属性: 翅膀:有,羽毛:有
- 方法: 飞
例化一个类的过程,就是创建类的一个实例,类的实例就是对象 。比如家里养了一只喜鹊(两只的话就是两个对象),这就是对象,它属于鸟类,满足鸟类的属性,具有鸟类的方法,同时也可以拥有自己的属性,如下:
- 实例名: 喜鹊1
- 属性: 翅膀:有,羽毛:有,颜色:黑白,体重:700g ……
- 方法: 飞
验证为什么需要OOP
验证环境的各个组件具有以下特点:
- 验证环境中不同组件其功能和所需处理的数据内容是不同的 。
- 验证环境中同一类组件其所具备的功能和数据内容相似 。
所以,基于以上两点,验证环境中各个组件角色明确、功能独立,使用面向对象编程与验证环境的构建原则十分吻合。
第一个transaction事务类:
class transaction;
bit [31:0] addr, crc, data[8];
function void display;
$display("transaction: %h", addr);
endfunction
endclass
可见class的定义和module的定义类似,不过module和class完全不同:
- module属于硬件域,class属于软件域 。
- module内的变量是静态的,代表硬件电路,class内的变量是动态的,代表软件行为 。
- class内可以并且建议定义软件变量(bit等),而module只能定义硬件变量(reg、wire等)。
- class例化后称为对象 ,module例化后称为实例 。
- class内不能出现initial和always。
OOP的重要概念
- class类: 基本模块包含成员变量(属性)和方法。module也可以存在变量和方法,不过属于硬件域。
- object对象: 类的实例。module也可以例化,属于硬件域。
- Handle句柄(指针): 用来指向对象的指针。module通过层级索引找到设计的实例。
- property属性(变量): 在类中声明的存储数据的变量。在module中就是reg和wire。
- method方法: 在类中可以使用function/task来定义方法,在module中定义function/task,也可以定义always/initial。
创建对象
再次强调v module和sv class的区别 :
-
两者共同点在于均使用“模板”来创建内存实例。
-
不同点在于v module例化是 静态 的,编译时完成,而sv class例化是 动态 的,可以任意时间点发生,这也使得类的例化方式更加灵活和节省空间。
-
v module中没有句柄概念,而是通过 层级索引 方式找到实例(A.B.C.sig1),而sv class通过 句柄 使操作更加灵活。
-
创建对象时,需要清楚什么是声明,什么是创建(例化) :
- 声明: transaction trans;
- 创建: trans = new();
-
创建对象时创建了什么? 开辟了新的内存空间,用来存放对象的成员变量和方法。
-
创建对象时可以通过自定义构建函数来完成变量的初识化和其他操作。
class transaction;
bit [31:0] addr, crc, data[8];
function new();
addr = 3;
foreach(data[i])
data[i] = 5;
endfunction
endclass
白话一刻
这是一个简单的SystemVerilog类定义,描述了一个名为transaction的类。SystemVerilog是一种硬件描述和验证语言,广泛用于电子设计自动化(EDA)领域。下面是代码各部分的解释:
类定义:
class transaction;
这一行定义了一个名为transaction的类。
成员变量:
bit [31:0] addr, crc, data[8];
这里定义了三个成员变量:
- addr:一个32位宽的位向量(bit [31:0]),用于存储地址。
- crc:同样是一个32位宽的位向量,通常用于存储循环冗余校验码。
- data:一个8元素的数组,每个元素都是一个32位宽的位向量。这个数组可能用于存储交易数据。
构造函数
function new();
addr = 3;
foreach(data[i])
data[i] = 5;
endfunction
这是transaction类的构造函数,它用于初始化类的实例。
- addr = 3;:将addr初始化为3。
- foreach(data[i]) data[i] = 5;:这是一个循环,用于遍历data数组的每个元素,并将每个元素的值设置为5。
结束类定义
endclass
- 构建函数new() 系统预定义的函数,不需要指定返回值, 函数会隐式的返回例化后的对象指针 ,所以并不是没有返回值, 不能加void 。
- new函数也可以 定义多个参数 作为初始化时外部传入数值的手段。
class transaction;
bit [31:0] addr, crc, data[8];
function new(bit[31:0] a=3, d=5);
addr = a;
foreach(data[i])
data[i] = d;
endfunction
endclass
initial begin
transaction trans; //声明
trans = new(10, 20); //带初始化数据的创建
end
白话一刻
initial begin
transaction trans; //声明
trans = new(10, 20); //带初始化数据的创建
end
这部分是SystemVerilog的测试台或模拟环境中的一个initial块,它用于执行初始化代码。
- transaction trans;:声明了一个transaction类型的变量trans。此时trans还没有被实例化。
- trans = new(10, 20);:调用transaction类的构造函数,传递两个参数10和20来创建trans的一个实例。这里,addr将被初始化为10,data数组的每个元素将被初始化为20。
句柄的传递
区分了 类(抽象)和对象(具体) 之后,还需要区分 对象(存储空间)和句柄(对象指针) 。也就是说,在创建了对象之后,该对象的存储空间位置不会变,而指向该空间的句柄可以有多个。
transaction t1, t2; //声明句柄 t1和t2(此时句柄悬空,无指向对象)
t1 = new(); //例化对象,将其句柄赋予t1
t2 = trans1; //将t1的值赋予t2,也就是句柄t1和t2指向同一对象
t1 =new(); //例化第二个对象,并将其句柄赋予t1
首先两个new就代表创建了两个对象,最终t1指向第二个对象,t2指向了第一个对象。
对象的销毁
- 软件编程的灵活在于 动态的分配内存空间 ,在资源闲置时可以回收空间。
- C++语言中的类除了有构建函数,还有析构函数, 析构函数的作用就是手动释放空间 ,这对编程人员的细心和经验提出了要求;而Java和Python等后续的OOP语言不再需要手动定义析构函数,而是自动回收释放空间。
- sv也采用自动回收空间的处理方式。回收原则: 当一个对象在整个程序中没有任何地方再需要它时,便会被销毁,也即是回收空间。不需要的意思就是没有句柄指向该对象 。
class word;
byte nb[]; //声明动态数组
function new(int n);
nb = new[n]; //创建动态数组(创建类的对象是使用"new()")
endfunction
endclass
initial begin : initial_1
word wd;
for(int i=1; i<=4; i++) wd = new(i);
end
initial begin : initial_2
#1p3
$display("How many Bytes are allocated for word instances??");
end
根据以上代码,假设wd=new(1)需要分配1Byte空间,那么在initial_2中当打印语句时,需要为例化开辟多少空间呢?
答案是4Byte,原因是wd是静态的;如果将wd声明改为“automatic word wd;”,答案就是0Byte,原因是wd是动态的,且#0ps时刻被创建,而#1ps打印时,wd变量已经消失,空间被回收了。
句柄的使用
- 句柄可以用来创建多个对象,也可以前后指向不同对象。
- 可以使用句柄来调用对象中的成员变量或者成员方法,如下:
transaction trans; //声明句柄
trans = new(); //例化对象
trans.addr = 32'd10; // 为对象的成员变量赋值
trans.display(); //调用对象的成员方法
静态变量
- 与硬件域不同的是,class中声明的变量默认是动态的,其生命周期在仿真中的某一时间点,也就是对象的创建到对象的销毁。
- 如果使用 关键字static 来声明class内的变量,则其为静态变量。静态变量的生命周期 贯穿整个仿真阶段 。
- 如果类中声明了静态变量,那么可以 直接引用该变量class::var ,或者通过例化对象引用object.var。类中静态变量声明后,无论例化多少个对象,只可以共享一个同名的静态变量,因此 类的静态变量的使用可以打通各个对象,但是要注意共享资源的保护,换句话说,任何地方修改静态变量,大家都会看到它的修改 。
静态方法
- 类似与静态变量,在class中定义的方法默认为动态的,我们可以通过 static关键字将其声明为静态方法 。
- 静态方法内可以声明并使用动态变量,但是 不能使用类的动态变量 。原因是在调用静态方法时,可能还没有创建具体的对象,对应的动态变量也就没有被创建,这时候是无法使用类的动态变量,编译时就会报错;静态方法中可以使用类的静态变量,因为静态变量和静态方法一样,编译时就创建了。
类的成员
写在前面
- 类是成员变量和成员方法的载体,这些成员可以完成 保存数据和处理数据的功能 ,并且类的变量和方法应该遵循“聚拢”原则,也就是一个类的功能要尽可能单一,做好专职工作。
- 类作为载体,天生具备了闭合属性,也就是将其属性(变量)和方法封装在类的内部,不会直接暴露给外部,并且 可以通过protected和local的关键词,设置变量和方法的外部访问权限 。
- 如果没有指明访问类型,成员 默认为public (public并不是关键字),意味着子类和外部都可以访问。
- 如果指明访问类型为 protecd ,那么 只有该类及其子类可以访问,外部无法访问 。
- 如果指明访问类型为 local ,那么 只有该类可以访问,子类和外部无法访问 。
- 访问类型的设定是为了更好的封装类,尤其是发布供他人使用的软件包,如果验证环境应用范围较窄,可以使用默认的public访问类型,方便类的外部更好的使用变量和方法。
定义和调用成员方法
class transaction1;
bit [31:0] addr, data[8];
// ......
function void display();
$display("dispaly transaction1");
endfunction
endclass
class transaction2;
bit [31:0] addr, data[8];
// ......
function void display();
$display("dispaly transaction2");
endfunction
endclass
transaction1 t1; //声明句柄t1
transaction2 t2; //声明句柄t2
initial begin
t1 = new(); //创建对象
t1.display(); //调用transaction1::display()
t2 = new(); //创建对象
t2.display(); //调用transaction2::display()
end
类的封装
类和结构体的异同 :
- 二者本身都可以定义数据成员。
- 类变量在声明后,需要构造才会创建对象实体,而struct在变量声明时已经开辟内存。(有时候类没有new函数,也并不会出错,并不是不需要new,而是系统自动调用了new函数)
- 类除了可以声明数据变量,还可以声明方法,而struct不能。换句话说,struct就是个数据结构,而class包含了数据成员以及对数据成员处理的方法。
类与module的异同 :
- 从数据和方法来看,两者都可以作为封闭容器来定义和存储。
- 从例化来看,module必须在仿真开始时就确定是否要例化,而类可以在仿真的任意时刻被例化。换句话说,module是硬件域,静态的,class是软件域,动态的。
- 从封装性来看,module内的变量和方法是对外部公共开发的,而类可以定义为公共的、受保护的和私有的。
- 从继承性来看,module没有继承性可言,也就是无法在原有的module的基础上进行module的功能扩展,而继承性是类的一大特点。
思考
- 可以在哪里定义类? 答案:module、interface、program和package,也就是所有“盒子”。
- 可以在类中再声明类成员吗? 答案:可以,类也是一种数据载体。
- What is this ? 答案:如果在类中使用 this ,即表明this.X所调用的成员是当前类的成员,而非同名的局部变量或者形式参数等。
function new(string name);
this.name = name; //将参数传递的name赋值给当前类的name变量
endfunction
类有编译顺序吗? 答案:有,建议的编译顺序是先编译基类,再编译高级类,或者说先编译将被引用的类,再编译引用 之前已经编译过的类 的类,其实就是个依赖关系。
类的继承
写在前面
- 继承也符合我们的认识世界的观点,我们对世界的认识无外乎归纳法和演绎法。
- 归纳法就是从个别特别到一般属性的方法,从具体对象中抽象出类的属性和方法,这就是定义类的思维方式。
- “白猫黑猫都是猫,抓住老鼠就是好猫”,这里白猫黑猫都继承于猫类,他们有一个属性是颜色,另一个属性是好坏。
class cat;
protected color_t color;
local bit is_good;
function set_good(bit s);
this.is_good = s;
endfunction
endclass
class black_cat extends cat;
function new();
this.color = "BLACK";
endfunction
endclass
class white_cat extends cat;
function new();
this.color = "WHITE";
endfunction
endclass
black_cat bk;
white_cat wt;
initial begin
bk = new();
wt = new();
bk.set_good(1);
wt.set_good(0);
end
由上面代码得出结论:
- 不可以通过外部修改黑/白猫的颜色,因为声明的是受保护的变量。
- 黑/白猫不可以自己初始化时设置is_good夸自己是好猫,因为cat类定义的is_good是local类型。
- 外部不可以通过访问黑/白猫的is_good属性来得知是不是好猫,因为cat类定义的is_good是local类型,无法访问。
- 黑/白猫是不是大脸猫,无从得知,因为没有这个属性。
案例
class basic_test;
int def = 100; //成员变量赋予默认值
int fin;
task test(sim_ini ini);
$display("basic_test::test");
endtask
function new(int var);
//......
endfunction
endclass
class test_wr extends basic_test;
function new();
super.new(def);
$display("test_wr::new");
endfunction
task test(stm_ini ini);
super.test(ini);
$display("test_wr::test");
//......
endtask
endclass
class test_rd extends basic_test;
function new();
super.new(def);
$display("test_rd::new");
endfunction
task test(stm_ini ini);
super.test(ini);
$display("test_rd::test");
//......
endtask
endclass
- 类test_wr和test_rd是子类,其父类为basic_test,也叫基类。
- 子类在定义new函数时,应该首先调用父类的new函数,即super.new()。
- 要想继承父类的属性和方法,必须调用(显式或隐式)super.new()。
- 从创建对象的初始化来看,用户应该注意如下的规则:
- 子类的实例对象在初始化时首先会调用父类的构造函数。
- 当父类构造函数完成时,会将子类实例对象中各个成员变量按照他们定义时的默认值初始化,如果没有默认值则不初始化。
- 在成员的变量默认值赋予后(声明的同时即赋值),才会最后进入用户定义的new函数中执行剩余的初始化代码。
成员的覆盖
在父类和子类里,可以定义相同名称的成员变量和方法(形式参数和返回值也应该相同),而在引用时,也将按照句柄类型来确定作用域。举例如下:
class basic_test;
int def = 100; //成员变量赋予默认值
function new(int var);
//......
endfunction
endclass
class test_wr extends basic_test;
int def = 200; //成员变量赋予默认值
function new();
super.new(def);
$dispaly("test_wr::super.def =%0d", super.def); //super.def为100
$dispaly("test_wr::this.def =%0d", this.def); //this.def为200
endfunction
//......
endclass
module tb;
//......
basic_test t;
test_wr wr;
initial begin
wr = new();
t = wr; //将子类对象句柄赋值给父类句柄(实际上父类句柄t仍旧只能访问父类变量,而并没有扩大作用域至子类,如果想扩大作用域,只能使用$cast,而不是等号)
$display("wr.def = %0d", wr.def );
$display("t.def = %0d", t.def );
end
endmodule
最后打印的wr.def和t.def的值分别为多少?答案是200和100。首先声明了父类的句柄t和子类句柄wr,创建了子类的实例wr,又将子类的句柄wr赋给了父类句柄t。此时 句柄t和wr都指向了这个对象 ,这里就有区分了,虽然都指向同一对象,但是 子类句柄wr可以访问这个对象中的全部变量 ,也就是def默认值为200,而 父类句柄t只能访问子类继承自父类的变量 ,也就是def默认值为100。这里有两个关键点, 一是父类和子类都声明了相同名字的变量,二是子类句柄赋值给了父类句柄 ,在此场景下需要特别注意。
总结:
- test_wr类新定义的变量test_wr::def和basic_test::有冲突(同名),但是在类的定义里, 父类和子类拥有同名的变量和方法也是允许的 。当子类作用域中出现父类同名的变量和方法,则 以子类作用域为准 。同时也可以使用this/super来指明使用子类/父类的变量/方法。
- 父类和子类拥有同名或非同名的变量或方法时,子类使用变量和方法,如果不指明super/this,则依照由近及远的原则来引用变量。
- 首先看变量是否是在函数内部定义的局部变量。
- 其次看变量是否是当前类定义的成员变量。
- 最好再看变量是否是父类或更底层类的变量。
句柄的使用
句柄的传递
句柄可以作为形式参数通过方法来完成对象指针的传递,从外部传入方法内部(注意:传递的参数是句柄,而不是对象,并且对象是创建在一块内存里的,永远不可能作为参数传递)。
task generator;
tranctions t;
t = new();
transmit(t);
endtask
task transmit(transaction t);
//......
endtask
句柄也可以在在方法内部首先完成修改,而后再由外部完成使用。
function void create(tranction tr); //Bug, miss ref
tr =new();
tr.addr = 100;
//initialize other fields
//......
endfunction
transaction t;
initial begin
create(t);
t.addr = 10;
$display(t.addr);
end
问题:最后显示的t.addr的数值是多少?
答案:报错。分析:create函数的参数默认为input,没有返回值,也就是create函数内所做的操作都是局部的,而在外部看来,句柄t还是个null,没有实例,所以在引用t.addr时会报错。改进方法是:参数声明为inout 或 添加ref关键字。
句柄的动态修改
程序执行时,可以在任何时候为句柄创建新的对象,并将新的指针赋值给句柄。
task generate trans();
transaction t; //声明句柄
tranction fifo[$]; //声明存放句柄的队列
t =new(); //创建对象
for(int=0; i<3; i++) begin
t.addr = i<<2;
fifo.push_back(t);
end
t = fifo.pop_front();
endtask
问题:最后t.addr数值多少?
答案:8。分析:首先循环对t.addr的赋值依次为0 4 8,队列依次存入三个t,最后弹出第一个赋给t,仿佛最后t.addr的值应该是第一个数字0,为什么会是8,原因就一个,队列里存放的是句柄,不是对象,三次存入的句柄t的内容不变,都指向对象t,而对象t的addr变量是8。所以牢记: 传递的是句柄,而不是对象。
包的使用
包的意义
-
sv语言提供了一种在多个module、interface和program中共享parameter、data、type、task、function、class等的方法,即利用package(包)的方式来实现。如果装修一个大房子(完整的验证环境)来看的话,我们喜欢将不同的模块的类定义归整到不同的package中。
-
这么做的好处在于将一簇相关的类组织在单一的命名空间下,使得分属于不同模块验证环境的类来自于不同package,这样便于通过package来解决类的归属问题。
包的定义
package regs_pkg;
`include "stimulator.sv"
`include "monitor.sv"
`include "checker.sv"
`include "env.sv"
endpackage
package arb_pkg;
`include "stimulator.sv"
`include "monitor.sv"
`include "checker.sv"
`include "env.sv"
endpackage
//导入包
module tb;
import regs_pkg::*;
import arb_pkg::*;
regs_mon mon1 = new();
arb_mon mon2 = new();
endmodule
-
两个package regs_pkg和arb_pkg中都定义了4个与模块验证相关的类,即stimulator、monitor、checker、env。两个不同的package内存在同名的类,但是它们的内容是不同的。
-
如果我们将这些重名的类归属到不同的package中去编译,不需要担心命名冲突的问题,因为package会将命名空间分隔开率,在使用同名类时,注明要使用哪个package的即可。
regs_pkg::monitor mon1 = new();
arb_pkg::monitor mon2 = new();
包与库的区分
- package可以对类名做一个隔离的作用,使用不同的package管理同名的类,可以解决命名冲突问题。(使用域名索引符“::”)
- package更多的意义是将软件封装在不同的命名空间中,以此来与全局的命名空间进行隔离。
- library是编译的产物,硬件都会被编译到库中,如果不指明编译库,会被编译到默认的库中(worklibrary),同样可以解决命名冲突的问题(不过设计中我认为都会依靠一套完整的命名规则来命名,这样不仅解决同名冲突问题,还可以从名字上了解到模块的更多信息)。
- 库既可以容纳硬件、也可以容纳软件,包括package。
包的命名规则
- 在创建package的时候,已经在指定包名称的时候隐含地指定了包的默认路径,即包文件所在的路径,如果在package中要include该路径之外的文件,需要额外指定搜索路径“+incdir+PATH”。
- 如果遵循package的命名习惯,不但要求定义的package名称独一无二,其内部定义的类也应该尽可能独一无二。
- 如果不同package中定义的类名也不相同,在顶层的引用可以通过“import pkg_name:😗”的形式,来表示在tb中引用的类如果在当前域中没有定义,会搜寻regs_pkg和arb_pkg中定义的类(前提是所有类不同名)。
- 类的命名上,建议加上指明特定身份的前缀,比如package名。
包的使用建议
- 在包中可以定义类、静态方法和静态变量。
- 如果将类封装在某一个包中,那么它就不应该在其他地方编译,这样可以方便后面对类的引用。
- 类和包是好朋友,包是类的归宿,类是包的子民。
- 一个完整模块的验证环境组件类,应该由一个对应的模块包来封装。
- 使用`include关键词完成类在包中的封装,要注意编译的前后顺序。
- 编译一个包的背后实际是将各类文件平铺在包中,按照顺序完成包和包内各类的有序编译。
- 使用类的可以通过`import完成包中所有类或者某个类的导入,使得新环境可以识别出来,否则类会躺在包外不被外部识别。
参考资料
- Wenhui’s Rotten Pen
- SystemVerilog
- chipverify