☰
STM32串口空闲中断DMA接收不定长数据与消息队列实战
2026/10/14 0:11:00 网站建设 项目流程

简介:这份资源面向嵌入式开发工程师与STM32学习者,聚焦串口空闲中断、DMA与FreeRTOS消息队列三者结合,解决不定长数据帧接收时CPU占用高、缓冲区管理繁琐的问题。压缩包共689个文件,约10.92MB,以357个c源文件与130个h头文件为核心,配合43个s汇编、37个d依赖及37个o目标文件,另有icf、sct等分散加载配置、uvprojx与uvoptx工程文件、lib库文件及hex烧录文件,构成可直接编译的完整工程。资源围绕串口空闲中断触发、DMA自动搬运、动态缓冲区调整与消息队列异步通信展开,读者可据此掌握中断回调中计算数据长度、投递队列,再由任务用xQueueReceive取出的完整链路,并理解中断优先级与DMA参数配置要点。目前已有5625人学习下载,适合希望提升串口通信实时性与资源利用率的开发者参考。

1. STM32串口空闲中断DMA接收不定长数据消息队列:为什么它是工业现场最稳的收包方案

如果你做过 Modbus RTU、自定义二进制协议或者带帧头帧尾的传感器协议,一定遇到过这个场景:主站发来一包数据,长度每次都不一样,用HAL_UART_Receive_IT一个字节一个字节收,CPU 被中断打断得怀疑人生;用HAL_UART_Receive_DMA收固定长度,又不知道对方到底发了多少字节。STM32 串口空闲中断配合 DMA 接收不定长数据,再挂一个消息队列把整包数据交给业务任务处理,就是解决这个问题的经典组合拳。它的核心思路是:DMA 负责把串口收到的字节静默搬进缓冲区,不占用 CPU;空闲中断(IDLE)负责在总线静默一个字节时间后告诉你“这一包发完了”;消息队列负责把这一包数据从中断上下文安全地传递到任务上下文。三者各司其职,CPU 占用率极低,实时性好,是工业现场和嵌入式产品里最稳的收包方案之一。这篇文章面向正在选型或已经踩坑的嵌入式工程师,从原理到代码到避坑,把这条链路讲透。

2. 空闲中断加DMA到底怎么配合:从寄存器行为到收包时机

2.1 空闲中断的触发条件与DMA的搬运逻辑

STM32 的 USART 外设有一个 IDLE 标志位,它的置位条件是:接收数据线在检测到一个完整字节(起始位+数据位+校验位+停止位)之后,又保持空闲(高电平)达到一个字节的传输时间。注意,这个“一个字节时间”是硬件根据波特率自动计算的,不需要软件干预。当 IDLE 置位时,如果使能了 IDLEIE 中断,就会进入 USART 中断服务函数。

DMA 这边,我们通常把串口的 RX 寄存器地址作为外设地址,把一块内存缓冲区作为目标地址,方向设为外设到内存,模式设为循环模式(Circular)或普通模式(Normal)。循环模式下,DMA 会一直搬,搬满一圈自动回到起点继续搬,缓冲区永远不会溢出,但你需要知道当前搬到了哪个位置。普通模式下,搬满指定长度就停止,需要重新配置才能继续收。

把两者结合起来:DMA 在后台默默搬字节,IDLE 中断在包结束时触发。在 IDLE 中断里,我们读取 DMA 的剩余传输计数寄存器NDTR,用缓冲区总长度减去NDTR,就得到了这一包实际收到的字节数。然后把这个长度和缓冲区指针打包,投递到消息队列,最后重置 DMA 的接收状态,准备下一包。

这里有一个关键点:IDLE 中断触发时,DMA 可能还在搬运最后一个字节,或者刚好搬完。所以读NDTR的时机要稍微注意,通常做法是在中断里先读NDTR,再清 IDLE 标志,顺序反了可能读到旧值。

2.2 为什么不用普通串口中断收不定长数据

普通串口中断每收到一个字节就进一次中断,波特率 115200 时,一个字节大约 87 微秒,意味着 CPU 每 87 微秒就被打断一次。如果系统里还有定时器中断、ADC 中断、其他通信中断,中断嵌套和优先级管理会变得非常复杂,稍有不慎就丢字节。而且每次中断都要读寄存器、存缓冲区、更新索引,CPU 开销随波特率线性增长。

