路科验证的个人空间 https://blog.eetop.cn/1561828 [收藏] [复制] [分享] [RSS]

空间首页 动态 记录 日志 相册 主题 分享 留言板 个人资料

日志

SV组件实现篇之二:激励器的封装

已有 2449 次阅读| 2016-12-25 23:41 |个人分类:验证系统思想|系统分类:芯片设计

在上一节《激励器的驱动》中我们谈到了,实际上module stm_ini自身也可以通过定义方法,最终使得stimulator、tests和tb隔离开来 ,达到初步的封装目的。而这种封装也是通过有形的module这个硬件“盒子”来实现的,我们这一节来看看,如何通过软件“包裹”的方式来实现封装,并且比较软件封装相比于硬件封装的优点。

类的封装(class encapsulation)
封装是类(class)的一大特性,SV中类的概念主要借鉴于软件语言的定义方式,而且在此基础上进行了简化,使得用户更易专注于类的使用,而非软件层面上内存的开销和回收,关于这一点,我们也会在本节中有所展开。

这里,我们可以将类同结构体(struct)以及模块(module)做比较。

首先来看,类与结构体的异同
  • 二者本身都可以定义数据成员
  • 类变量在声明之后,需要构造(construction)才会构成对象(object)实体,而struct在变量声明时已经开辟内存
  • 类除了可以声明数据变量成员,还可以声明方法(function/task),而struct则不能。
  • 从根本来讲,struct仍然是一种数据结构,而class则包含了数据成员以及针对这些成员们操作的方法

再来看类与模块(module)的异同
  • 从数据和方法定义而言,二者均可以作为封闭的容器来定义和存储
  • 从例化来看,模块必须在仿真一开始就确定是否应该被例化,这可以通过 generate 来实现设计结构体的变化;而对于类而言,它的变量在仿真的任何时段都可以被构造(开辟内存)创建新的对象。这一点重要区别,按照硬件和软件世界区分的观点来看,硬件部分必须在仿真一开始就确定下来,即module和其内部过程块、变量都应该是静态的(static);而软件部分,即类的部分可以在仿真任何阶段声明变量并动态创建出新的对象,这正是软件操作更为灵活的地方。
  • 封装性(encapsulation)来看模块内的变量和方法是对外部公共(public)开放的,而类则可以根据需要来确定外部访问的权限是否是默认的公共、受保护(protected)还是私有的(local)
  • 从继承性(inheritance)来看,模块没有任何的继承性可言,即无法在原有module的基础上进行新module功能的扩展,唯一可能做的方式恐怕只有简单的拷贝和在新拷贝module上做修改,而继承性正是类的一大特点。

接下来,我们将改造之前定义的module stm_ini和struct trans为class stm_ini和class trans来看看封装性的优点。

class trans;
bit [ 1:0] cmd;
bit [ 7:0] cmd_addr;
bit [31:0] cmd_data_w;
bit [31:0] cmd_data_r;
endclass

class stm_ini;

virtual interface regs_ini_if vif;

trans ts[];

task op_wr(trans t);
...

task op_rd(trans t);
...

task op_idle();
...

task op_parse(trans t);
...

task stmgen();
wait(vif != null);
@(posedge vif.rstn);
foreach(ts[i]) begin
op_parse(ts[i]);
end
endtask
endclass

从这个例子来看,class trans定义了内部的成员,而class stm_ini则定义了成员变量和成员方法,且默认下这些变量和方法都是公共(public)可访问的。

需要注意的是,在之前module stm_ini中的initial stmgen在class stm_ini的改造中,必须改为task stmgen,这是因为对于类而言,其内部的方法必须为task或者function,而不能使module中使用的硬件过程块always或者initial。同时,task stmgen自身也无法像initial stmgen一样可以在仿真开始自动执行,而必须由外部来调用,使其在相应时刻来执行。

这样,我们就可以顺利地将struct trans和module stm_ini改造为class trans以及class stm_ini了,而改造的过程中也应该注意,不可以出现硬件过程块(process block)。

而上面的两个类在定义过程中也缺省了构造函数new(),这是由于没有任何额外的初始化动作需要在构造函数中定义,所以就省略了构造函数的定义,但这并不代表它们没有构造函数,而是会使用系统默认的构造函数(空函数)。

