SV 第五章 面向对象编程基础

发布时间 2023-08-12 18:15:05作者: MinxJ

SystemVerilog验证

5 面向对象编程基础

5.1 概述

对于Verilog和C语言来说,由于他们不是面向对象变成语言,数据的存储往往是分布式的,例如把数据、地址、指令分别保存在不同的数组里面,不利于程序的解读。面向对象变成使得用户可以创建复杂的数据类型,将数据类型紧密地结合在一起,可以在更加抽象的层次建立测试平台和系统级模型。通过调用一个函数来执行一个动作,而不是改变信号的电平。

5.2 编写一个类

类封装了数据和操作数据的子程序,以下是一个例子:

class Transactions;
    bit [31:0] addr, crc, data[8];
    
    function void display;
        $display ("Transaction : %h", addr);
    endfunction : display
    
    function void calc_crc;
        crc = addr ^ data.xor;
    endfunction : calc_crc
endclass : Transactions

和C++类似。

上面这个例子里面,定义了一个Transaction类,里面有32位bit的地址、校验码、以及32位宽长度为8的数据数组。

同时在类里面定义了显示地址和计算循环冗余校验码的子程序。

对于SystemVerilog来说,它的类既可以定义在program、module、package里面,也可以定义在之外的任何地方,可以被程序和模块调用。

在一个项目里面,你可能需要将每个类当都保存在一个独立的文件里面,如果类太多了,文件数量太大了,可以把相关的类和类型定义使用package将他们捆绑在一起。

在面向对象编程(OOP)中,经常会面对一些术语,如下:

  • 类(class):包含变量和子程序的构建块
  • 对象(object):类的实例,将定义的类实例化出来之后,叫做对象
  • 句柄(handle):指向对象的指针,在Verilog中,通过实例名在模块外部应用信号和方法,一个OOP句柄就像是一个对象的地址,保存在一个只能指向单一数据类型的指针中。
  • 属性(property):存储数据的变量,verilog中的reg和wire
  • 方法(method):任务或函数中操作便利的程序
  • 原型(prototype):程序的头,包含程序名、返回类型和参数列,程序题则包含执行代码

5.3 类的用法

在verilog中,如果实例化一个模块,在编译阶段就会实例化称为一个具体的电路,而对于OOP来说,对象的实例化是在运行时,被测试平台需要的时候才被创建。

在Verilog中,模型被例化了之后就不会变了,只是信号在发生变化,而在SV中,及粒度仪象不断地被创建并用来驱动DUT,检查结果,最后这些对象占用的内存也可以被释放。

和C++类似,当我们声明想要使用一个类的时候,我们先声明它的句柄,然后通过句柄来得到被实例化的对象的句柄,而实例化句柄需要调用new的方法,也就是构建方法,这个构建方法是可以我们自行设定的,例子如下:

class Transaction;
    logic [31:0] addr, crc, data[8];
    
    function new(logic [31:0] a=3, d=5);
        addr = a;
        foreach (data[i])
            data[i] = d;
    endfunction
endclass

Transaction tr;	// 声明句柄
tr = new(10);		// 调用new的方法,位Transaction分配空间,实例化

同样的,这个new的方法也可以编写成传参的形式,设定缺省值,当我们在new的时候传参10的时候,此时实例化的对象中的a为10,而d为为缺省值5。

我们通过被句柄赋值,为句柄分配一个对象,我们也可以给句柄赋值null,来解除句柄和的分配。

对象实例化之后,对象的调用同样和c++等一致,通过“.”来访问对象中的成员,包含数据和子程序。例如:

Transaction t;		// 声明对象句柄
t = new();			// 实例化一个对象
t.addr = 32'h42;	// 给对象中的地址赋值
t.display();		// 调用对象的显示的方法

在类中的成员变量,可以是静态变量也可以是动态的。平时使用的变量都是动态的,实例化了多个同一个类,其中的变量也是分开存储的。如果类中包含静态变量,那么所有实例化的类,都会同时使用该静态变量,改变的是同一块地址上的数值。

