跳转至

SystemVerilog 语言

本章介绍 SystemVerilog 语言,由于篇幅的关系这不是一个全面的讨论,而是仅展示最核心部分的内容,特别是需要注意语言是可以综合的部分。 SystemVerilog 是诸多硬件描述语言中的一种,与软件编程语言中的 C 语言接近,在学习上不会存在障碍。 在实践上可以在初步掌握本章所介绍的语言组成部分之后就进行实际的硬件设计,语言的其余部分等待有需要的时候再学习。在实践中学习是一个更好的学习硬件语言的方法。

简介

本章是比较详细的 SystemVerilog 教程,同学第一次学习的时候,可以简略地过一遍,有个大概的印象,然后边编程边学习,中途如果遇到了不会写的语法,再回来看本章,或者查阅语言手册。

本章的重点是 SystemVerilog 支持哪些语法,可以部分作为手册使用。学习完本章后,还建议阅读 通过例子学硬件描述语言,讲述是如何把硬件设计的想法用 SystemVerilog 实现出来,并且哪些 SystemVerilog 写法是正确的,哪些写法是错误的。注意有很多语法正确的代码在实际当中并不能工作,或者存在一定的隐患,这也就是为何需要先熟悉一些编程惯例的原因。

概述

硬件描述语言是用来描述硬件各个组成模块以及它们之间关系的语言。当前广泛使用的硬件描述语言包括 VHDL 以及 Verilog,还有我们接下来要学习的 SystemVerilog。相对来说,Verilog 和 SystemVerilog 在实际的生产实践中使用得更多。在本实验教材中使用 SystemVerilog 语言。由于 SystemVerilog 语言有很多语法借鉴了非常广泛使用的 C 编程语言,因此相对来说学习 SystemVerilog 硬件描述语言不存在障碍。当然,特别需要注意的是写硬件和写软件代码是完全不同的,在以下的学习过程中需要特别注意硬件代码和软件代码的区别。这里有一个总体的原则,即软件代码体现了指令顺序流执行的思想,这是和冯诺依曼计算机的结构直接相关的;而硬件的特性是信号在各条信号线上是并行传播的,硬件描述语言描述的是各个模块之间的连接关系。

在没有硬件描述语言之前,设计硬件的时候往往使用绘制电路图的方式,即绘制所需要的各电子元件和连接关系。但是随着硬件规模的增大,绘制整个硬件的完整电路图并不现实,因此在现在的硬件设计中,通常把硬件分成很多个模块,并且仅绘制模块间的连接关系,模块变成一个具有确定功能的“黑盒子”。这也给了我们进行硬件设计的思想,即可以采取以自顶向下的方法先设计高层的模块,随后再细化到每一个更细致模块的实现中去,最终细化到基本的门电路。同学们实现自己处理器的过程中,建议先使用图形化的方式来构建处理器的各个模块以及它们之间的连接关系,之后再通过硬件描述语言进行描述,这样做会起到降低难度,易于理解的效果,并且容易评估自己所实现的进度,预估所需要的时间。

图形化的描述方法由于具有较差的可扩展性,已经不太可能跟得上所需要设计硬件的复杂性。在这种情况下,硬件描述语言应运而生,本质上是与电路的图形化描述等价的描述,在当前的硬件设计领域占到了主导的地位。相对于图形化的电路设计方法,硬件描述语言具有以下的特点与优势:

  1. 硬件描述语言的抽象程度高,方便进行精确的描述。
  2. 可以应对不同层次的设计,方便进行自底向上和自顶向下进行硬件设计。
  3. 具有易于修改的特点,这一点尤为对图形描述有优势。
  4. 可以适合更大规模硬件的设计,模块化的方法使得各个部分可以协同工作。
  5. 匹配硬件描述语言的设计工具以及文档现在都非常丰富。
  6. 硬件描述语言本身就是对于硬件最好的说明文档。

下面进入 SystemVerilog 硬件描述语言的基本语法,并通过例子的方式来说明各个语法单元的作用以及基本的硬件设计方法。

SystemVerilog 的模块程序基本结构

在学习语言的时候,最方便的就是从例子中学习。在描述语言语法结构之前,可以通过一个具体的例子来看一下 SystemVerilog 程序的基本组成。下面是一个最简单的与门电路,用以将输入的信号做与操作,然后输出。

一个 2 输入与门的模块描述:

module and2x (
    input wire a,
    input wire b,
    output wire r);
  assign r = a & b;
endmodule

这个与门电路的程序非常地简明,但是已经展现了 SystemVerilog 进行电路描述的基本要素。这里的程序最终在电路的物理实现上对应了一个组合逻辑中的与门。下面看一下这个程序所能够体现出来的 SystemVerilog 程序的语言元素。

首先,SystemVerilog 的程序是由若干个模块构造而成的,这也是硬件开发人员的基本思考单元。这样,开发人员在构建硬件的时候可以一个模块一个模块的进行,并可以通过多层次的连线,将小的模块连接成大的模块,最终来构造出目标的硬件。每一个模块的内容都包含在 moduleendmodule 之间。

硬件模块编写的时候需要定义输入和输出信号,这一点对于硬件模块来说尤为重要,因为模块描述基本上就是定义输出是如何随着输入的变化而变化的。在上述的模块定义中,输入为端口 a 和 b,输出为端口 r。模块即定义了 r 如何随着 a 和 b 的变化而变化。对于每一个输入输出来说,也同时需要定义其类型,这里的所有类型都是 wire 信号类型,默认表示 1 位宽的数据,只能是 0 或者 1(或者是高阻态,后面会涉及)。在类型之后,看到模块的主体部分即功能定义。这里的输出端口 r 的值为 a 和 b 的与操作。

在模块内部,每一个语句都需要以分号 ; 作为结尾。在注释上与 C 和 C++ 是一样的,使用 /* */ 进行单行或者多行的注释,或者使用 // 进行单行的注释。

总结来看,以下几个元素是在 SystemVerilog 语言中最基本的组成部分。

  1. 模块的声明和结束,模块以 module 开始,以 endmodule 结束,格式如下:

    module 模块名称 (
        端口 1,
        端口 2,
        端口 3
        ......);
    
    endmodule
    

    这里的模块名称可以任取为符合要求的标识符。

  2. 端口的定义:

    对于模块的每一个端口都需要定义出其是输入端口(input),还是输出端口(output),还是双向端口(inout)。不仅要定义端口的输入输出类型,还需要定义端口的信号类型,常见的有 wirereg。输入和双向端口不能被定义为寄存器(reg,见下)类型。

  3. 信号类型的声明:

    在本实验教程中,信号类型声明只使用了两种,即寄存器类型 reg 以及连线类型 wire ,在绝大多数的情况下如果无法确认使用reg 还是使用 wire ,都可以使用 logic 来代替。在 SystemVerilog 语言中定义的各种信号的类型实际上都对应着具体电路中的各种电路连接以及电路实体。如果没有任何声明的话,默认类型就是连线 wire 类型。为了书写方便,SystemVerilog 通常将定义和类型说明放在一起。并且可以直接放置在模块声明的端口列表中。例如,上述例子的类型声明采用了使用下面的形式:

    module and2x (
        input wire a,
        input wire b,
        output wire r);
    

    因为 wire 是默认的,这里的所有的 wire 都可以省略。

  4. 模块的功能定义:

    这部分是编写模块最为主要的部分,这将定义当前模块所能够完成的功能。可以有多种方法来完成这个任务。上述的例子给出了一个最为基本的方法,即通过 assign 语句来进行持续的赋值。这个语句通常被用来进行组合逻辑的设计,在描述上也很明显,即持续对输入进行响应。

    当然,在实际的工作过程中,上述的逻辑还需要考虑一定的延迟,所有的电路都会有一定的延迟。延迟在编写程序的时候体现不出来,但是在综合出电路的时候会有很大的影响,相同的功能低延迟的电路会获得更高的性能。如何编写一个更加低延迟而且高效的电路需要长期的经验。

