第八章 Verilog HDL语法

本章在讲什么

从这一章开始,我们正式进入"语法篇"。前面几章里,我们已经用 Vivado 跑通了第一个 LED 工程,但当时的代码更多是"照着抄"。要真正做到独立设计电路,就必须系统地掌握硬件描述语言——本教程使用的是 Verilog HDL

Verilog 是一种硬件描述语言(Hardware Description Language),它以文本形式描述数字系统硬件的结构和行为。它借鉴了 C 语言的部分语法形式(如 if/else、case 等控制语句),入门门槛不高——有 C 语言基础的读者,经过一个月左右的练习即可掌握。但它与 C 语言有本质区别

  • C 语言:软件语言,编译后在 CPU 上取址、译码、执行,是串行的指令流,不会生成任何硬件电路;
  • Verilog:硬件描述语言,综合下载到 FPGA 后,会生成真实的硬件电路,所有电路并行运行。

"并行执行"正是 FPGA 处理速度快的根本原因,也是初学者最需要转变的思维方式——写 Verilog 不是在"写程序",而是在"画电路"。

本章内容包括五大部分:

  1. Verilog 概述:Verilog 与 VHDL、C 语言的区别;
  2. Verilog 基础知识:逻辑值、标识符、数字进制、数据类型、运算符;
  3. Verilog 程序框架:注释、关键字、以 LED 流水灯为例的程序结构;
  4. Verilog 高级知识点:阻塞/非阻塞赋值、assign 与 always、latch、状态机、模块化设计;
  5. Verilog 编程规范:工程组织、文件头、命名、代码风格等。

核心概念拆解

8.1 Verilog 概述:为什么需要它

发展简史:Verilog 最初是 Gateway Design Automation 公司于 1983 年为其仿真器产品开发的硬件建模语言,1990 年面向公众发布,1995 年正式成为 IEEE 标准(IEEE Std 1364-1995,即 Verilog-95)。此后修正扩展为 IEEE Std 1364-2001(Verilog-2001),新增了敏感列表、多维数组、生成语句块、命名端口连接等实用功能,是目前最主流的版本,本教程代码全部遵循 Verilog-2001 语法。

为什么取代了原理图设计:早期 FPGA 设计常用原理图输入方式,直观但难以维护。随着电路规模增大,原理图方式无法满足项目需求,Verilog 凭借结构化描述能力、可复用性以及与 EDA 自动化工具的高度适配性,成为绝对主流。

Verilog 与 VHDL:两者都是 IEEE 标准硬件描述语言,共同点是都能形式化抽象表示电路行为与结构、支持层次化描述、支持高层到低层的综合转换、与实现工艺无关。区别在于 Verilog 语法类似 C、易学易用,拥有更广泛的设计群体(在美国高层次数字系统设计中 Verilog 与 VHDL 的使用率约为 80% vs 20%,国内 IC 设计公司也普遍采用 Verilog),因此本教程推荐学习 Verilog。

Verilog 与 C 语言:这是 FPGA 与单片机/CPU 的区别的本质——FPGA 全并行处理,速度极快;CPU 串行执行指令。理解了这一点,才算真正入了 FPGA 的门。

8.2 Verilog 基础知识

逻辑值:4 种状态

数字电路中,信号有四种逻辑值:

逻辑值 含义 对应电路状态
0 低电平 对应电路的 GND
1 高电平 对应电路的 VCC
X 未知 可能是高电平也可能是低电平
Z 高阻态 外部没有激励信号时处于悬空状态

图8.2.1 Verilog逻辑值

X 和 Z 这两个值在仿真调试中非常常见:信号未复位或未赋值时常表现为 X,信号悬空(比如未接的上拉/下拉)常表现为 Z,看到它们要能立刻联想到对应的电路状态。

标识符

标识符(Identifier)用于定义模块名、端口名和信号名等,可以是字母、数字、$ 和下划线 _ 的组合,但第一个字符必须是字母或下划线,并且区分大小写CountCOUNT 是两个不同的标识符)。