类的继承(class inheritance)
再接下来,我们将继续改造另外一个module tests,使其变为类,且将不同的测试向量尽可能的封装到不同的类里面,因为我们遵循一个简单的原则:
  • 如果两个测试向量分属于不同的测试场景,我们应该将其隔离开来。从更广义的层面来看,如果一个类的功能过于复杂,不够集中,就应该相办法让更多的类来承担不同的工作。这符合软件开发的“单一职责原则(SRP)”:即就一个类而言,应该仅有一个引起它变化的原因。如果一个类承担了多余一个的职责,那么引起它变化的原因就会有多个。如果一个类承担的职责过多,就等于把这些职责耦合在了一起。一个职责的变化可能会削弱或者抑制这个类完成其它职责的能力。这种耦合会导致脆弱的(fragile)的设计,当设计发生变化时,设计会遭受到意想不到的破坏。

所以,我们将之前的module tests拆分为三个类即class basic_test、class test_wr和class_rd:


class basic_test;

int def = 100;
int fin;

task test(stm_ini ini);
$display("basic_test::test");
endtask

function new(int val);
$display("basic_test::new");
$display("basic_test::def = %0d", def);
fin = val;
$display("basic_test::fin = %0d", fin);
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");
ini.ts = new[3];
foreach(ini.ts[i])
ini.ts[i] = new();
ini.ts[0].cmd = WR;
ini.ts[0].cmd_addr = 0;
ini.ts[0].cmd_data_w = 32'h0000_FFFF;
ini.ts[0].cmd = RD;
ini.ts[0].cmd_addr = 0;
fin = 150;
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");
ini.ts = new[2];
foreach(ini.ts[i])
ini.ts[i] = new();
ini.ts[0].cmd = RD;
ini.ts[0].cmd_addr = 'h10;
ini.ts[1].cmd = RD;
ini.ts[1].cmd_addr = 'h14;
fin = 200;
endtask
endclass


上述的类test_wr和test_rd为basic_test的子类(派生类),或者basic_test称之为test_wr和test_rd的父类(基类)。test_wr和test_rd继承了basic_test的成员变量int fin,也继承了它的成员方法virtual test。所以,就继承来看,类的继承包括了继承父类的成员变量和成员方法。

这里,我们先介绍如何继承构造函数new的,由于构造函数的继承不同于其它普通成员方法,所以我们这里单独列出来进行解释。

就class basic_test的构造函数而言,它对fin做了初始化,但是并没有返回任何值。实际上,构造函数也不允许显式地返回数值,因为系统会固定返回例化的对象句柄本身,这一点值得注意。此外,从new函数来看,由于是function,要求它不包含延时语句,即立刻执行返回。

默认的构造函数没有任何参数,且函数体为空,上面三个类的构造函数均不为空。像之前解释的一样,如果一个类没有提供用户定义的new函数,那么默认的new函数会被自动定义给该类。而子类在定义new函数时,应该首先调用父类的new函数即super.new()。如果父类的new函数没有参数,子类也可以省略该调用,而系统会在编译时自动添加super.new()

从对象创建时初始化的顺序来看,用户应该注意有如下的规则:
  1. 子类的实例对象在初始化时首先会调用父类的构造函数
  2. 当父类构造函数完成时,会将子类实例对象中各个成员变量按照它们定义时显式的默认值初始化,如果没有默认值则不被初始化。
  3. 在成员变量默认值赋予后,才会最后进入用户定义的new函数中执行剩余的初始化代码

从上面的例子,在实际执行中来看,如果先执行test_wr测试向量,那么实际的输出结果为:
# +TEST=test_wr is passed as a test vector
# basic_test::new
# basic_test::def = 100
# basic_test::fin = 0
# test_wr::new


从new函数执行的顺序来看,是先执行basic_test::new再执行test_wr::new,而结合上述对象创建时初始化顺序的规则来看,也就可以解释,为什么在test_wr::new中调用父类basic_test::new(def)时,没有将def默认值传递。这是由于,在调用basic_test::new(def)时,def并没有完成初始化,而在进入basic_test::new之后,由于basic_test再没有父类,且def默认值定义在basic_test中,所以,一旦进入basic_test::new之后,在执行其余代码前,def默认值已经被赋予。于是,打印出的结果是basic_test::def = 100,而basic_test::fin = 0。