语言元素

这一节介绍 SystemVerilog 的一些语言元素。这里不是对整个 SystemVerilog 语言进行介绍,只对其中比较重要的部分展开讨论,这已经足够进行一些比较大型的硬件设计。可以先学习本章的内容之后再做进一步深入的学习。

SystemVerilog 的语言元素

在 SystemVerilog 的语言元素中包括了空白字符,注释,操作符,数字,字符串,标识符以及关键字等几个部分。其中比较重要的是操作符,各种数字,标识符,以及关键字等。通过这几个语言元素描述了最后"综合"出来的电路的功能。

硬件描述里面的"综合"这个概念非常重要。综合类比于将高级语言编译为机器语言,并最终可以在物理硬件上执行。类似的,"综合"的含义就是将硬件描述语言的功能翻译为能够直接实现的电路,可以放到 FPGA 或者直接转化为硬件电路,用以执行所描述的功能。这些语言元素的大部分都是不言自明的,如果有 C 语言基础的话非常容易理解。

空白字符

与其它的编程语言类似,在 SystemVerilog 中也由一系列的语言元素组成。与 C 语言一样,各个语言元素之间是通过空白字符来隔开的。空白字符包括了空格,制表符 \t,回车,换行等。在进行编译的时候,空白字符被编译器自动用来切分出有功能的语言元素。由于 SystemVerilog 是自由风格的编程语言,空白字符对于程序的可读性是非常重要的。在编写程序的时候,可以通过合适的空白字符提高程序的可读性。

注释

SystemVerilog 的注释与 C++ 语言的完全一样,不再赘述。

标识符

SystemVerilog 开发者所能够使用的最基本的元素是标识符,用以表示信号等在实际综合过程中所需要定义的连线以及寄存器等。在 SystemVerilog 的标识符是由任意的英文字母、数字以及符号 $_(下划线)构成的。标识符的第一个字符必须是字母或者是下划线,不能是别的内容。另外一个值得注意的是在 SystemVerilog 中与 C 语言中一样,标识符是区分大小写的,大小写不同的标识符被认为是不同的。以下都是一些合法的标识符。

a
total
_delay
_d1_c2
D$
SOURCE

以下是一些非法的标识符举例,在实际开发中避免使用。

1total // 以数字开头
number# // 使用了非法的字符
$5 // 以$开头
关键字

当然,还有另外一类的合法标识符是程序员所不能使用的,这就是关键字。和其它的所有的编程语言一样,关键字是语言内部的保留字,用以完成程序的部分功能。保留字是不能作为变量的名字的,不能为开发者所使用。另外,在 SystemVerilog 中,所有的关键字都是小写的。在前面已经接触到了 SystemVerilog 中的一些关键字。在后面的学习过程中,会接触到更多的关键字。本书接触到的关键字不是 SystemVerilog 的所有的关键字,而是其中一部分所需要使用的。对于关键字的全集,读者可以查询语言本身的规范。

参数

在 C 语言中,一个良好的编程习惯是使用符号来代替常数硬编码。这一点在 SystemVerilog 中也是一样的。在 SystemVerilog 中可以使用 parameter 来定义一个符号常量。一个最为典型的应用就是来指定信号(向量)的宽度,即信号可以取几位。使用 parameter 的语法形式为:

parameter 参数名1=表达式1,参数名2=表达式2......;

例如:parameter WIDTH=16;

这样,可以使用 WIDTH 在程序中替代数值 16。将来希望程序扩展到 32 位宽的时候,直接修改 WIDTH 的取值即可,而不需要在每一个地方都将 16 修改为 32。这是一个良好的编程习惯,一方面便于修改,另外一个方面也增加了程序的可读性。

编译指导语句

SystemVerilog 中的编译指导语句与 C 语言中的编译指导语句(例如 #include)类似,指示 SystemVerilog 编译器的工作。编译指导语句都是不可综合的,会在编译的时候进行字符串等替换操作。一些编译指导语句与 C 语言对应的语言组成部分功能完全一样。

常用的编译指导语句包括 `define 宏定义语句, `include 文件包含语句,`ifdef`else, `elsif, `endif 条件编译语句。

`define 语句相当于 C 语言中的 #define,使用在一定程度上与前面的 parameter 类似。在编译的时候,`define 出来的宏名称被替换为后面的字符串。例如:

`define WIDTH 16
reg [`WIDTH-1:0] r;

这就与 reg[15:0] 相当。也可以注意到,这里在每次使用宏名称的时候,需要加上 ` 这样一个反向的单引号(美式键盘上 1 左边的那个键)。另外,在 `define这一行的行末不需要分号,这与实际功能语句不同。

`define 的宏替换功能与 C 语言中的宏替换功能一样强大,能够用来替换比较复杂的表达式。例如:

`define sum a+b

定义之后,可以使用:

assign res=sum;

来获得将两个 a 和 b 信号相加的效果,因为最终会被字符串替换,获得语句:

assign res=a+b;

`include 语句相当于 C 语言中的 #include 语句,用来包含其它的文件。例如 `include "adder.v",注意,这里同样也没有行末的分号。如果被包含的文件与源文件不在同一个目录下,则需要设置对应的相对路径,例如:

`include "../common/adder.v"

`ifdef, `else, `elsif, `endif 条件编译语句也相当于在 C 语言中的 #ifdef, #else, #elif, #endif 语句,用来设定哪一部分的源代码会最终编译。这里的条件判断则需要看一下是否有对应的宏名称已经被定义过。例如:

`define sum a+b
`ifdef sum
assign res=sum;
`else
assign res=a+b;
`endif

这样,就可以通过控制 sum 是否定义来选择对应的需要编译的源代码,不需要编译的源代码就被编译系统所忽略。如果需要嵌套更多的判断的话,可以使用 `elsif编译指导语句进行进一步判断。

SystemVerilog 中还有其它的编译指导语句,在某些特定的条件下会需要用到,一般也非常直观明了,查询语言的手册很快就能够明白。