DMA 方案下,CPU 完全不参与字节搬运,只在包结束时进一次 IDLE 中断。假设一包数据 50 字节,普通中断要进 50 次,DMA 方案只进 1 次。这个差距在高速通信或多路串口场景下是决定性的。

2.3 消息队列在中断与任务之间的桥梁作用

裸机环境下,你可以在 IDLE 中断里直接处理数据,但处理时间不能太长,否则影响其他中断响应。在 RTOS 环境下,中断里绝对不能调用阻塞 API,也不能做耗时解析。消息队列的作用就是:中断里只做最轻量的动作——记录长度、投递消息;任务里从队列取出消息,从容解析协议、执行业务逻辑。

消息队列的深度需要根据最坏情况估算:如果一包数据还没处理完,下一包又来了,队列里就会堆积。深度太小会丢包,深度太大浪费内存。一般建议至少 4 到 8 个节点,每个节点的数据区大小等于最大帧长。

3. 手把手搭建:从CubeMX配置到消息队列投递的完整代码

3.1 硬件与工程配置:时钟、串口、DMA、中断优先级

假设使用 STM32F103 系列,系统时钟 72MHz,串口 1 波特率 115200,8 数据位,1 停止位,无校验。在 CubeMX 里做以下配置:

  • 使能 USART1,模式 Asynchronous,波特率 115200。
  • 使能 USART1 的 DMA 接收通道,模式 Circular,数据宽度 Byte,优先级 Medium。
  • 使能 USART1 全局中断,NVIC 里设置合适的抢占优先级和子优先级。DMA 中断如果不用可以不开,我们只用 IDLE 中断。
  • 如果跑 FreeRTOS,使能一个队列,比如osMessageQueueNew(8, sizeof(Frame_t), NULL)。

配置完成后生成代码,接下来手动添加空闲中断使能和消息队列逻辑。

3.2 定义帧结构与消息队列:数据怎么打包

先定义一个结构体,用来在中断和任务之间传递一包数据:

// frame.h #ifndef FRAME_H #define FRAME_H #include <stdint.h> #define UART_RX_BUF_SIZE 256 // DMA 循环缓冲区大小,必须大于最大帧长 #define QUEUE_DEPTH 8 // 消息队列深度 typedef struct { uint8_t data[UART_RX_BUF_SIZE]; // 实际数据拷贝 uint16_t len; // 本帧有效长度 } Frame_t; #endif

这里把数据拷贝进结构体,而不是只传指针。原因是 DMA 循环缓冲区会被后续数据覆盖,如果只传指针,任务还没处理完,新数据就把旧数据冲掉了。拷贝虽然多花一点时间,但换来了数据安全。拷贝发生在中断里,256 字节的memcpy在 72MHz 的 M3 上大约几微秒,可以接受。如果帧长经常接近 256,可以适当减小缓冲区或提高任务优先级。

3.3 启动DMA接收与使能空闲中断

在串口初始化完成后,启动 DMA 接收并打开 IDLE 中断:

// uart_port.c #include "usart.h" #include "frame.h" #include "cmsis_os2.h" extern UART_HandleTypeDef huart1; extern osMessageQueueId_t uartQueue; // 在 main 里创建 static uint8_t dma_rx_buf[UART_RX_BUF_SIZE]; void UART_DMA_Start(void) { // 启动 DMA 循环接收,长度设为缓冲区大小 HAL_UART_Receive_DMA(&huart1, dma_rx_buf, UART_RX_BUF_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }

HAL_UART_Receive_DMA会配置 DMA 并启动传输。循环模式下,DMA 搬满 256 字节后自动回到开头,NDTR从 0 重新变成 256 再递减。__HAL_UART_ENABLE_IT是宏,直接操作寄存器使能 IDLE 中断。

3.4 空闲中断服务函数:读NDTR、拷贝数据、投递队列

在stm32f1xx_it.c里找到USART1_IRQHandler,添加 IDLE 处理:

// stm32f1xx_it.c #include "frame.h" #include "cmsis_os2.h" extern UART_HandleTypeDef huart1; extern osMessageQueueId_t uartQueue; extern uint8_t dma_rx_buf[UART_RX_BUF_SIZE]; void USART1_IRQHandler(void) { // 检查 IDLE 标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { // 清除 IDLE 标志:先读 SR 再读 DR __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止 DMA 以获取准确的 NDTR HAL_UART_DMAStop(&huart1); // 计算本帧长度 uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t len = UART_RX_BUF_SIZE - remain; if (len > 0 && len <= UART_RX_BUF_SIZE) { Frame_t frame; frame.len = len; memcpy(frame.data, dma_rx_buf, len); // 投递到消息队列,超时 0 表示不阻塞 osMessageQueuePut(uartQueue, &frame, 0, 0); } // 重新启动 DMA 接收 HAL_UART_Receive_DMA(&huart1, dma_rx_buf, UART_RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }

