【system verilog】time-slot,仿真的竞争与冒险,对齐与采样
前言
这篇文章主要讨论的是数字芯片验证领域,或者说仿真器仿真行为,这一范畴内的时序竞争与冒险。从关联性来讲,内容贴近这一篇博客:
https://blog.csdn.net/moon9999/article/details/102983963
不过因为最近又对这一内容有了更加深刻的领悟与认识,也意识到之前自己的理解是有一定误区的,所以希望借此记录,与大家分享。
本文的很大一部分内容来源自IEEE system verilog标准第四章“Scheduling semantics”,其余来自个人实验与其他相关资料。
真实时间与仿真时间
一般在功能仿真中,会涉及到两个时间观念:真实时间(或称之为CPU时间)与仿真时间。仿真时间很好理解,例如RTL电路中我们平时总说的第100个时钟周期、第100ns等时间概念均为仿真时间。
CPU时间则是仿真器真实花费的时间,例如我们仿真了50ms仿真时间,如果电路规模很大的话,可能会花费几小时甚至几天的CPU时间来完成。因此我们可以认为CPU时间就是真实世界的时间。
仿真过程中的时间是由一个个的time-slot构成的,由阻塞性的事件(event)与线程(thread/process)推进仿真时间前进。
事件与进程
system verilog的代码行为是由一个个离散事件组成,运行sv时也就是在执行一个个的事件与线程。值得注意的是,仿真器中线程与事件的执行是串行方式,而真实的RTL电路代码的执行方式是并行执行,仿真器需要通过调度串行事件来模拟芯片真实的并行行为。因此,为了模拟贴近真实电路行为,明确仿真环境行为,避免竞争冒险与采样不确定等危机,SV标准划分了明确的事件调度与代码执行区间。
关于进程,通常我们写下的每一句执行性的verilog代码和sv代码,在仿真器看来都是一个进程(更多时候将有时序或时间推进的行为成为进程/线程,因此function一般不被称作进程)。典型的进程包括:
Initia, always, always_comb, always_latch, always_ff, assign, task, 其他赋值语句等。
事件在标准中包括两种,update event和evaluation event,具体解释如下:
简单来讲就是任何一个数值/信号(sv中将数据划分为net型与variable储值型,这个另外讨论吧)的变化都是一个update event,对该变化敏感的若干进程感知该变化后的执行过程是一个evaluation event。因此可以认为进程也是事件的一个子集。
需要注意一点,对同一update event敏感的若干进程,在执行时必然时串行执行,但是串行执行顺序标准中没有做规定,不同仿真器可以做出自己的安排。
当然我们没有必要咬文嚼字,明确我们写下的执行性代码被编译器转化为一个个事件与进程,在不同时间点(time-slot)和不同触发条件下执行即可。
仿真过程
仿真时间由无数的time-slot构成的,每个time-slot中由划分了若干个区域,每个区域执行标准中规定的相应进程。一次完整的仿真过程也就是把全部time-slot执行完成的过程。这里贴以下标准中的伪码,做一下简单的分析,当然之后这段伪码我们还会再见。
一次完整的仿真器执行的功能仿真经历了什么呢?
- 仿真时间T归零,仿真开始;
- 初始化所有的储值单元和电路单元;
- 调度并执行所有初始化阶段的事件在ts 0时刻;
- 检查是否所有的time-slot都已经完成,如果还有若干time-slot需要执行,那么推进时间到第一个待执行的time-slot,并且设定仿真时间T为这个time-slot的时刻;举例,如果一个仿真环境只有一句打印,那么仿真会直接在T=0时刻结束,因为0时刻时候就已经没有time-slot待执行了。如果环境中有一句#10ns $display(),那么在0时刻后仿真器会发现还有一个time-slot待执行,因此将仿真时间推进到10ns执行这句打印,之后结束仿真;
- 执行T时刻的time-slot中的所有event(事件和进程),直到该time-slot内的全部事件执行结束,返回第4条。
直到将所有非空/待执行的time-slot执行结束,仿真器才会结束仿真过程。
Time-slot
于是乎,事情的重点又来到了time-slot。
Time-slot对于硬件RTL电路而言,就是一个时刻/时间点,但是对于软件仿真器而言,这一个时刻内发生了很多调度与执行进程。通过调度和组织执行进程的执行,将软件行为尽可能的模拟贴近真实硬件行为。
那么我们先将time-slot理解为仿真过程中每一个有事情要做的时间点即可,之后还是根据SV标准进行深入分析。
SV标准中将一个time-slot划分为了17个区域,分别是:
如果将这些regions展开在一个time-slot,就构成了下面这个图:
如果想深入研究这幅图的话,可以参考SV标准,在这里我们还是看简化图比较好,不是太影响理解。下面时画的简化time-slot图:
通过简化图我们可以发现,time-slot其实是一个具有对称美感的非常简单的结构,我们再复习一下time-slot中若干regions的功能;
Preponed域
一个time-slot的入口,也是当前time-slot的采样点,在进入当前的time-slot时立刻采样信号目前的实际值。
关于采样值与实际值多说一句,什么是采样值呢?就是在仿真的特定时刻(一般而言就是preponed时刻,当然是一般而言),将信号的数值进行保存以便之后使用。因此我们在环境中使用的信号都是采样值,值得注意的是,采样值在仿真中的大部分时刻与实际值是有差异的,但是不用担心只要写法规范这个差异不会造成问题。
以图示表示采样值与电路实际值关系如下图,可以看出在环境在20ns处对实际信号进行了采样,那么在20ns~45ns过程中环境中使用该信号时,使用的值都是A。环境中time-slot的采样点就是preponed域。
Active域
在active域内以任意顺序执行当前active状态的event,简单来讲我们常见的module进程比如assign赋值等都在active域内执行。
阻塞赋值直接在本区域完成,非阻塞赋值在本区域开始并被推入NBA域(no blocking region)域等待赋值完成,带有#0时延的线程被推入in-active域(小于最小仿真粒度的#延时四舍五入为#0后也应该推入in-active域,如1ns/1ps时,#0.4ps会被四舍五入为#0);
进程内部的操作时顺序完成的,例如begin-end块内的代码;进程间的顺序没有规定,可以以随意顺序执行,例如若干assign赋值,fork-join块中的并行线程;
需要注意的是不仅仅RTL代码可以放置在module,验证环境也可以放置在module中,如果这样做的话,验证环境的线程执行时间与RTL代码一致,因此有些工具书上说RTL代码在active/inactive/NBA区域执行,验证环境在re-active/re-inactive/re-NBA域中执行是不准确的;
In-active域
所有的#0delay线程会被推进到in-active域,等待active域(本轮)中的线程执行完成再执行in-active域中的线程。
NBA域
非阻塞赋值<=在NBA域中生效,因此如果在NBA域之后采样信号,采样得到的是赋值后的新值。
Observed域
active/inactive/NBA的线程全部执行完毕后进入observed域,observed域隔离开了module时间片和program时间片,避免了验证环境与RTL线程产生竞争和冒险;
断言在observed域执行匹配和判断,注意匹配所使用的是preponed采样的信号“旧值”;
下面呈镜像对称的re-域是program中的执行域,不再重复。那么接下来我们看下一个time-slot是如何调度这些时间regions的。
Ts调度执行
先把刚刚看的那幅图搬出来,我们看到在仿真器返现有time-slot待执行时候,就会跳转到这一时间点,并且执行execute_time_slot(T)来执行这一ts内的所有域和域内的进程。
execute_time_slot(T)的伪码如下图,我们来学习一下。
- 执行preponed中进程和pre-active中进程,进程主要是采样和DPI等操作,注意一个ts中这两个域的线程只会执行这一次;
- 检查active~pre-postponed regions中是否有非空/待执行的,如果有则开始执行,注意这里的进入执行是while语句,也为这regions不是线性执行的,而是反复检查是否regions全部执行结束,只要有未空regions就会跳回继续执行;
- 可以发现第一层while(4行处)的下面是两段while循环,泾渭分明的将module regions(active~post-observed)和program regions(re-active~post-re-NBA)分开,也就是说program regions中的进程一定是在module regions中进程执行的彻彻底底全部执行清之后,才会开始执行;
- 看第二层module regions的while循环(5行处),先执行active域中的进程,然后检查active~post-observed regions中是否有进程待执行,如果有就把这些进程放回到active region执行。
- 因此,module regions具体执行效果就是:a. 执行active域所有线程,遇到#0delay先挂起放到in-active去,遇到<=非阻塞赋值先把右面的值记下来,进程挂起到NBA域去;b. active内进程处理干净了,看下in-active内是否有挂起来的进程,如果有就把这些进行取出来到active,再进行一下active执行这些进程,如此反复直到active~in-active内都没有进程待执行了;c. 直到active和in-active中的进程全部执行干净,NBA域内非阻塞赋值的进程会生效,如果某一进程对NBA域内的update event敏感,则会在此时被提至active内;d. active~pre-observed域内的进程反复几轮执行完成后,module regions的进程执行结束,进入observed域,之后进入program regions,至此第二层循环(5行处)完成;但是注意这并不意味该time-slot不会再次回到module regions执行,仔细观察伪码和示意图可知,如果module中有进程对program regions event敏感,进程再次被提至active域等待执行;
- module regions之后time-slot推进到re-active~post-re-NBA regions循环执行进程,执行完成后检查所有regions中是否还有进程待执行,如果有则回到while循环的开始重复此过程;
可以看下如下几段代码:
always @(posedge clk) begin
if(rst_n == 1'b0)begin
data_out_vld <= 1'b0;
end
else begin
data_out_vld <= data_out_vld_ff1;//进程1
end
end
assign data_out_vld_back = data_out_vld;//进程2
//进程2对进程1敏感,会在NBA域之后被置于active域内待执行
assign data_out_vld_back = data_out_vld;//进程1 //module region
while(1)begin
@clk;
data_out_vld = 1;//进程2 //program region
end
//进程1会在进程2在active域内执行时候被置于active内,等待执行
两个典型场景
非阻塞赋值
aaa@qq.com(posedge clk)begin
sig_ff <= sig;
end
@(posedge clk)在时钟上升沿时发现有进程待执行,因此执行该time-slot regions。采样发生在ts的preponed region,因此采样到sig上一个ts postponed region的值也就是就值A,之后再active执行进程sig_ff <= sig,非阻塞进程在NBA域生效。
电平赋值
assign c = b + 1;
aaa@qq.com(b,c)begin
a = b + c;
end
信号b的跳变是update event,执行后所有对其敏感的进程会放置在相应的region,因此c=b+1和a=b+c进程都置于active region。两个进程随机顺序执行,因此从编译器角度看两种处理都是合乎规范的:c = b + 1先生效,a = b + c后生效,如下图1;a = b + c先生效,c = b + 1后生效,c值update再次将a = b + c至于active使其再生效;
两种行为都是符合标准的,看编译器选择,当然这里一般而言对仿真没有影响,真实电路行为也是明确的。
interface的采样
interface中有一个非常非常重要的东西,就是clocking block。clocking block里面我们通常会设置input skew和output skew。如设置default input #1ps,那么mon clocking block中的采样值是时钟上升沿前1ps的采样值,这样就避免了可能的取值不定。如设置default output #1ps,那么drv clocking block会在时钟上升沿后1ps去驱动interface.data(即RTL信号),避免了可能存在的驱动冒险。
当然这两个值不设置的话,默认input skew应该是#1step,即取值在当前time-slot之前的一个step(即上一个时间片的postponed域,由于前一个ts的postponed域采样值与当前ts preponed域值一样,可认为默认情况下interface clock block采样发生在该time-slot preponed域),默认output skew记不清楚是不是#1step了。
关于clocking block,我们主要明确两点:
- 无论在任何位置@clocking block(例如@bus.mon),该时钟对齐发生的时刻都是observed域,因此@bus.mon之后,module所有信号的值都已经update完成,可以看到“新值”。
- 无论在任何位置使用bus.mon.sig,使用的都是preponed(或更早,例如default input 1ps)的值,即跳变前的“旧值”。
验证环境中对齐和采样
验证环境搭建于program中
- 无论@rtl.clk或是@interface.mon(clocking block),使用rtl.sig(或interface.sig),会使用到的是新值,原因是program位于program regions,此时rtl代码已在module regions完成更新;
- 无论@rtl.clk或是@interface.mon(clocking block),使用interface.mon.sig,会使用到的是旧值,原因是interface.mon.sig在preponed region或更之前完成的更新;
验证环境搭建于module中
- @rtl.clk,使用rtl.sig(或interface.sig),会导致冒险不一定采样到新值或旧值,原因是rtl.sig的更新可能位于active region,环境中使用rtl.sig也位于active region,两个进程不确定哪一进程先执行;
- @rtl.clk,使用interface.mon.sig,会使用到的是旧值,原因是interface.mon.sig在preponed region或更之前完成的更新;
- @interface.mon(clocking block),使用rtl.sig(或interface.sig),使用到的是旧值,因为@interface.mon对齐行为发生在observered region,rtl信号已在之前的module regions完成更新;
- @interface.mon(clocking block),使用interface.mon.sig,原因是interface.mon.sig在preponed region或更之前完成的更新;