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接口进来,先经过移位寄存器完成串转并,然后写入FIFO做跨时钟域缓冲,状态机控制整个流程的节奏,同时字节对齐和溢出检测模块在旁路监控异常情况。
嗯,接收通路的核心设计思路就是这样。每个模块各司其职,组合起来就是一个健壮的接收链路。我个人觉得,设计时最难的不是单个模块怎么写,而是模块之间的握手信号和时序配合——这个需要你在仿真和调试中慢慢积累经验。
最后一个小建议:写RTL之前,先画一张类似上面的结构图。把数据流、控制流、时钟域边界都标清楚。这样写代码的时候思路会清晰很多,也不容易漏掉边界情况。