2. FPGA基础与Verilog实战:组合逻辑与时序逻辑、有限状态机设计、AXI4-Stream接口实战

好,咱们直接进入正题。FPGA 开发,说白了就是两件事:逻辑设计和接口通信。逻辑设计是内功,接口通信是招式。这一章,我把这两块的核心都给你拆开揉碎。

我个人习惯,学 FPGA 先别急着上板子跑。先把组合逻辑、时序逻辑、状态机这三个基本功练扎实。然后再看 AXI4-Stream 这种总线,你会发现它其实就是个带握手的移位寄存器。

2.1 组合逻辑与时序逻辑:数字电路的两大基石

很多初学者分不清组合逻辑和时序逻辑的区别。我打个比方:组合逻辑就像一扇门,输入一变,输出立刻变;时序逻辑就像一个水桶,只有时钟沿来了,才舀一瓢水进去。

对比项 组合逻辑 时序逻辑
输出依赖 仅当前输入 当前输入 + 历史状态
存储能力 无 有(寄存器/锁存器)
时钟驱动 不需要 必须有时钟沿
典型电路 与门、加法器、多路选择器 计数器、移位寄存器、状态机
Verilog 描述 assign 或 always @(*) always @(posedge clk)

2.1.1 组合逻辑:别让它产生锁存器

写组合逻辑时,最容易犯的错误就是——不小心综合出锁存器(Latch)。我在项目中遇到过一位同事,写了一个 always @(*) 块,if 条件没写 else,结果综合出来一堆锁存器,时序怎么都跑不通。

避坑指南: 在组合逻辑的 always @(*) 块中,所有分支都必须赋值。要么写全 if-else,要么用 case 加 default。否则综合工具会认为你想保持上一个值,从而生成锁存器。

来看一个正确的组合逻辑例子——4 选 1 多路选择器:

module mux4to1 (
    input  [1:0] sel,
    input  [3:0] data_in,
    output reg   data_out
);
    always @(*) begin
        case (sel)
            2'b00: data_out = data_in[0];
            2'b01: data_out = data_in[1];
            2'b10: data_out = data_in[2];
            2'b11: data_out = data_in[3];
            default: data_out = 1'b0;  // 必须写
        endcase
    end
endmodule

2.1.2 时序逻辑:时钟沿才是老大

时序逻辑的核心就一句话:所有寄存器更新,都发生在时钟沿。你想想看,如果两个寄存器之间有一堆组合逻辑,那信号从第一个寄存器传到第二个寄存器,需要时间。这个时间不能超过一个时钟周期。

我曾经调试过一个 SPI 接口,数据老是错位。查了半天,发现是跨时钟域的信号没做同步处理。嗯,这里要注意:跨时钟域信号必须用两级寄存器同步,否则亚稳态会让你怀疑人生。

// 跨时钟域同步器(两级寄存器)
module sync_2ff (
    input  wire       clk_dst,
    input  wire       async_in,
    output reg        sync_out
);
    reg sync_ff1;
    always @(posedge clk_dst) begin
        sync_ff1 <= async_in;
        sync_out <= sync_ff1;
    end
endmodule

2.2 有限状态机设计:控制逻辑的灵魂

有限状态机(FSM)是 FPGA 控制逻辑的核心。我个人的经验是:80% 的控制逻辑都可以用状态机来实现。状态机写得好,代码可读性高,调试也方便。

FSM 分两种:

  • Moore 型:输出只取决于当前状态
  • Mealy 型:输出取决于当前状态 + 输入

实际项目中,我更喜欢用 Moore 型。为什么?因为输出和时钟同步,不容易出毛刺。Mealy 型虽然更灵活,但组合逻辑输出容易产生毛刺,需要额外处理。

2.2.1 三段式状态机写法

我强烈推荐三段式状态机写法。三段式把状态跳转、状态寄存、输出逻辑分开,代码清晰,综合结果也好。

module fsm_example (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       start,
    output reg        done
);
    // 状态编码
    localparam IDLE  = 2'b00;
    localparam WORK  = 2'b01;
    localparam DONE  = 2'b10;

    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
        next_state = state;
        case (state)
            IDLE: if (start) next_state = WORK;
            WORK: next_state = DONE;
            DONE: next_state = IDLE;
            default: next_state = IDLE;
        endcase
    end

    // 第三段:输出逻辑(组合逻辑或时序逻辑)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            done <= 1'b0;
        else if (state == DONE)
            done <= 1'b1;
        else
            done <= 1'b0;
    end
