ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 HAL库UART中断处理:从IRQHandler到DMA+IDLE接收

STM32 HAL库UART中断处理:从IRQHandler到DMA+IDLE接收 1. 拿到HAL_UART_IRQHandler先搞清楚中断究竟从哪来很多人在STM32上做串口通信第一反应是打开中断、写回调、跑起来。等真出了问题——比如接收丢字节、回调不执行、死循环卡死——才回头去看HAL_UART_IRQHandler。说实话这函数才是整个HAL库UART中断处理的枢纽看不懂它就等于一直在黑盒里调参。先明确一个底层事实STM32的UART/USART外设中断源其实就那么几个——发送数据寄存器空TXE、接收数据寄存器非空RXNE、发送完成TC、总线空闲IDLE以及各种错误标志溢出ORE、帧错误FE、校验错误PE、噪声NE。不同系列寄存器命名有差异比如F1和F4用的是SR/DR而F0/G0/L0这些较新系列换成了ISR/ICR但中断事件的本质是一样的。这些中断标志会映射到同一个NVIC中断线也就是我们常说的USARTx_IRQn。进入中断服务函数后HAL库统一收口到HAL_UART_IRQHandler由它来做事件分发。HAL_UART_IRQHandler的核心逻辑概括起来就一句话读标志位判断当前是接收、发送、还是错误事件再分发给内部的底层处理函数最后调用用户回调。这和我早期用标准库写中断时手动读SR、判断标志、清标志、再逐字节搬运是同一个套路只是HAL库把它封装成了固定的流程。有个很关键的细节HAL_UART_IRQHandler内部会根据你之前调用的HAL_UART_Receive_IT还是HAL_UART_Transmit_IT去匹配不同的处理分支。也就是说你不主动调用接收函数即使RXNE中断标志置位IRQHandler也不会帮你收数据更不会进回调。很多人以为“开了中断就会自动收”这是理解偏差的根源。另外要注意不同系列的HAL库实现有细节差异。比如F1的HAL_UART_IRQHandler里对UART_FLAG_RXNE的判断是直接读SR寄存器而G0系列的库实现会区分UART_IT_RXNE_RXNE和UART_IT_RXNE_NE等标志组合。移植代码时不能拿F1的写法无缝套到G0上这点后面会展开。2. 回调函数全梳理TxCplt、RxCplt、ErrorCallback到底什么时候被调2.1 中断回调调用链从标志位到用户代码HAL库的UART回调机制设计思路是“框架处理事件用户处理业务”。你不需要在中断服务函数里写一堆标志判断只需要实现对应回调函数。接收完成的回调函数是HAL_UART_RxCpltCallback。它什么时候触发取决于接收方式调用HAL_UART_Receive_IT请求接收N个字节当N个字节全部收完进入一次RxCpltCallback。调用HAL_UART_Receive_IT请求接收1个字节那每收一个字节就触发一次回调。调用HAL_UART_Receive_DMADMA搬运完N个字节后触发一次回调。发送完成回调HAL_UART_TxCpltCallback同理调用HAL_UART_Transmit_IT发送N个字节全部发完后触发。注意默认情况下这两个回调和DMA传输完成回调HAL_UART_TxCpltCallback/HAL_UART_RxCpltCallback在DMA模式下同样会被调用只是DMA模式下还多了半传输回调HAL_UART_TxHalfCpltCallback和HAL_UART_RxHalfCpltCallback。还有一组容易被忽略的回调是HAL_UARTEx_RxEventCallback这是新版HAL库搭配HAL_UARTEx_ReceiveToIdle_IT和HAL_UARTEx_ReceiveToIdle_DMA使用的。它不是在接收满指定长度时触发而是在检测到总线空闲时提前把当前收到的数据长度告诉用户。做不定长协议解析时这函数比手动操作IDLE标志好用得多。2.2 错误回调UART_ErrorCallback的正确打开方式错误回调的完整签名是void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)注意这个回调没有直接给你错误码需要你在回调里面通过__HAL_UART_GET_FLAG或者读取huart-ErrorCode来判断具体错误类型。错误分为几类溢出错误ORE、帧错误FE、校验错误PE、噪声错误NE还有DMA模式下的传输错误。在HAL库中这些错误码会通过HAL_UART_ERROR_ORE、HAL_UART_ERROR_FE、HAL_UART_ERROR_PE、HAL_UART_ERROR_NE等宏定义标识DMA错误则复用HAL_DMA_ERROR_xxx系列。实际项目中最常见的是ORE溢出错误。它的含义是接收寄存器中的数据还没被读走新的数据又来了导致旧数据被覆盖。触发原因多是自己处理得太慢或者中断被更高优先级打断又或者一次性配置了过长的DMA接收。错误回调里我强烈建议做三件事读一次huart-ErrorCode定位错误类型。根据错误类型做恢复动作比如重新调用HAL_UART_Receive_IT或HAL_UART_Receive_DMA。必要的话设置一个错误标志供主循环查询。有一种反面写法是只在错误回调里打印日志然后什么都不做结果中断标志没清干净导致反复进入错误回调系统直接卡死在中断里。这属于最常见的低级别事故。3. 实战三种串口中断接收方案从零搭起来3.1 方案一单字节中断接收适合简单指令交互这是最基础的用法。初始化流程// 串口初始化默认8位数据、无校验、1停止位 HAL_UART_Init(huart1); // 开启接收中断请求接收1个字节 HAL_UART_Receive_IT(huart1, rx_data, 1);随后在中断服务函数里转调HAL库处理函数void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); }最后在回调函数里处理数据并开启下一次接收void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_byte(rx_data); HAL_UART_Receive_IT(huart1, rx_data, 1); } }这个方案的优点是逻辑清晰、代码少适合指令短、频率不高的场景。缺点也很明显每收一个字节就进出一次中断高波特率或者连续大数据流时CPU开销大且容易丢数据。有些初学者会犯一个错在回调里调用了HAL_UART_Receive_IT但是传入的缓冲区地址是局部变量结果下次中断来时目标地址已经失效数据写到栈里程序直接乱掉。记住接收缓冲区生命周期必须覆盖整个接收过程全局变量或静态变量是底线。3.2 方案二单字节中断IDLE空闲中断实现不定长帧接收很多项目并不事先知道一帧数据有多长此时可以用IDLE中断来判断“总线空闲了一小段时间认为一帧结束”。具体做法初始化时开启RXNE中断和IDLE中断。每个字节到达时通过HAL_UART_RxCpltCallback接收并存入缓冲区。总线空闲时IDLE中断触发在中断里置位帧完成标志主循环解析。使能IDLE中断的代码__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);中断服务函数里处理IDLE标志void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); rx_frame_ready 1; } HAL_UART_IRQHandler(huart1); }这里有一个值得注意的点__HAL_UART_CLEAR_IDLEFLAG在不同系列上实现不同。F1系列是通过读SR再读DR来清除的F0/G0系列则是直接写ICR寄存器的IDLECF位。移植时不能想当然。这种方案的优点是不需要DMA中等数据量下够用。但有代价每个字节仍然触发一次接收中断IDLE中断也要额外处理CPU占用率不算低。3.3 方案三DMAIDLE高性能不定长接收的常用做法如果数据量大、波特率高、还要求CPU尽量少参与那就上DMAIDLE。思路是配置UART接收的DMA通道让DMA自动把串口收到的数据搬运到内存缓冲区同时开启IDLE中断。当总线空闲时说明一帧收完DMA计数器会记录当前剩余未搬运的数量用总长度减去剩余数量就是本次实际收到的字节数。关键代码#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; // 启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 使能IDLE中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);IDLE中断中计算接收长度void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t rx_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rx_frame_ready 1; rx_frame_len rx_len; } HAL_UART_IRQHandler(huart1); }DMA模式下要注意缓冲区大小问题。如果收到的数据正好等于RX_BUF_SIZEDMA会自动触发传输完成回调此时IDLE中断可能没机会触发需要在HAL_UART_RxCpltCallback里也做帧处理否则数据会黏在缓冲区里没人处理。这个方案我用了很多年稳定可靠。但配置DMA时最常见的坑是初始化顺序必须先初始化UART再初始化DMA否则DMA请求信号没有正确连接。另外DMA中断和UART中断的优先级要合理分配建议DMA中断优先级略高于或等于UART中断。3.4 新库福利HAL_UARTEx_ReceiveToIdle系列API如果你用的HAL库版本比较新比如STM32CubeG0、CubeL4、CubeF4更新到较新的包那么还有一组更省事的APIHAL_UARTEx_ReceiveToIdle_IT(huart1, rx_buf, RX_BUF_SIZE); HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, RX_BUF_SIZE);它们把“接收指定长度”和“空闲检测”集成到了一起帧结束时会调用HAL_UARTEx_RxEventCallback并且通过参数告诉你实际接收了多少字节void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { rx_frame_len Size; rx_frame_ready 1; HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, RX_BUF_SIZE); } }这套API的好处是逻辑统一不用自己手动清IDLE标志。不过要确认固件包版本老版本没有这组函数。4. 错误处理与恢复不要让串口死在异常里4.1 常见错误码速查与恢复建议串口中断处理中我把常见错误整理成了一张表方便对照排查错误类型标志常见触发原因恢复手段溢出错误ORE数据到达时RXNE还没被读走新数据覆盖旧数据清ORE标志重启接收帧错误FE波特率偏差、线路干扰、停止位采样失败清FE标志检查波特率和接线校验错误PE使能了校验位但对端配置不一致清PE标志检查校验位配置噪声错误NE线路干扰严重清NE标志优化硬件或降低波特率DMA传输错误DMA TE/FEDMA配置错误、缓冲区访问越界停止DMA重新初始化DMA并启动接收在错误回调里完整的恢复代码可以参考下面这种写法void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint32_t err huart-ErrorCode; if (err HAL_UART_ERROR_ORE) { __HAL_UART_CLEAR_OREFLAG(huart1); } if (err HAL_UART_ERROR_FE) { __HAL_UART_CLEAR_FEFLAG(huart1); } if (err HAL_UART_ERROR_PE) { __HAL_UART_CLEAR_PEFLAG(huart1); } if (err HAL_UART_ERROR_NE) { __HAL_UART_CLEAR_NEFLAG(huart1); } // 错误后重新启动接收 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } }4.2 处理错误回调时最容易翻车的三个细节先说清标志问题。不同错误标志的清法不一样而且不同系列之间差异很大。F1的ORE标志需要先读SR再读DR才能清除而F0/G0系列直接写ICR寄存器的ORECF位就行。你如果是拿着F1的代码硬套到G0上清标志这一步就会出问题表现为“清了标志但错误中断依然反复进来”。第二点是错误状态下不能直接用同一个缓冲区重新启动接收。比如DMA模式下发生溢出错误之前启动的DMA接收可能还挂在那边此时直接再次调用HAL_UART_Receive_DMA可能返回HAL_BUSY导致启动失败。稳妥的做法是先HAL_UART_DMAStop再重新启动接收。我自己写代码时通常在错误恢复分支里先把DMA停掉清标志然后再开这一套组合下来非常稳。第三点是错误回调里尽量不要做太重的处理。错误回调本身运行在中断上下文你在里面做浮点运算、打印长日志、甚至调用HAL_Delay都可能把系统拖死。正确做法是置标志位把恢复动作放到主循环去做。4.3 中断优先级配置如何影响错误恢复NVIC优先级配置看似简单实际上对串口中断和错误恢复影响很大。UART接收中断服务函数里做的事情不多但要求“及时”尤其是不能比DMA中断和SysTick中断低太多。如果UART中断优先级比SysTick低而你在UART中断里调用HAL_Delay就会死等。原因很简单HAL_Delay依赖SysTick的uwTick计数而SysTick如果被UART中断抢占计数就停滞HAL_Delay永远等不到超时。这是新手最常见、也最难排查的卡死原因之一。我一般这样配优先级SysTick最低UART接收中断中等偏高DMA中断和UART中断同级或者略高。同时约定回调函数里绝对不调用HAL_Delay串口相关的逻辑尽量精简。5. 中断标志位操作的细节与避坑清单5.1 标志清除方式因系列而异别拿F1代码通吃前面提到过SR/DR和ISR/ICR的差异这里再展开说。F1系列的标志清除大多通过“读SR再读/写DR”的方式完成而F0/G0/L0系列把状态寄存器换成了ISR控制寄存器换成了ICR清标志直接写ICR对应位即可。这种差异带来的直接后果是你从F1移植到G0原本正常的IDLE中断、ORE恢复代码可能会出现编译通过但运行不正常的情况。比如IDLE标志的清除// F1系列 __HAL_UART_CLEAR_IDLEFLAG(huart1); // G0系列内部实现写ICR寄存器的IDLECF位 __HAL_UART_CLEAR_IDLEFLAG(huart1);宏名字一样内部实现已经不同。所以移植时不要只看函数名要打开HAL库头文件确认一下具体寄存器操作。5.2 接收缓冲区与DMA缓冲区的边界问题DMA模式下缓冲区是DMA和CPU共用的要特别注意数据竞争。一帧数据到达时DMA往缓冲区写数据同时主循环可能在读缓冲区解析协议。如果两者访问同一片内存而没有任何同步机制轻则协议解析出错重则程序跑飞。我个人的习惯是采用双缓冲区方案DMA接收缓冲区A和B当DMA写满A时在回调里切换DMA目标到B同时通知主循环去解析A。这样DMA和主循环各自操作不同的缓冲区彻底避开竞争。对于单缓冲区的低成本方案则必须保证“在DMA静止时解析数据”。IDLE中断触发的时机正好是总线空闲理论上DMA不会再写入数据此时解析相对安全。但要注意如果在IDLE中断里置标志后主循环还没来得及处理下一帧数据就来了DMA会继续往缓冲区写导致上一帧数据被覆盖。所以缓冲区大小要按“最大帧长x2”来规划留足主循环的响应余量。5.3 HAL库版本不同回调行为有差异HAL库一直在迭代即便同一个系列不同固件包版本下的UART驱动实现也有细微差别。最典型的是HAL_UARTEx_RxEventCallback这个回调早期版本根本没有后来才加入的。你在网上搜到的大部分代码都是老版本写法直接拷到新工程里可能找不到函数。遇到这种问题先看当前工程的stm32xxxx_hal_uart.h里有没有声明HAL_UARTEx_ReceiveToIdle_IT。如果没有说明固件包版本偏老要么升级固件包要么退回手动处理IDLE中断的方案。6. 一个完整的串口收发框架分享说了这么多最后分享一套我常用的框架覆盖DMAIDLE接收、中断发送、错误恢复三个部分可以直接抄进工程里改改用。头文件里定义缓冲区#define RX_BUF_SIZE 256 #define TX_BUF_SIZE 128 extern volatile uint8_t rx_frame_ready; extern volatile uint16_t rx_frame_len; extern uint8_t rx_buf[RX_BUF_SIZE]; extern uint8_t tx_buf[TX_BUF_SIZE];初始化部分void uart_init(void) { // 串口参数初始化115200, 8N1 huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1); // DMA接收配置由HAL_UART_Receive_DMA内部完成 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 开启IDLE中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 使能串口中断 HAL_NVIC_SetPriority(USART1_IRQn, 3, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); // 使能DMA中断如果使用DMA接收 HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 2, 0); HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn); }中断服务函数void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); rx_frame_len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (rx_frame_len 0) { rx_frame_ready 1; } } HAL_UART_IRQHandler(huart1); }主循环里处理协议帧while (1) { if (rx_frame_ready) { rx_frame_ready 0; // 解析rx_buf, 长度rx_frame_len parse_protocol(rx_buf, rx_frame_len); // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } }这套框架最舒服的地方在于中断里只做标志判断和长度计算协议解析全部放到主循环DMA自动搬运数据CPU占用率极低。实测在115200波特率、满负载收发的情况下运行稳定的很。在实际项目中我还习惯把串口错误次数统计起来一旦发现错误频率过高就主动降低波特率或者提示上位机重新同步。这属于工程上的防御性设计做出来之后设备在工业环境下的稳定性会明显提升。串口这东西看着简单真要稳定跑起来中断处理、缓冲区管理、错误恢复每一项都得做到位才行。
返回列表