11、SPI总线通信:SPI模式详解(CPOL/CPHA)、Master模式实现、Slave模式实现、Flash读写实战
SPI总线,说实话,是我在FPGA开发中用得最多的串行接口之一。它简单、高效,而且几乎所有的Flash、ADC、传感器都支持它。今天我们就来彻底搞懂SPI,从模式选择到Master/Slave实现,最后用Flash读写来实战验证。
11.1 SPI协议基础:四根线搞定一切
SPI全称是Serial Peripheral Interface,由Motorola公司推出。它用四根线实现全双工通信:
- SCLK:串行时钟,由Master产生
- MOSI:Master输出,Slave输入
- MISO:Master输入,Slave输出
- CS:片选信号,低电平有效
我刚开始接触SPI时,总觉得它比I2C简单多了。确实,SPI没有地址概念,没有应答机制,就是纯粹的移位寄存器对移位寄存器。你想想看,Master每输出一个时钟,就同时发送一位数据并接收一位数据,多干脆。
11.2 SPI的四种模式:CPOL和CPHA详解
这里有个坑,很多新手会栽在这里。SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)决定。
| 模式 | CPOL | CPHA | 时钟空闲电平 | 数据采样沿 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 低电平 | 上升沿 |
| 模式1 | 0 | 1 | 低电平 | 下降沿 |
| 模式2 | 1 | 0 | 高电平 | 下降沿 |
| 模式3 | 1 | 1 | 高电平 | 上升沿 |
我个人习惯用模式0,因为大部分Flash芯片默认就是模式0。但要注意,有些传感器用的是模式3,比如某些ADXL345加速度计。我曾经在项目里因为没仔细看数据手册,默认用了模式0,结果读出来的数据全是乱的,排查了半天才发现是模式不匹配。
核心要点:CPOL决定时钟空闲时的电平,CPHA决定数据在哪个时钟沿被采样。CPHA=0时,数据在第一个时钟沿采样;CPHA=1时,数据在第二个时钟沿采样。
11.3 Master模式实现:用状态机控制SPI时序
在FPGA里实现SPI Master,我推荐用状态机。为什么?因为状态机可以清晰地控制每个时钟周期的行为,而且容易调试。
下面是一个典型的SPI Master状态机设计:
// SPI Master状态机 - 紫光同创PGL22G实现
module spi_master (
input wire clk, // 系统时钟 50MHz
input wire rst_n, // 复位
input wire start, // 启动传输
input wire [7:0] tx_data, // 发送数据
output reg [7:0] rx_data, // 接收数据
output reg busy, // 忙标志
output reg sclk, // SPI时钟
output reg mosi, // Master输出
input wire miso, // Master输入
output reg cs_n // 片选
);
// 状态定义
localparam IDLE = 3'd0;
localparam START = 3'd1;
localparam SHIFT = 3'd2;
localparam STOP = 3'd3;
reg [2:0] state, next_state;
reg [3:0] bit_cnt; // 位计数器
reg [7:0] shift_reg; // 移位寄存器
// 状态转移
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 (start) next_state = START;
START: next_state = SHIFT;
SHIFT: if (bit_cnt == 4'd8) next_state = STOP;
STOP: next_state = IDLE;
default: next_state = IDLE;
endcase
end
// 时序逻辑:数据移位和时钟生成
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sclk <= 1'b0;
mosi <= 1'b0;
cs_n <= 1'b1;
busy <= 1'b0;
bit_cnt <= 4'd0;
shift_reg <= 8'd0;
rx_data <= 8'd0;
end else begin
case (state)
IDLE: begin
cs_n <= 1'b1;
sclk <= 1'b0; // 模式0:空闲低电平
busy <= 1'b0;
if (start) begin
shift_reg <= tx_data;
bit_cnt <= 4'd0;
end
end
START: begin
cs_n <= 1'b0; // 拉低片选
busy <= 1'b1;
end
SHIFT: begin
// 上升沿发送数据
sclk <= 1'b1;
mosi <= shift_reg[7]; // MSB先发
shift_reg <= {shift_reg[6:0], miso}; // 同时采样MISO
bit_cnt <= bit_cnt + 1;
end
STOP: begin
sclk <= 1'b0;
cs_n <= 1'b1; // 拉高片选
rx_data <= shift_reg; // 锁存接收数据
busy <= 1'b0;
end
endcase
end
end
endmodule
个人经验:我在实际项目中,通常会在SHIFT状态里加一个时钟分频计数器。因为系统时钟往往比SPI时钟快很多,比如50MHz的系统时钟要产生10MHz的SPI时钟,就需要分频。这个分频逻辑我一般放在状态机外面,用单独的计数器模块实现。
11.4 Slave模式实现:响应Master的时钟
Slave模式相对简单,因为它完全被动,跟着Master的时钟走。但有个关键点:Slave的时钟必须和Master同步,而且数据要在正确的时钟沿输出。
// SPI Slave实现 - 模式0
module spi_slave (
input wire sclk, // 来自Master的时钟
input wire cs_n, // 片选
input wire mosi, // 来自Master的数据
output reg miso, // 输出给Master的数据
input wire [7:0] tx_data, // 要发送的数据
output reg [7:0] rx_data // 接收到的数据
);
reg [3:0] bit_cnt;
reg [7:0] shift_reg;
always @(posedge sclk or posedge cs_n) begin
if (cs_n) begin
// 片选无效时复位
bit_cnt <= 4'd0;
shift_reg <= tx_data; // 加载要发送的数据
end else begin
// 在上升沿采样MOSI
shift_reg <= {shift_reg[6:0], mosi};
bit_cnt <= bit_cnt + 1;
if (bit_cnt == 4'd7) begin
rx_data <= {shift_reg[6:0], mosi}; // 接收完成
end
end
end
// 在下降沿输出MISO(模式0)
always @(negedge sclk or posedge cs_n) begin
if (cs_n)
miso <= 1'bz; // 高阻态
else
miso <= shift_reg[7]; // 输出最高位
end
endmodule
注意:Slave的MISO在片选无效时必须置为高阻态,否则多个Slave会打架。我曾经在一个多Slave系统里忘了加这个,结果MISO线上信号冲突,数据全乱了。排查了整整一天才找到问题。
11.5 Flash读写实战:以W25Q64为例
好了,理论讲完了,我们来点实战。W25Q64是一款常用的SPI Flash,容量64Mbit。它的操作很简单:发命令、发地址、读写数据。
常用的Flash命令:
- 0x9F:读ID,返回3个字节的设备ID
- 0x06:写使能,每次写操作前必须发
- 0x02:页编程,一次最多写256字节
- 0x03:读数据,从指定地址开始读
- 0x05:读状态寄存器,检查是否忙
下面是一个完整的Flash读ID的Verilog实现:
// Flash读ID示例
module flash_read_id (
input wire clk,
input wire rst_n,
input wire start,
output reg [23:0] device_id,
output reg done,
// SPI接口
output reg flash_cs,
output reg flash_sclk,
output reg flash_mosi,
input wire flash_miso
);
// 状态机实现
localparam IDLE = 4'd0;
localparam CMD = 4'd1; // 发送命令0x9F
localparam DUMMY = 4'd2; // 等待8个时钟
localparam READ1 = 4'd3; // 读第一个字节
localparam READ2 = 4'd4; // 读第二个字节
localparam READ3 = 4'd5; // 读第三个字节
localparam FINISH = 4'd6;
reg [3:0] state;
reg [3:0] bit_cnt;
reg [7:0] shift_reg;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= IDLE;
flash_cs <= 1'b1;
done <= 1'b0;
end else begin
case (state)
IDLE: begin
if (start) begin
state <= CMD;
flash_cs <= 1'b0; // 拉低片选
bit_cnt <= 4'd0;
shift_reg <= 8'h9F; // 读ID命令
end
end
CMD: begin
// 发送8位命令
flash_sclk <= 1'b1;
flash_mosi <= shift_reg[7];
shift_reg <= {shift_reg[6:0], flash_miso};
bit_cnt <= bit_cnt + 1;
if (bit_cnt == 4'd7) begin
state <= DUMMY;
bit_cnt <= 4'd0;
end
end
DUMMY: begin
// 发送8个空时钟
flash_sclk <= 1'b1;
bit_cnt <= bit_cnt + 1;
if (bit_cnt == 4'd7) begin
state <= READ1;
bit_cnt <= 4'd0;
end
end
READ1: begin
// 读第一个字节
flash_sclk <= 1'b1;
shift_reg <= {shift_reg[6:0], flash_miso};
bit_cnt <= bit_cnt + 1;
if (bit_cnt == 4'd7) begin
device_id[23:16] <= {shift_reg[6:0], flash_miso};
state <= READ2;
bit_cnt <= 4'd0;
end
end
// READ2和READ3类似,略
FINISH: begin
flash_cs <= 1'b1;
done <= 1'b1;
state <= IDLE;
end
endcase
end
end
endmodule
实战要点:Flash操作有几个坑要注意:
- 写操作前必须先发写使令(0x06)
- 写完后要轮询状态寄存器,等BUSY位清零
- 页编程一次最多256字节,跨页要分多次写
- 擦除操作很慢,块擦除可能要几百毫秒
11.6 避坑指南:SPI设计中的常见问题
做了这么多年FPGA,我在SPI上踩过的坑还真不少。这里分享几个最典型的:
- 时钟极性搞反:我曾经有个项目,Master和Slave都是我们自己做的,但两边CPOL设置不一致。结果通信时好时坏,最后用示波器一看,时钟空闲电平不一样。
- 片选信号没处理好:Flash的片选必须在整个操作过程中保持低电平,中间不能有毛刺。我建议用寄存器打两拍再输出,避免组合逻辑产生的毛刺。
- 时序余量不足:SPI时钟频率不是越高越好。Flash芯片有最小时钟周期要求,比如W25Q64最高支持80MHz,但实际布线长了、负载重了,频率就得降。
- 多字节传输的边界处理:读Flash时,如果连续读多个字节,片选要保持低,时钟不能停。我见过有人每读一个字节就拉高一次片选,结果数据全乱了。
我的调试习惯:遇到SPI通信问题,我第一件事就是拿示波器看波形。看片选有没有毛刺,看时钟和数据是否对齐,看数据在正确的时钟沿是否稳定。示波器是SPI调试最好的工具,没有之一。
好了,SPI的内容就讲到这里。从模式选择到Master/Slave实现,再到Flash读写实战,这套知识足够你应对大部分SPI应用场景了。下一章我们讲I2C总线,那个比SPI稍微复杂一点,但掌握了SPI再学I2C会轻松很多。