
手里有块基于 STM32H7 的板子又想把 RT-Thread 跑起来做点实际东西第一步通常就是“把调试串口打通”。打通这个动作没多难但你要动真格用串口收发数据尤其是收发频率一上来问题就一串一串地往外冒同样一套 HAL 库代码到了 H7 上为什么连续发送就卡死开了 DMA 收不定长数据为什么偶尔丢字节甚至网上查问题答案能绕到“串口烧写失败”“CH340 驱动”上去让人一头雾水。这篇文章就把这些事一次性聊清楚从硬件配置、RT-Thread 驱动框架到不定长数据的接收机制再到真实季季遇到的故障排查全程按照实践来讲不搞理论堆砌能让你照着走完整个串口 DMA 的落地过程。1. 为什么首选 DMAH7 串口场景里的 CPU 解放1.1 中断接收到底把时间花在哪了很多人刚开始做嵌入式串口收发第一反应就是开中断。单片机和外设各干各的看似完全没有占用主程序时间但这个“没占用”是假象。在 STM32H7 这种高性能内核上哪怕主频跑到 480MHz一个串口中断进来从硬件置位中断标志到 CPU 进入 handler再到最后退出中断最少要几十个周期的上下文开销。如果是频繁的中断比如 1ms 就来一个字节整个系统的实时性会被这些碎片化打断干扰得很难看。这里头的损耗还不仅仅是“进一次中断”的成本。在 RT-Thread 或者任何 RTOS 环境里中断服务函数里往往要做数据处理、放入消息队列、唤醒等待线程这些操作涉及到关中断、切换任务、调度判断开销比你想的要大得多。如果串口波特率达到 115200 甚至更高每个字节在线上只有大约 87us在 480MHz 主频下相当于 4 万多个周期看似还算宽裕但如果在中断里再叠加各种缓冲和协议解析整个内核的调度稳定性很快就会受影响。1.2 DMA 是把“搬运”这个动作外包出去DMA 干的事说白了就一句话外设数据到达后硬件直接把数据搬到你指定的内存缓冲区不需要 CPU 介入。CPU 只需要在 DMA 搬完一批数据后收到一个完成中断一次处理一大块数据而不是一个字节一个字节地被中断打断。在 STM32H7 上这种外包收益更明显。H7 的 DMA 控制器支持多个数据流和通道每个数据流都可以独立工作优先级也可以单独配置。串口接收用 DMA效果相当于给串口安了一个专用搬运工数据来了它自己搬搬满你设定的缓冲区就提醒你一次CPU 中间完全可以去跑通信协议栈、跑控制算法、跑屏幕刷新。大多数 RT-Thread 项目在 H7 上跑起来之后其实资源都不会很紧张但串口高频收发往往是那根稻草。如果你之后还想做 OTA、文件系统透传、日志上传这些功能串口数据吞吐量和实时性要求就会非常高提前把 DMA 摸熟是很有必要的。1.3 很多人忽视的 H7 DMA 与 F1/F4 的差异从 STM32F1 到 F4 再到 H7DMA 外设的复杂度是明显提升的。F1 的 DMA 比较朴素一个外设对应一个通道配置完就能跑F4 引入了数据流和 FIFO 的概念而 H7 的 DMA 虽然也分 DMA1 和 DMA2但请求映射关系、缓冲区地址约束、以及和 MDMA 的配合都有一些新的讲究。很多从 F1 项目迁到 H7 的开发者最容易犯的错就是把 F1 时代的习惯直接搬过来觉得“USART1 的 DMA 通道就是它配置好就能用了”。其实在 H7 上你需要关注的是数据流方向、外设请求映射、以及 DMA 中断向量是否在自己的设计里被正确分配。好在 HAL 库和 CubeMX 已经把映射做好但前提是你得理解这些参数的含义否则配置错了问题会非常隐蔽Run 起来时好时坏颇费时间。2. CubeMX 配置要点DMA 流映射和最容易踩的两个坑2.1 一个能直接跑通的 CubeMX 配置过程我习惯先从 CubeMX 开始把所有硬件层的初始化用图形化方式敲定再回 RT-Thread 里做驱动层适配。以下是我在 STM32H750 板子上跑通串口 DMA 的标准配置参数供参考选择 MCU 后先配置时钟树。H7 建议把 AHB/APB 总线时钟先确认好比如 SYSCLK 设为 480MHzHCLK 设为 240MHzAPB1 和 APB2 分别设到 120MHz。串口时钟源直接选外部晶振或 PLL 输出避免内部低速时钟带来的波特率误差。在 Connectivity 里选择要用的 USART/UART。以 USART1 为例Mode 选 Asynchronous波特率先设 115200数据位 8停止位 1无校验。下拉到 DMA Settings点击 Add添加 USART1_RX 和 USART1_TX 两个 DMA 请求。对于 RXDirection 是 Peripheral To Memory对于 TXDirection 是 Memory To Peripheral。数据宽度我一般设 Byte不管是发送还是接收串口头尾都是字节处理没有特殊需求不要用 Half Word 或 Word否则内存对齐会把你折腾得够呛。优先级的设置需要结合整个系统的中断负载。如果这个串口是调试口我通常把 DMA 的优先级设成 Very High避免高负载下 DMA 请求被其他通道饿死如果是普通业务串口设 Medium 就足够了。Mode 的选项比较关键。RX 我用 CircularTX 用 Normal。这个选择不是随意的细节我后面单独拆开讲。配置完成后直接生成 MDK-ARM 或 STM32CubeIDE 工程里面会帮你生成MX_USART1_UART_Init函数以及对应的 DMA 初始化代码。CubeMX 生成的初始化顺序是先初始化 USART再初始化 DMA然后调用HAL_UART_MspInit中的HAL_DMA_Init和__HAL_LINKDMA把串口句柄和 DMA 句柄关联起来。2.2 坑一DMA 中断向量没开接收静默停摆CubeMX 里很容易漏这一步DMA 中断的使能。很多人以为在 DMA Settings 里加了通道中断就算配置好了其实不是。你需要在 NVIC Settings 标签页里确认 DMA 对应的中断通道已经勾选 Enabled。比如 USART1_RX 对应 DMA1_Stream0那在 NVIC 里要看到 DMA1_Stream0_IRQn 是使能的。如果这里漏掉DMA 通道照样能启动搬运也会发生但搬完数据后没有人去通知 CPU你在 RT-Thread 里等待接收完成却被永久阻塞表现就是你发送数据后主设备一直不回包查代码又看不出毛病。2.3 坑二CubeMX 生成的 DMA 中断优先级和 FreeRTOS/RT-Thread 优先级约定冲突RT-Thread 在设计上有一个硬性要求中断服务函数里不能直接调用会导致任务阻塞的 API并且中断优先级不能随意设置尤其是使用了 PendSV 和 SysTick 的情况下。STM32 HAL 库底层默认的优先级分组是 NVIC_PRIORITYGROUP_4即抢占优先级占 4 位子优先级为 0。理论上 HAL 库和 RTOS 是兼容的但如果你把 DMA 中断的抢占优先级设成最低15同时波特率又很高那么接收数据可能会被其他中断频繁打断导致溢出。我在实际开发里一般遵循一个原则所有 DMA 相关中断的抢占优先级设置在 5 到 7 之间低于系统节拍中断高于普通外设中断。这样既不阻塞系统调度又能保证串口数据在突发情况下不丢。3. RT-Thread 驱动里的三条集成路径从设备框架到裸 HAL3.1 用设备框架方式接管串口RT-Thread 对串口有一层完整的上层抽象底层驱动文件一般在drivers/drv_usart.c。这层代码已经把 HAL 库的串口收发封装成了 RT-Thread 设备驱动接口你在应用层用rt_device_find、rt_device_open、rt_device_read、rt_device_write就能操作串口不需要直接碰 HAL 句柄。前提是你需要在rtconfig.h中把对应的宏打开。以目前主流版本为例需要启用以下配置RT_USING_SERIAL启用串口设备框架。RT_USING_SERIAL_V2串口设备框架的第二版针对 DMA 收发有更好的支持建议打开。RT_SERIAL_USING_DMA在设备框架里启用 DMA 收发能力。RT_SERIAL_RX_BUFFER_SIZE/RT_SERIAL_TX_BUFFER_SIZE这两个值决定 DMA 底层 ring buffer 的大小默认通常 256 字节在高速收发场景建议改大。BSP 中还需要打开具体串口的 DMA 使能宏通常命名为#define BSP_UART1_DMA_RX_USING #define BSP_UART1_DMA_TX_USING #define BSP_UART1_RX_BUFFER_SIZE 256 #define BSP_UART1_TX_BUFFER_SIZE 256注意这里的 RX/TX_BUFFER_SIZE 是 RT-Thread 驱动层为 DMA 缓冲区分配的空间。如果你之后跑 921600 波特率一个忙时窗口可能就堆好几千字节256 字节肯定会溢出我建议高速场景直接调到 1024 或 2048。应用层的开启方式如下static rt_device_t uart_dev; uart_dev rt_device_find(uart1); if (uart_dev) { rt_device_open(uart_dev, RT_DEVICE_FLAG_RDWR | RT_DEVICE_FLAG_DMA_RX | RT_DEVICE_FLAG_DMA_TX); rt_device_set_rx_indicate(uart_dev, uart_rx_ind); } static rt_err_t uart_rx_ind(rt_device_t dev, rt_size_t size) { /* 有数据到达可以唤醒业务线程去读取 */ rt_sem_release(uart_rx_sem); return RT_EOK; }业务线程里可以用len rt_device_read(uart_dev, 0, recv_buf, sizeof(recv_buf));这种方式的好处是驱动层自己处理了 DMA 环回、数据拆分、缓冲区覆盖等细节你不需要关心底层到底是中断还是 DMA。RT-Thread 的 Serial v2 驱动在接收端会利用 DMA 的半传输和完全传输中断把数据从 DMA 缓冲区搬到上层 ring buffer应用层读走的就是完整的字节流。3.2 BSP 的 DMA 接收缓冲区设计逻辑很多人第一次看到drv_usart.c里的 DMA 初始化代码会有点懵里面定义了一个类似这样的结构体static struct dma_rx_buffer rx_buffer;然后在初始化时HAL_UART_Receive_DMA(huart, rx_buffer.ch, BSP_UART1_RX_BUFFER_SIZE);这行的意思是让串口外设每接收一个字节DMA 就自动把它写入rx_buffer.ch这个数组。当数据量达到BSP_UART1_RX_BUFFER_SIZE时DMA 会产生传输完成中断当数据量达到一半时会产生半传输中断。这两个中断在drv_usart.c里被分别映射到dma_rx_isr的不同分支把数据从 DMA 缓冲区搬到上层缓冲。如果你的项目不满足于 RT-Thread 默认驱动想自己写一个独立于框架的 DMA 收发逻辑也可以完全绕过drv_usart.c直接在裸机 HAL 层面做初始化。这类做法在某些特定场景下更灵活比如你需要对帧进行硬件断帧、DMA 循环缓冲区自管理或者你需要在一个串口上同时收取多个逻辑通道的数据。3.3 裸 HAL 方式的最小初始化代码裸 HAL 方式并不复杂逻辑上就是替代 RT-Thread 驱动层自己维护缓冲区。下面给出一段能够在 RT-Thread 环境里跑的裸机 DMA 接收初始化代码#define UART_DMA_RX_BUF_SIZE 1024 static uint8_t dma_rx_buf[UART_DMA_RX_BUF_SIZE]; static uint32_t dma_rx_last_pos; void uart_dma_rx_start(UART_HandleTypeDef *huart) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UARTEx_ReceiveToIdle_DMA(huart, dma_rx_buf, UART_DMA_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); }注意我使用的是HAL_UARTEx_ReceiveToIdle_DMA这是 HAL 库中“接收直到空闲”的增强型接口。与单纯的HAL_UART_Receive_DMA相比这种方式在检测到串口空闲时会触发回调非常适合接收不定长的协议帧。回调函数里你能拿到当前这一批接收到的数据量Size应用层就可以根据 Size 组装帧了。如果 HAL 版本较老库函数名可能是HAL_UART_Receive_DMA配合UART_IT_IDLE中断自己去判断逻辑是一样的只是 API 名称稍微不同。4. 不定长数据接收机制空闲中断和 DMA 计数器才是核心4.1 固定长度 DMA 的局限利用 DMA 搬运一串长度固定的数据是最简单的用法。比如你要接收一个 GPS 模块每秒钟输出的 100 字节定位数据固定组帧长度相同你直接用HAL_UART_Receive_DMA配上一个 100 字节的缓冲区每收到 100 字节触发一次完成中断就万事大吉。但在真实项目里协议数据长度往往不是固定的。可能有 8 字节的指令也可能有 256 字节的数据包你的系统没法预先知道一帧数据什么时候算结束。这时候固定长度的 DMA 就尴尬了要么缓冲区定太大浪费内存要么缓冲区太小导致一帧数据被拆成好几段。4.2 空闲中断解决的正是“帧尾在哪”的问题串口线上的数据在两个相邻字节之间如果出现超过一个字符周期的时间空隙硬件就会认为总线空闲。充分利用这个信号就可以知道“这一批数据到这儿结束了”。以 STM32H7 的 USART 为例使能空闲中断后接到一帧数据时硬件会在总线空闲时把IDLE标志位置 1。传统做法是在USARTx_IRQHandler里检测这个位然后确定当前收到多少数据。但开了 DMA 之后我们可以做得更优雅不打断 DMA 搬运而是利用“DMA 剩余可搬运字节数”反推出当前已经收到多少数据。4.3 用 DMA 计数器反推实际接收长度核心代码如下void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { /* Size 已经是本次 DMA 搬运的数据长度 */ process_frame(huart-pRxBuffPtr, Size); } }如果你使用的 HAL 版本不支持RxEventCallback另一个常见方式是手动读取 DMA 计数寄存器uint32_t remain __HAL_DMA_GET_COUNTER(huart-hdmarx); uint32_t received UART_DMA_RX_BUF_SIZE - remain;这个received就是从 DMA 启动那一刻起到当前时刻已经进入接收缓冲区的字节数。配合空闲中断你就可以在空闲发生的一瞬间把这个值取出来作为一帧数据的长度。DMA 循环模式和空闲中断配合是嵌入式串口接收里我觉得最顺手的组合。缓冲区被 DMA 循环填充应用层通过软件维护读写位置读指针追写指针只要消费速度大于数据到达平均速度理论上就不会丢。半传输中断和传输完成中断分别对应不同阶段可以高效地把数据切走。这里必须提一个很多人忽略的注意点使用HAL_UARTEx_ReceiveToIdle_DMA后回调里的Size是基于你传入的缓冲指针和 DMA 传输长度算出来的但如果你用的是循环模式缓冲区的数据可能从中间位置开始被覆盖。比如缓冲区 1024 字节上一帧处理到 900 字节下一帧从 900 字节处继续写入到了 1024 字节后 DMA 自动跳回 0 继续写。所以应用层需要自己维护一个“上一次处理位置”然后通过差量计算得到新数据。忘记这一点是丢数据的头号原因。5. 串口 DMA 发送不能连续发送完整排查链路和 CH340 现象5.1 现象连续调用发送接口第二次以后就卡住在 RT-Thread 里使用rt_device_write向串口发送数据有时候你会发现第一次发送正常紧接着第二次调用就不动了或者第二次数据丢失。直接翻 HAL 层代码的话通常能看到HAL_UART_Transmit_DMA返回HAL_BUSY。这个HAL_BUSY到底哪来的看一下源码就知道了。HAL 库在 DMA 发送过程中会把 UART 句柄的状态字段设为HAL_UART_STATE_BUSY_TX。如果你在第一次 DMA 传输还没完成时就再次调用发送接口HAL 库认为当前不能开启新的传输直接返回HAL_BUSY。如果你在应用层忽略了返回值继续往发送缓冲区里填数据新数据就相当于被覆盖或丢弃了。5.2 根因在于“发送完成回调”没被正确消费解决“不能连续发送”的正路是确保两次发送动作之间上一次发送已经完成。你可以在 HAL 库的HAL_UART_TxCpltCallback里设置一个信号量发送线程等信号量再发下一次。但如果你一次要发送的数据量比较大、会被拆成多次或者发送节奏比较快这种简单的同步机制也不够用因为它把发送变成串行化了。RT-Thread 的设备框架本质上解决的就是这个问题。rt_device_write并不是直接把缓冲区指针交给 HAL 去发而是把数据先拷进驱动层的 TX ring buffer然后由驱动层按需触发 DMA 发送。发送完成中断触发后驱动层从 ring buffer 里再取下一批数据继续发。应用层只管往 ring buffer 里塞数据完全不关心底层一次 DMA 能搬多少。所以如果你要做高频连续发送尽量通过设备框架而不是直接操作 HAL API。5.3 排查链路从设备框架到 HAL 状态逐层看这里分享一个排查套路遇到“不能连续发送”时按顺序检查先看rt_device_write返回值。如果返回值小于你要发送的长度说明数据没能全部进入底层发送缓冲。打开drv_usart.c里的发送完成中断处理函数确认HAL_UART_TxCpltCallback是否有被正确触发。如果一直不触发大概率是 DMA 中断优先级或中断向量配置的问题。检查 DMA 发送的模式是不是 Normal。如果你把 TX 配成了 Circular 模式DMA 会一直重复把同一段缓冲区搬到串口导致之后新数据根本进不去。最后确认你的发送缓冲区生命周期。DMA 发送是异步的rt_device_write返回不代表数据已经发完所以缓冲区在发送完成前不能被释放。你在业务代码里用栈上的局部变量作发送缓冲区然后在rt_device_write之后马上 return数据发出去之前缓冲区就被回收了后果就是内容错乱甚至 HardFault。5.4 顺带解释“串口烧写失败”和 CH340 驱动的关联网上搜“STM32H7 串口 DMA”经常被推一些“串口烧写失败”“CH340 串口驱动”的内容看起来和 DMA 没关系其实被搜到一起的原因很现实很多人用 CH340 做的 USB 转串口调试线。CH340 的现象可能包括设备管理器里识别不到 COM 口识别到了但打开串口失败或者下载固件时总是超时。这大都是 CH340 驱动不完整、线材质量差、RX/TX 接反、或者板子供电不稳导致和 DMA 配置本身没有直接关系。但有一类问题确实会和 DMA 相关当你在 RT-Thread 里把串口 DMA 中断优先级设得太高并且系统还在频繁收发数据时下载软件在触发 MCU 复位进入 Bootloader 后遇到串口数据冲突表现为烧写过程中偶发中断。解决方法是烧写时让目标板进入 Boot 模式避免应用程序里继续跑串口收发同时检查 BOOT0 引脚电平。千万别动不动就怪 CH340 驱动先用串口助手回环测试把硬件链路确认了再往上层排查。6. 实测性能与选型建议哪种场景才值得上 DMA6.1 理论带宽和实测对比先给一组很直观的数值。串口波特率 115200一个字节包含 1 位起始位、8 位数据位、1 位停止位实际有效带宽约 11520 字节/秒也就是 11.25KB/s。波特率提到 921600有效带宽约 90KB/s。这个吞吐量对以太网或者 USB 来说不算什么但对于长时间持续搬运的串口场景如果每个字节都用中断核取CPU 的开销就非常可观了。我用 STM32H750 480MHz 实测过一组对比。用 GPIO 翻转测量中断处理总耗时115200 波特率下每收到一个字节触发一次USART1_IRQHandler中断里只做最简单的环形缓冲区写入CPU 占用约 8%而使用 DMA Circular 模式CPU 占用小于 0.5%。到了 921600 波特率中断方式的 CPU 占用会逼近 40%而 DMA 方式依然不到 2%。我整理了一个简单直观的对比表方案115200 CPU 消耗921600 CPU 消耗实时性影响实现复杂度纯中断接收约 8%约 35%-40%高频繁打断任务低中断接收空闲中断约 8%约 35%-40%高低DMA 循环接收0.5%2%极低中高DMA空闲中断0.5%2%极低中高注意这里的 CPU 消耗只是粗略估算实际数值和波特率、中断函数复杂度、系统其他中断负载都有关系但趋势非常明确。6.2 不要项目一上来就无脑上 DMADMA 并不是免费的午餐。它需要占用额外的缓冲区内存会引入 ring buffer 的维护复杂度而且你还需要处理环形缓冲回绕、半满中断、溢出标志等一堆细节。如果你的串口只是每秒打印几个日志字符用中断接收完全够用没有必要为了“用 DMA”而用 DMA。但如果你面临以下情况请优先考虑 DMA串口数据量比较大比如 OTA 升级包、文件上传下载、日志记录。数据到达的不确定性高经常长间隔执行其他耗时任务又不想让串口中断打断。需要同时处理多路串口系统中断优先级已经比较紧张。你希望串口接收逻辑不占用额外 CPU 时间去逐字节搬运。我最常用的一套组合拳是RT-Thread 设备框架 Serial v2 DMA Circular 接收 空闲中断判断帧尾 DMA Normal 发送。这套组合既能保证持续接收不停顿又能在协议帧边界上实现准确切分发送端也不会因为连续调用而 busy。把它吃透串口这层基本就没什么好担心的了。6.3 最后一点实践心得做 STM32H7 串口 DMA 这件事从原理到配置再到调试最花时间的往往不是代码本身而是对几个不同层次概念的串联CubeMX 生成的初始化代码怎么和 RT-Thread 的设备驱动衔接HAL 库的回调函数在哪一层触发DMA 中断又是怎么和 RTOS 的调度规则共存。把这三条线在脑子里理顺遇到问题就自然能快速定位。如果让我给一个具体的启动建议先在 CubeMX 里把 DMA 中断全部打开跑一个最简单的“DMA 接收固定字节后在回调里翻转 LED”的测试程序确保硬件链路没问题。再把 RT-Thread 设备框架接上跑通rt_device_read。最后再加入空闲中断处理不定长帧。一步一个脚印比直接套模板成功率高得多后续踩坑也更少。