逻辑说明:先判断 IDLE 标志,清除它。然后停止 DMA,读取NDTR计算长度。拷贝数据到局部Frame_t,投递队列。最后重新启动 DMA。注意HAL_UART_DMAStop会关闭 DMA 通道,重新启动需要再次调用HAL_UART_Receive_DMA。这个顺序保证了NDTR读取的准确性。

参数说明:osMessageQueuePut的第三个参数是消息优先级,第四个是超时时间。在中断里必须用 0 超时,不能阻塞。如果队列满,osMessageQueuePut返回错误,这一包就丢了。队列深度要提前估算好。

3.5 任务侧从队列取数据并解析

在任务里循环取队列:

// app_task.c #include "frame.h" #include "cmsis_os2.h" extern osMessageQueueId_t uartQueue; void UartProcessTask(void *argument) { Frame_t frame; for (;;) { // 阻塞等待消息,超时 100ms if (osMessageQueueGet(uartQueue, &frame, NULL, 100) == osOK) { // 在这里解析 frame.data,长度是 frame.len // 比如校验帧头、CRC、执行命令 Protocol_Parse(frame.data, frame.len); } } }

任务侧用阻塞等待,不占用 CPU。收到一包就解析一包。如果解析耗时较长,可以再开一个队列或直接处理,但要注意队列消费速度必须大于生产速度,否则会丢包。

4. 避坑指南:空闲中断DMA收包最常见的五个翻车现场

4.1 现象:第一包数据总是多出几个字节或长度不对

原因:DMA 启动前,串口 RX 线上可能有残留电平或噪声,导致 DMA 在使能瞬间就搬了几个无效字节。或者 IDLE 标志在使能中断前就已经置位,第一次进中断时读到的NDTR不是从缓冲区开头算起的。

解决:在启动 DMA 之前,先清空串口接收寄存器,清除所有挂起标志。具体做法是读一次SR和DR,然后调用__HAL_UART_CLEAR_IDLEFLAG。再启动 DMA 和 IDLE 中断。另外,可以在第一次进 IDLE 中断时丢弃数据,或者用帧头校验过滤。

4.2 现象:高速通信时偶尔丢包,队列里少了几帧

原因:IDLE 中断里做了memcpy和osMessageQueuePut,如果此时有更高优先级中断抢占,或者队列满,就会丢包。另外,HAL_UART_DMAStop和HAL_UART_Receive_DMA之间有一段时间 DMA 是关闭的,如果这期间来数据,就会丢。

解决:缩短中断里的处理时间。memcpy可以用 DMA 来做,但会增加复杂度。更实际的做法是提高串口中断优先级,确保它不被其他无关中断打断。队列深度加大到 16 甚至 32。如果还是丢,考虑用双缓冲区:DMA 搬一半时切换,但这对 IDLE 方案不适用,因为 IDLE 是包结束标志。另一种方案是只用普通模式 DMA,每包重新配置,但那样更慢。

4.3 现象:IDLE 中断进了,但NDTR读出来是 0 或缓冲区大小

原因:NDTR在 DMA 循环模式下,搬满一圈会从 0 重新变成 256。如果一包数据刚好是 256 字节的整数倍,NDTR可能读到 0 或 256,导致长度计算错误。另外,如果 DMA 传输完成中断也开了,可能会和 IDLE 中断冲突。

解决:限制最大帧长小于缓冲区大小,比如缓冲区 256,最大帧长 200。这样NDTR永远不会是 0 或 256 的边界情况。如果协议允许超长帧,必须用普通模式 DMA 或者分片处理。另外,不要在 IDLE 中断里同时处理 DMA 传输完成中断,两者选一个。

4.4 现象:任务里解析数据时发现数据被覆盖,前后帧混在一起

原因:中断里只传了指针,没有拷贝数据。DMA 循环缓冲区在任务处理期间被新数据覆盖。

解决:必须拷贝。拷贝到局部变量再投递队列,或者用内存池分配一块新缓冲区。拷贝长度是实际帧长,不是整个缓冲区大小。如果帧长经常很大,拷贝开销明显,可以改用“双缓冲区+引用计数”的方案,但实现复杂度高,一般项目用拷贝就够了。

