☰
STM32H743串口DMA+空闲中断:不定长接收的终极方案
2026/10/20 21:45:14 网站建设 项目流程

简介:面向STM32H743开发者与嵌入式工程师,这份资源围绕串口DMA与空闲中断的联合应用,提供可直接参考的工程代码与HAL库配置方案,解决不定长数据接收、CPU占用高及通信状态监控等实际问题。压缩包共141个文件,主要包含47个h头文件、27个c源文件及o编译中间文件、ld链接脚本、ioc工程配置等,整体大小约1.25MB,便于快速导入STM32CubeMX或HAL库工程使用。资料中可见stm32h7xx_hal_uart.c、stm32h7xx_hal_dma.c等关键驱动源码,可帮助理解串口DMA流配置、空闲中断触发逻辑及中断服务函数写法,并附带hex、map、elf等编译产物,方便对照验证。已有5743人学习下载,适合需要掌握STM32H7高性能串口通信开发的初中级嵌入式开发者阅读参考。

1. 串口不定长接收的终局方案:stm32h743 串口 DMA 加空闲中断

调试某设备串口协议时,最烦的不是波特率,而是报文长度不固定:一条命令短到只有 3 字节,一场完整的数据帧能到 512 字节。早期用单字节中断收,9600 波特率还能撑,一旦拉到 115200 就开始丢字节,CPU 被拖得连协议解析都得抢时间。后来换了一套组合拳,问题基本绝迹:stm32h743 串口 DMA 把数据从外设搬到内存,空闲中断在总线空闲时通知 CPU 来取整帧数据,CPU 全程只进一次中断。这套方案在连续接收、不定长报文同时存在的场景里非常能打,适合正在调串口协议、又不想被中断风暴折磨的人。从原理、初始化、回调处理到 H7 特有的坑,一篇过完。

2. 为什么空闲中断配 DMA:把帧边界和搬运都交给硬件

2.1 三种串口接收方案对比

串口接收不定长数据,工程师翻来覆去就那几种思路,先把方案选明白,后面代码才不返工。我按实际工程里的常见形态列了一张对比表:

方案实现方式CPU 占用丢数据风险适合场景
轮询主循环死查 RXNE 标志极高,阻塞其他任务低速下不高极低速、格式极简单的 Demo
单字节中断每收到 1 字节进一次中断每字节一次中断,115200 时每秒上万次高,进入中断延迟抖动就会丢帧短、频率低、实时性要求不高的场合
DMA + 空闲中断DMA 后台搬运数据,IDLE 中断标记帧结束每帧一次中断,几乎为零低,前提是缓冲够大不定长、高频、大数据帧的场景

DMA + 空闲中断不是唯一能收不定长数据的办法,但它是“CPU 介入最少、代码结构最干净”的办法。单字节中断在低波特率下也能用,问题在于 115200 波特率下每秒产生一万多次中断,任何一个高优先级中断插进来,串口 FIFO(如果硬件没有足够深度)就溢出。H743 的主频虽然跑到 480MHz,但中断频繁本身就是在浪费算力,把这些算力留给协议解析、算法、控制环路更划算。

2.2 空闲中断的触发原理:为什么它天生是帧边界

STM32 的串口空闲中断(IDLE)触发条件很多人理解偏了:它不是“总线一直空闲”就触发,而是“已经收到一个有效字节之后,总线上再空闲一个字节时间”才触发。也就是说,IDLE 硬件上天然把一帧数据的结束边界给你框出来了。

举个例子:上位机发 0xAA 0x01 0x02 0x55,四个字节连续被移位寄存器接收,最后一个字节的停止位结束后,总线保持高电平,持续时间超过一个字节的传输时间,此时 IDLE 标志置位,产生中断。这个中断时刻恰好是“这一帧已经完整到达”的时刻,数据此刻已经被 DMA 搬运到内存里了,CPU 去读,拿到的就是完整一帧。

帧到达这一瞬间,DMA 的计数器 NDTR 也刚好停在当前剩余值,用初始值减去当前值就得到实际字节数。这就是整套方案不用额外定时器、不用协议帧头帧尾也能判断一帧长度的核心。相比“DMA + 定时器超时”的做法,定时器要额外占一个外设、还要根据波特率算超时时间,波特率一变超时参数就得跟着调;空闲中断是纯硬件行为,零额外开销,换波特率也不用改逻辑。

