你真的懂Verilog吗?
时间:2021-10-11 14:00:34
手机看文章
扫描二维码
随时随地手机看文章
[导读]来源:内容来自「知乎」作者:王君实要想深入理解Verilog就必须正视Verilog语言同时具备硬件特性和软件特性。在当下的教学过程中,教师和教材都过于强调Verilog语言的硬件特性和可综合特性。将Verilog语言的行为级语法只作为语法设定来介绍,忽略了Verilog语言的软...
来源:内容来自「知乎」作者:王君实
要想深入理解Verilog就必须正视Verilog语言同时具备硬件特性和软件特性。在当下的教学过程中,教师和教材都过于强调Verilog语言的硬件特性和可综合特性。将Verilog语言的行为级语法只作为语法设定来介绍,忽略了Verilog语言的软件特性和仿真特性。使得初学者无法理解Verilog语言在行为级语法(过程块、赋值和延迟)背后隐藏的设计思想。本文尝试从仿真器的角度对Verilog语言的语法规则进行一番解读。
“精分”的Verilog语言
在集成电路的设计流程中,Verilog源文件有两个主要作用:综合和仿真。在图1中,数字①②③④标注的位置都可以使用Verilog作为设计的描述方法。
仿真器基本架构
Verilog语言确实不是一种可执行语言。图2展示了利用Verilog源文件进行仿真的过程。绝大多数仿真器都遵循这一思路,比如VCS、iVerilog、ModelSim、Vivado和Quartus等。首先,准备Verilog源文件以及一些Verilog库文件(标准单元等)。仿真器接收这些Verilog文件并将其转化为可执行的仿真源文件(C/C 等)。在这一过程中,仿真器解析Verilog文件的语法结构,并且根据Verilog语法的规范,将语法结构转化为仿真器中的事件响应函数或代码段。这些函数和代码段与仿真器框架源文件一起成为可执行仿真程序的源文件。接下类这些源文件经过编译得到可执行的仿真程序。VCS和iVerilog可以看到生成的可执行文件。ModelSim、Vivado和Quartus使用GUI管理设计流程,从而将这个可执行文件屏蔽了,使其对于用户可透明。用户可以在工程中找到生成的可执行文件。最后,运行可执行的仿真程序,进行软件仿真。
可执行仿真源文件和仿真器框架源文件一般是不可见的。不过在开源软件(例如iVerilog)中可以找到生成可执行仿真源文件的代码。仿真程序通常采用基于事件的仿真架构。这种仿真架构的核心是事件队列。事件队列中按照事件的响应时间排列着一系列的事件。响应时间相同的事件之间不应该有决定性的事件依赖关系。如果需要确定这些事件之间的顺序,可以引入Δ时间。响应时间为t Δ的事件必然晚于响应时间为t的事件。但是从仿真时间上,仍然表现为在相同时刻响应。事件队列按照时间先后顺序逐个响应事件队列中的事件。每一个事件,除了标注事件响应时间,还会标注事件类型以及其他需要的参数。通过事件类型,仿真引擎可以找到对应的响应函数。其他的参数则作为事件响应函数的输入参数。事件响应函数会产生新的事件。这些新的事件还会插入到事件队列中,并且按照其响应时间排序。图3展示仿真引擎响应一个事件的过程。仿真引擎响应事件队列中的第一个事件e1。事件e1被从队列中移除。事件队列从事件e2开始。仿真引擎根据e1的类型找到了事件响应函数。这个响应函数又调用了3个模块中的事件响应函数。这些事件响应函数模拟硬件电路的行为,并且产生了新的事件。模块1产生了事件e197和e199,分别插入到t1时刻和t99时刻;模块2产生了事件e198,插入t1 Δ时刻;模块3产生了事件e200,插入t100时刻。通过“读出第一个事件-响应事件-插入新事件”的循环,事件队列可以一直运行下去,直到事件队列为空或者达到了仿真结束的时间。另一方面,在仿真开始的时候,必须向事件队列中插入起始事件,从而开始仿真循环。Verilog仿真器提供了仿真引擎(在图2中的仿真器框架源文件部分),所以大家在写Verilog的时候不用去自己“造轮子”。但是仿真引擎并不知道事件和响应函数的对应关系以及响应函数的具体功能。仿真器的工作就是将Verilog文件转化为仿真响应函数并且与仿真引擎进行连接。生成的可执行仿真源文件和仿真器框架文件一起构成了完整的仿真器。接下来,分析一下Verilog的语法结构(过程块、赋值和延迟)如何变成仿真器的源文件。
过程块always过程块是Verilog最基本的行为级描述结构。通过在always语句中设置敏感列表,可以在恰当的时刻触发过程块内的操作。敏感列表中使用的条件主要是信号沿(上升沿、下降沿)以及信号值变化两种。如果敏感列表有多个条件,这些条件是“或”的关系,也就说只要有一个条件满足,always过程块中的语句就会执行一次。
对应到仿真器中,always过程块的语义就是给仿真中的特定事件绑定响应函数。always过程块中的语句序列是事件响应函数的函数体,而always语句的敏感列表确定了这个事件响应函数与哪些事件绑定。例如下面的D触发器。
一个条件可以被绑定多个事件响应函数。比如时钟信号的事件可以与所有的always块的事件响应函数绑定。当时钟信号的事件发生的时候,与其绑定的事件响应函数会逐个被调用。如果一个信号在多个always过程块中都被赋值,那么一个变量会被多个事件响应函数修改。在硬件上,这些响应函数之间应该是并发的,没有先后关系。但是,串行执行函数的软件是做不到的这样的并发的。在仿真器中,always过程块之间也是有顺序的。Verilog规定,always块之间的执行顺序是按照always块在Verilog文件中的先后顺序。这仅仅是为了适应软件仿真器所引入的设定。
如果敏感列表中有多个条件,表示always块与这些信号都绑定。如果always块没有执行敏感列表或者是给出一个星号(*),表示always块应该与过程块中所有的右值变量绑定。在这种情况下,由每个事件都直接触发事件响应函数可能会引起重复响应,即在某个时刻事件响应函数被多次触发的情况。为了避免这样的错误,仿真器中引入了仿真阶段的概念。同一个仿真阶段中响应的事件,响应时间必须,而且Δ时间也必须相同。在同一个仿真阶段中,每个事件响应信号只能被触发一次。每个仿真阶段中,首先在事件队列中找到需要响应事件,然后累计需要调用的事件响应函数。最后再依次调用这些事件响应函数。这样就保证了同一个时间的信号变化只会触发同一个always过程块一次。
除了always过程块,在Verilog中还定义了其他的过程块。与always过程块不同,这些过程块不由信号的事件触发,而是要单独在事件队列上插入事件,并且与过程块转化成的响应函数绑定。initial过程块只在仿真开始的时候执行一次。也就是说,如果定义initial过程块,那么事件队列上的第一个事件就是initial过程块的事件。repeat过程块和forever过程块在事件响应函数结束时向电路中添加触发下一次响应函数的事件。这个事件在下一个Δ时刻就会响应,由此往复。当重复了足够多次数后,repeat过程块会停止向事件队列中添加事件,从而结束repeat语句。forever过程块的循环不会结束。
赋值语句
Verilog语言提供了阻塞赋值和非阻塞赋值两种赋值语句。
需要说明的是,虽然本文提供了一种思路能够比较轻松地理解行为级描述的执行过程,但是仍然不建议大家在过程块中混用阻塞赋值和非阻塞赋值。混用赋值语句是危险的。
Assign赋值前面介绍的侧重于过程块。对于Assign赋值语句,原理其实是也一样的。例如
要想深入理解Verilog就必须正视Verilog语言同时具备硬件特性和软件特性。在当下的教学过程中,教师和教材都过于强调Verilog语言的硬件特性和可综合特性。将Verilog语言的行为级语法只作为语法设定来介绍,忽略了Verilog语言的软件特性和仿真特性。使得初学者无法理解Verilog语言在行为级语法(过程块、赋值和延迟)背后隐藏的设计思想。本文尝试从仿真器的角度对Verilog语言的语法规则进行一番解读。
“精分”的Verilog语言
在集成电路的设计流程中,Verilog源文件有两个主要作用:综合和仿真。在图1中,数字①②③④标注的位置都可以使用Verilog作为设计的描述方法。
- 综合工具读入源文件,通过综合算法将设计转化为网表,比如DC。能够综合的特性要求Verilog语言能够描述信号的各种状态(0,1,x,z)、信号和模块的连接(例化)以及模块的逻辑(赋值以及各种运算符)。
- 仿真器读入源文件,生成一个可执行程序用于仿真硬件的行为,比如VCS。能够仿真的特性要求Verilog语言又具有软件特性,对每一条语句的执行语义和顺序给出定义(延迟语句)。同时,软件特性使得Verilog语言更加灵活,具备了丰富的行为级仿真能力(条件分支、循环等)。
仿真器基本架构
Verilog语言确实不是一种可执行语言。图2展示了利用Verilog源文件进行仿真的过程。绝大多数仿真器都遵循这一思路,比如VCS、iVerilog、ModelSim、Vivado和Quartus等。首先,准备Verilog源文件以及一些Verilog库文件(标准单元等)。仿真器接收这些Verilog文件并将其转化为可执行的仿真源文件(C/C 等)。在这一过程中,仿真器解析Verilog文件的语法结构,并且根据Verilog语法的规范,将语法结构转化为仿真器中的事件响应函数或代码段。这些函数和代码段与仿真器框架源文件一起成为可执行仿真程序的源文件。接下类这些源文件经过编译得到可执行的仿真程序。VCS和iVerilog可以看到生成的可执行文件。ModelSim、Vivado和Quartus使用GUI管理设计流程,从而将这个可执行文件屏蔽了,使其对于用户可透明。用户可以在工程中找到生成的可执行文件。最后,运行可执行的仿真程序,进行软件仿真。
可执行仿真源文件和仿真器框架源文件一般是不可见的。不过在开源软件(例如iVerilog)中可以找到生成可执行仿真源文件的代码。仿真程序通常采用基于事件的仿真架构。这种仿真架构的核心是事件队列。事件队列中按照事件的响应时间排列着一系列的事件。响应时间相同的事件之间不应该有决定性的事件依赖关系。如果需要确定这些事件之间的顺序,可以引入Δ时间。响应时间为t Δ的事件必然晚于响应时间为t的事件。但是从仿真时间上,仍然表现为在相同时刻响应。事件队列按照时间先后顺序逐个响应事件队列中的事件。每一个事件,除了标注事件响应时间,还会标注事件类型以及其他需要的参数。通过事件类型,仿真引擎可以找到对应的响应函数。其他的参数则作为事件响应函数的输入参数。事件响应函数会产生新的事件。这些新的事件还会插入到事件队列中,并且按照其响应时间排序。图3展示仿真引擎响应一个事件的过程。仿真引擎响应事件队列中的第一个事件e1。事件e1被从队列中移除。事件队列从事件e2开始。仿真引擎根据e1的类型找到了事件响应函数。这个响应函数又调用了3个模块中的事件响应函数。这些事件响应函数模拟硬件电路的行为,并且产生了新的事件。模块1产生了事件e197和e199,分别插入到t1时刻和t99时刻;模块2产生了事件e198,插入t1 Δ时刻;模块3产生了事件e200,插入t100时刻。通过“读出第一个事件-响应事件-插入新事件”的循环,事件队列可以一直运行下去,直到事件队列为空或者达到了仿真结束的时间。另一方面,在仿真开始的时候,必须向事件队列中插入起始事件,从而开始仿真循环。Verilog仿真器提供了仿真引擎(在图2中的仿真器框架源文件部分),所以大家在写Verilog的时候不用去自己“造轮子”。但是仿真引擎并不知道事件和响应函数的对应关系以及响应函数的具体功能。仿真器的工作就是将Verilog文件转化为仿真响应函数并且与仿真引擎进行连接。生成的可执行仿真源文件和仿真器框架文件一起构成了完整的仿真器。接下来,分析一下Verilog的语法结构(过程块、赋值和延迟)如何变成仿真器的源文件。
过程块always过程块是Verilog最基本的行为级描述结构。通过在always语句中设置敏感列表,可以在恰当的时刻触发过程块内的操作。敏感列表中使用的条件主要是信号沿(上升沿、下降沿)以及信号值变化两种。如果敏感列表有多个条件,这些条件是“或”的关系,也就说只要有一个条件满足,always过程块中的语句就会执行一次。
对应到仿真器中,always过程块的语义就是给仿真中的特定事件绑定响应函数。always过程块中的语句序列是事件响应函数的函数体,而always语句的敏感列表确定了这个事件响应函数与哪些事件绑定。例如下面的D触发器。
always @ (posedge clk) begin
q <= d;
end
经过仿真器的转换就变成为如下的响应函数:function always_block1 :
q = d;
这个响应函数会与clk信号的上升沿事件(positive)进行绑定。当响应clk信号的上升沿事件的时候,仿真器会调用always_block1这个函数。一个条件可以被绑定多个事件响应函数。比如时钟信号的事件可以与所有的always块的事件响应函数绑定。当时钟信号的事件发生的时候,与其绑定的事件响应函数会逐个被调用。如果一个信号在多个always过程块中都被赋值,那么一个变量会被多个事件响应函数修改。在硬件上,这些响应函数之间应该是并发的,没有先后关系。但是,串行执行函数的软件是做不到的这样的并发的。在仿真器中,always过程块之间也是有顺序的。Verilog规定,always块之间的执行顺序是按照always块在Verilog文件中的先后顺序。这仅仅是为了适应软件仿真器所引入的设定。
如果敏感列表中有多个条件,表示always块与这些信号都绑定。如果always块没有执行敏感列表或者是给出一个星号(*),表示always块应该与过程块中所有的右值变量绑定。在这种情况下,由每个事件都直接触发事件响应函数可能会引起重复响应,即在某个时刻事件响应函数被多次触发的情况。为了避免这样的错误,仿真器中引入了仿真阶段的概念。同一个仿真阶段中响应的事件,响应时间必须,而且Δ时间也必须相同。在同一个仿真阶段中,每个事件响应信号只能被触发一次。每个仿真阶段中,首先在事件队列中找到需要响应事件,然后累计需要调用的事件响应函数。最后再依次调用这些事件响应函数。这样就保证了同一个时间的信号变化只会触发同一个always过程块一次。
除了always过程块,在Verilog中还定义了其他的过程块。与always过程块不同,这些过程块不由信号的事件触发,而是要单独在事件队列上插入事件,并且与过程块转化成的响应函数绑定。initial过程块只在仿真开始的时候执行一次。也就是说,如果定义initial过程块,那么事件队列上的第一个事件就是initial过程块的事件。repeat过程块和forever过程块在事件响应函数结束时向电路中添加触发下一次响应函数的事件。这个事件在下一个Δ时刻就会响应,由此往复。当重复了足够多次数后,repeat过程块会停止向事件队列中添加事件,从而结束repeat语句。forever过程块的循环不会结束。
赋值语句
Verilog语言提供了阻塞赋值和非阻塞赋值两种赋值语句。
a = b; // 阻塞赋值
a <= b; // 非阻塞赋值
按照语法定义,阻塞赋值会阻塞之后语句的执行;非阻塞赋值则不会阻塞之后语句的执行。阻塞语句达成的效果是下一条语句执行之前信号a已经变修改。非阻塞赋值达成的效果是,信号a的值只有到整个过程块执行完,才会被修改。需要注意的是,非阻塞赋值虽然被延后,但是所赋的值仍是之前得到的值。这一段话着实令人感到疑惑。现在我们从软件仿真器的角度重新来解析赋值语句。赋值语句其实包含两个过程:评估和更新。评估过程确定了需要赋给信号的值,而更新过程才真正的修改了信号的值。评估过程和更新过程是相互独立的。这两个过程中的关联只有需要赋的值。阻塞赋值的评估过程和更新过程是连续执行的,评估之后立即更新。所以,在执行下一条语句的时候,信号已经被修改了。在转换成仿真器代码时,阻塞赋值不需要特殊处理。例如always @(a, b, c) begin : add_mux1
t = a b;
d = t * c;
end
上述代码转化后的事件响应函数为function add_mux1 :
t = a b;
d = t * c;
非阻塞赋值的评估过程和更新过程是分开的。过程块中执行到赋值语句的时候,只进行了评估过程,确定需要赋给信号的值,然后继续向后执行。更新过程被延后到整个过程块执行之后。例如always @(a, b, c) begin : add_mux2
t <= a b;
d <= t * c;
end
上述代码转化后的事件响应函数为function add_mux2 :
t_update = a b;
d_update = t * c;
t = t_update;
d = d_update;
当阻塞赋值和非阻塞赋值混合的时候,也遵循同样的规则。例如always @(a, b, c) begin : add_mux3
t <= a b;
d = t * c;
end
上述代码转化后的事件响应函数为function add_mux3 :
t_update = a b;
d = t * c;
t = t_update;
对信号的赋值会产生一个事件,事件表示被赋值的信号发生了变化。如果有其他的过程块依赖于被赋值的信号,那么这个事件会被添加到事件队列中;反之,这个事件会被忽略。事件的响应时间为当前时间加Δ。赋值语句是仿真引擎能够持续运行的关键。大部分always块都是通过赋值语句向事件队列添加新的事件的。延迟行为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。需要说明的是,虽然本文提供了一种思路能够比较轻松地理解行为级描述的执行过程,但是仍然不建议大家在过程块中混用阻塞赋值和非阻塞赋值。混用赋值语句是危险的。
Assign赋值前面介绍的侧重于过程块。对于Assign赋值语句,原理其实是也一样的。例如
assign a = #5 b