上一次介绍了verilog语言中的词法结构,并给出了verilog词法的形式描述文件,可以通过flex工具生成词法分析程序。运行该程序,我们可以逐个读取源代码中的单词。当然,词法分析之前还有一个预处理过程,后面会给出预处理过程的实现代码。
学习一种计算机语言,我们在搞定单词表后,下一步关心的一个是底层的语言要素,就是这种语言描述什么样的数据类型和数据结构,如何描述,同时也关心这种语言的总体组织是什么样的,如何运行起来。软件工程师的第一个任务就是很得意地写个Hello, World!的程序,表示这种语言,我入门了,然后再苦苦折磨自己,陷入到语言的各种细节和各种技巧中去。
本节就介绍verilog语言中的数据类型和数结构,以及这种语言的程序结构,主程序是什么样子的,各个子程序如何调用,参数如何传输等等。后面还接着介绍如何用软件来描述它们,以便构造一个模拟运行平台。
让我们从一个例子开始,学习计算机语言是一个很奇怪的事情,按说把语言的语法手册和规范手册完整看一遍,应该是最有效的把,但是这样做往往记住了很多语言规则,还是不会编程序,最后连语法都忘记了。最好的学习办法就是做个实际的例子,然后通过看语法规范来补漏。
想象一下你拿到一个FPGA开发板,板上有一个FPGA,还有十个按钮,十个数码管(七段数字笔划加一个小数点),当然还有晶振电路之类来提供时钟信号和复位信号。开发板上已经将FPGA与按钮和数码管通过一个32位读写总线连接在一起。这个总线包括读写信号,读写地址,读写数据,写的时候还有一个字节使能,支持只写其中某几个字节。
按钮是否按下的信息可以由FPGA读某个地址,比如0xF0000000,返回的低10位值就是10个按钮是否按下的状态。数码管的控制也是每个数码管给一个地址,比如从地址0xF0000010到0xF0000019,每个字节地址对应一个数码管,往地址上写一个八位数,每一位代表该地址对应的数码管的对应段是否点亮,比如要显示一个0,就写个8’b00111111,要显示一个5,则写入8’b01101101,分别对应数码管中DPGFEDCBA八个显示单元的控制电平。
十位数码管:
我们要做的第一个Verilog应用就是做一个FPGA程序,来控制数码管上显示的内容。其中包括一个计数器,对时钟进行计数,控制数码管将计数值显示在数码管上。
计数器一开始是不动作的,在外部按第0个键时对计数器的值进行清零,按第1个键时停止计数,按第2个键开始计数,开始计数时计数值从当前值开始(如果多个键同时按下,则以序号小的为准)。
我们一下就引出几个问题:1.我们的Verilog应用要如何与FPGA板连接在一起,2.计数器如何设计,如何对时钟进行计数。3.计数器值到数码管的控制码之间的对应关系,4.按键状态以及计数值复位,当然还有更加基础的,计数值如何存储,所谓外部的数据如何读写。我们后面一个一个来解决。
我们写c语言代码时,会写一个所谓的主函数:
int main(int argc, char * argv[])
{
printf("Hello, World!\n");
return 0;
}
那么Verilog程序的主程序如何写呢?对应到c语言的函数,Verilog中是模块,主函数对应的就是与FPGA对外连接的所有I/O管脚的一个主模块。每个Verilog应用程序必须有一个主模块,主模块也称为顶层模块(top module)。前面描述的FPGA的主模块写成如下的verilog代码:
module main(wClk, nwReset,
wWrite, bWriteAddr,
bWiteData, bWriteMask,
wRead,
bReadAddr, bReadData);
input wClk, nwReset;
output wWrite;
output [31:0] bWriteAddr;
output [31:0] bWriteData;
output [3:0] bWriteMask;
output wRead;
output [31:0] bReadAddr;
input [31:0] bReadData;
endmodule
这段代码定义了一个所谓的模块,并引入一种简单的命名规则,名字以w,b,nw开始,分别表示一位高电平有效的信号,多位信号组合,和一位低电平有效的信号,后面是表示这个名称的英文,每个单词第一个字母大写,如果是缩写,就可以全部大写,或者把缩写当英文单词用,良好的命名规则是良好代码的基础。
模块定义从关键字module开始,后面跟着用户自己给模块取的名字,这个名字可以是前面词法中讲的simident或者escident。这里取main也不是主模块的意思,实际上每个FPGA开发工具或者ASIC的开发工具都能够让用户选择一个模块作为主模块。模块名称后面是一对括号括住的模块端口表,这里给出端口名字。所谓端口,可以理解位c语言函数中的参数,注意顺序是有意义的,有点象c语言中的函数原型定义了,只是类型不在这里定义。后面跟个分号,这样就声明了一个模块。紧接着声明各个端口的属性,包括输入输出,宽度等信息,用input表示这个端口时外部往模块中传信号,用output表示这个端口是模块往外部传信号,用inout表示这是双向传信号的端口。如果不给出宽度,则表示端口是一个一位信号,给宽度的办法是用一对方括号,中间有一对用:分开的整数常数。模块最后以一个endmodule结束。这是一个什么也不做的模块,后面我们会慢慢增加内容,完成前面说的功能。
这个main模块对外的接口有时钟信号wClk, 复位信号nwReset,这是从FPGA输入到主模块的。模块输出一组写信号:wWrite,bWriteAddr, bWriteData, bWriteMask。几个信号之间的关系及完成的功能是,wWrite为1时,往bWriteAddr地址上写一个32位值bWriteData,bWriteMask是写的时候的字节使能,4位对应32位中的四个八位,为1时表示写入,为0时表示不变。
该模块还提供了一组读信号,wRead, bReadAddr, bReadData。其功能是,wRead为1时,同时给出bReadAddr,下一个时钟周期bReadData就是bReadAddr地址上的值。
这个模块将是我们以后的模拟器的主模块,每个在我们的模拟器上运行的verilog应用程序都必须写这样原型的主模块。
在FPGA应用中,主模块描述的输入输出信号就是FPGA上各个管脚上的信号。FPGA开发工具有一个pin assignment的工具,就是将主模块的输入输出信号跟硬件管脚一一对应起来,并配置管脚上的输入输出特性,驱动特性,电压电流等令软件工程师看了头大的特性,幸好这辈子不是数字工程师啊,否则不被折腾到半死。
如果是ASIC设计,顶层模块的信号也同样对应到芯片管芯的输入输出脚信号,这个可能不是外部封装的管脚,毕竟从晶元到封装还有一次基板映射。当然ASIC开发平台中也有类似于pin assignment的动作。
PS.数字电路工程师看到这些个诸如应用程序,编译,主模块,主程序入口之类的叫法是不是很不爽啊,为了让软件工程师理解,先忍忍吧,名字而已,叫着叫着就习惯了。
软件工程师写完主函数后,接着写实现功能的若干函数,然后再主函数中调用,完成需要的功能。我们先来写个计数器模块。
module counter
#(parameter WIDTH=4,
MAXVALUE=9, RESETVALUE=0)
(input wClk, nwReset, wCounterIt,
output [WIDTH-1:0] bCouter,
output wCounterOverflow);
endmodule
这个模块定义方式跟前面不一样了,在模块名称与端口表之间有个所谓的参数表,我们希望定义一个可以通用的计数器,也就是外部可以指定计数器的位数,以及计数的最大值,到达这个值之后就输出wCounterOverflow,计数值从头开始,还有计数的复位值,也就是复位信号有效时的初始化值以及到达最大值后的从头开始的值。参数表以#开始,被一对括号括住,中间是多个参数,每个参数格式可以只给个名称,也可以是 名称=常数表达式 的格式,参数之间用逗号隔开,一个模块可以有多个参数表。如果外面使用模块时不给出参数,则参数采用常数表达式作为默认值。注意,参数在模块内可以按照常数对待,因为它在编译时的值是已知的。模块参数化的设计有点象c++中的模板,模板也是参数化的设计方式。参数还可以在模块中定义,后面的章节中会讲到verilog中如何定义模块参数及其他类型参数。考虑到在端口表中就可能用到参数(比如端口信号宽度),所以将参数表紧接着模块名称后面放是合理的,类似下面c++的模板函数定义,这个放得更前,放在函数返回类型和名称前面去了:
template <class T> void swap(T& a, T& b){}
后面的端口表也有变化,可以在端口表中指定端口的类型和宽度了,这个是不是有点象早期的c语言 到c99的变化,软件工程师细品一下。
这里还是定义了一个空的模块,输入wClk和nwReset,在每个时钟周期进行计数,nwReset有效时则进行复位。输入的wConterIt信号则表示每个周期是否要计数,输出bCounter是当前的计数值,wCouterOverflow则表示计数值是否达到指定的最大值,这个信号可以作为下一级计数器wCounterIt的输入,这样多个计数器就可以级联起来用了。
计数器内部必须记住状态,就是当前的计数值,这个计数值在c语言中用个整型变量来表示,在verilog中如何表示呢,我们先暂停一下编程的冲动,来看看verilog中的数据类型和数据结构,然后接着编这个模块。
前面也提到过,verilog语言中,数据是以位为基本单位的,每一位的状态有四种:0,1,x,z。其中0和1是标准的布尔量,它们参与运算按照布尔代数的规则做就是了。x表示状态不确定,或者不关心,无所谓,它参与计算,结果总是x。z表示没有值,在一根没有连接任何输入信号的电缆上量信号,结果是噪声,是没有意义的,所以一旦参与计算,跟x效果相同,它存在的意义主要是描述线缆的状态。
我们前面讲过,所谓电路,其实就是用线缆连接在一起的一些电路单元。其中电路单元我们用module来表示,在本节后面再详细介绍,线缆在verilog中则用所谓线网这种数据类型来表达。
线网有如下几种:supply0,supply1,tri,triand,trior,tri0,tri1,uwire,wire,wand,wor。线网变量声明时要给出它的类型,可选择给出延时,驱动强度(上下拉,弱上下拉,强上下拉),充电强度(小中大),还可以给出一个向量化的范围,表示这是一根多根电缆捆在一起的多芯线缆,最后是一系列标识符,表示线网的名称。
比如下面的声明:
wire a;//一位wire线缆
tried [15:0] address,data;//两根16芯线缆
作为软件工程师,我们不关心电缆的物理属性,这已经涉及到开关级描述了,不属于RTL范畴。比如什么驱动强度,什么充电强度,什么延时,阻抗匹配啊,是不是虚焊之类,软件工程师总是假定连上的就是好的,因此在后面的模拟器中不对这些属性进行模拟。
这些属性主要是影响两根电缆连接在一起后,上面的信号是如何的,比如一个是强上拉,一个是弱下拉,连在一起应该是上拉的效果,一个是强驱动,一个是弱驱动,连在一起结果是强驱动的输出有效。一根连接到多个输出的电缆,如果同时有多个输出电缆上的信号就会很复杂,如果只有一个输出,其他都是高阻态(不输出),线缆上的值就是这个输出的值。后面会给出线网的软件模型。关于线网的详细特征,这里就不再详细讲,以后我们只支持一种线网wire,如果verilog中声明了其他线网,编译器就报个警告,然后放一边不管。
除了线网之外,verilog支持integer,real,realtime,reg,和time数据类型,相应的声明方式就是类型后面跟一系列标识符。
对于reg类型,还可以声明是否是带符号的,以及位宽度(范围),比如:
reg r;//一位寄存器
reg [-1:4] b;//6位寄存器
reg signed [3:0] sreg,breg;//两个4位寄存器
integer i; //一个整数
verilog规定线网和reg的位宽可以由实现来给定最大值,但是这个最大值不得小于65536。
实际上,在RTL可综合描述中,一般都不支持除了reg之外的变量类型。所以后面的模拟器中我们只考虑reg类型的变量,其他类型要么不支持,要么就转换成reg。
所谓RTL可综合,就是编译器要能够把描述编译成一个对应的电路,其实是不是可综合(可编译成电路),只要关注两个问题,一个是描述的电路能否在时钟周期内给出稳定有效的信号,一个是因为电路时并发运行的,因此不能包括某种按顺序执行的隐形假定在里边,也就是c语言中可能的顺序,跳转,循环,分支等概念都必须严格审查,必须保证能够生成顺序无关的执行单元才行。这点目前看比较抽象,我们后面会随时解释。
verilog语言中支持声明线网和变量的数组,声明办法是在标识符后面加数组的下标范围。数组的最大大小也由实现来指定,但是不得少于2^24,16 777 216。比如:
reg xx[11:0];//12个一位信号数组
reg [31:0] regs[127:0];128个32位寄存器
数组可以是多维的:
wire w_array[7:0][4:0];//二维数组。
对数组的操作,只能对其中的某个成员进行操作,因为verilog没有类似c中指针的类型,所以无法同时操作多个数组成员。
然而对一个变量中的多位线网或寄存器操作是允许的,比如声明
reg[31:0] areg;
那么areg[12:7]是一个合法的操作数也是一个合法的赋值对象。这点后面的verilog表达式与赋值介绍中会进行更详细的介绍。
我们接着来看计数器模块的实现。注意verilog没有全局变量的概念,因为主模块也就是顶层模块已经是应用程序能访问的最外层结构了,从内部已经不能直接访问顶层模块外面了,这个不像c语言,在main函数之外还可以定义东西,每个函数内部可以访问全局的数据。verilog中每个模块只能引用自己定义的变量。变量定义在模块的端口描述到endmodule之间,为了更好地看到代码的解释,我们把代码拆成一段一段地放出来,中间夹杂着解释。
module counter
#(parameter WIDTH=4, MAXVALUE=9, RESETVALUE=0)
(input wClk, nwReset, wCounterIt,
output [WIDTH-1:0] bCouter,
output wCounterOverflow);
下面声明一个WIDTH宽度的寄存器用来保存计数器的值:
reg [WIDTH-1:0] bCurrentCounter;
下面声明一个一位寄存器来保存计数器是否溢出,作为时序电路模块的输出,从寄存器输出是一个比较好的选择,可以使用该模的模块的压力,毕竟每个组合电路本质上都是从寄存器到寄存器之间必须建立稳定信号的,我们的模拟器模拟的FPGA保证每个输入信号都是用寄存器输出的,内部逻辑不用缓存到寄存器中即可用于组合电路中:
reg wOverflow;
定义两个与输出端口同名同宽度的线网,这样声明之后,对这些线网的操作等价于对端口的操作:
wire [WIDTH-1:0] bCounter;
wire wCounterOverflow;
将输出线网与输出寄存器连在一起,当然这里也可以直接将输出定义为寄存器,不需要用线网重新中转,不过内部不直接操作输出端口,也是个好习惯的,对端口,还是要敬而远之,尽可能少操作它们。这种赋值方式称为持续性赋值,后面会讲到。编译后的电路上就是将线网与某个寄存器或者线网连接在一起。
assign bCounter = bCurrentCounter;
assign wCounterOverflow = wOverflow;
下面这个语句称为always块,一个模块中可以有多个always块,格式是always开始,后面跟个所谓的事件描述,比如时间延迟时间,时钟上沿事件等,然后跟一个语句(当个居于或者begin end括起来的复合语句, begin end对应到c语言即是{ },个人还是比较喜欢{ } 一些。意思是一个死循环,等到事件发生时就执行后面的语句,执行完后再等这个事件,这里的事件是@(posedge wClk,表示在时钟信号wClk的上升沿,就是从低电平到高电平转换的时刻:
always @(posedge wClk) begin
if (~nwReset) begin
如果复位信号(低电平)有效,则给出寄存器的初始值,这里用所谓非阻塞赋值,表示被赋值的对象是一个时钟沿锁存的寄存器,就是在时钟沿上把后面的表达式表示的组合电路的采样值锁存到寄存器中。这样的描述编译后生成一个寄存器写的动作,寄存器也确实能够记住状态了。有关表达式与赋值的详细情况,后面的章节中会继续介绍。
这里有个比较费解的问题,软件工程师一开始会有点难以接受,就是这个非阻塞赋值其实并没有让寄存器的输出变成表达式的值,也就是说如果这个时刻访问寄存器,得到的值是上个周期的值。这是因为在时钟上沿,表达式的值还没有开始形成,注意组合电路是有延迟的,在时钟上沿,每个寄存器锁存了值后,表达式(组合电路)才能在寄存器锁存完成后的某个时刻(寄存器输出信号建立时间)得到寄存器的输出,作为表达式的输入,然后表达式表示的组合电路经过某个建立时间之后,才能给出有效的值,在下个时钟上沿到达之前所有组合电路会稳定,因此组合电路在时钟上沿时的值其实时根据上个时钟周期寄存器输出的值计算出来的。
bCurrentCounter <= RESETVALUE;
wOverflow <= 1’b0;
end else begin
/*复位信号无效的情况,开始计数操作 */
if (wCounterIt) begin
if (bCurrentCounter == MAXVALUE) begin
到达计数器最大值,则重新设置为复位值,这里参数设置时要注意计数器宽度能表达的值包括最大值,否则计数永远到不了最大值,这样就无法达到设计要求了
bCurrentCounter <= RESETVALUE;
wOverflow <= 1’b1;
end else begin
计数器增加一,注意到这是所谓的非阻塞赋值,也就是赋值符号右边的表达式中bCurrentCounter是上个周期锁存后输出的值,赋予的值将在下个周期时钟信号上沿到达之前才会更新
bCurrentCounter <= bCurrentCounter + 1;
wOverflow <= 1’b0;
end
end
end
end
endmodule
代码拆成一段段的,太难看了,本来想把中间的说明用注释方式写出来的,但是写在代码中CSDN不能指定自动换行(是我没学会么?CSDN高手请留言指教),这样看的时侯得滑动着看注释,也挺难受的,可以尝试着手工换行,把每行搞得短一点,但是到底多宽,还是不好确定,长了手机用户不满意,短了电脑用户不满意。
这里还是忍不住把前面的代码收集在一起,没办法,看着代码不好看,很不舒服,是不是有点强迫症啊,反正又不是写网络小说,不算凑字数吧:
module counter
#(parameter WIDTH=4, MAXVALUE=9, RESETVALUE=0)
(input wClk, nwReset, wCounterIt,
output [WIDTH-1:0] bCouter,
output wCounterOverflow);
/*WIDTH宽度的寄存器用来保存计数器的值*/
reg [WIDTH-1:0] bCurrentCounter;
/*定义一个寄存器来表示计数器是否溢出*/
reg wOverflow;
wire [WIDTH-1:0] bCounter;
wire wCounterOverflow;
/*输出线网直接连接在寄存器上*/
assign bCounter = bCurrentCounter;
assign wCounterOverflow = wOverflow;
always @(posedge wClk) begin
if (~nwReset) begin /*复位处理*/
bCurrentCounter <= RESETVALUE;
wOverflow <= 1’b0;
end else begin
/*复位信号无效的情况,开始计数操作 */
if (wCounterIt) begin
if (bCurrentCounter == MAXVALUE) begin
bCurrentCounter <= RESETVALUE;
wOverflow <= 1’b1;
end else begin
bCurrentCounter <= bCurrentCounter + 1;
wOverflow <= 1’b0;
end
end /*wCounterIt*/
end /*nwReset*/
end /*always*/
endmodule
这个看着舒服多了。有关表达式与赋值,always块的更多的内容,后面会做更详细的介绍,这里先囫囵吞枣好了,不理解也不要纠结,你只要确定的一件事情是,我们完成了计数器模块。
前面提前演示了module的定义,verilog中的程序结构以模块为基础,所有模块的声明和定义都在同一个层次(不会在一个模块中定义另一个模块,这个跟c语言不会在一个函数中定义另一个函数类似,在pascal语言中是可以在一个过程中定义子过程的),这里先看看模块定义的详细语法说明,劳逸结合一下,然后再接着讨论我们的verilog应用。
按照IEEE 1364-2005,模块的定义语法(BNF格式)如下:
/*A.1.2 这里表示在IEEE 1364-2005中的章节号,以便对照学习*/
module_declaration ::=
{ attribute_instance } module_keyword module_identifier
[ module_parameter_port_list ] list_of_ports ;
{ module_item } endmodule
| { attribute_instance } module_keyword module_identifier
[ module_parameter_port_list ] [ list_of_port_declarations ] ;
{ non_port_module_item } endmodule
module_keyword ::= module | macromodule
其中的attribute_instance定义如下:
/*A.9.1*/
attribute_instance ::= (* attr_spec { , attr_spec } *)
attr_spec ::= attr_name [ = constant_expression ]
attr_name ::= identifier
为每个需要额外信息的对象提供附加信息,主要是为编译器或者仿真工具提供一些附加信息,BNF语法中用花括号括住,表示可以给一组或者多组属性,当然也可以不给出。1364规范中没有定义具体的附加信息,只是给出了如果要传达附加信息,应该遵循的格式。这样,如果你工作在某个FPGA开发平台或者ASIC开发平台上,可能要了解对应开发平台上定义的这些附加信息。可以理解为这些附加信息其实是为每个开发平台提供了一些verilog语言扩展的可能。看来开发平台很强势啊,verilog规范制定者都必须为他们预留语言扩展的办法才行,当然也许是几个开发平台参与verilog语言规范制定妥协下来的结果。这种做法可能会降低verilog代码的通用性,但是在性能或者其他方面得到提升,也可以用来实现一些特别的功能。比如我们用LCOM框架实现模拟器时,可以定义某些模块是模拟器预定义模块,这些模块甚至不是用verilog写的,而是用c语言直接写的。为了能够在用户的verilog代码中使用这些预定义的模块,我们可以写一个空的模块,使用指定的名称,并用attribute_instance的方式给出模块实现的LCOM CLSID编号,以便编译器连接该模块时,就可以根据CLSID直接生成LCOM对象。这在FPGA开发平台中也是经常用的,比如预定义的RAM,FIFO或者DSP单元,都是在库里有个空的模块,然后连接的时候换成相应的FPGA或ASIC工艺库中的单元。
后面的参数表是可选项,定义如下:
/* A.1.3 */
module_parameter_port_list ::= # ( parameter_declaration
{ , parameter_declaration } )
/* A.2.1.1*/
parameter_declaration ::= parameter [ signed ] [ range ]
list_of_param_assignments
| parameter parameter_type list_of_param_assignments
在后面是端口表
list_of_ports ::= ( port { , port } )
list_of_port_declarations ::= ( port_declaration { ,
port_declaration } )
| ( ) port ::= [ port_expression ]
| . port_identifier ( [ port_expression ] )
port_expression ::= port_reference | { port_reference {
, port_reference } }
port_reference ::= port_identifier [ [
constant_range_expression ] ]
port_declaration ::= {attribute_instance} inout_declaration
| {attribute_instance} input_declaration
| {attribute_instance} output_declaration
后面的module item才是组成module实现的主体,我们先看看包括些什么内容,后面章节中会进行详细介绍:
/*A.1.4*/
module_item ::= port_declaration ; |
non_port_module_item
module_or_generate_item ::=
{ attribute_instance } module_or_generate_item_declaration
| { attribute_instance } local_parameter_declaration ;
| { attribute_instance } parameter_override
| { attribute_instance } continuous_assign
| { attribute_instance } gate_instantiation
|{ attribute_instance } udp_instantiation
|{ attribute_instance } module_instantiation
| { attribute_instance }initial_construct
| { attribute_instance } always_construct
| { attribute_instance } loop_generate_construct
| { attribute_instance } conditional_generate_construct
non_port_module_item ::= module_or_generate_item
| generate_region
| specify_block
| { attribute_instance } parameter_declaration ;
所有的这些语法结构最终目标都是用合适的方法描述电路单元,以及单元之间的连接。我们会在后面的描述中做详细介绍,并特别关注每种语法结构能否编译成实际的电路,如果能够编译成实际的电路,讨论如何编译成实际的电路。
verilog语言中一个模块中可以使用另外一个模块的实例,因此verilog 中的程序结构实际上是一个树状的层次结构。这点与c++中的类有点像,一个类中可以声明另一个类的实例作为成员变量,形成一个类的层次结构。
声明模块实例的具体办法是,在前面的module_item中,使用module_instantiation类型的item。具体的格式是先是一个模块名称,然后是可选的实例化参数表,实例名称,以及端口连接表。子模块的输入输出可以与模块中声明的线网或寄存器连接在一起,如果子模块带参数,可以为实例指定参数。具体的语法如下:
/* A.4.1 Module instantiation*/
module_instantiation ::= module_identifier [ parameter_value_assignment ]
module_instance { ,module_instance } ;
parameter_value_assignment ::= # ( list_of_parameter_assignments )
list_of_parameter_assignments ::= ordered_parameter_assignment
{ , ordered_parameter_assignment }
| named_parameter_assignment { , named_parameter_assignment }
ordered_parameter_assignment ::= expression
named_parameter_assignment ::= . parameter_identifier ( [ mintypmax_expression ] )
module_instance ::= name_of_module_instance ( [ list_of_port_connections ] )
name_of_module_instance ::= module_instance_identifier [ range ]
list_of_port_connections ::= ordered_port_connection { , ordered_port_connection }
| named_port_connection { , named_port_connection }
ordered_port_connection ::= { attribute_instance } [ expression ]
named_port_connection ::= { attribute_instance } . port_identifier ( [ expression ] )
比如,我们下面的代码在main模块中声明10个counter模块的实例,对应10个计数器,十个计数器是级联在一起的,对时钟信号构成10个十进制的计数器。请仔细看代码中的注释,会结合代码给出较为详细的解释。为了照顾手机上的阅读体验,这里尝试手工进行了宽度限制,不过这样做会影响电脑上的阅读体验。
module main(wClk, nwReset,
wWrite, bWriteAddr, bWiteData, bWriteMask,
wRead, bReadAddr, bReadData);
input wClk, nwReset;
output wWrite;
output [31:0] bWriteAddr;
output [31:0] bWriteData;
output [3:0] bWriteMask;
output wRead;
output [31:0] bReadAddr;
input [31:0] bReadData;
/*声明与读端口的名称一样的线网,可以将读端口连接
在线网上*/
wire [31:0] bReadAddr;
wire [31:0]bReadData;
wire wRead;
wire wButton0Pressed;
wire wButton1Pressed;
wire wButton2Pressed;
/*我们一直在读按键的状态*/
assign wRead = 1’b1;
assign bReadAddr = 32’hF000_0000;
/*将bReadData[0]…bReadData[2]换个有
物理意义的名字的线网,这么做纯粹是为了
增加代码的可读性,也是为了如果硬件上修
改了三个button的位置或者信号来源,整个
代码只要改这几行就够了,后面的计数器修改
代码不用修改,编译器会处理这种别名式的命
名和赋值,不会影响最终生成的电路 */
assign wButton0Pressed = bReadData[0];
assign wButton1Pressed = bReadData[1];
assign wButton2Pressed = bReadData[2];
/*声明十个线网,作为本级计数器是否计数
的输入,第0级根据按键状态生成,其他的
由前级的溢出输出生成*/
assign wCounterin0 = wCounterIt;
wire wCountin0, wCountin1, wCountin2,
wCountin3, wCountin4, wCountin5,
wCountin6, wCountin7, wCountin8,
wCountin9;
wire [3:0] bCount0, bCount1, bCount2, bCount3, bCount4,
bCount5, bCount6, bCount7, bCount8, bCount9;
/*实例化参数和端口按照模块定义时的顺序
匹配,此时必须保证顺序正确,而且不能缺
少中间的某个参数,可以缺少最后的几个,
有点象c++中的参数默认值*/
counter #(4,9,0) counter0(wClk, nwCounterReset,
wCounterin0, bCount0, wCounterin1);
counter #(4,9,0) counter1(wClk, nwCounterReset,
wCounterin1, bCount1, wCounterin2);
counter #(4,9,0) counter2(wClk, nwCounterReset,
wCounterin2, bCount2, wCounterin3);
counter #(4,9,0) counter3(wClk, nwCounterReset,
wCounterin3, bCount3, wCounterin4);
counter #(4,9,0) counter4(wClk, nwCounterReset,
wCounterin4, bCount4, wCounterin5);
counter #(4,9,0) counter5(wClk, nwCounterReset,
wCounterin5, bCount5, wCounterin6);
counter #(4,9,0) counter6(wClk, nwCounterReset,
wCounterin6, bCount6, wCounterin7);
counter #(4,9,0) counter7(wClk, nwCounterReset,
wCounterin7, bCount7, wCounterin8);
/*不给出参数表的,使用模型定义时指定的
默认参数,如果模型没有指定默认参数,这
里必须给出参数*/
counter counter8(wClk, nwCounterReset,
wCounterin8, bCount8, wCounterin9);
/* 实例counter9的声明演示了另外一种声
明方式,实例化参数可以给出名字, 这样可
以不按照顺序,并可以只设置其中几个,没
有设置的用默认参数。端口匹配也可以用名
字匹配,这样代码可读性要好,并且有些端
口可以不连接到线网中,比如counter9的溢
出信号,外部不用,就不必要连接到外面的
线网上了。如果是输入端口,建议都应该连
接一个线网,以确保模块中能够得到正确的
值 */
counter #(RESETVALUE=0, WIDTH=4) counter9(
.wClk(wClk), .nwReset(nwCounterReset),
.wCounteit(wCounterin9), .bCounter(bCount9),
.wConteroverflow());
/*
声明一个寄存器,用来指示是否对在时
钟上沿进行计数动作,这个寄存器的输出
连接到线网wCounterin0上,作为个位数
计数器的是否计数动作的输入信号,这里
也展示一种verilog语言中声明的特点,
就是声明与顺序无关,毕竟它们都是描述
电路的。电路么,有顺序码?
*/
reg wCounterIt;
/*
下面的寄存器来指示是否复位计数器值,
它是一个低电平有效的信号
*/
reg nwResetCount;
/*
下面的代码来生成nwResetCount,演
示所谓的阻塞赋值,详细的内容后面会
做介绍,这里演示一种电路生成的特别
情况,虽然声明为寄存器,但是用阻塞
赋值,实际编译后并不生成寄存器,而
是生成一个线网,这点个人感觉是verilog
语言的一个败笔,明明声明了reg,结
果电路上是一个线网,好别扭的感觉啊
*/
always @* begin
if (~nwReset) begin
nwResetCount = 1’b0;
end else begin
if (wButton0Pressed)
nwResetCount = 1’b0;
else
nwResetCount = 1’b1;
end
end
/*下面的代码来生成wCounterIt */
always @(posedge wClk) begin
/* 计数器一开始是不动作的,在外
部按第0个键时对计数器的值进行清
零,按第1个键时停止计数,按第2
个键开始计数,开始计数时计数值
从当前值开始(如果多个键同时按
下,则以序号小的为准)
*/
if (~nwReset) begin
wCounterIt <= 1’b0;
end else if (wButton0Pressed==1’b0) begin
if (wButton1Pressed) begin
wCounterIt <= 1’b0;
end else if (wButton2Pressed) begin
wCounterIt <= 1’b1;
end
end
end
endmodule
这段代码还未经运行检验,不知道对不对,我们后面模拟器做出来之后可以来运行一下试试看。有关表达式,赋值,always块等内容,我们后面的章节中会更加详细地进行介绍,这里接着囫囵吞枣好了。
现在可以来想想我们如何做个verilog的模拟器了。最基本的问题就是,我们如何把编译出来的目标代码,就是所谓的电路用软件模拟运行起来。我们前面说过,所谓电路就是由一些基本的功能单元和一些线缆组成的。基本单元完成数字电路的逻辑功能和存储状态的功能,线缆则将它们连接在一起。一个基本单元有对外的端口,可能是输出端口或者是输入端口,或者二者都是。
我们特别规定一个基本单元的一个端口只能连接到一根电缆上,当然也可以什么都不接,就悬在空中。这个要求应该不算过分。如果有某位工程师硬要拿个烙铁在某个芯片的管脚上飞几根线出来,来证明基本单元的一个端口也能接几根电缆,那也别忘记了前面我们也说过,芯片的管脚并不对应着基本单元内部的端口,而只是从端口引出的一段线缆而已。所以你飞的线还是接到了线缆上,呵呵,抬杠谁不会啊。一根线缆则可以接到多个基本单元的端口上,甚至可以接到多根其他线缆上,当然也可以不连接任何东西。
所谓的电路运行,就是基本单元和线缆的运行。对基本单元,就是每个输入口去得到连接的电缆上的信号(如果没有连接就是高阻态?),然后内部进行计算后生成更新内部状态和输出输出端口上的值。所谓得到电缆上的信号就是对电缆得到连接的所有单元的输出端口的值,以及连接的电缆的值,对这些值做个线网融合得到的值,如果一根电缆没有连接任何一个输出端口,它就返回一个高阻态。所谓线网融合,就是如果电缆得到的连接在其上的所有基本单元和输出端口和电缆上得到的值都是高阻,那本身也是高阻态,如果有而且只有一个不是高阻态,那就取这个值。这里我们不允许出现一根电缆上连接的端口和电缆有两个或以上不在高阻态,此时就返回一个错误值,对应到电路上可能就是一个多驱动源电缆,如果驱动源输出电平不一样,结果是非法的电平。当然可以设计特别的线网类型和线网参数解决这种冲突,但是软件工程师就别介入这种高冷的玩法了。
同时,我们只关心每个时钟周期的运行,并假定每个时钟周期内所有的组合电路都能够输出稳定的数据。这样,其实上面的动作就可以每个时钟周期做每个基本单元和电缆都做一次。当然CPU实现时,我们不可能真的为每个基本单元和电缆都生成一个线程来运行,即使生成了也无法保证各个单元之间潜在的同步关系(无论如何也无法软件模拟到电路的实际延时啊)。这样都做一次肯定有个哪个先做哪个后做的顺序关系,这个顺序可能影响运行的结果,引入了与电路本身无关的影响。
数字电路作为一个有向图
这个问题的根源来自于将电路看作是一个有向图时,可能构成圈。如果不允许圈的存在,那很多功能比如有限状态机就不可能存在,计算机也就不存在了(计算机理论上就是图灵机嘛)。有圈的存在,就不可能要求每个单元和线缆都即时算出它的值,此时就会出现个循环的递归调用,计算不能终止,无法得到结果。我们可以要求纯的组合电路中不存在圈,因此组合电路单元和线缆的值都是可以即时算出来的。根据定义,它们的输出值时由输入值确定的。但是带寄存器时是允许圈的,寄存器的输入要跨过时钟沿之后才能够更新到输出,这样相当于逻辑上打断了计算的循环,寄存器的输出是内部存储的值,并不是当前的输入值。也就是说寄存器中逻辑上存在两个值,一个是输出值,一个是输入值,在跨过时钟沿时,输出值更新为输入值,输入值则根据前面的电缆的值重新计算。如果我们在每个时钟上沿计算输入值,然后更新到输出值,这样做会导致前面说的顺序问题,寄存器的输入值依赖连接到上面的电缆,电缆的值依赖与连接到电缆上的其他电缆或者基本单元的输出端口,这么下去,最终还是依赖于若干寄存器的输出。这样的话我们先更新某个寄存器的输出值,会影响其他寄存器的输入值的计算,于是整个电路的值就跟我们更新的顺序相关了。为了解决这个问题,我们将一个周期内所有寄存器的动作分为两步,第一步每个寄存器计算自己的输入值,然后缓存起来,先不更新输出,这样每个寄存器计算输入时都是根据依赖的寄存器的上一周期的值算出来的。第二步,让每个寄存器将缓存的值更新到输出上,这样做,就可以保证整个电路的值与计算顺序无关了。
注意,再强调一次,前面的做法隐含一个要求,即是纯粹的组合电路不能构成圈,有圈的组合电路无法确保在一个时钟周期内输出稳定的值(双稳态电路似乎是一个反例),编译器必须识别出这种情况并报错,这种描述归结到不可综合(编译)的描述。
另一方面,我们必须确保综合后的电路没有规定运行顺序,因此任何假定运行时有某种顺序的描述都是不能综合成电路的,比如
#5 a=0; //延时5个时间单位后将a设置为0
#10 b=1; //延时10个时间单位后将b设置为1
wait x;//等待信号x
@(posedge c) a=0; //在信号c的上沿设置a为0
@(negdge c) a=1; //在信号c的下沿设置a为1
这段verilog代码是合法的,描述了按照顺序执行的五个动作,但是这样的描述不能成为可综合的RTL描述,只能是行为级描述。因此实际上无法编译成实际可以运行的电路。verilog引入这样的描述,主要是支持仿真工具,支持在比较高的层次进行建模。不过个人感觉还不如设计两种语言来实现这个事情可能还好些,这样无差别地放在一个规范中,简直就是学习者的灾难啊。
不管如何,我们有了一个可以运行的方案,其要点如下:
我们来尝试给出模拟运行抽象的各个基本单元和电缆的LCOM接口,目前还在设计过程中,最终版本可能会有变化:
typedef unsigned int HDL4SEUINT32;
#include "guid.h"
DEFINE_GUID(IID_HDL4SEUINT, 0x57521e7a, 0xfdc5, 0x4682, 0x94, 0xc8, 0x8d, 0x2d, 0x2d, 0xa0, 0x5a, 0xc8);
typedef struct sIHDL4SEUnit {
OBJECT_INTERFACE
int (*Connect)(HOBJECT object, int index, HOBJECT from, int fromindex, int width);
int (*GetValue)(HOBJECT object, int index, int width, JHDLUINT32 *value);
}IHDL4SEUnit;
#define HDL4SEUNIT_VARDECLARE
#define HDL4SEUNIT_VARINIT(_objptr, _sid)
#define HDL4SEUNIT_FUNCDECLARE(_obj, _clsid, _localstruct) \
static int _obj##_hdl4se_unit_Connect(HOBJECT object, int index, HOBJECT from, int fromindex, int width); \
static int _obj##_hdl4se_unit_GetValue(HOBJECT object, int index, int width, JHDLUINT32 *value); \
static const IHDL4SEUnit _obj##_hdl4se_unit_interface = { \
INTERFACE_HEADER(_obj, IHDL4SEUnit, _localstruct) \
_obj##_hdl4se_unit_Connect, \
_obj##_hdl4se_unit_GetValue, \
};
DEFINE_GUID(IID_HDL4SESTATE, 0x136dbccd, 0x4da, 0x40fa, 0x94, 0x29, 0x18, 0x25, 0x68, 0x17, 0xe3, 0x63);
);
typedef struct sIHDL4SEState{
OBJECT_INTERFACE
int (*ClkTick)(HOBJECT object);
int (*Setup)(HOBJECT object);
}IHDL4SEState;
#define HDL4SESTATE_VARDECLARE
#define HDL4SESTATE_VARINIT(_objptr, _sid)
#define HDL4SESTATE_FUNCDECLARE(_obj, _clsid, _localstruct) \
static int _obj##_hdl4se_state_ClkTick(HOBJECT object); \
static int _obj##_hdl4se_state_Setup(HOBJECT object); \
static const IHDL4SEState _obj##_hdl4se_state_interface = { \
INTERFACE_HEADER(_obj, IHDL4SEState, _localstruct) \
_obj##_hdl4se_state_ClkTick, \
_obj##_hdl4se_state_Setup, \
};
接口IHDL4SEUnit中,Connect函数用来将当前单元(基本单元或者是电缆)的输入端口与指定的对象连接,其中object是对象本身,index是对象的输入端口序号,对电缆可以取零,内部记录一个表格就是了。电缆可以多次调用该函数连接多个对象,from是连接的目标对象,fromindex是目标对象的目标端口,对电缆而言设置为0。width是指连接的宽度,不一定要全部连接上,连接指定的位数即可。
GetValue是对对象输出端口进行求值,object是调用的对象本身,index是输出端口编号,width是位宽,value是返回值,用若干干个32位表示。
接口IHDL4SEState中
ClkTick函数来通知每个单元执行第一步,取得输入值并计算的到输出,缓存到对象中。
Setup函数来通知每个单元执行第二步,建立输出值,将第一步缓存的值更新到输出中。
一个模块编译之后,我们得到输入输出端口的信息(序号,名称,方向,位宽),参数表(名称,位宽,类型),内部线网(名称,宽度),子模块(名称,实例化参数,端口与线网的连接)。其他的诸如表达式赋值之类已经编译成模块和线网连接了(寄存器编译成线网或者寄存器模块,后面会详细描述)。这样,描述一个模块的要素是:名称,端口表,参数表,线网表,子模块实例表,连接部分。
对一个模块,我们实现一个模块对象,这个对象实现IDlist接口以便把模块对象放到一个链表中管理,实现一个IHDL4SEUint接口,来提供上下游的连接和求值,实现一个IHDL4SEState接口,来转发来自上层模型的IHDL4SEState相关的调用(这是因为模块中可能有寄存器实例,所以需要实现IHDL4SEState接口,当然,理论上纯组合逻辑实现的模块按说不需要实现该接口了。
编译生成的一位或多位寄存器基本单元用一个LCOM对象实现,这个对象实现IDList接口,IHDL4SEUnit接口和IHDL4SEState接口。
寄存器之外的基本单元按照类型各用LCOM实现对应的对象,这些对象实现IDList和和IHDL4SEUnit接口。
电缆可以抽象为一个不限输入端口个数,只有一个输出端口的模型,内部计算是按组合逻辑实现的,这样电缆对象用LCOM实现时,实现IDList接口和IHDL4SEUnit接口即可。
模拟器可以设计为一个软的FPGA,它实现提供一个能够与顶层模块连接的模块接口,加载顶层模块时其实就是把这个模块与顶层模块连接在一起。
顶层模块的接口可以设计得通用一点,提供一个类似于读写总线的端口集合,来访问模拟器提供的支持,比如可以由模拟器来提供存储器,BOOT ROM,控制台输入输出(tty),中断信号,显示帧存,软键盘等,这些设备挂接到模拟器上,可以通过顶层模块的输出输入端口来访问。
这个软的FPGA提供一些基本单元,前面讲过比如一位到多位的算术和逻辑运算,线网,寄存器,集线器和分线器等等,实现的方式可以在内部维护一个内部模块表,编译器把表达式与赋值编译成基本单元以及之间的连接,也可以在verilog中直接调用基本单元。
基本单元可以由第三方提供,比如浮点计算单元,甚至内嵌CPU单元,注册到模拟平台上就可以由编译器连接器连接在一起使用。
这个软的FPGA模拟对象自然也实现IHDL4SEUnit接口和IHDL4SEState接口,它本身也参与模拟。它还需要实现其他接口,来挂入第三方实现的对象,比如数码管,按钮等对象。
我们一方面可以通过一些模拟器的输出来判断输出是否正确,另一方面还需要对内部各种单元的运行过程进行监控,这样上面的每个对象还需要实现一个监控接口,能够在每个周期结束后由运行结果收集对象将感兴趣的模块中的某个信号的值读出来,记录下来供分析,在FPGA开发工具和ASIC开发工具中称为仿真波形文件。其实就是把感兴趣的一组信号在每个周期中的取值按照时钟周期画在一幅图上,用来分析verilog应用运行是否正确,这是verilog应用调试的重要手段。当然,我们实现的模拟器只精确到周期一级,无法跟专业的FPGA开发工具或者ASIC开发工具能够进行时序仿真输出的波形文件相比。但是我们的基本单元颗粒度大,只关心到每个周期中的表现,实现时每一步每个单元都可以并发计算,可以用多核CPU加速,甚至用一个服务器集群进行加速,因此我们理论上可以对大型的RTL可综合设计进行逻辑级的快速仿真,仿真结果可以保证每个周期逻辑上的正确性,这样基本上可以作为前端仿真了。
仿真波形文件示例
正在考虑到什么地方建立一个开源的项目,把一些结果传上去。目前完成了verilog的预处理器,词法分析,语法分析工作量比较大,需要时间完成。模拟器正在设计框架,一边做这个组学习材料,一边设计编译器和模拟器吧。CSDN好像不能开一个类似git似的项目,还是又有我没学会的技能?
请参考:
1.HDL4SE:软件工程师学习Verilog语言(二)
2.HDL4SE:软件工程师学习Verilog语言(一)
3.LCOM:轻量级组件对象模型
4.LCOM:带数据的接口
5.工具下载:在64位windows下的bison 3.7和flex 2.6.4
6.verilog-parser开源项目