4. SPI中断驱动开发:NVIC中断优先级配置、SPI全局中断使能、中断服务函数编写与环形缓冲区设计

各位同学,咱们今天聊点硬核的——SPI中断驱动。说实话,轮询方式在低速、短数据场景下还能凑合,但一旦数据量上来,CPU就被死死绑在SPI上,啥也干不了。我刚开始做项目时也踩过这个坑,后来才明白:中断才是嵌入式开发的灵魂。

这一章,我会带着大家一步步把SPI从轮询模式升级到中断驱动模式。核心就四件事:NVIC优先级怎么配、SPI中断怎么开、中断服务函数怎么写才优雅、环形缓冲区怎么设计才不丢数据。嗯,咱们一个一个来。

4.1 NVIC中断优先级配置——别让中断打架

STM32的NVIC支持最多16级抢占优先级和16级子优先级(具体看芯片型号)。配置不对,中断之间就会互相抢占,轻则数据错乱,重则系统死机。我见过一个同事,两个中断优先级设成一样,结果一个中断没处理完另一个又来了,直接卡死在中断里。

核心原则:SPI中断优先级不宜太高,留给系统滴答定时器和紧急事件。一般设到中等偏下即可。

配置步骤其实很简单,就三步:

  1. 设置优先级分组——调用 HAL_NVIC_SetPriorityGrouping() 或直接操作 SCB->AIRCR。
  2. 设置具体中断优先级——调用 HAL_NVIC_SetPriority()。
  3. 使能中断——调用 HAL_NVIC_EnableIRQ()。

我习惯用 NVIC_PriorityGroup_2,也就是2位抢占优先级、2位子优先级。这样最多4级抢占,够用且简单。

// 设置优先级分组:2位抢占 + 2位子优先级
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

// 设置SPI1全局中断:抢占优先级3,子优先级0
HAL_NVIC_SetPriority(SPI1_IRQn, 3, 0);

// 使能SPI1中断
HAL_NVIC_EnableIRQ(SPI1_IRQn);

小技巧:优先级数值越小,优先级越高。所以抢占优先级0是最高的,一般留给系统滴答定时器。SPI设到3,算是比较安全的。

4.2 SPI全局中断使能——TXE、RXNE、ERR一个都不能少

SPI的中断源主要有三个:

中断标志 触发条件 典型用途
TXE(发送缓冲区空) 发送缓冲区为空,可以写入新数据 发送数据时使用,每发一个字节触发一次
RXNE(接收缓冲区非空) 接收缓冲区有数据可读 接收数据时使用,每收一个字节触发一次
ERR(错误标志) 发生溢出、模式错误、帧错误等 异常处理,必须及时清除

配置时,我建议三个中断全部使能。ERR中断很多人会忽略,但我在项目中遇到过SPI从机掉线导致溢出错误,如果没有ERR中断,系统会一直卡在错误状态里出不来。

// 使能SPI1的TXE、RXNE、ERR中断
__HAL_SPI_ENABLE_IT(&hspi1, SPI_IT_TXE);
__HAL_SPI_ENABLE_IT(&hspi1, SPI_IT_RXNE);
__HAL_SPI_ENABLE_IT(&hspi1, SPI_IT_ERR);

注意:使能中断前,务必先清除对应的标志位。否则中断会立即触发,造成误处理。

4.3 中断服务函数编写——状态机思想

中断服务函数最忌讳的就是写成一坨大杂烩。你想想看,中断里既要判断发送、又要判断接收、还要处理错误,如果不用状态机管理,代码很快就会变成一团乱麻。

我推荐的做法是:在中断服务函数里只做三件事——判断中断源、调用对应的状态机处理函数、清除标志位。状态机本身放在外部函数里,这样逻辑清晰,也方便调试。

先定义几个状态:

typedef enum {
    SPI_STATE_IDLE,      // 空闲
    SPI_STATE_TX,        // 发送中
    SPI_STATE_RX,        // 接收中
    SPI_STATE_TX_RX,     // 全双工收发
    SPI_STATE_ERROR      // 错误状态
} SPI_State_t;

然后写中断服务函数:

void SPI1_IRQHandler(void)
{
    // 检查错误中断
    if (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_OVR) ||
        __HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_MODF) ||
        __HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_FRE)) {
        // 进入错误处理状态机
        spi_error_handler(&hspi1);
        return;
    }

    // 检查接收中断(RXNE)
    if (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_RXNE)) {
        spi_rx_handler(&hspi1);
    }

    // 检查发送中断(TXE)
    if (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_TXE)) {
        spi_tx_handler(&hspi1);
    }
}

