首先,恭喜大家,一直跟到了这个教程的第五期。如之前所说,从本期开始,我们将正式开始使用Verilog给FPGA写代码。而要写的东西,这是第2-4期里面介绍过的组合逻辑,时序逻辑和状态机。而Verilog也将会是在这之后用于描述各种硬件的语言。

其实读Verilog代码本身并不复杂,有软件编程经验的人就不难理解代码的含义,毕竟其语法和C语言很接近;但是写Verilog代码,就是另外一回事了。Verilog里面有很多和C接近的概念和语句,比如赋值,比如if-else,比如for循环,等等。但是Verilog的目标结果,逻辑门,却又和C的目标结果,程序,太不一样了。他们确实某种程度上是接近的语言,但是语言里有接近的东西并不表示他们就能实现接近的功能。或者反过来,想要实现同样的功能,未必会使用接近的方法。所以说,学习Verilog最好的捷径就是不要走捷径,从数字电路开始学习,而并非一开始就学习代码,以至于被代码所迷惑。

Verilog程序模块

Verilog程序如同其它的编程语言程序一样,有它特定的源文件格式。Verilog的源代码后缀名为.v,每一个Verilog文件都是一个Verilog模块,各位可以类比为编程语言中的函数。基本格式如下:

module 模块名(模块输入输出信号);
    模块内容
endmodule

其中这个模块名通常需要和对应的文件名相同,同一个文件只定义一个模块,比如adder.v里就应该只定义一个叫adder的模块。这个要求和Java对类的要求很相似。

输入输出信号则是接近一个函数的返回值和参数,只不过在Verilog里并不把参数和返回值放到不同的地方定义,而是都写在一起。所有的参数或者返回值,最终都只是导线而已。而导线根据驱动信号的方向,可以有输入和输出区别。至于需要多少个输入多少个输出,那就取决于具体的程序了。

模块内容则是模块内部的逻辑,也许有代码块(always),也许只是一些简单的接线(assign)。不过别忘了,一切最后都会回归到硬件。

最后说说模块的实例化,或者说调用。如前面所说,模块类似于软件编程语言里面的函数,它也确实有对应的函数名,参数,返回值等等类似的概念。那么要使用这个“函数”,自然也就需要一种调用的方法。只不过,Verilog里的调用,并不是像编程语言一样在特定位置执行特定代码(毕竟本身就没有“执行代码”这种操作),而是新复制一份这个模块所表示的硬件,然后连接对应的导线。这一点在参数的定义时其实也就有所体现,定义的输入输出并非是变量,而是导线, 也就是说,传递的内容并不是数值,而是连接。一旦连接被确定,传输就是时时进行的(因为线被连接上了)。这个和编程语言里的调用非常不一样,所以务必进行区分。具体的例子我们之后就会提到。

Verilog模块参数

模块参数的格式其实很固定,就是 方向 类型 宽度 名称。方向可以是输入(input)或者输出(output),注意方向是对于模块而言的,从外界进入到模块是input,而从模块输出到外部则是output。类型的话在Verilog里基础类型只有两种,一种是wire一种是reg。需要说明的是,不要望文生义,Verilog里的wire是指导线没错,但是reg并不说明这是一个寄存器。具体的区别会在后面单独解释。如果没有标明类型,通常会默认为wire,但是这个默认值是可以修改的。宽度表示这个信号的位宽。在其它的各种编程语言中,数据类型通常都会指定这个变量的位宽,比如说C语言中char就是8位,int通常是32位,double通常是64位,等等。而Verilog里类型并不能顺便指定这个变量的位宽,位宽需要单独指定。如果没有指定,通常而言就默认为1位。这其实是个Verilog里非常常见的错误,多位的信号忘了指定宽度,默认成了1位。名称也就是名称,和其它语言中的变量名称是一样的概念。接下来举几个例子。

input wire a, // 定义一个1位的wire型输入信号,名为a
output wire b, // 定义一个1位的wire型输出信号,名为b
output reg c, // 定义一个1位的reg型输出信号,名为c
input wire [3:0] d, // 定义一个4位(3至0)的wire型输入信号,名为d
output wire [9:0] e, // 定义一个10位(9至0)的wire型输出信号,名为e
output reg [0:8] f, // 定义一个9位(0至8)的reg型输出信号,名为f