成员覆盖(overridden members)
在父类和子类里,可以定义相同名称的成员变量和方法(形式参数和返回类型也应该相同),而在引用时,也将按照句柄类型来确定作用域。

例如,上面的例子, 经过代码更新为下面的部分用来说明成员覆盖:
class test_wr extends basic_test;
int def = 200;

function new();
super.new(def);
$display("test_wr::new");
$display("test_wr::super.def = %0d", super.def);
$display("test_wr::this.def = %0d", this.def);
endfunction

...
endclass

module tb;

...

basic_test t;
test_wr wr;

initial begin
wr = new();
t = wr;
$display("wr.def = %0d", wr.def);
$display("t.def = %0d", t.def);
end

task test(stm_ini ini);
super.test(ini);
$display("test_wr::test");
endtask
endmodule

输出结果
# basic_test::new
# basic_test::def = 100
# basic_test::fin = 0
# test_wr::new
# test_wr::super.def = 100
# test_wr::this.def = 200
# wr.def = 200
# t.def = 100

可以在test_wr新定义的成员变量test_wr::def,这个变量实际上跟basic_test::def有冲突,是同名的。而在类定义里,父类和子类拥有同名的变量和方法也是允许的,即子类作用域中如果出现同父类相同的变量名或者方法名,则以子类作用域为准。同时,我们也提供方法(super)来调用父类的变量或者方法

在上面的输出结果中,首先test_wr对象wr调用构造函数new,而在构造函数执行序列中,也是先执行basic_test::new,再执行test_wr::new。在tes_wr::new中,可以通过super.def以及this.def来区分调用父类的def还是自身的def,默认情况下,如果没有super或者this来指示作用域,则依照从近到远的原则来引用变量即
  1. 首先看def变量是否则是否是函数内部定义的局部变量。
  2. 其次看def是否是当前类定义的成员变量
  3. 最后再看def是否是父类或者更底层类的变量。

而test_wr::super.def以及test_wr::this.def在进入test_wr::new之后已经完成了默认值赋值,所以,打印出来的结果分别是:
# test_wr::super.def = 100
# test_wr::this.def = 200

而最后在调用wr.def和t.def时,可以发现wr.def毫无疑问地指向了test_wr::def,而t.def则指向了basic_test::def。虽然t本身也指向了对象wr,而在索引成员变量时,t只能索引其自身类basic_test的成员变量basic_test::def,而不会指向test_wr::def。
从上面这张图可以看到,句柄wr在索引def时会首先在test_wr作用域中搜索变量def,一旦发现test_wr::def之后,会使用该变量,如果没有发现,则会像上追溯到父类搜索该变量;而句柄t由于是basic_test类型,所以遵循的逻辑也是会首先指向basic_test作用域中搜索def,在上面例子中也找到了basic_test::def,而如果没有找到,它会继续向上追溯它的父类(如果有的话),但是它肯定不会追溯其子类test_wr是否有成员def。‘

这一点,关于通过不同类型句柄来追溯成员变量的原则,需要同下面追溯成员方法的原则区别开来,因为通过虚方法的定义,我们可以实现仿真时动态查找来实现父类句柄调用子类的方法。但是,依然无法通过父类句柄完成调用子类成员变量,只有通过句柄的转换,才可以实现这一点。关于句柄的转换我们会在后面的“句柄使用”中为大家介绍。

虚方法(virtual methods)
上面谈到了类的继承是从继承成员变量和成员方法两个方面,之前的例子中可以看到test_wr和test_rd分别继承了basic_test的成员变量以及new函数。 除了我们上面介绍的类的封装继承,关于类的多态性(polymorphism)也是必须关注的。正是由于类的多态性,使得用户在设计和实现类时,不需要担心实际对象是父类还是子类,只要通过虚方法的定义,就可以实现动态绑定(dynamic binding),或者在SV中称之为动态方法查找(dynamic method lookup)。

我们首先来看看,在上述例子中如果没有声明basic_test::test为虚方式时,下面的测试代码会输出结果会如何:

basic_test t;
test_wr wr;

initial begin
wr = new();
t = wr;

$display("wr test starts");
wr.test(ini);
$display("wr test ends");

$display("t test starts");
t.test(ini);
$display("t test ends");
end

输出结果:
# wr test starts
# basic_test::test
# test_wr::test
# wr test ends
# t test starts
# basic_test::test
# t test ends