你看,这样写是不是清爽多了?每个处理函数只负责一件事。比如 spi_rx_handler 只做接收数据的处理:

static void spi_rx_handler(SPI_HandleTypeDef *hspi)
{
    uint8_t data;

    // 读取接收数据
    data = (uint8_t)(hspi->Instance->DR);

    // 将数据写入环形缓冲区
    ring_buffer_write(&rx_buffer, data);

    // 更新状态机
    if (ring_buffer_is_full(&rx_buffer)) {
        // 缓冲区满了,切换到空闲状态
        spi_set_state(SPI_STATE_IDLE);
        __HAL_SPI_DISABLE_IT(hspi, SPI_IT_RXNE);
    }
}

状态机的好处:每个状态只处理自己该做的事,状态切换清晰,不会出现“发送还没完就开始接收”的混乱情况。我在一个多从机项目中用过这种设计,稳定运行了两年没出过问题。

4.4 环形缓冲区设计——FIFO的优雅实现

中断服务函数里直接处理数据?别闹。中断里讲究的是“快进快出”,数据处理应该放到主循环或任务里。那数据怎么传递?环形缓冲区(Ring Buffer)就是最好的选择。

说白了,环形缓冲区就是一个固定大小的数组,加上读指针和写指针。写指针追着读指针跑,形成一个环。我设计时一般会留一个空位来区分“满”和“空”,这样就不用额外维护计数器了。

#define RING_BUFFER_SIZE 256

typedef struct {
    uint8_t buffer[RING_BUFFER_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} RingBuffer_t;

// 初始化
void ring_buffer_init(RingBuffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ring_buffer_write(RingBuffer_t *rb, uint8_t data)
{
    uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE;

    // 检查是否满(留一个空位)
    if (next_head == rb->tail) {
        return -1;  // 缓冲区满
    }

    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 读取一个字节
int ring_buffer_read(RingBuffer_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }

    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE;
    return 0;
}

// 判断是否为空
int ring_buffer_is_empty(RingBuffer_t *rb)
{
    return (rb->head == rb->tail);
}

// 判断是否为满
int ring_buffer_is_full(RingBuffer_t *rb)
{
    uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE;
    return (next_head == rb->tail);
}

为什么用 volatile?因为 head 和 tail 会在中断和主循环中被同时访问,不加 volatile 的话,编译器优化可能导致读取到过时的值。我曾经因为这个bug排查了整整两天……

有了环形缓冲区,中断服务函数和主循环就解耦了。中断只管往缓冲区里塞数据,主循环只管从缓冲区里取数据。互不干扰,各司其职。

4.5 知识结构总览

下面这张图把本章的核心逻辑串起来了,大家可以对照着看:

SPI中断驱动开发知识结构 NVIC中断优先级配置 TXE中断使能 RXNE中断使能 ERR中断使能 中断服务函数(状态机思想) IDLE(空闲) TX(发送) RX(接收) ERROR(错误) 环形缓冲区(FIFO) 核心思想:中断快进快出 + 状态机管理 + 缓冲区解耦

从图上可以看得很清楚:NVIC配置是基础,中断使能是入口,状态机是骨架,环形缓冲区是数据通道。四者缺一不可。

4.6 避坑指南

最后,分享几个我踩过的坑,希望能帮大家少走弯路:

  • 中断里别做复杂运算——中断服务函数越短越好,数据处理交给主循环。我曾经在中断里做浮点运算,结果系统响应时间直接爆炸。
  • 环形缓冲区大小要合理——太小容易溢出,太大浪费内存。一般256字节起步,根据实际数据吞吐量调整。
  • 别忘了清除中断标志——很多新手会忘记这一步,导致中断反复触发,系统卡死。我习惯在中断服务函数的最后统一清除标志。
  • 多从机场景注意优先级反转——如果多个SPI从机共用中断线,优先级设置要格外小心,避免低优先级中断长时间阻塞高优先级中断。

重要提醒:调试中断程序时,建议先用逻辑分析仪或示波器观察SPI时钟和数据线,确认硬件通信正常后再调试软件。我见过太多人花几天时间找软件bug,最后发现是硬件虚焊……