做串口通信做了快十年从最开始的51轮询到后来的STM32F1中断收发再到现在的STM32H563上跑DMA加中断踩过的坑能写一本书。这个标题STM32H563 use DMA for RX and IT for TX on a UART概括的这套方案其实是现在嵌入式串口处理里非常主流的一种设计思路接收用DMA扛住发送走中断把CPU从繁忙的字节搬运里解放出来。这篇文章就把这套方案的原理、配置、代码和坑全部讲透适合正在做串口数据采集、通信协议处理、或者设备间高速通信的朋友参考。我最早接触这个需求是在做一块数据采集板卡主控换成STM32H563串口要对接一个每秒稳定输出几千字节数据的传感器模块。刚开始用传统方式每收一个字节就进一次中断CPU占用率直接飙高稍微优化一下别的地方就出现丢数据。后来改成DMA接收配合空闲中断问题立刻缓解。这套方案的价值不在于省那么几十行代码而是从根本上改变了串口数据的处理模型。1. 为什么 RX 走 DMA、TX 走中断1.1 RX 用 DMA 的核心动机串口接收用DMA本质上是把数据搬运这件事从CPU手里交出去。传统接收模式下每个字节到达会触发一次接收中断CPU需要从数据寄存器里把值读出来存进缓冲区再做判断再继续等待下一个字节。如果数据量小、波特率低这种模式没什么问题。但一旦数据变成连续的、高速的、突发的CPU就会疲于奔命在中断服务函数里主循环的实时性被严重压缩。DMA接收的思路完全不一样外设的接收数据寄存器有数据时DMA控制器自动将数据搬到内存缓冲区整个过程不需要CPU参与。只有在某些关键节点比如一帧数据接收完成、缓冲区满了、或者空闲事件发生时才需要CPU来处理一次。这种模式特别适合一帧一帧的通信场景比如Modbus报文、GPS NMEA语句、自定义协议帧等。CPU只需要在收到完整一帧之后做解析而不是在接收每个字节的过程中参与搬运。STM32H563上的DMA模块比老款芯片强得多。它的DMA请求是通过DMAMUXDMA请求复用器来映射的外设和DMA通道之间的对应关系配置起来非常灵活。老款F1系列的DMA通道是固定的UART1的RX只能接DMA1的通道5万一通道冲突就只能干瞪眼。H5上用DMAMUX之后自由度大大提升哪个DMA控制器、哪个通道接什么外设请求可以灵活指定工程配置上少了很多限制。1.2 TX 用 IT 的理由和边界发送为什么用中断而不是DMA这得看应用场景。串口发送一般有两种典型情况一种是需要持续发送大量数据比如把采集的数据批量上报上位机另一种是偶尔发一帧指令或者应答。第二种场景占了大多数而且一帧数据通常只有几个字节到几十个字节。对这么小的数据量用DMA反而有点浪费——要初始化、要配置、要处理完成中断事务开销甚至比发送本身还大。用中断发送调用一个函数把第一字节丢进数据寄存器剩下的由发送空中断TXE逐字节触发发送发送完毕之后闭中断逻辑清晰、CPU占用低而且不会阻塞主循环。TXDMA在频繁小数据量发送上还有个隐患需要频繁维护DMA缓冲区的计数和指针很容易出错。如果数据量很大比如一次发几百上千字节那DMA发送的确更合适性能上能把中断发送远远甩在后面。这两者的边界要分清楚小帧、低频用中断大帧、高频、连续用DMA。1.3 这套方案解决的实际问题用DMA接收加中断发送这种非对称组合解决的是串口通信里最实际的两个痛点接收不能丢数据发送不能卡死主逻辑。接收侧用DMA最大的收益是数据无感到达。CPU可能在别的地方忙处理显示、跑控制算法、读写Flash数据已经在后台安安静静地被搬进缓冲区。对于STM32H563这种主频250MHz的芯片CPU空闲下来可以做更多复杂运算而不是被串口字节打断几百次。发送侧用中断保证调用发送函数的时候不会像阻塞发送那样死等每个字节发完实时性更好。这套方案在很多产品里已经成了标配。2. STM32H563 平台特性与工程准备2.1 先搞清 H563 和 F1/F4 的差异网上关于STM32串口DMA的教程十个有八个还在用F103打底照搬到H563上十有八九要出问题。H563是Cortex-M33内核主频可以到250MHz外设总线架构和F1这种老平台差了很多。最需要注意的是DMA这块。在H5上你看到的不再是DMA1_Channel4这样的固定通道而是Request Mapping Stream的概念。首先要通过DMAMUX把UART的接收请求映射到指定的DMA Stream上然后再配置DMA Stream的通道参数。CubeMX生成的代码是分两层初始化的MX_DMAMUX_Init()和MX_DMA_Init()中间还有请求映射的配置。很多从F1转过来的朋友第一步就栽在这照着旧代码直接把DMA初始化一改没有配置DMAMUX结果DMA光初始化不干活。还有一点就是中断入口名称。F1系列叫USART1_IRQHandlerH5的HAL库里名字类似但细节不同而且HAL库在处理串口中断时会综合判断各种标志位接收、发送、空闲、错误等在同一个中断入口里走不同的回调函数。如果不习惯这套机制很容易出现数据到了但回调没触发的现象。2.2 CubeMX 配置串口、DMA、中断三件套在STM32CubeMX里配置这套方案流程并不复杂但每一步都要点对。第一步配置串口。选择用哪个USART比如USART1设置波特率、数据位、停止位、校验位。H563的串口数量可观USART/UART加起来有八九个选个够用的就行。如果通信距离短直接用板载的USB转串口连接调试口就行。第二步配置DMA。在USART1的配置界面找到DMA Settings选项卡点击Add添加一个DMA请求。接收请求选择USART1_RX方向是PeripheralToMemory模式选Normal。这里要注意不要选Circular模式除非你确定要做循环缓冲。数据宽度Peripheral和Memory都设为Byte字节8位串口传输一个字节省一半内存带宽。优先级可以根据实际情况选Medium或High如果DMA通道紧张优先级设置得当可以避免传输延迟。第三步打开串口全局中断。在NVIC设置里使能USART1 global interrupt。这个中断是DMA接收完成回调、空闲中断回调和发送完成回调的地基如果没开后面所有基于HAL的串口事件回调都不会被调用。用CubeMX生成代码之后进入main.c和usart.c检查确认配置项是否和预期一致尤其是DMA请求映射。H5的UART接收DMA要确认请求号正确否则会导致接收数据无法到达内存缓冲区。2.3 时钟与引脚规划的建议H563的系统时钟可以跑到250MHz但在配置时钟树时需要留意APB外设时钟与UART波特率的匹配关系。UART波特率计算是PCLK / (16 * USARTDIV)如果APB时钟配置得比较随意波特率误差可能超限导致通信不稳定。最好是让USART工作在从APB时钟分频得到的合适频率上比如APB1或APB2下设置一个不太高也不太低的时钟源。引脚规划上注意一个串口号通常有多个可选引脚映射比如USART1的TX/RX有几组不同的PIN组合需要确认和板上的实际线路一致。尤其在H5这种引脚复用功能极多的芯片上一个引脚可能同时是USART、SPI、定时器、ADC通道配置错了编译能过、运行就是没输出。建议在CubeMX的Pinout面板里对照原理图仔细检查USART1的RX和TX分别映射到了哪个引脚确保没有和调试器SWD引脚或其他外设冲突。还有一个小建议调试期间如果调试口和USART复用同一组引脚要么关掉调试口的复用要么换一组引脚。曾因为串口引脚和SWD调试脚冲突导致程序烧录后根本没办法在线调试排查了半天才发现是引脚互斥的问题。3. 核心代码实现3.1 接收侧IDLE DMA 不定长帧处理接收侧的核心是IDLE空闲中断加DMA这种组合能实现不定长帧接收。思路是把DMA配成Normal模式接收缓冲区设为一个较大的数组比如256字节启动DMA接收然后开启UART的空闲中断。当一帧数据到达且短暂的停顿出现时两个字节间隔大于一个字节时间硬件会判定空闲UART会产生空闲中断在空闲中断回调函数里通过DMA剩余计数器的差值计算出本次实际收到的字节数。H563的HAL库提供了一套封好的接口省了很多麻烦。启动接收的函数是HAL_UARTEx_ReceiveToIdle_DMA它把空闲中断和DMA接收绑在一起处理。回调函数是HAL_UARTEx_RxEventCallback在数据帧接收完成空闲到来时被调用函数参数里直接给出了本次接收到的数据长度。下面是典型的初始化和回调代码#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; // 初始化完成后调用这个启动接收 void uart_start_rx_dma(UART_HandleTypeDef *huart) { HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); }在HAL_UARTEx_RxEventCallback里处理完整帧void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // 收到一帧完整数据Size就是实际收到的字节数 rx_len Size; // 这里做协议帧解析、数据处理等业务逻辑 process_uart_frame(rx_buf, rx_len); // 处理完毕后重新启动接收 HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); } }这套代码的精髓在于HAL_UARTEx_ReceiveToIdle_DMA内部会把DMA和空闲中断一起使能收到数据期间DMA自动搬运最后一个字节之后发现空闲就进回调。业务逻辑只需要关心完整一帧到来这一个事件不需要关心每个字节。缓冲区满走到256字节的情况是正常业务的例外分支如果一帧数据确实会达到缓冲区上限需要在HAL_UART_RxCpltCallback里做同样处理把缓冲区数据立刻取走重新启动接收。3.2 发送侧中断发送与回调管理发送用中断调用的是HAL库的HAL_UART_Transmit_IT。这个函数接受缓冲区指针和长度它启动发送后立即返回不会阻塞主循环。HAL库内部在初始化状态机后逐个字节发送最后一个字节发送完毕触发完成中断之后进入发送完成回调。#define TX_BUF_SIZE 64 uint8_t tx_buf[TX_BUF_SIZE]; void uart_send_data(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len) { // 拷贝到发送缓冲区避免业务数据被修改 memcpy(tx_buf, data, len); // 启动中断发送 if (HAL_UART_Transmit_IT(huart, tx_buf, len) ! HAL_OK) { // 发送启动失败处理 } } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 一帧数据发送完成可以做后续处理比如切换方向等 } }有几个细节要说明。发送缓冲区要保证在发送完成之前不能被改写否则数据会错乱。HAL_UART_Transmit_IT内部状态机要求上一次传输完全结束后才能开始下一次传输否则会返回HAL_BUSY。处理方式有两种要么在调用前检查__HAL_UART_GET_FLAG(huart, UART_FLAG_TC)确认上帧发送完成要么做一个简单的发送队列把待发送的数据按顺序排队在TxCpltCallback中逐条取出发送。3.3 主循环里的数据流转在H563这种主频250MHz的平台上主循环的调度空间很大。串口接收DMA在后台干活CPU在主循环里可以处理其他任务比如按键扫描、状态机轮询、数据解析、控制算法等。数据流转逻辑简化成接收侧DMA把数据搬进rx_buf空闲中断通知主循环有完整帧到达。主循环在空闲时处理rx_buf中的数据按协议解析得到需要应答的数据后调用发送函数。发送中断逐字节发出主循环继续跑自己的逻辑。这种模型的优点是接收和发送之间没有强耦合主循环的每个循环周期都能在固定时间内完成CPU利用率非常稳定。实测在一个需要同时处理三路串口通信的H563工程里这套方案让三路串口全速接收每路115200波特率时CPU占用率都不到20%。4. 常见问题与排查实录4.1 接收数据错位或丢帧现象收到的数据帧开头几个字节是乱的或者偶尔少几个字节。排查步骤第一看DMA缓冲区大小是否够大。如果一帧数据可能超过缓冲区长度DMA会把新数据写到数组越界的位置表现就是数据错乱。第二看波特率精度。如果时钟树配置不当波特率偏差超过2%接收端在连续数据流中很容易丢位。建议用逻辑分析仪或者示波器抓一下TX端实际波形的波特率对比预期值。第三看是否有中断优先级竞争。如果UART中断被更高优先级的中断长时间屏蔽可能漏掉空闲中断导致一帧数据迟迟不进回调。这类问题最常见的根因是缓冲区分配太小。H563主频高、处理能力强有人把缓冲区设成32字节觉得小帧够用结果对面设备一开机就是64字节的握手报文自然出错。缓冲区宁可大一点256字节起步DMA搬运又不占用CPU浪费的是内存H563的RAM通常是几百K级别不用省这几个字节。4.2 回调重复启动 DMA 导致数据互相覆盖现象接收开始正常跑一会儿之后数据全乱或者缓冲区里的数据总是穿串。这个问题几乎都出在回调函数里重复调用了HAL_UARTEx_ReceiveToIdle_DMA。很多人在HAL_UARTEx_RxEventCallback里处理完数据后重新启动接收但忽略了在DMA传输完成回调HAL_UART_RxCpltCallback里也有一个启动逻辑。如果两个回调都对同一个串口调用启动接收就会出现接收缓冲区被两个并发接收任务交叉写入数据互相覆盖。解决办法是明确区分两个回调的职责HAL_UARTEx_RxEventCallback只处理空闲到来时的数据HAL_UART_RxCpltCallback只在缓冲区满到达设置的Size时处理。如果业务场景用不到缓冲区满的情况在HAL_UART_RxCpltCallback里就不要添加任何启动代码。启动接收的动作最好收敛到一处用一个专用的uart_start_rx_dma()函数来统一调用。4.3 TX 中断发送卡死或异常现象调用发送函数后没有任何数据发出或者只发出第一个字节就停了。优先检查HAL_UART_Transmit_IT的返回值。如果是HAL_BUSY说明上一次发送还没结束需要等待或者做队列。如果是HAL_ERROR大概率是传入的长度参数或者缓冲区指针有问题。再检查发送完成回调是否被正确处理。HAL库在发送最后一个字节完成后会关闭TXE中断如果代码里其他地方重新开启了TXE中断但没有正确的处理机制就会反复进入发送中断但数据寄存器里没有新数据导致死循环式的忙等。有个容易被忽视的坑发送缓冲区如果是栈上的局部数组函数返回后栈空间被复用数据就会被破坏。必须使用静态数组或者全局数组。4.4 代码模板速查把核心代码整理成一个简洁的模板方便直接参考// 串口中断服务函数 void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); } // DMA中断服务函数 void DMA1_Stream0_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_usart1_rx); } // 接收空闲回调 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { process_frame(uart_rx_buf, Size); uart_start_rx(huart1); // 重新启动接收 } } // DMA传输完成回调缓冲区满 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_frame(uart_rx_buf, RX_BUF_SIZE); uart_start_rx(huart1); // 重新启动接收 } } // 发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { // 处理发送完毕事件 }需要特别说明HAL_UARTEx_RxEventCallback和HAL_UART_RxCpltCallback在某些HAL库版本里可能同时存在编写时一定要确认事件来源避免重复处理同一批数据。实测中这套方案在STM32H563上跑得非常稳配合H5系列的外设资源很多场景下甚至不需要在中断服务函数里做复杂操作把数据搬进缓冲区就完事。最后再分享一个小技巧如果接收数据量非常大超过DMA缓冲区上限可以考虑把两个DMA缓冲区交替使用Ping-Pong缓冲一个缓冲区在处理数据时另一个在接收数据这样无论数据来多快、多长都不会丢字节。这个方案在H5上用DMAMUX实现起来非常顺手算是这套串口处理思路的进阶版本。