3、SPI发送数据通路设计

好,咱们今天聊聊SPI发送数据通路。这块儿说白了,就是数据怎么从你写进去,到最终从MOSI脚上飞出去的全过程。我当年第一次做SPI控制器时,觉得这玩意儿简单,结果调试时发现数据老是对不上,后来才发现是发送FIFO和移位寄存器之间的握手出了问题。嗯,咱们一步步拆开看。

3.1 发送FIFO设计

发送FIFO的作用,说白了就是个缓冲池。CPU或者DMA把数据扔进来,然后发送逻辑按自己的节奏取走。为什么要加这个?你想想看,如果CPU每次发一个字节都要等移位寄存器空,那效率得多低。我习惯用同步FIFO,深度一般取8或16,具体看系统需求。

FIFO的核心信号就这几个:

  • wr_en:写使能,CPU写数据时拉高
  • rd_en:读使能,发送状态机取数据时拉高
  • full:满标志,满了就不能再写
  • empty:空标志,空了就不能再读
  • data_in:写入的数据
  • data_out:读出的数据

这里有个坑,我曾经遇到过:FIFO的满标志判断一定要用格雷码跨时钟域处理,否则在高速场景下容易误判。嗯,这个后面跨时钟域章节会细讲,先记住有这么回事。

设计要点:

  • FIFO深度建议取2的幂次,方便地址指针回绕
  • 空/满标志用组合逻辑产生,但要注意时序
  • 读使能信号必须与时钟同步,避免亚稳态

3.2 移位寄存器设计

移位寄存器是发送通路的核心。它从FIFO取出并行数据,然后按位串行移出去。SPI有四种模式,移位时机取决于时钟极性和相位。我个人习惯把移位寄存器设计成可配置的,这样一套代码能兼容所有模式。

基本结构是这样的:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        shift_reg <= 'd0;
        bit_cnt   <= 'd0;
    end else if (shift_en) begin
        // 根据CPOL和CPHA决定移位时机
        if (cpha == 1'b0) begin
            // 在时钟第一个边沿移位
            shift_reg <= {shift_reg[WIDTH-2:0], 1'b0};
        end else begin
            // 在时钟第二个边沿移位
            shift_reg <= {shift_reg[WIDTH-2:0], 1'b0};
        end
        bit_cnt <= bit_cnt + 1'b1;
    end else if (load_en) begin
        // 从FIFO加载新数据
        shift_reg <= fifo_data;
        bit_cnt   <= 'd0;
    end
end

注意看,这里有个load_en信号,它由发送状态机产生。当移位寄存器发完一个字节后,状态机就会拉高load_en,从FIFO取新数据。我曾经犯过一个错误:忘了在加载数据时清零bit_cnt,结果下一个字节的位数对不上,调试了一整天才发现。

小技巧:移位寄存器的宽度建议参数化,比如用parameter DATA_WIDTH = 8。这样以后想改成16位数据宽度,改一个参数就行,不用到处改代码。

3.3 波特率发生器设计

波特率发生器,说白了就是分频器。系统时钟太高,SPI从机跟不上,所以得降频。我一般用计数器实现,分频系数可配。

设计思路:

  1. 输入系统时钟 clk,输出SPI时钟 sclk
  2. 通过配置寄存器 baud_div 控制分频比
  3. 计数器从0计到 baud_div-1,然后翻转sclk

举个例子,系统时钟50MHz,想要1MHz的SPI时钟,分频系数就是25。注意,这里分频系数是半周期计数,所以实际sclk频率 = clk / (2 * baud_div)。

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt <= 'd0;
        sclk <= 1'b0;
    end else if (cnt == baud_div - 1) begin
        cnt <= 'd0;
        sclk <= ~sclk;
    end else begin
        cnt <= cnt + 1'b1;
    end
end

嗯,这里要注意:分频系数不能为0,否则计数器会出问题。我一般在配置寄存器里加个保护逻辑,如果写0就默认用2分频。

3.4 发送状态机设计

发送状态机是整个发送通路的指挥官。它协调FIFO、移位寄存器和波特率发生器的工作。我习惯用三段式状态机,清晰又好维护。

状态定义:

状态 说明
IDLE 空闲状态,等待发送触发
LOAD 从FIFO加载数据到移位寄存器
SHIFT 移位发送,每个时钟边沿移出一位
WAIT 等待FIFO非空,用于连续发送

状态转移逻辑:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state <= IDLE;
    end else begin
        case (state)
            IDLE: begin
                if (tx_start) state <= LOAD;
            end
            LOAD: begin
                state <= SHIFT;
            end
            SHIFT: begin
                if (bit_cnt == DATA_WIDTH-1) begin
                    if (!fifo_empty) state <= LOAD;  // 连续发送
                    else              state <= IDLE;
                end
            end
            default: state <= IDLE;
        endcase
    end
end

这里有个设计细节:在SHIFT状态结束时,如果FIFO还有数据,就直接进入LOAD状态继续发,不用回到IDLE。这样可以实现连续发送,提高吞吐量。我最初的设计是每发完一个字节就回IDLE,结果发现效率低了一半,后来才改成现在这样。

注意:状态机输出信号要用组合逻辑或寄存器打一拍,避免产生毛刺。特别是cs_n(片选)信号,必须在发送开始前拉低,结束后拉高,中间不能抖动。

整体数据流

说了这么多,咱们用一张图把整个发送通路串起来:

SPI发送数据通路结构图 发送FIFO 深度: 8/16 移位寄存器 并行→串行 波特率发生器 分频系数可配 发送状态机 IDLE→LOAD→SHIFT data_out load_en/shift_en rd_en sclk MOSI 输出

从图上可以看得很清楚:发送状态机是大脑,它从FIFO读数据,控制移位寄存器加载和移位,同时波特率发生器提供时钟节拍。数据从FIFO到移位寄存器,再一位一位从MOSI脚送出去。整个流程环环相扣,任何一个环节出问题,数据就发不出去。

好了,发送数据通路就讲这么多。记住一句话:FIFO管缓冲,移位寄存器管串行化,波特率发生器管节奏,状态机管协调。把这四个模块搞明白,SPI发送这块儿就稳了。

避坑总结:

  • FIFO满空标志一定要可靠,否则数据会丢或重复
  • 移位寄存器加载时要同步清零bit_cnt
  • 波特率分频系数不能为0
  • 状态机输出要防毛刺,特别是片选信号

可可精品资料库