3. SPI中断配置实战:使能TXE/RXNE/ERR中断,配置NVIC优先级,编写中断服务函数

好,咱们直接进入正题。上一章我们把SPI的轮询模式跑通了,但说实话,在真正的项目里,我很少只用轮询。为什么?因为CPU大部分时间都在空转等待,浪费啊。中断模式才是嵌入式开发的常态。

这一章,我们就来亲手配置SPI的中断。说白了,就是让SPI硬件在“有事”的时候主动通知CPU,而不是CPU一遍遍去问“你好了没”。

3.1 中断源:TXE、RXNE、ERR 分别是什么?

在配置之前,你得先搞清楚这三个中断标志位各自代表什么。我刚开始学的时候,就搞混过TXE和TXIS,结果调试了半天。

中断标志 全称 触发条件 典型用途
TXE Transmit Buffer Empty 发送缓冲区为空,可以写入新数据 连续发送多字节数据
RXNE Receive Buffer Not Empty 接收缓冲区非空,有数据可读 读取接收到的数据
ERR Error Interrupt 发生溢出、模式错误或CRC错误 异常处理与恢复
我的小经验: 在大多数双向通信场景中,TXE和RXNE是成对使用的。你发送一个字节,硬件自动接收一个字节。ERR中断我建议永远开启——哪怕你觉得代码很稳定。我在一个电机驱动项目里,就因为没开ERR中断,总线冲突导致数据错乱,查了整整两天。

3.2 使能SPI中断:寄存器操作

使能中断其实就两步:设置SPI的CR2寄存器,把对应的位拉高。以STM32为例,我们操作的是 SPIx->CR2。

// 使能SPI1的TXE、RXNE、ERR中断
SPI1->CR2 |= SPI_CR2_TXEIE;   // 使能发送缓冲区空中断
SPI1->CR2 |= SPI_CR2_RXNEIE;  // 使能接收缓冲区非空中断
SPI1->CR2 |= SPI_CR2_ERRIE;   // 使能错误中断

// 或者一次性写
SPI1->CR2 |= (SPI_CR2_TXEIE | SPI_CR2_RXNEIE | SPI_CR2_ERRIE);

嗯,这里要注意:使能中断之前,最好先确认SPI外设已经处于关闭或复位状态,避免在配置过程中产生误中断。我习惯的做法是:先关SPI(SPI->CR1 &= ~SPI_CR1_SPE),配完中断再打开。

3.3 NVIC优先级配置:抢占优先级与子优先级

中断使能了,但CPU怎么知道该先响应哪个中断?这就轮到NVIC出场了。NVIC是Cortex-M内核的中断控制器,它决定了中断的优先级。

你想想看,如果SPI中断和定时器中断同时发生,谁先执行?这就取决于优先级配置。

3.3.1 优先级分组

STM32的NVIC支持4位优先级,可以拆分成抢占优先级和子优先级。通过 NVIC_PriorityGroupConfig() 来设置分组。

分组 抢占优先级位数 子优先级位数 典型场景
NVIC_PriorityGroup_0 0 4 所有中断同级,按子优先级排队
NVIC_PriorityGroup_1 1 3 2级抢占,8级子优先级
NVIC_PriorityGroup_2 2 2 4级抢占,4级子优先级
NVIC_PriorityGroup_3 3 1 8级抢占,2级子优先级
NVIC_PriorityGroup_4 4 0 16级抢占,无子优先级(最常用)
我个人习惯: 绝大多数项目直接用 Group_4。16级抢占优先级足够用了,而且逻辑简单——数字越小,优先级越高。别把优先级搞得太复杂,否则调试的时候你自己都记不住。

3.3.2 配置SPI中断优先级

假设我们使用Group_4,给SPI1中断分配抢占优先级2。

NVIC_InitTypeDef NVIC_InitStructure;

