3、SPI协议详解:SPI四线制(SCK、MOSI、MISO、CS)、四种工作模式(CPOL/CPHA)、多从机拓扑、Verilog实现SPI主控制器
3.1 聊聊SPI四线制:SCK、MOSI、MISO、CS
SPI,全称Serial Peripheral Interface,说白了就是串行外设接口。我最早接触它是在做传感器驱动的时候,那时候觉得这协议真简单,就四根线,比I2C好理解多了。
这四根线分别是:
- SCK(Serial Clock):时钟线,由主设备产生。我习惯叫它“心跳线”,因为所有数据都在它的节拍下传输。
- MOSI(Master Out Slave In):主设备输出,从设备输入。数据从主设备流向从设备。
- MISO(Master In Slave Out):主设备输入,从设备输出。数据从从设备流向主设备。
- CS(Chip Select):片选线,低电平有效。你想想看,一根CS线对应一个从设备,选谁就把谁拉低。
这里有个细节我提醒一下:SPI是全双工的。什么意思?就是MOSI和MISO可以同时传输数据。我在项目中遇到过有人把它当半双工用,白白浪费了一半带宽。
核心要点:SPI的传输是“交换”的过程。主设备发一个bit,同时从设备也发一个bit。所以即使你只想读数据,也得先发一个字节出去(通常是0x00或0xFF)。
3.2 四种工作模式:CPOL和CPHA
CPOL是时钟极性,CPHA是时钟相位。这两个参数决定了数据在时钟的哪个边沿采样。嗯,这里要注意,很多初学者就是在这栽跟头。
我直接给个表格,你一看就明白:
| 模式 | CPOL | CPHA | 采样边沿 | 空闲时钟电平 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 上升沿 | 低电平 |
| 模式1 | 0 | 1 | 下降沿 | 低电平 |
| 模式2 | 1 | 0 | 下降沿 | 高电平 |
| 模式3 | 1 | 1 | 上升沿 | 高电平 |
我个人习惯用模式0,因为大部分SPI外设默认就是模式0。但有一次我调试一个AD转换芯片,死活读不出数据,折腾了半天才发现它用的是模式3。从那以后,我每次看芯片手册第一件事就是确认CPOL和CPHA。
避坑指南:我曾经因为主设备和从设备的模式不匹配,导致数据全是0xFF。检查方法很简单:用示波器抓SCK和MOSI,看数据是在上升沿还是下降沿变化,再对照表格就能确定当前模式。
3.3 多从机拓扑:怎么接多个设备?
SPI支持多从机,但方式跟I2C不一样。I2C是靠地址区分,SPI是靠片选线区分。说白了,每个从设备独占一根CS线。
常见的拓扑有两种:
- 独立片选:每个从设备一根CS线,SCK、MOSI、MISO共享。这是最常用的方式,我推荐你用这个。
- 菊花链:从设备串联,数据从一个传到下一个。这种方式省CS线,但延迟大,我一般只在FPGA内部用。
你想想看,如果接了8个从设备,那就需要8根CS线。主设备的GPIO够不够?我建议用3-8译码器来扩展,比如74HC138,用3根GPIO控制8个CS。
注意:多从机共享MISO线时,必须保证未选中的从设备输出为高阻态(Hi-Z)。否则多个设备同时驱动MISO,轻则数据错误,重则烧毁IO口。我曾经见过一块板子就是因为这个原因,MISO引脚直接冒烟了。
3.4 Verilog实现SPI主控制器
好了,理论说完了,咱们直接上代码。我写了一个简单的SPI主控制器,支持模式0,可配置时钟分频。
module spi_master (
input wire clk, // 系统时钟
input wire rst_n, // 复位,低有效
input wire start, // 启动传输
input wire [7:0] data_in, // 要发送的数据
output reg [7:0] data_out, // 接收到的数据
output reg busy, // 忙标志
// SPI 接口
output reg sck, // SPI时钟
output reg mosi, // 主出从入
input wire miso, // 主入从出
output reg cs_n // 片选,低有效
);
// 状态机定义
localparam IDLE = 2'b00;
localparam TRANS = 2'b01;
localparam DONE = 2'b10;
reg [1:0] state, next_state;
reg [2:0] bit_cnt; // 位计数器,0~7
reg [7:0] shift_reg; // 移位寄存器
// 时钟分频参数(假设系统时钟50MHz,目标SPI时钟1MHz)
localparam DIV_CNT = 25; // 50MHz / (2*25) = 1MHz
reg [4:0] clk_div;
reg sck_en;
// 状态机
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 = TRANS;
TRANS: if (bit_cnt == 3'd7 && clk_div == DIV_CNT-1) next_state = DONE;
DONE: next_state = IDLE;
default: next_state = IDLE;
endcase
end
// 时钟分频和SCK生成
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
clk_div <= 0;
sck_en <= 0;
end else if (state == TRANS) begin
if (clk_div == DIV_CNT-1) begin
clk_div <= 0;
sck_en <= 1;
end else begin
clk_div <= clk_div + 1;
sck_en <= 0;
end
end else begin
clk_div <= 0;
sck_en <= 0;
end
end
// SCK生成(模式0:空闲低电平,上升沿采样)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
sck <= 1'b0;
else if (state == TRANS && sck_en)
sck <= ~sck;
else if (state == IDLE || state == DONE)
sck <= 1'b0;
end
// 数据发送和接收
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
shift_reg <= 8'd0;
bit_cnt <= 3'd0;
mosi <= 1'b0;
data_out <= 8'd0;
cs_n <= 1'b1;
busy <= 1'b0;
end else begin
case (state)
IDLE: begin
if (start) begin
shift_reg <= data_in;
bit_cnt <= 3'd0;
cs_n <= 1'b0; // 拉低片选
busy <= 1'b1;
end
end
TRANS: begin
// 在SCK下降沿更新MOSI(模式0)
if (sck_en && sck == 1'b1) begin
mosi <= shift_reg[7];
shift_reg <= {shift_reg[6:0], 1'b0};
end
// 在SCK上升沿采样MISO(模式0)
if (sck_en && sck == 1'b0) begin
shift_reg <= {shift_reg[6:0], miso};
if (bit_cnt == 3'd7)
data_out <= {shift_reg[6:0], miso};
bit_cnt <= bit_cnt + 1;
end
end
DONE: begin
cs_n <= 1'b1; // 拉高片选
busy <= 1'b0;
end
endcase
end
end
endmodule
这段代码我解释一下:
- 状态机有三个状态:IDLE等待启动,TRANS传输数据,DONE结束。
- 时钟分频器把系统时钟分频成SPI时钟,这里我设的是1MHz。
- 数据在SCK的下降沿发送(MOSI更新),上升沿接收(MISO采样),符合模式0。
- 传输完成后自动拉高CS,释放总线。
个人经验:我建议你在仿真时先跑一遍,重点看CS的时序。很多问题出在CS拉低后SCK还没稳定,或者传输结束后CS拉高太快。我曾经因为CS拉高和最后一个SCK边沿的时序关系没处理好,导致从设备认为传输还没结束。
嗯,SPI协议就讲到这里。说白了,它就是一个简单、高效、全双工的串行接口。你只要记住四根线、四种模式、多从机靠CS区分,再加上这段Verilog代码,基本就能应付大部分项目了。下一章咱们聊I2C,那个比SPI稍微复杂一点,但也是嵌入式工程师的必修课。