2.3 为什么在 H743 上特别值得用:DMA 矩阵与 Cache 差异

H743 比 F4 系列强在 DMA 矩阵和内存架构上。F4 的 DMA 也能做外设到内存搬运,但 H743 的 DMA 是双 AHB 主控,外设互联更灵活,多个 DMA 通道可以并行跑,串口接收不必和 ADC、定时器、SPI 抢总线带宽。这意味着你在跑高速串口的同时,其他外设的 DMA 传输也不会被明显拖慢。

同时 H743 默认可能开启 D-Cache,这在 F4 上是没有的。DMA 把串口数据写到内存,但 D-Cache 可能让 CPU 读到旧数据。这个坑排在后面避坑章节里专门讲,这里先有个概念:H7 上配 DMA 接收,Cache 一致性是你绕不开的检查项,这跟 F4 上直接读缓冲是两回事。

3. 初始化工程三件套:GPIO、串口、DMA 的配置顺序与参数

3.1 CubeMX 里的规划:先定引脚和 DMA 请求映射

我一般先在 CubeMX 里把芯片型号选成 STM32H743VIT6 或对应封装,然后做三件事:使能 USART1,引脚复用选到 PA9/PA10;把串口 DMA 接收请求映射到 DMA2 的某个 Stream;把 DMA 模式设为 Circular(循环模式)。CubeMX 生成的代码骨架基本能用,但有几个关键参数它给的不是最优,需要手动改。

映射关系要对着具体型号的数据手册查。H743 上 USART1 接收对应的 DMA 请求往往落在 DMA2,但具体是第几个 Stream、第几个 Channel 不同封装和型号有差异。千万别把 F4 上的映射直接搬过来,F4 的 DMA1/DMA2 和 H7 的 DMA 结构不完全一样。CubeMX 里能直接看到请求映射表,生成代码后它会自动填好 hdma_usart1_rx 的 Instance 和 Request,这两个字段别动。

3.2 代码:GPIO 与串口初始化,先让外设跑起来

/* 使能时钟,配置 PA9/PA10 为 USART1 复用功能 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_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.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 配置串口参数:115200-8-N-1 */ 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; HAL_UART_Init(&huart1);

逻辑说明:PA9 是 USART1 的 TX,PA10 是 RX,复用功能号 AF7。波特率先按 115200 设,后面要提速只改这一个参数。字长选 8 位、停止位 1 位、无校验是串口最常用的配置,如果你的协议带奇偶校验,WordLength 要同步改成 9 位。

参数说明:GPIO 速度选 VERY_HIGH 不是为了快,而是为了降低高速波特率下信号边沿的畸变;如果板子走线短、波特率不高,HIGH 也够。串口初始化完成后,接收路径还缺 DMA,先别急着发数据。

3.3 DMA 初始化:循环模式是让接收“自动续命”的关键

/* DMA2 接收 Stream 配置:外设到内存,循环模式 */ hdma_usart1_rx.Instance = DMA2_Stream5; hdma_usart1_rx.Init.Request = DMA_REQUEST_USART1_RX; 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_CIRCULAR; hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);

逻辑说明:外设地址不自增(PeriphInc 关闭),内存地址自增(MemInc 开启),数据和存储宽度都是字节。这三项配合起来,DMA 会把串口收到的每个字节按顺序写进内存缓冲区。

参数说明:Mode 是这套配置的灵魂,DMA_CIRCULAR 表示循环模式,缓冲区写满后 DMA 自动把计数器重置回初始值,继续从缓冲区头开始写。如果不想要回绕,可以用普通模式,但普通模式收满一次后 DMA 就停了,需要软件重新启动,连续接收时非常容易漏帧。我在这类场景里始终选循环模式,配合空闲中断每次算实际长度。

DMA2_Stream5 这个编号一定以你手里的 CubeMX 生成的为准,不同系列、不同封装会有变化,不要照抄。请求号 Request 同理。

3.4 启动接收的正确顺序:先开 DMA,再开空闲中断