SystemVerilog 中的数据

在数字逻辑电路设计,包括处理器的设计中,最为常用的数据显然是 0 和 1,分别代表了逻辑真和假或者电平的高和低。当然,在 SystemVerilog 中还提供了其它的数值类型。下面仅仅讨论一些必要的数据类型和取值,其它的一些数据类型和取值由于不常用将不再介绍。

常量,整数数值

在硬件开发的过程中,不能够改变的量被称为是常量(constants)。在 SystemVerilog 中有多种常量的形式,包括了整数,实数以及字符串。在处理器设计中,最为重要的常量形式为整数,下面仅仅对整数常量进行介绍。

整数的常量是按照一定的格式写出的,下面是格式规范:

+/- <位宽> ' <进制><数字>

通常也表示为:

+/- <size> ' <base><value>

这里,size 代表了二进制数的宽度,base 为进制的情况,而 value 则是用对应的进制所表达的数值。

与其它的语言一样,进制包括了二进制(b 或者 B),十进制(d 或者 D),八进制(o 或者 O)以及十六进制(h 或者 H)。

下面是一些有效的整数数值例子:

8'b01001010
16'H45EF
-8'D123
-16'o3333

上述是整数数值的规范编写方式。另外,在 ' 前面或者在进制与数字之间可以有空格,但是建议不要使用,统一到上面的风格即可。至于实数以及字符串,由于使用不多,并且字符串也仅用在仿真的时候,是不可综合的,在这里就不再赘述。

数据取值

有必要谈一下在 SystemVerilog 中的数据取值的问题。实际上,在进行硬件编码的时候,除了 0 和 1 的这样两个常用的信号取值之外,还有其它的一些逻辑状态可以作为信号的赋值。其中有两个比较重要的,即为 x(或者 X)和 z(或者 Z)。这两个取值不区分大小写,使用大写或者使用小写代表有同样的含义。

x 或者 X 的取值一般表明为不确定,或者未知的逻辑状态,用于不关心对应信号值的情况,该信号取任意值不影响整个逻辑电路的功能。

z 或者 Z 代表高阻态。高阻态典型的应用就是用于获得内存的输入(从内存中读数据到处理器内部)。在获得内存数据时,先将处理器引脚的状态置于 z 即高阻态,之后经过一定的时间延迟,就可以从对应的引脚处获得内存的输入值。

数据类型

基本数据类型包括了 wire 类型和 reg 类型。wire 类型代表了在硬件电路中的连线,其特征就是输出的值紧随着输入值的变化而变化。reg 数据类型会放到过程语句中(例如 always_ff),通过过程赋值语句进行赋值。reg 类型不一定必然会对应到硬件的寄存器,综合的时候会依据实际的情况使用连线(组合逻辑)或者寄存器(时序逻辑)来完成 reg 数据类型的功能。在 SystemVerilog 中,通常情况 reg 和 wire 类型都可以通过 logic 类型来统一表达。

数据类型还有向量和标量的区别。这一点非常简单,如果没有指定位宽,那么默认就是 1 位的位宽,即是一个标量。向量使用位宽来表达,使用中括号指定,形式为 [msb:lsb]。其中,msb 为最高位(most significant bit),lsb 是最低位(least significant bit)。

例如:

wire [7:0] data; // 这是一个8位的连线。
reg [31:0] res; // 32位的数据变量

如果使用向量的话,在 SystemVerilog 中有非常方便的向量访问方式,获取其中的一位或者几位。

l = data[7]; // 获取data的最高位
lob = res[7:0]; // 获取数据res中的最低8位,即最低一个字节

SystemVerilog 中的运算

运算的功能对于 SystemVerilog 来说非常重要,程序的功能描述大部分是通过运算来表达完成的。在数字逻辑的硬件电路设计中,最为基本的就是位运算。在基本的运算基础之上,可以实现其它更为丰富的运算功能,例如加法运算等。在 SystemVerilog 中也首先提供了各种位运算的功能,并且也提供了在此基础上的较为高层的运算功能。更为高级的运算就依赖于硬件设计人员的设计了。下面是一些常用的在 SystemVerilog 中的运算。

位运算符

位运算符是最基本的运算符,开发人员也很容易理解对应的物理逻辑门实现。与所有的编程语言一样,位运算符表达了两个操作数对应的位进行位运算的结果。SystemVerilog 中的位运算符包括以下的操作。(在下面的例子中,假设 a=8'b00001001b=8'b01010101。)

  • ~ 按位进行取反操作。~a=8'b11110110
  • & 按位进行与操作。a&b=8'b00000001
  • | 按位进行或操作。a|b=8'b01011101
  • ^ 按位进行异或操作。a^b=8'b01011100
  • ^~ 或者 ~^ 这两个运算符都是按位同或操作。a^~b=8'b10100011
  • >> 右移运算符。a>>2=8'b00000010
  • << 左移运算符。a<<2=8'b00100100
  • 缩位运算符:

    在位运算符中还有一类特殊的运算符需要注意一下,即缩位运算符,值得单独提出来作为一类描述。缩位运算符可以将一个向量按照一定的位运算"缩"成 1 位,因此被称为是缩位运算符。缩位运算符有下面的几个。

    • &

    • ~& 与非

    • |

    • ~\ 或非

    • ^ 异或

    • ^~, ~^ 同或

    由于缩位运算符能够将多个位缩成一位,其表达式即可以写为操作符后面跟着一个向量操作数。

    例如:reg [7:0] value; 如果 value = 7'b01010101。则:

    &value 结果为 0,|value 结果为 1,~^value 结果为 1。

下面列出所涉及的位运算符的真值表:

a b a&b a~&b a|b a~|b a^b a~^b
0 0 0 1 0 1 0 1
0 1 0 1 1 0 1 0
1 0 0 1 1 0 1 0
1 1 1 0 1 0 0 1
关系和逻辑运算符

与位运算符直接相关的操作就是关系和逻辑运算符。这些运算符可用于条件判断(例如使用在 if 语句中)。下面是常用的关系和逻辑运算符,这些运算符的含义与其它语言的相同。这些运算符的取值结果为 true(1 位的逻辑值 1)或者 false(1 位的逻辑值 0)。

  • && 逻辑与运算符。
  • || 逻辑或运算符。
  • ! 逻辑非运算符。
  • < 关系运算符小于。
  • <= 关系运算符小于或者等于。
  • > 关系运算符大于。
  • >= 关系运算符大于或者等于。
  • == 关系运算符等于。
  • != 关系运算符不等于。
  • === 关系运算符全等。
  • !== 关系运算符不全等。

上述的大部分逻辑运算符无须解释。下面解释一下全等运算符和不全等运算符,需要理解其与等于和不等于运算符的区别。