例如,将之前的例子做如下修改:

class Transaction;
    
    static int count = 0;		// 保存一创建的对象的数目
    int id;						// 作为实例化对象的唯一标志
    
    function new();
        id = count++;
    endfunction
endclass

Transaction t1, t2;
initial begin
    t1 = new();
    t2 = new();
    $display("Second id = %d, count =%d", t2.id, t2.count);
end

在这里例子里,无论创建了多少个对象,那么count只有一个,每创建一个对象,count就会自增1,而id是动态变量,每一个对象都有自己的id。

静态变量一般在声明的时候就初始化,不能在构造函数里面初始化,因为每一个新的对象都会调用构造函数,每次调用都会将静态变量的值进行初始化,我们只需要确保,静态变量在使用前已经初始化就可以了。

从原则上来说,一个类最好是自给自足的,对外部的引用越少越好,如果你需要在类里面使用一个全局变量,应当优先考虑使用静态变量。

对于静态变量来说,可以不用句柄去访问(“t2.count”),可以通过和C++类似的方式去访问,即,在类名后面加上“::”,即,类作用域操作符。(“Transaction::count”)。

有静态的变量那么就有静态的方法,类似的,类的每一个实例都需要从同一个对象里面获取信息的时候,那么我们就可以使用静态变量来避免对存储空间上的浪费,如果在类中有操作静态变量的方法,那么这些方法也随着对象的增多而增多,静态方法就可以避免子程序在实例化多个对象过程中对于内存的浪费。例如:

class Transaction;
    static Config cfg;
    static int count = 0;
    int id;
    
    static function void display_statics();
        $display("Transaction cfg.mode=%s,count=%d", cfg.mode.name(),count);
    endfunction
endclass

Config cfg;

initial begin
    cfg = new(MODE_ON);
    Transaction::cfg = cfg;			// 调用静态变量
    Transaction::display_statics();	// 调用静态方法
end

和普通的function或者task不同,类中的方法默认使用的自动存储,即,不需要再去使用额外的automatic修饰符。

如果类的方法有很多,怎么才能保证可读性呢?如果是C语言,通常我们会把一种操作的函数声明放在头文件中,可以一目了然的看到该种操作或者叫库的方法。如果我们能保证类的方法集中在一块,甚至一页纸内,那么一个类的可读性将大大提高。

和C语言或其他语言类似,我们也可以使用先声明,后写函数体的形式来做,让函数声明集中在一起,增加可读性,当我们在类的代码块之外定义函数体的时候,我们在声明的时候应当使用“extern”修饰符,表明函数体在代码块之外。例如:

class PCI_Tran;
    bit [31:0] addr, data;
    extern function void display();
endclass

function void PCI_Tran::display();
    $display("@%0t:PCI:addr=%h,data=%h", $time, addr, data);
endfunction

值得注意的是,SV种可以以循序你在代码块之外创建变量,之前我们学习过,这样的变量实际上创建在TOP层之上,即$root层,如果我们在代码块种存在和$root层同名的变量,那么如果想避免歧义,就应该指定它的层级位置,例如:

int limit;				// $root.limit

program automaic p;
   
   int limit;			// $root.p.limit

   class Foo;
       int limit;		// $root.p.Foo.limit
   endclass
   
   initial begin
       int limit = $root.limit;
   end
   
endprogram

对于这种相对关系,this有异曲同工之妙,this关键字和其他面向对象的编程语言一样,可以指带类一级层次,例如:

class Scoping;
    string oname;
    
    function new(string oname);
        this.oname = oname;		// 类一级变量oname = 局部变量oname
    endfunction
endclass

类中也可以通过使用另一个类,来实现层级关系,可以达到重用和控制复杂度的目的。举个例来说,你的每一个事物都可能需要一个带有时间戳的统计块,它记录事务开始和结束的时间,以及有关此次事务的所有信息,例如:

