3、硬件描述语言基础(Verilog/VHDL):寄存器与组合逻辑,状态机设计,时序约束入门

各位好,我是老张。今天咱们聊聊硬件描述语言的基础。说实话,很多做算法的同事一看到 Verilog 就头疼,觉得跟 C 语言长得像但用起来完全不是那么回事。嗯,确实如此。硬件描述语言描述的是电路,不是指令序列。这个思维转换,是入门的第一道坎。

我个人习惯把 HDL 看作「画电路图的文本工具」。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。想清楚这一点,很多问题就迎刃而解了。

3.1 寄存器与组合逻辑:数字电路的两大基石

数字电路说白了就两种东西:组合逻辑和时序逻辑。组合逻辑是纯门电路,输出只取决于当前输入;时序逻辑带记忆,输出还跟历史状态有关。寄存器就是时序逻辑的核心元件。

3.1.1 组合逻辑:没有记忆的「纯函数」

组合逻辑就像数学里的纯函数——输入一样,输出永远一样。常见的与或非门、加法器、多路选择器都属于组合逻辑。

在 Verilog 里,组合逻辑通常用 assign 连续赋值或者 always @(*) 过程块来描述。我建议初学者尽量用 assign,简单直观,不容易出错。

// 组合逻辑示例:半加器
module half_adder(
    input  wire a, b,
    output wire sum, cout
);
    assign sum  = a ^ b;    // 异或:和
    assign cout = a & b;    // 与:进位
endmodule

这里有个坑。我曾经见过一个同事,在 always @(*) 块里漏写了某个赋值分支,结果综合出来一个锁存器(latch)。锁存器这东西,时序分析时特别麻烦,能不用尽量别用。怎么避免?记住一条:组合逻辑的 always 块里,所有被赋值的变量在每个分支都要赋值,或者给个默认值。

避坑指南: 我曾经在项目里因为组合逻辑的敏感列表没写全,导致仿真和综合结果不一致。后来养成习惯,组合逻辑一律用 always @(*) 或者 always_comb(SystemVerilog),让工具自动推导敏感列表。

3.1.2 寄存器:数字电路的「记忆细胞」

寄存器本质上是 D 触发器的集合。它在时钟沿采样输入数据,并保持到下一个时钟沿。没有寄存器,数字电路就没法实现流水线、状态机、计数器这些高级功能。

Verilog 里描述寄存器很简单,用 always @(posedge clk) 块就行。注意:被赋值的变量要声明为 reg 类型。