endmodule
个人技巧: 第二段次态逻辑中,我习惯先写 next_state = state 作为默认值,这样 case 里只需要写跳转条件,省去很多 else 分支。

2.3 AXI4-Stream 接口实战:FPGA 与 Zynq 的通信桥梁

AXI4-Stream 是 Xilinx 生态里最常用的高速流式接口。说白了,它就是一个带握手的移位寄存器。数据在时钟沿传输,通过 TVALID 和 TREADY 握手来控制节奏。

我在做图像加速项目时,整个数据通路全是 AXI4-Stream。从 DDR 读出来的像素数据,经过缩放、滤波、色彩转换,最后输出到显示,全部用 Stream 接口串联。调试起来非常方便,因为每个模块的接口都一样。

2.3.1 握手协议:VALID 和 READY

AXI4-Stream 的握手规则很简单:

  • 发送方:当数据有效时,拉高 TVALID
  • 接收方:当准备好接收时,拉高 TREADY
  • 数据传输:在 TVALID && TREADY 同时为高的时钟沿,数据被传输

你想想看,如果接收方还没准备好,发送方就得等。这就是流控。我曾经遇到过一个 bug,接收方 TREADY 一直为低,结果发送方数据堵在 FIFO 里,最后溢出。嗯,这里要注意:一定要确保握手信号正确,否则数据会丢。

// AXI4-Stream 发送端示例
module axis_sender (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [7:0]  data_in,
    input  wire        data_valid,
    output reg         axis_tvalid,
    output reg  [7:0]  axis_tdata,
    input  wire        axis_tready
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            axis_tvalid <= 1'b0;
            axis_tdata  <= 8'd0;
        end else if (data_valid && axis_tready) begin
            axis_tvalid <= 1'b1;
            axis_tdata  <= data_in;
        end else if (axis_tready) begin
            axis_tvalid <= 1'b0;
        end
    end
endmodule

2.3.2 典型应用:数据流处理流水线

AXI4-Stream 最大的优势是模块化。每个处理模块都长一个样:输入 Stream,输出 Stream。你可以像搭积木一样把模块串起来。

下面这张图展示了典型的 Stream 处理流水线:

AXI4-Stream 数据流处理流水线 数据源 预处理 算法加速 后处理 TVALID/TREADY TVALID/TREADY TVALID/TREADY 每个模块通过 AXI4-Stream 接口连接,握手信号控制数据流动 模块内部可以包含 FIFO 缓冲,用于处理速率不匹配

2.3.3 实战:用 AXI4-Stream 实现数据包检测

最后,咱们来个实战例子。假设你要检测数据流中的特定包头(比如 0xAA),检测到后拉高一个标志信号。用 AXI4-Stream 实现非常简单:

module packet_detector (
    input  wire        clk,
    input  wire        rst_n,
    // Slave 接口(输入)
    input  wire [7:0]  s_axis_tdata,
    input  wire        s_axis_tvalid,
    output reg         s_axis_tready,
    // Master 接口(输出)
    output reg  [7:0]  m_axis_tdata,
    output reg         m_axis_tvalid,
    input  wire        m_axis_tready,
    // 检测结果
    output reg         packet_detected
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            s_axis_tready   <= 1'b0;
            m_axis_tvalid   <= 1'b0;
            m_axis_tdata    <= 8'd0;
            packet_detected <= 1'b0;
        end else begin
            // 默认准备好接收
            s_axis_tready <= 1'b1;
            
            // 当输入有效且输出准备好时,转发数据
            if (s_axis_tvalid && m_axis_tready) begin
                m_axis_tvalid <= 1'b1;
                m_axis_tdata  <= s_axis_tdata;
                
                // 检测包头
                if (s_axis_tdata == 8'hAA)
                    packet_detected <= 1'b1;
                else
                    packet_detected <= 1'b0;
            end else begin
                m_axis_tvalid <= 1'b0;
            end
        end
    end
endmodule
核心要点: AXI4-Stream 的设计哲学是解耦。发送方不用关心接收方内部怎么处理,只要握手成功,数据就传过去了。这种设计让 FPGA 工程师可以专注于每个模块的内部逻辑,而不必操心模块间的时序协调。