咖啡图片
正在将巧克力泡入咖啡
ntainer" style="display: none">
文章

最容易入门的HDL

Verilog核心语法

最容易入门的HDL

Verilog HDL(简称 Verilog)是一种硬件描述语言,用于数字电路的系统设计和仿真,同一语言可用于生成模拟激励,以及指定测试的约束条件。Verilog 可对算法级(描述功能)、门级(描述门电路)、开关级(描述晶体管)三个抽象设计层次进行建模。假定你已经掌握了 C 语言的语法,下面对 Verilog 中常用的核心语法进行梳理,其它不常用的、只用在仿真中的、甚至有些不建议使用的部分可以参看菜鸟教程之类的专门介绍语法的资料。在正式开始之前请记住,写 Verilog 代码其实更像是“建模”而不是“编程”。祝你好运!

数值表示

声明数值的规范格式为位宽 + 基数 + 数值。基数在不指定时默认为十进制,数值相同下不同基数格式完全等效。数值有 0/1/x/z 四种取值,x 表示未知,z 表示高阻(信号没有驱动时的结果)。

1
2
3
4
1'b0;
4'd3;
6'sd-15; 	// 负数更要显式指定位宽; s表示有符号数。
6'b11_0001; // 较长的数值建议每四位加下划线

数据类型

纯软件的编程语言中,数据类型的作用多为告诉处理器怎么解释一段内存,而在硬件描述语言中,数据类型会映射到实际的硬件结构上。wirereg 是 Verilog 中最核心的两种类型,它们的本质区别在于信号驱动的方式不同。

  • wire 表示硬件单元之间的物理连线,用于连接驱动源(提供值的地方)和被驱动端口(接收值的地方),抽象为电路中的导线。wire 由其连接的器件输出端连续驱动,即驱动源变化,wire 立即变化。典型的驱动源包括原语/模块的输出、连续赋值语句 assign 等。没有驱动元件连接到 wire 型变量时,其缺省值为 “Z”。wire 可以在定义时赋值或定义后用 assign 赋值;但无论哪种方式,由于 wire 型变量由其它电路驱动,每个 wire 变量最多只能有一个驱动源,因此只能被赋值一次。不声明数据类型时,wire 为默认的数据类型。
  • reg 表示存储单元,会保持数据原有的值直到被改写,不需要驱动源。reg 只能在过程块中被赋值,初始化的 reg 在仿真开始为 X,默认为无符号数。reg 在时序过程块中被赋值时会被综合为触发器,而在组合过程块中被赋值且所有分支对它都有输出时,会被综合成组合逻辑网络(即门电路+导线),若存在分支对它没有输出则会被综合成锁存器 latch,这是需要避免的。

变量可以被组织成向量和数组的形式。向量可以通过索引或指定 bit 位后固定位宽的向量域选定向量的某一位或若干相邻位进行操作,语法为 [bit+: width] 或 [bit-: width],表示从起始 bit 位开始递增或递减 width 位。此外,Verilog 支持可变的向量域选择。

1
2
3
4
5
6
7
8
9
// 向量声明
reg [31:0] data1;
output reg [0:0] y;   // 1-bit 也是向量类型
input wire [3:-2] z;  // 6-bit 的向量,允许负数向量域
wire [0:7] b;         // b[0]为最高权重位,但不建议这样设计

// 指定向量域
A = data1[31:24];
A = data1[31-:8];

Verilog 支持对操作数位宽的自动扩展和对运算结果的拼接。位宽自动扩展即当参与运算的两个操作数位宽不一样时,会自动把位宽小的那个扩展成和大的相同再进行运算。有符号数会自动补符号位,无符号数补 0。拼接操作符用大括号表示,操作数必须指定位宽。拼接时注意位宽和顺序;一般拼接右值,左值按完整变量写。