推荐写法:简洁、清晰、易懂,如 countfifo_wr。具体规范:

  • 用有意义的名字,如 sumcpu_addr
  • 用下划线区分单词组合;
  • 采用前缀/后缀约定:时钟用 clk 前缀(clk_50m),低电平有效用 _n 后缀(enable_n);
  • 普通内部信号全小写,参数全大写;
  • 自定义标识符不能与关键字同名。

数字进制格式

Verilog 支持二进制、八进制、十进制、十六进制,常用格式为 <位宽>'<进制><数值>

  • 二进制:4'b0101 表示 4 位二进制数 0101;
  • 十进制:4'd2 表示 4 位十进制数 2(即二进制 0010);
  • 十六进制:4'ha 表示 4 位十六进制数 a(即二进制 1010),十六进制计数为 0~9、a~f,f 对应十进制 15。

注意:当代码中没有指定位宽与进制时,默认为 32 位十进制,例如直接写 100,实际表示 32'd100

数据类型:三大类

真正对应硬件电路的是寄存器类型线网类型,参数类型则是编译期的常量。

1)寄存器类型(reg):表示一个抽象的数据存储单元,只能在 always 语句和 initial 语句中被赋值,值在两次赋值之间保持不变。有一个非常容易混淆的点:

  • 若 always 语句带时钟(描述时序逻辑),reg 变量综合为真正的寄存器
  • 若 always 语句不带时钟(描述组合逻辑),reg 变量综合后其实只是硬件连线

寄存器类型的缺省值是 x(未知)。最常用的是 reg 类型:

//reg define
reg  [31:0]  delay_cnt;    //延时计数器
reg          key_flag ;    //按键标志

2)线网类型(wire):表示结构化元件之间的物理连线,其值由驱动元件决定(如 assign 连续赋值或门的输出)。没有驱动元件连接时,缺省值为 z(高阻态)。最常用的是 wire 类型:

//wire define
wire         data_en;         //数据使能信号
wire  [7:0]  data   ;         //数据

3)参数类型(parameter):参数是一个常量,常用于定义状态机的状态编码、数据位宽和延迟大小等。由于可以在编译时(例化时)修改参数值,它常被用于"参数可调"的模块。参数定义是局部的,只在当前模块内有效:

//parameter define
parameter    DATA_WIDTH = 8;  //数据位宽为8位

运算符

Verilog 运算符按功能分为 7 类:

1)算术运算符+-*/%(取余)。注意:Verilog 实现乘除法(尤其是除法)非常浪费组合逻辑资源。2 的指数次幂的乘除法应使用移位运算完成;非 2 的指数次幂的乘除法一般调用工具提供的现成 IP(其底层仍由与或非门等组合逻辑搭建)。

2)关系运算符><>=<===!=,用于条件判断,关系为真返回 1,为假返回 0。优先级低于算术运算符。

3)逻辑运算符!(逻辑非)、&&(逻辑与)、||(逻辑或),用于连接多个关系表达式实现更复杂的判断。只有 a 和 b 都为 1 时 a && b 才为 1;a 或 b 有一个为 1 时 a || b 即为 1。

4)条件运算符a ? b : c——a 为真选 b,否则选 c,功能等同于 always 中的 if-else。

5)位运算符~(按位取反)、&(按位与)、|(按位或)、^(按位异或),直接对应数字逻辑中的与、或、非门等逻辑门,对两个信号的对应位逐一运算。

易混淆点:位运算符与逻辑运算符长得像但用途不同——逻辑运算符(&&||!)一般用在条件判断上,位运算符(&|~)一般用在信号赋值上。

6)移位运算符<<(左移)、>>(右移),移出的空位一律补 0。例如 8bit 的 a = 3(二进制 00000011),a << 2 后仍是 8bit,最高 2bit 被移出丢弃,结果为 00001100 即 12。一般用左移代替乘法、右移代替除法,但仅限 2 的指数次幂。

