简介: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全耗在进出中断上,主循环里的控制逻辑直接饿死。后来把串口接收彻底改成DMA,CPU占用率从接近40%掉到了2%以内,整机流畅度完全不是一个级别。
这篇内容我就拿这个实际项目当例子,把STM32H743上UART+DMA从配置到落地完整过一遍。适合正在做高速串口采集、打算把串口任务从CPU中断里解放出来、或者刚接触H7系列被DMA和Cache折磨过的朋友。核心围绕三个方向:H743的UART和DMA到底怎么协同工作、CubeMX和代码层面怎么配才能不翻车、以及实测中我踩过的坑和排查方法。就算你之前只用过标准库、对HAL不熟,按下面的思路走一遍也能跑起来。
1. 为什么是H743+DMA+UART这个组合
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支持小数分频这点是真方便,比如上面算的divider=2.5,H7的FBRR可以把整数部分和小数部分拆开写,不会出现F103那种只能取整导致误差跑到2%以上的尴尬。
2.2 DMA传输模式:Normal、Circular还是双缓冲
DMA模式选错,代码层面会绕很多弯路。这里直接把三种模式和适用场景列开。
普通模式(Normal):DMA搬运完设定的长度就停,适合一次性发送数据块。UART的TX方向我基本都用这个。
循环模式(Circular):DMA搬完一个周期后自动回到起始地址继续搬,适合持续接收不定长数据。RX方向做“环形收”非常合适——数据不断往缓冲区里写,CPU只在需要时来取。
双缓冲模式(Double Buffer):DMA可以在两个缓冲区之间交替写入,一个缓冲区写满后自动切到另一个,同时可以触发中断让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区域禁止Cache:
static 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用Normal,RX用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 接收实现:空闲中断加循环DMA
H7的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-Cache,DMA数据全乱
这个前面提过,是最典型的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/波特率误差偏大 | 检查时钟树,配小数分频FBRR |
| DMA从不触发接收回调 | 未打开串口全局中断 | 在CubeMX的NVIC里打开USARTx全局中断 |
| 低功耗唤醒后DMA无法恢复 | DMA通道在低功耗下挂起 | 唤醒后重新调用HAL_UARTEx_ReceiveToIdle_DMA |
5. 最后的实操心得
如果只让我说一句,那就是:STM32H743上跑UART,DMA不是“进阶选项”,而是“默认选项”。从逐字节中断改成DMA,不只是把CPU占用率降下来那么简单,而是让整个系统的实时性重新回到你的控制范围内——CPU不再被不知何时到来的串口数据牵着鼻子走,主循环里的控制算法、屏幕刷新、网络协议栈才有稳定的执行周期。
调试这种DMA链路时,我的土办法是在每个关键节点打几个通用GPIO翻转点,用逻辑分析仪看时间和时序,比纯靠断点调试高效得多。H7的调试工具链已经很成熟,但如果代码跑飞或进入了不该进的中断优先级里,GPIO波形能一眼看出问题发生在DMA完成阶段还是UART移出阶段。
这套“H743+DMA+UART”的组合,换到SPI、ADC、甚至外部并行总线都是同一套思路。缓冲区管理、Cache一致性、DMA模式选择这些经验是一通百通的,你把串口调通了,再去看H7的SPI DMA接收、双ADC高速采样,会发现都是熟悉的东西。最后再分享一个习惯:帧缓冲区一定要做大一点,不要卡着数据量申请。宁可浪费几百字节RAM,也别在流量峰值时让DMA覆盖到内核正在处理的内存区域。
本文还有配套的精品资源,点击获取