☰
STM32F103串口DMA+IDLE不定长接收实战指南
2026/10/26 1:02:35 网站建设 项目流程

1. 为什么串口接收不能只靠中断

刚接触 STM32F103 串口通信的人,十有八九都是从"每来一个字节进一次中断"开始的。我当年也是这么干的,用 HAL 库的HAL_UART_Receive_IT()一个字节一个字节地收,收到就塞进缓冲区,主循环里再慢慢解析。小数据量、低波特率的时候确实能跑,但只要波特率上到 115200 以上、数据帧稍微长一点,问题就全冒出来了。

最典型的症状是丢字节。原因不复杂:每接收一个字节,CPU 都要保存现场、跳进中断服务函数、把数据从 DR 寄存器读走、清标志、再恢复现场。这一套动作在 72MHz 的 F103 上大概要消耗几十个时钟周期。如果波特率是 115200,一个字节的传输时间大约是 87 微秒,单看好像很宽裕,但一旦系统里还有别的中断(比如定时器、SysTick),中断嵌套和优先级抢占就会让串口中断响应变慢,FIFO 又没有(F103 的 USART 只有一个字节的接收寄存器),下一个字节来了上一个还没读走,ORE 溢出标志就置位了,数据直接丢。

另一个更隐蔽的坑是"不定长数据"的处理。很多教程教你用固定长度接收,比如HAL_UART_Receive_DMA(&huart1, buf, 100),收满 100 字节才触发一次回调。可现实里的串口协议——不管是 Modbus RTU、自定义的 AT 指令,还是传感器上报的 JSON 文本——长度几乎都是不固定的。你没法预知这一帧到底是 8 字节还是 64 字节。用固定长度接收,要么收不满一直等,要么收多了把下一帧的头给吃掉。

所以真正能在项目里落地的方案,核心就两件事:用 DMA 把 CPU 从"搬字节"这件事里解放出来,用空闲中断(IDLE)来判定一帧数据的结束。DMA 负责在后台把 USART 收到的每一个字节自动搬到内存缓冲区,CPU 全程不参与;IDLE 中断则在串口总线空闲(也就是一帧数据发完、线路静默超过一个字节时间)时触发一次,告诉你"这一帧收完了,去处理吧"。这套组合拳下来,无论帧长是 5 字节还是 200 字节,CPU 只在帧结束时被打断一次,效率高得不是一点半点。

这篇文章我会把 STM32F103 上这套 DMA + IDLE 不定长接收方案从头到尾讲透,包括 CubeMX 配置、代码实现、DMA 双缓冲思路、以及我自己踩过的几个特别坑(比如首帧丢失、IDLE 标志清除顺序、DMA 计数器读取时机)。目标读者是已经会用 HAL 库点灯、会配置串口但还没搞定高效接收的朋友,看完你应该能直接把这套代码抄进自己的项目。

2. 先把 USART、DMA、IDLE 三者的关系理清楚

在动手写代码之前,我强烈建议先把这三个东西各自扮演什么角色想明白。很多人代码跑不通,不是语法问题,而是脑子里对"谁在什么时候干什么"没有清晰的图景。

2.1 USART 在 DMA 模式下的数据流

STM32F103 的 USART 外设内部有一个接收数据寄存器(DR)和一个发送数据寄存器(严格说是同一个地址的收发共用寄存器)。当接收到一个完整字节后,硬件会把数据放进 RDR,同时置位 RXNE 标志。在传统中断模式下,RXNE 置位会触发中断,CPU 进中断把数据读走。而在 DMA 模式下,你可以把 USART 的 RXNE 事件配置成 DMA 请求源,一旦 RXNE 置位,DMA 控制器就自动发起一次传输,把 RDR 里的数据搬到你指定的内存地址,搬完自动把内存地址指针加一。整个过程 CPU 完全不介入。

这里有个关键点:DMA 传输的触发源是 RXNE,不是"接收到一个字节"这个抽象概念。理解这一点对后面排查问题很重要。比如你发现 DMA 搬过来的数据错位了,就要去想是不是 RXNE 被清了但 DMA 请求没跟上,或者 DMA 通道配置的传输宽度不对(USART 是 8 位,DMA 也得配成字节传输)。

2.2 IDLE 中断到底在什么时候触发

IDLE 是 USART 状态寄存器(SR)里的一个标志位,全称 IDLE Line Detection。它的触发条件是:接收线路在检测到一个字节的停止位之后,持续保持空闲(高电平)达到一个完整帧的时间。通俗讲,就是"数据发完了,线路上没动静了"。

