目录
7.1.1使用fork...join和begin...end
在实际硬件中,时序逻辑通过时钟沿来激活,组合逻辑的输出则是随着输入的变化而变化。所有这些并发的活动在verilog的寄存器传输级上是通过initial和always块语句、实例化和连续赋值语句实现的。为了模拟和检验这些并发的语句块,激励平台使用许多并发执行的线程。在测试平台的环境里,大多数语句块被模拟成事务处理器,并运行在各自的线程里。
SV的调度器就像是一个交通警察,总是不停的选择下一个要运行的线程。可以用本章的方法来控制线程,进而控制测试平台。
每个线程都会和临近的线程通信,下图中,发生器把激励传递给代理,环境类需要知道发生器什么时候完成任务,以便及时终止测试平台中还在运行的线程,这需要借助线程间的通行(IPC)来完成。常见的线程间通信有标准的verilog事件、事件控制、wait语句、SV信箱和旗语等。
7.1线程的使用
虽然所有的线程结构都可以在模块和程序块中,但实际上测试平台隶属于程序块。结果是代码总是以initial块启动,从0时刻开始执行。虽然always块不能放在程序块中,但是通过initail块内引入forever循环便可解决这个问题。
标准的verilog对语句有两种分组方式:begin...end和fork...join。前者顺序执行,后者并发执行。后者的不足时必须等到fork...join内的所有语句执行完后才能继续块内其他语句的执行,因此在verilog中很少用。
SV引入了两种新的创建线程的方法:fork...join_none和fork...join_any。
测试平台通过已有的结果如事件、@事件控制、wait和diable语句以及旗语和信箱等,来实现线程间的通信、同步以及对线程的控制。
7.1.1使用fork...join和begin...end
fork...join中的代码都是并行执行的,所以带短延时的语句执行的比带长延时的语句快。如上例所示,fork...join直到以#50开头的最后一条语句执行结束后才得以完成。
7.1.2使用fork...join_none来产生线程
fork...join_none块在调度其块内语句时,父线程继续执行。
7.1.3使用fork...join_any来实现线程同步
fork...join_any块在调度其块内语句时,当执行完块内第一条语句后,父线程才继续执行,其他停顿的线程也得以继续。
7.1.4在类中创建线程
使用fork...join_none可以开启一个线程,比如随机事务发生器的代码。下例示范了一个使用任务run创建N个数据包的发生器/驱动器类。
上例有几点需要注意,首先事务处理器并不是在new()函数里启动的,构造函数只是对对象进行初始化操作,并不启动任何线程。把构造函数同真正的事务处理代码分开,允许你在开始执行事务处理代码之前修改任何变量,这样就可以引入错误检测、修改缺省值或者变更代码的行为。
其次,任务run通过fork...join_none块启动了一个线程,该线程并非在父类中启动,而是任务运行后产生的。
7.1.5动态线程
在verilog中线程是可预知的,可以通过统计源代码中initial、always和fork...join块的数量来确定一个模块中有多少线程;而在SV中,你可以动态的创建线程,而不用等到他们都执行完毕。
下例中,测试平台产生随机事务并把它们发送到待测设计中,待测设计把事务存放预定的一段时间后再把事务返回。测试平台必须等待事务完成,但又不希望停止随机数据的产生。
当任务check_trans被调用时,它便产生一个线程用来检测总线以获取匹配的事务地址。
7.1.6线程中的自动变量
当使用循环来创建线程时,如果在进入下一轮循环前没有保存变量值,便会碰到一个难以发现的漏洞。看下例:
SV首先对fork...join_none里的线程进行调度,但是由于#0延时的存在,这些线程要在原始代码块之后执行,所以打印出来的是“3 3 3”,即终止循环的变量。
#0时延阻塞了当前线程,并且把它重新调度到当前时间片之后启动。时延使得当前线程必须等到所有在fork...join_none语句中产生的线程执行完之后才得以运行。
应该在fork...join_none语句使用自动变量来保存变量的拷贝:
带初始化的自动变量声明在for循环的线程里运行。在每次循环中,k的一个拷贝被创建并被赋予当前的j值。在循环完成后,#0阻塞了当前线程,因此三个线程一起运行,打印出各自拷贝的k值。当线程运行完毕后,在当前时间片已经没有其他时间残留,SV进入下一个语句$display的执行。
需要注意的是,如果代码是在使用自动存储的程序或模块里,那么声明时不用使用关键字automatic.另一种写法是在fork...join_none外部自动声明变量:
7.1.7等待所有的衍生线程
在SV中,所有的initial块执行完后,仿真器就退出了。如果有的线程运行时间比较长,可以使用wait fork语句来等待所有的子线程结束。
7.1.8在线程间共享变量
在一个类的方法里,可以使用局部变量、类变量或者程序中定义的变量。但是如果你忘记声明某个变量,SV会到更高层次的范围里寻找,直至找到匹配的声明。如果两部分代码无意间共享了某一变量,这会导致难以发现的漏洞,而漏洞的原因就是忘记了最内层变量的声明。看下例:
解决的办法是,在包含所有变量使用的最小范围内声明所有的变量。
7.2停止线程
verilog中的disable语句可以用于停止SV中的线程。
7.2.1停止单个线程
这里,最外部的begin...end块顺序执行,内部的fork...join_any分为两个线程并行执行,一个是简单的wait,另一个是带时延的显示。如果正确的总线地址来的足够早,则wait线程先完成,之后的disbale结束剩余的线程;若总线地址来的时间在TIME_OUT之后,那么错误的信息就会被带时延的线程显示出来,之后的disbale结束剩余的线程。
7.2.2结束多个线程
上例使用disable来停止一个署名块中的所有线程,SV引入disable fork语句停止从当前线程中衍生出来的所有子线程。需要注意的是,你可能会无意识的停止过多的线程,应该使用fork...join把目标代码包围起来以限制disable fork的作用范围。
线程0在带有disable的fork...join之外,所以不受影响。
上例是更加稳健的写法,使用了带标号的disable.
7.2.3禁止被多次调用的任务
如果在某个任务内部禁止该任务,这就像是任务的返回语句,会停止所有由该任务启动的线程。如果该任务已经被多个线程调用,禁止其中的一个将导致它们全部被禁止。
下例中,任务wait_for_time_out被调用了3次,从而衍生了三个线程。线程0在#2延时后禁止了该任务。只要运行该代码就可以看到三个线程都启动了,但是由于线程0中的disable语句,这些线程最终都没有完成。
7.3线程间的通信
测试平台中的所有线程都需要同步并通信交换数据。所有这些数据交换和控制的同步称为线程间的通信(IPC),在SV中可使用事件、旗语和信箱来完成。
7.4事件
verilog的事件可以实现线程的同步,一个线程总是要等待一个带@操作符的事件,这个操作符是边沿敏感的,所以它总是阻塞着,等待事件的变化。其他线程可以通过->操作符来触发事件,接触对第一个线程的阻塞。
SV对事件做了几方面的增强,首先事件可以作为同步对象的句柄,运行在对象之间共享事件,而不用把事件定义成全局变量。最常见的方式是把事件传递到一个对象的构造器中。
在verilog中,当一个线程在一个事件上发生阻塞时,正好另一个线程触发了这个事件,则竞争的可能性出现了。如果触发线程先于阻塞线程执行,则触发无效;SV提供了triggered()函数,可用于查询某个事件是否已经被触发,包括在当前时刻。线程可以等待这个函数的结果,而不用在@操作符上阻塞。
7.4.1在事件的边沿阻塞
运行上面的代码时,第一个初始化块启动,触发e1事件,然后阻塞在e2上;第二个初始化块与第一个块并行,触发e2事件(唤醒第一个块),然后阻塞在e1上;但是由于第一个事件是零宽度的脉冲,所以第二个线程会因为错过第一个事件而被锁住。
7.4.2等待事件的触发
可以使用电平敏感的wait(e1.triggered())来替代边沿敏感的阻塞语句@e1.如果事件在当前时间步已经被触发,则不会引起阻塞;否则会一直等到事件被触发为止 。
当运行上述代码时,第一个初始化块启动,触发e1事件,然后阻塞在e2上;第二个初始化块启动,触发e2(唤醒第一个块),然后阻塞在e1上。
上述的例子都存在竞争的条件,它们在不同仿真器上的执行结果可能不完全相同。
7.4.3在循环中使用事件
如果在循环中使用wait(handshake.triggered()),一定要确保在下次等待之前时间可以向前推进,否则代码将进入一个零延时循环,因为wait会在单个事件触发器上反复执行。看下例:
正如应把时延放到always块内一样,需要把时延放到一个事件处理循环中去。下例中,边沿敏感的时延语句在每次事件触发时都会执行且执行一次。
如果同一时刻需要发送多个报告,就不应该使用事件,而应该使用其他内嵌排队机制的线程通信方法,如旗语和信箱。
7.4.4传递事件
SV中的事件可以像参数一样传递给方法。下例中,一个事件被事务处理器用作其执行完毕的标识信号。
7.4.5等待多个信号
上例中只有单个发生器释放出单个事件,如果测试环境必须等待多个子线程完成,比如有N个发生器呢?最容易的办法是使用wait fork来等待所有子线程完成,这样做的问题在于要等待所有的事务处理器、驱动器以及在测试环境中衍生出来的其他线程。
对此,解决的办法是创建一个新线程并从中衍生出子线程,然后保证每个线程阻塞在每个发生器的一个事件上。看下例:
另一个方法是记录下已触发事件的数目,如下例:
还可以仅仅对运行着的发生器进行计数,这个计数值可以是类中的静态变量,看下例:
7.5旗语
使用旗语可以实现对同一资源的访问控制。想象一下对于一辆汽车只有一把钥匙,显然每次就只能有一个人开车。为了应对这种情况,可以约定 谁有钥匙谁就开车。在你用完车后,你会让出车子供别人使用。车钥匙就是旗语,而车就是资源,钥匙保证了在同一时刻只能有一个人开车,同理,旗语保证了在同一时刻只能有一个事件访问某一资源。
当测试平台存在一个资源,如一条总线,对应着多个请求方,而实际物理设计只允许单一驱动时,便可以使用旗语。在SV中,一个线程如果请求“钥匙”而得不到,则会一直阻塞;多个阻塞的线程会以先进先出(FIFO)的方式进行排队。
7.5.1旗语的操作
旗语有3种操作:使用new可以创建一个带单个或多个钥匙的旗语;使用get可以获取一个或多个钥匙;使用put可以返回一个或多个钥匙。当试图获取一个旗语而不希望被阻塞时,可以使用try_get()函数,它返回1表示由足够多的钥匙,返回0则表示钥匙不够。看下例:
7.5.2带多个钥匙的旗语
使用旗语有两个地方需要注意:
(1)返回的钥匙可以比取出来的多;
(2)当测试程序需要获取和返回多个钥匙时,务必小心。假设你只剩下一把钥匙,而有一个线程请求两把钥匙而被阻塞,这是若另一个线程出现且只请求一把钥匙,SV会如何处理这种情况?这时SV会将第二个请求排在第一个前面,先进先出的原则在这里被忽略。
7.6信箱
如何在两个线程之间传递信息?考虑发生器需要创建很多事务并传递给驱动器的情况,你可能会任务仅仅用发生器线程去调用驱动器中的任务就可以了,但是这样发生器需要知道到达驱动器的层次化路径,这样会降低代码的可重用性。此外,这种代码风格还迫使发生器和驱动器以同一速率运行,这样会引发同步问题。
把发生器和驱动器想象成具备自治能力的事务处理器对象,它们通过信道交换数据。每个对象从它的上游对象得到事务(如果对象本身是发生器则创建事务),进行一些处理然后传递到下游。这里是信道必须允许驱动器和接收器的异步操作。
解决的方法就是使用SV中的信箱。从硬件角度出发,对信箱的理解就是将其看做一个具有源端和收端的FIFO。源端把数据存进信箱,收端则从信箱中获取数据。信箱可以有容量上的限制,也可以没有。当源端试图从一个容量固定且已经饱和的信箱中继续放入数据时,则会发生阻塞直至信箱中的数据被移走;同样如果收端试图从一个空信箱中获取数据,它也会被阻塞直至有数据放入信箱。如图所示:
信箱是一种对象,必须调用new进行例化。例化时有一个可选的参数size用以限制信箱中的条目数。如果size是0或者没有指定,则信箱默认为无限大。
可以使用put任务把数据放入信箱,可以使用get获取信息中的数据;如果信箱为满,则put会阻塞;如果信箱为空,则get会阻塞;peek任务可以获取信息里的数据的拷贝而不移除数据。
缺省情况下,信箱没有数据类型,所以允许在其中放入任何混合类型数据,但是一般一个信箱里只存放一种类型的数据。
一个典型的漏洞就是在循环外面构造一个对象,然后使用循环对对象进行随机化并把它们存入信箱。因为实际上只有一个对象,它被一次又一次的随机化。下图显示了所有指向同一个对象的句柄,请注意信箱里存放的只是句柄而非对象本身,所以最终得到的是一个含有多个句柄的信箱,且所有的句柄都指向同一个对象。从信箱里获取句柄的代码实际上只能见到最后一组随机值。
解决的办法如下,就是要确保每次循环都有构造对象:
结果如下所示,每个句柄都指向了不同的对象。这种类型的发生器被称为“蓝图模式”。
下例示范的驱动器正在等待来自发生器的事务。
如果不希望戴安在访问信箱时发生阻塞,可以使用try_get()和try_peek()函数。如果函数执行成功,它们会返回一个非零值,否则返回0,这比使用num记录信箱中的条目数可靠。
7.6.1测试平台里的信箱
下面各例示范了一个发生器(generator)和一个驱动器(driver)使用信箱和顶层程序实现数据交换的过程。注意两个类需要在程序块内定义,以便它们都能看到总线接口的定义。
下例示范了驱动器driver。
7.6.2定容信箱
在构造信箱时可以指定一个最大容量,缺省容量是0,表示信箱容量不限。当试图往一个已满的信箱中存入数据时,put会发生阻塞,直至信箱中的数据被取走。
上例创建了只能存放单条数据的信箱,生产方线程试图把三条信息存入信箱,而消费方则每1ns提取一个信息。如下所示,第一个
put执行成功后,生产方线程试图执行put(2)却被阻塞,消费方线程被唤醒并取走信息1后,生产方线程才能把信息2存入信箱。
定容信箱在两个线程之间扮演了缓冲器的角色。
7.6.3在异步线程间使用信箱通信
如果想让生产方和消费方两个线程步调一致,那就需要额外的握手信号。在下例中,生产方和消费方是两个类,使用信箱交换数据,但两者之间没有同步信号 。运行结果如图所示,生产方一直到运行结束,消费方还没有开始启动。
由于没有同步信号,导致在消费方没有开始取数的时候生产方就已经把三个数据存入信箱,这是因为线程没有碰到阻塞语句就会一直执行,而消费方在第一次调用get时就被阻塞了。
7.6.4使用定容信箱和探视(peek)来实现线程的同步
为了使两个线程同步,生产方创建一个事务并存入信箱,然后开始阻塞直到事务被消费方处理掉。事务处理完成的标志是事务最终被消费方从信箱里移除,而非事务初次检测到。
下例显示了使用定容信箱实现两个线程同步的方法。消费方使用一个内建的信箱方法peek()来探视信箱里的数据而不将它移出。当消费方处理完数据后,便使用get()移出数据,这样使得生产方可以生成一个新的数据。如果消费方使用get来替代peek,那么事务会被立即移出信箱,这样生产方可能会在消费方完成事务处理之前生成新的数据。
可以看到生产方和消费方是步调一致的,但是生产方任然比消费方提前一个事务的时间。这是因为,容量为1的定容信箱只有在你试图对第二个事务进行put操作时才会发生阻塞。
7.6.5使用信箱和事件来实现线程的同步
你可能希望两个线程使用握手信号,以使生成方不要超前于消费方。既然消费方以阻塞的方式等待生产方使用信箱,那么生产方也可以以阻塞的方式等待消费方完成对信箱条目的处理。这可以通过在生产方添加阻塞语句如事件、旗语或第二个信箱来实现。
下例在生产方把数据存入信箱后使用事件来阻塞它,消费方则在处理完数据后再触发事件。
执行结果如下,在消费方触发事件之前,生产方不会继续往前执行。
可以看到,生产方和消费方成功取得了同步,因为在旧的数值被取走之前,生产方不会产生新的数值。
7.6.6使用两个信箱来实现线程的同步
对两个线程进行同步的另一种方式是再使用一个信箱把消费方完成的信息发回给生产方。看下例:
返回到rtn信箱中的值是原始数据的相反值,当然可以设定任意值。结果如下:
7.6.7其他同步技术
通过变量或旗语来阻塞线程同样也可以实现握手。事件是最简单的结构,其次是通过变量阻塞。旗语相当于第二个信箱,但是没有信息交换。SV的定容信箱用起来比其他数据稍差是因为无法再生产方放入第一个事务的时候让它阻塞,生产方总是比消费方提前一个事务的时间。
7.7构筑带线程并可实现线程间通信的测试程序
在懂得使用线程以及线程间的通信(IPC)之后,就可以构造出带事务处理器的基本测试平台。
7.7.1基本的事务处理器
7.7.2配置类
配置类允许你在每次仿真时对系统的配置进行随机化。
7.7.3环境类
环境类包含了发生器、代理、驱动器、监视器、检验器、记分板以及它们之间的配置对象和信箱。
7.7.4测试程序
测试代码被放到程序块中;