1
2
3
4
// 拼接
wire [31:0] temp1, temp2;
assign temp1 = {byte1[7:0], byte2[31:8]};
assign temp2 = {32{1'b0}};

数组中的每个元素都可以作为一个标量或者向量,形如:<数组名> [<下标>]。数组维数没有限制,不过一般不超过二维。数组与向量的访问方式在一定程度上类似,但两者是截然不同的数据结构。向量是一个单独的元件,位宽为 n;数组由多个元件组成,其中每个元件的位宽为 n 或 1,比如存储器变量就是用于描述 RAM 或 ROM 行为的寄存器数组。

1
2
3
4
// 数组声明
reg [7:0] addr_bus[3:0];
wire data_bit[7:0][5:0];
reg [7:0] mem [255:0];   // 256 个数据,每个数据为8-bit的reg

变量之外,常量在 Verilog 中用关键字 parameterlocalparam 声明,只能赋值一次。parameter 可以在实例化时覆盖,作为模块的可配置参数。localparam 则用于定义不可被覆盖的常量,作为模块内部的固定值。

1
2
3
// 常量声明
parameter data_width = 10'd32;
localparam mem_size = data_width * 10;

赋值方式

赋值方式在硬件描述语言中负责解释驱动信号的方式,Verilog 中有连续赋值和过程赋值两类赋值方式。连续赋值指 assign,只能驱动 wire 类型,用于建模简单的组合逻辑。任何操作数的改变都影响表达式的结果,适合建模硬件导线关系,始终生效。过程赋值指在过程块赋值,只能驱动 reg 类型,用于建模时序逻辑和复杂的组合逻辑。过程赋值只有在语句执行的时候,才会起作用。变量在被赋值后,其值将保持不变,直到重新被赋予新值。

过程块的含义是在特定条件触发时执行一次块中的语句,这个所谓的特定条件就是过程块中括号部分的内容,被称为敏感表,用于明确哪些信号发生变化时需要触发执行这个过程块。一个过程块产生一个独立的控制流,执行时间均从 0 时刻开始。过程块不可嵌套,各个过程块相互独立、并行执行。因此一个变量不能在不同过程块中赋值,否则会导致多重驱动冲突。

过程块只有 initialalways 两种,initial 块只执行一次,不可综合,always 块重复执行,根据敏感表中的内容又分为组合 always 块和时序 always 块。组合 always 块中使用阻塞赋值 =,时序 always 块中使用非阻塞赋值 <=。另外,reg 在两种 always 块中的综合结果不同,在组合 always 块中会被综合成组合逻辑网络(门电路和导线),在时序 always 块中会被综合成触发器。

阻塞和非阻塞的区别在于赋值生效的时机不同,阻塞赋值 = 属于顺序执行语句,即下一条语句执行前,当前语句一定会执行完毕,在过程块中会立即更新目标变量的值。非阻塞赋值 <= 属于并行执行语句,即下一条语句的执行和当前语句的执行是同时进行的,它不会阻塞位于同一个语句块中后面语句的执行。在时钟沿触发的过程块中,非阻塞赋值不会立即生效,而是将所有触发所有满足触发条件的 always 块的所有非阻塞赋值语句放入队列中,然后统一按队列顺序执行更新。

操作数和操作符

绝大部分规则与 C 语言一致,这里指出需要额外注意的点。

  • 算术操作符和关系操作符中,如果操作数某一位为 x,则计算结果为 x。

  • 逻辑操作符的计算结果是一个 1bit 的值。如果一个操作数不为 0,则等价于逻辑 1;如果一个操作数等于 0,则等价于逻辑 0。如果它任意一位为 x 或 z,则等价于 x。

  • 推荐使用更安全的 === 来减少 bug 风险(判断前不作类型转换)。全等比较时,如果按位比较有相同的 x 或 z,返回结果也可以为 1,因此全等比较的结果一定不包含 x。

  • 按位操作符对 2 个操作数的每 1bit 数据进行按位操作。如果 2 个操作数位宽不相等,则用 0 向左扩展补充较短的操作数。

  • 归约操作符只有一个操作数,作用是对单个向量操作数进行逐位操作,最终得到 1 位结果 ,常用来判断向量中某些位的整体特征(如是否全 1、是否有 1 等)。

    1
    2
    3
    4
    
    // 归约操作符
    & a[3:0]     // AND: a[3]&a[2]&a[1]&a[0]. Equivalent to (a[3:0] == 4'hf)
    | b[3:0]     // OR:  b[3]|b[2]|b[1]|b[0]. Equivalent to (b[3:0] != 4'h0)
    ^ c[2:0]     // XOR: c[2]^c[1]^c[0]
    
  • 部分逻辑操作符、按位操作符和归约操作符使用相同的符号表示,由作用的操作数数量和计算结果(即上下文)加以区分。

  • >> 是逻辑左移,>>> 是算术左移。逻辑左移和算术左移的区别在于对有符号数符号位的处理。逻辑左移将二进制数当无符号数处理,所有位包括最高位均左移,右侧补 0;算术左移将二进制数当有符号数处理,保留符号位不变仅对数值位左移,右侧补 0。逻辑右移和算术右移类似,逻辑右移时左边高位补 0;算术右移时左边高位补符号位,以保证数据缩小后值的正确性。

控制流语句

Verilog 设计中常用的控制流语句只有条件语句 if else 和多路分支语句 case,循环语句 for 会配合 generate 被用于重复例化多个相同模块,而循环语句 whilerepeatforever 基本只活跃在仿真中。另外注意,if else 可以实现 case 的功能,但在电路实现上,前者有优先级而后者没有,因此两者综合出的电路也不同。

模块及其例化

模块定义的语法如下,其中包含可选的参数列表和可选的端口列表。端口类型按照端口信号的方向分为 inputoutputinout 三种。input 端口和 inout 端口都由外部信号驱动,需要实时反映外部信号变化,不能声明为只在过程赋值时才更新的 reg 类型;对模块外部来说,input 端口可以连接 wire 型或 reg 型变量,inout 必须连接 wire 型变量。output 可以声明为 wire 或 reg 数据类型;对模块外部来说,必须连接 wire 型变量。

1
2
3
4
5
6
// 模块定义
module module_name
    #(parameter_list)
    (port_list);
    // Declarations and Statements;
endmodule

在一个模块中引用另一个模块并对其端口进行连接,叫做模块例化。例化时建议使用命名端口连接,将需要例化的模块端口与外部信号按端口名字连接,端口顺序随意。如果某些端口不需要在外部连接,例化时可以悬空不连接(.() 留空)。output 端口悬空是可以的,input 端口悬空则会表现为高阻状态,因此 input 端口不使用应连接到常量值而不是悬空。另外,值得注意的是,例化端口与连续信号位宽不匹配时,端口会通过无符号数的右对齐或截断方式进行匹配。

1
2
3
4
5
6
7
// 模块例化
full_adder u_adder0(
    .Ai(a[0]),
    .Bi(b[0]),
    .Ci(c==1'b1 ? 1'b0 : 1'b1),
    .So(so_bit0),
    .Co(co_temp[0]));

当一个模块被另一个模块引用例化时,高层模块可以对低层模块的参数值进行覆盖,这样在编译时可将不同的参数传递给多个相同名字的模块,而不用单独为只有参数不同的多个模块再新建文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// module defination
module  ram
#(
    parameter AW = 2 ,
    parameter DW = 3
)
(
    input               CLK ,
    input [AW-1:0]      A ,
    input [DW-1:0]      D ,
    input               EN ,
    input               WR ,
    output reg [DW-1:0] Q
);
	// 省略内部逻辑
endmodule

// instantiation
ram #(.AW(4), .DW(4))
u_ram(.CLK(clk), .A(a[AW-1:0]), .D(d), .EN(en), .WR(wr), .Q(q));

需要重复例化多个模块时,使用 generate、genvar 和 for 关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module full_adder4
(
    input [3:0]  a ,   // adder1
    input [3:0]  b ,   // adder2
    input        c ,   // input carry bit
    output [3:0] so ,  // adding result
    output       co    // output carry bit
);
    wire [3:0]    co_temp ;
    // 此处第一个例化模块格式有所差异,需要单独例化
    full_adder1  u_adder0(
        .Ai     (a[0]),
        .Bi     (b[0]),
        .Ci     (c==1'b1 ? 1'b1 : 1'b0),
        .So     (so[0]),
        .Co     (co_temp[0]));

    genvar        i ;
    generate
        for(i=1; i<=3; i=i+1) begin: adder_gen
        full_adder1  u_adder(
            .Ai(a[i]),
            .Bi(b[i]),
            .Ci(co_temp[i-1]), // 上一个全加器的溢位是下一个的进位
            .So(so[i]),
            .Co(co_temp[i]));
        end
    endgenerate
    assign co    = co_temp[3] ;
endmodule

函数和任务

函数 function 和任务 task 都是用于封装重复的行为级设计,避免重复代码的多次编写的机制。函数和任务可以在模块中任意位置定义和调用,作用范围局限于此模块。

函数有如下特点:

  • 只能描述组合逻辑,是可综合的,适合用于封装相对复杂的组合逻辑。

  • 至少有一个输入变量且输入端口不能包含 inout,有且只有一个返回值(函数名本身对应的值),没有输出。
  • 可以调用其它函数但不能调用任务。
  • 总在零时刻就开始执行。
  • 不能单独作为一条语句出现,而只能作为赋值语句的右值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 函数举例
module endian_rvs
#(parameter N = 4)
(
    input en,
    input [N-1:0] a ,
    output [N-1:0] b
);
    reg [N-1:0]          b_temp ;
    always @(*) begin
    if (en) begin
            b_temp =  data_rvs(a);
        end
        else begin
            b_temp = 0 ;
        end
    end
    assign b = b_temp ;

    // function entity
    function [N-1:0] data_rvs
    (
        input [N-1:0] data_in
    );
        parameter MASK = 32'h3;
        integer k;
        begin
            for(k=0; k<N; k=k+1) begin
                data_rvs[N-k-1] = data_in[k];
            end
        end
    endfunction
endmodule

任务有如下特点:

  • 可描述组合逻辑和时序逻辑,但不能包含 always 块,一般只用于仿真。
  • 可以没有或者有多个输入,输入端口声明可以包含 inout,没有返回值,可以没有或有多个输出。
  • 可以调用其它函数或任务。
  • 可以在非零时刻开始执行。
  • 可以作为一条单独的语句出现在语句块中。
  • 被调用时,端口必须按顺序对应。

另外,任务可以看作过程型赋值,任务的 output 信号在任务中所有语句执行完毕后才会返回。任务的内部变量只在任务中可见,如果需要观察任务对变量的操作过程,则需要将变量在任务之外模块之内声明为全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 任务举例
`timescale 1ns/1ns
module test ;
    reg          clk, rstn ;
    initial begin
        rstn    = 0 ;
        #8
        rstn = 1 ;
        forever begin
            clk = 0 ; # 5;
            clk = 1 ; # 5;
        end
    end

    reg  [3:0] a, b;
    wire [3:0] co ;
    initial begin
        a = 0 ;
        b = 0 ;
        sig_input(4'b1111, 4'b1001, a, b);
        sig_input(4'b0110, 4'b1001, a, b);
        sig_input(4'b1000, 4'b1001, a, b);
    end

    task sig_input ;
        input [3:0] a ;
        input [3:0] b ;
        output [3:0] ao ;
        output [3:0] bo ;
        @(posedge clk) ;
        ao = a ;
        bo = b ;
    endtask ; // sig_input

    xor_oper u_xor_oper
    (
      .clk  (clk ),
      .rstn (rstn),
      .a    (a   ),
      .b    (b   ),
      .co   (co  )
    );

    initial begin
        forever begin
            #100;
            if ($time >= 1000)  $finish ;
        end
    end
endmodule

此外需要注意,函数和任务的局部变量都是静态的,即每次调用时,局部变量都使用同一个存储空间。若函数或任务被并发执行,并行的函数或任务会同时对同一块地址进行操作,导致不确定的结果。这种情况需要使用关键字 automatic 修饰函数或任务,调用时可以自动分配新内存空间。

时间单位指定

在 Verilog 模型中,时延有具体的单位时间表述,用 `timescale 定义时延及仿真的单位和精度,将时间单位与实际时间相关联。其中 time_precision 的大小需要小于等于 time_unit 的大小。

1
`timescale      time_unit / time_precision

由于 Verilog 中没有默认的 `timescale,如果不显式指定,模块就有可能继承前面编译模块参数并导致设计出错。一个设计中的多个模块都带有 ` timescale 时,则 time_precision 确定为所有模块中的最小值,其它 time_precision 都相应地换算为这个精度,time_unit 不受影响。如果有并行子模块,子模块间的 `timescale 并不会相互影响。time_precision 越小,仿真时占用内存越多,实际使用的仿真时间就越长,所以在满足需要的前提下应尽量将 time_precision 设置得大一些。

Verilog 开发规范

大部分来自华为 Verilog 开发规范。

设计风格类

  • 无特殊情况建议显式指明位宽,数值较长的每四位加下划线,并注意不要让结果超出位宽的表示范围发生溢出
  • 采用小写字母定义 wire、reg、input/inout/output 以及模块名
  • 采用大写字母定义参数,参数名小于 20 个字母
  • 时钟信号应前缀 “clk”,复位信号应前缀 “rst”
  • 三态输出的寄存器信号应后缀 “_z “
  • 输入端口前缀 “i_“,输出端口前缀 “o_”
  • 延迟打拍的变量加后缀 _r1、_r2等
  • _d 表示延迟后的信号,_t 表示暂时存储的信号,_n 表示低有效的信号,_s 表示 slave 信号,_m 表示 master 信号
  • module 例化名用 U_xx_x 标示(多次例化用次序号 0、1、2…)
  • 使用降序排列定义向量有效位顺序,即向量的 0 索引统一为最低权重位,且不使用负数的向量域
  • 不能使用 VHDL 保留字或 verilog 保留字作为变量名
  • 顶层模块的输出信号必须被寄存(防止毛刺跑出芯片外)
  • 三态逻辑可以在顶层模块使用,子模块中避免使用三态
  • 没有未连接的端口
  • 到其它模块的接口信号,按如下顺序定义端口信号:输入、(双向)、输出
  • 建议使用 IP catalog 生成的乘法电路
  • 例化模块时使用命名端口连接而不要使用顺序端口连接,即显式写出要连接的端口名称。无需连接的端口留空而不要不写
  • 模块例化时,端口信号与连接信号隔开,并各自对齐。连接信号为向量时指明其位宽,方便阅读和调试。
  • 不要书写空的模块,即一个模块至少要有一个输入和一个输出
  • 敏感表中的表达式要用 “negedge " 或 "posedge " 的形式,而不要用 "clk" 或 " clk == 1'b1 " 的形式
  • 异步复位,高电平有效用 “If ( = 1'b1)",低电平有效用 "if ( = 1'b0)"
  • if 语句嵌套不能太多
  • 建议不使用 include 语句
  • 建议每个模块加 timescale(规避 timescale 根据编译顺序继承上一个文件可能导致的问题)
  • 代码中给出必要的注释
  • 每个文件有一个文件头,格式为文件名、设计者、日期、主要功能描述和更新记录
  • 每个文件只包含一个模块
  • 模块名和文件名保持一致
  • 定义模块时,端口类型和端口数据类型直接写在端口列表中

设计可靠性类

  • 操作数位宽不一致时建议用注释说明,避免自动扩展位宽带来隐患。有符号数扩展符号位,如果扩展出来的符号位和另一个操作数的数值部分相加了就会出问题
  • 组合 always 块中使用 case 语句必须加上 default 以规避综合出锁存器的风险。除非锁存器是刻意设计的,否则都意味着设计存在问题
  • 任何控制流语句,建议使用 beginend 关键字,编译器一般按就近原则编译,不加 beginend 关键字可能导致不安全的行为
  • 同步时序逻辑的 always block 中有且只有一个时钟信号,并且在同一个沿动作(如上升沿)
  • 同步时序逻辑的 module 中,在时钟信号的同一个沿动作
  • 采用同步设计,避免使用异步逻辑(全局异步复位除外)
  • 一般不要将时钟信号作为数据信号输入
  • 不要在时钟路径上添加任何 buffer,也不要门控时钟(会导致 EDA 把专用的时钟网络当作普通信号布线)
  • 在顶层模块中,时钟信号必须可见
  • 不要采用向量的方式定义一组时钟信号
  • 不要在模块内部生成时钟信号,使用 DLL/PLL 产生的时钟信号
  • 建议使用单一的全局同步复位电路或者单一的全部异步复位电路
  • 不要在复位路径上添加任何 buffer,也不要使用任何门控复位信号
  • 不使用 PLI 函数
  • 不使用事件变量
  • 不使用系统函数
  • 不使用用户自定义单元(UDP)
  • 不使用 disable 语句
  • 不使用 ===!=== 等不可综合的语句
  • 建议不使用 forever、repeat、while 等循环语句
  • 避免产生 latch(CPU 接口除外)
  • 组合逻辑语句块敏感表中的敏感变量必须和该块中使用的一致,不能多也不能少
  • 一个 always 语句中有且只能有一个敏感表
  • 在时序 always 块的敏感表中必须都是沿触发事件,不允许出现电平触发事件
  • 数据位宽要匹配
  • 不使用 real、time、realtime 类型,建议不使用 integer 类型
  • 移位变量必须是一个常数
  • 避免使用异步反馈环路
  • 组合 always 块中使用阻塞赋值 =,时序 always 块中使用非阻塞赋值 <=,不得混用(时序不易控制,容易得到意外结果)
  • 非阻塞型赋值不应加单位延时,尤其是对于寄存器类型的变量赋值时
  • 例化模块端口的位宽应与要连接的信号位宽保持一致,避免自动匹配可能造成的隐患
  • 时序要求、优化目标不同的信号路径需要拆分到不同的模块中。比如时序紧的关键路径和不追求速度的非关键路径就不要写在一个模块中。同一条组合路径上的代码写在同一个模块中
  • 一个 always 块中只更新一个 reg 变量;理论上来说,always块之间是并行的,always块内部是串行的,不过考虑到非阻塞赋值会先记录要更新的值,然后等敏感表中的条件满足时一起更新,所以这一条没有那么严格。

不常用规则类

  • 整型常量基数格式中不能有 “?”
  • 禁止使用空的时序电路块及非法的 always 结构
  • 不要在连续赋值语句中引入驱动强度和延时
  • 不要为 net、n_input、n_output、enable_gate 型变量定义驱动强度、电荷保持强度以及延时
  • 禁止使用 trireg(具有电荷保持特性连接)NET 型定义
  • 禁止使用 tri1、tri0、triand 和 trior 型的连接(net)
  • 在 RTL 级代码中不能含有 initial 结构,也不可以对任何信号进行初始化赋值,而应采用复位的方式进行初始化
  • 不要在过程块中使用 assign、deassign、force、release 等语句
  • 不要使用 wait 语句
  • 不要使用 fork-join 语句块
  • 不要为驱动类型为 supply0 和 supply1 型的连线(net)赋值
  • 设计中不使用 macro_module
  • 不要在 RTL 代码中实例门级单元,尤其是下列单元:CMOS 开关、RCMOS 开关、NMOS 开关、PMOS 开关、RNMOS 开关、RPMOS 开关、trans 双向开关、rtrans 双向开关、tranif0、tranifl、rtranif0、rtranifl、pull_gate
  • 不使用 specify 模块

参考资料

Verilog 语法

HDL Bits

HDL Bits 参考答案

VSCode 中的 Verilog 开发插件配置

wire 和 reg 区别

几种赋值语句

表达式位宽扩展

signed 和$signed( )用法

本文由作者按照 CC BY 4.0 进行授权
/body>