这个特性正好用来做不定长帧的边界判定。因为串口通信里,一帧数据内部的字节是连续发送的,字节与字节之间的间隔远小于一个帧时间;而两帧数据之间,发送方总会有停顿(哪怕很短),这个停顿一旦超过一个帧时间,IDLE 就置位了。

需要注意 IDLE 标志的清除方式:必须先读 SR 寄存器,再读 DR 寄存器,这个顺序不能反。这是 F103 参考手册(RM0008)里明确写的。很多人在中断里直接调__HAL_UART_CLEAR_IDLEFLAG()发现清不掉,或者清了之后反复进中断,就是没搞清这个顺序。HAL 库的宏内部其实帮你做了这个序列,但如果你自己写寄存器操作,一定要记住。

2.3 DMA 的循环模式与普通模式之争

DMA 通道有个 CIRC(循环)位,决定传输完设定长度后是停止还是自动重装。做不定长接收时,我强烈建议用循环模式。原因很简单:你事先不知道一帧有多长,如果配成普通模式,DMA 搬满你设定的缓冲区长度就停了,后面来的数据全丢。配成循环模式,DMA 搬满一圈自动回到缓冲区头部继续搬,永远不会停,你只需要在 IDLE 中断里读一下"当前搬到哪里了"就行。

那怎么知道当前搬了多少字节?答案是读 DMA 通道的CNDTR(当前数据传输数量)寄存器。你初始化时设的总长度是 N,CNDTR 会随着传输递减,所以已接收字节数 = N - CNDTR。这个公式是整套方案的核心,务必记牢。

下面这张表把三个角色的分工总结一下,方便对照理解:

角色职责触发时机CPU 参与度
USART硬件收发字节,置位 RXNE/IDLE收到字节 / 线路空闲无
DMA把 RDR 数据搬到内存,自动递增指针RXNE 置位时无
IDLE 中断通知 CPU 一帧结束,读取长度线路空闲超一帧时间仅帧结束时一次

3. CubeMX 配置里那些容易配错的选项

我见过太多人代码逻辑没问题,就是 CubeMX 里某个勾没打对,结果调一整天。这一节把关键配置项逐个说清楚,包括为什么这么配。

3.1 USART 基础参数与中断使能

以 USART1 为例,波特率 115200、8 位数据、1 位停止、无校验,这是最常见的组合。重点在 NVIC 设置里:必须勾选 USART1 global interrupt,因为 IDLE 中断属于 USART 的全局中断向量,不勾这个 IDLE 永远不会进中断。很多人只勾了 DMA 中断,结果发现 IDLE 死活不触发,就是漏了这一步。

另外,如果你用的是 HAL 库,HAL_UART_Receive_DMA()这个函数内部会自动使能 DMA 请求和 IDLE 检测吗?答案是不会自动使能 IDLE 中断。IDLE 中断需要你手动调用__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)来开启。这一点特别容易忘,因为 DMA 请求是HAL_UART_Receive_DMA帮你开的,你会误以为 IDLE 也一起开了。

3.2 DMA 通道选择与模式设置

F103 的 DMA1 有 7 个通道,USART1_RX 固定映射到DMA1_Channel5,USART1_TX 映射到 DMA1_Channel4。这个映射关系是硬件定死的,不能随便选,CubeMX 里会自动帮你分配,但你得知道为什么是这两个通道,方便看参考手册时对号入座。

DMA 配置里几个关键项:

  • Direction:外设到内存(Peripheral To Memory),因为我们是接收。
  • Peripheral data width:Byte(8 位),和 USART 数据宽度一致。
  • Memory data width:Byte。
  • Mode:Circular(循环模式),理由前面说过了。
  • Increment Address:Peripheral 不递增(RDR 地址固定),Memory 递增(缓冲区指针要往后走)。
  • Priority:Medium 或 High 都行,看系统里有没有更紧急的 DMA 需求。

注意:DMA 的 Memory 地址递增一定要开,否则所有数据都堆在缓冲区第一个字节上,你会看到缓冲区里永远是最后一个字节。这个坑我当年踩过,排查了半天以为是 USART 配置问题。

3.3 缓冲区大小与 CNDTR 的初始值

缓冲区大小设多少合适?这取决于你的最大帧长。一般建议设成最大帧长的 2 倍以上,留出余量。比如你的协议最大帧是 128 字节,那就开 256 字节的缓冲区。为什么是 2 倍?因为循环模式下,如果一帧刚好接近缓冲区长度,DMA 指针可能已经绕回头部了,你读 CNDTR 算出来的长度会出错。留一倍余量能有效避免这种边界情况。