相等运算符(==)在进行比较的时候,需要按每位进行比较,只有所有的位都相等的时候,最后的结果值才会是 true。但是如果其中的某一位是高阻态(用 Z 表示)或者是不定值(用 X 表示),那么最终的结果是不定值,这一点是需要值得注意的。对于全等(===)来说,对于这些高阻态或者不定值也需要进行比较,只有完全一致才会获得 true 的结果。在进行真正比较的时候,需要注意这一点来选择合适的运算符。下表的例子清楚地说明了这一点。

A B A==B A===B
4b1101 4b1101 1 1
4b1100 4b1101 0 0
4b110Z 4b110Z X 1
4b11XX 4b11XX X 1
算术运算符

算术运算符也是常用的运算符,包括了加(+)、减(-)、乘(×)、除(÷)运算符,这些运算符可以用在整数的运算中。还有一个算术运算符与 C 语言中的运算符一致,即取模运算符(%)。可以看到,这些运算符并不是最基本的运算符,在其背后综合出的电路中,需要使用对应的门电路组织成的组合逻辑来完成。这是语言内部提供的高层的逻辑单元功能,方便在开发的时候直接集成使用,而不需要采用模块调用的方式。在处理器的设计中,算术运算符的最重要的作用是用来构成 ALU,这些算术运算符在设计 ALU 运算的时候会非常方便。当然,语言内部加法器,乘法器和除法器都是最基本的设计(但是不一定是性能差的,因为也经过了优化的过程),如果要设计更加高效的功能单元,则需要开发人员进行自行设计。

条件运算符

在 C 语言中有一个三参数的运算符,即条件运算符 condition? result1 : result2。这个运算符在 SystemVerilog 中也存在,也描述了类似的功能,即 signal = condition ? expression1 : expression2。如果 condition 为 true 的话,signal 取 expression1 的值,否则取 expression2 的值。

位拼接运算符

还有一个重要的运算符即位拼接运算符 { }。这个运算符能够很方便把多个信号拼接为向量的形式。位拼接运算符的使用也非常方便,只需要把信号排列在大括号中间即可。{a[3:0], b[7:6],c} 代表了将 a 的第 3 至第 0 位,b 的第 7 位和第 6 位,以及信号 c 拼接在一起,构成一个新的信号向量。

以上就是在 SystemVerilog 中的常用的各种运算符,这些运算符在设计处理器的时候会被使用到,需要熟练掌握。还有一点没有提的是运算符的优先级,就不在这里列出了。在大部分的情况下只会在一个表达式中使用少数几个运算符,如果不太清楚优先级顺序的话,使用括号就可以了。

SystemVerilog 的行为语句

关于硬件描述语言功能的讨论

行为语句是 SystemVerilog 中最为重要的功能语句,用来定义具体模块的行为。在开始讲述行为语句之前,先针对硬件描述语言的特性来讨论一下。在术语上一直使用的是硬件描述语言这样的叙述方式,而不是硬件设计语言。这样描述的区别是很重要的,因为硬件描述语言大部分不是进行硬件的设计,没有说明底层的硬件构成,而更多的是阐述模块对外的功能表现是怎样的,如何响应外部输入信号,如何形成到外部的输出信号。也就是说,在使用硬件描述语言的时候,更多的是描述对应的电路模块应该具有什么样的功能,而不是电路模块内部是由哪些元器件组成的,内部的构成如何。完成这样描述之后,硬件语言的编译器会翻译为对应的底层硬件的实现。由于底层硬件由多种实现的方式,例如可以通过 FPGA 的方式,或者通过直接晶体管元器件方式来实现,因此硬件描述语言有一定的中立性,不依赖于具体的物理实现方式。

这样,就会有一个问题,由于符合 SystemVerilog 语法标准的代码是对硬件行为的一种描述,而不是直接的电路设计,因此描述完成之后不一定是可以综合的(即直接可以物理实现的)。特别是对于高层的设计方法来说,更是这样,过于复杂的描述,往往很有可能就综合不出来。以当前大部分的 EDA 软件的综合能力来说,只有比较低层(例如 RTL 级别的,或者更低层)的行为描述才是能够保证可以综合的。高层的描述则需要注意,不要编写符合语法要求但是不能综合的行为描述。至于什么样的语句能够被综合,什么样的语句不能够被综合,除了一些常用的模式之外,这与 EDA 软件的综合能力也相关。随着软件综合能力的增强,原来不能综合的也可能可以综合了。

对于开发人员来说,就需要增加自己的经验,使得自己开发出来的硬件描述可以在更多的平台上能够综合。这也没有捷径可以走,重要的一点就是在编写代码的时候有意识保证自己编写的语句可以综合,避免对综合软件造成困扰。在开发的时候,时刻要注意自己的程序最终是需要被转化为硬件电路的。开发的时候,需要极力避免按照程序顺序执行的方式去思考问题,而是按照电路的电信号传递去思考,所有的功能都是并行执行的。哪怕是按照顺序方式描述出来的电路模块,只是描述了这个电路模块应具有的对外可见的功能,在最终综合完成之后,电信号会在所有的导线上进行并行传播。特别的,各个模块之间都是并行执行的,而不是串行的。下面就要进入到 SystemVerilog 语言的行为语句部分的讨论,上述的关于硬件描述语言的讨论尤为重要。需要在阅读代码以及自己进行开发的时候牢记在心。

SystemVerilog 的行为语句综述

SystemVerilog 的行为语句与其它语言的语法功能(可以将前面的运算功能当成其它语言中的表达式)相当,包括了赋值语句,过程语句,条件语句,编译指导语句等。这些语句构成了 SystemVerilog 中对于功能描述的基本模式。

由于在 SystemVerilog 中,并不是所有的行为语句都是可以综合的。不可综合的行为语句作用也十分重要,往往会被应用到仿真环境中。在实际编写 SystemVerilog 代码的时候,需要时刻注意这一点,清楚知道每一部分的代码是否需要反映到最终的硬件中。

在本章的后面部分主要讨论 SystemVerilog 的可综合的行为语句,对于仿真使用的不可综合的行为语句读者可以在理解本章的内容之后参考其它的 SystemVerilog 书籍。在 SystemVerilog 中的可综合的行为语句主要包括以下几个部分。

  1. always 过程语句;
  2. 使用 begin-end 组合起来的语句块;
  3. 可以进行持续赋值的语句 assign;
  4. 阻塞的过程赋值语句 =,非阻塞的过程赋值语句 <=
  5. for 循环语句。

always 过程语句

在 SystemVerilog 中,always 过程语句显得尤为重要。在一个模块中,always 过程语句使用是不受限制的,可以有多个 always 过程语句。基于前面的讨论,一个模块的多个 always 过程语句是并行执行的。在实现的时候,通常会使用两种 always 过程语句:

  • always_comb:用来实现组合逻辑
  • always_ff:用来实现时钟边沿触发的时序逻辑
always_comb 过程语句

always_comb 过程语句的使用方法如下:

always_comb 
    语句(可以是一条语句,或者是语句块)

如果这里的语句只有一条语句,不需要加 begin end 来构成语句块,如果超过一条语句则需要通过 begin end 来构造成语句块。因此,实际上更加经常使用的形式是如下的通过语句块来表达的形式。

