STM32 串口空闲中断 + DMA 接收不定长数据:从原理到抗粘包完整实现(HAL 库)

摘要:串口是嵌入式设备间最常用的通信接口,但上位机、传感器下发的数据帧长度往往不固定,靠轮询或逐字节 RXNE 中断接收不仅 CPU 开销大,还容易丢字节。本文基于 STM32F407ZGT6 与 HAL 库,用「DMA 搬运 + 空闲中断(IDLE)收尾」的组合实现不定长数据的零拷贝接收,并给出粘包/半包的处理方案。实测在 115200 波特率下连续收发 20 万字节,丢包率 0%,DMA 中断触发次数相比逐字节 RXNE 降低约 96%,CPU 占用从 31% 降到不足 3%。提供完整的 CubeMX 配置步骤、中断处理代码与可运行工程。

一、为什么逐字节接收会拖垮系统

先交代一下我遇到的实际问题。我做的一个 Modbus 网关,下位机串口要同时接 4 路 RS485,每路波特率 115200。最初为了省事,直接用 HAL_UART_Receive 阻塞接收 + RXNE 中断逐字节往环形缓冲区里塞。结果设备跑起来之后,主循环里负责解析的线程经常卡顿,示波器上看串口还偶尔丢字节。

问题的根源很简单:每收到一个字节就要进一次中断、压一次栈、搬运一次数据。115200 波特率下,一个字节大约 87μs 就能到齐,4 路同时来数据时,中断几乎连成一片。CPU 大部分时间都在中断和环形缓冲区的读写里打转,留给业务逻辑的时间所剩无几。

这时候就需要换一种思路:数据搬运交给 DMA,帧结束的判断交给硬件。前者把逐字节的中断开销降为零,后者让 CPU 不用靠超时猜数据有没有收完。

二、空闲中断是怎么判断"一帧结束"的

2.1 硬件层面的机制

STM32 串口有一个常被忽略却非常好用的功能——空闲中断(IDLE Interrupt)。它的触发条件是:串口总线上从"有数据"变成"持续空闲"超过一个完整数据帧的时间,硬件就自动把 USART_SR 寄存器里的 IDLE 位置 1。

可以这样理解:发数据的一方说一句话、喘口气,你就知道这句话说完了。这个"喘口气"的间隔,就是 IDLE 用来判断帧边界的依据。

对 STM32F407 来说,关键寄存器位是:

寄存器作用
USART_CR1IDLEIE空闲中断使能(置 1 打开)
USART_SRIDLE空闲状态标志(硬件置位,需软件清除)

这里有个必须记住的细节:IDLE 位不能用 __HAL_UART_CLEAR_FLAG 这类通用接口清除,只能通过"先读 SR、再读 DR"这个固定序列来清。HAL 库已经封装成了宏 __HAL_UART_CLEAR_IDLEFLAG(),直接调它就行,底层就是读两次寄存器。

2.2 三种接收方案怎么选

动手之前我列了一张对比表,这也是我最终选型的关键依据:

方案中断次数CPU 占用能否处理不定长实现复杂度
阻塞接收 HAL_UART_Receive高(死等)需预知长度最简单
RXNE 逐字节中断每字节 1 次可以简单
超时解析法每帧 1~2 次可以(依赖定时器)中等
DMA + IDLE每帧 1 次天然支持中等

逐字节中断在数据量大时开销不可接受;超时解析法要靠定时器判断"多久没数据算一帧",超时阈值一旦设置不当就容易把一帧拆成两半或两帧并成一帧。DMA + IDLE 是硬件天然支持不定长帧的方案——DMA 负责搬,IDLE 负责告诉你"搬完了,这一帧就是这么长"。所以我选它。

相关阅读:《STM32使用HAL库DMA空闲中断实现串口不定长数据接收》 — 最小化的寄存器级实现,可对照理解 IDLE 标志位的清法

三、整体工作流程

整个接收流程可以用下面这张时序图概括:

内存缓冲区 USART 外设 DMA 控制器 CPU 内存缓冲区 USART 外设 DMA 控制器 CPU 启动接收 (HAL_UART_Receive_DMA) 监听数据 字节到达,逐字节搬运 写入内存 (CNDTR 递减) 总线空闲超过 1 字节时间 触发 IDLE 中断 读 CNDTR 计算已收字节数 拷贝/处理这一帧 重新启动下一次接收

关键点在第 6 步:已接收字节数 = 缓冲区总长度 − 当前 CNDTR 剩余值。因为 DMA 的 CNDTR 寄存器每搬一个字节就减 1,读它就知道还剩多少没搬,反推就知道搬了多少。这一步用宏 __HAL_DMA_GET_COUNTER() 拿到。

四、CubeMX 配置

工程基于 STM32F407ZGT6,下面是我实际配置的参数。如果你的板子是 F103、F0 或 G0 系列,思路完全一致,只是寄存器和引脚不同。

  1. 系统时钟:HSE 8MHz 晶振,PLL 倍频到 168MHz(F407 主频)。
  2. USART1 配置
    • 模式:Asynchronous(异步)
    • 波特率:115200,数据位 8,停止位 1,校验位 None
    • 引脚:PA9(TX)/ PA10(RX)
  3. DMA 配置(NVIC Settings 页签下):
    • 给 USART1_RX 添加一个 DMA 通道,方向 Peripheral → Memory
    • 模式这里先选 Normal,后面会讲为什么
  4. 中断:勾选 USART1 的全局中断(在 NVIC Settings 里 enable)。

生成代码后,DMA 的初始化已经由 CubeMX 生成好了,我们只需要补接收逻辑。

五、核心代码实现

5.1 全局变量

接收缓冲区必须定义成全局变量,因为中断和主循环都要访问它:

/* USER CODE BEGIN PV */
#define UART_RX_BUF_LEN   256   /* 单帧最大长度,按需调大 */
uint8_t  uart_rx_buf[UART_RX_BUF_LEN]; /* DMA 接收缓冲区 */
volatile uint16_t uart_rx_len = 0;     /* 本帧实际接收长度 */
volatile uint8_t  uart_rx_ready = 0;   /* 一帧接收完成标志 */
/* USER CODE END PV */

uart_rx_lenuart_rx_readyvolatile 是必须的:中断里改、主循环里读,编译器优化可能把它们缓存到寄存器里导致主循环永远看不到变化。这个坑我早年踩过,标志位不加 volatile-O2 优化下主循环死循环读同一个值,调了整整一晚上。

5.2 启动接收

main() 里,初始化完成之后、进入主循环之前,启动第一次 DMA 接收:

/* USER CODE BEGIN 2 */
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);          /* 打开空闲中断 */
HAL_UART_Receive_DMA(&huart1, uart_rx_buf, UART_RX_BUF_LEN); /* 启动 DMA 接收 */
/* USER CODE END 2 */

注意这两行的顺序:必须先把串口、DMA 都初始化完,最后再开 IDLE 中断。我一开始图省事,把 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE) 写在了串口初始化之前,结果板子一上电、什么都没发就疯狂进 IDLE 中断。排查了半小时才发现——IDLE 中断在总线上电后本身就是空闲状态,串口还没准备好就使能它,等于直接触发。把这一行挪到所有初始化之后,问题消失。

5.3 中断服务函数

这是整个方案的核心,写在 stm32f4xx_it.cUSART1_IRQHandler 里:

void USART1_IRQHandler(void)
{
  /* USER CODE BEGIN USART1_IRQn 0 */
  if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET)
  {
    /* 1. 先清 IDLE 标志,再停 DMA(顺序不能反) */
    __HAL_UART_CLEAR_IDLEFLAG(&huart1);

    /* 2. 停掉本次 DMA,锁定已接收的数据 */
    HAL_UART_DMAStop(&huart1);

    /* 3. 用 CNDTR 反推实际接收长度 */
    uart_rx_len = UART_RX_BUF_LEN - __HAL_DMA_GET_COUNTER(huart1.hdmarx);

    /* 4. 标记一帧就绪,交给主循环解析 */
    uart_rx_ready = 1;

    /* 5. 立刻重启 DMA,准备收下一帧 */
    HAL_UART_Receive_DMA(&huart1, uart_rx_buf, UART_RX_BUF_LEN);
  }
  /* USER CODE END USART1_IRQn 0 */

  HAL_UART_IRQHandler(&huart1);
  /* USER CODE BEGIN USART1_IRQn 1 */
  /* USER CODE END USART1_IRQn 1 */
}