初始化时HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE)里的 BUF_SIZE 就是 CNDTR 的初始值。记住这个值,后面算长度要用。

4. 代码实现:从初始化到 IDLE 中断处理

配置讲完了,进入正题。这一节给出完整的代码骨架,并逐段解释为什么这么写。

4.1 初始化顺序不能乱

初始化的顺序很讲究,顺序错了会出现各种诡异现象。我推荐的顺序是:

  1. 先初始化 USART(MX_USART1_UART_Init()),此时先不要开接收。
  2. 初始化 DMA(CubeMX 生成的MX_DMA_Init()通常在前)。
  3. 调用HAL_UART_Receive_DMA()启动 DMA 接收。
  4. 调用__HAL_UART_ENABLE_IT()使能 IDLE 中断。

为什么先启动 DMA 再开 IDLE?因为如果先开 IDLE,在 DMA 还没准备好的瞬间来了数据,IDLE 触发了但 DMA 没搬数据,你读到的长度就是 0。虽然概率低,但在上电初始化阶段确实可能发生。先让 DMA 就位,再开 IDLE,逻辑上更稳妥。

/* 在 main 函数初始化部分 */ MX_DMA_Init(); MX_USART1_UART_Init(); /* 启动 DMA 接收,缓冲区 rx_buf,长度 BUF_SIZE */ HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE); /* 使能 IDLE 中断 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

4.2 IDLE 中断服务函数的正确写法

IDLE 中断和普通的 USART 接收中断共用USART1_IRQHandler。HAL 库生成的这个函数会调用HAL_UART_IRQHandler(),但那个函数默认不处理 IDLE(除非你用了带 IDLE 回调的新版 HAL)。所以我们需要在HAL_UART_IRQHandler()之前自己判断 IDLE 标志。

void USART1_IRQHandler(void) { /* 判断是否是 IDLE 中断 */ if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { /* 清除 IDLE 标志:先读 SR 再读 DR */ __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 停止本次 DMA 传输,方便读取 CNDTR */ HAL_UART_DMAStop(&huart1); /* 计算接收到的字节数 */ uint16_t recv_len = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); /* 处理数据,比如拷贝到另一个缓冲区或直接解析 */ process_frame(rx_buf, recv_len); /* 重新启动 DMA 接收,准备下一帧 */ HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }

这段代码有几个细节值得展开说。

第一,__HAL_UART_CLEAR_IDLEFLAG内部做了什么。这个宏展开后是"读 SR 寄存器,再读 DR 寄存器"的序列,正好满足手册要求的清除顺序。如果你自己写huart1.Instance->SR; huart1.Instance->DR;效果一样,但用宏更清晰。

第二,为什么要HAL_UART_DMAStop。因为 CNDTR 是实时变化的,DMA 还在跑的时候你读它,读到的值可能在你读的瞬间又变了。虽然实际影响很小,但为了严谨,先停 DMA 再读 CNDTR,读到的就是这一帧结束时的确定值。读完再重启 DMA。

第三,重启 DMA 的必要性。有人会问,DMA 不是循环模式吗,为什么还要重启?原因是HAL_UART_DMAStop把 DMA 关了,必须重新调HAL_UART_Receive_DMA才能再次启动。如果你不想停 DMA,也可以不停,直接读 CNDTR,但那样就要自己处理指针复位的问题,反而更麻烦。停-读-重启这个流程最清晰。

4.3 处理函数里该做什么、不该做什么

process_frame是在中断上下文里调用的,所以绝对不能做耗时操作。我见过有人在里面直接跑协议解析、打印日志、甚至操作 Flash,结果就是中断卡死,系统假死。

正确的做法是:在中断里只做"搬运"——把rx_buf里的recv_len个字节拷贝到一个独立的帧缓冲区,然后置一个标志位,让主循环去处理。拷贝用memcpy就行,速度快。如果帧特别长(几百字节),可以考虑用双缓冲或者消息队列,把指针传出去而不是拷贝数据。

volatile uint8_t frame_ready = 0; uint8_t frame_buf[256]; volatile uint16_t frame_len = 0; void process_frame(uint8_t *data, uint16_t len) { if (len == 0 || len > sizeof(frame_buf)) return; memcpy(frame_buf, data, len); frame_len = len; frame_ready = 1; }

主循环里判断frame_ready,处理完清零。这样中断里只花几十微秒,不影响系统实时性。

