
1. 从轮询到中断为什么SPI通信需要“打断”一下如果你用过STM32的SPI最开始大概率是从轮询模式入手的。初始化、发送、等待标志位、接收一个简单的循环代码直白逻辑清晰。对于低速、非实时的应用比如偶尔读取一个传感器数据轮询完全够用。但当你需要同时处理多个任务或者SPI通信的数据量变大、频率变高时问题就来了CPU会像个“痴情汉”一样傻傻地等在while(!SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE));这样的语句后面什么也干不了直到发送完成或接收到数据。整个系统的效率被这一个外设“卡”住了。这时候中断Interrupt模式的价值就凸显出来了。它的核心思想是“事件驱动”。CPU不必再主动、持续地去查询SPI的状态而是告诉SPI“嘿等你发送寄存器空了TXE或者接收寄存器有数据了RXNE就立刻‘打断’我一下我来处理。” 这样在SPI硬件自己忙着移位、传输数据的时候CPU可以被解放出来去执行其他任务比如处理按键、更新显示、运行其他算法。只有当SPI真正需要CPU介入搬移数据时才会通过中断信号“呼叫”CPUCPU处理完中断服务程序后又立刻回到原来的任务。这种异步处理机制是提升单片机系统并发能力和实时响应性的关键。所以探讨STM32 SPI的中断方式本质上是在探讨如何让这个强大的同步串行接口更好地融入一个多任务、高效率的嵌入式系统中。它不仅仅是换一个函数那么简单而是涉及到中断服务程序ISR的设计、数据缓冲区的管理、以及如何避免在高速数据流中丢失字节或产生冲突。接下来我们就深入其中看看如何从零开始搭建一个稳定可靠的SPI中断通信框架。2. SPI中断的硬件机制与关键标志位解析要玩转中断必须先理解SPI硬件在什么条件下会产生中断请求。STM32的SPI模块有几个关键的状态标志位它们连接到NVIC嵌套向量中断控制器是触发中断的源头。2.1 核心中断源TXE与RXNE这是最常用、也是最核心的两个中断源。TXETransmit buffer Empty发送缓冲区空标志。当SPI的发送数据寄存器DR为空可以写入新的待发送数据时该标志位被硬件置1。如果此时开启了TXEIETXE中断使能就会产生一个中断。在中断服务程序里我们的典型操作就是向DR寄存器写入下一个要发送的字节。RXNEReceive buffer Not Empty接收缓冲区非空标志。当SPI的接收数据寄存器DR接收到一个完整的数据注意在全双工模式下每发送一个字节就会同时接收一个字节时该标志位被硬件置1。如果开启了RXNEIERXNE中断使能就会产生中断。在中断服务程序里我们需要及时从DR寄存器中读取这个接收到的字节否则如果下一个数据接收完成上一个数据就会被覆盖在有些模式下会产生溢出错误。注意这里有一个非常重要的细节。在全双工主模式下通常我们只需要使能TXE中断。为什么因为当我们向DR写入一个数据启动传输后硬件会自动完成时钟驱动和数据的收发。一旦这个字节发送完成TXE置1意味着同时也必然有一个字节被接收完成RXNE也会置1。所以在TXE中断服务程序中我们通常做两件事1. 读取DR清除RXNE标志并获取接收到的数据2. 写入下一个要发送的数据清除TXE标志并启动下一字节传输。这样用一个中断源就管理了收和发两个流程效率最高也避免了中断嵌套的复杂性。2.2 错误中断源ERR错误中断同样重要它能帮助我们在通信异常时快速定位问题。OVROverrun错误溢出错误。当接收数据寄存器DR中的数据尚未被读取RXNE仍为1而一个新的数据已经接收完成并准备写入DR时就会发生溢出OVR标志置1。如果开启了ERRIE错误中断使能就会产生错误中断。这通常是因为CPU处理接收中断太慢或者中断被意外关闭导致的。发生OVR后SPI接收通道可能会被阻塞需要先读DR再读SR状态寄存器来清除OVR标志。MODFMode Fault错误模式错误。在多主设备系统中当SPI被配置为主设备但其NSS引脚被拉低被其他主设备占用时会发生此错误。对于单主设备系统通常将NSS配置为软件管理并置高可以避免此错误。CRC错误如果使能了硬件CRC校验在传输完成后校验失败会产生此错误。在初始化时我们一般会先开启错误中断ERRIE以便在调试阶段能捕获硬件异常。待系统稳定后可以根据实际情况决定是否关闭。2.3 中断使能与NVIC配置的逻辑链条使能一个SPI中断需要完成一条“硬件通路”的配置SPI外设级使能在SPI的CR2寄存器中设置对应的中断使能位TXEIE, RXNEIE, ERRIE。这是告诉SPI模块“你可以产生中断信号了。”NVIC级使能在NVIC中使能对应SPI中断线如SPI1_IRQn的中断。这是告诉CPU的中断控制器“SPI1这条中断线发来的请求我允许你接收并处理。”全局中断使能确保CPU的全局中断是开启的对于Cortex-M内核通常通过__enable_irq()或操作PRIMASK寄存器实现。缺少其中任何一步中断都无法得到响应。在HAL库中HAL_SPI_Transmit_IT()或HAL_SPI_Receive_IT()函数内部会帮我们完成第1步和第2步的使能。但当我们自己用标准外设库或LL库或者进行更底层的优化时必须清晰地理解这条链路。3. 构建一个健壮的中断驱动SPI数据引擎理解了原理我们来搭建一个实用的中断模式SPI引擎。这里我们不局限于简单的单次收发而是设计一个能够处理数据块、带有缓冲区的“引擎”这在连续读取传感器、驱动显示屏等场景中非常实用。3.1 数据结构与状态机设计首先我们定义管理传输的核心数据结构。这比直接操作全局变量更清晰也更安全。typedef struct { SPI_HandleTypeDef *hspi; // 对应的SPI句柄 uint8_t *tx_buffer; // 发送缓冲区指针 uint8_t *rx_buffer; // 接收缓冲区指针 uint16_t tx_size; // 待发送总字节数 uint16_t rx_size; // 待接收总字节数 uint16_t tx_count; // 已发送字节数 uint16_t rx_count; // 已接收字节数 volatile uint8_t state; // 传输状态READY, BUSY, COMPLETE, ERROR } SPI_TransferHandle_t; // 传输状态定义 #define SPI_TRANSFER_READY 0 #define SPI_TRANSFER_BUSY 1 #define SPI_TRANSFER_COMPLETE 2 #define SPI_TRANSFER_ERROR 3这个结构体封装了一次数据传输的所有上下文信息。使用状态机state字段来管理传输生命周期是避免在中断中发生并发冲突的关键。例如当引擎处于BUSY状态时任何新的传输请求都应该被拒绝或排队。3.2 核心中断服务程序ISR实现中断服务程序要短小精悍只做最必要的事情。以下是基于标准外设库风格的核心ISR伪代码逻辑// 假设这是SPI1的中断服务程序 void SPI1_IRQHandler(void) { SPI_TransferHandle_t *pHandle spi1_transfer_handle; // 获取全局句柄 // 1. 处理错误中断优先级最高 if (SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_ERR) ! RESET) { uint16_t error_flags SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_OVR | SPI_I2S_FLAG_MODF); pHandle-state SPI_TRANSFER_ERROR; // 清除错误标志和中断标志具体操作依赖手册 SPI_I2S_ClearITPendingBit(SPI1, SPI_I2S_IT_ERR); // 可以在这里记录错误类型或触发一个错误回调函数 return; // 发生错误本次传输终止 } // 2. 处理接收中断 (RXNE) if (SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_RXNE) ! RESET) { uint8_t received_data SPI_I2S_ReceiveData(SPI1); // 读取数据同时清除RXNE标志 if (pHandle-rx_buffer ! NULL pHandle-rx_count pHandle-rx_size) { pHandle-rx_buffer[pHandle-rx_count] received_data; } // 注意全双工模式下RXNE中断可能不需要单独使能见2.1节说明 SPI_I2S_ClearITPendingBit(SPI1, SPI_I2S_IT_RXNE); } // 3. 处理发送中断 (TXE) - 这是驱动传输的主循环 if (SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_TXE) ! RESET) { if (pHandle-tx_count pHandle-tx_size) { // 还有数据要发写入下一个字节 SPI_I2S_SendData(SPI1, pHandle-tx_buffer[pHandle-tx_count]); } else { // 所有数据已发送完毕关闭TXE中断防止空触发 SPI_I2S_ITConfig(SPI1, SPI_I2S_IT_TXE, DISABLE); // 如果也不需要接收了例如只发送模式可以在这里判断并完成状态切换 } SPI_I2S_ClearITPendingBit(SPI1, SPI_I2S_IT_TXE); } // 4. 传输完成判断 // 一种常见的完成条件发送完毕并且接收也达到了预期数量对于全双工 // 另一种对于只发送发送完即完成对于只接收需要依靠其他机制如NSS引脚判断结束。 // 这里以全双工发送/接收等量数据为例 if ((pHandle-tx_count pHandle-tx_size) (pHandle-rx_count pHandle-rx_size)) { pHandle-state SPI_TRANSFER_COMPLETE; // 关闭所有相关中断 SPI_I2S_ITConfig(SPI1, SPI_I2S_IT_TXE | SPI_I2S_IT_RXNE | SPI_I2S_IT_ERR, DISABLE); // 可以在这里调用用户定义的回调函数通知主程序传输完成 if (transfer_complete_callback ! NULL) { transfer_complete_callback(pHandle); } } }这个ISR展示了典型的中断处理流程错误优先处理然后处理数据收发最后判断传输是否完成并进行收尾工作。注意清除中断标志位是必不可少的步骤否则会连续进入中断。3.3 上层应用接口设计有了底层引擎我们需要提供简洁的API给应用层调用。// 初始化传输句柄 void SPI_TransferInit(SPI_TransferHandle_t *handle, SPI_HandleTypeDef *hspi); // 启动一次中断传输非阻塞 SPI_Status_t SPI_Transfer_IT(SPI_TransferHandle_t *handle, uint8_t *pTxData, uint16_t txSize, uint8_t *pRxData, uint16_t rxSize) { if (handle-state SPI_TRANSFER_BUSY) { return SPI_BUSY; // 忙拒绝新请求 } // 重置句柄状态 handle-tx_buffer pTxData; handle-rx_buffer pRxData; handle-tx_size (pTxData NULL) ? 0 : txSize; handle-rx_size (pRxData NULL) ? 0 : rxSize; handle-tx_count 0; handle-rx_count 0; handle-state SPI_TRANSFER_BUSY; // 使能错误中断始终开启以捕获异常 __HAL_SPI_ENABLE_IT(handle-hspi, SPI_IT_ERR); // 关键如何启动第一次传输 // 对于全双工主模式手动写入第一个数据从而“点燃”传输链。 if (handle-tx_size 0) { // 使能TXE中断写入第一个数据后后续将由TXE中断自动推进。 __HAL_SPI_ENABLE_IT(handle-hspi, SPI_IT_TXE); // 手动写入第一个数据触发时钟和传输过程。 handle-hspi-Instance-DR handle-tx_buffer[handle-tx_count]; } else if (handle-rx_size 0) { // 纯接收模式从设备需要发送哑元数据Dummy来产生时钟。 // 可以预先填充发送缓冲区为0xFF然后按发送模式启动。 // 或者使能RXNE中断并手动写入一个哑元数据启动时钟。 } return SPI_OK; } // 查询传输状态非阻塞 SPI_Status_t SPI_Transfer_GetState(SPI_TransferHandle_t *handle) { return handle-state; } // 阻塞等待传输完成可选用于简化某些场景 SPI_Status_t SPI_Transfer_Wait(SPI_TransferHandle_t *handle, uint32_t timeout) { uint32_t tickstart HAL_GetTick(); while (handle-state SPI_TRANSFER_BUSY) { if ((timeout ! HAL_MAX_DELAY) ((HAL_GetTick() - tickstart) timeout)) { // 超时处理强制停止中断重置状态 __HAL_SPI_DISABLE_IT(handle-hspi, SPI_IT_TXE | SPI_IT_RXNE | SPI_IT_ERR); handle-state SPI_TRANSFER_ERROR; return SPI_TIMEOUT; } // 可以在这里执行一些低优先级任务或者进入低功耗模式WFI // __WFI(); } return (handle-state SPI_TRANSFER_COMPLETE) ? SPI_OK : SPI_ERROR; }通过这样的设计应用层可以非常方便地发起一个非阻塞的SPI传输然后去处理其他任务定期查询状态或等待回调函数被调用。整个通信过程在后台由中断自动完成。4. 避坑指南中断模式SPI的典型陷阱与调试心得在实际项目中从轮询切换到中断模式可能会遇到一些意想不到的问题。下面是我踩过的一些坑和总结的经验。4.1 数据错位与“快一拍/慢一拍”问题这是最常见的问题之一。现象是接收到的数据总是和发送的数据对不上比如收到了下一个字节或者收到了前一个字节。根因分析根本原因在于TXE和RXNE中断的处理时机与数据寄存器的读写顺序不匹配。在全双工模式下写入DR会启动一次完整的收发一个字节出去一个字节进来。如果处理逻辑是“在TXE中断里只发送在RXNE中断里只接收”那么时序就乱套了。解决方案遵循“在TXE中断中先读后写”的黄金法则。具体来说在TXE中断服务函数里首先读取DR寄存器这将清除RXNE标志并获取上一次传输收到的字节。然后判断是否还有数据要发送。如果有写入下一个字节到DR这将清除TXE标志并启动下一次传输。将第1步读到的数据存入接收缓冲区。 这样读和写的操作与硬件动作严格对齐确保了数据流同步。4.2 缓冲区溢出OVR与数据丢失在高速连续传输时如果CPU来不及处理接收中断就会发生OVR错误导致数据丢失。根因分析中断服务程序执行时间过长、中断被更高优先级中断长时间阻塞、或者主程序关闭了全局中断都可能导致CPU无法及时响应RXNE中断。当DR中旧数据未被读取新数据已经移入移位寄存器准备写入DR时OVR发生。解决方案与调试优化ISR确保中断服务程序尽可能短小。只做最核心的数据搬运和状态更新复杂的处理如数据解析、校验放到主循环或低优先级任务中。合理设置中断优先级给SPI中断设置一个合适的优先级。如果系统中有USB、以太网等高速外设SPI的优先级可能需要高于它们以确保实时性反之如果SPI服务于一个低速传感器其优先级可以设低一些。使用DMA这是终极解决方案。对于大批量、高速率的SPI传输强烈建议使用DMA。让DMA控制器自动在内存和SPI数据寄存器之间搬运数据完全解放CPU且不会产生溢出错误。中断仅用于传输开始和结束的通知。调试时务必在初始化时使能错误中断ERRIE并在错误中断中设置断点或点亮LED。一旦发生OVR能立刻捕获。清除OVR错误需要按特定顺序操作先读DR再读SR。4.3 主从模式下的NSS引脚管理陷阱当使用硬件NSS从设备选择引脚时配置不当会导致通信完全失败。问题场景STM32作为从设备期望主设备通过NSS引脚控制通信周期。但STM32的SPI NSS引脚有几种模式硬件从模式、硬件主模式、软件模式。踩坑实录我曾遇到STM32作为从机配置为“硬件NSS输入”但主机的NSS信号是一个很短的脉冲。STM32的SPI在检测到NSS上升沿通信结束时会自动进入“从设备失能”状态内部逻辑被复位。如果此时主机紧接着发起下一次传输NSS再次拉低从机STM32可能还没来得及重新初始化内部状态导致第一个字节的传输出错。解决策略策略一推荐对于从设备将NSS配置为“软件管理”SPI_NSSInternalSoft_Set在代码中控制SPI_CR1的SSM和SSI位。这样完全忽略硬件NSS引脚通信的起始和结束由应用层协议如特定的命令字节来控制更加灵活稳定。策略二如果必须使用硬件NSS确保主设备的NSS信号在两次传输之间有足够长的空闲时间参考芯片数据手册中的参数t_{NSS}。或者在从设备的SPI错误中断中检查MODF错误并在错误处理中重新初始化SPI。4.4 中断与DMA的混合使用考量在一些复杂场景比如需要发送一大块数据但只接收其中几个特定字节的响应时可能会考虑发送用DMA接收用中断。潜在风险DMA发送完成中断和SPI RXNE中断是异步发生的。如果DMA发送完最后一字节后立即关闭了SPI时钟而此时最后一个字节的接收尚未完成RXNE标志未置起就会导致最后一个接收数据丢失或错误。经验技巧在这种情况下不要依赖DMA传输完成中断TC作为整个通信结束的标志。应该配置DMA在传输完成后不自动关闭SPI或DMA然后转而启用SPI的RXNE中断。在RXNE中断中判断是否收到了最后一个期望的字节全部收齐后再在软件中手动停止DMA和SPI。这确保了收发时钟周期的完整性。5. 进阶实战中断模式SPI驱动OLED屏幕与SD卡理论最终要服务于实践。我们来看两个常见但略有差异的中断SPI应用场景体会一下配置上的细微差别。5.1 驱动SPI OLED屏幕SSD1306单向发送为主OLED屏幕通常只需要MCU向其发送命令和数据属于半双工发送模式。屏幕本身几乎不返回数据除了少数读状态命令。配置要点工作模式SPI配置为只发送Transmit Only主模式。在SPI_CR1寄存器中可以设置BIDIMODE0双线双向或配置为单向。更简单的做法是依然使用全双工模式但忽略接收到的数据通常是0xFF或0x00。中断策略使能TXE中断即可。在TXE中断服务程序中写入下一个要发送的数据命令或GRAM数据。因为不需要关心接收所以无需处理RXNE。数据组织由于OLED屏幕需要区分命令和数据通常通过一个DC数据/命令引脚来控制。在发送前先设置DC引脚电平然后启动SPI中断传输。可以将DC引脚的控制和SPI数据打包成一个“事务”在TXE中断中根据当前发送的是命令还是数据来动态控制DC引脚注意时序。优化技巧为了追求更高的刷新率可以将一帧屏幕数据如128x64/81024字节放入一个大的发送缓冲区然后启动一次性的长传输。此时中断频率会很高需要评估CPU负载。如果负载过高应优先考虑使用DMA。5.2 读写SD卡SPI模式命令与数据的交替SD卡在SPI模式下通信是半双工的但方向会频繁切换。先由主机发送命令CMD然后SD卡返回响应R1, R7等接着可能进入数据令牌阶段进行数据块的读写。通信模式分析这是一个典型的“发送-等待-接收”交替的过程并且有严格的超时要求。例如发送CMD0后需要等待最多74个时钟周期内SD卡返回响应发送CMD8后需要接收R7响应5个字节。中断设计策略单纯的TXE或RXNE中断流不适合这种变长、变方向的通信。更常见的做法是采用状态机超时机制并配合中断。发送阶段使能TXE中断将命令字节如6字节的CMD通过中断流发送出去。发送完成后关闭TXE中断。等待/接收响应阶段切换到接收模式。这里有两种思路思路A轮询等待在发送完成后延迟少量时间然后开启RXNE中断并开始向SD卡发送哑元时钟0xFF。在RXNE中断中收取响应字节直到收到有效的响应起始位通常是0x00或超时。这种方式逻辑简单但中断可能频繁触发。思路B超时中断发送完成后启动一个硬件定时器如基本定时器作为超时监视。同时使能RXNE中断。在RXNE中断中收到第一个有效字节后关闭定时器。如果在定时器中断中先于RXNE中断发生则判定为超时错误。这种方式更健壮。数据块传输阶段对于读数据块主机需要持续发送时钟0xFFSD卡会连续返回数据。可以使用RXNE中断流来接收但同样要处理起始令牌0xFE和CRC字节。对于写数据块主机需要先发送起始令牌0xFE然后使用TXE中断流连续发送数据最后发送CRC。核心心得驱动SD卡超时处理和错误重试机制比SPI本身的中断逻辑更重要。必须为每个命令阶段设置合理的超时并在超时后重试或返回错误。中断在这里的作用是高效地搬运数据块而命令响应的处理则需要更精细的状态控制。6. 性能考量与替代方案何时该用DMA中断模式虽然解放了CPU但每个字节的传输都会产生一次中断。对于115200波特率的UART这或许可以接受每秒约11520个字节即11520次中断。但对于以几十MHz时钟运行的SPI如果全速传输中断频率将是MHz级别这会给CPU带来巨大的上下文切换开销严重时甚至无法处理完当前中断下一个中断又来了导致系统崩溃。中断模式的性能边界一个经验法则是评估你的SPI中断服务程序ISR的执行时间可以用IO翻转示波器测量。如果ISR执行时间T_isr乘以数据传输速率字节/秒接近或超过CPU的处理能力那么中断模式就不合适了。例如ISR需要2usSPI以10Mbps传输约1.25MB/s那么每秒中断次数为1.25MCPU花费在ISR上的时间就高达2.5秒远超1秒这显然不可能。DMA的优势DMA直接存储器访问控制器可以在不占用CPU核心的情况下在外设和内存之间自动搬运数据。对于SPI你只需要配置好源地址内存、目标地址SPI-DR、数据长度然后启动DMA。在整个数据块传输过程中CPU完全自由。DMA传输完成后会产生一个传输完成中断或半传输中断来通知CPU进行后续处理如解析数据、准备下一包。选择建议单次传输数据量小10字节频率低使用轮询或中断均可中断更省CPU。单次传输数据量中等10~100字节频率中等中断模式是很好的选择实现相对简单。单次传输数据量大100字节或频率高1MHz强烈推荐使用DMA。这是STM32这类现代MCU的标准用法能极大提升系统整体性能。连续流式传输如音频流、图像数据必须使用DMA并配合双缓冲区Double Buffer或循环模式Circular Mode以实现无缝连续传输。从中断模式过渡到DMA是STM32 SPI应用从“能用”到“高效、专业”的关键一步。当你被频繁的中断搞得焦头烂额时就是该认真研究DMA的时候了。不过DMA的配置和调试复杂度更高需要处理好外设与DMA通道的映射、数据对齐、传输完成标志清除等问题这又是另一个值得深入探讨的话题了。