资讯动态

STM32H743串口DMA+空闲中断:不定长接收的终极方案

发布时间:2026/10/20 21:45:31 来源:尧图企业网站定制
简介面向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 的 TXPA10 是 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 读数据时优先命中 CacheCache 里还是旧内容于是你读了半天都是缓存里的老数据。这个问题 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 接收把这几点提前刻在脑子里省下的调试时间会非常可观。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