首先,在执行wr.test()时,由于wr类型为test_wr,则索引到的test()应该为test_wr类的方法test。同时,由于在test_wr::test中显式调用了super.test(),则会先执行basic_test::test,然后再执行test_wr::test中其余的代码。这里值得注意的是,默认情况下,子类覆盖(overridden)的方法并不会 继承父类同名的方法,而只有通过super.method()的方式显示执行,才会达到执行继承父类方法的效果,初学SV的用户容易在这里混淆方法覆盖和类继承的概念。

然而,当wr对象的句柄传递给t后,由于t本身是basic_test类,所以,在执行t.test时,t只会搜寻basic_test::test方法。如果basic_test::test已经定义过,那么就如上面输出结果所示,只会执行basic_test::test;如果basic_test::test没有定义,那么在编译时会报告错误,因为首先要确保t的类型basic_test自身已经定义test方法。

从下面的图中可以发现这种方法索引是同之前在“成员覆盖”中关于成员变量索引一致的,即索引的方法只会依照t的类型basic_test来索引:
读者可以从输出结果看到,t.test并没有执行test_wr::test,而是执行了basic_test::test。这种执行结果使得我们不得不小心句柄传递时的类型,而这种限制又跟类的多态性支持是违背的。因为父类的句柄是可以指向子类对象,但如果无法保证通过父类类型句柄调用子类方法的话,那么这种句柄的传递也就失去了多半的意义。在实际编码过程中,我们的需求要求父类句柄在调用方法时,可以在运行时确定自身的指向对象的类型,进而再调用正确地方法。

这里,我们将上面已经在编译阶段就可以确定下来调用方法所处的作用域称之为静态绑定(static binding),与之对应的动态绑定。动态绑定指的是,在调用方法时,会在运行时来确定句柄指向对象真正的类,再来动态指向该调用的方法。

为了实现动态绑定,我们将basic_test定义为虚方法:

class basic_test;

...

virtual task test(stm_ini ini);
$display("basic_test::test");
endtask

...
endclass

只做了这么一个改动以后,我们继续运行之前的测试代码,可以看到运行结果变为:
# wr test starts
# basic_test::test
# test_wr::test
# wr test ends
# t test starts
# basic_test::test
# test_wr::test
# t test ends

可以发现,由于实现声明了t的类basic_test::test为虚方法,会在执行t.test时检查t所指向对象的真正类型为test_wr,进而调用test_wr::test,于是,输出的结果与调用wr.test一致。
这样,我们就可以通过虚方法的使用来实现类方法的调用时的动态查找,而且也使得用户无需担心使用的是父类句柄还是子类句柄,因为最终都会实现动态方法查找,执行正确的方法。

这里,我们将定义虚方法的一些建议列举出来供读者参考:
  • 在为父类定义方法时,如果该方法日后可能会被覆盖或者继承,那么应该声明为虚方法
  • 虚方法如果要定义,应该尽量定义在底部父类中。这是因为如果virtual是使用在类继承关系的中间类中,那么只有从该中间类到其子类的调用链中会遵循动态查找,而最底层类到该中间类的方法调用仍然会遵循静态查找。
  • 虚方法通过virtual声明,只需要声明一次即可。例如上面代码中,只需要将basic_test::test声明为virtual,而其子类则无需再次声明,当然再次声明来表明该方法的特性也是可以的。
  • 虚方法的继承也需要遵循相同的参数和返回类型,否则,子类定义的方法必须归类同名不同参的另外方法。

句柄使用(handle usage)
我们在“虚方法”中可以看到发现,通过虚方法的声明使得在通过父类句柄索引子类方法时,可以通过静态绑定的形式在仿真过程中来解决。而仍然有一些成员无法通过这种方法来解决,它们包括
  • 父类没有定义,只在子类中定义了的方法,
  • 父类没有声明,只在子类中声明了的变量
  • 父类和子类同时声明了的变量

对于前两种情形,父类在引用成员时,会遇到编译错误,因为静态绑定会在检查句柄类型,而父类没有定义这些成员。对于后一种情况,则父类句柄只会索引到父类声明的变量,而不会索引到子类中同名的变量。

那么,在句柄使用时,我们经常会遇到下列的几种问题
  • 句柄悬空
  • 句柄类型转化
  • 对象拷贝