/* 定义接收缓冲区,注意看缓冲区大小的上限约束 */ #define RX_BUF_SIZE 1024 uint8_t rx_buf[RX_BUF_SIZE]; /* 启动 DMA 接收 */ HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE); /* 手动使能空闲中断,老版本 HAL 需要这一句,新版本有的库已自动开 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

逻辑说明:Receive_DMA 把 DMA 和串口绑定并启动传输,这时候串口收到的数据就开始往 rx_buf 里写。随后使能 IDLE 中断,这样总线空闲时就能触发回调。

参数说明:RX_BUF_SIZE 不宜开得太大也不要太小。DMA 的 NDTR 计数器是 16 位,单次 DMA 缓冲上限实际是 65535 字节,超过这个值计数器会回绕导致长度计算完全错乱。缓冲区设成 1024 是常见做法,能覆盖绝大部分协议帧;如果你协议里最大帧有 2000 字节,缓冲区就得设到 2048,但要用协议层拆帧来兜底,不能指望一帧完整落在缓冲区里。

启动顺序有个讲究:先启动 DMA 再开 IDLE 中断,防止极端情况下第一个字节到达时 DMA 还没准备好,导致 RXNE 置位但没人搬运,数据丢失。

4. 中断与数据落盘:空闲中断回调、NDTR 算帧长与缓存保护

4.1 空闲中断回调:几行代码算出真实帧长

/* 这是 HAL 库留给用户的空闲中断回调,重写它即可 */ volatile uint16_t frame_len = 0; volatile uint8_t frame_ready = 0; void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 先读 DMA 剩余计数,再清中断标志 */ uint16_t remain = __HAL_DMA_GET_COUNTER(huart->hdmarx); frame_len = RX_BUF_SIZE - remain; if (frame_len > 0) { frame_ready = 1; } __HAL_UART_CLEAR_IDLEFLAG(huart); } }

逻辑说明:空闲中断触发时,DMA 已经搬完这一帧数据,NDTR 停在剩余计数值。用初始化时设置的总长度 1024 减去当前剩余值,就得到本次 DMA 搬了多少字节,也就是这一帧的实际长度。

参数说明:frame_len 和 frame_ready 一定要用 volatile 修饰,因为它们在中断和主循环两个上下文里共享。__HAL_DMA_GET_COUNTER 返回 16 位计数器值,对应公式里的类型是 uint16_t;如果缓冲区大小超过 65535,这个公式就失效了。

回调函数里只做两件事:算长度、置标志。不要去解析数据、不要调 HAL_Delay、更不要 printf。中断上下文里做重活,轻则拉长中断响应时间,重则直接 HardFault,这是很多新手第一次跑通后踩得最疼的坑,详细原因放避坑章节。

4.2 主循环处理帧数据:取走、解析、避免覆盖

while (1) { if (frame_ready) { frame_ready = 0; /* 先清标志,防止重复处理 */ uint16_t len = frame_len; /* 本地副本,防止中断修改 */ /* 在这里解析 rx_buf,取 len 字节的数据 */ process_frame(rx_buf, len); /* 循环模式下 DMA 不需要重启,直接等下一帧 */ } }

逻辑说明:主循环检测到 frame_ready 后,先清标志再取长度,这是防止窗口期竞态的标准做法。中断里会更新 frame_len,所以用本地变量先保存一份,避免处理过程中长度被改写。

参数说明:rx_buf 是 DMA 直接写入的内存,主循环读它的时候要意识到这是共享资源。如果下一帧在 process_frame 处理完之前就到了,DMA 会把新数据覆盖到缓冲区上,你拿到手的数据就是新旧掺杂的。这个问题在低速、低频场景下不明显,高频下就很危险,解决办法是双缓冲,最后一章展开。

如果你用的是普通模式的 DMA(非循环),那么处理完一帧后必须手动重启接收,常见做法是先停再启动:

HAL_UART_DMAStop(&huart1); __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);

逻辑说明:HAL_UART_DMAStop 会把 DMA 计数器重置,清 IDLE 标志避免残留中断反复触发,然后再调用 Receive_DMA 绑定新缓冲。普通模式每次接收都要走这套流程,代码稍微繁琐,回调里也要处理——这也是我在连续接收场景弃用普通模式的原因。

4.3 多帧连续到达时的拆帧思路

