4. Verilog RTL设计-接收通路:接收移位寄存器、接收FIFO设计、接收状态机、字节对齐与溢出处理

接收通路,说白了就是SPI从机怎么把数据「吃进来」的整个过程。我做了这么多年接口设计,发现接收通路出问题的概率其实比发送通路高不少——因为你要处理的是外部来的异步信号,时钟域、边沿、字节对齐,哪个环节没想清楚,仿真时可能看不出来,一上板就翻车。

今天咱们就把接收通路的四个核心模块拆开揉碎了讲。我个人习惯是从数据流的角度去理解:先有移位寄存器把串行数据攒成并行,再用FIFO做跨时钟域缓冲,状态机控制整个流程,最后处理字节对齐和溢出。嗯,咱们一个一个来。

4.1 接收移位寄存器设计

接收移位寄存器,说白了就是个「串转并」的桶。SPI协议里,数据是在SCK的边沿被采样的——从机模式下,我们通常用SCK的上升沿采样MISO上的数据。

我记得刚入行时有个坑:移位方向。SPI有四种模式(CPOL/CPHA组合),但不管哪种模式,数据都是从MSB开始移还是LSB开始移,这个得看你的协议配置。我一般会在寄存器里留一个bit来控制方向,这样兼容性最好。

核心设计要点:

  • 移位寄存器宽度 = 数据位宽(常见8/16/32位)
  • 每个SCK有效边沿移入1bit
  • 移位完成信号(shift_done)用于触发后续逻辑
  • 支持MSB-first和LSB-first两种模式

来看一段我常用的代码框架:

// 接收移位寄存器
reg [DATA_WIDTH-1:0] rx_shift_reg;
reg shift_done;

