4. SPI发送路径设计:发送FIFO设计、移位寄存器实现、数据串行化过程、MSB/LSB优先配置

好,咱们今天聊聊SPI发送路径。这部分说白了,就是数据怎么从你的核心逻辑,一步一步变成串行比特流,从MOSI管脚飞出去。我做了这么多年SPI控制器,发送路径其实是整个模块里最容易出bug的地方,尤其是时序和位序问题。

4.1 发送FIFO设计:为什么要加这个缓冲区?

你想想看,CPU或者DMA往SPI控制器里丢数据,速度是不固定的。而SPI的SCK时钟,频率可能很低,也可能很高。如果没有一个缓冲区,CPU就得掐着点等SPI发完一个字节才能写下一个,效率太低了。

发送FIFO的作用,就是解耦这两个时钟域或速率域。CPU只管往FIFO里写,SPI移位寄存器只管从FIFO里读,各干各的。

核心参数:
  • 深度:我一般用8或16。太深浪费面积,太浅容易溢出。如果系统DMA响应快,8深度足够。
  • 宽度:跟数据寄存器宽度一致,通常是8位。但有些控制器支持16位或32位,那就按最大位宽来。
  • 空/满标志:必须要有。空标志告诉移位寄存器“没数据了,别拉了”;满标志告诉CPU“你慢点写”。

我个人习惯用双端口RAM + 读写指针来实现FIFO。格雷码跨时钟域处理?嗯,如果读写时钟不同源,确实需要。但很多场景下SPI控制器和系统总线是同步的,直接用二进制指针加同步器就行。

避坑指南:我曾经在一个项目里,FIFO深度只设了4,结果CPU突发写了8个字节,直接溢出。后来我加了个“几乎满”标志,提前通知DMA暂停。记住:FIFO深度要结合系统最大突发长度来算。

4.2 移位寄存器实现:数据串行化的核心

移位寄存器,说白了就是一个可以左右移位的寄存器。SPI发送时,每个SCK时钟沿,把寄存器里的数据推出去一位。

我常用的结构是这样的:

// 发送移位寄存器,支持MSB/LSB优先
reg [DATA_WIDTH-1:0] shift_reg;
reg [CNT_WIDTH-1:0]  bit_cnt;  // 位计数器

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        shift_reg <= 'b0;
        bit_cnt   <= 'b0;
    end else if (tx_start) begin
        // 从FIFO读取数据,加载到移位寄存器
        shift_reg <= fifo_rdata;
        bit_cnt   <= DATA_WIDTH - 1;
    end else if (tx_shift_en) begin
        // 每个SCK时钟沿,移出一位
        if (msb_first) begin
            shift_reg <= {shift_reg[DATA_WIDTH-2:0], 1'b0};  // MSB优先
        end else begin
            shift_reg <= {1'b0, shift_reg[DATA_WIDTH-1:1]};  // LSB优先
        end
        bit_cnt <= bit_cnt - 1;
    end
end

// MOSI输出:取最高位或最低位
assign mosi = msb_first ? shift_reg[DATA_WIDTH-1] : shift_reg[0];

这里有个细节:tx_shift_en信号什么时候产生?它应该跟SCK的边沿对齐。如果是CPOL=0, CPHA=0模式,在SCK上升沿采样,那就在SCK下降沿移位。这样数据在SCK上升沿时已经稳定了。

注意:移位寄存器的加载时机和移位时机,必须严格错开。我见过有人把加载和移位放在同一个时钟沿,结果数据还没稳定就被推出去了,整个字节错位。

4.3 数据串行化过程:从并行到串行的完整流程

咱们把整个过程串起来看看:

  1. FIFO非空:移位寄存器空闲时,从FIFO读取一个字节。
  2. 加载数据:在SCK空闲期间,把数据打入移位寄存器。
  3. 逐位移出:每个SCK时钟沿,移出一位到MOSI。
  4. 传输完成:位计数器归零,产生tx_done信号,通知FIFO可以读下一个数据。

这个过程,我用一张图来展示:

发送FIFO 深度: 8, 宽度: 8 fifo_rdata[7:0] 移位寄存器 8位,可左/右移 bit_cnt: 7→0 mosi (串行) MOSI 管脚输出 控制逻辑 tx_start, tx_shift_en, tx_done 控制信号 MSB/LSB配置 msb_first 寄存器 配置位序 SPI发送路径数据流

这张图把整个发送路径串起来了。你注意看,控制逻辑是大脑,它决定什么时候加载、什么时候移位、什么时候通知FIFO。

4.4 MSB/LSB优先配置:一个寄存器搞定

为什么需要这个配置?因为不同的从设备,数据格式不一样。有些传感器先发高位,有些LCD屏先发低位。如果控制器不支持配置,你就得在软件里手动翻转字节,麻烦不说,还容易出错。

实现方式其实很简单:

  • MSB优先:移位方向向左,每次取最高位输出。比如数据0xA5 (1010_0101),先发1,再发0,以此类推。
  • LSB优先:移位方向向右,每次取最低位输出。同样0xA5,先发1,再发0,再发1... 嗯,顺序完全反过来了。

代码里,我通常用一个寄存器msb_first来控制:

// 位序选择
assign mosi = msb_first ? shift_reg[DATA_WIDTH-1] : shift_reg[0];

// 移位方向
always @(posedge clk) begin
    if (tx_shift_en) begin
        if (msb_first)
            shift_reg <= {shift_reg[DATA_WIDTH-2:0], 1'b0};  // 左移
        else
            shift_reg <= {1'b0, shift_reg[DATA_WIDTH-1:1]};  // 右移
    end
end
我的经验:有些工程师喜欢用case语句分别处理MSB和LSB,其实没必要。一个mux加两种移位方向,代码更简洁。另外,记得在复位时把msb_first设为默认值(通常是MSB优先),这样兼容性最好。

4.5 避坑总结

最后,我把自己踩过的坑列一下,你写代码时注意:

问题 现象 解决方案
FIFO深度不足 数据丢失,通信错乱 根据最大突发长度计算深度,加“几乎满”标志
移位寄存器加载与移位冲突 第一个bit错误 加载和移位错开一个SCK周期
MSB/LSB配置没同步到移位方向 数据位序颠倒 确保配置同时影响输出选择和移位方向
位计数器溢出 多移或少移一位 计数器到0后立即停止移位,等待下次加载

嗯,发送路径就这些内容。说白了,FIFO是缓冲,移位寄存器是核心,MSB/LSB配置是灵活性。把这三点搞明白,发送路径基本不会出大问题。

可可精品资料库