发送通路设计:从FIFO到状态机的完整链路
大家好,我是你们的SPI设计课讲师。今天我们来聊聊发送通路——说白了,就是数据怎么从你的逻辑核心,一路送到SPI的MOSI引脚上。这条路径上有四个关键模块:发送FIFO、移位寄存器、波特率发生器,还有那个控制一切的发送状态机。
我个人习惯把发送通路比作一条流水线。FIFO是仓库,移位寄存器是包装工,波特率发生器是节拍器,状态机就是那个喊口号的工头。嗯,咱们一个一个来看。
核心链路: 软件写数据 → FIFO缓存 → 状态机调度 → 移位寄存器串行化 → 波特率控制输出 → MOSI引脚
1. 发送FIFO设计:别让CPU等你
FIFO的作用说白了就是解耦。CPU写数据的速度和SPI发送的速度往往不匹配。你想想看,CPU可能一次突发写8个字节,但SPI要一个一个bit往外送。没有FIFO,CPU就得干等着。
设计要点:
- 深度选择: 我一般选8或16深度。太浅了容易溢出,太深了浪费面积。我在一个项目中遇到过深度选4的情况,结果CPU一次中断处理16字节,FIFO直接爆了。
- 宽度: 跟SPI数据宽度一致,通常是8位。但有些控制器支持可配置宽度(4/8/16位),这时候FIFO宽度要取最大值。
- 空/满标志: 这是必须的。空标志给状态机用,满标志给中断控制器用。
我的经验: 异步FIFO的格雷码指针同步,跨时钟域处理一定要小心。我曾经因为格雷码没处理好,导致空标志误判,数据还没准备好状态机就开始移位了——结果发出去的全是垃圾。
// 发送FIFO接口示例
module tx_fifo (
input wire clk,
input wire rst_n,
// 写侧(CPU/总线侧)
input wire wr_en,
input wire [7:0] wr_data,
output wire full,
// 读侧(状态机侧)
input wire rd_en,
output wire [7:0] rd_data,
output wire empty
);
// 这里用同步FIFO实现,深度8
reg [7:0] mem [0:7];
reg [2:0] wr_ptr, rd_ptr;
reg [3:0] count; // 计数法判断空满
assign full = (count == 8);
assign empty = (count == 0);
// ... 读写指针更新逻辑
endmodule
2. 移位寄存器实现:串行化的艺术
移位寄存器,说白了就是把并行数据变成串行bit流。SPI协议里,数据是在时钟边沿逐位移出的。
实现方式:
- 左移还是右移? 取决于MSB first还是LSB first。SPI标准是MSB first,但有些器件支持LSB first。我建议设计成可配置的。
- 移位方向: 用case语句或者条件赋值。我个人喜欢用移位操作符 << 或 >>,代码更简洁。
- 加载时机: 状态机在LOAD状态把FIFO数据加载到移位寄存器。
注意: 移位寄存器的位宽必须和FIFO数据位宽一致。如果SPI支持4/8/16位可配置,移位寄存器要设计成最大位宽,然后用计数器控制实际移位的位数。
// 移位寄存器核心逻辑
reg [7:0] shift_reg;
reg [2:0] bit_cnt; // 位计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
shift_reg <= 8'h00;
bit_cnt <= 3'd0;
end else if (load_en) begin
shift_reg <= fifo_data; // 从FIFO加载
bit_cnt <= 3'd7; // 8位数据,从高位开始
end else if (shift_en) begin
shift_reg <= {shift_reg[6:0], 1'b0}; // 左移,MSB先出
bit_cnt <= bit_cnt - 1'b1;
end
end
assign mosi = shift_reg[7]; // 最高位输出到MOSI
3. 波特率发生器:节奏的把控者
波特率发生器,说白了就是一个分频器。系统时钟比如50MHz,SPI时钟可能只需要1MHz,那就分频50倍。
设计考量:
- 分频系数: 通常用寄存器配置,比如baud_div[15:0]。分频值 = 系统时钟 / (2 * 目标SPI时钟)。
- 占空比: SPI时钟要求50%占空比吗?不一定。CPOL=0时,时钟空闲为低,第一个边沿是上升沿。CPOL=1时相反。但占空比50%是最通用的。
- 使能控制: 状态机控制波特率发生器的启动和停止。发送期间才产生时钟,空闲时时钟保持。
避坑指南: 我曾经在波特率发生器里用了组合逻辑分频,结果时钟毛刺一大堆。后来老老实实用计数器+触发器分频,问题解决。记住:SPI时钟一定要用寄存器输出,别用组合逻辑。
// 波特率发生器
reg [15:0] clk_cnt;
reg sclk;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
clk_cnt <= 16'd0;
sclk <= 1'b0;
end else if (tx_active) begin // 状态机控制使能
if (clk_cnt == baud_div) begin
clk_cnt <= 16'd0;
sclk <= ~sclk; // 翻转产生时钟
end else begin
clk_cnt <= clk_cnt + 1'b1;
end
end else begin
clk_cnt <= 16'd0;
sclk <= (cpol) ? 1'b1 : 1'b0; // 空闲电平
end
end
4. 发送状态机:IDLE → LOAD → SHIFT → DONE
这是发送通路的大脑。四个状态,循环往复。
状态机行为描述:
| 状态 | 行为 | 转移条件 |
|---|---|---|
| IDLE | 等待FIFO非空,sclk保持空闲电平 | FIFO非空 → LOAD |
| LOAD | 从FIFO读取数据,加载到移位寄存器,复位bit_cnt | 加载完成(1个时钟)→ SHIFT |
| SHIFT | 每个sclk边沿移出一位,bit_cnt递减 | bit_cnt==0 → DONE |
| DONE | 产生发送完成标志(可触发中断),等待确认 | 确认后 → IDLE |
// 发送状态机实现
localparam IDLE = 2'b00;
localparam LOAD = 2'b01;
localparam SHIFT = 2'b10;
localparam DONE = 2'b11;
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 (!fifo_empty) next_state = LOAD;
LOAD: next_state = SHIFT; // 加载只需1拍
SHIFT: if (bit_cnt == 0) next_state = DONE;
DONE: next_state = IDLE; // 确认后返回
endcase
end
// 输出控制
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
load_en <= 1'b0;
shift_en <= 1'b0;
tx_done <= 1'b0;
fifo_rd <= 1'b0;
end else begin
load_en <= (state == LOAD);
shift_en <= (state == SHIFT) && sclk_posedge; // 时钟上升沿移位
tx_done <= (state == DONE);
fifo_rd <= (state == LOAD); // 在LOAD状态读取FIFO
end
end
关键点总结:
- FIFO深度选8或16,别太小也别太大
- 移位寄存器支持MSB/LSB可配置,用参数化设计
- 波特率发生器用计数器分频,输出用寄存器
- 状态机四个状态,注意DONE状态要产生中断信号
我的建议: 写RTL的时候,先把状态机画出来(就像上面的SVG图),再写代码。这样思路清晰,不容易漏状态。我在项目中吃过亏,一开始直接写代码,结果状态转移条件漏了一个,仿真跑了两天才发现。
好了,发送通路的四个模块就讲到这里。记住:FIFO是缓冲,移位寄存器是串行化的核心,波特率发生器控制节奏,状态机调度一切。把这四个模块拼起来,你的SPI发送通路就基本成型了。