always @(posedge sck or negedge rst_n) begin
    if (!rst_n) begin
        rx_shift_reg <= 'b0;
        shift_done <= 1'b0;
    end else begin
        if (sck_negedge && cs_n == 1'b0) begin  // 下降沿采样
            if (msb_first) begin
                rx_shift_reg <= {rx_shift_reg[DATA_WIDTH-2:0], miso};
            end else begin
                rx_shift_reg <= {miso, rx_shift_reg[DATA_WIDTH-1:1]};
            end
        end
        
        // 移位完成判断
        if (bit_count == DATA_WIDTH-1 && sck_posedge) begin
            shift_done <= 1'b1;
        end else begin
            shift_done <= 1'b0;
        end
    end
end

我的经验:移位完成信号最好只拉一个时钟周期,不要持续拉高。否则后面接的状态机容易误触发。我曾经因为这个bug调了整整两天,最后发现是脉冲宽度没处理好。

4.2 接收FIFO设计

为什么要加FIFO?你想想看,SPI的时钟SCK和系统时钟clk通常是异步的。移位寄存器工作在SCK域,而后续的数据处理在系统时钟域。跨时钟域同步,FIFO是最稳妥的方案。

我建议用同步FIFO配合双时钟域处理——什么意思呢?写时钟用SCK域,读时钟用系统时钟域。但要注意,FIFO深度要能覆盖最坏情况下的延迟。

参数 推荐值 说明
FIFO深度 8或16 取决于系统响应延迟
数据宽度 等于移位寄存器宽度 8/16/32位
写使能 shift_done脉冲 每个字节写入一次
读使能 状态机控制 系统时钟域读取

这里有个细节:FIFO的空满标志必须用格雷码做跨时钟域同步。我见过有人直接用二进制计数器跨时钟域,结果仿真没问题,上板后偶尔读错数据——这就是典型的亚稳态问题。

注意:FIFO的写时钟域和读时钟域频率可能相差很大。比如SCK只有1MHz,系统时钟是100MHz。这种情况下,写使能脉冲在系统时钟域看起来非常窄,一定要做脉冲同步或展宽处理。

4.3 接收状态机设计

接收状态机是整个接收通路的大脑。它要协调移位寄存器、FIFO、以及上层接口之间的交互。我个人习惯用三段式状态机,清晰且容易维护。

状态机通常包含以下几个状态:

  • IDLE:等待片选信号CS_N拉低
  • RECEIVE:正在接收数据,等待移位完成
  • WRITE_FIFO:将移位寄存器的数据写入FIFO
  • CHECK_ALIGN:检查字节对齐状态
  • ERROR:溢出或对齐错误处理

为什么会需要CHECK_ALIGN状态?因为SPI传输不一定总是字节对齐的。比如主机发了9个时钟周期,你配置的是8位数据模式,那最后1bit怎么办?这就是字节对齐要处理的问题。

// 接收状态机(简化版)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_state <= IDLE;
    end else begin
        case (rx_state)
            IDLE: begin
                if (cs_n == 1'b0)
                    rx_state <= RECEIVE;
            end
            
            RECEIVE: begin
                if (shift_done)
                    rx_state <= WRITE_FIFO;
                else if (cs_n == 1'b1)  // 片选提前拉高
                    rx_state <= CHECK_ALIGN;
            end
            
            WRITE_FIFO: begin
                if (fifo_full)
                    rx_state <= ERROR;  // 溢出处理
                else
                    rx_state <= RECEIVE;  // 继续接收
            end
            
            CHECK_ALIGN: begin
                // 检查剩余bit数
                if (bit_count_remain == 0)
                    rx_state <= IDLE;
                else
                    rx_state <= ERROR;  // 非对齐错误
            end
            
            ERROR: begin
                // 等待主机重新发起传输
                if (cs_n == 1'b1)
                    rx_state <= IDLE;
            end
        endcase
    end
end

避坑指南:我曾经在RECEIVE状态里忘了处理CS_N中途拉高的情况。结果主机提前结束传输时,状态机卡死在RECEIVE,再也回不去了。所以一定要在所有状态里都考虑CS_N的异步拉高。

4.4 字节对齐与溢出处理

字节对齐,说白了就是保证你收到的数据能正确拼成完整的字节。SPI协议本身没有字节对齐的强制要求,但你的FIFO是按字节存的,不对齐的数据存进去就是错的。

我常用的做法是维护一个bit计数器。每收到一个bit,计数器加1。当计数器达到8的倍数时,说明一个完整的字节已经收齐,可以写入FIFO。如果传输结束时计数器不是8的倍数,那就报对齐错误。

溢出处理更直接——FIFO满了你还往里写,数据就丢了。我的做法是:

  • 在WRITE_FIFO状态检查fifo_full信号
  • 如果满了,丢弃当前数据,进入ERROR状态
  • 置位溢出标志位,供软件查询
  • 等待主机重新发起传输

关键设计决策:溢出时是丢弃旧数据还是新数据?我个人建议丢弃新数据,保留旧数据。因为旧数据可能是之前已经部分处理过的,丢了会影响整个数据流的连续性。而新数据丢了,主机重传一次就好。

4.5 整体接收通路结构

下面我用一张SVG图来展示接收通路的整体结构,方便你理解各个模块之间的连接关系:

SPI接收通路整体结构图 接收移位寄存器 串行→并行转换 SCK域 接收FIFO 跨时钟域缓冲 SCK域→CLK域 接收状态机 流程控制 CLK域 字节对齐 & 溢出 错误检测与处理 CLK域 shift_done 数据[7:0] fifo_empty fifo_full 读/写控制 错误标志 来自SPI接口 到上层模块 SCK时钟域 系统时钟域 (CLK) 数据流向: 串行→并行 缓冲 控制

从这张图可以看得很清楚:数据从SPI接口进来,先经过移位寄存器完成串转并,然后写入FIFO做跨时钟域缓冲,状态机控制整个流程的节奏,同时字节对齐和溢出检测模块在旁路监控异常情况。

嗯,接收通路的核心设计思路就是这样。每个模块各司其职,组合起来就是一个健壮的接收链路。我个人觉得,设计时最难的不是单个模块怎么写,而是模块之间的握手信号和时序配合——这个需要你在仿真和调试中慢慢积累经验。

最后一个小建议:写RTL之前,先画一张类似上面的结构图。把数据流、控制流、时钟域边界都标清楚。这样写代码的时候思路会清晰很多,也不容易漏掉边界情况。

可可精品资料库