Verilog 语法教程
Verilog HDL(简称 Verilog)是一种硬件描述语言(Hardware Description Language, HDL),用于数字电路的系统设计。它能在算法级、门级、开关级等多种抽象层次上对硬件进行建模。
阅读本教程前,你需要了解基本的数字电路知识,包括组合逻辑、时序逻辑与时钟/复位的概念。
如果想要动手练习 Verilog,建议在 HDLBits 网站进行练习。
一个完整例子
下面先看一个完整的计数器模块,再逐块拆解它用到的语法:
|
|
一、module 与文件结构
一个 Verilog 设计由若干 module(模块) 组成,每个模块用 module ... endmodule 包裹:
module后跟模块名与端口列表,endmodule表示模块结束(结尾没有分号)。- 模块名应使用有意义的英文,不能是 Verilog 关键字,也不能含空格。
- 工程中通常一个
.v文件只放一个 module,且文件名与模块名保持一致,方便工具管理。 - 模块内部由**端口声明、数据类型定义、连续赋值(
assign)、过程块(always)**等部分构成。
|
|
注释
Verilog 支持两种注释,与 C 语言相同:
- 行注释
//:从//到本行行尾。 - 块注释
/* ... */:可跨越多行。
|
|
注释会被综合器忽略,仅用于提升可读性。建议对关键信号含义、状态编码、时序/约束意图加上注释。
二、端口定义
端口是模块与外部电路的接口,必须声明方向与(可选的)类型/位宽。
1. 三种方向
| 方向 | 含义 | 典型用途 |
|---|---|---|
input |
模块输入,由外部驱动 | 时钟、复位、数据输入 |
output |
模块输出 | 数据/状态输出 |
inout |
双向(三态) | 开漏总线,如 IIC 的 sda/scl |
2. 端口默认是 wire
端口声明时若只写方向(不写 reg),默认类型为 wire。因此:
input永远是外部驱动的wire,不能被always赋值。output若要在always块里被赋值,必须显式声明为reg(即output reg);若仅由assign驱动则保持wire。
3. 两种声明风格
现代 ANSI 风格(推荐):方向与类型直接写在端口列表中,直观不易出错。
|
|
传统非 ANSI 风格:端口列表只列名字,类型/方向再到模块体内声明(旧代码常见,但容易遗漏):
|
|
三、数据类型:wire 与 reg
Verilog 中最常用的两种变量类型是 线网(wire) 与 寄存器(reg)。
1. wire(线网类型)
顾名思义表示一根"导线",自身不能存储状态,必须由外部驱动才能呈现 0 或 1(驱动源可以是 assign、模块端口或基本门)。常用于组合逻辑的输出与模块间的连线。
|
|
2. reg(寄存器类型)
reg 类型变量可以在过程块(always)中被赋值。从硬件角度看,它名字里的"寄存器"容易误导——它不一定综合成真正的触发器,最终变成什么电路,完全取决于它写在哪种 always 里:
- 写在
always @(posedge clk)里 → 综合成 D 触发器(Flip-Flop):每位都是一个小存储单元,只在时钟上升沿"啪"地记住新值,平时保持不动。这是reg最常见的硬件形态。 - 写在
always @(*)里 → 综合成组合门电路(如与门、或门、选择器),没有记忆,输出随输入即时变化。此时reg只是"过程中暂存变量",并不是真正的寄存器。 - 若分支没写全(缺
else/default)→ 综合器为"保持原值"会悄悄生成锁存器(latch),一般要尽量避免。
一句话:reg 在时序块里 = 触发器(有记忆),在组合块里 = 组合门(无记忆),关键在于它所在的 always。
|
|
简单记忆:
assign驱动的用wire,always块里被赋值的用reg。
3. signed 有符号类型(仅介绍)
入门阶段极少用到,这里只做概念性介绍,不要求掌握,知道有这回事即可。
默认情况下 wire / reg 是无符号的,算术与比较都按无符号处理。在类型后加 signed 关键字可声明为有符号(二进制补码),此时算术、关系运算以及 >>> 算术右移都会按正负来解释:
|
|
要点:有符号只改变"如何解释这些 bit"与运算规则;线网/寄存器里存的仍是普通 bit。当有符号与无符号操作数混用时,结果按无符号处理,需要特别小心。
四、位宽与向量
1. 标量 vs 向量
从硬件角度看,Verilog 里的一个信号其实就是一根导线;指定位宽后,它就变成一捆并排的多根导线,这捆"导线束"就叫做向量(vector)。
- 标量(scalar):没有指定位宽,本质是 1 根导线,只能表示 0 或 1 一个 bit。
- 向量(vector):位宽大于 1,本质是 N 根并排的导线,可一次性承载 N 个 bit,做多位运算(
[3:0]就是 4 根导线,编号 0~3)。
|
|
可以想象 wire [31:0] data; 在电路里并不是一根"粗线",而是 data[0]、data[1] … data[31] 共 32 根独立的导线捆在一起,平时统一叫 data 来批量操作。这种"多根导线"的视角,对理解后面的位选、拼接、移位都很关键。
2. 向量声明与高低位
向量格式为 [msb : lsb](高位在前、低位在后),位宽 = msb - lsb + 1:
|
|
3. 位选与域选
可以对向量做单 bit 选择或连续段选择:
|
|
4. 拼接与复制
- 拼接运算符
{}:把多个信号拼成更宽的总线。 - 复制
{n{ }}:把某值重复 n 次。
|
|
5. 向量的数组(存储器)
前面说的 reg [3:0] counter 是一个"4 位宽的向量"。如果再加一对方括号,就声明了一组相同宽度的向量,也就是存储器(memory) —— 常用于寄存器堆、RAM、FIFO 等:
|
|
读法要分清两个维度:
- 左侧
[7:0]是"每个元素的位宽"(数据宽度)。 - 右侧
[3:0]是"元素的个数/地址范围"(深度)。
|
|
注意几个常见限制:
- 不能一次性对整个存储器赋值,只能按字逐一赋值(通常写在带下标的
always块里):
1 2 3 4integer 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
为严谨,实际编码中一般始终指定位宽:
|
|
3. 不定宽、负数与实数
- 不指定位宽时,编译器通常按宿主字长(常见 32 位)处理:
counter = 'd100;或counter = 100;。 - 负数在位宽之前加减号:
counter = -6'd15;是合法的;-放在数值之后(如4'd-2)则非法。 - Verilog 还有
real实数类型,支持科学计数法(如1.2e4、1E-3),但不参与综合,只用于测试/建模。
六、运算符与基础计算
Verilog 提供丰富的运算符。下面按"算术前先补一句:所有运算都在位上按位宽进行,结果位宽不够会截断高位、不够会补符号/零"来分类介绍最常用的几类。
1. 算术运算符
| 运算符 | 含义 | 说明 |
|---|---|---|
+ |
加 | 按位宽相加,溢出则截断高位 |
- |
减 | 按位宽相减 |
* |
乘 | 综合为乘法器,结果位宽通常为两操作数位宽之和 |
/ |
除 | 综合资源开销大,尽量少用(2 的幂建议用移位代替) |
% |
取模 | 同 /,综合代价高,慎用 |
|
|
注意:Verilog 中的
+ - *是无符号按位宽计算;若操作数带signed声明则为有符号运算。位宽不匹配时,窄的操作数会零扩展(无符号)或符号扩展(有符号)到宽的位宽后参与运算。
2. 移位运算符
| 运算符 | 含义 | 说明 |
|---|---|---|
<< |
逻辑左移 | 右侧补 0,等价于 ×2ⁿ |
>> |
逻辑右移 | 左侧补 0,等价于 ÷2ⁿ |
<<< |
算术左移 | 有符号场景下同逻辑左移 |
>>> |
算术右移 | 有符号时左侧补符号位(保持正负) |
|
|
硬件视角:移位在综合后几乎不消耗逻辑资源——它并不是"计算",而是把导线重新连了一下。例如
x << 1综合出来就是:把x[6:0]接到输出[7:1]、输出最低位[0]接地(补 0)。正因为只是"改接线",所以比乘除法器便宜得多。因此,对 2 的整数次幂的乘除,优先用移位实现,比
/、*更省逻辑资源。例如addr * 4写成addr << 2。
3. 位运算与归约运算符
| 运算符 | 含义 | 说明 |
|---|---|---|
~ |
按位取反 | 每一位取反 |
& |
按位与 | 对应位相与 |
| |
按位或 | 对应位相或 |
^ |
按位异或 | 不同为 1 |
~^ / ^~ |
同或 | 相同为 1 |
&(单目) |
归约与 | 把所有位"与"起来,得 1 位 |
|(单目) |
归约或 | 把所有位"或"起来,得 1 位 |
^(单目) |
归约异或 | 奇偶校验常用 |
|
|
4. 关系与等式运算符
| 运算符 | 含义 |
|---|---|
> >= < <= |
大小比较,结果为 1 位 |
== != |
相等 / 不等 |
=== !== |
全等 / 不全等(会区分 x/z,仅用于仿真,不可综合) |
|
|
5. 拼接、复制与条件运算符
- 拼接
{}:已在"位宽与向量"一节介绍,可把多个信号拼成更宽总线。 - 复制
{n{ }}:重复某值 n 次。 - 条件运算符
?::条件 ? 真值 : 假值,常用于assign中做二选一。
|
|
运算符优先级与 C 语言类似(乘除移位 > 加减 > 比较 > 位运算 > 条件),写复杂表达式时多用括号避免歧义。
七、always 过程块
always 是描述硬件行为的过程块,内部可以写 if/else、case、for 等控制语句。它的行为完全由**敏感表(sensitivity list)**决定——敏感表里写什么事件,块就在什么事件发生时执行。
实际工程中,绝大多数的 always 只有两种"标准写法",分别对应时序逻辑和组合逻辑:
| 写法 | 触发方式 | 综合出 | 典型用途 | 赋值方式 |
|---|---|---|---|---|
always @(posedge clk or negedge rst_n) |
时钟 / 复位边沿 | 触发器(带复位) | 打拍、计数、状态机 | 非阻塞 <= |
always @(*) |
任意输入信号变化 | 组合逻辑(门电路) | 译码、多选一、运算 | 阻塞 = |
下面分别介绍这两种写法。
1. 时序逻辑:always @(posedge clk or negedge rst_n)
敏感表里写的是边沿事件,块只在边沿到来的那一时刻执行一次。这是描述触发器(Flip-Flop)与寄存器的最标准形式,几乎总是写成"时钟上升沿 + 复位下降沿":
|
|
posedge clk:时钟上升沿触发——每个时钟沿把输入"打一拍"。negedge rst_n:复位下降沿也触发,因此复位是异步的(复位一拉低立刻生效,不等待时钟)。- 若要做同步复位,就把复位写进
if判断、且不列入敏感表:always @(posedge clk) if (!rst_n) ...。 - 这种写法综合出来的是带复位的 D 触发器;块内使用非阻塞
<=赋值(见 §八)。
2. 组合逻辑:always @(*)
组合逻辑的输出只取决于当前输入(没有时钟、没有记忆),所以只要右侧用到的任何信号变化,输出就该立刻更新。敏感表用通配符 * 表示"自动包含所有用到的信号",既省事又不会漏写:
|
|
*等价于手动列出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 要分支写全:
|
|
4. begin / end 语句块
begin ... end 把多条语句组织成一个顺序语句块,相当于 C 的 { }。在 always、if、case、for 等结构里,当分支包含多条语句时必须用 begin/end 包裹(单条语句可省略):
|
|
5. case 语句
case 是多分支选择,常用于译码、状态机跳转。语法:case (表达式) 分支1: 语句; 分支2: 语句; default: 语句; endcase。从硬件角度看它就是一个多路选择器(MUX):选择信号决定把哪一路输入接到输出。注意:
- 各分支按全等比较(
===)匹配,且分支值之间不应重叠。 - 组合逻辑务必写
default,否则未覆盖的情况会被综合成锁存器。
|
|
相关变体:
casez(把?/z当作"不关心"通配)、casex(把x与z都当通配)。可综合设计里优先用casez+?表示无关位。
6. for 循环
for 循环用于在同一时刻展开重复操作(如批量初始化数组、按位处理)。注意它是在综合/展开(elaboration)阶段被展开成多份并行硬件,因此循环次数必须是常量,不能依赖运行时的变量:
|
|
因为循环会被展开,所以它常用于对存储器/向量做批量赋值、或实现移位寄存器等多位重复结构。循环变量一般用
integer声明。
7. initial 块
initial 与 always 类似也是过程块,但它只在上电/仿真开始时执行一次(没有敏感表):
|
|
绝大多数非测试平台的 FPGA/ASIC RTL 中,不要依赖 initial 完成寄存器初始化,应使用复位逻辑。
initial主要用于**测试平台(testbench)**设置初值与激励;在可综合的 RTL 中多数综合器不支持(或忽略),变量初值应交给复位信号来完成。
八、阻塞赋值与非阻塞赋值
always 块内的赋值有两种符号,初学者最容易混淆:
| 赋值 | 符号 | 行为 |
|---|---|---|
| 阻塞赋值 | = |
立即生效,语句顺序执行(像软件一行行跑) |
| 非阻塞赋值 | <= |
在块结束时统一更新,语句间并行(像硬件同时打拍) |
硬件视角:电路是并行工作的——一个时钟上升沿到来,所有 D 触发器同时把各自的新值锁存进去。非阻塞
<=正是这种"并行同时打拍"的建模:同一条always里的多条<=都基于进入本周期时的旧值计算,并在块结束时一起更新,互不干扰。而阻塞=是"算完一个再算下一个"的串行思路,适合描述组合逻辑里"前一句的结果立刻给后一句用"的依赖关系。记住这个"并行 vs 串行"的差别,就抓住了两种赋值的本质。
1. 黄金规则
- 时序逻辑(
posedge clk等)一律用非阻塞<=:保证同一时钟沿上各语句基于"旧值"同时更新。 - 组合逻辑(
always @(*))一律用阻塞=:保证前后语句按顺序、即时生效。
2. 反例:时序块里用错会出 bug
意图:把输入 d 打两拍(两级流水),正确的 q2 应等于上一周期的 q1。
|
|
3. 反例:组合块里用错也会出错
|
|
牢记:“时序用
<=、组合用=”,几乎能避免所有赋值类时序 bug。
九、parameter 与 localparam
两者都用于定义常量,可在模块内作为位宽、分频系数、地址等"可配置参数"使用。
parameter:可被上层在实例化时覆盖的常量,用来做模块参数化(如数据位宽、从机地址)。localparam:仅模块内部使用的常量,不能被实例化覆盖(如状态机的状态编码、分频系数)。
|
|
现代模块推荐用
#(parameter ...)的 ANSI 风格在端口前声明参数;实例化时可按名覆盖,如uart_tx #(.DATA_W(16)) u_tx (...)。
十、模块实例化
Verilog 通过实例化把若干子模块"连起来"构成更大系统。可以类比成**“搭电路板 / 插元件”**:每个子模块是一颗现成的芯片,实例化就是把它焊到板子上,而 .端口(信号) 就是用导线把外部信号连到这个元件对应的引脚上。
实例化语法:模块名 实例名 ( .端口名(连接信号), ... );,推荐用按名连接(.port(signal)),顺序可乱、可读性好:
|
|
一个模块可以实例化多次(同一颗芯片焊多颗,只要取不同实例名)。也可用按位置连接(省略
.端口名,按模块定义顺序填实参),但易错、可读性差,不推荐。参数化模块实例化时还可按名覆盖参数,如uart_tx #(.DATA_W(16)) u_tx (...)(见 §九)。