验证一个设计需要经过几个步骤:生成输入激励、捕获输出响应、决定对错和衡量进度。但是首先你需要一个合适的测试平台,将它连接到设计上,如下图:
测试平台包裹着设计,发送激励并且捕获设计的输出。测试平台组成了设计周围的“真实世界”,模仿设计的整个运行环境。由于verilog的端口描述繁琐,代码常会长达数页,并且容易产生连接错误,所以测试平台需要一种更高层次的方法来跟设计建立通信。你需要一种可靠的描述时序的方法,这样就可以在正确的时间点驱动和采样同步信号,避免verilog模型中常见的竞争状态。
理想的开发过程,要求所有的项目都有两个独立的小组:一个小组创建设计,另一个小组验证设计。这两个小组各自阅读最初的设计规范,然后各自做出解释。设计者需要编写满足规范的代码,而验证工程师需要创建使得设计不满足设计规范的场景。
同样,测试平台的代码独立于设计的代码。在verilog中,两种代码处在不同的模块中。但是使用模块来保存测试平台经常会引起驱动和采样上的时序问题,所以SV引入程序块(program block),从逻辑上和时间上来分开测试平台。
随着设计复杂度的增加,模块之间的连接也变得更加复杂。两个RTL模块之间可能有几十个连接信号,这些信号必须按照正确的顺序排列使得它们能够正常通信。一旦出现不匹配的连接或者错误连接,设计就不能正常工作了。解决上述问题的方法就是使用接口,它是SV中一种代表一捆连线的结构,具有智能同步和连接功能的代码。一个接口可以像模块那样例化,也可以像信号一样连接到端口。
下图是顶层设计的框图,包括测试平台、仲裁器、时钟发生器和连接的信号。
下面的代码是把RTL模块连接到测试平台的例子。SV已经扩展了传统的reg类型,新名字是logic。唯一不能使用logic变量的地方就是含有多个驱动的连线,这个时候必须使用连线类型如wire。
测试平台定义在另一个模块中,与设计所在的模块相互独立。一般来说,测试平台通过端口与设计连接。
顶层网单连接了测试平台和DUT,并且含有一个简单的时钟发生器(clock generator)。
上例中的网单很简单,但是真实的设计往往有数百个端口信号,需要数页代码来声明信号和端口。所有的这些连接是极容易出错的,因为一个信号可能流经好几个设计层次,必须一遍又一遍的连接和声明。最糟糕的是如果想添加一个信号,它必须在多个文件中定义和连接,SV接口可以解决这些问题。
SV使用接口为块之间的通信建模,接口可以看做一捆智能的连线。接口包含了连接、同步、甚至两个或多个块之间的通信功能,它们连接了设计块和测试平台。
下图给出了测试平台和DUT之间使用接口通信的实例。注意接口扩展到了这两个块中,包括了测试平台和DUT的驱动和接收功能模块。时钟可以是接口的一部分或者一个独立的端口。
最简单的接口仅仅是一组双向信号的组合。这些信号使用logic数据类型,可以使用过程语句驱动。
下面的DUT使用了接口而非端口:
下例给出了测试平台,可以通过使用实例名arbif.request来引用接口的信号。接口信号必须使用非阻塞赋值来驱动!
所有的块都在top模块中连接:
如果希望在一个接口中放入一个新的信号,只需要在接口定义和实际使用这个接口的模块中修改。这种特性极大降低了连线出错的概率。使用接口时需要确保在模块和程序块之外声明接口变量。下例将包含接口定义的语句紧跟在其他语句的后面,这是一个常见错误。
可以将接口的信号直接连接到每个端口上,看下例:
在接口中使用modport结构可以将信号分组并且指定方向。看下例:
下面是相应的DUT和测试平台,它们都在各自的端口连接表中使用了modport。应当指出的是需要将modport名即DUT或者TEST放在接口名的后面。除了modport名外,其他部分与前面的例子相同。
顶层模块与之前相比没有什么变化,因为modport只需要在模块首部指明,而在模块例化时不需要指明。尽管代码没有多大变化,但是这个接口指明了信号的方向,更加确切代表了真实的设计。
在设计中可以通过两种方式来使用这些modport名:可以在使用接口信号的程序和模块中使用modport名;也可以在顶层模块中使用modport名,然后把接口放到程序和模块的端口列表里。本书推荐前一种方法,因为modport是实现的细节,不应分散在顶层模块中。但是,你可能需要多次例化一个模块,它们分别连接到不同的modport,即有不同的接口信号组。在这种情况下,需要在例化模块的时候指明modport而非在模块中指明。
并非接口中的每个信号都必须要连接,接口需要为主从设备和仲裁器定义三个modport,此外还需要一个监视modport。
可以使用monitor modport创建一个总线监视模块,如下例:
在接口中不能例化模块,但是可以例化其他接口;
使用接口的优势如下:
(1)接口便于设计重用,当两个块之间有两个以上的信号连接,并且用特定的协议通信时,应当考虑使用接口。如果信号组一次又一次的重复出现,例如在网络交换机中,那么就应该使用虚拟接口。
(2)接口可以用来替代原来需要在模块中反复声明并且位于代码内部的一系列信号,减少了连接错误。
(3)要增加信号时,在接口中只需要声明一次,不需要在更高层次的模块中声明,减少了连接错误的可能性。
(4)modport允许一个模块很方便的将接口中的一系列信号捆绑在一起,也可以为信号指明方向以方便工具自动检查。
接口的劣势如下:
(1)对于点对点的连接,使用modport的接口描述与使用信号列表的端口一样冗长。接口带来的好处是所有的声明集中在一起,减少了出错几率。
(2)必须同时使用信号和接口名,可能会使模块更加冗长。
(3)如果要连接的两个模块使用的是一个不会被重复的专用协议,使用接口需要做比端口连线更多的工作。
(4)连接两个不同的接口很困难,你需要拆分出独立的信号并正确驱动它们。
测试平台 与DUT之间的时序必须密切配合。在时钟周期级的测试平台,需要在相对于时钟信号的合适时间点驱动和接收同步信号。驱动的太晚或者采样的太早,测试平台的动作就会错过一个周期。即使在同一个时间片内(例如所有的时间都发生在100ns),设计和测试平台的事件也会引起竞争状态,比如同一个信号同时被读取和写入,读取到的数值究竟是原数值还是刚写入的数值?在verilog中,非阻塞赋值可以在测试模块驱动DUT的时候解决这个问题,但是测试程序不能确保采集到DUT产生的最新值。SV中有几种结构可以帮助你控制通信中的时序问题。
接口块可以使用时钟块来指定同步信号相对于时钟的时序。时钟块中的任何信号都将同步的驱动或采样,这样保证了测试平台在正确的时间点与信号交互。时钟块大都在测试平台中使用,也可以创建抽象的同步模型。
一个接口可以包含多个时钟块,因为每个块中都只含有一个时钟表达式,所以每一个对应一个时钟域。典型的时钟表达式如@(posedge clk)定义了单沿时钟,而@(clk)定义了DDR时钟(双数据率)。
也可以在时钟块中使用default语句指定一个时钟偏移,但是默认情况下输入信号仅在设计执行前被采样,并且设计的输出信号在当前时间片内又被驱动会当前设计。
一旦定义了时钟块,测试平台就可以用@arbif.cb表达式等待时钟,而不需要描述确切的时钟信号和边沿。这样即使改变了时钟块中的时钟或者边沿,也不需要修改测试平台的代码。看下例:
时钟模块cb声明了块中的信号在时钟的上升沿有效,同时将request和grant视为同步信号,信号的方向是相对于modport的。
如果测试平台在接口中使用过程赋值语句驱动一个异步信号,那么该信号必须是logic类型。wire类型变量只能被连续赋值语句驱动,此外时钟块中的信号始终是同步的,可以定义为wire或者logic。
接口中的信号使用logic类型的另一个原因是如果你无意使用了多个元件驱动的驱动源,编译器会自动报错。
测试平台不仅在逻辑上而且在时序方面独立于设计。 我们来看测试仪如何使用同步信号与芯片通信。在实际的硬件设计中,DUT中的存储单元在时钟的有效沿锁存输入信号。这些数值由存储单元输出,然后通过逻辑块到达下一个存储单元。从上一个存储单元的输入到下一个存储单元输入的延时必须小于一个周期。所以测试仪需要在时钟沿之后驱动芯片的输入,然后在下一个时钟沿之前读取输出。
测试平台需要模仿测试仪的这种行为,它应当在有效时钟边沿或时钟边沿之后驱动待测设计,然后在有效时钟边沿达到之前,在满足协议时序的情况下,尽可能晚的采样。
如果DUT和测试平台仅仅由verilog模块构成,这几乎是不可能实现的。如果测试平台在时钟边沿驱动DUT,就会存在竞争状态。解决这个问题的一种方法是给系统添加一点小小的延迟,比如#0.这迫使verilog代码的线程停止并在所有其他代码完成后重新调度执行。但是在大型设计中,往往不可避免的是存在多个线程都想在最后执行,那么谁的#0将胜出呢?实际情况是,每次运行的结果都可能不同,并且不同仿真器的结果也是不可预测的,所以要避免使用#0,以免代码不稳定并且不可移植。
另一个解决办法是使用一个较大的延时,#1.但是也会产生与#0相同的问题,应该避免使用。
下例给出了一个在设计和测试平台间可能存在竞争状态的实例。竞争状态出现在测试平台先产生start信号,然后再产生其他信号的时候。内存被start信号唤醒的时候,write、addr和data信号却还保留着原来的值。你可以使用非阻塞赋值将这些信号都做一个细微的延迟,但是不要忘记这个时候测试平台和设计都在使用这些赋值语句。设计和测试平台之间依然存在竞争状态的可能性。
对设计的输出信号的采样存在着同样的问题。你希望在时钟沿到来之前的最后时刻捕获数据,但是你不能再出现时钟边沿的时候采样,因为设计的输出值可能已经变了,应当在时钟沿到来之前的Tsu时间上采样。
出现竞争的根源在于设计和测试平台的事件(event)混合在同一个时间片(time slot)内,即使在纯RTL程序中也会发生相同的问题。这就需要将两种事件进行分开调度!在SV中,测试平台的代码在一个程序块中,这和模块非常相似:模块含有代码和变量,可以在其他模块中例化。但是程序块不能有任何的层次级别,例如模块的实例、接口或者其他程序。
SV中引入一种新的时间片划分方式,如下图。在verilog中,大多数的事件在有效区域(active region)执行。
在一个时间片内首先执行的是active区域,在这个区域中运行设计事件,包括RTL、门级代码和时钟发生器(clock generator)。第二个区域是observed区域,执行断言。接下来是执行测试平台的reactive区域。注意到时间并不是单向的向前流动-observed和reactive区域的事件可以出发本时钟周期内active区域中进一步的设计事件。最后就是postponed区域,它将在时间片的最后,所有设计活动都结束后的只读时间段进行信号采样。如下表所示:
下例给出了前面仲裁器例子测试平台的部分代码:
测试代码应当包含在一个单个的程序块中,使用OOP通过对象而非模块来创建一个动态、分层的测试平台;应当总是将程序块声明为automatic类型,这样它的行为就会更加接近基于堆栈的语言中的函数,例如C语言。
在verilog中,仿真在调度事件存在的时候会继续执行,直到遇到$finish。SV中增加了一种结束仿真的方法。SV将任何一个程序块都视为含有一个测试。如果仅有一个程序块,那么当完成所有initial块中的最后一个语句时,仿真就结束了,因为编译器认为这就是测试的结尾。即使还有模块的线程在运行,仿真也会结束。所以当测试结束时无需关闭所有的监视器(monitor)和驱动器(driver).
如果存在多个程序块,仿真在最后一个程序块结束的时候结束。也可以执行$exit提前中断任何一个程序块,当然也可以使用$finish来结束仿真。
时钟块的默认时序是在#1step延时后采样输入信号,在#0延时后驱动输出信号。1step延时规定了信号在前一个时间片的postponed区域,在设计有任何新动作之前采样,这样就可以在时钟改变之前捕获输出。因为时钟模块的原因,测试平台的输出信号时同步的,所以他们直接送入设计中。在reactive区域运行的程序块在同一个时间片内再一次触发active区域,这可以想象为再设计和测试平台之间插入了一个同步器来记住这个过程,如下图所示:
测试平台需要驱动和采样设计的信号,这主要是通过带有时钟块的接口实现的。异步信号通过接口时没有任何延时,比如下例中的rst,而时钟块中的信号将得到同步。
当从时钟块中读取信号的时候,你是在时钟沿之前得到采样值,例如在postponed区域。下面的代码给出了一个从DUT中读取grant同步信号的程序块。
下图中的波形表明,arbif.cb.grant在时钟边沿到来之前获得数值。当接口的输入信号恰好在时钟沿(25ns)变化时,信号的新值并不是在下一个时钟周期(35ns)传递给测试平台。
当在时钟块中使用modport时,任何同步接口信号必须加上接口名和时钟块名的前缀。例如在下例中,arbif.cb.request是合法的,但 arbif.request是非法的。
在时钟块中应当使用同步驱动,即“<=”操作符来驱动信号。这是由于测试平台在reactive区域执行而设计代码在active区域执行,所以信号在赋值后不会立即改变。若果测试平台在时钟的有效沿驱动同步接口信号,那么其值会立即传递到设计中,这是因为时钟块的默认延时为#0;如果测试平台在时钟有效沿之后驱动输出,那么该值直到时钟的下一个有效沿才会被设计捕获。
下例是在同一个时钟周期的不同时间点驱动同一个同步信号的实例。
注意在第三个周期产生的中间值2永远不会被DUT捕获,因为在周期结束时测试平台产生了值1.
异步驱动时钟块信号会导致数值丢失。相反的,应该使用时钟延时前缀以保证在时钟沿驱动信号。
如果想在驱动一个信号前等待两个周期,可以使用“repeat(2) @bus.cb”或将时钟周期延时##2;
在verilog中如果想驱动一个双向信号,需要用连续赋值语句将reg连接到wire;在SV中,接口中的双向同步信号因为连续赋值的引入而变得更加容易使用。当在程序中对线网赋值的时候,SV实际上将值写到了一个驱动该线网的临时变量中。所有驱动器的输出经过判决后,程序可以直接通过连线读取该值。
SV并没有明确定义如何驱动接口中的异步双向信号。有两种解决的办法:使用一个跨模块引用和连续赋值语句;使用虚接口。
在SV中,可以在program块中使用initial块,但是不能使用always块。因为当program中最后一个initial块结束的时候,仿真实际上也默认结束了,就像执行$finish一样。如果加入了always块,它将永远不会结束,这样就不得不调用$exit来发出结束程序块的信号。但是如果一定要使用always块,可以使用“initial forever”来代替。
时钟与其说与测试平台结合的比较紧密,不如说跟设计结合的更加紧密,因此时钟发生器应该定义成一个模块。当设计进一步细化实施时,你会创建一个时钟树,随着时钟信号进入系统并且在块与块之间传递时,必须仔细控制时钟的抖动。
测试平台并没有如此挑剔,它只需要知道什么时候可以驱动和采样信号。功能验证关心的是在正确的时钟周期内提供正确的值,而不是纳秒级的延时和时钟的相对偏移。
不应该把时钟放在程序块里,上例中将时钟发生器置于一个程序块中,这会引起信号间的竞争。clk和out_sig信号都从reactive区域开始传递,在active区域进入设计,根据这两个信号到达的不同可能会引起竞争状态。
将时钟发生器放在一个模块中可以避免竞争状态,如果想使发生器的属性随机化,可以创建随机变量来产生时钟抖动、频率和其他特性。下例是一个位于模块中的时钟发生器,它有意避免了0时刻的边沿以免竞争情形的发生。所有的时钟边沿使用阻塞赋值生成,它们将在active区域触发事件的发生。如果你确实需要在0时刻产生一个时钟边沿,那么可以使用非阻塞赋值语句设置初始值,这样一来所有的时钟敏感逻辑电路都会在时钟变化之前执行。
不要用功能验证来验证底层时序,本书所描述的测试平台只检查DUT的行为,但是不检查时序,时序的检查在静态时序分析工具中完成。
现在有了在模块里描述的设计,一个在程序块中的测试平台和将它们连接到一起的接口,下面就是例化和连接所有这些代码块的顶层模块。
上例使用了一个快捷符号.*(隐式端口连接),能自动在当前级别自动连接模块实例的端口到具体信号,只要端口和信号的名字和数据类型相同。
SV编译器不会成功编译任何一个在端口列表中含有接口的模块或者程序块,因为端口中含有接口的模块或程序块必须连接到该接口的一个实例上。
有时候需要在仿真过程中创建的程序或模块之外的对象,以便参与仿真的所有对象都能访问。在verilog中只有宏定义才可以跨越模块的边界,而且经常被用来创建全局变量。SV引入了编译单元(compilation unit),它是一起编译的源文件的组合。任何模块或程序块边界之外 的作用域称为编译单元作用域,也称为$unit。在这个作用域内的任何成员都类似于全局成员,可以被所有低一级的块访问。但是它们又不同于真正的全局成员,例如parameter在编译时其他源文件不可见。
本书将块外的作用域称为顶层作用域,在这个作用域中可以定义变量、参数、数据类型甚至方法。看下面例子:
实例名$root允许你从顶层作用域开始引用系统中的成员名。当你的代码需要引用另一个模块中的成员时,例如il.var,编译器首先在本作用域内查找,然后在上一层作用域查找,如此直到到达顶层作用域。你可能需要在顶层模块中使用il.var,但是处于中间层次的相同实例名会将搜索引入歧途,最终给你一个错误的变量。你可以通过$root指定绝对路径明确的引用跨模块的变量。看下例:
如果打算做跨模块引用,那么请使用顶层模块的显示例化。同时可以使用一个宏来保存路径层次,这样当路径改变时,只需要修改宏代码即可。
程序块可以读写模块中的所有信号,可以调用模块中的所有例程,但是模块看不到程序块。这是因为测试平台需要访问和控制设计,但是设计却独立于测试平台。
在测试平台中使用函数从DUT获取信息是一个好方法。在大多数情况下读取信号值是可行的,但是如果设计代码变化了,测试平台就可能错误的解释数值。模块中的函数可以封装两者间的通信,使得测试平台更加便捷的跟设计保持同步。
可以使用SV断言(SVA)在设计中创建时序断言,断言的例化跟其他设计块的例化类似,而且在整个仿真过程中都是有效的。仿真器会跟踪哪些断言被激活,从而可以在此基础上收集功能覆盖率的数据。
测试平台的过程代码可以检查待测设计的信号值和测试平台的信号值,并且在存在问题时采取相应的行动。例如,如果产生了总线请求,期望在两个周期后产生应答,可以使用if语句来检查这个应答。
断言比if语句更加紧凑,但是断言的逻辑条件与if语句中的比较条件是相反的,设计者应该期望括号内的表达式为真,否则输出一个错误信号。
如果正确产生了grant信号,那么测试继续执行。如果信号不符合期望值,仿真器将给出一个如下所示的信息:
该消息指出,在test.sv文件中的第七行,断言top.t1.a1在55ns开始检查信号grant,但是查出了错误。
你可能倾向于使用完整的SVA语法来检查一个时间段上的详细序列,但是要小心使用。断言是声明性代码,它的执行过程和过程代码有很大差别。使用几行代码就可以验证复杂的时序关系。
一个立即断言有可选的then和else分句。如果你想改变默认的消息,可以添加你自己的输出信息。
另一种断言就是并发断言,可以认为它是一个连续运行的模块,为整个仿真过程检查信号值。你需要在断言内指定一个采样时钟。看下例:
真正的设计具有很多的输入和输出,本小节给出了一个四端口ATM路由器的实例,如下图:
下面的代码给出了ATM路由器的端口连线,端口的类型和方向与首部定义分开。真实的路由器代码中的端口声明延伸了几乎整整一页。
下图是一个ATM路由器连接到测试平台的框图,其中的信号部分被分组装进接口。
下面是使用了modport和时钟块的RX和TX接口。
注意接口中的名字都使用了固定名字,所以需要把同样的代码复制4次。后续的章节介绍了使用虚拟接口来简化。
ref端口的行为相对于input,output,inout来说,它其实是对变量的引用,其值应该是变量最后一次的赋值。将一个变量连接到多个ref端口就可能产生竞争,因为多个模块的端口都可能更新同一个变量。
前面的小节介绍过,仿真在程序块中的最后一个initial块结束时结束。真实的情况是当最后一个initial块完成时,它隐性的调用$exit以标志程序的结束。但是仿真并没有完全结束。模块或程序块可以定义一个或多个finial块来执行退出仿真器前的代码。这是一个用来放置清理任务的最佳位置,比如关闭文件。在finial块中不能调度事件,或者任何时延信息。应当指出的是无需担心分配内存的释放,因为仿真器会自动处理这个事件。