class Statistics;
    time startT,stopT;		// 事务的时间
    static int ntrans = 0;	// 事务的数目
    static time total_elapsed_time = 0;
    
    function time how_long;
        how_long = stopT - startT;
        ntrans++;
        total_elapsed_time += how_long;
    endfunction
    
    function void start;
        startT = $time;
    endfunction
    
endclass

class Transactions;
    bit [31:0] addr,crc,data[8];
    
    Statistics stats;		// Statistics 句柄
    
    function new();
        stats = new();		// 创建stats实例
    endfunction
    
    task create_packet();
    	// 填充包数据
        stats.start();
        // 传送数据包
    endtask
    
endclass

最外层Transaction在new()的方法中实例化Statistics类,然后通过分层调用其类中的成员,例如stats.startT。

我们可以将大大的类分成若干个小类,分割的原则取决于使用它的频率以及简易程度,没必要分割出一个只含有一个或者两个成员的类,也没有必要分割一个只被调用一次的类。

使用不同的类之间嵌套,还有一个问题,就是由于便已存在先后顺序,在编译到你声明了这个类之间,在另一个类中被调用了,编译器是不知道这个新的数据类型的,所以你需要使用tpedef语句声明这个类名。以下是一个例子:

typedef class Statistics;		//	定义低级别类
    
class Transaction;
    Statistics stats;			// 使用Statistics类
    ···
endclass
    
class Statistics;				// 定义Statistics类
    ···
endclass

当然,类作为一种数据类型,可以作为参数传递给方法,当你调用了一个带有标量标量的方法并且使用ref关键词的时候,SV传递该标量的地址,所以方法也可以修改标量变量的值,如果不适用ref关键词,SV会将标量的值复制到参数变量中,对该参数变量的任何修改都不会影响原来变量的值。

句柄也是一种类型的指针,和C语言很类似,但是又不一样,因为在SV中参数的传递是有方向的,如果我们使用ref关键字,那么和C语言的地址很类似,但是如果我们不使用ref,不同的参数方向会有不同的效果,例如input是将传去的数据拷贝一份在方法里,如果是output的话就是反向复制,先在方法里新建一个同一个类型的变量,然后操作后,用新的变量的地址替换掉原先的地址。使用ref就是标准的地址传参了。

如果要在程序中修改对象,以下是一个正确的例子:

function void create (ref Transaction tr);
    tr = new();
    tr.addr = 42;
    // 初始化其他域
    ···
endfunction

Transaction t;
initial begin
    create(t);			// 创建一个transaction
    $display(t.addr)	// 失败,因为t = null
end

同样的,类可以看作一种类型,同样也可以有数组,就像是int数组或者其他数组一样,但是没有“对象数组”这种说法,一般称为“句柄数组”,应该记住这些句柄可能并没有指向一个对象,也可能多个句柄指向同一个对象。

5.4 对象的复制

有时候我们需要对对象进行复制,防止对象的方法修改原始对象的值,或者在一个发生器中保留约束。可以使用简单的new函数的内建拷贝功能,也可以为更复杂的类编写专门的对象拷贝代码。

使用new复制一个对象简单而可靠,它创建了一个新的对象,并且复制了现有对象的所有变量,和你自己定义的new()函数不一样,这个过程new()函数并不会调用,这里的new是关键字操作符。以下是一个例子:

class Transaction;
    bit [31:0] addr, crc, data[8];
endclass

Transaction src, dst;
initial begin
    src = new;		// 创建第一个对象
    dst = new src;	// 使用new操作符进行复制
end

对于一些较为复杂的类,也可以这样操作,如果类中含有其他类的句柄,实例化了其他类的对象,那么将会拷贝其中实例化对象的值,不会调用new的方法,为类中的子类新建一个。以下是例子:

typedef class Statistics;

class Transaction;
    bit [31:0] addr, crc, data[8];
    static int count = 0;

    int id;
    Statistics stats;

    function new;
        stats = new();
        id = count++;
    endfunction