7)拼接运算符{a, b} 将两个或多个信号的某些位拼接起来作为一个新信号,这是 C 语言中没有的特殊运算符。

优先级(从高到低):!~*/%+-<<>><<=>>===!=&^|&&||?(最低)。记不住也没关系,写代码时多用括号明确优先级即可。

8.3 Verilog 程序框架

注释与关键字

注释有两种:/* ... */ 块注释(可跨多行)和 // 行注释。建议统一使用 //

关键字是语言保留字,如 moduleinputoutputregwirealwaysassignbeginendposedge/negedgecaseif/else 等。注意:只有小写的关键字才是保留字always 是关键字而 ALWAYS 不是。

常用关键字速查:

关键字 含义 关键字 含义
module / endmodule 模块开始/结束定义 assign 产生 wire 信号语句的关键字
input / output / inout 输入/输出/双向端口 begin / end 语句的起始/结束标志
parameter 参数定义 posedge / negedge 时序电路的标志(上升沿/下降沿)
wire / reg 线网/寄存器信号定义 case / default / endcase case 语句起止与默认分支
always 产生 reg 信号语句的关键字 if / else 条件分支

程序框架:以 LED 流水灯为例

module led(
    input               sys_clk  ,  //系统时钟
    input               sys_rst_n,  //系统复位,低电平有效
    output  reg  [3:0]  led         //4位LED灯
    );

//parameter define
parameter  WIDTH     = 25        ;
parameter  COUNT_MAX = 25_000_000;  //板载50M时钟=20ns,0.5s/20ns=25000000,需要25bit位宽

//reg define
reg    [WIDTH-1:0]  counter     ;
reg    [1:0]        led_ctrl_cnt;

//wire define
wire                counter_en  ;

//***********************************************************************************
//**                                 main code
//***********************************************************************************