4.5 现象:系统运行一段时间后死机或队列操作返回错误

原因:在中断里调用了带阻塞的队列 API,或者队列控制块被意外修改。另外,osMessageQueuePut在中断里使用时,需要确认 RTOS 支持中断安全版本。有些 RTOS 的Put在中断里必须用PutFromISR。

解决:查 RTOS 文档,中断里用对应的 FromISR 版本。FreeRTOS 用xQueueSendFromISR,CMSIS-RTOS2 的osMessageQueuePut本身是中断安全的,但超时必须为 0。如果返回osErrorResource,说明队列满,要么丢包,要么加大深度。死机问题还要检查栈溢出,中断里用的局部变量Frame_t有 256 字节,如果中断栈太小会溢出。把Frame_t改成静态或全局变量,或者减小缓冲区。

5. 进阶技巧:用双缓冲和零拷贝把收包性能再压榨一倍

前面讲的方案已经能覆盖 90% 的场景,但如果你的波特率上到 921600 甚至更高,或者一包数据几百字节,中断里的memcpy和队列投递就会成为瓶颈。这时候可以考虑双缓冲加零拷贝的变种。

思路是这样的:准备两块 DMA 缓冲区 A 和 B,DMA 循环模式在 A 和 B 之间交替搬运。当 IDLE 中断触发时,当前活跃的缓冲区是 A,那么 B 就是空闲的。在中断里,我们只记录 A 的帧长和指针,把 A 的“所有权”通过队列交给任务,然后立即把 DMA 的目标地址切换到 B。任务处理完 A 后,通过另一个队列把 A 归还给中断。这样中断里没有memcpy,只有指针切换和队列投递,速度极快。

但要注意几个坑:第一,DMA 目标地址切换必须在 DMA 停止时进行,否则行为未定义。第二,如果任务处理太慢,A 和 B 都被占用,新数据来了就没地方放,只能丢包。所以双缓冲适合处理速度稳定的场景,队列深度要配合好。第三,IDLE 中断里判断当前活跃缓冲区需要额外逻辑,比如用一个标志位记录 DMA 当前目标。

下面是一个简化的双缓冲实现片段:

// double_buf.c #define BUF_SIZE 256 static uint8_t buf_a[BUF_SIZE]; static uint8_t buf_b[BUF_SIZE]; static uint8_t *active_buf = buf_a; static uint8_t *ready_buf = NULL; void UART_IDLE_Handler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t len = BUF_SIZE - remain; if (len > 0) { // 当前活跃缓冲区变成待处理 ready_buf = active_buf; // 切换活跃缓冲区 active_buf = (active_buf == buf_a) ? buf_b : buf_a; // 投递指针和长度到队列 BufMsg_t msg = { ready_buf, len }; osMessageQueuePut(uartQueue, &msg, 0, 0); } HAL_UART_Receive_DMA(&huart1, active_buf, BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }

任务侧收到BufMsg_t后直接解析ready_buf,解析完把缓冲区归还。归还机制可以用另一个队列,或者用引用计数。这个方案把中断里的拷贝彻底去掉,中断执行时间从几十微秒降到几微秒,适合高波特率多路串口场景。

不过,双缓冲的复杂度也上去了。我自己的习惯是:如果波特率不超过 115200,帧长不超过 128 字节,直接用拷贝方案,简单可靠,不容易出玄学问题。只有确实遇到性能瓶颈,才上双缓冲。另外,双缓冲下如果任务处理时间波动大,容易丢包,需要加统计计数器监控丢包率,一旦发现丢包就报警或降速。

还有一个细节:HAL_UART_DMAStop会关闭 DMA 通道,重新启动需要时间。在 921600 波特率下,一个字节大约 10 微秒,停止和重启之间可能丢掉几个字节。如果协议允许帧间有间隔,问题不大;如果帧间很紧,就要考虑用 DMA 的“半传输中断”或者直接操作寄存器,不经过 HAL 库。直接操作寄存器可以做到无间隙切换,但代码可移植性变差,需要根据具体芯片手册来写。

最后说一个验证方法:在任务里加一个计数器,统计每秒收到的帧数和总字节数,和发送端对比。如果收包数少于发送数,就是丢了。再在中断里加一个计数器,统计 IDLE 中断次数,和任务收包数对比,就能定位是中断没进还是队列丢了。这个黑匣子手段在调试现场非常管用,比猜来猜去强。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询