// 寄存器示例:8位同步复位寄存器
module reg_8bit(
    input  wire       clk,
    input  wire       rst_n,   // 低电平复位
    input  wire [7:0] d,
    output reg  [7:0] q
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 8'b0;        // 异步复位
        else
            q <= d;           // 时钟沿采样
    end
endmodule

注意这里用的是非阻塞赋值 <=。为什么?因为寄存器赋值需要「同时发生」的效果。如果用阻塞赋值 =,仿真时会出现意想不到的时序问题。我刚开始学的时候也搞混过,后来记住一句话:时序逻辑用非阻塞,组合逻辑用阻塞。

特性 组合逻辑 寄存器(时序逻辑)
输出依赖 仅当前输入 当前输入 + 历史状态
时钟依赖 无 需要时钟沿触发
Verilog 赋值 =(阻塞) <=(非阻塞)
综合结果 门电路 触发器 + 门电路
典型用途 译码器、加法器、MUX 计数器、状态机、流水线

3.2 状态机设计:控制通路的「大脑」

状态机是数字电路里最常用的控制结构。3A 算法里的数据调度、流水线控制、握手协议,背后都是状态机在驱动。

我个人习惯把状态机分成三段式来写。为什么?因为可读性好,综合结果也干净。三段式分别是:

  1. 状态转移:当前状态 + 输入 → 下一状态
  2. 状态寄存器:时钟沿更新当前状态
  3. 输出逻辑:根据当前状态(或状态+输入)产生输出

你想想看,把这三个部分分开,调试的时候一眼就能看出问题在哪。我曾经接手过一个项目,状态机全写在一个 always 块里,两百多行代码,改一个状态要翻半天。后来我花了一周时间重构,拆成三段式,问题瞬间清晰了。

// 三段式状态机示例:简单交通灯控制
module traffic_light(
    input  wire       clk,
    input  wire       rst_n,
    input  wire       car_sensor,
    output reg  [1:0] light   // 00:红, 01:黄, 10:绿
);

    // 状态编码
    localparam IDLE    = 2'b00;
    localparam GREEN   = 2'b01;
    localparam YELLOW  = 2'b10;
    localparam RED     = 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 = car_sensor ? GREEN : IDLE;
            GREEN:  next_state = YELLOW;          // 固定时间后转黄灯
            YELLOW: next_state = RED;
            RED:    next_state = car_sensor ? GREEN : RED;
            default: next_state = IDLE;
        endcase
    end

    // 第三段:输出逻辑(组合逻辑)
    always @(*) begin
        case (state)
            IDLE:   light = 2'b00;  // 红灯
            GREEN:  light = 2'b10;  // 绿灯
            YELLOW: light = 2'b01;  // 黄灯
            RED:    light = 2'b00;  // 红灯
            default: light = 2'b00;
        endcase
    end
endmodule
个人经验: 状态编码尽量用独热码(one-hot)或者格雷码(Gray code)。独热码译码简单、速度快,适合 FPGA;格雷码相邻状态只变一位,适合低功耗场景。二进制编码虽然省寄存器,但组合逻辑大,时序容易出问题。

3.3 时序约束入门:让工具知道你的「期望」

写完了代码,综合工具怎么知道你的电路要跑多快?靠时序约束。说白了,时序约束就是告诉工具:「我希望这个路径在多少纳秒内完成」。

我刚开始做部署时,觉得时序约束是后端的事,跟我没关系。直到有一次,综合出来的网表仿真全对,上板子就是跑不稳。查了三天,发现是跨时钟域的路径没加约束,工具默认给了很松的限制。嗯,从那以后我再也不敢忽视时序约束了。

3.3.1 最基本的约束:时钟周期

最常用的约束就是定义时钟周期。SDC(Synopsys Design Constraints)格式里这样写:

# 定义时钟,周期10ns,占空比50%
create_clock -name clk -period 10.000 [get_ports clk]

# 定义输入延迟(相对于时钟沿)
set_input_delay -clock clk -max 2.000 [get_ports data_in]
set_input_delay -clock clk -min 0.500 [get_ports data_in]

# 定义输出延迟
set_output_delay -clock clk -max 3.000 [get_ports data_out]
set_output_delay -clock clk -min 1.000 [get_ports data_out]

这里有个关键点:set_input_delay 和 set_output_delay 描述的是外部器件的时序特性,不是芯片内部的。很多新手搞反了,以为这是芯片内部的延迟。其实它告诉工具:数据相对于时钟沿提前/滞后多久到达芯片引脚。

3.3.2 跨时钟域约束

3A 算法里经常有多个时钟域(比如 AXI 总线时钟和计算核时钟)。跨时钟域的路径必须加 set_false_path 或者用同步器处理。不加约束的话,工具会试图优化这些路径的时序,浪费大量资源。

# 跨时钟域路径,设为 false path
set_false_path -from [get_clocks clk_a] -to [get_clocks clk_b]
set_false_path -from [get_clocks clk_b] -to [get_clocks clk_a]
注意: 设 false path 的前提是你已经用同步器(双级触发器或异步 FIFO)处理了跨时钟域的数据传递。否则,直接设 false path 会导致亚稳态问题传播到下游逻辑。

3.4 本章知识体系

下面这张图总结了本章的核心知识点和它们之间的关系。我建议你把它当作学习路线图,按顺序逐个攻克。

硬件描述语言基础 - 知识体系 寄存器与组合逻辑 状态机设计 时序约束入门 组合逻辑 寄存器/触发器 三段式状态机 状态编码 时钟约束 IO约束 assign / always @(*) 阻塞赋值 vs 非阻塞赋值 避免锁存器 always @(posedge clk) 同步/异步复位 非阻塞赋值 状态转移 + 寄存器 + 输出 三段分离,可读性强 独热码 / 格雷码 create_clock set_input/output_delay set_false_path 核心:代码即电路,约束即期望

本章核心要点:

  • 组合逻辑没有记忆,用 assign 或 always @(*) 描述,注意避免锁存器。
  • 寄存器有时钟沿触发,用 always @(posedge clk) 描述,赋值用 <=。
  • 状态机推荐三段式写法:状态转移 + 状态寄存器 + 输出逻辑,分离清晰。
  • 时序约束是告诉工具你的时序期望,时钟约束是基础,跨时钟域要设 false path。

好了,这一章的内容就这些。记住:写 HDL 的时候,脑子里要时刻想着「这行代码会综合成什么电路」。有了这个意识,你离资深工程师就不远了。