endclass

class Statistics;
    time startT, stopT;     // time of office
    static int ntrans = 0;  // count of office
    static time total_elapsed_time = 0;

    function time how_long;
        stopT = $time;
        how_long = stopT - startT;
        ntrans++;
        total_elapsed_time += how_long;
    endfunction

    function void start;
        startT = $time;
    endfunction

endclass

Transaction src, dst;

program test;
    initial begin
        src = new();
        src.stats.startT = 42;
        dst = new src;          // copy
        $display("src id is %0t, src.stats.startT is %0t", src.id, src.stats.startT);
        $display("dst id is %0t, dst.stats.startT is %0t", dst.id, dst.stats.startT);

        // Statistics
        src.stats.start;
        #100 src.stats.how_long;
        $display("src id is %0t, start time %0t, how_long %0t", src.id, src.stats.startT, src.stats.total_elapsed_time);
        $display("dst id is %0t, start time %0t, how_long %0t", dst.id, dst.stats.startT, dst.stats.total_elapsed_time);

    end
endprogram

这算是一个比较综合的例子,在一开始,使用typedef声明了Statistics类,因为该类是Transaction类的子类,但是在编译到父类的时候,子类还没被定义,所以需要提前声明,不然会报错。

定义了Transaction类的两个句柄,src和dst,在pragram中,显示为src句柄实例化了一个对象,然后将时间值42赋值给了src的子类中的startT变量的值。

dst通过关键字new拷贝了src的值,这个时候类中的子类句柄stats也拷贝过来了,这个时候dst和src中的子类句柄指向的是同一个对象,所以此时dst中子类中的值应该是一样的。

同样的,在调用了src子类的方法之后,dst中的子类句柄因为和src指向的是同一个对象,所以,他们中的值也一同改变。

以下是仿真结果:

      (Specify +UVM_NO_RELNOTES to turn off this notice)

src id is 0, src.stats.startT is 42000
dst id is 0, dst.stats.startT is 42000
src id is 0, start time 0, how_long 100000
dst id is 0, start time 0, how_long 100000
$finish at simulation time               100000
           V C S   S i m u l a t i o n   R e p o r t 
Time: 100000 ps
CPU Time:      0.220 seconds;       Data structure size:   0.2Mb
Sat Aug 12 14:57:31 2023

可以从仿真结果中看到,src和dst的子类句柄指向同一个。

如果你的类中不包含对于其他类的引用,那么自己编辑一个copy函数也是很容易的。对于上面的例子可以看到,我们使用new关键字的时候,类中的子类并没有在复制的过程中新建,所以我们可以自己编写一个深层次的复制函数。对上面的例子,做如下修改:

typedef class Statistics;

class Transaction;
    bit [31:0] addr, crc, data[8];
    static int count = 0;

    int id;
    Statistics stats;

    function new;
        stats = new();
        id = count++;
    endfunction

    function Transaction copy;
        copy = new();       // new one Transaction
        copy.addr = addr;
        copy.crc = crc;
        copy.data = data;
        copy.stats = stats.copy();
    endfunction

endclass

class Statistics;
    time startT, stopT;     // time of office
    static int ntrans = 0;  // count of office
    static time total_elapsed_time = 0;

    function time how_long;
        stopT = $time;
        how_long = stopT - startT;
        ntrans++;
        total_elapsed_time += how_long;
    endfunction

    function void start;
        startT = $time;
    endfunction

    function Statistics copy;
        copy = new();       // new one Statistics
        copy.startT = startT;
        copy.stopT = stopT;
    endfunction

endclass

Transaction src, dst;

program test;
    initial begin
        src = new();
        src.stats.startT = 42;
        dst = src.copy();          // copy
        $display("src id is %0d, src.stats.startT is %0t", src.id, src.stats.startT);
        $display("dst id is %0d, dst.stats.startT is %0t", dst.id, dst.stats.startT);

        // Statistics
        src.stats.start;
        #100 src.stats.how_long;
        $display("src id is %0d, start time %0t, how_long %0t", src.id, src.stats.startT, src.stats.total_elapsed_time);
        $display("dst id is %0d, start time %0t, how_long %0t", dst.id, dst.stats.startT, dst.stats.total_elapsed_time);

    end
