5.1概述
对结构化编程语言,例如 Verilog和C语言来讲,它们的数据结构和使用这些数据结构的代码之间存在很大的沟壑。数据声明、数据类型与操作这些数据的算法经常放在不同的文件里,因此造成了对程序理解的困难。
Verilog程序员的境遇比C程序员更加棘手,因为Ⅴ erilog语言中没有结构( structures),只有位向量和数组。如果你想要存储一个总线事务( bus transaction)的信息,你就需要多个数组:一个用于保存地址,一个用于保存数据,一个用于保存指令等等。事务( transaction)N的信息分布在这些所有的数组中。用来创建、发送和接收事务的代码位于模块( module)中,但这个模块可能连接到总线上,也可能根本没有连接到总线上。最糟糕的是,这些数组都是静态的,所以如果测试平台( testbench)只配置了100个数组项,而当前测试需要101个时,就需要修改源代码来改变数组的大小,并且重新编译。结果,数组的大小被配置成可以容纳最大数目的事务,但是在一个普通的测试中,大多数的存储空间却浪费了。
面向对象编程(OOP)使用户能够创建复杂的数据类型,并且将它们跟使用这些数据类型的程序紧密地结合在一起。用户可以在更加抽象的层次建立测试平台和系统级模型,通过调用函数来执行一个动作而不是改变信号的电平。当使用事务来代替信号翻转的时候,你就会变得更加高效。这样做的附加好处是,测试平台跟设计细节分开了,它们变得更加可靠,更加易于维护,在将来的项目中可以重复使用。
如果用户已经熟悉了面向对象编程(OP),则可以跳过这一章,因为 SystemVerilog相当严格地遵守OOP的规则。但你还是需要读一下5.18节,以便了解如何搭建一个测试平台。第8章给出了一些诸如继承等OOP的高级概念,以及更多的测试平台搭建技巧。
5.2考虑名词,而非动词
将数据和代码组合在一起可以有效地帮助你编写和维护大型测试平台。如何把数据和代码组合到一起?你可以先想想测试平台是怎么工作的。测试平台的目标是给一个设计施加激励,然后检查其结果是否正确。如果把流入和流出设计的数据组合到一个事务里,那么围绕事务及其操作实施测试平台就是最好的办法。在OOP中,事务就是测试平台的焦点。
传统的测试平台强调的是要做的操作:创建个事务、发送、接收、检查结果、然后产生报告。而在OOP中,你需要重新考虑测试平台的结构,以及每部分的功能。发生器( generator)创建事务并且将它们传给下一级,驱动器( driver)和设计进行会话,设计返回的事务将被监视器( monitor)捕获,记分板( scoreboard)会将捕获的结果跟预期的结果进行比对。因此,测试平台应该分成若干个块( block),然后定义它们相互之间如何通信。
5.3编写第一个类(Clas)
类封装了数据和操作这些数据的子程序。例5.1是一个通用数据包类。这个数据包包含了地址、CRC和一个存储数值的数组。在 Transaction类中有两个子程序:一个输出数据包地址的函数和一个计算循环冗余校验码〔CRC: cyclic redundancy check)的函数。
为了更加方便地对齐一个块的开始和结東部分,你可以在该块的最后放上一个标记(label)。在例5.1中,这些结束标记可能看起来是多余的,但是在具有很多嵌套块的真实代码中,标记可以很好地帮助你配对简单的结束或 endtask, endfunction和endaclass。
例5.1简单的 Transaction类
class Transaction;
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 :Transaction
5.4在哪里定义类
在 System Verilog中,你可以把类定义在 program、 module、 package中,或者在这些块之外的任何地方。类可以在程序和模块中使用。本书仅给出了类在程序块中使用的情况,如第4章所示。在此之前,可以将程序块当作一个包含了测试代码的模块,它含有一条测试、组成测试平台的对象及创建、初始化并运行测试的初始化块。当你创建一个项目的时候,可能需要将每个类保存在独立的文件中。当文件的数目变得太大的时候,可以使用 System Verilog的包( package)将一组相关的类和类型定义捆绑在一起。例如,可以将所有的SCSI/ATA事务组合到一个包中。这个包与系统的其他部分独立,可以单独地编译。其他不相关的类,例如事务、记分板或者不同协议( protocol)的类应该放在不同的文件中。
关于包的更详细的信息可以参见 System Verilog LRM。
5.5OOP术语
下面是一些OOP的术语定义以及它们与 Verilog-2001的大致的对应关系。
(1)类( class):包含变量和子程序的基本构建块。 Verilog中与之对应的是模块(mocule)
(2)对象( object):类的一个实例。在 Verilog中,你需要实例化一个模块才能使用它
(3)句柄( handle):指向对象的指针。在 verilog中,你通过实例名在模块外部引用信号和方法。一个OOP句柄就像一个对象的地址,但是它保存在一个只能指向单一数据类型的指针中。
(4)属性( property):存储数据的变量。在 Verilog中,就是寄存器(reg)或者线网(wire)类型的信号
(5)方法( method):任务或者函数中操作变量的程序性代码。 Verilog模块除了 initial和 always块以外,还含有任务和函数
(6)原型( prototype):程序的头,包括程序名返回类型和参数列表。程序体则包含了执行代码。
本书使用了更加传统的 Verilog术语:变量( variable)和程序( routine),而没有使用OOP中的属性( property)和方法( method)(本书实际上始终将 routine翻译为“程序或子程序—一译者)。
在 Verilog中,通过创建模块并且逐层例化就可以得到一个复杂的设计。在OOP中创建类并且例化它们(创建对象),你可以得到一个相似的层次结构下面是一个对这些OQP术语的比喻。将类视为一个房子的蓝图( blueprint),该设计图描述了房子的结构,但是你不能住在一个蓝图里,你需要建造一幢实际的房子。一个对象就是一个实际的房子。如同一组蓝图可以用来建造每个房子的各个部分,一个类也可以创建很多的对象。房子的地址就像一个句柄,它唯一地标志了你的房子。在你的房子里面,你有很多东西,例如带有开关的灯(开或者关)。类中的变量用来保存数值,而子程序用来控制这些数值。一个房子类可能具有很多盏灯,对 turn_on_porch_ light()的一个简单调用就可以将一个房子的走廊灯变量值置为ON。
5.6创建新对象
Verilog和OOP都具有例化的概念,但是在细节方面却存在着一些区别。一个 Verilog模块,例如一个计数器,是在代码被编译的时候例化的。而一个 System verilog类,例如个网络数据包,却是在运行中测试平台需要的时候才被创建。Ⅴerilog的例化是静态的,就像硬件一样在仿真的时候不会变化,只有信号值在改变。而 System Verilog中,激励对象不断地被创建并且用来驱动DUT,检查结果。最后这些对象所占用的内存可以被释放以供新的对象使用。O0P和Ⅴerilog之间的相似性也有一些例外。 Verilog的顶层模块是不会被显式地例化的。但是 System Verilog类在使用前必须先例化。另外, Verilog的实例名只可以指向一个实例,而 System verilog句柄可以指向很多对象,当然一次只能指向一个。
5.6.1实例化对象
在例5.2中,tr是一个指向 Transaction类型对象的句柄,因此tr可以简称为个 Transaction句柄。
例52声明和使用一个句柄
Transaction tr;//声明一个句柄
Tr=new();//为一个 Transaction对象分配空间
在声明句柄tr的时候,它被初始化为特殊值nu11。接下来,你调用new()函数来创建 Transaction对象。new函数为 Transaction分配空间,将变量初始化为默认值(二值变量为0,四值变量为X),并返回保存对象的地址。对于每一个类来讲, System Verilog创建一个默认的new函数来分配并初始化对象。有关new函数的更多细节,请参见5.6.2节。
5.6.2定制构造函数( Constructor)
有时候OOP术语会使一个简单的概念看起来复杂化。例化是什么意思?当你调用new函数例化一个对象的时候,你是在为该对象申请一个新的内存块来保存对象的变量。例如, Transaction类有两个32位的寄存器(addx和crc)以及一个具有八个元素(数据)的数组,总计包含10个长字( longword),或者说40个字节。所以当你调用new函数的时候, System verilog就会分配40字节的存储空间。如果你使用过C语言,你可以发现这个步骤跟mal1loc函数非常相似。(应当指出的是, System Verilog为四值变量使用更多的内存,并且会保存一些内部信息,例如对象的类型等。)
构造函数除了分配内存之外,它还初始化变量,在默认情况下,它将变量设置成默认数值——二值变量为0,四值变量为X,等等。你可以通过自定义new函数将默认值设成你想要的数值。这就是为什么new函数也称为“构造函数”,因为它创建对象,一如用木头和钉子建造你的房子。但是new函数不能有返回值,因为构造函数总是返回一个指向类对象的句柄,其类型就是类本身。
例5.3简单的用户定义的new()函数
class Transaction ;
logic [31:0] addr ,crc, data[8];
function new;
addr=3;
foreach (data[i])
data[i]=5;
endfunction
endclass
例5.3将addr和data设为固定数值,但是crc仍将被初始化位默认值X( Systemverilog自动为对象分配存储空间)。你可以使用具有默认值的函数参数来创建更加灵活的构造函数,如例5.4所示。这样你就可以在调用构造函数的时候给addr和data指定值或者使用默认值。
例54一个带有参数的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
initial
begin
Transaction tr;
tr=new(10); //data使用默认值
end
System verilog怎么知道该调用哪个new()函数呢?这取决于赋值操作符左边的句柄类型。在例5.5中,调用 Driver构造函数内部的new()函数,会调用 Transaction的new()函数,即使 Driver的new函数的定义离它更近。这是因为tr是Transaction句柄, System verilog会做出正确的选择,创建一个 Transaction类的对象。
例5.5调用正确的new()函数
class Transaction ;
...
endclass :Transaction
class Driver;
Transaction tr;
function new(); //Driver的new 函数
tr=new();//调用Transaction 的New函数
endfunction
endclass;Driver
5.6.3将声明和创建分开
你应该避免在声明一个句柄的时候调用构造函数,即new函数。虽然这样在语法上是合法的,但是这会引起顺序问题,因为在这时构造函数在第一条过程语句前就被调用了。你可能希望按照一定的顺序初始化对象,但是如果在声明的时候调用了new函数,你就不能控制这个顺序了。此外,如果你忘记了使用 automatic存储空间,构造函数将在仿真开始时,而非进入块的时候被调用。
5.6.4new()和new[ ]的区别
你可能已经注意到new()函数跟2.3节中用来设置动态数组大小的new[]操作看起来非常相似。它们都申请内存并初始化变量。两者最大的不同在于调用new()函数仅创建了一个对象,而new[ ]操作则建立一个含有多个元素的数组。new( )可以使用参数设置对象的数值,而new[ ]只需使用一个数值来设置数组的大小。
5.6.5为对象创建一个句柄
OOP的新手经常会混淆对象和对象的句柄。其实两者之间的区别是非常明显的。你通过声明一个句柄来创建一个对象。在一次仿真中,一个句柄可以指向很多对象。这就是OOP和 System verilog的动态特性。
在例5.6中,t1首先指向一个对象,然后指向另一个对象。图5.1给出了对象和指针最后的结果。
例5.6为多个对象分配地址
Transaction t1,t2;//声明两个句柄
t1=new();//为第一个Transaction对象分配地址
t2=t1; //t1 和t2 都指向该对象
t1=new();//为第二个Transaction对象分配地址
为什么我们希望动态地创建对象?在一次仿真过程中,你可能需要创建成百上千个事务。 System Verilog使你能够在需要的时候自动创建对象。在 verilog中,你只能使用固定大小的数组,而且这个数组必须要大到能够容纳最大数量的事务。应当指出的是,这种动态的对象创建不同于 Verilog语言之前所提供的任何特性。Verilog模块的实例和它的名字是在编译的过程中静态地捆绑在一起的。即使是在仿真过程中产生和自动注销的 automatic变量,名字和内存也总是捆在一起的。
5.7对象的解除分配( deallocation)
你已经知道了如何创建一个对象,但是你知道怎么回收它吗?例如,你的测试平台创建并且发起了上千次的事务,例如发到DUT的事务。一旦你得知事务已经成功完成,并且也得到了统计结果,你就不需要再保留这些对象了。这时候,你需要回收内存。否则,长时间的仿真会将内存耗尽,或者运行得越来越慢。垃圾回收是一种自动释放不再被引用的对象的过程。 System verilog分辨对象不再被引用的办法就是记住指向它的句柄的数量,当最后一个句柄不再引用某个对象了System verilog就释放该对象的空间。
例5.7创建多个对象
Transaction t; //创建一个句柄
t=new(); //分配一个新的Transaction
t=new(); //分诶第二个,并释放第一个
t=null; //解除分配第二个
例5.7的第二行调用new()创建了一个对象,并且将其地址保存在句柄t中。下个new()函数的调用创建了一个新的对象,并将其地址放在t中,覆盖了句柄t先前的值。因为这时候已经没有任何句柄指向第一个对象, System verilog就可以将其解除分配了。对象可以立刻被删除,或者等上一小段时间再删除。最后一行明确地清除句柄,所以至此第二个对象也可以被解除分配了。
如果你熟悉C++,这些对象和句柄的概念可能看起来不陌生,但是这两者存在着些非常重要的区别。 System verilog的句柄只能指向一种类型,即所谓的“安全类型”。在C中,一个典型的无类型指针只是内存中的一个地址,你可以将它设为任何数值,还可以通过预增量(pre- Increment)操作来改变它。这时候你无法保证指针一定是合法的。C++的指针相对安全些但和C有类似的问题。 System Verilog不允许对句柄作和C类似的改变,也不允许将一种类型的句柄指向另一种类型的对象。( System Verilog的OOP规范比起C++来更加接近Java)。
其次,因为 System verilog在没有任何句柄指向一个对象的时候自动回收垃圾,这就保证了代码中所使用的任何句柄都是合法的。而在C/C++中,指针可以指向一个不再存在的对象。在这些语言中,垃圾回收是手动的,所以当你忘了手动释放对象的时候,代码就可能会存在内存泄露。
System verilog不能回收一个被句柄引用的对象。如果你创建了一个链接表,除非手工设置所有的句柄为null,清除所有句柄,否则 SystemVerilog不会释放对象的空间。如果对象包含有从一个线程派生出来的程序,那么只要该线程仍在运行,这个对象的空间就不会被释放。同样的,任何被一个子线程所使用对象在该线程没有结東之前不会被解除分配。关于线程的更多信息请参见第7章。
5.8使用对象
现在你已经分配了一个对象,那么如何来使用它呢?回到 Verilog模块的对比,可以对对象使用“.”符号来引用变量和子程序,如例5.8所示。
例5.8使用对象的变量和子程序
Transaction t; //声明一个Transaction句柄
t=new(); //创建衣蛾Transaction对象
t.addr=32'h44; //设置变量的值
t.display();//调用一个子程序
严格的OOP规定,只能通过对象的公有方法访问对象的变量,例如get()和put(),这是因为直接访问变量会限制以后对代码的修改。如果将来出现一个更好的(或者另种)算法,你可能因为需要改变所有那些直接引用变量的代码,而导致你不能采用这种新的算法。
5.9静态变量和全局变量
每个对象都有自己的局部变量,这些变量不和任何其他对象共享。如果有两个Transaction对象,则每个对象都有自己的addr、crc和data变量。但有时候你需要个某种类型的变量,被所有的对象所共享。例如,可能需要一个变量来保存已创建事务的数目。如果没有OOP,可能需要创建一个全局变量。然后你就有了一个只被一小段代码所使用,但是整个测试平台都可以访问的全局变量。这会“污染”全局名字空间(namespace),导致即使你想定义局部变量,但是变量对每个人都是可见的。
5.9.1简单的静态变量
在 System Verilog中,可以在类中创建一个静态变量。该变量将被这个类的所有实例所共享,并且它的使用范围仅限于这个类。在例5,9中,静态变量 count用来保存迄今为止所创建的对象的数目。它在声明的时候被初始化为0,因为在仿真开始不存在任何的事务。每构造一个新的对象,它就被标记为一个唯一的值,同时 count将被加1。
例5.9含有一个静态变量的类
class Transaction;
static int count =0; //已经创建的对象的数目
int id; //实例的唯一标志
function new ();
id=count++;//设置标志,count递增
endfunction
endclass
Transaction t1,t2;
initial
begin
t1=new(); //第一个实例,id=0,count=1
t2=new(); //第二个实例id=1,count=2
$display("Second id =%d ,count=%d ",t2.id,t2.count);
end
在例5.9中,不管创建了多少个 Transaction对象,静态变量 count只存在一个。你可以认为 count保存在类中而非对象中的。变量id不是静态的,所以每个 Transaction都有自己的id变量,如图52所示。这样,你就不需要为 count创建一个全局变量了。
使用ID域是在设计中跟踪对象的一个非常好的方法。在调试测试平台的时候,你经常需要一个唯一的值。 System Verilog不能输出对象的地址,但是可以创建ID域来区分对象。当你打算创建一个全局变量的时侯,首先考虑创建一个类的静态变量。一个类应该是自给自足的,对外部的引用越少越好。
5.9.2通过类名访问静态变量
例5.9中使用了句柄来引用静态变量。其实无需使用句柄,你可以使用类名加上::,即类作用域操作符,如例5.10所示。
class Transaction;
static int count =0; //已经创建的对象的数目
int id; //实例的唯一标志
function new ();
id=count++;//设置标志,count递增
endfunction
endclass
`include "class_static.sv"
module tb;
Transaction t1,t2;
initial
begin
t1=new(); //第一个实例,id=0,count=1
//$display("Second id =%d ,count=%d ",t1.id,t1.count);
$display("first count =%d ,", Transaction::count);
t2=new(); //第二个实例id=1,count=2
//$display("Second id =%d ,count=%d ",t2.id,t2.count);
$display ("second count is %d ",Transaction::count);
end
endmodule
5.9.3静态变量的初始化
静态变量通常在声明时初始化。你不能简单地在类的构造函数中初始化静态变量,因为每一个新的对象都会调用构造函数。你可能需要另一个静态变量来作为标志,以标识原始变量是否已被初始化。如果需要做一个更加详细的初始化,你可以使用初始化块。但是要保证在创建第一个对象前,就已经初始化了静态变量。
5.9.4静态方法
静态变量的另一种用途是在类的每一个实例都需要从同一个对象获取信息的时候。例如, transaction类可能需要从配置对象获取模式位。如果该位在 transaction类中定义成了非静态句柄,那么每一个对象都会有一份模式位的拷贝,造成内存浪费。例5.11举例说明了如何使用静态变量。例5.11句柄的静态存储
class Transaction;
static Condig cfg;//使用静态存储的句柄
MODE_E mode;
function new ();
mode=cfg.mode;
endfunction
endclass
Config cfg;
initial
begin
cfg=new(MODE_ON);
Transaction:: cfg =cfg;
end
当你使用更多的静态变量的时候,操作它们的代码会快速增长为一个很大的程序。在 System Verilog中,你可以在类中创建一个静态方法以用于读写静态变量,甚至可以在第一个实例产生之前读写静态变量。
例5.12中含有一个简单的静态函数来显示静态变量的值。 System verilog不允许静态方法读写非静态变量,例如id。你可以根据下面的代码来理解这个限制。在例5.12的最后调用 display_static函数的时候,还没有创建任何 Transaction类的对象,所以还没有为变量id分配存储空间。
例5.12显示静态变量的静态方法
class Transaction;
static Config cfg;
static int count=0;
int id;
//显示静态变量的静态方法
static function void display_statics();
$display("Transaction cfg.mode %s ,count =%0d ",cfg.mode.name(),count);
endfunction
endclass
initial
begin
cfg=new(MODE_ON);
Transaction ::cfg =cfg;
Transaction :;display_statics();
end
5.10类的方法
类中的程序也称为方法,也就是在类的作用域内定义的内部task或者 function。例5.13为类 Transaction和 PCI_Tran定义了 display()方法。 SystemⅤ erilog会根据句柄的类型调用正确的 display()方法。
例5.13类中的方法
class Transaction;
bit [31:0] addr,crc ,data[8];
function void display();
$display("@ %0t :TR addr =%h ,crc=%h",$time,addr,crc);
$write("\t data [0-7]=");
foreach (data[i])
$write(data[i]);
$display();
endfunction
endclass
class PCI_Tran;
bit [31:0] addr,data;//使用真实的名字
function void display();
$display("@%0t :PCI :addr=%h,data=%h",$time,addr,data);
endfunction
endclass
Transaction t;
PCI_Tran pc;
initial
begin
t=new();//创建一个Transaction 对象
t.display();; //调用Transaction的方法
pc=new();//创建一个PCI事务
pc.display(); //调用 PCI事务的方法
end
类中的方法默认使用自动存储,所以你不必担心忘记使用 automatic修饰符。
5.11在类之外定义方法
在 System Verilog中你可以将方法的原型定义(方法名和参数)放在类的内部,而方法的程序体(过程代码)放在类的后面定义。
下面是一个如何创建一个块外声明的例子。复制该方法的第一行,包括方法名和参数,然后在开始处添加关键词 extern。然后将整个方法移至类定义的后面,并在方法名前加上类名和两个冒号(::作用域操作符)。上例中的类可以如下定义。
例5.14块外方法声明
class Transaction;
bit [31:0] addr,crc ,data[8];
extern function void display();
endclass
function void Transaction::display();
$display("@ %0t :TR addr =%h ,crc=%h",$time,addr,crc);
$write("\t data [0-7]=");
foreach (data[i])
$write(data[i]);
$display();
endfunction
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
Transaction t;
PCI_Tran pc;
initial
begin
t=new();//创建一个Transaction 对象
t.display();; //调用Transaction的方法
pc=new();//创建一个PCI事务
pc.display(); //调用 PCI事务的方法
end
方法的原型定义跟内容不相匹配是一个常见的编码错误。 SystemVerilog要求除了多一个类名和作用域操作符之外,原型定义跟块外的方法定义一致。此外有些OOP编译器(g十十和VCS)禁止在原型和类的内部指定参数的默认值。因为参数默认值对于调用该方法的代码非常重要,但是对于如何实现就不那么重要了,所以它们最好放在类定义部分。
另一个常见错误是在类的外部声明方法时忘记写类名。这样做的结果是它的作用范围高了一级(也许是在整个程序或包的范围内都可调用),当某个任务试图访问类一级的变量和方法的时候编译器就会报错如例5.15所示。
例5.15类的外部任务定义忘记类名
class Broken;
int id;
extern function void display;
endclass
function void display;//忘记::分隔符
$display("Broken :id =%0d ",id);//错误,找不到id
endfunction
5.12作用域规则
在编写测试平台的时候,需要创建和引用许多的变量。 System verilog采用与 Verilog相同的基本规则,但是略有改进。作用域是一个代码块,例如一个模块,一个程序、任务、函数、类或者 begin-end块。For和 foreach循环自动创建一个块,所以下标变量可以作为该循环作用域的局部变量来声明和创建。你可以在块中定义新的变量。 System Verilog中新增的特性是可以在一个没有名字的begin-end块中声明变量,例如for循环内定义了索引变量名字可以相对于当前作用域,也可以用绝对作用域表示,例如以$root开始。对于一个相对的名字, System Verilog查找作用域内的名字清单,直到找到匹配的名字。如果不想引起歧义,可以在名字的开头使用 $root。
例5.16在不同的作用域内使用了相同的名字。应当指出的是,在实际的代码里应当使用更加有意义的名字。例子中的limit被用作全局变量、程序变量、类变量,任务变量和初始化块中的局部变量。后者是一个未命名的块,所以最终创建的标记( label)取决于具体的工具。
例5.16名字作用域
program automatic p;
int limit; //$root.p.limit
class Foo;
int limit ,array[]; //$root.p.Foo.limit
//$root.p.Foo.print.limit
function void print(int limit)
for (int i=0; i<limit;i++)
$display("%m ::arrat[%0d]=%0d",i,array[i]);
endfunction
endclass
initial
begin
int limit =$root.limit;
Foo bar;
bar=new;
bar.array=new[limit];
bar.print(limit)
end
endprogram
对测试平台来说,你可以在 program或者 initial块中声明变量。如果一个变量仅在一个 initial块中使用,例如计数器,应当在使用它的块中声明,以避免跟其他块出现潜在的冲突。注意:如果在一个未命名的块内定义变量,如例5.16中的 initia1块,那么最终在各种工具中的层次结构名字就可能完全不同。
类应当在 program或者 module外的 package中定义。这应当是所有测试平台都该遵守的,你可以将临时变量在测试平台最内部的某处定义,如果在一个块内使用了一个未声明的变量,碰巧在程序块中有一个同名的变量,那么类就会使用程序块中的变量,不会给出任何的警告。在例5,17中,函数Bad: display没有声明循环变量i,所以 System Verilog将使用程序级变量的i。调用该函数就会改变test.i的值,这可能不是你所希望的!
例5.17使用了错误的变量的类
program test;
int i ;//程序级变量
class Bad;
logic [31:0] data[];
//调用该函数将会改变程序级的变量i
function void display;
//在下面的雨具里忘了声明i
for (i=0; i<data.size;i++)
$display("data[%0d ]=%x",i,data[i]);
endfunction
endclass
endprogram
如果你将类移到一个 package中,那么类就看不到程序一级的变量了,由此就不会无意调用到它了。
例5.18将类移入 package来查找程序错误
package Mistake;
class Bad;
logic [31:0] data[];
//未定义i,不会被编译
function void display;
for (i=0; i<data.size;i++)
$display("data[%0d]=%x",i,data[i]);
endfunction
endclass
endpackage
program test;
int i; //程序级变量
import Mistake::*;
endprogram
5.12.1this是什么
当你使用一个变量名的时候, System verilog将先在当前作用域内寻找,接着在上一级作用域内寻找,直到找到该变量为止。这也是 Verilog所采用的算法。但是如果你在类的很深的底层作用域,却想明确地引用类一级的对象呢?这种风格的代码在构造函数里最常见,因为这时候程序员使用相同的类变量名和参数名。在例5.19中,关键词“this”明确地告诉 System verilog你正在将局部变量 oname赋给类一级变量 oname。
例5.19使用this指针指向类一级变量
class scoping ;
string oname;
function new(string oname);
this.oname=oname; //类变量oname=局部变量onme
endfunction
endclass
5.13在一个类内使用另一个类
通过使用指向对象的句柄,一个类内部可以包含另一个类的实例。这如同在 Verilog中、在一个模块内部包含另一个模块的实例,以建立设计的层次结构。这样包含的目的通常是重用和控制复杂度。例如,你的每一个事务都可能需要一个带有时间戳的统计块,它记录事务开始和结束的时间,以及有关此次事务的所有信息,如图5.3所示。
例5.20给出了 statistics类的定义。
例5.20 statistics类的声明
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 Transaction ;
bit[31:0] addr,crc,data[9];
Statistics stats;//Statistics 句柄
function new();
stats=new();//创建stats实例
endfunction
task create_packet();
//填充数据包
stats.start();
//传送数据包
endtask
endclass
最外层的类 Transaction可以通过分层调用语法来调用 Statistics类中的成员例如 stats. startT。一定要记得例化对象,否则句柄 stats是nu11,调用 start会失败。这最好在上层即 Transaction类的构造函数中完成。
5.13.2编译顺序的问题
有时候你需要编译一个类,而这个类包含一个尚未定义的类。声明这个被包含的类的句柄将会引起错误,因为编译器还不认识这个新的数据类型。这时侯你需要使用typedef语句声明一个类名,如下例所示。
例5.22使用 typedef class语句
typedef class Statistics;//定义低级别的类
class Transaction ;
Statistics stats;//使用Statistics类
...
endclass
class Statistics ;//定义Statistics 类变量oname
...
endclass
5.14理解动态对象
在静态分配内存的语言中,每一块数据都有一个变量与之关联,例如 verilog中可能有一个wire类型的变量 grant,整数变量 count和一个模块实例i1。在OOP中,不存在这种一一对应关系。可能有很多对象,但是只定义了少量句柄。一个测试平台在仿真过程中可能产生了数千次事务的对象,但是仅有几个句柄在操作它们。如果你只写过verilog代码,对于这种情况你可能需要好好地适应一下。
在实际使用中,每一个对象都有一个句柄。有些句柄可能存储在数组或者队列中,或者在另一个对象中,例如链表。对于保存在邮箱( mailbox)中的对象,句柄就是 SystemVerilog的一个内部结构。关于邮箱的更加详细的信息参见7.6节。
5.14.1将对象传递给方法
当你将对象传递给一个方法的时候发生了什么?也许这个方法只需要读取对象中的值,例如上面的 transmit.。又或者你的方法可能会修改对象的值,例如创建一个数据包的方法。不管是哪一种情形,当你调用方法的时候,传递的是对象的句柄而非对象本身。
在图5,4中,任务 generator调用了 transmit。两个句柄 generator.t和transmit.t都指向同一个对象。
当你调用一个带有标量变量(不是数组,也不是对象)的方法并且使用ref关键词的时候, System verilog传递该标量的地址,所以方法也可以修改标量变量的值。如果你不使用ref关键词, System verilog将该标量的值复制到参数变量中,对该参数变量的任何改变不会影响原变量的值。
例5.23传递对象
//将包传送到一个32位总线上
task transmit (Transaction t);
bus.rx_data<=t.data;
t.stats.startT=$time;
endtask
Transaction t;
initial
begin
t=new();//为对象分配空间
t.addr=42;//初始化数值
transmit(t);//将对象传递给任务
end
在例5.23中,初始化块先产生一个 Transaction对象,并且调用 transmit任务,transmit任务的参数是指向该对象的句柄通过使用句柄, transmit可以读写对象中的值。但是,如果 transmit试图改变句柄,初始化块将不会看到结果,因为参数t没有使用ref修饰符。
方法可以改变一个对象,即使方法的句柄参数没有使用ref修饰符。这容易给新用户带来混淆,因为他们将句柄和对象混为一谈。如上例所示, transmit可以在不改变句柄t的情况下为对象加盖时间戳。如果你不想让对象在方法中被修改,那么就传递一个对象的拷贝给方法,这样原来的数据就保持不变。关于对象复制的更多信息参见5.15节。
5.14.2在任务中修改句柄
一个常见的编码错误是,当你想修改参数的值的时侯,忘记在方法的参数前加ref关键词,尤其是句柄。在例5.26中,参数tx没有被声明为ref,所以在方法内部对tx的修改不会被调用该方法的代码看到。参数tr默认的信号方向是 input。
例5.24错误的事务生成任务,句柄前缺少关键词ref
function void create(Transaction tr);//错误,缺少ref
tr=new();
tr.addr=42;
//初始化其他域
endfunction
Transaction r;
initial begin
create(t);//创建一个transaction
$display(t.addr);//失败,因为t=null
end
尽管 create修改了参数tr,调用块中的句柄t仍为nu11。你需要将参数tr声明为ref。
例5.25正确的事务发生器,参数是带有ref的句柄
function void create (ref Transaction tr);
...
endfunction //create
5.14.3在程序中修改对象
在测试平台中,一个常见的错误是忘记为每个事务创建一个新的对象。在例5.26中, generator bad任务创建了一个有随机值的Transaction对象,然后将它多次传送给设计。
例5.26错误的发生器,只创建了一个对象
task generator_bad(int n);
Transaction t;
t=new();//创新一个新对象
repeat (n)
begin
t.addr=$rondom();//变量初始化
$display("second addr =%h",t.addr);
transmit(t); //将它发送到DUT
end
endtask
这个错误的症状是什么?上面的代码仅创建一个 Transaction,所以每一次循环,generator bad在发送事务对象的同时又修改了它的内容。当你运行这段代码的时候,Display会显示很多不同的addr值,但是所有被传送的 Transaction都有相同的addx数值。如果 transmit的线程需要耗费几个周期完成发送,就有可能出现这种错误,因为对象的内容在传送的期间被重新随机化了。如果 transmit任务发送的是对象的副本,你就可以多次重复利用这个对象了。这种错误也会发生在邮箱中,如例7.31所示。
为避免出现这种错误,你需要在每次循环的时候创建一个新的 Transaction对象。
例5.27正确的产生器,创建多个对象
task generator_good(int n );
Transaction t;
repeat(n)
begin
t=new();//创建一个新对象
$display("sending addr=%h",t.addr);
transmit(t);//将它发送到DUT
end
endtask
5.14.4句柄数组
在写测试平台的时候,可能需要保存并且引用许多对象。你可以创建句柄数组,数组的每一个元素指向一个对象。例5.28给出了一个保存十个总线事务的句柄数组。
例5.28使用句柄数组
task generator();
transmit tarry[10];
foreach (tarry[i])
begin
tarry[i]=new();//创建每一个对象
transmit(tarry[i]);
end
endtask
array数组由句柄构成,而不是由对象构成。所以需要在使用它们之前创建所有对象,就像你为一个普通的句柄创建对象一样。没有任何办法可以调用new函数为整个句柄数组创建对象。
不存在“对象数组”的说法,虽然可以使用这个词来代表指向对象的句柄数组。你应当牢记这些句柄可能并没有指向一个对象,也可能有多个句柄指向了同一个对象。
5.15对象的复制
有时候可能需要复制一个对象,以防止对象的方法修改原始对象的值,或者在一个发生器中保留约束。可以使用简单的new函数的内建拷贝功能,也可以为更复杂的类编写专门的对象拷贝代码。8.2节介绍了为什么需要创建一个复制方法。
5.15.1使用new操作符复制一个对象
使用new复制一个对象简单而且可靠,它创建了一个新的对象,并且复制了现有对象的所有变量。但是你已经定义的任何new()函数都不会被调用。
例5.29使用new复制一个简单类
class Transaction;
bit[31:0] addr,crc,data[8];
endclass
Transaction src,dst;
initial
begin
src=new;//创建第一个对象
dst=new src;//使用new 操作符进行复制
end
这是一种简易复制( shallow copy),类似于原对象的一个影印本,原对象的值被盲目地抄写到目的对象中。如果类中包含一个指向另一个类的句柄,那么,只有最高一级的对象被new操作符复制,下层的对象都不会被复制。在例5.30中, ransaction类包含了个指向 statistics类的句柄,原始定义见例5.20。
例5.30使用new操作符复制一个复杂类
class Transaction;
bit [31:0] addr ,crc, data[8];
static int count =0;
int id;
Statistics stats;//指向Statistics对象的句柄
function new;
stats=new();//构造一个新的Statistics对象
id=count++;
endfunction
endclass
Transaction src,dst;
initial begin
src=new();//创建一个transaction对象
src.stats.startT=42;
dst=new src;//用new操作符将src拷贝到dst中
dst.stats.startT=96;//改变dst 和src的stats
$display(src.stats.startT);
end
初始化块创建第一个 Transaction对象,并且修改了它内部 statistics对象的变量,见图5.5。
当调用new函数进行复制的时候, Transaction对象被拷贝,但是 Statistics对象没有被复制。这是因为当你使用new操作符复制一个对象的时候,它不会调用你自己的new()函数。相反的变量和句柄的值被复制,所以现在两个 Transaction对象都具有相同的id值,如图5.6所示。
更糟糕的是,两个 Transaction对象都指向同一个 Statistics对象,所以使用src句柄修改 startT会影响到dst句柄可以看到的值。
5.15.2编写自己的简单复制函数
如果你有一个简单的类,它不包含任何对其他类的引用,那么编写copy函数非常容易。
例5.31含有copy函数的简单类
class Transaction ;
bit[31:0] addr,crc,data[8];//没有Statistics句柄
function Transaction copy;
copy =new();//创建目标对象
copy.addr=addr;//填入数值
copy.crc=crc;
copy.data=data;//赋值数组
endfunction
endclass
例5.32使用copy函数
Transaction src dst;
initial
begin
src=new();//创建第一个对象
dst=src.copy;//赋值对象
end
5.15.3编写自己的深层复制函数
对于并非简单的类,你应该创建自己的copy函数。通过调用类所包含的所有对象的copy函数,可以做一个深层的拷贝。你自己的copy函数需要确保所有用户域(例如ID)保持一致。创建自定义copy函数的最后阶段需要在新增变量的同时更新它们。
例5.33复杂类的深层复制函数
class Transaction ;
bit[31:0] addr,crc,data[8];
Statistics stats;//指向Statistics对象的句柄
static int count=0;
int id;
function new;
stats=new();
id=count++;
endfunction
function Transaction copy;
copy=new();//创建目标
copy.addr=addr;//填入数值
copy.crc=crc;
copy.data=data;
copy.stats=stats.copy();//调用Statstics::copy函数
id=count++;
endfunction
endclass
copy调用了构造函数new(),所以每一个对象都有一个唯一的id。需要为 statistics类和层次结构中的每一个类增加一个copy()方法。
例5.34 statistics类定义
class Statistics;
time startT,stopT;//Transaction的事件戳
function Statistics copy;
copy=new();
copy.startT=startT;
copy.stopT=stopT;
endfunction
endclass
这样一来当你复制一个 Transaction对象的时候,它会有自己的 statistics对象,如例5.35所示。例5.35使用new操作符复制复杂类
Transaction src,dst;
initial
begin
src=new();//创建第一个对象
src.stats.startT=42;//设置起始时间
dat=src.copy();//使用深层赋值将src赋值给dst
dst.stats.startT=96;//仅改变dst的stats的值
$display(src.stats.startT);//
end
5.15.4使用流操作符从数组到打包对象,或者从打包对象到数组
某些协议,如ATM协议,每次传输一个字节的控制或者数据值。在送出一个 transaction之前,需要将对象中的变量打包成一个字节数组。类似的,在接受到一个字节串之后,也需要将它们解包到一个 transaction对象中。这两种功能都可以使用流操作符来完成,流操作符的例子见2.11.3节。
你不能将整个对象送入流操作符,因为这样会包含所有的成员,包括数据成员和其他额外信息,如时间戳和你不想要打包的自检信息。你需要编写你自己的pack函数,仅打包你所选择的成员变量。
例5.36含有pack和 unpack函数的 Transaction类
class Transaction;
bit[31:0] addr,crc,data[8];//实际数据
static int count =0;//不需要打包的数据
int id;
function new();
id=count++;
endfunction
function void display();
$write("Tr :id =%0d ,addr =%x,crc =%x",id,addr,crc);
foreach (data[i])
$write("%x",data[i]);
$display;
endfunction
function void pack (ref byte bytes[40]);
byte={>>{addr,crc,data}};
endfunction
function Transaction unpack(ref byte bytes[40]);
{>>{addr,crc,data}}=bytes;
endfunction
endclass:Transaction
例5.37使用pack和 unpack函数
Transaction tr,tr2;
byte b[40];//addr+crc+data=40字节
initial
begin
tr=new();
tr.addr=32'h0a0a0a0;//填满对象
tr.crc=1;
foreach (tr.data[i])
tr.data[i]=i;
tr.pack(b);//打包对象到字节数组
$write("Pack results:");
foreach (b[i])
$write("%h",b[i]);
$display;
tr=new();
tr2.unpack(b);
tr.display();
end
5.16公有和私有
OOP的核心概念是把数据和相关的方法封装成一个类。在一个类中,数据默认被定义为私有,这样防止了其他类对内部数据成员的随意访问。类会提供一系列的方法来访问和修改数据。这也使得你能够在不让类用户知道的情况下修改方法的具体实现方式。例如,一个图形包可能会将它的内部表示法由笛卡儿坐标变成极坐标,而用户接口(访问的方法)的功能却不会改变。考虑一下 Transaction含有一个载荷( payload)和一个CRC,这样硬件就可以检测到错误。在传统的OOP中,你会定义一个方法设置载荷的值同时也设置CRC的值,这样它们就可以保持同步。这样你的对象就总是具有正确的数值。
但是测试平台不同于其他的程序,例如网页浏览器或者文字处理器。一个测试平台需要能够注入错误。你需要产生一个错误的CRC,以便测试硬件是如何处理错误的。OOP语言诸如C十十和Java使你能够制定变量和方法的可见性。默认情况下,任何成员都是局部的,除非加上了标记。
在 System verilog中,所有成员都是公有的,除非标记为local或者protected。你应当尽量使用默认值,以保证对DUT行为的最大程度的控制,这比软件的长期稳定性更加重要。例如,CRC公有将使你能够轻易地往DUT中注入错误。如果CRC是局部的,你可能需要编写额外的代码来避开数据隐藏机制,最终使测试平台变得更大更复杂。
5.17题外话
作为OOP的初学者,你可能不愿意将数据封装成类,而仅将数据存放在一些变量中。避免这种想法!一个简单的DUT监视器可能只在接口上采样几个数值,但不要将它们简单地保存在整数变量中然后传递给下一级。这样可能会在一开始节省一点时间,但最终你还是需要将这些数值组合到一起以构成一个完整的事务。这些事务中的几个可能需要被组合成更高级别的事务,例如DMA事务。所以,应该立刻将这些接口数值封装成一个事务类。这样,你就可以在保存数据的时候同时保存相关信息(端口号、接收时间),然后将该对象传递给测试平台的其他部分。
5.18建立一个测试平台
你现在已经离使用类创建一个简单的测试平台更近了一步了。下面是第1章中的图。显然,图5.9中的事务是对象,但是每一个块也代表了一个类。
图中的 Generator、 Agent、 Driver、 Monitor、 Checker和 Scoreboard都是类,被建模成事务处理器( transactor)。它们在 Environment类内部例化。为了简单起见,Test处在最高层,即处在例化 Environment类的程序中。功能覆盖( Functional Coverage)的定义可以放在 Environment类的内部或者外部。
事务处理器由一个简单的循环构成,这个循环从前面的块接受事务对象,经过变换后送给后续块。有一些块,例如 Generator,没有上游块,所以该事物处理器就创建和随机复制每一个事务,而其他的对象例如 Driver接收到一个事务然后将其作为信号发送到DUT中。
例5.38基本的事务类
class Transaction;//通用类
Transaction tr;
task run;
forever begin
//从前一个块中获取事务
...
//做一些处理
//...
//发送到下游块中
//...
end
endtask
endclass
在块之间如何交换事务呢?在程序性的代码中,你需要在一个对象里调用另一个对象,或者使用FIFO之类的数据结构来保存块之间的事务。在第7章你将会学到如何使用邮箱,一种能够延迟一个线程直到有新的数值加入的FIFO。