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块:

  1. 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
  2. 第二段:组合逻辑,描述次态产生的条件(状态转移条件)
  3. 第三段:组合逻辑或时序逻辑,描述输出
// 三段式状态机示例:简单序列检测 "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 本章知识体系

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。

第三章知识体系:Verilog HDL基础 第三章:Verilog HDL 基础 — 知识体系 语法速览 组合逻辑 时序逻辑 状态机设计 模块 / wire / reg 阻塞 vs 非阻塞赋值 assign / always @(*) 无记忆 / 无时钟 always @(posedge clk) 非阻塞赋值 <= 三段式写法 独热 / 格雷 / 二进制 Testbench 编写 时钟生成 / 激励序列 自检机制 / assert 波形导出 / 调试

3.6 小结

这一章的内容,是FPGA设计的基本功。语法速览让你能看懂代码,组合逻辑和时序逻辑让你知道代码对应什么电路,状态机让你能设计控制逻辑,Testbench让你能验证设计是否正确。