5. 首帧丢失、长度错乱、反复进中断:三个真实踩坑记录

代码看起来简单,但实际调的时候坑一个接一个。这一节我把印象最深的三个问题完整复盘一遍,包括排查思路,希望能帮你少走弯路。

5.1 上电后第一帧数据总是丢

这个问题的现象是:系统上电后,发送方发的第一帧数据,STM32 这边完全没反应,从第二帧开始才正常。我一开始怀疑是发送方的问题,换了串口助手手动发,还是一样。

排查过程是这样的:我在 IDLE 中断里加了个计数器,发现第一帧的时候 IDLE 中断根本没进。这就排除了"数据收到了但处理错了"的可能,问题出在更底层。然后我在HAL_UART_Receive_DMA之后、__HAL_UART_ENABLE_IT之前加了个延时,发现延时越长,第一帧丢失的概率越低。这说明是初始化时序问题。

根因是:HAL_UART_Receive_DMA内部使能 DMA 请求和 USART 的 DMA 接收使能位(DMAR)需要几个时钟周期才能生效,而如果发送方在这之前就把第一帧发完了,IDLE 标志会在你使能 IDLE 中断之前就置位。等你使能中断时,IDLE 标志已经是置位状态,但中断不会补触发(边沿触发),于是这一帧就被漏掉了。

解决办法有两个:一是在使能 IDLE 中断之前,先手动检查一次 IDLE 标志,如果已置位就手动处理一次;二是让发送方在 STM32 初始化完成后再发数据(实际项目里通常做不到)。我采用的是第一种,在__HAL_UART_ENABLE_IT之后加一段:

if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); /* 处理这一帧 */ }

这个补丁加上后,首帧丢失问题彻底解决。

5.2 接收长度偶尔多出几个字节

第二个坑更隐蔽:大部分时候长度是对的,但偶尔会多出 1 到 3 个字节,多出来的内容是上一帧的尾部。这个问题困扰了我好久。

后来用逻辑分析仪抓波形才看明白:IDLE 中断触发时,DMA 可能还有最后几个字节没搬完。因为 IDLE 的触发条件是"线路空闲超过一个帧时间",而 DMA 搬运是有延迟的(虽然很短,但存在)。如果 CPU 响应 IDLE 中断特别快,在 DMA 搬完最后几个字节之前就读了 CNDTR,算出来的长度就会偏小——等等,偏小的话应该是少字节,为什么我遇到的是多字节?

再仔细分析,发现是另一个原因:我在中断里先HAL_UART_DMAStop再读 CNDTR,但HAL_UART_DMAStop内部会先关闭 DMA 请求,此时如果 RDR 里还有一个刚收到但没搬走的字节,这个字节就丢了,同时 CNDTR 没变,导致算出来的长度比实际少 1。而"多字节"的现象,是因为上一帧处理完后重启 DMA,但重启时 CNDTR 重装为 BUF_SIZE,如果上一帧的尾部数据还在缓冲区里没被覆盖,读长度时把旧数据也算进去了。

这个问题的本质是缓冲区没有清零。解决办法很简单:每次处理完一帧后,把rx_buf清零,或者至少把刚用过的区域清零。我选择在重启 DMA 前用memset(rx_buf, 0, BUF_SIZE),虽然有点浪费,但 256 字节的 memset 在 72MHz 上也就几微秒,完全可接受。

5.3 IDLE 中断反复触发停不下来

第三个坑最吓人:系统跑着跑着,IDLE 中断疯狂触发,主循环完全没机会执行,看门狗都喂不上,直接复位。

这个问题的根因是IDLE 标志没清干净。前面说过,清 IDLE 标志要"先读 SR 再读 DR"。我当时的代码里,__HAL_UART_CLEAR_IDLEFLAG是调了的,但我在它之前先调了__HAL_UART_GET_FLAG,而GET_FLAG内部也会读 SR。这就导致读 SR 的动作发生了两次,但读 DR 只发生一次,清除序列被打乱,标志没清掉。

HAL 库的宏设计其实已经考虑到了这点,__HAL_UART_CLEAR_IDLEFLAG是一个完整的读 SR + 读 DR 序列。问题出在我自己画蛇添足,在它前面又加了一次标志判断。后来我把判断和清除合并,直接用__HAL_UART_GET_FLAG判断后立刻调__HAL_UART_CLEAR_IDLEFLAG,中间不插入任何其他寄存器操作,问题就消失了。

提示:清 IDLE 标志的代码要"一气呵成",中间不要插入任何可能读写 SR 或 DR 的操作,否则清除序列会被破坏。