空闲中断每检测到一次总线空闲就触发一次,所以多帧连续到达时,每帧都会触发一次回调,frame_ready 会被置位多次。如果主循环处理不够快,只能处理最新一帧,中间帧就丢了。

这时候要在协议层做拆帧,比如帧头 + 长度字段 + 校验,处理流程大概是:每次拿到 rx_buf 里的数据,先扫描帧头,确认一帧完整长度,再从缓冲区拷走,把剩余数据搬到缓冲头部,等待下一帧补全。这套逻辑写起来不复杂,但和本文主题关系不大,点到为止。真正要解决处理不过来,还得靠最终章的半缓冲机制。

5. H743 避坑实录:Cache 一致性、DMA 回绕和标志清除的五个坑

5.1 现象一:回调里一调 HAL_Delay 就 HardFault

现象:在 HAL_UART_IdleCpltCallback 里加了延时或打印,程序跑几秒直接进 HardFault。查了很久以为是数组越界,最后定位在中断上下文。

原因:空闲中断属于串口中断,优先级可以被设置为任意值。但如果你的系统中还有别的更高优先级中断频繁触发,空闲回调里做阻塞操作,中断栈就会累积嵌套,H743 的栈空间一旦被顶穿,HardFault 立刻发生。这个锅不完全算栈的,更多是“在中断里做慢操作”这个行为本身破坏了实时性。

解决:回调里只置标志位、记长度、清标志,所有真正的处理全部放到主循环。这是嵌入式开发的老规矩,在 H7 这种性能强的芯片上也别破例。

5.2 现象二:帧头经常丢一两个字节

现象:串口助手一次发 10 个字节过来,缓冲里有时只有 8 个,且丢的位置不稳定,多数在最前面。

原因:有两个来源。第一个是 DMA 还没完成初始化时串口数据已经到了,丢失发生在开头;第二个是 IDLE 中断触发得太快,在最后一个字节的停止位还没完全结束时就判定“空闲”,此时最后一个字节还卡在移位寄存器里,DMA 还没来得及把它搬进内存,你的长度计算把这一个字节漏了。

解决:第一点通过“先启动 DMA、再开 IDLE 中断”解决,顺序在前文已经固定;第二点可以在清 IDLE 标志后加一小段等待,让最后一个字节完成搬运。但更稳妥的办法是:在协议层做容忍,接收完一帧后延时几个微秒再处理,或者在解析时以帧头为准对齐数据。工程上我一般选后者,因为加延时容易引入新问题。

5.3 现象三:收到第一帧后就再也不进中断了

现象:程序启动后能收到一帧,之后就算串口继续发数据,回调也不再触发。DMA 状态看着也没问题。

原因:用的是普通模式的 DMA。普通模式下 DMA 传输到指定长度就停止,NDTR 归零后不再自增。虽然串口数据还在往 RDR 写,但没人搬运了,缓冲不再更新,空闲中断虽然触发,但你算出来的 frame_len 是 0 或者固定值,看起来就像“不再进中断”。

解决:改用 DMA_CIRCULAR 循环模式,让 DMA 永远待命,或者每次处理完手动调用 HAL_UART_Receive_DMA 重新绑定。“收到一帧就哑火”这个问题几乎都出在 DMA 模式上,以后排查先看这一项。

5.4 现象四:DMA 明明在收,CPU 读到的却全是旧数据

现象:用调试器看内存,DMA 已经往 rx_buf 写入了新数据,但主循环里读 rx_buf 一直是上一次的值,甚至全是零,看着像玄学。

原因:H743 开了 D-Cache。DMA 写内存是直接写 SDRAM 或 SRAM,不经过 Cache,而 CPU 读数据时优先命中 Cache,Cache 里还是旧内容,于是你读了半天都是缓存里的老数据。这个问题 F4 上不存在,H7 上存在且必须主动处理。

解决:读 DMA 数据前,手动失效这一段 Cache:

/* 处理数据前,使 rx_buf 对应的 D-Cache 失效,强制从内存重新读取 */ SCB_InvalidateDCache_by_Addr((uint32_t *)((uint32_t)rx_buf & ~0x1F), (frame_len + 31) & ~0x1F);