endprogram

值得注意的是,编写copy的时候,返回变量就是和函数同名的。

以下是编译仿真结果:

      (Specify +UVM_NO_RELNOTES to turn off this notice)

src id is 0, src.stats.startT is 42000
dst id is 1, dst.stats.startT is 42000
src id is 0, start time 0, how_long 100000
dst id is 1, start time 42000, how_long 100000
$finish at simulation time               100000
           V C S   S i m u l a t i o n   R e p o r t 
Time: 100000 ps
CPU Time:      0.200 seconds;       Data structure size:   0.2Mb
Sat Aug 12 17:03:20 2023

通过编译结果可以看到,时间值42被拷贝到dst中,说明copy函数正常工作,并且可以看到,自己编写的copy函数给新的句柄复制的时候,得到了新的id以及不同的开始时间,说明在copy的过程中,为它的子类新建了新的对象。

5.5 使用流操作符从数组到打包对象,或者从打包对象到数组

有些协议,例如ATM协议,每次传输一个字节的控制或者数据,在送出一个transaction之前,需要将对象中的变量打包成一个字节数组。类似的,在接收到一个字节串之后,也需要将他们解包一个transaction对象中,都可以用流操作符完成。

但是你不能将整个对象送入流操作符,因为这样会包含所有的成员,包括数据成员和其他额外信息,如时间戳和你不想打包的自检信息,需要你自己编写你的pack函数,需要打包的成员变量。以下是一个例子:

class Transaction;
    bit [31:0] addr, crc, data[8];
    static int count = 0;

    int id;

    function new;
        id = count++;
    endfunction

    function void pack(ref byte bytes[40]);
        bytes = {>>{addr,crc,data}};
    endfunction

    function Transaction unpack(ref byte bytes[40]);
        {>>{addr,crc,data}} = bytes;
    endfunction

    function void display();
        $write("Tr:id = %0d, addr = %x, crc = %x", id, addr, crc);
        foreach(data[i]) $write(" %x", data[i]);
        $display;
    endfunction

endclass


Transaction tr1, tr2;
byte b[40];

program test;
    initial begin
        tr1 = new();
        tr1.addr = 32'ha0a0a0a0;
        tr1.crc = 1'b1;
        foreach (tr1.data[i])
            tr1.data[i] = i;

        tr1.pack(b);         // pack object
        $write("Pack results:");
        foreach (b[i])
            $write("%h", b[i]);
        $display;

        tr2 = new();
        tr2.unpack(b);
        tr2.display();
    end
endprogram

以下是编译仿真结果:

      (Specify +UVM_NO_RELNOTES to turn off this notice)

Pack results:a0a0a0a0000000010000000000000001000000020000000300000004000000050000000600000007
Tr:id = 1, addr = a0a0a0a0, crc = 00000001 00000000 00000001 00000002 00000003 00000004 00000005 00000006 00000007
$finish at simulation time                    0
           V C S   S i m u l a t i o n   R e p o r t 
Time: 0 ps
CPU Time:      0.200 seconds;       Data structure size:   0.2Mb
Sat Aug 12 17:54:27 2023

可以看到在类内,实现了打包和解包的两个过程。

值得注意的是,在调用解包的函数的时候,并没有左值,于是返回值是一个Transaction类的地址,这个值给了tr2句柄本身。

5.6 公有和私有

OOP的核心概念就是将数据和相关的方法封装成一个类,在一个类中,数据默认被定义为私有,这样防止了其他类对内部数据成员的随意访问。

但是在SV中,所有的成员都是公有的,除非编辑为local或者protected,应当尽可能使用默认值,保证对DUT行为的最大程度的控制。