3. 硬件描述语言Verilog基础:模块结构、数据类型、运算符、赋值语句、过程块
Verilog HDL(Hardware Description Language)是数字IC前端设计与验证的核心语言。与软件编程语言(如C/C++)最大的不同在于,Verilog描述的是硬件电路的结构与行为,其执行本质上是并行的,且代码风格直接映射为逻辑门、触发器和连线。本章将围绕RTL(寄存器传输级)可综合设计的核心语法要素展开,帮助学员建立从“软件思维”向“硬件思维”转换的基石。我们将深入剖析模块的层次化结构、四值逻辑系统、线网与变量数据类型、运算符的硬件含义、阻塞与非阻塞赋值的本质区别,以及过程块(always/initial)的建模规则。
上图展示了本章五个核心知识点之间的逻辑关系:模块结构是代码的容器,数据类型定义了信号的物理属性,运算符描述了组合逻辑的运算方式,赋值语句决定了时序逻辑的更新方式,而过程块则是描述硬件行为的主要入口。所有要素最终共同服务于可综合RTL代码的生成,并映射为实际的硬件电路。
3.1 模块结构:硬件设计的层次化封装
核心原理
在Verilog中,模块(Module)是基本的设计单元,它封装了电路的功能、输入输出端口以及内部实现细节。一个模块既可以代表一个简单的逻辑门,也可以代表一个复杂的处理器核心。模块化设计是应对大规模数字系统复杂性的关键手段,它允许设计者将系统分解为多个可独立设计、验证和复用的子模块。
模块的基本语法结构如下:
module module_name #(parameter WIDTH = 8) ( // 参数化与端口列表
input wire clk, // 时钟输入
input wire rst_n, // 异步复位,低有效
input wire [WIDTH-1:0] data_in, // 数据输入
output reg [WIDTH-1:0] data_out // 数据输出
);
// 内部信号声明
wire [WIDTH-1:0] internal_wire;
// 功能描述(数据流、行为级或结构化)
assign internal_wire = data_in;
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
data_out <= {WIDTH{1'b0}};
else
data_out <= internal_wire;
end
endmodule
端口是模块与外部环境交互的接口,分为输入(input)、输出(output)和双向(inout)三种方向。端口的数据类型可以是线网(wire)或变量(reg)。在RTL设计中,input端口通常声明为wire,而output端口根据赋值方式决定是wire还是reg。
判断方法或操作流程
在进行模块划分时,应遵循以下原则:
- 功能内聚:每个模块应完成一个明确、单一的功能,例如FIFO控制器、CRC校验器、I2C接口等。
- 接口最小化:模块间的通信信号应尽可能少,以降低耦合度,便于独立验证。
- 层次化组织:顶层模块只负责实例化子模块并连接信号,不包含具体逻辑实现。
- 参数化设计:使用parameter定义位宽、深度等常量,提高代码复用性。
实例化子模块时,推荐使用显式端口连接(.port_name(signal)),避免使用位置连接(容易出错且可读性差)。
案例拆解或实战场景
假设我们需要设计一个8位同步FIFO,其内部包含存储阵列、写指针逻辑、读指针逻辑和状态判断逻辑。合理的模块划分如下:
| 子模块名称 | 功能描述 | 端口特点 |
|---|---|---|
| fifo_mem | 双端口RAM存储阵列 | 写地址/数据输入,读地址/数据输出 |
| fifo_wptr | 写指针生成与满标志 | 时钟、复位、写使能,输出写地址和满信号 |
| fifo_rptr | 读指针生成与空标志 | 时钟、复位、读使能,输出读地址和空信号 |
| fifo_top | 顶层模块,实例化上述子模块并连接 | 外部接口信号 |
这种划分使得每个子模块都可以独立进行仿真验证,且fifo_mem可以被其他设计复用。
3.2 数据类型:四值逻辑与线网/变量
核心原理
Verilog使用四值逻辑系统:0(逻辑低)、1(逻辑高)、z(高阻态)、x(未知或冲突)。其中z和x主要用于仿真验证,在可综合设计中,z通常用于三态缓冲器,而x应避免出现在RTL代码中(综合工具无法处理x)。
数据类型主要分为两大类:
- 线网(wire):表示硬件电路中的物理连线。它由驱动源(如门输出、assign语句)持续驱动,不能存储值。如果没有驱动源,线网默认为高阻态z。
- 变量(reg):表示数据存储单元。在过程块(always/initial)中被赋值,可以保持上一次赋的值。注意,reg并不一定对应硬件寄存器,在组合逻辑always块中,reg综合后只是连线。
此外,还有整数(integer)、实数(real)和时间(time)等数据类型,但它们在可综合RTL设计中很少使用,主要用于测试平台(testbench)。
判断方法或操作流程
选择wire还是reg,遵循以下规则:
- 在assign语句中赋值的信号,必须声明为wire。
- 在always块中赋值的信号,必须声明为reg。
- 模块的input端口必须是wire类型。
- 模块的output端口可以是wire或reg,取决于赋值方式。
向量(多比特信号)的声明格式为:wire [MSB:LSB] signal_name; 或 reg [MSB:LSB] signal_name;。位宽必须明确,避免隐式声明(默认1位)。
案例拆解或实战场景
考虑一个简单的数据通路:
module data_path (
input wire [7:0] a, b,
input wire sel,
output reg [7:0] y
);
wire [7:0] sum;
wire [7:0] diff;
assign sum = a + b; // sum是wire,由加法器驱动
assign diff = a - b; // diff是wire
always @(*) begin
if (sel)
y = sum; // y是reg,在always中赋值
else
y = diff;
end
endmodule
在这个例子中,sum和diff是组合逻辑的输出,用wire声明;y是二选一多路选择器的输出,在always块中赋值,因此声明为reg。综合后,y并不会生成寄存器,只是一个MUX的输出。
_n表示低有效,_d表示延迟打拍信号),可以显著提高代码可读性。
3.3 运算符:硬件运算的映射
核心原理
Verilog提供了丰富的运算符,它们在综合时会映射为相应的硬件逻辑单元。理解运算符的硬件含义是编写高效RTL的关键。
| 运算符类别 | 符号 | 硬件映射 | 注意事项 |
|---|---|---|---|
| 算术 | + - * / % | 加法器、减法器、乘法器、除法器 | 乘法器面积大,除法器不可综合(通常用移位或IP核) |
| 逻辑 | && || ! | 逻辑门(与、或、非),结果为1位 | 用于条件判断,操作数被视为布尔值 |
| 位运算 | & | ^ ~ | 按位逻辑门 | 逐位操作,结果位宽与操作数相同 |
| 归约 | & | ^ ~& ~| ~^ | 多输入逻辑门(如奇偶校验) | 对向量的所有位进行运算,结果为1位 |
| 移位 | << >> <<< >>> | 桶形移位器或连线 | 逻辑移位补0,算术移位保留符号位 |
| 拼接 | {a,b} | 连线重组 | 用于组合位向量,可嵌套和重复 |
| 关系 | > < >= <= | 比较器 | 结果为1位布尔值 |
| 相等 | == != === !== | 相等比较器 | ===可比较x和z,但不可综合 |
| 条件 | ? : | 多路选择器 | 可嵌套,综合为MUX树 |
判断方法或操作流程
在编写RTL时,应遵循以下运算原则:
- 避免使用除法器和取模运算,除非是常数除法(可用移位近似)。
- 乘法运算:对于小位宽(如8x8)可以直接使用
*,综合工具会推断出乘法器;对于大位宽或高性能要求,应例化DSP单元或使用流水线结构。 - 移位运算:左移1位相当于乘以2,右移1位相当于除以2(对于无符号数)。
- 拼接运算符:常用于字节序转换、位宽扩展和循环移位。
案例拆解或实战场景
设计一个字节序转换模块,将大端字节序转换为小端字节序:
module endian_swap (
input wire [31:0] big_endian,
output wire [31:0] little_endian
);
assign little_endian = {big_endian[7:0], big_endian[15:8],
big_endian[23:16], big_endian[31:24]};
endmodule
这里使用了拼接运算符,将4个字节重新排列。综合后,这只是一组交叉连线,不消耗任何逻辑资源。
==比较时,如果操作数包含x或z,仿真结果可能不符合预期。在可综合代码中,应确保操作数在正常工作时不会出现x或z状态。另外,===运算符虽然可以比较x和z,但无法综合,只能用于testbench。
3.4 赋值语句:阻塞与非阻塞的本质区别
核心原理
Verilog中有两种过程赋值语句:阻塞赋值(=)和非阻塞赋值(<=)。这是RTL设计中最重要、也最容易混淆的概念。
- 阻塞赋值(=):在begin...end块中,语句按顺序执行。当前赋值语句执行完后,才会执行下一条语句。它模拟的是组合逻辑的行为,赋值立即生效。
- 非阻塞赋值(<=):在begin...end块中,所有赋值语句的右端表达式在进入块时同时计算,然后在块结束时统一更新左端变量。它模拟的是时序逻辑(触发器)的行为,赋值在时钟边沿后生效。
硬件映射规则:
- 在时序逻辑(always @(posedge clk))中,必须使用非阻塞赋值。
- 在组合逻辑(always @(*))中,必须使用阻塞赋值。
- 在assign语句中,只能使用连续赋值,不存在阻塞/非阻塞的概念。
判断方法或操作流程
编写RTL时,遵循以下“黄金法则”:
- 设计时序逻辑时,使用
always @(posedge clk or negedge rst_n)块,内部全部使用<=。 - 设计组合逻辑时,使用
always @(*)块,内部全部使用=,且所有被赋值的信号必须在所有分支中被赋值(避免产生锁存器)。 - 不要在同一个always块中混合使用阻塞和非阻塞赋值。
- 不要在多个always块中对同一个变量赋值。
案例拆解或实战场景
考虑一个简单的移位寄存器,使用非阻塞赋值:
module shift_reg (
input wire clk,
input wire rst_n,
input wire din,
output reg dout
);
reg q1, q2;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
q1 <= 1'b0;
q2 <= 1'b0;
dout <= 1'b0;
end else begin
q1 <= din;
q2 <= q1;
dout <= q2;
end
end
endmodule
如果错误地使用阻塞赋值:
always @(posedge clk) begin
q1 = din;
q2 = q1; // q2会立即得到din的值,而不是q1的旧值
dout = q2;
end
这样综合出来的电路只有一个触发器,而不是三个,功能完全错误。非阻塞赋值确保了所有触发器在时钟边沿同时采样旧值,实现了正确的移位功能。
3.5 过程块:always与initial
核心原理
过程块是Verilog中描述硬件行为的主要结构,包括always块和initial块。
- always块:用于描述可综合的硬件逻辑。它根据敏感列表(sensitivity list)中的事件触发执行。敏感列表可以是电平(如
@(*)表示所有输入变化)或边沿(如@(posedge clk))。 - initial块:仅在仿真开始时执行一次,用于初始化信号、生成测试激励。它不可综合,只能用于testbench。
always块的敏感列表决定了其综合后的硬件类型:
| 敏感列表形式 | 综合结果 | 典型应用 |
|---|---|---|
| always @(*) | 组合逻辑 | MUX、译码器、加法器 |
| always @(posedge clk) | 触发器(同步时序逻辑) | 计数器、状态机、移位寄存器 |
| always @(posedge clk or negedge rst_n) | 带异步复位的触发器 | 大多数时序逻辑 |
| always @(posedge clk or posedge rst) | 带异步置位的触发器 | 特定复位需求 |
判断方法或操作流程
编写always块时,需注意以下要点:
- 组合逻辑:使用
@(*),所有输入信号变化都会触发块执行。块内必须使用阻塞赋值,且所有分支都要赋值,否则会综合出锁存器(latch)。 - 时序逻辑:使用
@(posedge clk),块内使用非阻塞赋值。复位逻辑应放在if语句的最前面。 - 避免生成锁存器:在组合逻辑always块中,如果某个分支没有赋值,综合工具会推断出锁存器,这通常不是设计意图。
- 敏感列表完整性:对于组合逻辑,使用
@(*)可以自动包含所有输入信号,避免遗漏。
案例拆解或实战场景
设计一个带同步使能的4位计数器:
module counter (
input wire clk,
input wire rst_n,
input wire en,
output reg [3:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 4'b0000;
else if (en) begin
if (count == 4'b1111)
count <= 4'b0000; // 回卷
else
count <= count + 1'b1;
end
// 如果en为0,count保持不变(隐含的else分支)
end
endmodule
在这个例子中,count在时钟上升沿更新。当en为低时,没有赋值语句,但非阻塞赋值特性使得count保持原值,综合后是一个带使能的寄存器。
@(posedge clk)敏感列表,但内部使用阻塞赋值,综合后会产生触发器,但仿真行为可能不符合预期。务必保持敏感列表与赋值类型的一致性。
3.6 本章总结与设计要点复盘
本章涵盖了Verilog RTL设计的五个基础要素,它们是构建任何数字电路的核心语法单元。为了确保设计正确且可综合,请牢记以下要点:
- 模块化:将复杂设计分解为功能单一、接口清晰的子模块,使用参数化提高复用性。
- 数据类型:wire代表连线,reg代表变量(不一定是寄存器)。在assign中赋值用wire,在always中赋值用reg。
- 运算符:理解每种运算符的硬件映射,避免使用不可综合的运算符(如除法、取模、===)。
- 赋值语句:时序逻辑用非阻塞赋值(<=),组合逻辑用阻塞赋值(=),严禁混用。
- 过程块:always @(*)描述组合逻辑,always @(posedge clk)描述时序逻辑,initial仅用于仿真。
掌握这些基础后,下一步将进入更复杂的RTL设计范式,如有限状态机(FSM)、流水线设计和跨时钟域处理。扎实的基础是应对复杂设计挑战的保障,建议读者通过大量的小型设计练习(如计数器、移位寄存器、加法器、MUX)来巩固本章知识,并养成使用仿真工具(如VCS、ModelSim)验证代码行为的习惯。