3. SPI协议详解与实战:SPI四种模式、SPI双工通信机制、在RTOS中编写SPI主机驱动、SPI DMA传输优化
SPI,全称Serial Peripheral Interface,串行外设接口。说实话,这可能是嵌入式工程师打交道最多的总线之一。我最早接触SPI是在做一款心率传感器驱动的时候,当时被时钟极性搞晕了好几天。今天咱们就把SPI这层窗户纸捅破,从原理到实战,一步到位。
3.1 SPI四种模式:别让时钟极性坑了你
SPI通信的核心,是主设备控制时钟信号,从设备跟着时钟走。但这里有个关键点:时钟空闲时是高还是低?数据在时钟的哪个边沿采样?这两个问题,就衍生出了SPI的四种模式。
我个人习惯用CPOL(时钟极性)和CPHA(时钟相位)来记忆:
- CPOL = 0:时钟空闲时为低电平
- CPOL = 1:时钟空闲时为高电平
- CPHA = 0:数据在时钟的第一个边沿采样
- CPHA = 1:数据在时钟的第二个边沿采样
组合起来就是四种模式:
| 模式 | CPOL | CPHA | 空闲时钟电平 | 数据采样边沿 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 低电平 | 上升沿 |
| 模式1 | 0 | 1 | 低电平 | 下降沿 |
| 模式2 | 1 | 0 | 高电平 | 下降沿 |
| 模式3 | 1 | 1 | 高电平 | 上升沿 |
我曾经踩过一个坑:某款加速度传感器手册上写的是"SPI Mode 3",我默认用了模式0。结果读出来的数据全是0xFF。折腾了两天,最后用逻辑分析仪一看,时钟极性完全反了。从那以后,我拿到任何SPI器件,第一件事就是确认CPOL和CPHA。
你想想看,如果主设备和从设备的模式不匹配,数据采样时刻对不上,通信肯定失败。所以,驱动开发的第一步,永远是看数据手册的时序图。
3.2 SPI双工通信机制:同时收发才是真本事
SPI是全双工通信。什么意思?就是主设备发数据的同时,也在收数据。这跟I2C不一样,I2C是半双工,同一时刻只能一个方向传输。
SPI的硬件结构决定了这一点:
- MOSI:主出从入
- MISO:主入从出
- SCLK:时钟,由主设备控制
- CS:片选,低电平有效
每次传输,主设备通过MOSI发一个字节,同时从设备通过MISO发一个字节。说白了,一次SPI传输,其实是交换了两个字节。
举个例子,你要读一个传感器的寄存器:
- 主设备先发寄存器地址(写操作)
- 从设备在MISO上返回数据
- 但注意!主设备发地址的同时,从设备也在发数据——只不过那个数据是无效的
- 同样,主设备读数据时,也要继续发时钟,但MOSI上发的是0x00或任意值
核心理解:SPI的读写是成对出现的。你发一个字节,就一定会收到一个字节。很多新手以为读操作就是单纯地收,其实不是——你得先发时钟,从设备才会给你数据。
3.3 在RTOS中编写SPI主机驱动
在RTOS环境下写SPI驱动,跟裸机最大的区别是:要考虑任务调度和资源互斥。我习惯把SPI驱动封装成一个独立的模块,提供统一的接口。
先看一个基础的SPI主机初始化代码:
/* spi_master.h */
typedef struct {
SPI_TypeDef *instance; /* SPI外设基地址 */
uint32_t baudrate; /* 波特率 */
uint8_t mode; /* 0-3 */
void (*cs_ctrl)(uint8_t level); /* 片选控制回调 */
} spi_config_t;
/* spi_master.c */
static SemaphoreHandle_t spi_mutex;
void spi_master_init(spi_config_t *cfg)
{
/* 1. 创建互斥锁,保护SPI总线 */
spi_mutex = xSemaphoreCreateMutex();
/* 2. 配置GPIO:SCK, MOSI, MISO, CS */
gpio_init();
/* 3. 配置SPI外设寄存器 */
/* 设置CPOL和CPHA */
uint32_t cpha = (cfg->mode & 0x02) ? 1 : 0;
uint32_t cpol = (cfg->mode & 0x01) ? 1 : 0;
cfg->instance->CR1 = (cpha << 0) | (cpol << 1);
cfg->instance->CR1 |= SPI_CR1_MSTR; /* 主机模式 */
cfg->instance->CR1 |= SPI_CR1_SPE; /* 使能SPI */
/* 4. 保存片选控制函数 */
spi_cs_ctrl = cfg->cs_ctrl;
}
然后是收发函数。这里要注意,RTOS环境下必须加互斥保护:
int spi_master_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint32_t len)
{
int ret = 0;
/* 获取互斥锁,防止多任务同时操作SPI */
if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return -1; /* 超时未获取到锁 */
}
/* 拉低片选 */
spi_cs_ctrl(0);
for (uint32_t i = 0; i < len; i++) {
uint8_t tx_data = tx_buf ? tx_buf[i] : 0x00;
uint8_t rx_data;
/* 发送一个字节 */
while (!(cfg->instance->SR & SPI_SR_TXE));
cfg->instance->DR = tx_data;
/* 等待接收完成 */
while (!(cfg->instance->SR & SPI_SR_RXNE));
rx_data = cfg->instance->DR;
if (rx_buf) {
rx_buf[i] = rx_data;
}
}
/* 等待传输完成 */
while (cfg->instance->SR & SPI_SR_BSY);
/* 拉高片选 */
spi_cs_ctrl(1);
/* 释放互斥锁 */
xSemaphoreGive(spi_mutex);
return ret;
}
我的经验:片选控制一定要放在互斥锁内部。否则,任务A刚拉低片选,还没来得及发数据,任务B抢占了CPU,也去拉低片选——两个从设备同时被选中,总线就乱了。这种bug很难复现,但一旦出现,数据就全错了。
3.4 SPI DMA传输优化:让CPU喘口气
轮询方式发送数据,CPU一直在等标志位。如果传输的数据量大(比如刷屏幕、读音频数据),CPU就被占死了。这时候,DMA就派上用场了。
DMA传输的核心思想:CPU告诉DMA控制器"你去搬数据",然后CPU去干别的事。搬完了,DMA发个中断通知一下。
看一个SPI+DMA的发送实现:
void spi_master_dma_send(uint8_t *data, uint32_t len)
{
/* 1. 配置DMA通道 */
/* 源地址:data缓冲区 */
/* 目的地址:SPI->DR寄存器 */
/* 传输长度:len */
/* 传输完成触发中断 */
DMA->SAR = (uint32_t)data;
DMA->DAR = (uint32_t)&SPI->DR;
DMA->NDTR = len;
DMA->CR |= DMA_CR_TCIE; /* 使能传输完成中断 */
DMA->CR |= DMA_CR_EN; /* 启动DMA */
/* 2. 使能SPI的DMA发送请求 */
SPI->CR2 |= SPI_CR2_TXDMAEN;
}
/* DMA传输完成中断 */
void DMA_IRQHandler(void)
{
if (DMA->ISR & DMA_ISR_TCIF) {
/* 清除标志 */
DMA->IFCR |= DMA_IFCR_CTCIF;
/* 关闭SPI的DMA请求 */
SPI->CR2 &= ~SPI_CR2_TXDMAEN;
/* 通知任务:传输完成 */
xSemaphoreGiveFromISR(dma_done_sem, NULL);
}
}
接收也是一样的道理。但要注意,SPI的DMA接收必须和发送成对使用。因为SPI是全双工,你只开接收DMA,不开发送DMA,时钟从哪来?
优化技巧:我一般会开两个DMA通道,一个发一个收,用硬件流控制同步。这样CPU只需要在开始和结束时介入,中间完全不用管。对于智能手表这种对功耗敏感的设备,DMA传输能让CPU更早进入睡眠模式,省电效果很明显。
3.5 避坑指南:SPI驱动常见问题
最后,分享几个我实际项目中遇到的坑:
- 时钟频率过高:从设备有最大时钟限制,别以为主设备能跑多快就设多快。我遇到过一款陀螺仪,手册写最大10MHz,结果8MHz就丢数据了——因为PCB走线太长,信号质量不行。
- 片选时序:有些器件要求片选拉低后等几个微秒再发时钟。这个延时不能省,否则第一个字节可能错位。
- 中断优先级:在RTOS中,SPI中断优先级不能乱设。如果DMA中断被更高优先级的中断打断,可能导致数据丢失。我习惯把DMA中断设为中等优先级,比系统时钟低,比普通外设高。
- 字节序:SPI默认是MSB先行,但有些器件是LSB先行。记得看数据手册的SPI配置寄存器,一般都有LSBFIRST位可以设置。
嗯,SPI这部分内容就这些。说白了,SPI驱动不难,难的是把细节处理好。时钟模式、互斥保护、DMA优化,这三关过了,基本就稳了。下一章咱们聊I2C,那个比SPI稍微复杂一点,但套路是一样的。