以上就是一些例子,具体的用途当然还是需要结合整个模块来理解。

Verilog内部信号定义

内部信号定义就类似于软件编程语言中的变量。只不过这里的变量,不一定具有存储数值的能力,有可能只是作为导线起到接线的作用。内部信号定义的语法格式几乎是和模块参数的是一样的,只是去掉了 方向 定义。毕竟不需要和外界通信,自然也就没有输入输出的说法。以下是几个例子。

wire a; // 定义一个1位的wire型信号,名为a
reg [31:0] b; // 定义一个32位的reg型信号,名为b

Verilog的表达式和运算符

Verilog作为一个类C语言,其表达式、运算符和C语言都非常接近。但是需要指出的是,在Verilog里进行数字运算的时候,综合器会根据操作产生需要的运算器(加法器、乘法器等等)有些时候默认的行为可能并不合适,可能需要特别注意。以下是几个常见的运算符,完整的这里就不列出了。

运算符   运算  
---------------------------     
+        加法       
-        2's compliment 减法       
<<       左移       
~        按位取反       
&        按位与       
^        按位异或       
? :      三目选择

Verilog数值表示

Verilog的数值表示相比其它的编程语言要复杂一些。原因很简单,前面在信号定义里提到了,一个信号的宽度可以是任意数值,而不是根据类型固定的几个数值。为此,数值的表示方式中也得能够体现这个特点。Verilog中数值的格式为:宽度 ' 进制 数字。宽度就是位宽,一个数字,和之前信号定义里的位宽对应。进制可以为二进制(b)十进制(d)和十六进制(h),最后的数字就是要表示的数字。比如说要表示一个8位的十进制数255,就写作 8'd255 。同样的数字用二进制表示为 8'b11111111,十六进制表示为 8'hFF。这三种写法是完全等效的,只是看具体应用时哪种方便了。当然,宽度需要大于足够表示这个数字的最低宽度,比如还是255,255最少需要8位来表示,所以并不能写作4'd255,但是可以写20'd255,因为20位足够大了。以下是一些例子。

5'd16 // 5位十进制数16
7'h23 // 7位十六进制数0x23
2'b10 // 2位二进制数0b10

Verilog程序语句 - assign

Verilog的程序语句基本可以分为两大类,一类是固定赋值语句(assign),另外一类是代码块(always)中使用的语句。之前说的if-else,for一类的语句都是配合语句块使用的。

先来讲assign。assign的作用很简单,就是接线。比如说有两条线a和b,要把他们连接起来,通常来说语句如下:

assign a = b; 或者 assign b = a;

这两种写法有什么区别吗?如果只是接线,那么接上就是接上了,a和b接起来与b和a接起来真的有区别吗?答案是,有。虽然只是导线,但是导线最终还是会连接到输入输出端口或者逻辑门上,这样导线也就有了驱动方和被驱动方的区别。比如a如果连接到了一个输入接口上(从外部进入到FPGA),而b连接到了一个输出接口上(从FPGA输出到外部),这样合理的写法就是b=a,信号会从外部进入FPGA连接到a上,随后从a连接到b,再输出到FPGA外部。当编写代码的时候,应该清楚,虽然这是连线,但是右边永远应该是驱动方,左边应该是被驱动方,就像编程语言里面数据从右向左传输一样。

值得注意的是,assign能够做的不单单只是简单的连线。assign已经足以实现很多组合逻辑了。

举个例子,我们用之前在第三期里面见过的原理图和代码。来自第三期的一个半加器的原理图:

1551047229822-5-1.png

提示一下,这个电路的作用是,输入两个1位的二进制数,计算它们的和,输出2位的结果。我们通过观察输入数字和输出数字的关系的方法,发现这个电路其实只需要两个逻辑门就可以实现需要的功能,于是也就画出了这样的电路图。其中U1和U3就是两个逻辑门元件。同时我们也讲了这个电路可以很简单的用FPGA来实现:把两个按键的输入和两个LED的输出都连接上FPGA(通常开发板上都有),然后在FPGA内完成所需的逻辑。我们现在重新再来看一次这个代码。