6. 让方案更稳的几个进阶优化

基础方案跑通之后,如果你的项目对可靠性要求更高,可以在这几个方向上做优化。这些都是我在实际产品里验证过的。

6.1 用 DMA 双缓冲彻底消除处理延迟

单缓冲的问题是:IDLE 中断里处理数据期间,DMA 是停的,如果这时候新数据来了,就会丢。虽然处理很快,但在高频率通信场景下仍有风险。双缓冲的思路是准备两块缓冲区 A 和 B,DMA 在 A 上收的时候,CPU 处理 B;收满一帧切到 B,CPU 处理 A。STM32F103 的 DMA 本身支持双缓冲模式(通过 MEM2MEM 和 CIRC 组合,或者用两个通道交替),但配置略复杂。

更简单的做法是软件双缓冲:在 IDLE 中断里,把当前缓冲区的内容快速拷贝到备用缓冲区,然后立刻重启 DMA,主循环处理备用缓冲区。这样 DMA 停止的时间被压缩到只有 memcpy 的时间,大大降低丢数据概率。

6.2 超长帧与缓冲区溢出的保护

如果发送方发了一帧超过缓冲区长度的数据,循环模式下 DMA 会绕回头部覆盖前面的数据,你读到的长度会是一个"绕圈"后的值,解析必然出错。保护措施是:在 IDLE 中断里判断recv_len是否接近 BUF_SIZE,如果接近就认为发生了溢出,丢弃这一帧并记录错误。更严格的做法是配合 DMA 的传输完成中断(TC),在 DMA 搬满一圈时也触发一次处理,但那样逻辑会复杂不少。

6.3 和 printf 重定向共存时的注意事项

很多项目会用printf重定向到 USART 做调试输出。如果你把printf和 DMA 接收配在同一个 USART 上,要注意发送和接收是独立的,DMA 接收不受 printf 影响。但如果你也用 DMA 发送,就要小心发送和接收共用 DMA 控制器时的通道优先级问题。我的建议是:调试用的 printf 走一个单独的 USART,业务数据的收发走另一个,物理隔离最省心。

下面这张表对比了几种接收方案的适用场景,方便你根据项目需求选型:

方案CPU 占用支持不定长实现复杂度适用场景
单字节中断高需软件超时低低速、短帧、调试
DMA + 固定长度低不支持中定长协议
DMA + IDLE极低支持中绝大多数业务场景
DMA 双缓冲 + IDLE极低支持高高速、高频通信

7. 我在实际项目里总结的几条经验

这套 DMA + IDLE 方案我从 F103 一直用到 F4、H7 系列,核心逻辑没变过,但每换一个平台都会遇到新的细节问题。分享几条通用的经验。

第一,中断里永远只做搬运,不做解析。这是铁律。我见过太多项目在中断里跑状态机、算 CRC、拼 JSON,短时间看不出问题,一旦通信频率上来就各种丢帧。把数据拷出来,标志位置起来,主循环慢慢处理,系统稳定性会有质的提升。

第二,CNDTR 的读取时机比你想的更重要。如果你不停 DMA 直接读,读到的值可能正在变化;如果你停了 DMA 再读,要注意停的瞬间可能丢一个字节。我的做法是停 DMA 后,先读一次 DR 寄存器把可能残留的字节读走,再读 CNDTR,这样最保险。

第三,缓冲区一定要留足余量并定期清零。前面 5.2 节讲的多字节问题,根源就是旧数据残留。养成"用完即清"的习惯,能避免大量诡异问题。

第四,调试阶段用 GPIO 翻转配合逻辑分析仪。在 IDLE 中断入口翻转一个 GPIO,用逻辑分析仪同时抓串口波形和这个 GPIO,你能直观看到 IDLE 触发时刻和最后一个字节结束时刻的关系,对理解时序帮助极大。这比盯着代码猜要高效得多。

第五,不同批次的 F103 芯片,DMA 行为可能有细微差异。我遇到过某批次的 C8T6 在极高波特率下 DMA 响应比另一批慢一两个周期的情况。所以产品定型前,一定要用实际要用的那批芯片做压力测试,别只在开发板上验证。

这套方案的价值在于,它把串口接收从"CPU 全程盯着"变成了"CPU 只在关键时刻出手",释放出来的算力可以去做真正重要的业务逻辑。对于任何需要稳定串口通信的 STM32 项目,我都强烈建议直接上这套 DMA + IDLE 的组合,一次投入,长期受益。

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

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

立即咨询