第三章 时序逻辑与状态机:让FPGA拥有“记忆”

各位同学,欢迎来到第三章。前两章我们聊了组合逻辑,说白了就是“输入一变,输出立马变”。但现实世界哪有这么简单?你想想看,一个按键按下去,CPU需要判断是“按下”还是“抖动”,这需要时间概念。FPGA要处理图像,一帧一帧的数据流,没有“记忆”怎么行?

这一章,我们就来聊聊FPGA的“记忆”是怎么实现的。触发器、寄存器、状态机、计数器……这些是时序逻辑的基石。我个人习惯把时序逻辑比作“有节拍的舞蹈”,时钟就是那个节拍器。好了,我们开始吧。

3.1 触发器与寄存器:存储的基本单元

先说触发器。FPGA里最常用的就是D触发器。它的核心功能就一句话:在时钟上升沿(或下降沿)到来时,把输入D的值“锁存”到输出Q上。其他时间,Q保持不变。

为什么需要这个?因为组合逻辑的输出有毛刺、有竞争冒险。触发器就像个“采样器”,只在时钟边沿那一刻采样,把干净的数据存下来。我在项目中遇到过,一个图像边缘检测算法,没用寄存器打拍,结果输出全是噪点。后来加了三级寄存器同步,问题就解决了。

多个D触发器并在一起,就是寄存器。比如8位寄存器,就是8个D触发器共享同一个时钟。在Verilog里,我们通常这样写:

// 一个简单的8位寄存器
reg [7:0] data_reg;

always @(posedge clk) begin
    data_reg <= data_in;  // 非阻塞赋值,注意!
end

这里有个关键点:非阻塞赋值“<=”。我刚开始学的时候,总搞不清“=”和“<=”的区别。简单说:在时序逻辑里,永远用“<=”。它表示“在时钟沿到来时,同时更新所有寄存器”。如果用“=”,可能会产生意想不到的竞争。

小技巧: 写时序逻辑时,养成习惯,always块里统一用“<=”。组合逻辑才用“=”。这个习惯能帮你避免90%的时序仿真问题。

3.2 同步复位 vs 异步复位

复位,就是把寄存器清零或置位。FPGA里有两种复位方式:同步复位和异步复位。很多新手会纠结选哪个。我直接说结论:我个人习惯,能用同步复位就用同步复位。

为什么?看代码:

// 同步复位
always @(posedge clk) begin
    if (rst_n) begin
        data_reg <= 8'b0;
    end else begin
        data_reg <= data_in;
    end
end

// 异步复位
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        data_reg <= 8'b0;
    end else begin
        data_reg <= data_in;
    end
end

异步复位的好处是“立即生效”,不需要等时钟沿。但问题也在这里:复位信号释放时,如果刚好不在时钟沿附近,容易导致寄存器进入亚稳态。我曾经在一个项目中,用了异步复位,结果板子偶尔上电后状态不对。查了两天才发现是复位释放时序有问题。

避坑指南: 如果你非要用异步复位,请务必做“异步复位、同步释放”处理。简单说,就是把异步复位信号用两级寄存器同步一下,再送给逻辑。这个电路我几乎在每个项目里都会用。

嗯,这里还要提一句:现在很多FPGA厂商(比如Xilinx)推荐使用同步复位,因为内部资源对同步复位有优化。你想想看,跟着厂商走,一般没错。

3.3 有限状态机(FSM)设计

状态机,是时序逻辑的“大脑”。图像处理里,状态机无处不在:摄像头配置、数据流控制、算法流水线调度……说白了,状态机就是“根据当前状态和输入,决定下一个状态和输出”。

FSM分两种:Moore型和Mealy型。Moore型的输出只取决于当前状态,Mealy型的输出还取决于输入。我个人偏好Moore型,因为输出更稳定,时序容易收敛。但Mealy型有时更节省状态数。

看一个简单的例子:检测串行数据中的“101”序列。

// 三段式状态机写法(推荐)
module fsm_101 (
    input clk,
    input rst_n,
    input 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 == S2 && din == 1'b1)
            dout <= 1'b1;
        else
            dout <= 1'b0;
    end

endmodule

这里我用了三段式状态机。为什么推荐三段式?因为:

  • 第一段:时序逻辑,负责状态更新。简单明了。
  • 第二段:组合逻辑,负责计算下一个状态。容易调试。
  • 第三段:时序逻辑,负责输出。输出没有毛刺。

我在项目中见过有人用一段式(全写在一个always里),代码是短了,但后期维护简直是噩梦。尤其是状态多了以后,根本分不清是组合逻辑还是时序逻辑的问题。

核心建议: 写状态机,永远用三段式。这是FPGA工程师的基本素养。

下面我用一张图来展示状态机的核心逻辑:

当前状态 下一状态 输入 组合逻辑 (次态计算) 时钟沿 反馈(当前状态更新) 输出

这张图展示了状态机的核心:当前状态和输入经过组合逻辑,算出下一状态;时钟沿到来时,下一状态变成新的当前状态。如此循环。

3.4 计数器与分频器

计数器,是状态机的一种特例。它就是个“数数”的电路。但别小看它,图像处理里,行计数、场计数、像素计数,全得靠它。

最简单的计数器:

reg [7:0] cnt;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 8'b0;
    else if (cnt == 8'd255)
        cnt <= 8'b0;
    else
        cnt <= cnt + 1'b1;
end

分频器,本质就是计数器。比如你想从50MHz得到1Hz的时钟,那就数50,000,000个周期,然后翻转输出。但注意:在FPGA里,尽量不要用分频后的时钟作为全局时钟。为什么?因为分频时钟的占空比、抖动都不如PLL生成的时钟好。

我曾经在一个项目中,直接用计数器分频做像素时钟,结果图像边缘有重影。后来换成PLL生成时钟,问题就没了。所以我的建议是:需要不同频率的时钟,用PLL或MMCM,别自己分频。计数器用来做使能信号更合适。

实用技巧: 用计数器生成“时钟使能”信号,而不是分频时钟。比如每数到100,产生一个高电平脉冲,用这个脉冲作为使能。这样所有逻辑仍然工作在同一个时钟域,时序分析简单得多。

好了,这一章的内容就到这里。时序逻辑是FPGA设计的核心,触发器是基础,状态机是骨架,计数器是工具。把这些搞明白,你就能处理大部分图像加速中的控制逻辑了。

可可精品资料库