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传输,其实是交换了两个字节

举个例子,你要读一个传感器的寄存器:

  1. 主设备先发寄存器地址(写操作)
  2. 从设备在MISO上返回数据
  3. 但注意!主设备发地址的同时,从设备也在发数据——只不过那个数据是无效的
  4. 同样,主设备读数据时,也要继续发时钟,但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驱动常见问题

最后,分享几个我实际项目中遇到的坑:

  1. 时钟频率过高:从设备有最大时钟限制,别以为主设备能跑多快就设多快。我遇到过一款陀螺仪,手册写最大10MHz,结果8MHz就丢数据了——因为PCB走线太长,信号质量不行。
  2. 片选时序:有些器件要求片选拉低后等几个微秒再发时钟。这个延时不能省,否则第一个字节可能错位。
  3. 中断优先级:在RTOS中,SPI中断优先级不能乱设。如果DMA中断被更高优先级的中断打断,可能导致数据丢失。我习惯把DMA中断设为中等优先级,比系统时钟低,比普通外设高。
  4. 字节序:SPI默认是MSB先行,但有些器件是LSB先行。记得看数据手册的SPI配置寄存器,一般都有LSBFIRST位可以设置。

嗯,SPI这部分内容就这些。说白了,SPI驱动不难,难的是把细节处理好。时钟模式、互斥保护、DMA优化,这三关过了,基本就稳了。下一章咱们聊I2C,那个比SPI稍微复杂一点,但套路是一样的。