介绍
Verilog的寄存器传输级上通过initial和always块语句,实例化,连续赋值语句来模拟并发的活动。为了模拟和检验这些语句块,测试平台使用许多并发执行的线程。如果按照软件的思维理解硬件仿真,仿真各个模块是独立运行的线程(thread),模块(线程)在仿真一开始便并行执行,除了每个线程会按照内部产生事件来触发过程语句块之外,同时还依靠相邻模块间的信号变化来完成模块之间线程的同步。这个过程需要借助线程之间的通信(IPC)来完成。如Verilog事件,事件控制,wait语句,SV信箱和旗语。
什么是线程?
(1)线程是独立运行的程序,需要被触发,可以结束或者不结束
(2)module的initial和always都可以看作独立的线程,会从仿真0时刻开始,可以选择结束或者不结束。硬件模型中都是always语句块,可以看成多个独立运行的线程,而这些线程会一直占用仿真资源,因为他们并不会结束。而软件测试平台验证环境都是用initial语句去创建,在仿真过程中验证环境的对象可以创建和销毁,因此软件测试端资源的占用是动态的。
SV除了fork...join语句之外,还引入了两种新的创建线程的方法——fork...join_none和fork...join_any语句,下例是在类中创建线程的例子:
class Gen_drive;
task run(int n);
Packet P;
fork
repeat(n) begin
p=new();
assert(p.randomize());
transmit(p);
end
join_none
endtask
task transmit(input Packet p);
...
endtask
endclass
Gen_drive gen;
initial begin
gen=new();
gen.run(10);
end
注意,事物处理器不是在new()函数里启动的,构造函数只是对数值进行初始化,并不启动任何线程,把构造函数和真正的进行事务处理的代码分开,允许你在开始执行事务处理代码之前修改任何变量。
线程中的变量
当你使用循环来创建线程时,如果在进入下一轮循环前没有保存变量值,会碰到常见而难以发现的错误。如果函数调用的是静态存储,那么每个线程都会共享相同的变量,这会导致后面的调用会覆盖前面调用的值。所以在并发线程中务必使用自动变量来保存数值。
不良的代码
program no_auto;
initial begin
for(int j=0;j<3;j++)
fork
$write(j); //得到的都是最后的索引值
join_none
#0 $display("\n");
end
endprogram
#0阻塞了当前线程,在上例时延使得当前线程必须等到所有fork...join_none语句中产生的线程执行完之后才得以运行。最后打印出来的是“3 3 3”,正确的代码如下所示:
initial begin
for(int j=0;j<3;j++)
fork
automatic int k=j;
$write(k);
join_none
#0 $display;
end
在每轮循环之中,k的一个拷贝被创建并设置为j的当前值,然后fork...join_none被调度,包括k的拷贝。在循环完成后,#0阻塞了当前线程,因此三个线程一起运行,打印出各自的拷贝值k。需要注意的是,如果代码是在使用自动存储的程序或者模块里,那么声明可以不使用关键词automatic。
等待所有的线程
SV中,当程序的initial块全部执行完毕,仿真器就可以退出了。此时线程可能还在运行,如果我们希望等待fork块所有线程执行完毕再退出initial块,我们可以用wait fork语句来等待所有子线程的结束。
停止线程
正如你需要在测试平台中创建线程,你可能也需要停止线程。Verilog中disable语句可以用于停止SV中的线程。
parameter TIME_OUT=1000;
task check_trans(Transaction tr);
fork
begin
fork:timeout_block
begin
wait(bus.cb.addr==tr.addr);
$display("@ % 0t:Addr match%d",$time,tr.addr);
end
#TIME_OUT $display("@ % 0t:ERROR:timeout",$time);
join_any
disable timeout_block;
end
join_none
endtask
如果正确的总线地址来得足够早,则wait结构先完成,fork...join_any得以执行,之后disable结束剩余的线程,但是如果在TIME_OUT时延完成前总线地址都没有得到正确的值,那么错误的警告信息就会被打印出来,join_any被执行,而后的disable将结束wait线程。
使用disable可以用来停止一个署名块的所有线程,当然,你想停止多个线程,你可以使用disable fork语句来停止当前线程中衍生出来的所有线程。
initial begin
check_trans(tr0); //线程0
fork //线程1
begin
check_trans(tr1); //线程2
fork //线程3
check_trans(tr2) //线程4
join
#(TIME_OUT/2) disable fork;
end
join
end
在一个时延之后,disable fork停止了线程1及其所有子线程2-4。线程0在带有disable的fork...join之外,所以不受影响。
当你从某个块内部禁止线程的时候一定要小心,你停止的可能比预期的更多。比如你在某个任务之中禁止了该任务,这也会停止所有由该任务启动的线程。如果该任务已经被多个线程调用,禁止其中的一个将导致它们全部被禁止。
task wait_for_time_out(int id);
if(id==0)
fork
begin
#2;
$display("@ % 0t:disable wait_for_time_out",$time);
disable wait_for_time_out;
end
join_none
fork:just_a_little
begin
$display("@ % 0t: %m:%0d entering thread",$time,id);
#TIME_OUT;
$display("@ % 0t: %m:%0d done",$time,id);
end
join_none
endtask
initial begin
wait_for_time_out(0);
wait_for_time_out(1);
wait_for_time_out(2);
end
如图,任务wait_for_time_out被调用了三次,从而衍生了三个线程。线程0在#2延时之后禁止了该任务。只要运行了这段代码,就可以看到三个线程都启动了,但是因为线程0的disable语句,这些线程都没有完成。
线程间的通信
Verilog中,一个线程总是总是要等待一个带@操作符事件。这个操作符是边沿敏感,所以它总是阻塞着,等待事件的变化。其他的线程可以通过->操作符来触发事件,结束对第一个事件的阻塞。但是,握手信号事件可能在同一时间被触发,由于delta cycle的时间差使两个初始块可能无法等到e1或者e2。更安全的方式是使用event的方法的triggered()。
但是在使用wait(handshake.triggered())的时候,要注意循环语句。一定要确保在下次等待之前时间可以向前推进,否则你的代码将进入一个零时序循环,原因是wait会在单个事件触发器上反复执行。例如:
forever begin
wait (handshake.triggered());
$display("Receive next event");
end
这是一个零时许循环。要么需要把时延放到一个事件处理循环之中,或者采用边沿敏感的时序语句@。最后值得注意的是:事件可以像参数一样传递给子程序。比如:function new(event done);事件可以放在传递参数列表之中。
semaphore旗语可以实现对同一资源的访问控制,当测试平台中存在一个资源,比如一条总线,对应着多个请求方,而实际物理设计中又只允许单一驱动时,便可以使用旗语。旗语有三种基本操作:new方法可以创建一个带单个或者多个钥匙的旗语,get可以获取一个或者多个钥匙,put可以返回一个或者多个钥匙。如果你试图获取一个旗语而希望不被阻塞,可以使用try_get()函数。
之所以要使用semaphore,是因为对于线程之间共箱资源的使用方式,应该遵循互斥访问原则。控制共享资源的原因在于,如果不对访问作控制,可能会出现多个线程对同一资源的访问,进而导致不可预期的数据损坏和线程的异常,这种现象称之为“线程不安全”。
program automatic test(bus_ifc.TB bus);
semaphore sem; //创建一个旗语
initial begin
sem=new(1); //分配一把钥匙
fork
sequencer(); //产生两个总线事物线程
sequencer();
join
end
task sequencer;
repeat($urandom%10) //随机等待0-9个周期
@bus.cb;
sendTrans();
endtask
task sendTrans;
sem.get(1);
@bus.cb;
bus.cb<=t.addr
...
sem.put(1);
endtask
endprogram
如果要在两个线程之间传递信息呢?如果仅仅使用发生器线程去调用驱动器中的任务,发生器需要知道到达驱动器任务的层次化路径,这会降低代码的可重用性。此外,这种代码风格还会迫使发生器与驱动器在同一速率运行,在一个发生器需要控制多个驱动器的情况下会引发同步问题。
解决办法是SV的信箱。从硬件角度去看,对信箱的最简单的理解是将它看成是具有源端和收端的FIFO。当源端线程试图向一个容量固体且饱和的信箱里放入数值时,会发生阻塞直到信箱里的数据被移走。同样地,如果收端线程试图从一个空信箱里移走数据,它也会被阻塞直到有数据放入。mailbox是一种对象,需要用new()来例化。例化的时候可以有一个可选的参数来限定其存储的最大数量。size是0或者没有指定,则信箱是无限大的,可以容纳任意多的条目。put()可以数据放入mailbox,get()可以从信箱移除数据。peek()可以获取对信箱里数据的拷贝而不移除它(白嫖hhh)。
值得注意的是,一个典型的漏洞是在循环外面构造一个对象,然后使用循环对对象进行随机化并将它们放入信箱。因为实际上只有一个对象,它被一次一次随机化。所以你最后得到的是一个含有多个句柄的信箱,所有句柄都指向同一个对象。从信箱里获取的代码实际上只能见到最后一组的随机值。如下所示:
task generator_bad(int n,mailbox mbx);
Transaction t;
t=new();
repeat (n) begin
assert(t.randomize());
$display("GEN: Sending addr=%h",t.addr);
mbx.put(t);
end
endtask
所以要确保每个循环中都含有构造对象,对象随机化并放入信箱三个完整的步骤。
class Generator;
Transaction tr;
mailbox mbx;
function new(mailbox mbx);
this.mbx=mbx;
endfunction
task run(int count);
repeat(count) begin
tr=new();
assert(tr.randomize);
mbx.put(tr);
end
endtask
endclass
class Driver;
Transaction tr;
mailbox mbx;
function new(mailbox mbx);
this.mbx=mbx;
endfunction
task run(int count);
repeat(count) begin
mbx.get(tr);
@(posedge bus.cb.ack);
bus.cb.kind<=tr.kind;
...
end
endtask
endclass
program automatic mailbox_example(bus_if.TB bus,...);
`include "transaction.sv"
`include "generator.sv"
`include "driver.sv"
mailbox mbx;
Generator gen;
Driver drv;
int count;
initial begin
count=$urandom_range(50);
mbx=new();
gen=new(mbx);
drv=new(mbx);
fork
gen.run(count);
drv.run(count);
join
end
endprogram
信箱在缺省的情况下类似于容量不限的FIFO,消费方取走物品之前生产方可以向信箱里放任意数量的物品。但是你希望消费方处理完物品之前生产方阻塞住,以便两个线程步调一致。因此,你也可以使用定容信箱,它在两个线程之间扮演着一个缓冲器的角色。
总结
你的设计可以用很多并发运行的独立块来建模,所以测试平台也要产生很多激励流来监测并发线程的反应。SV中的fork...join和fork...join_any和fork...join_none用于动态创建线程的结构。线程之间可以用事件,旗语,信箱以及@事件控制和wait语句来实现通信和同步,最后使用disable可以中止线程。