NVIC_InitStructure.NVIC_IRQChannel = SPI1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;  // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;          // 子优先级(Group_4下无效)
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

这里有个坑:优先级数值越小,优先级越高。0是最高,15是最低。我曾经见过有人把SPI中断优先级配成15,结果被其他中断疯狂打断,SPI通信一直超时。

3.4 编写中断服务函数

中断服务函数(ISR)是中断模式的灵魂。你需要根据中断标志位来判断发生了什么事件,然后做对应的处理。

3.4.1 标准ISR框架

void SPI1_IRQHandler(void)
{
    // 1. 检查错误中断
    if (SPI1->SR & SPI_SR_OVR)
    {
        // 溢出错误处理
        uint8_t tmp = SPI1->DR;  // 读DR清OVR标志
        tmp = SPI1->DR;          // 再读一次(某些系列需要)
        // 记录错误计数或复位SPI
        Error_Handler();
    }

    // 2. 检查接收中断(RXNE)
    if (SPI1->SR & SPI_SR_RXNE)
    {
        // 读取接收到的数据
        uint8_t rx_data = (uint8_t)SPI1->DR;
        // 存入接收缓冲区
        if (rx_buffer_index < RX_BUFFER_SIZE)
        {
            rx_buffer[rx_buffer_index++] = rx_data;
        }
    }

    // 3. 检查发送中断(TXE)
    if (SPI1->SR & SPI_SR_TXE)
    {
        // 如果还有数据要发送
        if (tx_buffer_index < tx_length)
        {
            SPI1->DR = tx_buffer[tx_buffer_index++];
        }
        else
        {
            // 数据发送完毕,关闭TXE中断防止频繁触发
            SPI1->CR2 &= ~SPI_CR2_TXEIE;
        }
    }
}
重要提醒: 在ISR中,读取DR寄存器会自动清除RXNE标志。但OVR(溢出)标志需要先读SR再读DR才能清除。不同STM32系列可能有细微差别,务必查阅参考手册。我曾经在F1和F4之间移植代码时,就因为这个差异踩了坑。

3.4.2 中断服务函数的执行效率

ISR里不要做耗时操作。比如不要在ISR里调用printf、malloc或者复杂的数学运算。你想想看,如果ISR执行时间过长,其他中断就会被阻塞,系统实时性就没了。

我一般只在ISR里做三件事:

  • 读取/写入数据到缓冲区
  • 更新标志位或计数器
  • 清除中断标志

剩下的数据处理,交给主循环或任务去完成。

3.5 整体配置流程

把上面几步串起来,就是一个完整的SPI中断初始化流程。我画了一张图,帮你理清思路。

SPI中断配置完整流程 1. 使能SPI时钟与GPIO时钟 2. 配置GPIO复用功能(SCK/MISO/MOSI) 3. 初始化SPI参数(模式/速率/数据格式) 4. 使能SPI中断(TXE/RXNE/ERR) 5. 配置NVIC优先级并使能中断通道 6. 使能SPI外设(SPE=1) 7. 编写ISR处理数据 关键提醒 • 步骤4和5顺序可互换 • 建议先配NVIC再使能中断 • 避免在使能中断后产生误触发

3.6 避坑指南:我踩过的几个坑

  • 中断标志没清干净: 有些标志位是硬件自动清除的,有些需要软件写1清除。比如TXE是硬件自动置位和清除,但ERR中的OVR需要读SR再读DR才能清除。搞混了就会导致ISR反复进入。
  • 优先级配得太低: 我曾经把SPI中断优先级配成15,结果系统里一个高优先级的定时器中断每100us触发一次,SPI的ISR一直被抢占,导致接收数据丢失。后来我把SPI优先级提到3,问题解决。
  • 在ISR里调用HAL_Delay: HAL_Delay依赖SysTick中断,如果SysTick优先级比SPI低,就会死锁。我建议ISR里只用寄存器操作,别用HAL库的阻塞函数。