module lesson3(
    input  wire a,
    input  wire b,
    output wire c,
    output wire d);
 
    assign c = a & b;
    assign d = a ^ b;

endmodule

以上就是当时给出的代码。现在已经可以看出来了,lesson3是模块名,括号里的定义都是对于输入输出信号的定义,而模块主体是两句assign语句。

首先先来看参数部分。参数部分定义了四个信号,分别叫a b c d,两个输入两个输出,都没有定义宽度,所以默认为1位,类型为wire。这个代码就是实现上面的那个电路,而电路中有两个按键,是从外部输入进模块的,所以定义了两个输入信号。同理为了输出两个LED需要的信号,定义了两个输出信号。

再来看模块主体。模块主体只有两条assign,内容也非常简明,一个是把a和b做与运算后连接上c,另外一个是把a和b做异或运算后连接上d。这也就是对之前电路图的表示了。

注意到这里出现了一个等号,是不是说明这个操作就类似于软件编程语言中的赋值呢?并不是。赋值所表示的是,计算出右边的结果,复制保存到左边的变量当中。而Verilog的assign,如之前所说,只是连接的作用。比如assign c = a & b;就是表示,产生一个能计算a & b的电路(一个与门),并把结果和c连接起来。从效果来说的话,和赋值的区别就是,赋值是一瞬间发生的事情,获取a和b的值,计算a&b,存入c。赋值完成后,c和a、b就没有别的关系了,即使a和b的值在后面发生了变化,c也会保留先前的值。而assign这里,c只是一条导线,c没有记忆,c并不能保留a & b的值。如果a、b发生变化,c也会随即发生变化。

为此,作为总结的话,assign语句可以用于描述组合逻辑,assign的左值就是组合逻辑的输出,而右侧的表达式则是组合逻辑的逻辑表达式。

Verilog程序语句 - always

讲完了上面的assign,我们已经可以用Verilog来描述组合逻辑了。但是还不能描述时序逻辑。时序逻辑需要使用一种称为always语句块的东西。这里选取第4期的第一个例子,按下按键让灯改变状态的例子:

1551047231369-5-2.png

当时也给出了对应的Verilog代码:

module light(
    input key,
    output reg light);
 
    always@(posedge key)
    begin
        light <= ~light;
    end
 
endmodule

还是一样,一个模块,模块名称为light,有两个信号,一个是输入,叫做key,没有指定类型(默认为wire);另外一个是输出,叫做light,类型为reg。显然key就是按键输入,而light就是灯的输出。注意到这里light被定义成了reg类型,这里确实表示灯应该连接到一个寄存器(触发器)上。

模块主体中只有一个always语句块,always语句块的格式如下:

always@(触发条件)
begin
    语句1
    语句2

end

always语句块的含义就是,当触发条件满足的时候,执行语句。begin和end的作用就类似于C语言中的{ },只是把多个语句并在一起而已。

这个例子中的触发条件只有一个,就是posedge key,表示key输入信号的上升沿(posedge)触发。如果需要下降沿则是negedge。而语句只有一条,light <= ~light。这个确实是一个赋值语句,表示计算右边的值,存入左边的寄存器。当然实际上发生的事情就是,产生一个能够计算右边结果的电路(非门),接入保存左边信号(reg light)的触发器的输入端,并且把always的触发条件接入触发器的时钟输入。这样,当触发条件满足的时候,其实也就是给触发器产生了时钟,触发器的输入端,也就是赋值语句右边的结果,会被存入寄存器。刚刚的解释请对照着原理图再理解一次。以上就是“触发条件满足 执行语句”这个听起来很“软件”的操作的硬件实现。

然而上面的这个解释,其实就是暗示了一个限制:因为时钟永远是几乎同时到达各个触发器,所以各个赋值只能是同时发生的。如果你写以下的代码:

a <= b;
c <= a;

