
简介STM32H743 是意法半导体推出的一款高性能微控制器搭配 DMA 与 UART 空闲中断可实现高吞吐、低 CPU 占用的串行数据收发。整套工程面向嵌入式开发者完整演示了 UART 初始化、DMA 通道配置、传输方向与长度设定、空闲中断处理以及收发缓冲区管理等关键环节覆盖从寄存器配置到中断服务程序的完整闭环。资源共 192 个文件压缩包约 1.36MB以 86 个 C 源文件、98 个头文件为主体另含 MDK 工程文件、启动文件与 hex 固件可直接在 STM32H743 平台导入编译验证。已有 3429 人学习下载。借助工程内的代码结构开发者可以快速掌握 DMA 加空闲中断实现任意长度数据接收的核心思路明确 CPU 与 DMA 的职责划分为传感器数据采集、无线模块通信等项目提供可直接复用的模板并在实际调试过程中形成系统化的排错方法提升系统并发处理与资源利用率。 如果把H743当成一个大号F103用那就有点糟蹋芯片了。我手里这块板子最初是给一台视觉分拣设备做通信网关的主控就是STM32H743外设负载里最重的不是屏幕不是网络反而是看起来最不起眼的串口——要和飞控通信、和编码器盒子通信、还要把打包好的数据帧通过UART扔给上位机。跑到921600波特率之后单个字节中断的接收方式立马就顶不住了中断频率太高CPU全耗在进出中断上主循环里的控制逻辑直接饿死。后来把串口接收彻底改成DMACPU占用率从接近40%掉到了2%以内整机流畅度完全不是一个级别。这篇内容我就拿这个实际项目当例子把STM32H743上UARTDMA从配置到落地完整过一遍。适合正在做高速串口采集、打算把串口任务从CPU中断里解放出来、或者刚接触H7系列被DMA和Cache折磨过的朋友。核心围绕三个方向H743的UART和DMA到底怎么协同工作、CubeMX和代码层面怎么配才能不翻车、以及实测中我踩过的坑和排查方法。就算你之前只用过标准库、对HAL不熟按下面的思路走一遍也能跑起来。1. 为什么是H743DMAUART这个组合1.1 H743的UART资源与性能上限STM32H743这颗M7内核主频能到480MHz片上串口资源非常充裕USART加UART加起来有8组。更关键的是H7的UART模块本身强化过一方面支持8倍和16倍过采样另一方面波特率发生器带小数分频FBRR这让那些非整数波特率比如3Mbps、1.5Mbps可以配得比较准不会像F1那样容易出现误差累积。以USART1挂在APB2为例如果PCLK2配置成120MHz用16倍过采样算波特率分频系数divider 120000000 / (16 * 3000000) 2.5这个2.5在F1上是没法精确表达的但在H7上可以通过小数部分配出来实测3Mbps的帧误码率在短线、双绞线环境下可以接受。不过我不建议一味拉高波特率。常见工业场景里921600已经非常够用1Mbps到2Mbps属于“留足余量”的档位再往上就要考虑线材、隔离器、串口芯片的极限了。我这套方案最终定在921600数据传输量大时也跑过1.5Mbps稳定性都还可以。1.2 中断接收在高波特率下的致命问题很多人调串口从“每收一个字节进一次中断”开始这个逻辑简单没错但高波特率下问题很直接921600bps意味着每秒钟约92160个字节也就是约10.8微秒就有一个字节进来。MCU进出一次中断的开销加上压栈、读寄存器、判断帧头、存缓冲区十几微秒可能还不够CPU几乎被串口“独占”。这不是优化得好不好的问题是架构上就错了。DMA方式的核心思路是UART外设收到数据后直接由DMA搬运到内存缓冲区整个过程CPU不参与。只有在你想要的一帧数据收完时DMA配合空闲中断IDLE才通知一次CPU。1000个字节只会进一次中断和逐字节中断的开销差了几个数量级。另外H7的DMA还带了一个很关键的东西叫DMAMUX它把DMA请求和DMA流之间做了一层可编程路由。以前F4上某个DMA流只能服务固定的外设H7上则灵活得多这给布线、引脚分配、多外设复用DMA提供了很大便利。2. 硬件细节与三个容易翻车的点2.1 过采样、小数波特率与误差计算在配置UART时CubeMX会帮你计算波特率寄存器最终的值但底层逻辑最好心里有数。H7的USART波特率计算公式是BRR PCLK / (oversampling * BaudRate)其中oversampling可以是16或8。CubeMX默认走16倍过采样这时的“最佳”是波特率时钟误差在0.5%以内。8倍过采样可以把波特率上限翻倍但对时钟误差更敏感如果PCLK来源不够稳反而容易出乱码。我自己的习惯是1.5Mbps以下都用16倍过采样稳妥为主真要上3Mbps再开8倍并且用示波器实测波形。H7支持小数分频这点是真方便比如上面算的divider2.5H7的FBRR可以把整数部分和小数部分拆开写不会出现F103那种只能取整导致误差跑到2%以上的尴尬。2.2 DMA传输模式Normal、Circular还是双缓冲DMA模式选错代码层面会绕很多弯路。这里直接把三种模式和适用场景列开。普通模式NormalDMA搬运完设定的长度就停适合一次性发送数据块。UART的TX方向我基本都用这个。循环模式CircularDMA搬完一个周期后自动回到起始地址继续搬适合持续接收不定长数据。RX方向做“环形收”非常合适——数据不断往缓冲区里写CPU只在需要时来取。双缓冲模式Double BufferDMA可以在两个缓冲区之间交替写入一个缓冲区写满后自动切到另一个同时可以触发中断让CPU处理已经写满的那个。这种方法让“处理”和“接收”在时间上重叠适合持续大流量但H7上实现对缓冲区地址对齐、DMA请求配置要求都比较高我一般在高负载场景才会用。实际选型时可以按下面的表格对号入座场景推荐DMA模式原因串口发送数据帧Normal一次发完发完停止串口接收不定长指令Circular 空闲中断持续接收帧边界用IDLE判断持续大流量采集双缓冲或Circular 半满中断减少丢数据风险处理与接收重叠2.3 H7特有的D-Cache问题绕不开这是从F1/F4迁移到H7最容易踩的坑。H7内核有L1 Cache其中D-Cache会缓存内存数据。UART通过DMA把数据写进RAM但CPU读取时如果命中了D-Cache里的旧数据读到的就是“过期内容”表现出来就是串口收到的数据错乱、丢字节、甚至完全不对但调试器里看DMA缓冲区明明是对的。解决办法有两种思路。第一种是把串口缓冲区所在内存区域配置成不可缓存non-cacheable这是最推荐的方式一劳永逸不用每次手动清理。第二种是每次DMA收发后手动做Cache维护比如// 接收后让Cache失效强制CPU从内存重新读取 SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); // 发送前先把缓冲区的脏数据写回内存 SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);我最终用的是MPU配置SRAM区域为non-cacheable的方案在CubeMX里初始化MPU区域即可。把缓冲区放到0x24000000开始的AXI SRAM区域然后配置对应的MPU区域禁止Cachestatic void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress 0x24000000; MPU_InitStruct.Size MPU_REGION_SIZE_64KB; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable MPU_REGION_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable MPU_REGION_NO_CACHE; MPU_InitStruct.IsShareable MPU_REGION_NOT_SHAREABLE; MPU_InitStruct.Number MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable 0x00; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_CTRL_PRIVILEGED_DEFAULT); }从H7入门到现在我所有DMA缓冲区都按这个规格放Cache相关问题基本绝迹。3. 从CubeMX到代码串口DMA接收发送的完整实现3.1 CubeMX配置的初始化要点新建工程后在CubeMX里按这几步配置就够了不用改额外的寄存器芯片选STM32H743ZIT6时钟树配置成最高480MHz注意USART1挂的APB2时钟我这边配到120MHz。打开USART1模式选Asynchronous波特率按实际需求填我这边填921600数据位8、停止位1、无校验。在DMA Settings选项卡里添加两个DMA请求USART1_TX和USART1_RX。方向分别是MemoryToPeripheral和PeripheralToMemory模式按表里说的TX用NormalRX用Circular。打开USART1全局中断NVIC这里一定不能省因为DMA配合空闲中断判断帧尾需要CPU响应。CubeMX生成的初始化函数里DMA句柄和UART句柄会自动关联好。它的好处是DMAMUX的请求映射、优先级这些细节都帮你配好了不用手动翻寄存器表。如果对CubeMX自动生成的代码不放心可以在main函数里HAL_UART_Init之后、外设正式工作前自己调用HAL_UART_DMAStop之类的接口重新确认状态。3.2 发送实现一次DMA发送的完整生命周期发送侧简单直接用HAL库封好的接口uint8_t tx_buf[256]; uint16_t tx_len 0; // 发送一帧数据 HAL_UART_Transmit_DMA(huart1, tx_buf, tx_len);这里有三个我自己总结的约束第一tx_buf在DMA搬运期间不能被修改。如果发送频率高且缓冲区是复用的必须等上一次发送完成再填新内容。HAL提供了发送完成回调void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { tx_busy 0; // 标记发送空闲主循环可以继续填新数据 } }第二DMA发送完成并不代表UART已经把数据完全移位输出完毕。DMA把数据搬到UART数据寄存器后串口还要逐位往外发。如果这时马上进入低功耗或关闭外设末端几个字节就会丢。稳妥的做法是在HAL_UART_TxCpltCallback里等待TC标志或者直接通过HAL_UART_GetState查询状态while (HAL_UART_GetState(huart1) ! HAL_UART_STATE_READY);第三发送回调是在中断里执行的不要在回调里放耗时的处理逻辑置个标志位就够了。我见过有人直接回调里发下一帧数据层级一深时序全乱。3.3 接收实现空闲中断加循环DMAH7的HAL库已经封装好了“接收直到空闲”的函数这是一个非常顺手的工具。它做的事是DMA持续往缓冲区里写数据当串口线上出现空闲即一帧数据结束的标志时触发UART空闲中断然后调用一个事件回调告诉你当前这一轮总共收到了多少字节。static uint8_t rx_buf[1024]; // 启动接收DMA循环写入rx_buf HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf));事件回调里能拿到实际收到的长度void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { // Size 是这一帧的实际字节数 process_frame(rx_buf, Size); // 重新开始下一轮接收 HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buf, sizeof(rx_buf)); } }这个方案非常适合“不定长数据帧”的应用不管是Modbus帧、自定义协议包还是GPS的NMEA语句都能靠空闲中断天然识别出一条完整帧。需要注意的是如果主机发的数据流是连续不断且没有间隔的空闲中断永远不会触发所以协议设计里最好留一个帧间间隔或者使用DMA的半满中断配合环形缓冲区来做流式处理。3.4 环形缓冲区与DMA的半满中断有些场景帧长不确定甚至数据是连续流不可能每次都靠空闲中断切帧。这种时候我的做法是DMA配置成Circular模式同时打开DMA的半传输中断和传输完成中断。也就是说缓冲区前半部分写满时进一次中断后半部分写满时再进一次CPU在两个区间交替期间把已填满的那一半数据消费掉。缓冲区大小和半满中断的配合逻辑并不复杂核心就是用读写指针来维护环形缓冲#define RX_BUF_SIZE 1024 static uint8_t rx_dma_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head 0; static volatile uint16_t rx_tail 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_tail RX_BUF_SIZE / 2; // 后半段写完 } } void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_tail 0; // 前半段写完 } }主循环里从rx_head到rx_tail之间的数据就是新收到的数据处理完把rx_head更新到rx_tail即可。这种结构在数据流大而连续时非常稳丢帧率比逐字节中断低一个量级。4. 调试验证与问题排查实录4.1 一开D-CacheDMA数据全乱这个前面提过是最典型的H7坑。现象就是关闭D-Cache一切正常一打开D-Cache串口DMA接收的数据就出现错乱调试器看内存确实有数据但应用层读不对。根本原因是CPU读到了Cache中的旧数据。解决方式就是把DMA缓冲区放进non-cacheable区域或者每次收发前后手动Clean/Invalidate。我个人推荐MPU方式一次配好不用每次收发前都操心。这在CubeMX里做也简单不需要动链接脚本。4.2 每次DMA发送完最后一个字节丢失发送最后几个字节丢失大概率是DMA传输完成和UART移位寄存器还没“倒空”造成的。DMA把最后一个字节写进UART的TDR后DMA任务就结束了但UART还要按位向外发送。如果发送完成回调里立刻改变了缓冲区内容、关外设或者进低功耗最后几个字节自然就没了。在代码层面给两个对策一是调用发送接口后等待HAL_UART_GetState回到READY二是在TXCpltCallback里等待UART的TC标志置位。我最后采用的方式是在回调里翻转一个电平引脚用示波器确认DMA结束和线上数据真正结束之间的时间差这时就能直观看到大概需要多少微秒。4.3 空闲中断误触发完整帧被拆成好几段把接收逻辑设计成“收到空闲就回调”最大的误伤来自串口线噪声和上位机发送的不连续。如果上位机把一个包分成几段时间间隔发送每次间隔都会被识别成帧尾导致本来完整的一帧被拆开。处理方法有两个取向一个是在接收端做“短超时合并”机制空闲中断后不立即处理而是启动一个软件定时器等几十毫秒如果没新数据再当作帧结束另一个是修改上位机发送逻辑保证一个数据帧一次性写完不要拆分发送。前者比较通用我用的就是从机端短超时合并的做法。可以利用H7的硬件定时器做一个帧超时窗口空闲中断后启动定时器定时到了才通知应用层有完整帧。4.4 串口助手看到的波特率数据乱码但DMA缓冲区正常有一种情况比较迷惑逻辑分析仪或示波器上看到的波特率对不上但DMA缓冲区里的数据看起来“好像是”正确的。这时候要优先检查PCLK时钟树的配置H7的串口时钟来源如果被CubeMX自动优化过实际分频后的PCLK可能不是整数。比如PCLK2实际是119.8MHz而不是120MHz那921600波特率的误差就会偏大长时间传输后出现偶然乱码。排查时可以用串口助手的回环测试也可以写一个简单的“循环发送0x55”测试程序观察示波器波形高电平持续时间是否为波特率周期的整数倍。如果误差超过0.5%检查CubeMX的时钟树必要时调整PLL配置让PCLK落到完全整数。4.5 问题速查表现象可能原因解决方法DMA接收数据全乱D-Cache未处理MPU配置non-cacheable区域或手动Cache维护发送末尾丢字节未等UART的TC标志发送回调里等TC或轮询HAL_UART_GetState完整帧被拆开空闲中断对帧间隔太敏感加短超时合并延时后再上报帧长时间运行后偶发乱码PCLK/波特率误差偏大检查时钟树配小数分频FBRRDMA从不触发接收回调未打开串口全局中断在CubeMX的NVIC里打开USARTx全局中断低功耗唤醒后DMA无法恢复DMA通道在低功耗下挂起唤醒后重新调用HAL_UARTEx_ReceiveToIdle_DMA5. 最后的实操心得如果只让我说一句那就是STM32H743上跑UARTDMA不是“进阶选项”而是“默认选项”。从逐字节中断改成DMA不只是把CPU占用率降下来那么简单而是让整个系统的实时性重新回到你的控制范围内——CPU不再被不知何时到来的串口数据牵着鼻子走主循环里的控制算法、屏幕刷新、网络协议栈才有稳定的执行周期。调试这种DMA链路时我的土办法是在每个关键节点打几个通用GPIO翻转点用逻辑分析仪看时间和时序比纯靠断点调试高效得多。H7的调试工具链已经很成熟但如果代码跑飞或进入了不该进的中断优先级里GPIO波形能一眼看出问题发生在DMA完成阶段还是UART移出阶段。这套“H743DMAUART”的组合换到SPI、ADC、甚至外部并行总线都是同一套思路。缓冲区管理、Cache一致性、DMA模式选择这些经验是一通百通的你把串口调通了再去看H7的SPI DMA接收、双ADC高速采样会发现都是熟悉的东西。最后再分享一个习惯帧缓冲区一定要做大一点不要卡着数据量申请。宁可浪费几百字节RAM也别在流量峰值时让DMA覆盖到内核正在处理的内存区域。本文还有配套的精品资源点击获取