news 2026/10/26 1:03:58

STM32串口DMA+空闲中断实现不定长数据接收:从原理到踩坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32串口DMA+空闲中断实现不定长数据接收:从原理到踩坑

1. 从轮询和中断说起:为什么串口要折腾 DMA

刚玩 STM32F103 的时候,串口收数据基本就是两种路子:要么在主循环里轮询读取 USART 的 RXNE 标志,要么开一个接收中断,一个字节一个字节地往缓冲区里塞。这两种办法在数据量小、频率低的时候完全够用,但一旦遇到连续发送几十上百字节、或者间隔不可预测的帧数据,问题立马就暴露出来了。

拿轮询来说,代码写起来最简单:

while (1) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { data = USART_ReceiveData(USART1); // 存进数组 } // 其他任务... }

可问题是,轮询所谓的"实时"其实是假的。主循环里只要多跑几个耗时函数,比如显示刷新、按键扫描、或者稍微复杂点的数学运算,串口数据就可能漏掉。串口波特率 115200 意味着每个字节大约 86 微秒,而你这段时间内只要 CPU 被别的代码占了,RXNE 置位了没人管,下一个字节一来就把前一个覆盖了。更麻烦的是 STM32F103 的 USART 硬件不带缓存,只是靠移位寄存器逐字节接收,慢一拍就是丢数据。

中断接收解决了一部分问题,但随之而来的是另一个烦恼:每来一个字节就要进一次中断。115200 波特率下,接收 100 字节就意味着要进 100 次中断,每次中断都要压栈、清标志、读数据、判断帧尾,CPU 的实际负载远比你想象的高。而且,如果接收端没有及时把 DR 寄存器读走,还会造成 overrun 错误。很多人在这里吃过亏,数据量大一点中断进得太频繁,主程序反而被拖得反应迟钝。

于是就有了这个经典组合:DMA + 空闲中断。DMA 负责把串口收到的数据一个字节一个字节搬进内存缓冲区,整个过程不需要 CPU 参与;而串口的空闲中断(IDLE)负责在"一帧数据结束"的时刻通知 CPU 来处理这一帧。两个机制配合起来,既能高速收数据,又不用每收一个字节都打扰 CPU,还能直接解决"串口怎么知道这一帧完了"的核心难题。

2. 先别急着写代码:硬件准备和 CubeMX 配置有不少讲究

2.1 最小系统搭建和电平转换问题

STM32F103C8T6 这类芯片做实验,最常见的就是买一块所谓的最小系统板。但"最小系统"这四个字有时候坑挺大——很多便宜的板子晶振都只是贴了个 8MHz 晶振,但实际起振条件(负载电容)往往做得不标准,导致 USB 转串口的波特率不准。我自己的建议是,如果手头板子跑 115200 经常出现随机乱码,先量一下晶振是否正常工作,或者干脆把 HSE 换成内部的 HSI,虽然内部 RC 精度只有 1%~2%,但配合 115200 波特率下接收整个 8~10 个字节的帧基本没压力。

5V 转 3.3V 电路也是个绕不开的问题。大多数 USB 转串口模块(比如常见的 CH340G 模块)输出的是 5V 电平,而 STM32F103 的 GPIO 容忍 5V 输入,所以直接接 RX 引脚其实问题不大;但如果你的模块是 FT232 或者国产某些 3.3V 输出的模块,那接法是另一回事。这里真正要小心的不是电平,而是共地——串口通信两端不共地,信号线上漂着的电势差会让 DMA 收到的数据随机出错,排查起来特别隐蔽。我给所有做串口实验的朋友的建议就一句话:先接 GND,再接 TX/RX,至少能省掉一半的排查时间。

2.2 CubeMX 里 USART 和 DMA 的通道分配

用 STM32CubeMX 配置的时候,首先要清楚 F103 的 DMA 控制器有两个(DMA1、DMA2),且各个通道是固定的映射关系。USART1 的接收只能走 DMA1 通道 5,发送只能走 DMA1 通道 4。如果你片内外设比较多,RAM 变量又大,容易碰到 DMA 通道被占用的尴尬。例如项目里同时用了 ADC 连续采样占着 DMA1 通道 1、SPI 收发占着通道 2 和 3,这时候你还能用的空闲通道就剩 4~7,恰好够 USART1 用,但如果想再多开一个串口 DMA,通道不够就非常难受了。

在 CubeMX 中配置串口 DMA 时,注意点主要在这一块:

配置项推荐值说明
DMA ModeNormal接收用 Normal,便于每帧结束后手动重置
Peripheral Increment不勾选外设地址(DR 寄存器)固定不变
Memory Increment勾选内存地址自动递增,数据连续存储
Data WidthByte(8位)串口数据就是字节宽度
PriorityHigh接收优先级建议高于普通外设,实测更稳

这一步很多人会犯的一个典型错误是把 DMA Mode 选成 Circular。Circular 模式好处是可以循环接收,不需要每次手动重置 NDTR,但对不定长数据帧来说反而麻烦:你没法简单靠 DMA 传输完成中断来判断一帧结束了,必须借助环形缓冲区的读写指针来管理,稍不留神就会丢帧或者错位。所以我的建议是,第一版先老老实实用 Normal 模式 + 空闲中断,把逻辑跑通,再考虑 Circular 优化。

2.3 时钟树和串口波特率配置的细节

STM32F103 的串口时钟来自 APB2(USART1)或 APB1(USART2/3)。如果 APB 分频系数不是 1,那 USART 外设时钟会和系统时钟不同频,CubeMX 会自动算波特率分频值,但建议自己确认一下。比如系统时钟 72MHz、APB1 分频 2,那 APB1 上的时钟是 36MHz,USART2 即使配置波特率 115200,实际分频系数是 36MHz / 115200 = 312.5,这 0.5 的分频误差虽然在可容忍范围内,但如果对方设备要求严格,可能就出现偶发丢字。改用 USART1(挂在 APB2 上,时钟 72MHz)会好很多。

另一个容易踩的坑是 DMA 时钟。F103 的 DMA1 和 DMA2 都挂在 AHB 总线上,CubeMX 初始化的时候会默认使能时钟,但如果你手动写代码,千万别漏了__HAL_RCC_DMA1_CLK_ENABLE(),漏掉之后程序一运行,DMA 请求永远发不出去,接收缓冲区始终是空的,查半天都不知道问题在哪。我在一个项目里就因为这个浪费了整整一天。

3. 关键代码全程拆解:初始化、使能空闲中断、回调处理

3.1 USART + DMA 初始化代码

初始化顺序其实是有讲究的:先初始化 USART,再初始化 DMA,最后单独开启 DMA 接收,并且使能空闲中断。如果你中途交换了顺序,可能会出现 DMA 还没准备好就收到了串口数据,导致第一个帧直接丢失。

用 HAL 库写一遍,完整代码如下:

#define RX_BUF_SIZE 512 uint8_t usart1_rx_buf[RX_BUF_SIZE]; uint16_t usart1_rx_len = 0; UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; void MX_USART1_DMA_Init(void) { __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // 引脚配置 GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // USART1 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; HAL_UART_Init(&huart1); // DMA: USART1_RX -> DMA1_Channel5 hdma_usart1_rx.Instance = DMA1_Channel5; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_NORMAL; hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); // 关联 DMA 到 USART __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); }

这段代码里最容易被忽略的是__HAL_LINKDMA这个宏。它把 DMA 句柄和 UART 句柄内部的一个指针绑定起来,HAL 库后续HAL_UART_Receive_DMA()操作的就是这个指针。如果你在 CubeMX 之外的代码里手动关联,遗漏了这行,那么调用 HAL 接收函数时控制就是空的,要么进 hardfault,要么功能完全无效。

3.2 使能空闲中断的正确位置

空闲中断(IDLE)并不是单独一个可以随意开关的全局中断,HAL 库里需要这样使能:

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

关键在于这行代码放什么位置。很多人直接放在 main 函数的 after-init 阶段,也没错,但在实际项目中,更好的做法是放在启动每一轮 DMA 接收之后:

void uart1_start_rx(void) { HAL_UART_Receive_DMA(&huart1, usart1_rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }

之所以这样做,是因为当你完成一帧数据的处理、重置 DMA 缓冲区之后,下一帧数据可能立刻就到。如果你只是全局开一次空闲中断,回调函数里处理完数据后忘了重新开启接收,就会出现在下一次接收之前再也没有中断触发的风险。把"开启 DMA 接收 + 重新使能空闲中断"封装成一个函数,每次处理完一帧之后就调用一次,整个流程就闭环了。

另外说一句,HAL 库里串口 DMA 接收函数HAL_UART_Receive_DMA()本身会清掉 DMA 相关的标志,并且会设置中断回调相关状态,但它并不会帮你使能 IDLE 中断。这一点 HAL 的文档写得不太直白,导致很多刚开始用的人只调了这个函数却没手动使能 IDLE,最后 DMA 收满了 512 字节才触发传输完成中断,而不是在空闲时触发——那当然不是你想要的行为。

3.3 中断回调的完整处理流程

F103 的 USART1 全局中断只有一个入口USART1_IRQHandler,HAL 库已经帮你把接收中断、错误中断、以及我们需要的空闲中断都汇聚到了一起。标准写法是:

void USART1_IRQHandler(void) { // 空闲中断判断 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 先清标志 // 停止 DMA,统计实际收到多少字节 HAL_UART_DMAStop(&huart1); } HAL_UART_IRQHandler(&huart1); }

这里面顺序非常重要:先清 IDLE 标志,再停止 DMA。如果你先用HAL_UART_DMAStop()停掉 DMA,然后进中断处理函数,但 IDLE 标志没有被清除,那么下一帧数据进来之后,这个残留的 IDLE 标志会再次触发中断,结果就是你收到了一帧假数据或者重复数据。我在早期代码里因为顺序写反,调试时发现收 3 帧数据会跳成 4 次中断,花了不少时间才意识到是这个标志没清干净。

停止 DMA 之后,需要从 DMA 的 CNDTR 寄存器读出"还剩余多少个字节没搬完",这样实际收到的长度就等于缓冲区大小减掉剩余值:

uint16_t rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx);

读取 CNDTR 也有讲究,必须在 DMA 还在使能状态时读,停了之后再读虽然也能取到数,但某些芯片勘误表里提到过边界时刻读回的值可能偏差一两个字节。稳妥的做法是先调用HAL_UART_DMAStop()冻结传输,再马上读 CNDTR,并且在读取时关闭中断保护一下,避免极端情况下读写交错。

拿到长度之后就进入业务处理环节,比如:

void uart1_idle_callback(void) { uint16_t rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (rx_len > 0) { // 这里可以解析协议、拷贝数据、置位接收完成标志 // 比如 memcpy 到另一个消息队列缓冲区 process_frame(usart1_rx_buf, rx_len); } // 处理完必须重新开启接收,等待下一帧 uart1_start_rx(); }

可以看到,整个流程里 CPU 只做两件事:一是收到空闲中断后停下来,算一下这一帧有多长;二是把缓冲区交给业务函数去解析。其余时间里,CPU 完全不用管串口,数据搬运全被 DMA 代劳了。这在 115200 波特率下可能看不出多大差别,但你把波特率提到 921600,或者让 CPU 同时跑着更重的算法、显示刷新、文件系统操作时,差距就非常明显了。实测下来,DMA + 空闲中断方案在 1MHz 以下串口速率基本都能稳吃,CPU 占用率从中断方式的 30% 降到 5% 以下都有可能。

4. 实战中踩过的坑:首帧丢失、NDTR 清零和 DMA 通道冲突

4.1 首帧丢失问题:HAL 库的经典谜团

搜"DMA 接收首帧丢失"的朋友应该不少,我也遇到过,而且第一次遇到时完全摸不着头脑。现象很诡异:程序烧进去,串口工具发送第一帧数据,接收端缓冲区是空的;之后再发送第二帧、第三帧,都正常。核对代码逻辑,怎么看都没毛病——初始化没错、中断使能没错、回调处理也没错,为什么偏偏第一帧会丢掉?

后来翻 HAL 库源码才发现,问题出在HAL_UART_Receive_DMA()和 DMA 中断状态上。F103 的 DMA 在复位之后,其通道的中断状态寄存器 DMA_ISR 里可能残留了上一次的标志位。CubeMX 生成的初始化代码在调用HAL_DMA_Init()时会执行一次__HAL_DMA_DISABLE(),但这个禁用操作并不会清除之前的中断标志。当你第一次调用HAL_UART_Receive_DMA()时,HAL 库会先判断 DMA 通道状态,由于 ISR 里残留的 Transfer Complete 标志还在,它认为 DMA 已经传输完成,直接中断返回,实际并没有启动新的传输。这第一帧就无声无息地漏掉了。

解决方式有两种。第一种,在HAL_UART_Receive_DMA()之前强制清零中断标志:

__HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_TC5 | DMA_FLAG_HT5 | DMA_FLAG_TE5); HAL_UART_Receive_DMA(&huart1, usart1_rx_buf, RX_BUF_SIZE);

第二种更彻底,干脆不用 HAL 的 DMA 接收函数,而是直接操作寄存器使能通道。不过为了代码一致性和跨芯片复用性,我建议还是保留 HAL 函数,只额外加一行清标志。实测下来,加了这个清标志之后,首帧丢失问题再也没出现。

4.2 NDTR 清零:为什么 DMA 复位时必须先停再写

另一个高频问题是"NDTR 清零之后 DMA 不工作了"。NDTR(Number of Data Transfer Register)是 DMA 的剩余传输数寄存器,写入目标长度后,每搬运一个字节自动减一。你要重新启动一轮 DMA 接收,逻辑上应该先把 NDTR 恢复到缓冲区大小,但这背后有个麻烦:如果在 DMA 还处于使能状态时直接写 NDTR,根据 F103 参考手册的说明,这样会导致未定义行为——轻则传输计数错乱,重则 DMA 直接罢工。

正确顺序是:先调用HAL_UART_DMAStop()或者手动关闭对应 DMA 通道(DMA_Cmd(..., DISABLE)),确认 DMA 已禁用后,再写 CNDTR,然后再重新使能。如果你用 HAL 库,HAL_UART_Receive_DMA()内部会自己去操作 NDTR 这一套,不需要你手动写,这也是尽量别绕过 HAL 库的原因。

不过有一点要特别注意:HAL_UART_DMAStop()这个函数在 HAL 库里有个副作用,它会同时把 DMA 的中断关闭。你的接收流程如果依赖 DMA 传输完成中断来做某些附加判断,比如判断缓冲区是否已满,那重新启动 DMA 之后,还得再次配置中断使能。这在 CubeMX 生成的代码里通常不会自动帮你做,需要手动补上。我见过不少人在程序里手动调用HAL_UART_DMAStop()后,DMA 中断就再也不触发了,原因就是这个。

4.3 DMA 通道冲突:别怪硬件,先看看你的"总线占用"

F103 的 DMA 是轻量级的,只有 7 通道(DMA1 有 7 个,DMA2 有 5 个),而且每个通道只能服务于特定外设请求。USART1_RX 固定对应 DMA1_Channel5,这一点除了软件层,硬件层也有严格映射,根本没得选。所以当你的项目同时跑 SPI、ADC、I2C、多个串口时,通道数量和映射关系就成了硬约束。

我遇到过的真实案例是这样的:一个采集系统里用 TIM1 的 PWM 触发 ADC 采样,ADC 用 DMA1 通道 1 搬运,SPI1 用 DMA1 通道 2 接收,USART1 用 DMA1 通道 5 接收串口数据。表面上看三个功能互不冲突,但调试过程中发现 USART1 时不时丢字节,而且没有任何规律。排查了很久才发现,问题出在 ADC 的 DMA 配置里MemInc开了,和 USART1 的缓冲区在内存上挨得太近,ADC 搬运时偶尔会越界写入,把 USART1 的接收缓冲区覆盖了一段。这不是 DMA 通道冲突,是缓冲区内存布局的越界问题。

另一个常见冲突是 DMA1 通道 2 同时被 SPI1_RX 和 TIM2_CH3 请求映射占用。如果你的 SPI 是主机模式,从外部设备接收数据,同时又开着定时器输入捕获,两条通道的请求需要仲裁。DMA 的仲裁优先级的规则很简单:软件优先级高的先执行,同优先级下通道编号小的先执行。如果不注意,SPI 接收和定时器捕获同时到达时,其中一个请求会被延迟几个周期,高波特率下就会丢数。解决思路就是尽量把高实时性的外设配置成 High 优先级,且把低频外设放到更高编号的通道上。

这里插一句搜到的热词"stm32f103c8t6 dma 同道",我理解说的就是同通道资源共享的意思。在 F103 上,如果你把两个外设配置到同一个通道,即使它们的请求不会同时发生,也会存在潜在冲突。最安全的做法是:除非万不得已,一个 DMA 通道只绑定一个外设请求。

4.4 边界处理和字节错位问题

DMA 搬运是没有帧意识的,它不知道什么是协议帧,只按固定的字节长度搬运。因此,当你用 Normal 模式 + 空闲中断时,存在一个潜在窗口:一帧数据长度恰好等于缓冲区大小时,DMA 会先触发传输完成中断,此时数据已经完整收到,但空闲中断可能尚未触发,甚至在极端时序下空闲中断被 DMA 传输完成中断抢先清掉标志,导致你收到的是半个帧。

为了避免这种边界问题,我的做法是把缓冲区长度设置成比期望最大帧长大 1 个字节以上,例如最大协议帧是 256 字节,缓冲区就开 300 字节。这样正常帧永远不会填满缓冲区,必然会在帧尾产生一个空闲间隔,触发 IDLE。如果你实在不能浪费那几十字节内存,那就要在 DMA 传输完成中断回调里做同样的帧数据处理逻辑:

void DMA1_Channel5_IRQHandler(void) { if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_TC5)) { __HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_TC5); // 同样处理数据 uint16_t rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx); process_frame(usart1_rx_buf, rx_len); uart1_start_rx(); } HAL_DMA_IRQHandler(&hdma_usart1_rx); }

字节错位问题则出现在另一种场景:发送端把数据分成了两个字节、又在一段时间内持续输出时,你的 DMA 缓冲区里收到的可能是"后半段旧帧 + 前半段新帧"的拼接体。比如上一帧是A1 B2 C3,下一帧是D4 E5 F6,接收端可能得到B2 C3 D4——这些字节看起来毫无规律,但如果你用固定帧头帧尾校验,能立刻识别出来。处理办法一般是加帧头(比如 0xAA 0x55)、加长度字段和 CRC 校验。不要指望硬件帮你对齐帧边界,这类工作必须在协议层解决。

5. 性能优化和扩展:缓冲区设计、发送端 DMA 和 RTOS 配合

5.1 缓冲区大小和 CPU 占用的实测对比

很多人在设计阶段纠结缓冲区开多少字节合适。根据我自己的经验,先按下面这个规则来:缓冲区大小 = 最大帧长 × 2。为什么是两倍?因为当你处理完一帧数据、重新启动 DMA 之前的那一小段窗口里,如果下一帧已经开始到达,一倍长的缓冲区就出现覆盖风险。两倍缓冲给你留出了足够的余量。如果内存紧张,至少也要留出"最大帧长 + 一个时间段内可能到达的额外字节数"的空间。

拿 115200 波特率举例,每秒大约传输 11520 字节,每帧 100 字节,一帧耗时约 8.7ms。如果你的处理函数能在 8.7ms 内跑完,那么单缓冲就够了;但处理函数里如果做了文件系统写入、LCD 刷新、或者云端数据包封装,耗时很容易超过 8.7ms。这时候单缓冲必然出问题。所以我的默认方案是双缓冲:一块缓冲区用于 DMA 写入,另一块用于业务处理,处理完再交换。实现上可以借助 DMA 的半传输中断(Half Transfer 标志),当一个缓冲写完一半时开始处理前半段,写完一半时处理后半段。

用 DMA 半传输中断配合 CIRCULAR 模式,可以做到无缝隙接收,这是很多高性能通信设备的做法。但前提是你对协议和 CPU 处理时间有足够的把握。F103 主频只有 72MHz,ULP 级别的处理里,半传输中断来临时如果 CPU 正在跑一个长耗时函数,延迟就会导致缓冲区覆盖。这种情况我建议干脆把串口波特率降下来,或者把处理任务的优先级提高。

5.2 发送端要不要也用 DMA

接收用了 DMA,发送呢?答案是:如果你的应用需要高频发送数据(比如日志输出、协议应答、不断上报传感器数据),发送也用 DMA 是划算的。F103 的 USART TX DMA 配置和 RX 基本镜像,发送时的逻辑更加简单,你把要发送的数组地址和长度告诉 DMA,它自动搬运到 DR 寄存器,发送完成后再触发 DMA 传输完成中断。

但有一个细节值得注意:发送完成后 DMA 会在所有数据发送完毕时才触发中断,但如果你紧接着就要修改发送缓冲区的内容,必须等待传输完成标志确认。很多坑都出在"没等发送完成就修改 buffer"上。用 HAL 库时,要做的是:

HAL_UART_Transmit_DMA(&huart1, tx_data, tx_len); // 等 DMA 发送完成中断回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { // 现在才能安全修改 tx_data }

有人会问,发送端和接收端波特率一样,硬件流控也没问题,为什么还讨论"ESP32 DMA 接收 RX 数据发送端需要改变发送方式吗"这类问题?我的观点是:发送端只要保证按字节流连续输出即可,不需要针对接收端的 DMA 做任何特殊调整。DMA 只是接收端内部搬运数据的方式,不影响物理层的波形。真正影响接收端 DMA 是否能正常完成的,是发送端是否在帧与帧之间留出了足够的空闲时间。所谓"空闲",不是要让线路保持什么特殊电平,而是要让接收端 USART 检测到超过一个字节时间没有任何数据到达,从而触发 IDLE。所以你可能不需要改变发送方式,但至少要确保帧间隔大于 1 个字节的传输时间。115200 波特率下一个字节约 86 微秒,实际上你只要帧间隔大于 100us 就能稳定触发 IDLE,这在绝大多数通信协议里根本不是约束。

5.3 如果把 FreeRTOS 请进来:DMA 和任务怎么配合

在 FreeRTOS 项目里用 DMA 接收时,最常见的需求是:串口收到完整一帧后,告诉某个任务来处理。这里推荐的做法是:在空闲中断回调里只做两件事,一是获取 DMA 数据长度并拷贝到静态缓冲区,二是通过队列或二值信号量通知任务。不要在中断里做任何可能阻塞的操作,尤其是不要调用osDelay或者动态分配内存的malloc。

void uart1_idle_callback(void) { uint16_t rx_len = RX_BUF_SIZE - (uint16_t)__HAL_DMA_GET_COUNTER(&hdma_usart1_rx); BaseType_t higherPriorityTaskWoken = pdFALSE; if (rx_len > 0) { memcpy(rtos_frame_buf, usart1_rx_buf, rx_len); xQueueSendFromISR(frame_queue, &rx_len, &higherPriorityTaskWoken); } uart1_start_rx(); portYIELD_FROM_ISR(higherPriorityTaskWoken); }

这里有一个关键点:中断回调里必须重新启动 DMA 接收,否则下一次数据到达时没有任何接收机制在工作。有些初学者把uart1_start_rx()放在任务里,由任务处理后调用来开启接收,这在任务竞争激烈的系统里会有窗口问题——任务可能还没跑起来,下一帧已经到了。稳妥的做法就是像上面代码那样,中断里直接重新启动 DMA,保证接收的连续性。

5.4 扩展:基于此方案做 Modbus 从机或者任意自定义协议

DMA + 空闲中断不定长接收这套玩法,最普遍的落地场景就是 Modbus-RTU 从机。Modbus-RTU 的帧没有显式长度字段,只靠帧间间隔判断一个帧是否结束。标准规定帧间隔是 3.5 个字符时间,在 9600 波特率下约 4ms,在 115200 波特率下约 0.3ms。你用 DMA 加空闲中断完全可以实现:只要把串口空闲检测的间隔设定得足够短(USART 的空闲事件定义是"总线空闲超过 1 个完整字节时间"),然后在回调里解析 Modbus 帧即可。

不过要注意,Modbus 标准要求的 3.5T 间隔比 F103 的 1T 空闲间隔宽松得多,所以实际效果是 F103 可能把一字节夹在上一帧尾部、以及下一帧头部的粘包数据判成一帧,需要在解析层做更严格的帧超时判断。我的做法一般是空缺中断只用于"数据到达被打包",然后在任务层再加一个毫秒级软定时器:如果解析一个帧的时长超时,强制丢弃或重发。这个组合方案我在多个项目上跑过,稳定性相当不错。

如果你想在这个基础上继续升级,可以扩展出环形缓冲区 + 空闲中断的方案。思路是把 DMA 模式改成 Circular,配合HAL_UART_RxCpltCallback和HAL_UART_Ex_RxEventCallback(F1 系列用HAL_UARTEx_ReceiveToIdle_DMA在最新 HAL 中也可用)。但 F103 的 HAL 版本差异较大,有的版本没有HAL_UARTEx_ReceiveToIdle_DMA这个函数,需要自己封装。这也是我一直推荐先用 Normal + 手动 IDLE 的原因——逻辑简单、代码可移植性高、排查容易。

6. 最后的几点个人体会和调试心得

这套 DMA + 空闲中断的方案,我在航模遥控数据回传、工业传感器采集、甚至一个小型户外物流终端上都用过。回想起来,真正让我觉得最值的,不是省了多少 CPU 时间,而是它把"串口接收"和"协议处理"这两件事彻底解耦了。串口只负责把数据按时、完整地交到缓冲区,业务逻辑只管处理缓冲区里的数据,两边互不干扰。尤其是在一个大型项目里,串口驱动被人反复改过好几遍之后,这种清晰的分工能省下无数扯皮的精力。

调试这个方法唯一让我觉得麻烦的还是"眼见为实"这一步。建议从一开始就把串口工具设置成新行结尾(比如\n),这样每次发送完整帧后,空闲中断触发的位置和你肉眼看到的帧边界是一致的。如果帧间间隔太短,你可能在调试时发现中断触发得很频繁,那不要急着改代码,先看看是不是你的调试工具在发送时自动附加了额外的字节。

另外选型时也提一嘴:如果你是在做两个 MCU 之间的高速串口通信,比如两个 STM32F103 对发,务必两边都用同一套波特率、同一套帧格式,且帧间间隔保持明确稳定。我自己做测试时常用 921600 波特率,每帧 256 字节,两边都用 DMA + 空闲中断,实测跑了一整晚,收发了十万多帧,只有最开始因为没清 DMA 标志丢了首帧,后面全程无丢失,这种稳定性是我用中断接收从来没达到过的。

最后再分享一个很小的技巧:在回调里调试时,不要直接打印整帧数据。直接printf输出一帧 256 字节,在 115200 波特率下要把数据重新送出去,而串口发送和 DMA 接收是同一个外设,可能把你的调试信息混进接收缓冲区。我一般是往调试日志里只打印rx_len和帧头帧尾几个关键字节,确认帧结构没问题就好。真要看整帧内容,用在线调试器挂断点看内存更靠谱。

这套代码和思路欢迎拿去用。如果在实际项目中遇到什么奇怪的现象,尤其是和首帧丢失、缓冲区覆盖、DMA 通道冲突相关的,欢迎回来交流。趁我还没忘干净这些踩出来的坑,能帮一个是一个。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 1:01:37

硬件I2C vs 软件I2C:嵌入式开发者必知的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 0:44:58

2026年AI技术趋势:多模态大模型与边缘计算应用

1. 2026年AI领域发展全景观察2026年2月4日这一天,人工智能领域正经历着从技术突破到产业落地的关键转折期。作为从业者,我明显感受到AI技术栈已经形成了从底层芯片到上层应用的完整生态。这一天发生的技术动态和行业事件,恰好反映了当前AI发展…

作者头像 李华
网站建设 2026/10/26 0:13:46

FPGA DAC 目标码限速器:每拍限步、目标反向与满量程跨越的实测验证

给 DAC 更新目标码时,目标可能从正值突然跳到负值。若希望数字控制量逐步靠近目标,需要约束的是“本次更新离旧值最多多远”,并在最后一步恰好停在目标。它与一直加固定增量的累加器不同:目标和最大步幅都可以变化,方向…

作者头像 李华
网站建设 2026/10/26 0:06:16

Redis在大数据实时分析中的核心应用与优化实践

1. 项目概述:Redis在大数据实时分析中的独特价值第一次接触Redis是在2013年处理一个电商秒杀系统时,当时我们需要在毫秒级响应时间内处理上万并发请求。传统数据库根本无法应对这种场景,而Redis凭借其内存存储和单线程模型完美解决了问题。如…

作者头像 李华
网站建设 2026/10/26 0:02:59

热力学计算技术如何降低AI图像生成能耗

1. 热力学计算技术如何革新AI图像生成去年我在优化一个图像生成项目时,发现服务器集群的功耗曲线简直触目惊心——生成1000张高清图像消耗的电量足够一个家庭使用半个月。这促使我开始关注计算能耗问题,而热力学计算技术的出现,可能正是解决这…

作者头像 李华
网站建设 2026/10/25 23:45:10

轮胎橡胶行业PPT实战:从数据链搭建到页面落地全流程拆解

简介:轮胎橡胶行业是一个全球化、竞争激烈的市场,制造工艺与自动化水平直接决定企业成本与产品竞争力。演示文稿以该行业为背景,面向轮胎企业工程师、工业自动化从业者及相关专业学生,从全球前十厂商格局切入,介绍了原…

作者头像 李华