Featured image of post Verilog语法教程

Verilog语法教程

Verilog基础入门语法。

Verilog 语法教程

Verilog HDL(简称 Verilog)是一种硬件描述语言(Hardware Description Language, HDL),用于数字电路的系统设计。它能在算法级、门级、开关级等多种抽象层次上对硬件进行建模。

阅读本教程前,你需要了解基本的数字电路知识,包括组合逻辑、时序逻辑与时钟/复位的概念。

如果想要动手练习 Verilog,建议在 HDLBits 网站进行练习。

一个完整例子

下面先看一个完整的计数器模块,再逐块拆解它用到的语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
module counter10(
    input         rstn,        // 复位端,低有效
    input         clk,         // 输入时钟
    output [3:0]  cnt,         // 4 位计数输出
    output        cout         // 溢出进位
);

    reg [3:0] cnt_temp;        // 计数寄存器

    always @(posedge clk or negedge rstn) begin
        if (!rstn)
            cnt_temp <= 4'b0;                       // 复位时归零
        else if (cnt_temp == 4'd9)
            cnt_temp <= 4'b0;                       // 计到 9 后归零
        else
            cnt_temp <= cnt_temp + 1'b1;            // 否则加 1
    end

    assign cout = (cnt_temp == 4'd9);  // 进位:计到 9 时拉高
    assign cnt  = cnt_temp;            // 实时计数输出
endmodule

一、module 与文件结构

一个 Verilog 设计由若干 module(模块) 组成,每个模块用 module ... endmodule 包裹:

  • module 后跟模块名端口列表endmodule 表示模块结束(结尾没有分号)。
  • 模块名应使用有意义的英文,不能是 Verilog 关键字,也不能含空格。
  • 工程中通常一个 .v 文件只放一个 module,且文件名与模块名保持一致,方便工具管理。
  • 模块内部由**端口声明、数据类型定义、连续赋值(assign)、过程块(always)**等部分构成。
1
2
3
4
5
6
module 模块名 ( 端口列表 );
    // 端口声明
    // 信号/寄存器定义
    // assign 连续赋值
    // always 过程块
endmodule

注释

Verilog 支持两种注释,与 C 语言相同:

  • 行注释 //:从 // 到本行行尾。
  • 块注释 /* ... */:可跨越多行。
1
2
3
4
// 这是一行注释
reg [3:0] cnt;   // 行尾也可以写注释
/* 这是
   跨行块注释 */

注释会被综合器忽略,仅用于提升可读性。建议对关键信号含义、状态编码、时序/约束意图加上注释。

二、端口定义

端口是模块与外部电路的接口,必须声明方向与(可选的)类型/位宽

1. 三种方向

方向 含义 典型用途
input 模块输入,由外部驱动 时钟、复位、数据输入
output 模块输出 数据/状态输出
inout 双向(三态) 开漏总线,如 IIC 的 sda/scl

2. 端口默认是 wire

端口声明时若只写方向(不写 reg),默认类型为 wire。因此:

  • input 永远是外部驱动的 wire,不能被 always 赋值。
  • output 若要在 always 块里被赋值,必须显式声明为 reg(即 output reg);若仅由 assign 驱动则保持 wire

3. 两种声明风格

现代 ANSI 风格(推荐):方向与类型直接写在端口列表中,直观不易出错。

1
2
3
4
5
6
7
8
module counter10(
    input         rstn,
    input         clk,
    output [3:0]  cnt,      // 默认 wire,用 assign 驱动
    output        cout      // 默认 wire
);
    // ...
endmodule

传统非 ANSI 风格:端口列表只列名字,类型/方向再到模块体内声明(旧代码常见,但容易遗漏):

1
2
3
4
5
6
7
module counter10(rstn, clk, cnt, cout);
    input  rstn;
    input  clk;
    output [3:0] cnt;
    output       cout;
    // ...
endmodule

三、数据类型:wire 与 reg

Verilog 中最常用的两种变量类型是 线网(wire)寄存器(reg)

1. wire(线网类型)

顾名思义表示一根"导线",自身不能存储状态,必须由外部驱动才能呈现 0 或 1(驱动源可以是 assign、模块端口或基本门)。常用于组合逻辑的输出与模块间的连线。

1
2
3
wire        interrupt;
wire        flag1, flag2;
wire        gnd = 1'b0;      // 常接地的线

2. reg(寄存器类型)

reg 类型变量可以在过程块(always中被赋值。从硬件角度看,它名字里的"寄存器"容易误导——它不一定综合成真正的触发器,最终变成什么电路,完全取决于它写在哪种 always 里:

  • 写在 always @(posedge clk) 里 → 综合成 D 触发器(Flip-Flop):每位都是一个小存储单元,只在时钟上升沿"啪"地记住新值,平时保持不动。这是 reg 最常见的硬件形态。
  • 写在 always @(*) 里 → 综合成组合门电路(如与门、或门、选择器),没有记忆,输出随输入即时变化。此时 reg 只是"过程中暂存变量",并不是真正的寄存器。
  • 若分支没写全(缺 else / default)→ 综合器为"保持原值"会悄悄生成锁存器(latch),一般要尽量避免。

一句话:reg 在时序块里 = 触发器(有记忆),在组合块里 = 组合门(无记忆),关键在于它所在的 always

1
2
3
reg         clk_temp;
reg         flag1, flag2;
reg  [3:0]  counter;

简单记忆:assign 驱动的用 wirealways 块里被赋值的用 reg

3. signed 有符号类型(仅介绍)

入门阶段极少用到,这里只做概念性介绍,不要求掌握,知道有这回事即可。

默认情况下 wire / reg无符号的,算术与比较都按无符号处理。在类型后加 signed 关键字可声明为有符号(二进制补码),此时算术、关系运算以及 >>> 算术右移都会按正负来解释:

1
2
3
4
reg signed [7:0] a;     // 有符号 8 位,范围 -128 ~ +127
reg        [7:0] b;     // 无符号 8 位,范围 0 ~ 255

a = -8'sd10;            // 注意:字面量也要带 s 才是"有符号字面量"

要点:有符号只改变"如何解释这些 bit"与运算规则;线网/寄存器里存的仍是普通 bit。当有符号与无符号操作数混用时,结果按无符号处理,需要特别小心。

四、位宽与向量

1. 标量 vs 向量

从硬件角度看,Verilog 里的一个信号其实就是一根导线;指定位宽后,它就变成一捆并排的多根导线,这捆"导线束"就叫做向量(vector)

  • 标量(scalar):没有指定位宽,本质是 1 根导线,只能表示 0 或 1 一个 bit。
  • 向量(vector):位宽大于 1,本质是 N 根并排的导线,可一次性承载 N 个 bit,做多位运算([3:0] 就是 4 根导线,编号 0~3)。
1
2
3
wire        a;          // 1 根导线(1 位标量)
reg  [3:0]  counter;    // 4 根并排导线(4 位向量),位宽 = 3-0+1 = 4
wire [31:0] data;       // 32 根并排导线(32 位向量/总线)

可以想象 wire [31:0] data; 在电路里并不是一根"粗线",而是 data[0]data[1]data[31] 共 32 根独立的导线捆在一起,平时统一叫 data 来批量操作。这种"多根导线"的视角,对理解后面的位选、拼接、移位都很关键。

2. 向量声明与高低位

向量格式为 [msb : lsb](高位在前、低位在后),位宽 = msb - lsb + 1

1
2
3
reg  [3:0]   counter;     // 大端:bit3 为最高位 MSB,bit0 为最低位 LSB
wire [8:2]   addr;        // 7 位向量,编号从 8 到 2
reg  [0:31]  data;        // 小端:bit0 为 MSB(不推荐,易混淆,尽量统一用 [msb:lsb])

3. 位选与域选

可以对向量做单 bit 选择连续段选择

1
2
counter[0]        // 取最低位
counter[3:1]      // 取 bit3、bit2、bit1 共 3 位

4. 拼接与复制

  • 拼接运算符 {}:把多个信号拼成更宽的总线。
  • 复制 {n{ }}:把某值重复 n 次。
1
2
wire [7:0] bus = {cnt, 4'b0000};     // 把 4 位 cnt 拼到高 4 位,低 4 位补 0
wire [3:0] zero = {4{1'b0}};         // 等价于 4'b0000

5. 向量的数组(存储器)

前面说的 reg [3:0] counter 是一个"4 位宽的向量"。如果再加一对方括号,就声明了一组相同宽度的向量,也就是存储器(memory) —— 常用于寄存器堆、RAM、FIFO 等:

1
2
3
reg [7:0] byte1 [3:0];    // 声明一个"4 个字、每字 8 位"的存储器
                           // 左侧 [7:0] = 每个字的位宽
                           // 右侧 [3:0] = 字的个数(地址范围 0~3)

读法要分清两个维度:

  • 左侧 [7:0] 是"每个元素的位宽"(数据宽度)。
  • 右侧 [3:0] 是"元素的个数/地址范围"(深度)。
1
2
3
byte1[0]           // 取第 0 个字(一个 8 位向量)
byte1[2][7:0]      // 取第 2 个字的全部 8 位
byte1[addr][3]     // 取第 addr 个字的第 3 位

注意几个常见限制:

  • 不能一次性对整个存储器赋值,只能按字逐一赋值(通常写在带下标的 always 块里):
    1
    2
    3
    4
    
    integer i;
    always @(posedge clk) begin
        if (wen) byte1[waddr] <= wdata;  // 只写某一个字
    end
    
  • 存储器元素(byte1[addr])在传统的连续赋值 assign 中不能直接整体驱动,一般放在 always 块里用 reg 描述,或用 for 循环初始化。
  • wire 也可以声明成数组(如 wire [7:0] bus [3:0]),但在可综合设计中存储器几乎都用 reg

五、数值的表示方式

1. 四种逻辑值

Verilog 用下列四种基本值表示硬件电平:

含义
0 逻辑 0 或"假"
1 逻辑 1 或"真"
x / X 未知(未初始化 / 多驱动冲突)
z / Z 高阻(常用于 inout 三态/开漏释放)

2. 基数格式

数字语法为 <位宽>'<基数><数值>,合法的基数有四种:

  • 二进制 'b / 'B
  • 十进制 'd / 'D
  • 十六进制 'h / 'H
  • 八进制 'o / 'O

为严谨,实际编码中一般始终指定位宽

1
2
3
4'b1011             // 4 位二进制
32'h3022_c0de       // 32 位十六进制,下划线仅便于阅读、无实际意义
4'd9                // 4 位十进制 9

3. 不定宽、负数与实数

  • 不指定位宽时,编译器通常按宿主字长(常见 32 位)处理:counter = 'd100;counter = 100;
  • 负数在位宽之前加减号:counter = -6'd15; 是合法的;- 放在数值之后(如 4'd-2)则非法。
  • Verilog 还有 real 实数类型,支持科学计数法(如 1.2e41E-3),但不参与综合,只用于测试/建模。

六、运算符与基础计算

Verilog 提供丰富的运算符。下面按"算术前先补一句:所有运算都在上按位宽进行,结果位宽不够会截断高位、不够会补符号/零"来分类介绍最常用的几类。

1. 算术运算符

运算符 含义 说明
+ 按位宽相加,溢出则截断高位
- 按位宽相减
* 综合为乘法器,结果位宽通常为两操作数位宽之和
/ 综合资源开销大,尽量少用(2 的幂建议用移位代替)
% 取模 /,综合代价高,慎用
1
2
3
4
5
6
reg [7:0] a, b;
reg [8:0] sum;        // 进位位宽多留 1 位
reg [15:0] prod;

sum  = a + b;         // 8 位 + 8 位,结果用 9 位接住进位
prod = a * b;         // 8×8 = 16 位

注意:Verilog 中的 + - *无符号按位宽计算;若操作数带 signed 声明则为有符号运算。位宽不匹配时,窄的操作数会零扩展(无符号)或符号扩展(有符号)到宽的位宽后参与运算。

2. 移位运算符

运算符 含义 说明
<< 逻辑左移 右侧补 0,等价于 ×2ⁿ
>> 逻辑右移 左侧补 0,等价于 ÷2ⁿ
<<< 算术左移 有符号场景下同逻辑左移
>>> 算术右移 有符号时左侧补符号位(保持正负)
1
2
3
4
reg [7:0] x;
x = x << 1;           // 左移 1 位(乘 2),低位补 0
x = x >> 2;           // 右移 2 位(除 4),高位补 0
x = x <<< 1;          // 算术左移

硬件视角:移位在综合后几乎不消耗逻辑资源——它并不是"计算",而是把导线重新连了一下。例如 x << 1 综合出来就是:把 x[6:0] 接到输出 [7:1]、输出最低位 [0] 接地(补 0)。正因为只是"改接线",所以比乘除法器便宜得多。

因此,对 2 的整数次幂的乘除,优先用移位实现,比 /* 更省逻辑资源。例如 addr * 4 写成 addr << 2

3. 位运算与归约运算符

运算符 含义 说明
~ 按位取反 每一位取反
& 按位与 对应位相与
| 按位或 对应位相或
^ 按位异或 不同为 1
~^ / ^~ 同或 相同为 1
&(单目) 归约与 把所有位"与"起来,得 1 位
|(单目) 归约或 把所有位"或"起来,得 1 位
^(单目) 归约异或 奇偶校验常用
1
2
3
4
5
6
reg [3:0] v;
reg       all_one, any_one, parity;

all_one  = &v;        // 归约与:v 全 1 时为 1
any_one  = |v;        // 归约或:v 任意位为 1 时为 1
parity   = ^v;        // 归约异或:v 中 1 的个数为奇数时为 1

4. 关系与等式运算符

运算符 含义
> >= < <= 大小比较,结果为 1 位
== != 相等 / 不等
=== !== 全等 / 不全等(会区分 x/z,仅用于仿真,不可综合)
1
if (cnt == 4'd9) ...   // 组合/时序里常用 == 做判断

5. 拼接、复制与条件运算符

  • 拼接 {}:已在"位宽与向量"一节介绍,可把多个信号拼成更宽总线。
  • 复制 {n{ }}:重复某值 n 次。
  • 条件运算符 ?:条件 ? 真值 : 假值,常用于 assign 中做二选一。
1
2
3
assign sel_out = sel ? a : b;          // 二选一多路器
assign bus     = {hi, 8'b0, lo};       // 拼接
assign mask    = {8{1'b1}};            // 8 位全 1

运算符优先级与 C 语言类似(乘除移位 > 加减 > 比较 > 位运算 > 条件),写复杂表达式时多用括号避免歧义。

七、always 过程块

always 是描述硬件行为的过程块,内部可以写 if/elsecasefor 等控制语句。它的行为完全由**敏感表(sensitivity list)**决定——敏感表里写什么事件,块就在什么事件发生时执行。

实际工程中,绝大多数的 always 只有两种"标准写法",分别对应时序逻辑组合逻辑

写法 触发方式 综合出 典型用途 赋值方式
always @(posedge clk or negedge rst_n) 时钟 / 复位边沿 触发器(带复位) 打拍、计数、状态机 非阻塞 <=
always @(*) 任意输入信号变化 组合逻辑(门电路) 译码、多选一、运算 阻塞 =

下面分别介绍这两种写法。

1. 时序逻辑:always @(posedge clk or negedge rst_n)

敏感表里写的是边沿事件,块只在边沿到来的那一时刻执行一次。这是描述触发器(Flip-Flop)与寄存器的最标准形式,几乎总是写成"时钟上升沿 + 复位下降沿":

1
2
3
4
5
6
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;          // 复位有效(低有效)时,值清零
    else
        q <= d;             // 否则在时钟上升沿把 d 锁存到 q
end
  • posedge clk:时钟上升沿触发——每个时钟沿把输入"打一拍"。
  • negedge rst_n:复位下降沿也触发,因此复位是异步的(复位一拉低立刻生效,不等待时钟)。
  • 若要做同步复位,就把复位写进 if 判断、且不列入敏感表:always @(posedge clk) if (!rst_n) ...
  • 这种写法综合出来的是带复位的 D 触发器;块内使用非阻塞 <= 赋值(见 §八)。

2. 组合逻辑:always @(*)

组合逻辑的输出只取决于当前输入(没有时钟、没有记忆),所以只要右侧用到的任何信号变化,输出就该立刻更新。敏感表用通配符 * 表示"自动包含所有用到的信号",既省事又不会漏写:

1
2
3
4
5
6
7
8
always @(*) begin
    case (sel)
        2'b00:   y = a;
        2'b01:   y = b;
        2'b10:   y = c;
        default: y = 1'b0;
    endcase
end
  • * 等价于手动列出 always @(a or b or c or sel),但不用手列,避免漏写敏感信号导致仿真与综合行为不一致。
  • 组合逻辑块内使用阻塞 = 赋值(见 §八)。
  • 一定要把分支写全(加 default),否则综合器会推断出锁存器(见 §3)。

硬件视角:上面这段 case 综合后,本质就是一个多路选择器(MUX)——sel 是选择信号,它控制把 a / b / c / 0 中的哪一路"连"到输出 y。所以 case、或者 ?: 条件运算符,在硬件里都是同一个东西:一个被选择信号控制的多路开关。

3. 避免意外锁存器(latch)

组合逻辑里,如果 if 缺少 else、或 case 缺少 default,综合器会认为"条件不满足时输出应保持原值",从而推断出锁存器。通常我们不希望出现锁存器,所以组合 always分支写全

1
2
3
4
5
6
always @(*) begin
    if (sel)
        y = a;
    else                // 必须有 else,否则 y 在 sel=0 时被锁存
        y = b;
end

4. begin / end 语句块

begin ... end 把多条语句组织成一个顺序语句块,相当于 C 的 { }。在 alwaysifcasefor 等结构里,当分支包含多条语句时必须用 begin/end 包裹(单条语句可省略):

1
2
3
4
5
6
7
8
always @(posedge clk) begin
    if (en) begin
        a <= 1'b1;
        b <= 1'b0;   // 多条语句必须包在 begin/end 中
    end
    else
        a <= 1'b0;   // 单条语句可省略 begin/end
end

5. case 语句

case 是多分支选择,常用于译码、状态机跳转。语法:case (表达式) 分支1: 语句; 分支2: 语句; default: 语句; endcase。从硬件角度看它就是一个多路选择器(MUX):选择信号决定把哪一路输入接到输出。注意:

  • 各分支按全等比较===)匹配,且分支值之间不应重叠。
  • 组合逻辑务必写 default,否则未覆盖的情况会被综合成锁存器。
1
2
3
4
5
6
7
8
always @(*) begin
    case (sel)
        2'b00:   y = a;
        2'b01:   y = b;
        2'b10:   y = c;
        default: y = 1'b0;     // 防止 latch
    endcase
end

相关变体:casez(把 ? / z 当作"不关心"通配)、casex(把 xz 都当通配)。可综合设计里优先用 casez + ? 表示无关位。

6. for 循环

for 循环用于在同一时刻展开重复操作(如批量初始化数组、按位处理)。注意它是在综合/展开(elaboration)阶段被展开成多份并行硬件,因此循环次数必须是常量,不能依赖运行时的变量:

1
2
3
4
5
integer i;
always @(posedge clk) begin
    for (i = 0; i < 4; i = i + 1)
        byte1[i] <= 8'b0;   // 4 个字同时清零
end

因为循环会被展开,所以它常用于对存储器/向量做批量赋值、或实现移位寄存器等多位重复结构。循环变量一般用 integer 声明。

7. initial 块

initialalways 类似也是过程块,但它只在上电/仿真开始时执行一次(没有敏感表):

1
2
3
4
reg [3:0] cnt;
initial begin
    cnt = 4'b0;          // 仿真初值
end

绝大多数非测试平台的 FPGA/ASIC RTL 中,不要依赖 initial 完成寄存器初始化,应使用复位逻辑。initial 主要用于**测试平台(testbench)**设置初值与激励;在可综合的 RTL 中多数综合器不支持(或忽略),变量初值应交给复位信号来完成。

八、阻塞赋值与非阻塞赋值

always 块内的赋值有两种符号,初学者最容易混淆:

赋值 符号 行为
阻塞赋值 = 立即生效,语句顺序执行(像软件一行行跑)
非阻塞赋值 <= 在块结束时统一更新,语句间并行(像硬件同时打拍)

硬件视角:电路是并行工作的——一个时钟上升沿到来,所有 D 触发器同时把各自的新值锁存进去。非阻塞 <= 正是这种"并行同时打拍"的建模:同一条 always 里的多条 <= 都基于进入本周期时的旧值计算,并在块结束时一起更新,互不干扰。而阻塞 = 是"算完一个再算下一个"的串行思路,适合描述组合逻辑里"前一句的结果立刻给后一句用"的依赖关系。记住这个"并行 vs 串行"的差别,就抓住了两种赋值的本质。

1. 黄金规则

  • 时序逻辑(posedge clk 等)一律用非阻塞 <=:保证同一时钟沿上各语句基于"旧值"同时更新。
  • 组合逻辑(always @(*))一律用阻塞 =:保证前后语句按顺序、即时生效。

2. 反例:时序块里用错会出 bug

意图:把输入 d 打两拍(两级流水),正确的 q2 应等于上一周期q1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 非阻塞(正确):两条语句都基于进入本周期时的旧值计算,q2 得到上一拍的 q1
always @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
end

// 阻塞(错误):q1 立即被更新,随后 q2 读到的是"刚更新的 q1",
// 结果 q1、q2 同一拍都变成 d,退化为直通,没有延迟效果
always @(posedge clk) begin
    q1 = d;
    q2 = q1;
end

3. 反例:组合块里用错也会出错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 阻塞(正确):y 先算出,z 用到的是刚算出的 y
always @(*) begin
    y = a & b;
    z = y | c;
end

// 非阻塞(错误):块结束时才统一更新,z 用到的是"上一轮"的 y,并非本次结果
always @(*) begin
    y <= a & b;
    z <= y | c;
end

牢记:“时序用 <=、组合用 =,几乎能避免所有赋值类时序 bug。

九、parameter 与 localparam

两者都用于定义常量,可在模块内作为位宽、分频系数、地址等"可配置参数"使用。

  • parameter可被上层在实例化时覆盖的常量,用来做模块参数化(如数据位宽、从机地址)。
  • localparam仅模块内部使用的常量,不能被实例化覆盖(如状态机的状态编码、分频系数)。
1
2
3
4
5
6
7
module uart_tx #(parameter DATA_W = 8, parameter BAUD = 115200) (
    input clk, ...
);
    localparam IDLE    = 2'b00;            // 状态编码,不对外暴露
    localparam CNT_MAX = 100_000_000 / BAUD - 1;
    reg [DATA_W-1:0] shift_reg;
endmodule

现代模块推荐用 #(parameter ...) 的 ANSI 风格在端口前声明参数;实例化时可按名覆盖,如 uart_tx #(.DATA_W(16)) u_tx (...)

十、模块实例化

Verilog 通过实例化把若干子模块"连起来"构成更大系统。可以类比成**“搭电路板 / 插元件”**:每个子模块是一颗现成的芯片,实例化就是把它焊到板子上,而 .端口(信号) 就是用导线把外部信号连到这个元件对应的引脚上。

实例化语法:模块名 实例名 ( .端口名(连接信号), ... );,推荐用按名连接.port(signal)),顺序可乱、可读性好:

1
2
3
4
5
6
7
8
9
wire [3:0] cnt_w;
wire       cout_w;

counter10 u_counter (          // u_counter 是把 counter10 这个"芯片"焊到板子上的实例
    .rstn (rstn),              // 用导线把外部 rstn 连到它的 rstn 脚
    .clk  (clk),               // 把 clk 连到它的 clk 脚
    .cnt  (cnt_w),             // 它的 cnt 输出脚,引到板上的 cnt_w 这根导线
    .cout (cout_w)
);

一个模块可以实例化多次(同一颗芯片焊多颗,只要取不同实例名)。也可用按位置连接(省略 .端口名,按模块定义顺序填实参),但易错、可读性差,不推荐。参数化模块实例化时还可按名覆盖参数,如 uart_tx #(.DATA_W(16)) u_tx (...)(见 §九)。

最后更新于 Jul 29, 2026 17:14 +0800
使用 Hugo 构建
主题 StackJimmy 设计