always_comb
begin
    //本过程的功能描述
end

下面通过一个四选一数据选择器的模块例子来说明 always_comb 过程语句的各个组成部分。下面是这个四选一数据选择器的描述:

module mux4_1 (
    input din1,
    input din2,
    input din3,
    input din4,
    input se1,
    input se2,
    output reg out);

    always_comb
        case ({se1,se2})
            2'b00 : out = din1;
            2'b01 : out = din2;
            2'b10 : out = din3;
            2'b11 : out = din4;
        endcase
endmodule

可以看到,在这个四选一的数据选择器中,四个输入信号分别为 din1~din4,两个选择信号分别为 se1 和 se2,一个输出信号为 out。

这里可以看到,这个 always_comb 过程语句的有四个输入信号以及两个选择信号,其中任何一个发生变化,依据这个过程语句的描述,输出 out 都将发生变化。

在上述电路中,由于在 always_comb 过程语句的功能描述部分只有一条 case 语句,因此不需要使用 begin/end 来组织成语句块。case 语句的使用非常方便,从上面的例子中可以看到,其格式为:

case ({se1,se2})
endcase

在 case 语句的内部对不同的情况进行判断,然后进行处理。另外,这里又看到了使用大括号 {} 构成的位拼接运算符的作用。

always_ff 过程语句

在实现时序逻辑的时候,需要在上升沿或者下降沿事件中触发寄存器的更新。在 SystemVerilog 中,使用 posedge 指定上升沿,使用 negedge 指定下降沿。可以将边沿敏感类型的信号放置到 always_ff 过程块的敏感信号列表中,下面例子将常用时钟信号加入到 always_ff 过程的敏感信号列表中,在时钟的正上升沿触发过程块。

always_ff @(posedge clk)

这里响应的是一个时钟 clk 的上升沿信号,一旦一个时钟的上升沿发生,那么下面的 always 过程语句就“执行”。时钟是驱动处理器执行的基础,在进行综合的时候会综合出时序电路。

begin/end 块语句

begin/end 能够将多条语句组合成语句块。当然,如果只是一条语句的话,使用 begin/end 也没有问题(推荐使用这种表达方式),就好像 C 语言程序里面每个 if 语句后面都跟随一个大括号。下面是一个译码器的例子,可以清楚看到通过 begin/end 来构造出一个语句块。

module decoder2_4 (
    input [1:0] in,
    output reg [3:0] out);

    always_comb begin
        out = 4'b0000;
        case(din)
            2'b00 : out = 4'b0001;
            2'b01 : out = 4'b0010;
            2'b10 : out = 4'b0100;
            2'b11 : out = 4'b1000;
        endcase
    end
endmodule

明显看到在上述的语句块中,因为需要一个初值,在 begin/end 中有两条语句,就必须构造出一个语句块。另外一个值得注意的一点是,begin/end 构造出的语句块也往往被称为是串行块,其含义就是在 begin/end 之间的语句是“顺序执行”的。这里也体现出了硬件描述语言的特点。硬件不可能像软件一样会逐步的一条一条执行指令,在硬件综合完成之后,各个部分的电信号就开始驱动整个硬件电路信号扩散,并逐步稳定下来。而这里所谓的顺序执行,实际上是为了开发者阅读方便,"说明"这部分电路的功能,而不是实际上硬件就是这样执行的。这里的硬件在被综合出来之后就是一个常用的 2-4 的译码器,输出会随着输入的变化而随时变化。

赋值语句

赋值语句可以说是任何一门编程语言的最基本的部分。对于 SystemVerilog 来说也不例外,通过赋值语句可以将不同的信号组织起来。在 SystemVerilog 中,赋值语句包括了持续赋值语句和过程赋值语句。持续赋值语句在过程外使用,与过程语句并行执行。过程赋值语句在过程内使用,串行执行,用于描述过程的功能。(注意,这里的串行执行只是描述了行为,具体的电信号传递仍然是并行的。)

先来看一下持续赋值语句。在 SystemVerilog 中使用 assign 作为持续赋值语句使用,用于对 wire 类型的变量进行赋值。其对应的硬件也非常好理解,即通过对输出进行赋值,当输入变化的时候,经过一定的延迟,输出就会按照 assign 所描述的那样发生变化。

例如:assign res = input_a & input_b;

在这个例子中,输入 input_ainput_b,输出 res 都是 wire 类型的变量。当两个输入的任意一个发生变化的时候,输出 res 都会发生变化。当然,这样的一个变化不会是立即的,而是需要经过一定的延迟,因为任何一种电路都会有延迟。

在一个模块中,可以有多个 assign 的持续赋值语句,这些持续赋值都是并行执行的。一旦赋值语句中的任何信号发生变化,那么这个赋值语句的输出信号(赋值等号的左边那个信号)就会跟着变化。一个模块的持续赋值语句和前面所说的 always 过程语句都可以出现多次,他们之间的执行关系也是并行的,对应于电路上的信号值的变化。assign 持续赋值语句由于表达能力的限制,只能反映一些简单的变化,而 always 过程语句则可以复杂很多,用以描述复杂的输出信号和输入信号的关系。

在 always 过程里面也有赋值语句。在过程里面的赋值语句被称为是过程赋值语句,一般用来对 reg 类型的变量进行赋值。过程赋值语句分为两种类型,一个是非阻塞赋值语句(<=),一个是阻塞赋值语句(=)。它们之间的区别是:

  1. 非阻塞 non-blocking 赋值语句(<=)在赋值语句出现的地方不立即更新被赋值的信号,而是等到整个过程块结束的时候才更新。由于不是立即发生的,在过程内的描述中,仿佛这条语句不存在一样,因此被称为是非阻塞的。只是在过程的最后会执行所有的非阻塞赋值语句,在这个执行的过程中,所有的左值会维持原来的值不变。非阻塞赋值语句反映了时钟边沿触发的寄存器的行为特征,在 always_ff 中需要采用非阻塞赋值 <=

  2. 阻塞 blocking 赋值语句(=)在赋值语句出现的地方就立即完成赋值操作,左值立刻发生变化。一个块语句中存在多条阻塞赋值语句的话,这些阻塞赋值语句会按照先后顺序关系,一条一条的“执行”,前面的赋值语句没有执行完,后面的赋值语句不会执行。这样的一种行为模式,就跟网络 IO 编程中的阻塞函数调用方式一样,一定要完成函数执行之后,这个函数调用才会退出。阻塞赋值语句这种特性和普通的软件编程语义一样,可以用来直观描述组合逻辑的行为特征,在 always_comb 中需要采用阻塞赋值 =

下面的例子非常适合用来说明阻塞和非阻塞在执行上的效果差别。

module blocking (
    input clk,
    input a,
    output c);

    reg b;

    // 下面的代码语法正确,但是不建议(应当)在时序逻辑中使用阻塞赋值
    always_ff @(posedge clk) begin
        b = a;
        c = b;
    end
