4. 中断驱动SPI:中断使能与配置、中断服务函数编写、半双工通信示例

中断驱动,说白了就是让硬件主动通知你,而不是你一直轮询去问。我刚开始做SPI驱动时,总觉得轮询简单可靠,直到有一次项目要求CPU在SPI传输间隙还要处理传感器融合算法……嗯,轮询那套直接卡死。从那以后,我对中断驱动就特别上心了。

4.1 中断使能与配置——别漏了这一步

很多人写SPI中断驱动,上来就写中断服务函数,结果发现根本不进中断。为什么?因为中断使能没配全。SPI中断使能,至少需要配三个地方:

  1. SPI控制器本身的中断使能寄存器——比如STM32上的SPI_CR2的TXEIE、RXNEIE位
  2. NVIC(嵌套向量中断控制器)的使能——SPI对应的IRQ通道要打开
  3. 全局中断使能——cpsie指令或者__enable_irq()

我在项目中遇到过,有人配了前两个,忘了开全局中断,调试了一整天……后来发现是这么低级的错误。

核心配置流程:

  • 初始化SPI时钟、GPIO复用
  • 配置SPI控制寄存器(模式、波特率、数据格式)
  • 使能SPI中断位(TXEIE/RXNEIE/ERRIE)
  • 配置NVIC优先级并使能SPI中断通道
  • 使能全局中断
  • 最后使能SPI外设(SPI_CR1的SPE位)

这里有个细节:SPI使能(SPE)最好放在最后。我曾经先开了SPE再配中断,结果SPI一使能就产生了一个虚假的RXNE中断,导致服务函数读到垃圾数据。你想想看,这种问题排查起来多头疼。

4.2 中断服务函数编写——短小精悍是王道

中断服务函数(ISR)的核心原则就一条:快进快出。不要在ISR里做复杂计算、打印、动态内存分配。我见过有人把printf直接写在ISR里,结果串口中断优先级比SPI低,直接死锁。

一个典型的SPI中断服务函数骨架:

void SPI1_IRQHandler(void)
{
    // 检查中断源
    if (SPI1->SR & SPI_SR_RXNE) {
        // 读DR寄存器自动清除RXNE标志
        uint8_t rx_data = SPI1->DR;
        // 存入接收缓冲区
        if (rx_index < RX_BUF_SIZE) {
            rx_buf[rx_index++] = rx_data;
        }
        // 如果接收完成,置标志位
        if (rx_index == expected_len) {
            transfer_done = 1;
        }
    }

    if (SPI1->SR & SPI_SR_TXE) {
        // 发送缓冲区还有数据就继续写
        if (tx_index < TX_BUF_SIZE) {
            SPI1->DR = tx_buf[tx_index++];
        } else {
            // 发送完毕,关闭TXE中断防止反复触发
            SPI1->CR2 &= ~SPI_CR2_TXEIE;
        }
    }

    // 错误处理
    if (SPI1->SR & (SPI_SR_OVR | SPI_SR_MODF | SPI_SR_CRCERR)) {
        // 读SR再读DR清除错误标志
        volatile uint32_t tmp = SPI1->DR;
        tmp = SPI1->SR;
        error_flag = 1;
    }
}

个人习惯:我通常会在ISR里只做数据搬运和标志位设置,真正的业务逻辑放在主循环或任务里处理。这样ISR执行时间能控制在几微秒以内。

4.3 半双工通信示例——一条线搞定

SPI半双工模式,说白了就是同一根数据线,分时收发。这在引脚受限的场景下特别有用。比如我做过一个温湿度传感器驱动,MCU只有8个引脚可用,SPI用了3根线(SCK、MOSI、CS),省下的引脚刚好接个按键。

半双工模式下,SPI的数据线(MISO/MOSI)会合并成一根,叫SIMO(Slave In Master Out)或SOMI(Slave Out Master In)。配置时要注意:

配置项 全双工 半双工
数据线数量 2根(MOSI+MISO) 1根(SIMO/SOMI)
SPI_CR1的BIDIMODE 0 1
SPI_CR1的BIDIOE 无效 1=输出,0=输入
收发方向 同时收发 分时收发

下面是一个半双工中断驱动的读写示例:

// 半双工发送
void spi_halfduplex_tx(uint8_t *data, uint16_t len)
{
    // 配置为输出模式
    SPI1->CR1 |= SPI_CR1_BIDIOE;

    tx_buf = data;
    tx_len = len;
    tx_index = 0;
    transfer_done = 0;

    // 使能TXE中断
    SPI1->CR2 |= SPI_CR2_TXEIE;

    // 写入第一个数据触发传输
    SPI1->DR = tx_buf[tx_index++];
}

// 半双工接收
void spi_halfduplex_rx(uint8_t *buf, uint16_t len)
{
    // 配置为输入模式
    SPI1->CR1 &= ~SPI_CR1_BIDIOE;

    rx_buf = buf;
    rx_len = len;
    rx_index = 0;
    transfer_done = 0;

    // 使能RXNE中断
    SPI1->CR2 |= SPI_CR2_RXNEIE;

    // 发送空数据产生时钟
    SPI1->DR = 0xFF;
}

注意:半双工模式下,切换收发方向时,需要等待当前传输完全停止。我踩过这个坑——发送完立刻切输入,结果最后一个bit还没移出去,数据就乱了。建议在切换方向前加一个小的延时,或者检查SPI_SR的BSY位。

4.4 中断驱动的知识体系

我把中断驱动SPI的核心逻辑画了张图,方便你理解整体流程:

中断驱动SPI通信核心流程 主循环/任务 发起SPI传输 硬件自动触发中断 中断服务函数(ISR) 读DR/写DR 更新缓冲区索引 置传输完成标志 返回主循环处理数据 ISR执行时间 < 10μs 主循环处理业务逻辑

这张图展示了中断驱动的核心思想:主循环发起传输后,CPU可以去做其他事情;硬件在完成一个字节传输后触发中断,ISR负责数据搬运;传输完成后置标志,主循环再回来处理数据。说白了,就是让CPU从轮询的苦力活里解放出来。

4.5 避坑指南

  • 中断优先级:SPI中断优先级不要设得太高,否则会饿死其他中断。我一般设成中等优先级,比定时器低,比外部中断高。
  • 临界区保护:如果主循环和ISR共享缓冲区,记得在访问缓冲区前关中断或使用原子操作。我吃过这个亏——主循环在读缓冲区时,ISR刚好写进来,数据就错位了。
  • 错误中断:别忘了使能错误中断(ERRIE)。SPI通信中过载(OVR)和模式错误(MODF)很常见,不处理的话,通信会莫名其妙卡死。
  • 中断标志清除:读SPI_DR寄存器会自动清除RXNE标志,写SPI_DR会自动清除TXE标志。但错误标志需要手动清除——先读SR,再读DR。

总结一下:中断驱动SPI的核心就是配置好中断使能、写好短小精悍的ISR、处理好半双工的方向切换。做到这三点,你的SPI通信就能在中断模式下稳定运行,CPU利用率也能大幅提升。