最近在调一块基于STM32H743的采集板,外接传感器通过串口上报数据,一帧几百字节,波特率115200。第一版偷懒用中断方式接收,结果每来一个字节就触发一次中断,等业务逻辑一多,CPU占用率肉眼可见地往上走,偶尔还丢包。后来把串口收发都改成DMA方式,接收配上STM32H7的空闲中断,跑在RT-Thread系统上,数据过来DMA直接搬到内存,应用线程用信号量唤醒处理,整个CPU负载一下子降下来。这篇就把RT-Thread加stm32h7串口DMA这条完整链路从CubeMX配置到代码实现写一遍,里面那些串口烧写失败、DMA发送不能连续、数据丢失之类的坑也一并整理,给后面搞H7项目的兄弟少走点弯路。
1. 为什么H7项目里建议直接用串口DMA
1.1 数据搬运不该浪费CPU
很多朋友一上来就把串口中断打开,来一个字节进一次中断,代码写起来确实简单:中断里拼缓冲区,拼够了扔给解析函数。但要知道,STM32H7是一颗主频能跑到480MHz的Cortex-M7,让它一趟趟去搬每个字节,太浪费了。DMA(Direct Memory Access)干的就是这件事,外设和内存之间的数据搬运完全由DMA控制器完成,搬完再给CPU发个通知,这期间CPU该干嘛干嘛,RT-Thread里其他线程还能继续跑。
这里有个很容易忽视的本质:串口波特率再高,也就是一个字节一个字节地流转,CPU如果负责搬运,每处理一个字节都要做压栈、出栈、判状态、写缓冲,指令开销远比想象大。尤其板上还有以太网、USB、文件系统、传感器解析这些任务时,中断频繁抢占,实时性一定受影响。
1.2 DMA方案和轮询、中断的对比
做串口传输,基本就三条路:轮询、中断、DMA。轮询最费CPU,纯裸机小工程可以临时顶一下,放到RT-Thread这种系统里基本不建议,接收线程死等会拖垮整个调度。中断方式应用最普遍,适合单字节触发或者低数据量,但是如果每字节都进中断,系统上下文切换的成本很高。DMA方式适合数据量大、频率高、帧长不固定的场景,它把搬运工作完全交给硬件,CPU只处理“一帧完整数据来了”这个事件。
| 方式 | CPU占用 | 实时性 | 适合场景 |
|---|---|---|---|
| 轮询 | 极高 | 差 | 临时调试、一次收发几个字节 |
| 中断 | 中 | 好 | 数据量小、帧长短 |
| DMA+空闲中断 | 低 | 好 | 不定长帧、大数据量、高速串口 |
如果你只用串口做日志输出,那中断完全够用。但如果是数据采集、AT指令交互、Modbus、GPS NMEA这类不定长协议帧,DMA加空闲中断可以说是最优解。
1.3 什么场景值得上DMA
结合我这边的情况,DMA方案比较典型的场景有几个:第一,串口数据速率高,比如921600甚至2Mbps,中断高频触发让CPU吃紧;第二,帧长几十上百字节,DMA一次搬完,效率远高于逐字节中断;第三,RT-Thread系统里有多个高优先级任务在跑,串口只是其中之一,不能让它干扰整个系统调度;第四,RS485通信,发送完后需要在DMA完成回调里精确控制方向引脚,这个用DMA天然合适。
2. CubeMX配置:串口和DMA的初始化细节
2.1 工程里的时钟和引脚准备
我用的是STM32H743VIT6,CubeMX建工程时,RCC选外部晶振HSE,时钟树按480MHz主频配置。H7的时钟树比F1/F4复杂,总线分出来好几个域,APB1和APB2各120MHz,需要注意串口时钟源是挂在哪条总线上。比如USART1挂在APB2,USART2/3挂在APB1,DMA传输本身不参与波特率计算,但DMA控制器位于D2域,和串口在同一个时钟域里,时钟配置不对,DMA外设映射可能出问题,所以还是建议先按标准480MHz主频模板生成。
引脚方面,USART1我用的PA9/PA10,也就是TX和RX,这个不复杂,AF模式由CubeMX自动配置,基本不用手改。需要注意H7的引脚功能复用比较多,CubeMX一般会自动分配AF编号,自己核验一眼就行。
2.2 RX用Circular,TX用Normal
进入USART1配置页,Mode选Asynchronous,波特率按实际需求填,我这里115200。重点在DMA Settings里,点Add后会出现USART1_RX和USART1_TX两个DMA请求,分别添加进去。RX的Mode强烈建议选Circular,也就是循环模式,DMA会不停地把接收到的数据写进环形缓冲区,配合后面说到的空闲中断,天然适合不定长接收。TX的Mode选Normal即可,发送完一次就停,下次发送前重新启动。
数据宽度都用Byte,对应串口字节流。Increment Address设置上,外设地址不增,内存地址增,这个是CubeMX自动处理的,不要手动去改。Priority建议RX设High或Very High,因为接收丢数据比发送延迟更致命,TX设Medium或者Low都行,毕竟发送失败顶多重传,接收丢帧可能造成协议整个错乱。
之前看过不少工程,RX和TX优先级都设成Very High,这在多路串口同时启用时容易造成DMA仲裁冲突,更合理的方式是给关键接收通道高优先级,发送通道低一点。H7的DMA1和DMA2内部有仲裁机制,多路一起跑时优先级分配不当会出现偶发延迟。
2.3 中断配置和NVIC优先级
DMA要配合串口空闲中断使用,所以USART1的Global interrupt必须勾上,否则IDLE中断进不来。DMA中断这里也要开,发送完成会依赖DMA传输完成中断上报给驱动。NVIC优先级建议设置成中间档次,比如4到7,不要设成0这样的最高优先级。原因很简单,RT-Thread在调度的时候有临界区保护,如果串口中断优先级太高,会频繁打断系统调度,反而影响整体实时性;设太低又怕数据丢失。
你可能会问,优先级到底多少合适。我的经验是,在RT-Thread环境下,外设中断优先级统一设在5-10之间,并且同类型外设保持一个梯度,保证中断不嵌套太深。H7的中断优先级用NVIC 4bit表示,数值越小优先级越高,具体分配没有绝对标准,只要保证它比SysTick低一档,比PendSV高一档就行。
2.4 生成代码后必须检查的几个点
CubeMX生成代码后,很多人直接丢进工程编译,结果跑起来各种诡异问题。我会重点检查三样东西。第一,huart1的DMA句柄是否正常关联,打开main.c能看到HAL_UART_MspInit里有一堆__HAL_LINKDMA,确认RX和TX各关联一个DMA句柄。第二,看stm32h7xx_hal_msp.c里DMA中断是否都开了,有时候CubeMX会漏生成。第三,也是H7最容易踩的坑:如果开启了D-Cache,DMA缓冲区的Cache一致性必须处理。细节后面单独说,这里先记住一点,DMA写内存和CPU读内存如果隔着一个Cache,读到的可能是旧数据。
3. RT-Thread侧:把串口DMA驱动真正点亮
3.1 RT-Thread Studio的方式
如果你用RT-Thread Studio建工程,流程非常顺。基于芯片选STM32H743系列创建项目后,工程里会自带H7的BSP和串口驱动。要启用DMA,直接双击工程里的RT-Thread Settings,在弹出的配置界面里找到硬件相关的Serial选项,把对应UART的RX和TX DMA勾上。
这个操作的实质是往rtconfig.h里写入几个宏,比如BSP_UART1_RX_USING_DMA和BSP_UART1_TX_USING_DMA,同时会依赖RT_USING_SERIAL_DMA这个总的开关。STudio里勾选后会自动裁剪,不用手动改头文件。重新构建后,可以打开编译产物里的rtconfig.h确认宏是否生效。
3.2 menuconfig方式
如果用的是ENV或者Linux命令行,那就是另一套流程。在BSP目录下执行scons --menuconfig,进入Hardware Drivers Config->On-chip Peripheral Drivers,找到UART相关配置,选中DMA选项。注意menuconfig的选项文字可能不直接叫DMA,而是类似“Enable UARTx DMA transmission”之类的描述,看仔细一点。
保存退出后,紧跟着执行scons编译。如果改了配置没生效,大概率是Kconfig的依赖没触发,可以在menuconfig里先退出再重新进入,确认选项状态保存上。
3.3 驱动源码里如何判断DMA生效
配置开关只是第一步,实际跑起来之前最好看下驱动源码。拿RT-Thread H7 BSP的drv_usart.c来说,里面会有大量以#ifdef BSP_UART1_RX_USING_DMA包起来的代码。打开文件搜一下这几个宏,就能确认当前工程是否真的把DMA接收逻辑编译进去了。
还要注意一点,RT-Thread新版驱动对H7的DMA接收实现,本质上是通过HAL库的HAL_UARTEx_RxEventCallback来上抛事件的。也就是说,串口DMA接收时,UART的空闲中断一触发,HAL库就会调用这个回调,RT-Thread驱动在这个回调里更新缓冲区状态并通知应用层。所以,如果你在应用里发现对应的回调没执行,先查中断标志是否配置正确,再查DMA配置是否被宏裁剪掉。
4. 接收链路的核心:DMA加空闲中断
4.1 为什么只有DMA还不够
很多新手有个误解,以为DMA打开了就能自动接收一帧完整数据。其实DMA只是个搬运工,它不知道什么时候算“一帧结束”。比如你的协议帧不定长,10到200字节都可能,DMA会源源不断地把字节搬进缓冲区,但永远不会告诉你“这一帧结束了”。如果用DMA传输完成中断,那只有当缓冲区装满时才触发,这也不符合不定长帧的需求。
这时候必须靠串口本身的一个硬件功能:空闲中断(IDLE)。UART在接收线上检测到持续一个字节时间的空闲后,会自动置起IDLE标志。这个时机,恰好就是“一帧数据发完了”的时刻。DMA负责把数据搬进内存,IDLE负责告诉你有完整一帧数据到达,两者配合才是完整的接收方案。
4.2 接收长度怎么算、环形缓冲回绕问题
开启Circular模式后,DMA会循环往缓冲区里写数据。应用层拿到IDLE中断,怎么知道这次来了多少字节?很简单,用这个公式:
uint16_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t received_len = RX_BUF_SIZE - remaining;因为DMA计数器是剩余待传输字节数,缓冲区总大小减去剩余值就是已接收的字节数。这是在H7上处理串口DMA接收最核心的一句代码,无论你是裸机还是RT-Thread,原理都一样。
但是环形缓冲有个坑:当数据刚好跨过缓冲区末尾,也就是DMA已经回绕了一圈,这时候received_len计算出来的是“当前写指针到缓冲区末尾”的那部分长度,而一帧数据的头部可能已经写到缓冲区开头去了。你可以连续判断两次received_len的大小关系,如果后一次比前一次小,说明发生了回绕,把数据头尾拼接处理。RT-Thread的驱动已经处理了这部分逻辑,应用层直接用rt_device_read拿数据即可,但如果你是自己写裸机程序,这个回绕就是必须处理的问题。
4.3 应用层如何用信号量拿到数据
RT-Thread的串口驱动封装得很好,应用层不用自己去处理DMA计数器和回绕。配置好DMA之后,我们在应用层注册一个接收完成回调rx_indicate,当一帧数据到达时,驱动会调用这个回调,我们在回调里释放信号量唤醒读线程。
static struct rt_semaphore rx_sem; static rt_size_t rx_len; static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { rx_len = size; rt_sem_release(&rx_sem); return RT_EOK; }然后在接收线程里等待信号量,信号量到位后调用rt_device_read把数据从驱动缓冲区读出来。这比中断里拼字节再丢进队列要清晰得多。需要注意,回调是在中断上下文里执行的,不能在里面做耗时操作,释放信号量就够了。
5. 发送链路的坑:DMA连续发送问题
5.1 发送DMA的基本流程和潜在风险
串口发送走DMA,流程很好理解:调用rt_device_write把数据交给驱动,驱动配置DMA寄存器启动搬运,搬完后DMA触发完成中断,驱动回调通知应用。这个流程本身不复杂,但很多人在实际使用中遇到一个非常典型的问题:rt_device_write调用一次没问题,连续调用两次,第二次的数据要么发不出去,要么直接覆盖第一次的数据。
这是因为DMA发送是异步的。第一次rt_device_write返回时,DMA可能还在搬运数据,你紧接着把下一个数据块丢给驱动,驱动看DMA正忙,要么返回错误,要么直接把新数据写到同一个缓冲里,覆盖了还没发完的旧数据。如果你用HAL库裸机编程,直接调用HAL_UART_Transmit_DMA,更会直接返回HAL_BUSY。
5.2 HAL库“DMA发送不能连续发送”的根源
回到热词里提到的那句“stm32串口hal库使用dma发送数据不能连续发送”,根源就在这里。HAL库里串口发送有一个状态机,在DMA发送过程中,huart->gState会被标记为HAL_UART_STATE_BUSY_TX,这时候再次调用HAL_UART_Transmit_DMA,函数会检查状态并返回HAL_BUSY。
解决思路不是去改HAL库状态,而是让业务层保证“上一次发送完成后再启动下一次发送”。裸机可以通过TX完成回调置个标志,或者发送前轮询__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC)。RT-Thread下则可以用completion机制,发送完成回调里唤醒,然后继续发下一包。
5.3 RT-Thread下用completion做发送同步
RT-Thread里提供了rt_completion,非常契合这个场景。每次发送前初始化completion,调用rt_device_write后阻塞等待completion,DMA发送完成回调里调用rt_completion_done唤醒。这样外部调用发送接口时,看起来就和同步发送一样,永远不会出现连续发送覆盖问题。
static struct rt_completion tx_done; static void uart_tx_done(rt_device_t dev, void *buffer) { rt_completion_done(&tx_done); }这里有个细节,rt_device_set_tx_complete注册的回调,是驱动在DMA发送完成后调用的,实际就是DMA传输完成中断的下半部分。如果你的串口只用来调试输出,可以用RT-Thread默认的rt_kprintf,但那是轮询方式,不走DMA,这点要区分清楚。
6. 完整实操:RT-Thread串口DMA收发示例
6.1 初始化与应用线程创建
讲完原理,直接给一份能跑的示例代码。假设USART1已配置好DMA,RT-Thread的DMA宏已开启,接下来就是纯应用层的事。初始化串口设备、创建信号量、配置回调、创建接收线程。
#include <rtthread.h> #include <rtdevice.h> #define UART_DEVICE_NAME "uart1" #define RX_BUF_SIZE 256 static rt_device_t serial_dev; static struct rt_semaphore rx_sem; static struct rt_completion tx_done; static rt_uint8_t rx_buf[RX_BUF_SIZE]; static rt_size_t rx_len; static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { rx_len = size; rt_sem_release(&rx_sem); return RT_EOK; } static void uart_tx_done(rt_device_t dev, void *buffer) { rt_completion_done(&tx_done); }初始化函数里,重点是把设备打开、注册回调、创建接收线程。
static int uart_dma_init(void) { rt_err_t ret; serial_dev = rt_device_find(UART_DEVICE_NAME); if (serial_dev == RT_NULL) { rt_kprintf("find %s failed\n", UART_DEVICE_NAME); return -RT_ERROR; } rt_sem_init(&rx_sem, "uart_rx_sem", 0, RT_IPC_FLAG_FIFO); rt_completion_init(&tx_done); ret = rt_device_open(serial_dev, RT_DEVICE_FLAG_RDWR); if (ret != RT_EOK) { rt_kprintf("open %s failed\n", UART_DEVICE_NAME); return ret; } rt_device_set_rx_indicate(serial_dev, uart_rx_ind); rt_device_set_tx_complete(serial_dev, uart_tx_done); rt_thread_t t = rt_thread_create("uart_reader", uart_reader_entry, RT_NULL, 2048, 15, 10); if (t != RT_NULL) { rt_thread_startup(t); } return RT_EOK; } INIT_APP_EXPORT(uart_dma_init);这里用INIT_APP_EXPORT,系统启动后会自动执行初始化。注意一点,如果BSP默认配置了DMA模式,rt_device_open用RT_DEVICE_FLAG_RDWR即可,驱动内部会根据编译宏走DMA接收路径。如果你遇到open失败,可以检查打开时传入的flag和驱动底层DMA初始化是否匹配,有些BSP版本要求显式传RT_DEVICE_FLAG_DMA_RX。
6.2 接收处理线程代码
接收线程的逻辑非常简单,等信号量,读数据,处理帧。
static void uart_reader_entry(void *param) { rt_size_t len; while (1) { rt_sem_take(&rx_sem, RT_WAITING_FOREVER); len = rt_device_read(serial_dev, 0, rx_buf, rx_len); if (len > 0) { /* 这里拿到一帧完整数据,交给协议解析处理 */ rt_kprintf("rx %d bytes\n", len); } } }有一点要和你说明白:rt_device_read读出的数据量不一定会等于刚才rx_indicate里上报的size,因为在你等待信号量的过程中,又可能有新数据进来。所以最好不要假设read返回的就是一帧完整数据,而是在实际业务里自行定义帧头帧尾或者超时判断。如果严格按帧协议来,建议把数据接到自己的帧解析缓冲区再逐步处理。
6.3 发送接口代码
发送接口我用completion来做同步阻塞发送,业务层直接调用这个函数即可保证稳定发送。
void uart_dma_send(const rt_uint8_t *data, rt_size_t len) { rt_completion_init(&tx_done); if (rt_device_write(serial_dev, 0, data, len) != len) { rt_kprintf("uart write failed\n"); return; } rt_completion_wait(&tx_done, RT_WAITING_FOREVER); }如果你做的是RS485通信,那发送完成回调里还需要再加上拉高或拉低DE引脚的逻辑,因为只有DMA真正把最后一位发出去了,才能切换方向。这个点用中断发送很难做的精确,用DMA完成回调就非常自然。
7. 常见问题与排查技巧实录
7.1 串口烧写失败,多半是这三个原因
串口烧写失败这个问题,热词里出现了,说明很多人卡在这。H7下载程序时连接不上,我经验里最常见的就三件事。第一,BOOT0引脚没有拉高,H7默认从Flash启动,你想要用串口下载必须让它从系统存储器启动,也就是BOOT0电平置高,下载完再拉低复位。第二,CH340驱动有问题,Win10/11系统有时自动装的CH340驱动不对劲,设备管理器里能看到串口但实际收发不通,去官方装一个驱动基本能解决。第三,USB转串口模块质量问题,CH340本身便宜但稳定性参差不齐,换CP2102或者FT232会有改善,下载波特率也可以降到115200或者更保守的57600。
还有一种容易被忽略的情况,板子用的是自动下载电路,DTR和RTS控制BOOT0和NRST时序,如果你的USB转串口模块不支持或者电平不匹配,也会导致连接超时。
7.2 接收丢数据或截帧
遇到接收丢数据,第一反应看缓冲区大小对不对。如果DMA缓冲区只有64字节,而你的协议帧动不动一百多字节,Circular模式下数据早就覆盖了,丢数据是必然的。第二反应看中断优先级,UART的优先级如果设得比RT-Thread调度临界区低太多,高负载下中断可能被延迟,导致IDLE中断响应不及时,DMA缓冲区被后续数据覆盖。第三,就是之前说的Cache一致性问题。
H7打开D-Cache后,CPU读DMA缓冲区时读到的可能是Cache里的旧内容。这是H7和F1/F4最大的区别。解决办法有两个,要么把DMA缓冲区所在内存区域配置成non-cacheable,要么在读取前调用SCB_InvalidateDCache_by_Addr强行无效化D-Cache。如果你使用的是RT-Thread BSP默认配置,尤其要注意它默认有没有开D-Cache,开了就按这个思路去处理。
7.3 DMA发送卡死、线程阻塞
如果发送函数卡在rt_completion_wait里不出来,大概率是发送完成回调没触发。可能原因:DMA中断没开,或者驱动里对应的DMA中断服务函数没映射。H7的DMA中断函数比较多,DMA1_Stream0_IRQHandler、DMA1_Stream1_IRQHandler这样的,在BSP里有些需要手动映射到HAL库的HAL_DMA_IRQHandler。如果已经用了RT-Thread驱动,认真确认中断向量表里DMA中断有定义。
另外,别把rt_completion_wait的等待时间设成RT_WAITING_FOREVER去等一个永远不会来的回调,调试阶段建议加个超时,比如rt_completion_wait(&tx_done, rt_tick_from_millisecond(100)),超时后打印错误,定位问题会快很多。
我见过一个案例,发送回调逻辑里直接调用了rt_kprintf,结果发出来的数据变成了死循环输出,因为rt_kprintf本身可能是轮询发送,在DMA完成中断里调用导致中断被打断,各种怪问题。原则就是,中断里的回调越短越好。
7.4 数据乱码和时钟配置
串口打印出来乱码,十有八九是波特率对不上。H7的时钟树复杂度高,你在CubeMX里改了PLL分频,但忘了看UART的时钟源,波特率偏差就会比较大。尤其有些工程改完时钟后,HAL_UART_Init里的UART_CLOCK参数没有同步更新,HAL库拿到的波特率分频系数计算出来是错的。
排查办法很简单,发一串连续递增的十六进制数,比如01 02 03 04 05 06,用逻辑分析仪抓UART线上的波形,量一下实际波特率。逻辑分析仪能直接识别出实际波特率,然后回过来改CubeMX配置,重新生成代码。
7.5 快速排查表
| 问题 | 现象 | 主要原因 | 处理方式 |
|---|---|---|---|
| 串口烧写失败 | 连接超时、无法识别COM | BOOT0未拉高、CH340驱动、波特率过高 | BOOT0拉高再复位;换官方驱动;下载波特率降到115200 |
| 接收丢数据 | 偶发字节丢失、截帧 | 缓冲区过小、回绕未处理、Cache一致性 | 加大缓冲;用Circular+IDLE;Invalidate DCache |
| 发送卡死 | 线程阻塞在completion | DMA中断未打开、回调未触发 | 检查DMA中断映射;给wait加超时 |
| 乱码 | 数据内容错乱 | 时钟源配置错误、波特率偏差 | 核对CubeMX时钟树;用逻辑分析仪测实际波特率 |
| 数据不出来 | 完全无收发 | 串口设备未open、DMA宏未开 | 检查rtconfig.h宏;确认rt_device_open返回值 |
最后再分享一个我实际项目里的体会:不要一上来就想着把所有东西都塞进DMA。串口真的是个慢外设,如果你只是打个日志、调试一下,普通中断方式完全够用。但一旦你确认了自己的场景确实需要DMA,那就把链路从CubeMX到RT-Thread的配置一次性理清楚。H7这颗芯片性能强、资源多,但坑也多,尤其是Cache和DMA的一致性,记好这一点能帮你省下至少一半的调试时间。建议第一次做H7串口DMA时,哪怕CubeMX已经生成了DMA配置,也去翻一下H7参考手册的DMA请求映射表,对照自己用的串口确认DMA通道有没有选错,这个习惯非常值得养成。