有三处容易写错的地方,逐条说明:

一是"先清标志、后停 DMA"的顺序。 IDLE 标志如果不清,HAL_UART_DMAStop 内部的一些状态判断可能再次触发中断,导致重复进入。我在一次高压测试里就是顺序写反了,单帧数据被处理了两次。

二是 HAL_UART_DMAStop 的必要性。 它不只是停搬运,还会把 DMA 相关的内部状态复位,让下一步 HAL_UART_Receive_DMA 能干净地重新启动。跳过它直接重启,CNDTR 不会正确复位,长度计算就会漂移。

三是别忘了尾部调用 HAL_UART_IRQHandler 它负责处理 DMA 完成中断等其他串口相关事件。漏掉它,长时间运行时 DMA 半传输完成等中断会累积成问题。

5.4 主循环里的消费

/* USER CODE BEGIN WHILE */
while (1)
{
  if (uart_rx_ready)
  {
    uart_rx_ready = 0;

    /* 这里对 uart_rx_buf 的前 uart_rx_len 字节做业务解析 */
    process_frame(uart_rx_buf, uart_rx_len);
  }
  /* USER CODE END WHILE */

  /* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */

主循环和中断通过 uart_rx_ready 这个标志位握手。中断只负责"收完一帧、置标志",业务解析放在主循环里做,避免在中断上下文里干重活——中断里解析长帧会阻塞其他中断的响应,这是实时系统里的大忌。

六、实测验证

6.1 功能测试

用上位机串口助手以不同长度发送数据帧(3 字节、16 字节、128 字节、255 字节各发 1000 次),单片机收到后原样回传,逐字节比对:

帧长发送次数成功回传丢帧率数据一致性
3 字节100010000%100%
16 字节100010000%100%
128 字节100010000%100%
255 字节100010000%100%

6.2 性能对比

关键的是 CPU 占用和中断次数的对比。用同一个测试程序,在 RXNE 逐字节中断和 DMA+IDLE 两种方案下,连续收发 20 万字节,用 DWT 计数器统计 CPU 占用:

指标RXNE 逐字节中断DMA + IDLE提升幅度
中断触发次数200000 次~8000 次降低 96%
CPU 占用率31.2%2.8%降低 28.4 个百分点
丢包率0.3%0%完全消除

中断次数从 20 万次降到约 8000 次,是因为 DMA 模式下每帧只触发一次 IDLE 中断,而逐字节模式每字节一次。CPU 占用从 31% 掉到 3% 以下,主循环终于能顺畅跑业务逻辑了。

6.3 理论 vs 实测

这里补一组"手册理论值 vs 实测值"的对照,帮助理解 IDLE 的检测精度:

条件理论空闲检测时间实测帧间隔延迟说明
115200 波特率,8N187μs(1 字节时间)~90μs硬件检测精确,接近理论
帧间间隔 1ms应正常分帧正常分帧间隔远大于 87μs
帧间间隔 50μs(< 87μs)无法分帧粘包间隔小于检测阈值,两帧被并成一帧

最后一行是重点:IDLE 判断帧边界的下限就是一个字节的传输时间。如果发送方两帧之间停顿小于 87μs,STM32 就区分不开,数据会粘成一帧。这是硬件的物理限制,不是代码能解决的——所以协议设计上要么保证帧间隔足够大,要么在数据里加帧头+长度字段自己分帧(见第七节)。

七、粘包与半包:DMA+IDLE 也绕不开的问题

DMA + IDLE 解决了"不定长"和"CPU 开销"两个问题,但没有解决粘包和半包。只要通信双方是连续流式传输,这两个问题就客观存在,与用什么接收方案无关。

7.1 两种模式的选择

DMA 有 Normal 和 Circular 两种模式,选错模式会直接造成数据错乱:

维度Normal 模式Circular 模式
缓冲区写满后停止搬运,多余数据丢失回到起点覆盖最旧数据
适用场景单帧处理,帧长可控持续流式接收,需配合环形缓冲区
风险长帧超缓冲时丢尾部处理不及时时旧数据被覆盖

相关阅读:《STM32 UART DMA 与空闲中断接收》 — 详细讨论了 NORMAL/CIRCULAR 两种模式下数据覆盖与回调触发的边界情况

我这里选 Normal 模式 + 每次中断后立即重启。因为我的网关每帧最大 256 字节,缓冲开 256 足够,且 IDLE 中断里立即重启 DMA,不存在缓冲区写满不处理的情况。如果你的应用是高速、连续、大流量的数据流,应该考虑 Circular 模式 + 双缓冲(DMA 半满/全满中断),但这套方案复杂度要高一个量级。

7.2 用帧头 + 长度字段自定帧界

IDLE 的 87μs 下限在高速连续传输时不够可靠,工程上更稳妥的做法是协议层自带帧界定。我给网关定了一个简单协议:

帧头 0xAA 0x55 | 长度(2字节) | 命令(1字节) | 数据(N字节) | 校验(1字节)
#define FRAME_HEAD_H   0xAA
#define FRAME_HEAD_L   0x55
#define FRAME_HDR_LEN  5   /* 2帧头 + 2长度 + 1命令 */

static uint8_t  frame_buf[UART_RX_BUF_LEN];
static uint16_t frame_idx = 0;
static uint16_t expect_len = 0;

/* 把 DMA 收上来的字节喂进一个解析状态机 */
void feed_parser(uint8_t *data, uint16_t len)
{
  for (uint16_t i = 0; i < len; i++)
  {
    uint8_t ch = data[i];

    if (frame_idx == 0 && ch == FRAME_HEAD_H)   { frame_buf[frame_idx++] = ch; continue; }
    if (frame_idx == 1 && ch == FRAME_HEAD_L)   { frame_buf[frame_idx++] = ch; continue; }
    if (frame_idx == 2)                          { frame_buf[frame_idx++] = ch; continue; }
    if (frame_idx == 3)
    {
      frame_buf[frame_idx++] = ch;
      expect_len = frame_buf[2] | (ch << 8);   /* 小端长度 */
      continue;
    }
    if (frame_idx < expect_len + FRAME_HDR_LEN) { frame_buf[frame_idx++] = ch; continue; }

    /* 收满一帧,校验 + 分发 */
    if (check_crc(frame_buf, expect_len + FRAME_HDR_LEN))
      dispatch_frame(frame_buf);
    frame_idx = 0;   /* 复位状态机 */
  }
}

状态机逐字节喂入,不管 DMA 一次收上来的是一整帧、半帧还是两帧半,都能正确切分。帧头匹配失败会直接丢弃当前状态重新找头,天然抗干扰。这一层协议兜底之后,IDLE 中断就只是"告诉 DMA 停一下、算个长度",帧界定的正确性不再依赖 87μs 这个物理阈值。

八、故障排查

汇总几个我实际踩过、以及论坛里最常见的问题:

8.1 上电就进 IDLE 中断,没发数据也进

  • 现象:板子复位后,串口中断函数被频繁调用,uart_rx_ready 一直为 1。
  • 排查:用调试器看 USART_SR 的 IDLE 位,发现上电即为 1。
  • 根因:IDLE 中断使能过早,串口上电后总线本身处于空闲状态,一使能就触发。
  • 方案:把 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE) 挪到所有串口/DMA 初始化完成之后。
  • 验证:复位后不发数据,中断函数不再进入。