参数说明:SCB_InvalidateDCache_by_Addr 要求地址按 32 字节对齐,长度也建议按 32 字节对齐取整。rx_buf 是数组,编译器不一定按 32 字节对齐,所以先把地址向下取整到 32 字节边界,长度向上取整。好的习惯是把 rx_buf 定义成 32 字节对齐:

__attribute__((aligned(32))) uint8_t rx_buf[RX_BUF_SIZE];

不想在每次读数据前都做失效,也可以把串口接收缓冲放到一段配置为 non-cacheable 的 MPU 区域,业务代码完全不用管 Cache。代价是 MPU 配置复杂度上来了,H7 初学者建议先用 InvalidCache 方案,代码直观,出问题也好排查。

5.5 现象五:高波特率下偶发丢帧,长度算出来忽大忽小

现象:9600 波特率一切正常,拉高到 921600 后,每收几十帧就有几帧长度不对,要么大于实际数据,要么等于缓冲区长度。

原因:缓冲区回绕叠加处理不及时。循环模式下,如果 DMA 已经写满 1024 字节并回绕了一次,NDTR 重新从 1024 开始递减。空闲中断触发时,当前 NDTR 可能已经处于回绕之后的值,用 1024 减去它得到的是一个远小于实际累计长度的数值,或者直接算出了负数。也就是说,只要一帧数据量超过缓冲区剩余空间,单独靠 NDTR 算长度就不靠谱了。

解决:两个方向。一是缓冲区按协议最大帧的 1.5 倍以上配置,保证一帧数据绝不会跨越回绕点;二是引入双缓冲机制,让主循环和 DMA 交替使用两个半区,避免缓冲区被覆盖。第一种方向省事,适用于协议帧长度可控的场景;第二种方向是真正的工程常态解法,放在最后一章。

6. 进阶:双缓冲让收发不打架,以及我的验证习惯

要让串口接收在高负载下不丢帧,又不让处理逻辑等 DMA 站完,标准做法是双缓冲。把 1024 字节的缓冲区切成两个 512 字节的半区,DMA 写前半区时,CPU 处理后半区;DMA 写后半区时,CPU 处理前半区。这样收发双方永远不会同时碰同一块内存。

H7 的 DMA 在循环模式下天然支持半传输中断(HT)和传输完成中断(TC)。半传输中断表示 DMA 写完了前半区,传输完成中断表示写完了后半区。有人觉得这两个中断加上空闲中断会不会太复杂,其实各有分工:HT/TC 解决“连续大流量”场景,空闲中断解决“一帧零头”场景。大部分协议一个来回不至于塞满 1024 字节,但频繁大包时双缓冲的优势就出来了。

/* 双缓冲配置:buff1 和 buff2 交替使用 */ uint8_t rx_buf[RX_BUF_SIZE]; volatile uint8_t active_buf = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { /* 后半区写满,处理前半区 */ active_buf = 0; process_data(&rx_buf[0], RX_HALF_SIZE); } void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { /* 前半区写满,处理后半区 */ active_buf = 1; process_data(&rx_buf[RX_HALF_SIZE], RX_HALF_SIZE); }

逻辑说明:当 HT 中断触发,说明 rx_buf[0..511] 已经填满,主逻辑可以安心处理这一半;与此同时 DMA 继续往 rx_buf[512..1023] 写入。等 TC 中断触发时,后半区也满了,再处理后半区。这样不会出现“刚读到一半,数据被覆盖”的问题。

参数说明:双缓冲有一个前提——单帧数据长度不能超过半个缓冲区,否则半传输中断语义会被破坏。所以缓冲区大小设置要按最大帧长度 * 2往上走。另外,因为 HT/TC 中断和空闲中断可能几乎同时触发,处理前要再次确认数据长度,最稳妥的做法仍然是用 NDTR 差值计算出本次真实长度,再决定去哪半个区取数据。

从那以后,我每在 H7 上做串口接收,都会强制自己走一遍固定检查单:先确认 DMA 模式是循环模式,再确认缓冲区和 Cache 对齐,最后读一遍空闲中断回调里有没有阻塞调用。这套三连习惯帮我避开了绝大多数“看起来像随机故障”的问题。如果你也准备在 H743 上做串口 DMA 接收,把这几点提前刻在脑子里,省下的调试时间会非常可观。希望帮到你。

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

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

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

立即咨询