4. SPI中断驱动开发:NVIC中断优先级配置、SPI全局中断使能、中断服务函数编写与环形缓冲区设计
各位同学,咱们今天聊点硬核的——SPI中断驱动。说实话,轮询方式在低速、短数据场景下还能凑合,但一旦数据量上来,CPU就被死死绑在SPI上,啥也干不了。我刚开始做项目时也踩过这个坑,后来才明白:中断才是嵌入式开发的灵魂。
这一章,我会带着大家一步步把SPI从轮询模式升级到中断驱动模式。核心就四件事:NVIC优先级怎么配、SPI中断怎么开、中断服务函数怎么写才优雅、环形缓冲区怎么设计才不丢数据。嗯,咱们一个一个来。
4.1 NVIC中断优先级配置——别让中断打架
STM32的NVIC支持最多16级抢占优先级和16级子优先级(具体看芯片型号)。配置不对,中断之间就会互相抢占,轻则数据错乱,重则系统死机。我见过一个同事,两个中断优先级设成一样,结果一个中断没处理完另一个又来了,直接卡死在中断里。
核心原则:SPI中断优先级不宜太高,留给系统滴答定时器和紧急事件。一般设到中等偏下即可。
配置步骤其实很简单,就三步:
- 设置优先级分组——调用
HAL_NVIC_SetPriorityGrouping()或直接操作SCB->AIRCR。 - 设置具体中断优先级——调用
HAL_NVIC_SetPriority()。 - 使能中断——调用
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 知识结构总览
下面这张图把本章的核心逻辑串起来了,大家可以对照着看:
从图上可以看得很清楚:NVIC配置是基础,中断使能是入口,状态机是骨架,环形缓冲区是数据通道。四者缺一不可。
4.6 避坑指南
最后,分享几个我踩过的坑,希望能帮大家少走弯路:
- 中断里别做复杂运算——中断服务函数越短越好,数据处理交给主循环。我曾经在中断里做浮点运算,结果系统响应时间直接爆炸。
- 环形缓冲区大小要合理——太小容易溢出,太大浪费内存。一般256字节起步,根据实际数据吞吐量调整。
- 别忘了清除中断标志——很多新手会忘记这一步,导致中断反复触发,系统卡死。我习惯在中断服务函数的最后统一清除标志。
- 多从机场景注意优先级反转——如果多个SPI从机共用中断线,优先级设置要格外小心,避免低优先级中断长时间阻塞高优先级中断。
重要提醒:调试中断程序时,建议先用逻辑分析仪或示波器观察SPI时钟和数据线,确认硬件通信正常后再调试软件。我见过太多人花几天时间找软件bug,最后发现是硬件虚焊……