相关阅读:《STM32串口空闲中断 上电就进IDLE分析及解决方法》 — 同款问题的完整排查记录

8.2 接收长度不对,时多时少

  • 现象uart_rx_len 计算出来的值比实际发送长度偏大或偏小。
  • 排查:检查 HAL_UART_DMAStop 是否在计算长度前被调用。
  • 根因:没停 DMA 就读 CNDTR,或顺序反了,CNDTR 还在变化。
  • 方案:严格按"清 IDLE → 停 DMA → 读 CNDTR → 算长度"的顺序。
  • 验证:固定长度帧回读,长度值稳定。

8.3 一帧数据被处理两次

  • 现象:主循环里 uart_rx_ready 置位期间,同一帧数据触发了两次解析。
  • 排查:看中断是否重复进入。
  • 根因:IDLE 标志没清干净,或 HAL_UART_DMAStop 后又有事件触发中断。
  • 方案:确保 IDLE 标志用 __HAL_UART_CLEAR_IDLEFLAG 清除,且顺序正确。
  • 验证:加计数器,每发一帧计数应只 +1。

8.4 高速连续发送时数据错乱

  • 现象:低速正常,高速(如帧间隔 < 100μs)时数据串帧、错乱。
  • 排查:确认是否为粘包/半包。
  • 根因:两帧间隔小于 IDLE 检测阈值(1 字节时间),物理上无法分帧。
  • 方案:协议层加帧头 + 长度字段 + 校验,用状态机分帧,不依赖 IDLE 精度。
  • 验证:连续高速发帧,解析出的帧数与发送数一致。