endmodule
module nonblocking (
    input clk, 
    input a,
    output reg c);

    reg b;

    // 这是正确的写法
    always_ff @(posedge clk) begin
        b <= a;
        c <= b;
    end
endmodule

在上述的例子中,前面一个是阻塞赋值语句的例子,后面一个是非阻塞赋值语句的例子。在前面的例子中,最后 b 和 c 的值都统一改为 a 的值。因为由于阻塞的特性,b=a 必须要先完成,之后的 c=b 执行的就是 c=a 的操作。而后面的例子中,由于是非阻塞的赋值,所有赋值起到效果的是在过程结束的时候。在过程结束的时候,b 就被赋值为 a 的值,而 c 被赋值为 b 之前的值。这个效果从最终的模拟信号波形图可以看得很清楚。

阻塞赋值的仿真波形图

非阻塞赋值的仿真波形图

上述的阻塞赋值和非阻塞赋值的最终的综合出来的硬件物理电路当然是不同的。非阻塞赋值要比阻塞赋值多加一个触发器。这显然是因为在非阻塞情况下,信号 b 和 c 的变化不是同步的,这样就需要通过一个触发器进行一个周期的延迟。

条件语句

几乎所有的编程语言中都会包含条件语句,并且几乎都使用了 if 这样的最为常见的方式。在 SystemVerilog 中,条件语句包括了 if-else 语句以及 case 语句。这两个语句在 SystemVerilog 中的使用方法与在其它的语言中几乎完全一样。

对于 if-else 的条件语句来说,主要有下面的三种使用形式:

1. if(逻辑表达式) 语句1;

2. if(逻辑表达式) 语句1;

    else 语句2;