对于句柄悬空的问题,从软件层面来空有两种可能,一种是句柄原先指向的对象已经被析构(deallocation)进而销毁,另外一种是句柄在声明之后,为被指向一个有效的对象空间,即为null值。

由于SV的对象空间回收机制简单,用户无需定义析构函数,所以上述第一种可能不会存在,关于对象的垃圾回收话题,我们会在稍后的话题“对象回收”中单独解释。第二种可能则极容易在新手的代码中出现,对于悬空的句柄或者悬空的接口,都是同样需要首先被赋值,进而索引对象成员的。

我们就之前的代码中关于类stm_ini的定义:

class stm_ini;

virtual interface regs_ini_if vif;

...

task stmgen();
wait(vif != null);
@(posedge vif.rstn);
foreach(ts[i]) begin
op_parse(ts[i]);
end
endtask
endclass

无论是声明了句柄还是虚接口,在引用它们指向的对象成员之前,都需要为其赋值。在上面的stm_ini::stmgen中,通过wait(vif != null)来确保在调用vif中变量之前,vif已经通过外部的赋值指向了一个实例化的接口

或者常见的其它方式例如在引用之前先检查句柄是否悬空,如下面的例子,在引用之前,先判断t是否悬空,通过这种措施可以检查句柄悬空问题,也使得运行时的调试更为方便。

initial begin
wr = new();
t = wr;
if(t == null)
$error("invalid handle t and wr");
$display("wr.def = %0d", wr.def);
$display("t.def = %0d", t.def);

...
end

关于句柄类型转化的话题也是新手容易出错的地方。由于上面已经提到,虚方法仍然无法实现一些父类句柄访问子类成员的情况,这就使得有的时候我们需要将父类句柄转化为子类句柄类型。我们都知道,子类句柄给父类句柄赋值的时候,是可以直接赋值的,因为我们将test_wr是一种basic_test是没有错的;然而,如果要将父类句柄赋值给子类句柄,则可能会出错,因为上述的句柄t指向的是basic_test的子类test_wr,而不是另外的子类test_rd。

所以,如果要将父类句柄赋值给子类句柄,我们应该做一些额外地措施来保障这一转化没有问题。我们再来看上面经过改造的例子:

basic_test t;
test_wr wr;
test_wr hwr;
test_rd hrd;

initial begin
wr = new();
t = wr;
hwr = t;
hrd = t;
end

对于t=wr的赋值我们不会有疑问,而hwr=t和hrd=t呢?虽然我们知道,t实际指向的是test_wr对象,那么将t赋值给一个test_wr句柄hwr,看起来也应该是允许的吧?而将t赋值给一个test_rd句柄应该是非法的,因为它是另外一个子类句柄,不可以指向test_wr对象。是这样分析的,是吗?

实际上,我们的编译器可没我们这么“聪明”,如果像我们上面那样将父类句柄再次赋值给任何的子类句柄,无论实际上是不是正确的类型,编译器都会报错。因为编译器在编译时遇到上述的赋值,只会做静态检查,即检查右侧的句柄类型是否与左侧的句柄类型兼容,而静态检查也只允许子类句柄赋值于父类句柄。所以,上述的两种赋值都是错误的。

那么,既然静态检查不允许做这样的赋值,我们只能寄希望与动态检查和转化了。这里,我们仍然要感谢$cast()系统函数。正是有了它,解决了父类句柄赋值给子类句柄这一大烦恼。

我们再来看看,经过$cast()的帮忙,上述代码的可行性:

initial begin
wr = new();
t = wr;
if(!$cast(hwr, t))

点赞

评论 (0 个评论)

facelist

您需要登录后才可以评论 登录 | 注册

  • 关注TA
  • 加好友
  • 联系TA
  • 0

    周排名
  • 0

    月排名
  • 0

    总排名
  • 0

    关注
  • 253

    粉丝
  • 25

    好友
  • 33

    获赞
  • 45

    评论
  • 访问数
关闭

站长推荐 上一条 /1 下一条

小黑屋| 关于我们| 联系我们| 在线咨询| 隐私声明| EETOP 创芯网
( 京ICP备:10050787号 京公网安备:11010502037710 )

GMT+8, 2024-3-29 02:31 , Processed in 0.016921 second(s), 12 queries , Gzip On, Redis On.

eetop公众号 创芯大讲堂 创芯人才网
返回顶部