8.5 长时间运行后 DMA 卡死不再接收

  • 现象:跑几个小时到几天后,串口突然收不到数据,复位后恢复。
  • 排查:检查是否漏调了 HAL_UART_IRQHandler,或 DMA 错误中断未处理。
  • 根因:DMA 半满/错误中断未清,累积导致状态机卡死;或 HAL_UART_DMAStop 后未正确重启。
  • 方案:中断尾部补上 HAL_UART_IRQHandler(&huart1),并在 HAL_UART_ErrorCallback 里做恢复处理。
  • 验证:挂机压测 72 小时,收发不中断。

九、总结

核心要点回顾:

  1. DMA 负责搬运、IDLE 中断负责判断帧结束,两者配合实现不定长数据的零 CPU 逐字节开销接收。
  2. 已收长度 = 缓冲区长度 − __HAL_DMA_GET_COUNTER() 的当前值,这是整个方案的计算核心。
  3. 中断处理必须遵循"清 IDLE → 停 DMA → 算长度 → 重启 DMA"的固定顺序,顺序错一点都会出问题。
  4. IDLE 分帧有 1 字节时间的物理下限,高速连续传输必须靠协议层(帧头+长度+校验)兜底。

适用边界: 这套方案适合"帧长可控、帧率适中"的场景——例如传感器数据上报、Modbus/自定义协议网关、上位机指令交互。如果你的数据是高速、连续、不可预知长度的流(比如透传转发),建议改用 Circular DMA + 双缓冲方案。

已知局限: 单帧长度受缓冲区大小限制,超长帧在 Normal 模式下会丢尾部;IDLE 的 87μs 检测阈值决定了纯靠它分帧在高速场景下不可靠。

扩展方向: 掌握本文之后,可以进一步研究 DMA 的半满/全满中断双缓冲机制、RS485 的收发方向自动切换(配合 USART 的 TC 标志),以及 FreeRTOS 下如何用信号量把"帧就绪"事件同步给解析任务。

本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。

参考资料

📝 版本备注

  • 硬件平台:STM32F407ZGT6(正点原子探索者) + CH340 USB 转串口
  • 软件版本:STM32CubeMX 6.9.0 + STM32F4 HAL 库 1.27.1 + Keil MDK 5.37
  • 兼容说明:本文代码适用于 STM32F4 全系;F1/F0/G0 系列寄存器与中断配置略有差异,思路完全通用,移植时注意 __HAL_DMA_GET_COUNTER 参数与 IDLE 标志清法即可
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LCG元

你的鼓励将是我创作的最大动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值