//计数到最大值时产生高电平使能信号
assign  counter_en = (counter == (COUNT_MAX - 1'b1))  ?  1'b1  :  1'b0;

//用于产生0.5秒使能信号的计数器
always @(posedge sys_clk or negedge sys_rst_n) begin
    if (sys_rst_n == 1'b0)
        counter <= 1'b0;
    else if (counter_en)
        counter <= 1'b0;
    else
        counter <= counter + 1'b1;
end

//led流水控制计数器
always @(posedge sys_clk or negedge sys_rst_n) begin
    if (sys_rst_n == 1'b0)
        led_ctrl_cnt <= 2'b0;
    else if (counter_en)
        led_ctrl_cnt <= led_ctrl_cnt + 2'b1;
end

//通过控制IO口的高低电平实现发光二极管的亮灭
always @(posedge sys_clk or negedge sys_rst_n) begin
    if (sys_rst_n == 1'b0)
        led <= 4'b0;
    else begin
        case (led_ctrl_cnt)
            2'd0 : led <= 4'b0001;
            2'd1 : led <= 4'b0010;
            2'd2 : led <= 4'b0100;
            2'd3 : led <= 4'b1000;
            default : ;
        endcase
    end
end

endmodule

逐段解读这段代码的"骨架":

  • 模块定义:以 module 开始、endmodule 结束;
  • 端口定义:紧跟模块名后的括号内声明输入/输出信号(本例输入为时钟和低有效复位,输出为 4 位 led);
  • 参数定义parameter 放在端口之后,方便灵活调整计数最大值、位宽等(25_000_000 中的下划线只是为了增强可读性);
  • reg/wire 定义:集中声明,各司其职;
  • main code 注释分隔线:不添加也不会报错,但作为良好的编程规范推荐添加;
  • assign 语句:组合逻辑,用条件运算符产生计数使能;
  • always 语句always @(posedge sys_clk or negedge sys_rst_n) 表示时钟上升沿或复位下降沿触发;begin/end 是语句块起止;<= 是非阻塞赋值(稍后详解);caseendcase 结束、以 default 兜底。

begin/end 什么时候必须加:if 条件后只有一条赋值语句时,加不加都可以(推荐不加,更简洁);超过一条赋值语句时必须加

//一条语句:两种写法皆可,推荐前者
if(en == 1'b1)
    a <= 1'b1;

//多条语句:必须加begin/end
if(en == 1'b1) begin
    b <= 1'b1;
    c <= 1'b1;
end

8.4 Verilog 高级知识点

阻塞赋值与非阻塞赋值

这是 Verilog 最重要的概念之一,也是初学者最容易出问题的地方。

阻塞赋值(Blocking,符号 =:在同一个 always 块中,后面的语句受前面语句影响——一条阻塞赋值语句没有执行结束,后面的语句就不能执行,即被"阻塞"。begin/end 之间的语句是顺序执行的串行语句,这一点和 C 语言很像。

阻塞赋值的执行可看作一个步骤:计算 RHS(等号右边)的值并立即更新 LHS(等号左边),期间不允许其他语句干扰。

我们用一段时序逻辑下的阻塞赋值来看效果——功能要求:复位时 a=1、b=2、c=3;不复位时 a 清零,并把 a 赋给 b、b 赋给 c:

图8.4.1 阻塞赋值代码

图8.4.2 阻塞赋值的信号波形图

从波形图看:复位时 a=1、b=2、c=3;结束复位后,时钟上升沿到来(波形图中 2 时刻)时,a=0、b=0、c=0。因为阻塞赋值先执行 a=0,执行完后才把 a 的值(已经是 0)赋给 b,再把 b 的值(已经是 0)赋给 c——一条接一条串行完成。

非阻塞赋值(Non-Blocking,符号 <=:由时钟节拍决定。时钟上升沿到来时,先计算所有语句的 RHS,然后 begin/end 之间的所有赋值语句同时更新 LHS——一个时钟只执行一次,属于并行执行。这是与 C 语言最大的差异点。

非阻塞赋值可看作两个步骤

  1. 赋值开始时,计算 RHS;
  2. 赋值结束时,更新 LHS。

"非阻塞"指的是:在计算 RHS 和更新 LHS 期间,允许其他非阻塞赋值语句同时计算 RHS 和更新 LHS。

同样的功能用非阻塞赋值实现:

图8.4.3 非阻塞赋值代码

图8.4.4 非阻塞赋值的信号波形图

从波形图看:复位时同样是 a=1、b=2、c=3;结束复位后时钟上升沿到来时,a=0、b=1、c=2——与阻塞赋值结果完全不同!因为三条语句的 RHS(0、a、b,即 0、1、2)是同时计算的、同时更新 LHS 的,b 和 c 拿到的是"旧值"。

使用场景总结(务必牢记):

  • 描述组合逻辑时使用阻塞赋值 =——如 assign 语句、不带时钟的 always 语句:
//示例1:assign赋值语句
assign  data  =  (data_en == 1'b1)  ?  8'd255  :  8'd0;

//示例2:不带时钟的always语句
always @(*) begin
    if (en) begin
        a = a0;
        b = b0;
    end
    else begin
        a = a1;
        b = b1;
    end
end
  • 描述时序逻辑时使用非阻塞赋值 <=——如带时钟的 always 语句:
//示例3:带时钟的always语句
always @(posedge sys_clk or negedge sys_rst_n) begin
    if (!sys_rst_n) begin
        a <= 1'b0;
        b <= 1'b0;
    end
    else begin
        a <= c;
        b <= d;
    end
end

assign 和 always 的区别

两者都是 Verilog 的基本语句,关键差异在"带不带时钟":

  • assign 不能带时钟,只能产生组合逻辑;
  • always 既可以不带时钟也可以带时钟。不带时钟时(always @(*)),逻辑功能与 assign 完全一致,都是纯组合逻辑(虽然信号还定义成 reg 类型,但综合出来只是连线)。

经验法则:简单的组合逻辑用 assign,复杂的组合逻辑(如多层 if/case 嵌套)用不带时钟的 always;带时钟的 always 产生的才是真正的寄存器(时序逻辑)。

什么是 latch(锁存器)

latch 是电平敏感的存储单元,而寄存器是边沿触发的存储单元,两者都能存储数据,但来源不同:锁存器由组合逻辑意外产生,寄存器由时钟触发产生于时序电路。

latch 的主要危害是产生毛刺(glitch),对下一级电路非常危险,且隐蔽性强、不易查出,所以设计中应尽量避免 latch

产生 latch 的原因:在不带时钟的 always 组合逻辑中,if 缺少 else 分支、或 case 缺少 default 分支,导致信号在某些输入条件下"保持原值",综合器只能生成锁存器来实现这种保持。

解决办法很简单:if 必须带 else,case 必须带 default

图8.4.5 带时钟的always语句(左)和不带时钟的always语句(右)

图8.4.6 缺少else的带时钟的always语句电路图

图8.4.7 缺少else的不带时钟的always语句电路图

对比上面两张综合电路图可以直观看到:缺少 else 的带时钟 always 综合出的 q 仍是普通的时钟寄存器(图 8.4.6);而缺少 else 的不带时钟 always 综合出的电路中出现了一个 latch(图 8.4.7)。这一点要特别注意——只有不带时钟的 always 才会因 if/case 不完整产生 latch

状态机(FSM)

硬件电路是并行执行的,当需要按照流程或步骤完成某个功能时,如果用大量 if 嵌套来实现,代码复杂度会陡增、可读性会变差。这时就该用状态机了:它把一项功能的完成分解为若干步,每一步对应一个状态,通过预先设计的顺序在各状态之间转换,状态转换的过程就是实现逻辑功能的过程。

有限状态机(Finite State Machine,FSM)是一种在有限个状态之间按一定规律转换的时序电路,可视为组合逻辑与时序逻辑的组合。复杂流程控制(如 SDRAM 控制器)几乎都用状态机实现。

两大类模型(按输出是否与输入相关划分):

  • Mealy(米勒)型状态机:输出不仅取决于当前状态,还取决于输入信号;
  • Moore(摩尔)型状态机:输出只取决于当前状态,与输入无关。

三种实现写法

  • 一段式:整个状态机写在一个 always 模块里,既描述状态转移又描述输入输出。不推荐——组合逻辑与时序逻辑混在一起,不利于代码维护、修改和约束;
  • 二段式:两个 always 模块,一个用同步时序描述状态转移,另一个用组合逻辑判断转移条件并描述输出。需要定义现态和次态两个状态;
  • 三段式:三个 always 模块——第一段同步时序描述状态转移,第二段组合逻辑判断转移条件,第三段描述状态输出(可用组合电路或时序电路输出)。

实际应用中三段式状态机使用最多:它把组合逻辑和时序逻辑分开,有利于综合器分析优化和程序维护;又把状态转移与状态输出分开,代码更清晰易读。

三段式状态机的基本格式:

  1. 第一个 always:同步状态跳转;
  2. 第二个 always:组合逻辑判断状态转移条件;
  3. 第三个 always:描述状态输出。

编写状态机前一般先画出状态跳转图,思路会清晰很多。下面以 7 分频为例(实际分频功能未必需要状态机,此处仅演示写法):

图8.4.10 七分频状态跳转图

状态编码采用独热码(每个状态只有一位为 1,也可以直接用十进制 0~7 编码),随后定义当前状态和下一个状态两个寄存器:

parameter S0 = 7'b0000001;     //独热码定义方式
parameter S1 = 7'b0000010;
parameter S2 = 7'b0000100;
parameter S3 = 7'b0001000;
parameter S4 = 7'b0010000;
parameter S5 = 7'b0100000;
parameter S6 = 7'b1000000;

reg  [6:0]   curr_st     ;     //当前状态
reg  [6:0]   next_st     ;     //下一个状态

完整的三段式状态机代码如下:

module divider7_fsm (
    //系统时钟与复位
    input       sys_clk      ,
    input       sys_rst_n    ,

    //输出时钟
    output reg  clk_divide_7
    );

//parameter define
parameter S0 = 7'b0000001;     //独热码定义方式
parameter S1 = 7'b0000010;
parameter S2 = 7'b0000100;
parameter S3 = 7'b0001000;
parameter S4 = 7'b0010000;
parameter S5 = 7'b0100000;
parameter S6 = 7'b1000000;

//reg define
reg  [6:0]   curr_st     ;     //当前状态
reg  [6:0]   next_st     ;     //下一个状态

//*****************************************************
//**                    main code
//*****************************************************

//状态机的第一段采用同步时序描述状态转移
always @(posedge sys_clk or negedge sys_rst_n) begin
        if (!sys_rst_n)
            curr_st <= S0;
        else
            curr_st <= next_st;
end

//状态机的第二段采用组合逻辑判断状态转移条件
always @(*) begin
    case (curr_st)
        S0: next_st = S1;
        S1: next_st = S2;
        S2: next_st = S3;
        S3: next_st = S4;
        S4: next_st = S5;
        S5: next_st = S6;
        S6: next_st = S0;
        default: next_st = S0;
    endcase
end

//状态机的第三段描述状态输出(这里采用时序电路输出)
always @(posedge sys_clk or negedge sys_rst_n) begin
    if (!sys_rst_n)
        clk_divide_7 <= 1'b0;
    else if ((curr_st == S0) | (curr_st == S1) | (curr_st == S2) | (curr_st == S3))
        clk_divide_7 <= 1'b0;
    else if ((curr_st == S4) | (curr_st == S5) | (curr_st == S6))
        clk_divide_7 <= 1'b1;
    else
        ;
end

endmodule

代码要点解读:

  • 第一段(同步时序):复位时回到 S0 状态,否则每个时钟沿把 next_st 更新到 curr_st——对应 Moore 模型中的"状态寄存器";
  • 第二段(组合逻辑):根据当前状态判断下一状态——对应"产生下一状态的组合逻辑 F"。实际应用中一般根据输入条件决定跳转或保持,case 末尾的 default 用于防止状态机跑飞到异常状态;
  • 第三段(状态输出):推荐使用时序电路输出而非组合电路输出,因为同步时序设计能提高稳定性、消除毛刺。采用时序输出的模型在输出组合逻辑 G 后再增加一级输出寄存器。

图8.4.11 状态机时序电路输出模型

时序输出的好处:有效滤除组合逻辑输出的毛刺;更利于时序计算与约束;对总线形式的输出,更容易使总线数据对齐、减小偏移,降低接收端采样出错概率。

从代码可以看出,输出 clk_divide_7 只与当前状态 curr_st 有关、与输入无关,所以这个状态机属于 Moore 型

模块化设计与模块例化

模块化设计是 FPGA 设计中极为重要的技巧:不同团队可专注各自的子模块(分工协作);可对单个子模块精准仿真调试;修改某一子模块不影响其他模块(维护升级方便)。划分模块的基本原则是:子模块功能相对独立、模块内部联系尽量紧密、模块间的连接尽量简单

复杂系统一般采用自顶向下的设计方式:系统划分成几个功能模块,每个功能模块再划分成下一层子模块,每个模块对应一个 module、一个 Verilog 文件。顶层模块一般只做例化(调用其他模块),不做逻辑

图8.4.12 模块化设计概念框图

模块例化(模块调用/实例化)是连接子模块与高层模块的手段,既简化每个模块的代码,又便于后期维护。以静态数码管为例:驱动数码管静态显示需要两个模块——计时模块(time_count)和数码管静态显示模块(seg_led_static),再加一个顶层模块:

图8.4.13 静态数码管显示模块框图

两个子模块的端口定义(节选):

//计时模块
module time_count(
    input           clk     ,   // 时钟信号
    input           rst_n   ,   // 复位信号
    output   reg    flag        // 一个时钟周期的脉冲信号
);
//parameter define
parameter  MAX_NUM = 25000_000; // 计数器最大计数值
……
endmodule

//数码管静态显示模块
module seg_led_static (
    input               clk     ,   // 时钟信号
    input               rst_n   ,   // 复位信号(低有效)
    input               add_flag,   // 数码管变化的通知信号
    output  reg  [5:0]  sel     ,   // 数码管位选
    output  reg  [7:0]  seg_led     // 数码管段选
);
……
endmodule

顶层模块对两个子模块进行例化:

module seg_led_static_top (
    input               sys_clk  ,       // 系统时钟
    input               sys_rst_n,       // 系统复位信号(低有效)
    output    [5:0]     sel      ,       // 数码管位选
    output    [7:0]     seg_led          // 数码管段选
);

//parameter define
parameter  TIME_SHOW = 25'd25000_000;    // 数码管变化的时间间隔0.5s

//wire define
wire       add_flag;                     // 数码管变化的通知信号

//*****************************************************
//**                    main code
//*****************************************************

//例化计时模块
time_count #(
    .MAX_NUM    (TIME_SHOW)
) u_time_count(
    .clk        (sys_clk  ),
    .rst_n      (sys_rst_n),

    .flag       (add_flag )
);

//例化数码管静态显示模块
seg_led_static u_seg_led_static (
    .clk        (sys_clk  ),
    .rst_n      (sys_rst_n),

    .add_flag   (add_flag ),
    .sel        (sel      ),
    .seg_led    (seg_led  )
);

endmodule

例化的两个关键点:

  1. 端口例化.子模块端口(顶层信号) 一一对应连接,对应信号的位宽必须保持一致;例化模块名一般命名为 u_ + 子模块名(同一模块可被例化多次,用不同例化名区分,如 u_u1_);
  2. 参数例化(参数传递):在模块名后加 # 跟参数列表,如上例中顶层用 TIME_SHOW 覆盖了子模块的 MAX_NUM。参数例化后,子模块内该参数的实际值以顶层传递的为准;若不例化参数,则使用子模块内部定义的值。

补充两个相关语法:

  • localparam:本地参数,用法与 parameter 基本一致,区别是 localparam 不能被上层模块例化,适合定义模块内部使用的常量;
  • 顶层模块例化子模块时,只需要知道子模块的端口信号名,不需要关心其内部实现——这正是模块化设计"接口先行"思想的体现。

8.5 Verilog 编程规范

良好的编程规范是 FPGA 工程师的必备素质:多人协作时需要互相检视代码,没有统一规范,检视、维护、重用的工作量不可想象;即使是自己写的代码,几个月后再看也会变得陌生。规范能让新人尽快融入项目,也让别人更容易看懂你的代码。

工程组织形式:一个工程目录一般包含四部分:

XX工程名
|--doc    //存放工程相关文档:datasheet(数据手册)、设计方案等
|--prj    //存放工程文件和用到的IP文件
|--rtl    //存放工程rtl代码(工程核心),文件名与module名一致,按模块层次分开存放
|--sim    //存放工程仿真代码,复杂工程的仿真不可或缺,能极大减少调试工作量

文件头声明:每个 Verilog 文件开头必须有版权、作者、创建日期、内容介绍等声明文字。建议一个 .v 文件只包含一个 module,保持清晰易懂。

输入输出定义:推荐采用 Verilog-2001 语法格式(端口直接写在 module 括号内),建议一行只定义一个信号、信号全部对齐、同一组信号放在一起。

parameter/wire/reg 定义:parameter 紧跟端口定义之后、常量命名全大写;wire/reg 集中定义、同功能信号放在一起、信号对齐、位宽用降序 [6:0]、时钟用 clk 前缀、复位用 rst 后缀、不能用关键字作信号名、一行只定义一个信号。

信号命名:命名要体现意义(如 fifo_wr_en 表示 FIFO 写使能);用 _ 分隔单词(如 sys_clk);内部信号全小写、模块名小写;低电平有效用 _n 后缀、异步信号用 _a 后缀、纯延迟打拍信号用 _dly 后缀。

always 块:一个 always 配一对 begin/end(begin 与 always 同行);always 前加注释;时钟复位触发统一写 posedge sys_clk or negedge sys_rst_n;一个 always 块只包含一个时钟和复位;时序逻辑用非阻塞赋值

assign 块:逻辑不能太复杂(否则可读性差);assign 前加注释;组合逻辑用阻塞赋值

其他规范:不用 TAB 全用空格(不同解释器对 TAB 翻译不一致);注释清晰简洁不废话、统一用 //、注释对齐;核心代码和信号定义之间需要增加注释;模块例化用 u_xx 命名。

其他注意事项

  • 代码写得越简单越好,方便他人阅读和理解;
  • 不使用 repeat 等循环语句;
  • RTL 级代码不使用 initial 语句(仿真代码除外);
  • 避免产生 latch:组合逻辑里的 if 必须带 else、case 必须带 default;
  • 避免使用太复杂和少见的语法,否则可能降低综合器优化力度。

初学者容易踩的坑

  1. 用 C 语言的串行思维理解 Verilog:begin/end 之间用阻塞赋值时才是顺序关系,带时钟 always 里的非阻塞赋值是并行同时发生的。b <= a 拿到的是"旧"的 a。时刻提醒自己:写的是电路,不是程序。
  2. 阻塞/非阻塞赋值混用或用反:口诀"组合逻辑用 =,时序逻辑用 <="。在带时钟的 always 里用 =,或者在同一个 always 里混用两种赋值,都会造成仿真与综合行为不一致的隐蔽 bug。
  3. reg 类型被误认为一定是寄存器:不带时钟的 always 中定义的 reg,综合出来只是组合逻辑连线。判断标准是看 always 带不带时钟,而不是看类型声明。
  4. 组合逻辑 if 缺 else、case 缺 default,产生 latch:latch 带来的毛刺很难排查。养成习惯——不带时钟的 always 里 if 必配 else、case 必配 default(哪怕分支内容为空语句)。
  5. 例化时位宽不匹配:模块端口与顶层连接信号的位宽必须一致,否则会出现隐式的截位/扩展,行为难以预期。
  6. 记不住运算符优先级又不加括号:不同类型运算符混用时务必用括号明确优先级,避免歧义。
  7. 状态机没画状态跳转图就动手写代码:先画图后写码,思路清晰不易漏状态;case 记得写 default,防止状态机进入异常状态后无法恢复。

小结 & 下一步

本章是全书语法篇的核心,覆盖了 Verilog 从基础到进阶的完整知识链:

  • 四个逻辑值(0/1/X/Z)、标识符命名规则、进制表示(默认 32 位十进制);
  • 三大数据类型:reg(时序为寄存器、组合为连线)、wire(物理连线)、parameter(可例化的常量);
  • 七类运算符,重点是位运算与逻辑运算的区别、移位代替 2 的幂次乘除、拼接运算符 {};
  • 程序框架:module 端口 + parameter + reg/wire + assign/always;
  • 核心难点:阻塞赋值(组合、串行)与非阻塞赋值(时序、并行)的波形级差异;assign 与 always 的分工;latch 的成因(组合逻辑 if/case 不完整)与规避;
  • 状态机:Mealy/Moore 两大模型,一段式/二段式/三段式三种写法,重点掌握三段式(时序跳转 + 组合译码 + 时序输出);
  • 模块化设计:顶层只做例化不做逻辑,掌握端口例化与参数例化(#)语法;
  • 编程规范:doc/prj/rtl/sim 工程组织、命名约定、代码风格,是一个专业 FPGA 工程师的基本素养。

语法规则只有落到代码里才能内化。下一步进入第九章"数字电路基础",补齐进制转换、逻辑门、组合/时序逻辑等硬件设计的理论地基,之后我们就可以开始一个个真正的外设实验了。


本笔记基于正点原子《领航者 ZYNQ 之 FPGA 开发指南》第八章整理,图片版权归原厂所有。