电子说
延迟行为
Verilog语言的延迟语句虽然不能综合,但是在仿真过程中应用得很多。延迟语句可以用在testbench中构建时钟信号和激励,也可以用在Verilog模块中模拟实际电路的延迟。延迟语句可以出现在两条赋值语句之间,也可以出现一条赋值语句中间。
#3 a = b; //延迟语句在赋值语句之间
a = #3 b; //延迟语句在赋值语句内部
在赋值语句之间的延迟语句可以延迟语句的执行。对于仿真器来说,处于赋值语句之间的延迟语句有两个作用。首先,延迟语句会暂停当前always块的执行,结束当前的仿真阶段,更新之前没有完成的赋值,完成当前事件的响应并将控制前交还给事件队列。然后,延迟语句会在事件队列中添加一个新的事件。这个事件表示在延迟语句指定的时刻开始执行这函数剩下的部分。例如
always @(a, b, c) begin : add_mux4
t <= a + b;
#1 d = t * c;
end
上述代码转化后的事件响应函数为
function add_mux4_1 :
t_update = a + b;
t = t_update;
addEvent( curr_time + 1, add_mux4_2 );
function add_mux4_2 :
d = t * c;
Verilog文件中的1个过程块被转换为两个函数。第一个函数add_mux4_1对应于延迟语句之前的部分,第二个函数add_mux4_2对应于延迟语句之后的部分。
add_event是本文定义的一个原语,表示向事件队列中添加一个事件。第一个参数表示事件响应的时间,第二个参数表示响应事件需要调用的事件响应函数。从第三个参数开始,之后的参数会作为事件响应函数的参数,传递给事件响应函数。
add_event( curr_time + 1, add_mux4_2 )表示在当前时间(curr_time)后1个时间单位的时候响应这个事件。事件需要调用add_mux4_2函数。响应函数不需要额外的参数。在调用add_mux4_2时,信号t已经完成更新。
在赋值语句中间的延迟语句将评估和更新阶段分割到两个时刻进行。评估过程仍然在语句执行的时候进行,但是更新过程延后到延迟语句指定的时刻进行。延迟语句是否阻塞过程块的执行,取决于赋值语句本身。如果是阻塞赋值语句,赋值语句中间的延迟语句会阻塞过程块的执行;如果是非阻塞赋值,延迟语句不会阻塞过程块的执行。例如
always @(a, b, c) begin : add_mux5
t <= #1 a + b;
d = #2 t * c;
end
上述代码转化后的事件响应函数为
function add_mux5_1:
t_update = a + b; // 1
d_update = t * c; // 2
addEvent( curr_time + 1, update_t, t_update );
addEvent( curr_time + 2, add_mux5_2, d_update );
function update_t( t_update ) :
t = t_update; // 3
function add_mux5_2( d_update ) :
d = d_update; // 4
如果没有延迟语句,事件响应函数的执行顺序应该是 1->2->4->3。由于第一个语句中的延迟语句,语句3需要在当前时刻之后1个时间单位时执行,即update_t。t_update作为事件响应函数的参数,在update_t中更新给信号t。由于第二个语句中的延迟语句,过程块被打断为两个部分,第二个函数需要在当前时刻之后2个时间单位时执行,即add_mux5_2。add_mux5_2需要使用d_update作为参数。
理解到这一层,就可以处理更加复杂的波形了。例如下面这一段代码。
module test;
reg x,y,z;
assign #25 a = 1;
always begin
#20;
x = #10 a;
#3 y = a;
#3 z = a;
#7;
end
endmodule
经过仿真器的转换,上面的Verilog语句会形成如下的事件响应函数。
function assign1 :
a = 1;
function always1_1 :
addEvent( curr_time + 20, always1_2 );
function always1_2 :
x_update = a;
addEvent( curr_time + 10, always1_3, x_update );
function always1_3( x_update ) :
x = x_update;
addEvent( curr_time + 3, always1_4 );
function always1_4 :
y = a;
addEvent( curr_time + 3, always1_5 );
function always1_5 :
z = a;
addEvent( curr_time + 7, always1_6 );
function always1_6 :
addEvent( curr_time + delta, always1_1 );
在仿真开始时候,首先向事件队列中添加两个事件,分别是在0+25时刻调用assign1,以及在0+0时刻调用always1_1。事件响应过程如图4所示。always过程块被延迟语句分割成了6个响应函数。每个部分都向事件队列添加能够触发下一个响应函数的事件。信号x的第1次评估发生在20时刻,而第1次更新发生在30时刻,所以信号x的第一次赋值仍为X。直到第2次评估时(63时刻)才能获得有效的信号1,并且在73时刻更新给信号x。
图4 示例过程的事件队列响应过程和波形图
需要说明的是,虽然本文提供了一种思路能够比较轻松地理解行为级描述的执行过程,但是仍然不建议大家在过程块中混用阻塞赋值和非阻塞赋值。混用赋值语句是危险的。
Assign赋值
前面介绍的侧重于过程块。对于Assign赋值语句,原理其实是也一样的。例如
assign a = #5 b & c;
这条assign语句同样可以看做一个事件响应函数。这个函数绑定的事件是信号b或信号c发生变化。延迟语句的效果也是一样的。延迟语句将评估和更新过程分开。当信号b或信号c发生变化时进行评估,并在事件队列中添加一个新的更新事件。5个时间单位之后,响应更新事件,将评估的值更新给信号a。
转换后的事件响应函数如下。
function assign1:
a_update = b & c;
addEvent( curr_time + 5, update_a, a_update );
function update_a( a_update ) :
a = a_update;
调试
Verilog仿真器普遍提供了Verilog代码的调试能力,比如断点和单步运行。在VCS、ModelSim、Vivado和Quartus中都能找到调试模式。断点和单步运行是典型的软件调试手段,是软件工程师的看家本领。但是对于硬件来说,断点和单步运行却是不可理解的,因为硬件是并行的。如果将断点理解为硬件电路在某一个时刻的状态,那么此时应该有多条语句被同时中断。硬件电路不会像软件一样在某个函数中中断并且单步执行,而且其他过程块或语句毫无影响。
前面已经介绍过,Verilog并不是可执行语言。真正的可执行仿真程序是由仿真器提供的仿真框架源代码和由Verilog语言转换而来的仿真程序源代码构成的。实际上,Verilog语言调试的断点并不是添加给硬件的或者Verilog源文件的,而是添加到可执行仿真程序中对应的事件响应函数的。单步调试的对象也是可执行仿真程序中的事件响应函数。所以,Verilog代码可以引入断点和单步调试。
在进一步解释Verilog调试器的机制之前,必须先解释一下软件调试器是如何调试程序的。为了使得可执行文件可调试,编译器会在可执行程序中添加调试信息。以C语言为例,编译器会在可执行文件中添加调试信息(如图5所示)。添加的位置是在对应于C语言语句的汇编代码段起始的位置。在进行软件调试的时候,软件调试器会在有调试信息的地方暂停(比如0x4005a5)。进行单步调试的时候,每一步也都是停在C语言语句开始的地方(比如0x4005bf)。
图5 可调试程序中添加的调试信息(利用objdump命令得到)
Verilog调试器的作用就是将可执行仿真程序和Verilog语言对应起来。一种思路是将编译器插入的调试信息与Verilog语言对应起来。编译器插入的调试信息是对应于可执行的仿真源文件,而这些源文件是由仿真器生成的。所以Verilog调试器可以获得调试信息与Verilog语句的对应关系。另一种思路是通过编译器直接给可执行仿真程序添加与Verilog语言对应的调试信息。这样Verilog调试器从可执行程序就可以获得必要的信息,而不需要额外的消息来源。
Verilog调试器只能对可以与Verilog语言对应的代码部分添加断点,也就是只能对事件响应函数添加断点。Verilog调试器不能调试由仿真器提供的仿真框架源代码。当单步调试遇到always块结束或者assign语句之后,调试器不会进入仿真引擎,而是直接跳转到下一个事件响应函数。此外,Verilog调试器还限制了断点和单步调试的粒度只能以Verilog语句为单位,而不能进一步缩小粒度到可执行仿真程序的语句甚至汇编层面。
图6 Verilog程序加断点的过程
以图6中的Verilog程序为例,经过Verilog仿真器得到右边所示的仿真源文件,再经过编译得到可执行程序。在左边Verilog程序中的一行设置了一个断点(图5中左边第7行),这个断点实际上是设置在右边的可执行程序的事件响应函数always1_4中的(图5中右边第2行)。每当仿真程序运行到always1_4时就触发中断,暂停程序执行。通过仿真器添加的调试提示信息,调试器能够知道中断的位置是Verilog语言的第7行,从而在图形界面上显示。
从断点的位置开始单步调试。从可执行仿真程序的层面来说应该在第3行暂停,并且呈现第3行执行后状态。但是,Verilog调试器会过滤仿真框架的程序,也就是过滤掉无法对应到Verilog程序的语句。所以,可执行程序不会在第3行之后暂停,而是继续执行。第3行结束后,程序从事件响应函数返回,进入仿真框架的部分。仿真器框架的代码也会被调试器忽略,直到仿真程序进入下一个事件响应函数。最终,程序进入always1_5。调试器会在第6行暂停,并且将中断的位置对应到Verilog软件的第8行。程序运行的标志会显示在图6中第8行的位置。
以上的过程对于用户来说都是不可见的。从用户的角度看来,只能看到程序指针从第7行跳到第8行,并且第7行语句的效果在波形图上展现了出来。这就是Verilog语言调试背后隐藏的过程,其核心仍然是软件调试。
结语
本文的初衷是提供通过仿真器理解Verilog语言的思路。文中关于Verilog仿真器的描述采用了最简单、最直接的思路,当然也是效率最低的。实际的仿真器会通过各种软件技巧进行优化,提高仿真效率。文中使用的一些概念借鉴自SystemC,比如仿真阶段和“评估-更新”机制。电路仿真器的设计思路和概念都是类似的或者相通的,可以触类旁通。
如果有读者想进一步理解Verilog仿真器,不妨看一下开源Verilog仿真器iVerilog的源码。此外,SystemC也是一套很好的硬件电路仿真框架,建议学习SystemC标准。IEEE的SystemC标准会阐述SystemC需要的仿真引擎以及编程规范。
作者才疏学浅,挂一漏万,请大家多批评指正。
全部0条评论
快来发表一下你的评论吧 !