3. 硬件描述语言基础:Verilog HDL语法速览、组合逻辑与时序逻辑、状态机设计、Testbench编写
说实话,很多初学者觉得Verilog就是另一门编程语言。写起来跟C差不多,有if、有case、有for。但我要泼盆冷水——Verilog是描述硬件,不是写软件。你写的每一行代码,最终都会变成门电路、触发器和连线。脑子里没有电路图,代码写得再漂亮也是白搭。
这一章,我带你把Verilog的核心语法过一遍。重点不是背语法手册,而是建立硬件思维。我会结合自己在Zynq上踩过的坑,告诉你哪些写法能综合,哪些写法会让综合器崩溃。
核心观点:Verilog不是软件语言。它是硬件描述语言。你写的是电路,不是算法。
3.1 Verilog语法速览:够用就行
我不打算把IEEE标准1364-2005从头讲一遍。那太枯燥了。我只讲你在FPGA开发中最常用的语法元素。其他的,用到再查手册。
3.1.1 模块结构
一个Verilog模块,就是一个硬件单元。它有输入、输出,内部有逻辑。
module counter (
input wire clk, // 时钟
input wire rst_n, // 异步复位,低有效
input wire en, // 使能
output reg [7:0] count // 8位计数器输出
);
// 时序逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 8'd0;
else if (en)
count <= count + 1'b1;
end
endmodule
注意几个关键点:
- wire:连线类型,用于组合逻辑输出或模块间连接
- reg:寄存器类型,用于时序逻辑中存储状态
- always @(posedge clk or negedge rst_n):敏感列表,描述触发条件
- 非阻塞赋值 <= :用于时序逻辑,多个赋值同时发生
我的习惯:模块的输入输出端口,我统一用 wire 声明。内部需要寄存器的信号,在 always 块里用 reg。这样端口清晰,内部逻辑也干净。
3.1.2 常用数据类型
| 类型 | 说明 | 典型用法 |
|---|---|---|
wire |
连线,组合逻辑输出 | 模块间连接、assign语句 |
reg |
寄存器,存储状态 | always块中的赋值目标 |
integer |
32位有符号整数 | 仿真中的循环变量 |
parameter |
常量参数 | 定义位宽、计数上限 |
3.1.3 运算符速查
Verilog的运算符跟C很像,但有几个坑要注意:
- 位宽匹配:运算结果的位置由操作数最大位宽决定。我曾经因为位宽不匹配,导致一个乘法器结果被截断,查了整整两天。
- 拼接运算符 { }:非常实用。比如
{a, b}把a和b拼成一个更宽的向量。 - 缩减运算符 &、|、^:对一个向量的所有位做与、或、异或。比如
&data判断data是否全1。
3.2 组合逻辑与时序逻辑:本质区别
这是FPGA设计最基础、也最重要的概念。我见过太多人把组合逻辑和时序逻辑混在一起写,结果综合出来的电路完全不是想要的。
3.2.1 组合逻辑
组合逻辑的输出只取决于当前输入。没有记忆功能,没有时钟。典型例子:加法器、多路选择器、译码器。
// 组合逻辑:用 assign 实现
assign sum = a + b;
// 组合逻辑:用 always @(*) 实现
always @(*) begin
case (sel)
2'b00: mux_out = data0;
2'b01: mux_out = data1;
2'b10: mux_out = data2;
2'b11: mux_out = data3;
default: mux_out = 1'b0;
endcase
end
注意:组合逻辑的 always 块,敏感列表必须用 @(*),表示所有输入信号变化都触发。如果漏写了某个信号,综合时会生成锁存器(latch),这通常不是你想要的。
3.2.2 时序逻辑
时序逻辑的输出不仅取决于当前输入,还取决于之前的状态。它需要时钟沿来触发状态更新。典型例子:计数器、移位寄存器、状态机。
// 时序逻辑:D触发器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
时序逻辑的核心是非阻塞赋值 <= 。它保证所有赋值在时钟沿同时发生,模拟了硬件中寄存器的并行更新行为。
3.2.3 组合 vs 时序:对比表
| 特性 | 组合逻辑 | 时序逻辑 |
|---|---|---|
| 输出依赖 | 仅当前输入 | 当前输入 + 历史状态 |
| 时钟 | 不需要 | 需要时钟沿触发 |
| 赋值方式 | 阻塞赋值 = | 非阻塞赋值 <= |
| 综合结果 | 门电路、多路选择器 | 触发器、寄存器 |
| 典型应用 | 加法器、译码器 | 计数器、状态机 |
3.3 状态机设计:有限状态机(FSM)
状态机是数字系统的核心。从简单的按键消抖,到复杂的通信协议解析,都离不开状态机。我个人习惯把状态机分成三段式来写,这样代码清晰,综合结果也稳定。
3.3.1 三段式状态机
三段式把状态机拆成三个always块:
- 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
- 第二段:组合逻辑,描述次态产生的条件(状态转移条件)
- 第三段:组合逻辑或时序逻辑,描述输出
// 三段式状态机示例:简单序列检测 "101"
module seq_detector (
input wire clk,
input wire rst_n,
input wire din,
output reg dout
);
// 状态编码
localparam IDLE = 2'b00,
S1 = 2'b01,
S2 = 2'b10,
S3 = 2'b11;
reg [1:0] state, next_state;
// 第一段:状态跳转
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
case (state)
IDLE: next_state = (din) ? S1 : IDLE;
S1: next_state = (din) ? S1 : S2;
S2: next_state = (din) ? S3 : IDLE;
S3: next_state = (din) ? S1 : S2;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(时序逻辑,寄存器输出)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
dout <= 1'b0;
else if (state == S3 && din)
dout <= 1'b1;
else
dout <= 1'b0;
end
endmodule
我的经验:第三段输出我习惯用时序逻辑,这样输出会寄存一拍,避免组合逻辑产生的毛刺。代价是多一个时钟周期的延迟,但换来的是信号干净。在Zynq的AXI总线接口设计中,这个做法尤其重要。
3.3.2 状态编码选择
| 编码方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 二进制编码 | 状态寄存器最少 | 组合逻辑复杂,译码延迟大 | 状态数多(>16个) |
| 格雷编码 | 相邻状态跳转只有1位变化 | 译码逻辑稍复杂 | 跨时钟域、低功耗 |
| 独热编码 | 译码逻辑简单,速度快 | 状态寄存器多(N个状态需N个触发器) | 状态数少(<8个),高速设计 |
我在Zynq上做加速器控制时,状态数通常不超过8个,所以一直用独热编码。速度最快,时序容易收敛。
3.4 Testbench编写:验证你的设计
写Testbench,说白了就是给你的设计造一个虚拟的测试环境。你给它喂激励,看它输出对不对。我见过太多人写完RTL就直接上板子调,结果烧一片废一片。其实花半小时写个Testbench,就能发现80%的低级错误。
3.4.1 Testbench基本结构
`timescale 1ns / 1ps
module tb_counter;
// 信号声明
reg clk;
reg rst_n;
reg en;
wire [7:0] count;
// 实例化待测模块
counter uut (
.clk (clk),
.rst_n (rst_n),
.en (en),
.count (count)
);
// 时钟生成
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns周期,100MHz
end
// 激励生成
initial begin
// 初始化
rst_n = 0;
en = 0;
#20;
rst_n = 1; // 释放复位
#10;
en = 1; // 使能计数
#100;
en = 0; // 停止计数
#50;
$finish;
end
// 波形输出
initial begin
$dumpfile("counter.vcd");
$dumpvars(0, tb_counter);
end
endmodule
3.4.2 常用Testbench技巧
- 时钟生成:用
forever配合#delay生成周期时钟。我习惯把时钟周期定义成参数,方便改频率。 - 激励序列:用
initial块按时间顺序给出激励。复杂场景可以用repeat、for循环生成大量测试向量。 - 自检机制:在Testbench中加入
$display打印关键信息,用assert做自动比对。别傻盯着波形看,眼睛会瞎的。
我曾经踩过的坑:写Testbench时忘了加 `timescale,结果仿真时间单位默认是1s,一个时钟周期等了2秒才跳变。我还以为是代码写错了,折腾了半天。所以,`timescale 一定要写在最前面。
3.5 本章知识体系
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。
3.6 小结
这一章的内容,是FPGA设计的基本功。语法速览让你能看懂代码,组合逻辑和时序逻辑让你知道代码对应什么电路,状态机让你能设计控制逻辑,Testbench让你能验证设计是否正确。