会发生什么呢?如果是软件编程语言中的赋值,不难看出b的值会被存入c。而这里,因为赋值是同时发生的,所以a原先的值会被存入c,而b的值会被存入a。所以这个赋值操作(<=)的名字也就是非阻塞赋值,表示上一条赋值语句的执行并不会阻塞下一条赋值语句的执行,也就是说所有赋值同时发生。这也就是符合硬件实际情况的设计。

以上可以看出,always语句块可以用来实现时序逻辑的触发器部分。不过,always语句块除了可以实现时序逻辑,还能实现组合逻辑。组合逻辑不需要等待任何时钟就会发生,或者说一旦输入变化,输出就会发生变化。为此,Verilog中,用always语句块来实现组合逻辑的写法就是

always @(*)
begin
    语句1
    语句2

end

星号(*)就表示了任何输入发生变化都会触发,也就是像组合逻辑的表现一样。如果用always来重写前面的那个组合逻辑的例子,就会是这样:

always @(*)
begin
    c = a & b;
    d = a ^ b;
end

注意到这里的赋值用的不再是之前的 <= 了,而是变成了 = 。这个赋值在Verilog里称为阻塞式赋值。也就是和传统编程语言一样的赋值方式,前面的先执行,后面的晚执行。所以你可以写出类似这样的代码:

always @(*)
begin
    c = 1'b0;
    if ((a == 1'b1) || (b == 1'b0))
      c = 1'b1;
end

这段代码完全可以按照传统编程语言的思路来理解,首先将c赋值为0,随后,如果a为1,或者b为0,那么就将c赋值为1,否则就保持不变。但是事情真的是这样的吗?并不是。不如来考虑下,如果代码真的是这么执行的,会发生什么事情。当模块中有任何信号发生变化的时候,c都会先赋值为0,随后按照ab的输入判断是不是要赋值为1。这就是这个代码所表示的意思。那么,假设现在a为1,b为0,c应该结果是1吧?如果b从0变成了1,会发生什么呢?c还是会先变成0,然后发现条件成立,再变成1。

对吗?并不对。c会保持为1,条件仍然成立,c会一直保持为1。阻塞式赋值只是Verilog中的一个语法糖。真实的硬件并不能够实现赋值的先后顺序,设计一个阻塞式赋值的语法只是为了方便描述逻辑。如刚刚那个always块,其实就等效于如下的assign语句:

assign c = a | ~b;

对应的硬件无非就是一个非门加上一个或门而已。并没有什么特殊的“赋值电路”,这个电路就可以实现,在任何输入发生变化的时候产生相应的输出。而至于Verilog中关于“阻塞式赋值”的设定,只是为了方便描述逻辑而存在的。有了阻塞式赋值的语法,我们在写代码的时候就可以在一个always语句块多次对同一个变量赋值,只有最后一次结果才会被保留。通常的做法就是先给信号设定一个初始值,随后在使用不同的语句按照情况赋新的值。

做一个简单的总结的话,always语句有两种写法。第一种是用于描述时序逻辑的always语句,形式为always@(posedge 时钟信号),代码块中只使用非阻塞式赋值(<=)。而第二种,则是用于描述组合逻辑的always语句,形式为always@(*),代码块中只使用阻塞式赋值(=)。前面讲了那么多原理上的东西,是为了让大家明白Verilog中代码和实际硬件的对应关系,这样也就能帮助大家理解为什么在Verilog中不能像C一样写代码。

练习

前面的这些就已经介绍完了Verilog中最重要的一些部分。不难发现,Verilog缺少一种真正的能让代码按顺序执行的方式(时序逻辑中所有代码同时执行;组合逻辑中所有代码可以认为是时刻在执行)。这种功能并不是Verilog故意缺失,而是因为这种代码并没有真实的硬件对应。但是这种功能经常又是必要的,怎么办呢?解决方法就是使用我们在第五期里面讲过的状态机。

这里呢,就给大家留一个作业,参考今天给出的两个时序逻辑和组合逻辑的例子,把他们结合起来,实现在第五期里面讲过的状态机。下一期将会公布答案,并且复习其在FPGA上的实现流程。下期同样也会介绍仿真软件的使用。那么我们下次见。