3. if (逻辑表达式1)语句1;

    else if (逻辑表达式2 语句2;

    else if (逻辑表达式3 语句3;

    ........

    else if (逻辑表达式n 语句n;

    else 语句n+1;

上面这三种形式无非就是只需要一个判断,或者需要多个判断,以及是否需要增加 else 匹配后续的动作等的形式。

module decoder2_4 (
    input [1:0] din,
    output reg [3:0] dout);

    always_comb begin
        dout = 4'b0000;
        if (din == 2'b00)
            dout = 4'b0001;
        else if (din == 2'b01)
            dout = 4'b0010;
        else if (din == 2'b10)
            dout = 4'b0100;
        else if (din == 2'b11)
            dout = 4'b1000;
    end
endmodule

在 SystemVerilog 中也提供了 case 这样的条件判断语句。case 的条件判断语句主要是为了方便使用,避免使用过多的 if-else 进行编写。case 语句使用的是下面的语法形式:

case (敏感表达式)
    条件判断1:语句1
    条件判断2:语句2
    .........
    条件判断n:语句n;
    default:语句n+1;
endcase

在敏感表达式中会计算出不同的条件,进而会跳到匹配的语句去“执行”。需要注意的是,这里的语句就直接是分支的语句,不需要像 C 语言一样在其中插入 break,在语句执行完成后,直接跳出了 case 语句本身。也就是说,执行完语句 1 之后,就直接退出了整个 case 语句,而不是继续执行语句 2。在 C 语言中,如果没有使用 break 语句,剩余的语句会逐一执行的,直到碰到 break 或者 case 语句结束。SystemVerilog 这样的行为模式对于程序员来说更加友好。前面已经有 case 语句的例子,在这里就不再进行举例了。

循环语句

在 SystemVerilog 中也存在循环语句。可综合的循环语句为 for 语句,并且这个 for 语句和 C 语句中的 for 语句使用形式完全一样。for 语句的形式为:

for (表达式1;表达式2;表达式3)语句;

也即:

for(循环变量赋初值;循环结束条件;循环变量增值)执行语句;

上述的展开表达分别解释了形式表达中的表达式 1,表达式 2 和表达式 3 的含义。可以看到,确实与 C 语言中的对应的 for 循环语句完全是一样的。

循环语句比较特殊,不太容易直观想象得出综合之后的效果。同样的,综合器处理起来也不容易。对某些综合器能够综合的 for 循环语句在另外的综合器中不见得也能综合。相比于前面讨论的语句(赋值和判断语句),for 循环语句描述的功能更加高层和抽象(也包括其它类型的循环语句)。虽然写程序容易,但是转化为硬件的难度会更大。即便转化完成,可能所需要的硬件资源也很多,效率不高。因此,除非是一些对语句进行重复设置的情况,尽量不要使用循环语句,以免对综合器造成困扰。

除了上述的 for 语句之外,在 SystemVerilog 中也有其它三个循环语句,分别为 forever 语句,repeat 语句,while 语句。其中 forever 语句会连续执行语句,主要在仿真中使用,用以生成周期性的波形,例如时钟信号。

repeat 语句的使用格式为:

repeat(循环次数的表达式)
begin
    语句或者语句块
end //单个语句不需要begin和end

while 语句的使用格式为:

while(循环执行的条件表达式)
begin
    语句或者语句块
end //单个语句不需要begin和end

由于 repeat 语句和 while 语句的功能实际上都可以通过 for 语句表达出来,另外也由于 for 语句在大部分的 EDA 工具中都是可以综合的,而 repeat 和 while 往往是不可综合的,因此在自己编写代码的时,如果需要生成可以综合的代码,尽量使用 for 语句来实现循环。但是,正如上面所讨论的一样,综合出来的效率不一定很高,应该谨慎使用。

module for_adder (
    input [7:0] a,
    input [7:0] b,
    input cin,
    output reg [7:0] sum,
    output reg cout);

    reg c;
    integer i;

    always_comb begin
        c = cin;

        for (i = 0; i < 8; i++) begin
            {c,sum[i]} = a[i] + b[i] + c;
        end
        cout = c;
    end
endmodule

这里是通过 for 循环语句来实现的一个加法器。真正的加法器并不需要这样实现,这里是对 for 语句的功能进行举例。可以看到,在过程语句里面的 for 循环描述了每一位加法的过程,并最终获得结果。从这个 for 循环语句看不出最终的电路会是什么样子的,因此这是一个非常明显的功能描述的代码,描述的层次已经比较抽象,而不是功能设计的代码。

SystemVerilog 的设计层次与风格

在 SystemVerilog 中,可以使用不同的方式来进行电路的设计,有的时候也会给初学者很大的困扰。因为语言有很大的灵活性,对于相同的电路可以有不同的设计方法。这种灵活性可能会对初学者来说不太好掌握。下面就通过一个 1 位全加器的简单例子来说明在 SystemVerilog 中的不同的设计层次与设计方法。

1 位的全加器的输入包括 1 位的低位进位 cin,两个 1 位的输入信号 a 和 b,输出则包括了一个当前位的和 sum 以及向高位的进位 cout。全加器的电路图可以直接从 1 位全加器的真值表中获得,大部分的数字电路以及组成原理的教科书都有 1 位全加器的例子,可做参考。

a b cin cout sum
0 0 0 0 0
0 0 1 0 1
0 1 0 0 1
0 1 1 1 0
1 0 0 0 1
1 0 1 1 0
1 1 0 1 0
1 1 1 1 1

从 1 位全加器的真值表可以获得 1 位全加器的逻辑表达式(注意这里只使用了与或非门的表达,如果使用其它的逻辑运算符可以进一步简化):

CarryOut = (¬A*B*CarryIn)+(A*¬B*CarryIn)+(A*B*¬CarryIn)+(A*B*CarryIn)
=(B*CarryIn)+(A*CarryIn)+(A*B)
Sum = (¬A*¬B*CarryIn) + (¬A*B*¬CarryIn) + (A*¬B*¬CarryIn) +(A*B*CarryIn)

这样就可以很容易获得 1 位全加器的电路表达形式:

上面已经完成了一个一位全加器的设计,并且已经设计出了电路。真正的工作已经完成了,这实际上就是传统的硬件设计方法。作为例子,在这里首先把一个已经实现的电路绘制出来,然后再逐步讨论使用 SystemVerilog 进行不同的描述,目的就是展示硬件与对应的描述之间的关联。

在上述的电路中,使用了三个非门(not),四个 3 输入的与门(and),三个 2 输入的与门(and),一个 4 输入的或门(or),一个 3 输入的或门(or)。这里的非门,与门和或门都是 SystemVerilog 中的内置的门电路,可以直接使用。这样,可以将上述电路中的每一条线进行命名,然后就可以直接构造出 SystemVerilog 的结构描述(调用门元件)。当然,如果是直接是输入线,就不必命名了,使用输入的名称即可,同理对于输出线可以使用输出的名称。

module full_adder1 (
    input a,
    input b,
    input cin,
    output sum,
    output cout);

    wire a_n, b_n, cin_n, sum_p1,sum_p2,sum_p3,sum_p4, cout_p1, cout_p2, cout_p3;

    not(a_n,a),(b_n,b),(cin_n,cin);
    and(sum_p1,a_n,b_n,cin),(sum_p2,a_n,b,cin_n),(sum_p3,a,b_n,cin_n),(sum_p4,a,b,cin),(cout_p1,b,cin),(cout_p2,a,cin),(cout_p3,a,b);
    or(sum,sum_p1,sum_p2,sum_p3,sum_p4),(cout,cout_p1,cout_p2,cout_p3);
endmodule

注意,上述所有的内置门电路(not, and, or 门),第一个参数为输出,剩余的参数为输入。可以看到,上述 1 位全加器的门级结构描述直接描述了各个门之间是如何连线的。由于是文本文件,因此连线不像图形那么直观。但是,从代码中的各个信号线的命名,以及对应于命名完成的信号线作为门级的调用参数来看,各个门元件的连线就比较清楚了。门级结构描述虽然不是最底层的描述(比如直接用晶体管搭建),但是已经非常接近底层的描述,可以被直接综合出来,使用元件进行直接映射即可。

但是,门级结构描述的缺点也很明显,要求用户自行完成门级的设计,直接映射到硬件。这种方法一般用于设计比较简单的电路,或者设计非常高效工作的电路,方便综合器直接进行综合。当然,这里的门级结构描述的例子还有一个作用,即其结构描述的方式可用于通过部分的逻辑电路模块来构造更加大型的电路模块。当我们讨论完其它两种描述之后再回顾一下会更加清楚。

门级结构描述虽然方便了底层的综合器,但是对于编程来说不太方便,开发者希望能够进行更加高层的设计。一个选择就是将上述的逻辑表达式直接写到程序里面这就是数据流描述的方法。

module full_adder1 (
    input a,
    input b,
    input cin,
    output sum,
    output cout);

    assign sum = (~a&~b&cin)|(~a&b&~cin)|(a&~b&~cin)|(a&b&cin);
    assign cout = (b&cin)|(a&cin)|(a&b);
endmodule

数据流描述方法描述了在组合逻辑中,输出是如何随着输入数据的变化而变化,使用的是持续赋值语句 assign。持续赋值语句的抽象层次要比前面的门级结构描述更加抽象。只要有了逻辑表达式关系,直接翻译为 SystemVerilog 中的运算符即可,而不用仔细考虑底层的门电路构成。但是,数据流描述的抽象层次还不是很高,前面的例子已经很难从给出的数据流中看到这是一个 1 位的全加器。实际上,对于复杂的硬件逻辑设计来说,使用行为级描述更为妥当,即直接描述出硬件所需要完成的功能,而不需要考虑这些硬件具体是如何实现的。具体如何实现交给 EDA 综合软件去做。

module full_adder1 (
    input a,
    input b,
    input cin,
    output reg sum,
    output reg cout);

    always_comb begin
        {cout,sum}=a+b+cin;
    end
endmodule

从行为级描述中完全看不到最终的电路是怎样使用元件以及怎样布线的,但是这个程序非常简单明了。这对于开发人员来说极为方便,直接能够看出是一个全加器,因为这一段程序完整的描述了一个全加器所需要完成的功能。

上面的结构级描述,数据流级描述以及行为级描述就是 SystemVerilog 开发过程中可以使用的三种不同层次的对于硬件的描述方法。结构级描述直接描述了硬件电路的结构,最为具体,但是不够抽象。数据流描述更加接近传统的逻辑设计,抽象程度中等。行为级描述只需要抽象描述一个硬件的功能单元完成什么样的功能即可,不需要说明硬件是如何构造的,最为抽象。在实际的设计过程中,这三种方式可以互相混合使用,针对不同的电路可以选择不同的描述方式。

在设计更加大型的硬件电路的时候,使用结构级描述是必不可少的。在前面的例子中,已经看到是如何通过调用门级的基本逻辑单元来完成全加器的功能。这样的方法在设计更加大型的电路中也是相同的。可以设计一些小型的电路模块,然后通过结构的描述来设计出规模更大的电路。下面就通过设计 4 位的加法器来说明结构描述在设计大型电路时候的作用。

4 位加法器的构成使用了 4 个 1 位的加法器,通过级联之后就可以获得。

通过对其中的信号进行命名,可以通过结构描述的方式来描述上述的电路。实际上,这里需要命名的信号就是进位到前一级的进位,可以分别命名为 cin1,cin2,cin3。上述电路的描述如下。

module full_adder4 (
    input [3:0] a,
    input [3:0] b,
    input cin,
    output [3:0] sum,
    output cout);

    full_adder1 a0(a[0],b[0],cin,sum[0],cin1);
    full_adder1 a1(a[1],b[1],cin1sum[1],cin2);
    full_adder1 a2(a[2],b[2],cin2sum[2],cin3);
    full_adder1 a3(a[3],b[3],cin3sum[3],cout);
endmodule

可以看到,这里的结构级描述与之前的门级结构描述在形式上是完全一样的。前面门级结构描述调用的是语言内建的元件,但是在这里调用的是开发者自己的模块。同时,在这里也看到了调用的另外一种形式,即可以对每一次“调用”进行命名,分别命名为 a0, a1, a2, a3。

到现在为止对于 SystemVerilog 的编程语言进行了一个基本的介绍。在实际进行硬件设计的时候,出发点还是自顶向下的方法,对硬件的总体先分成多个互相独立的模块,然后定义模块之间的连线关系。之后,每一个独立的模块可以分别进行设计,连线关系即是它们之间的接口。最终完成的硬件通过结构描述的方式将设计完成的模块连接在一起。

一些编程建议与经验

下面的内容与 SystemVerilog 语言的语法不直接相关,但是会讨论一些经验以及常见的错误。这里的一些常见错误同学平时也会碰到,建议先阅读理解大概的含义,在具体编程碰到问题的时候也可以返回本章查看与理解。也注意多与同学讨论,少走弯路,减少调试难度。另外,一些具体实验中可能碰到的困难和错误会安排在具体的实验章节部分。

除了 SystemVerilog 的语法之外,下面的一些与语言相关的提示需要大家注意一下,会有一些帮助。

`default_nettype none

`default_nettype none 这是建议的做法。在 SystemVerilog 中,所有没有被定义的标记 label 都被默认认为是 wire 类型的。但是,这种默认的行为有的情况下是非常危险的,比如在信号名字上出现的拼写错误不会被探测出来。因此,建议在所有的源文件的开始加上这一句,取消默认行为。

锁相环电路

PLL 是 FPGA 上专用的时钟生成模块,内部是模拟电路。在启动时需要一段时间才能进入稳定状态,所以有个 locked 信号输出,表示它稳定了。在锁相环电路稳定输出之后,locked 信号会被置位,此时可以做电路中寄存器初始化的工作。

调时序

硬件编程基本上是仿真驱动的,其中比较难的部分是调时序,使得各个部分的时序可以相互匹配,也满足对于外设的时间要求。不同模块之间,由于寄存器的关系,会有相位差,然后需要增加几个空的状态机节拍,这样可以匹配不同路径的信号传播。

阻塞赋值语句和非阻塞赋值语句

关于 =<=:一般来说,组合逻辑用 =,时序逻辑用 <=。另外 wire 和 reg 是语法层面的内容,assign 的左值必须是 wire,always 里的左值必须是 reg,否则综合就会报错。最终是否综合成触发器,是根据有没有时钟信号决定的。综合器怎么知道哪个是时钟信号呢?通过 posedge 的描述方法可以知道对应的模块里面需要响应正边沿(posedge)或者是负边沿(negedge),从而会综合出触发器。在 SystemVerilog 中,如果有可能的话尽量使用 logic 这样的类型并匹配 always_comb 和 always_ff 来分别描述组合逻辑和时序逻辑。在这两种不同类型的 always 块中也要遵循上述的赋值语句使用方法。

程序的可读性

写 SystemVerilog 的代码和写软件应用程序的代码一样,有一件事情需要特别注意,就是提高程序的可读性,增加程序的可维护性。在选择信号名称的时候需要按照名称选择的惯例,有一些命名方法是常用的,按照其惯常的用法就可以了,不要改变其名字的用法。下面是一些名字使用的惯例,同学们应当遵守来提高自己程序的可读性。

  • _i, _o,分别代表一个模块的输入信号和输出信号。

  • n 或者 _n 为后缀,表明这个信号是 0 使能,0 表示有效。

  • clkclock 时钟信号,后面或者前面接上频率,可以显示时钟信号的频率。

  • rstreset 复位信号,使得信号可以重置,一般在重置响应中写入状态机的初值。

  • wewrite enable 信号,对应于模块的写入使能。

  • oeoutput enable 信号,对应于模块的输出使能。

  • cechip enable,对应于模块的总体使能信号。上述的信号几乎在所有的模块中都会有。(注意信号是正向的还是反向的,即 1 使能还是 0 使能。在说明书中,0 使能会在信号名称的上面带有横线。)

  • selectsel,这个信号一般用于对芯片的选择。

其它的信号也会有惯例使用的情况,大家在平时的时候可以多注意学习。

代码检查工具

下面的网址中有一些 SystemVerilog 的工具可以供大家参考。

https://www.veripool.org/

verilator --lint-only -Wall [source_files.v]... 可以帮助做一些检查。

一些特殊的语法点

ram_address = pc[2+:21];

含义就是从第 2 位开始的 21 位,就是 22:2,把最后两位去掉。也可以写成 pc[22-:21]pc[22:2]

case 语句可能出现错误的情况:

在使用 case 的时候,一定要注意把所有的信号在所有的情况下写全了,要不然有一些信号就没有定义,会造成错误。或者可以灵活使用阻塞赋值语句,=,在过程最前面的时候先进行赋值。

下面的 case 代码块是正确的:

always_comb begin
    ram_we_n = 1'b1;
    ram_oe_n = 1'b1;
    ram_address = 21'h0;

    case (state)
        STATE_FETCH: begin
            ram_oe_n = 1'b0;
            ram_address = pc[22:2];
        end
        STATE_MEM: begin
            ram_oe_n = mem_op_write;
            ram_we_n = ~mem_op_write;
            ram_address = ex_val_o[22:2]; 
        end
    endcase
end

下面的 case 语句代码块也是正确的:

always_comb begin
    case (state)
        STATE_FETCH: begin
            ram_oe_n = 1'b0;
            ram_we_n = 1'b1;
            ram_address = pc[22:2];
        end
        STATE_MEM: begin
            ram_oe_n = mem_op_write;
            ram_we_n = ~mem_op_write;
            ram_address = ex_val_o[22:2]; 
        end
        default: begin
            ram_oe_n = 1'b1;
            ram_we_n = 1'b1;
            ram_address = 21'h0;
        end
    endcase
end

但是下面的 case 语句代码块是错误的,为什么?(注意有哪些情况没有考虑完善?)

always_comb begin
    case (state)
        STATE_FETCH: begin
            ram_oe_n = 1'b0;
            ram_address = pc[22:2];
        end
        STATE_MEM: begin
            ram_oe_n = mem_op_write;
            ram_we_n = ~mem_op_write;
            ram_address = ex_val_o[22:2]; 
        end
        default: begin
            ram_oe_n = 1'b1;
            ram_we_n = 1'b1;
            ram_address = 21'h0;
        end
    endcase
end

Warning: empty statement in sequential block

如果两个分号放在一起的话 ;;,就会出现这个警告。删掉一个分号即可。这里一个容易出现的错误是在信号常数定义 `define 的时候,不小心在信号后面跟了一个分号,例如 `define OP\_ADD 4'h9; 这里多了一个分号。在模块代码里面直接使用的时候就会出现上面的情况。


最后更新: 2023年9月16日
作者:Jiajie Chen (43.11%), Youyou Lu (36.16%), gaoyichuan (13.25%), Kang Chen (7.38%), Shaofeng Ding (0.11%)