
简介STM32Cube 双串口 DMA 互透传工程面向嵌入式开发初学者、电子竞赛选手与需要提升串口吞吐能力的工程师聚焦 UART1 与 UART2 之间基于 DMA 的双向数据转发解决传统轮询或中断方式在连续、不定长数据收发时频繁占用 CPU、处理效率偏低的问题。压缩包共 143 个文件、约 3.62MB包含 20 个 C 源文件与 42 个 H 头文件另有 CubeMX 的 .ioc 配置、Keil 的 .uvprojx 工程以及编译生成的 .o、.d、.crf、.axf、.map 等中间文件和输出文件便于直接打开对照外设配置与构建结果。已有 1872 人学习下载。工程涵盖串口与 DMA 初始化、DMA 收发启动、传输完成中断处理及串口错误回调等关键环节可作为双串口透传、数据转发和 HAL 库 DMA 调试的参考模板帮助读者理解配置流程、验证方法与常见排错思路也适合用于学习串口参数设置、DMA 通道分配和中断优先级安排。1. 窄带与宽带两个串口互拷为什么我最后没敢用 memcpy 轮询前阵子接手一个采集盒子的固件两头都只能靠串口对话一边是 115200 的老传感器另一边是 921600 的通信模组中间那颗 STM32 干的活说白了就一句话——左边进来什么右边原样吐出去反向也一样。听上去是memcpy就能糊弄过去的事真上手才发现坑不少轮询会把主循环固定死在等字节上而两边波特率差八倍一旦发送侧跟不上丢包丢得毫无征兆。这套基于 STM32Cube 的双串口 DMA 互透传工程解决的就是这件事两路 USART 各自挂 DMA用空闲中断做变长分帧中间加一层软 FIFO 做速率解耦。它适合手里已经有 CubeMX 骨架、想让两路串口各跑各的又不互相拖累的开发者也适合刚学完 HAL 库、第一次给串口接 DMA 的同学拿来对着改。2. 技术选型与 CubeMX 配置双串口 DMA 的时钟、中断与模式怎么点2.1 先把吞吐算清楚再决定缓冲区开多大串口透传最容易被忽略的一步是算账。8N1 格式下每字节 10 bit115200 对应大约 11.5 KB/s921600 对应大约 92 KB/s。这意味着从慢口往快口转永远是“喂不饱”从快口往慢口转才是真正会出事的方向——92 KB/s 的数据涌进来出口只有 11.5 KB/s差出来的部分必须有地方待着否则只能丢。我一般是这么定尺寸的接收缓冲区RX_BUF_SZ只跟单帧最大长度有关取 256 够应付绝大多数传感器帧发送 FIFOTX_FIFO_SZ则跟“两边速率差 × 允许的阻塞时间”有关快转慢的场景建议 1024 起步2 的幂方便取模编译成按位与。真要长时间跑满 8 倍速率差任何有限 FIFO 都会溢出这时候要么加硬件流控要么在上层做丢帧策略——这一点后面避坑章节会展开。轮询方式的问题不在于慢而在于它把 CPU 绑在了字节上。115200 下一个字节间隔 87 μs主循环里稍微有个延时或者 Flash 操作就可能错过 RXNE。DMA 把“搬字节”这件事交给硬件CPU 只在“一帧结束”的时候被打断一次中断次数从每字节一次降到每帧一次这个差别在 921600 下非常明显。2.2 CubeMX 里必须点开的几项配置新建工程后时钟树先配到芯片能跑的最高主频然后按下面这张表点。这里有个细节两路串口的波特率不需要相等透传本来就允许两端异步。配置项USART1USART2说明ModeAsynchronousAsynchronous8N1无硬件流控Baud Rate115200921600两端独立互不影响DMA RXUSART1_RX / CircularUSART2_RX / Circular优先级 Low内存自增DMA TXUSART1_TX / NormalUSART2_TX / Normal每次发送重新装填NVICUSART1 global IRQUSART2 global IRQ抢占优先级 1NVICDMA 对应 Stream IRQDMA 对应 Stream IRQ抢占优先级 2必须比串口低最后一行是我见过最容易翻车的地方。串口中断里会去调HAL_UART_Transmit_DMA如果 DMA 流中断的抢占优先级比串口还高就会出现串口中断正改着 HAL 状态机、DMA 中断插进来又改一遍的情况表现为偶发的数据错位甚至卡死。数字越小优先级越高所以 DMA 的数值要大于等于串口。另外CubeMX 生成的main.c里初始化顺序是有讲究的/* main.c 里的调用顺序不能调换 */ MX_GPIO_Init(); MX_DMA_Init(); /* 必须先开 DMA 时钟再初始化用到 DMA 的外设 */ MX_USART1_UART_Init(); MX_USART2_UART_Init();MX_DMA_Init()里做的是__HAL_RCC_DMA1_CLK_ENABLE()和 NVIC 配置。如果手工挪过顺序或者把串口初始化提到前面HAL_UART_Init里挂 DMA 时会因为 DMA 控制器时钟没开而直接返回错误表现是串口能收但永远不进 DMA 回调。这个坑不报错、不打印只表现为不工作排查起来相当费时间。2.3 Normal 还是 Circular取决于你要不要留一条应对后续问题的路接收侧我强烈建议在 CubeMX 里直接选 Circular。Normal 模式下 DMA 搬满Size个字节就自己停了如果你的帧长恰好等于缓冲区长度下一次传输不会自动开始得靠中断重新Start。而变长帧场景下帧长本来就飘忽Normal 模式会让“什么时候该重挂”这件事变得很难判断。发送侧反而要用 Normal。原因很直接发送是“我说了算”的动作每次HAL_UART_Transmit_DMA传一段确定的长度传完触发TxCpltCallback语义清晰。如果用 CircularDMA 会在缓冲区里无限绕圈发同一段数据除非你精确地在某个时刻去HAL_DMA_Abort那个时序极难掐准通常会造成尾部多发一截重复数据。提示部分 HAL 版本在UART_Start_Receive_DMA内部会重新设置 DMA 模式导致你在 CubeMX 里选 Normal 也不一定生效。所以判断依据不要看图形界面去看stm32f4xx_hal_uart.c里那个函数实际写了什么或者干脆按 Circular 的心理预期去写代码任何时候都假设它可能环绕。3. 工程结构拆解一个字节从 USART1 进、从 USART2 出的完整链路3.1 生成代码与应用代码的边界STM32Cube 生成的工程有个特点所有用户代码都塞在USER CODE BEGIN/USER CODE END之间重新生成不会丢。但真正撑起业务的部分不该待在这些缝里我的习惯是单独开一对app_uart.c/h生成代码只负责“把外设配起来”。Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── usart.h │ │ └── app_uart.h # 应用层接口透传逻辑全在这里 │ └── Src/ │ ├── main.c # 只在 USER CODE 区调 app_uart_init() │ ├── stm32f4xx_it.c # 中断入口CubeMX 生成不用改 │ ├── usart.c # CubeMX 生成的 MX_USARTx_UART_Init │ └── app_uart.c # 端口对象、软 FIFO、回调实现 ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ └── Project.ioc # 配置源头改配置只改这个这样拆的好处是重生成代码时不用提心吊胆。改波特率、加一路串口、换芯片型号都只在 CubeMX 里动app_uart.c里只依赖huart1/huart2两个句柄几乎不用碰。反过来说如果透传逻辑写进了usart.c的USER CODE区下一次点“Generate Code”之后你得逐段肉眼比对有没有被覆盖那种感觉很不保险。app_uart.h里只暴露两个函数一个初始化、一个转发。真正的实现细节——FIFO 指针怎么绕、回调里怎么重挂 DMA——全部藏在.c文件里。这个边界划清楚之后后面要在中间插协议解析层只需要改port_forward的实现上层调用方完全无感。3.2 数据链路DMA → 空闲中断 → 软 FIFO → DMA整条链路是四段每段解决一个不同的问题缺一段都会出毛病。第一段是 DMA 硬件搬运。串口每收到一个字节DMA 控制器直接从DR寄存器读到内存缓冲区CPU 完全不参与。这一步解决的是“高频小数据搬运”的效率问题。第二段是空闲中断分帧。串口总线上两个字节之间只要有超过一个字符时间的静默硬件就会置位 IDLE 标志。对变长帧协议来说这个“静默”天然就是帧边界比任何自定义的帧头帧尾判断都可靠。HAL 库把它封装成了HAL_UARTEx_ReceiveToIdle_DMA回调里直接给你“这一帧收了多少字节”省掉了自己读SR再读DR清标志的那套操作。第三段是软 FIFO。这是很多人会跳过的一步也是最容易出问题的一步。原因在于HAL_UART_Transmit_DMA是非阻塞的但它同时只能有一个传输在跑。如果上一帧还在发下一帧就到了这个函数会返回HAL_BUSY数据直接丢掉。921600 下一帧 100 字节只要 1 ms 左右就发完而快转慢的场景里出口发这 100 字节要 8 ms中间必然有新的帧进来。软 FIFO 就是在这两个速率之间做缓冲。第四段是发送 DMA。FIFO 里有数据、且当前没有传输在跑的时候取一段连续内存交给 DMA传完在TxCpltCallback里推进尾指针、再取下一段。3.3 中断优先级与临界区这套结构里有个隐含前提FIFO 的头指针只在一个上下文里写尾指针只在另一个上下文里写。我的做法是把所有 FIFO 操作都放进中断上下文——RxEventCallback是中断TxCpltCallback也是中断两者不会互相打断同一个串口的 RX 和 TX 各自独立但都通过同一个 FIFO 结构体访问。即便如此还是要小心嵌套。如果 USART1 的回调正在写 port2 的 FIFO此时 USART2 的中断优先级相同或更高插进来也去写 port2 的 FIFO头指针就会错乱。解决办法很简单把两路串口中断设成相同的抢占优先级Cortex-M 内核默认不允许同优先级中断互相抢占天然互斥。如果因为别的原因必须设成不同优先级那就在fifo_put外面套一层__disable_irq()/__enable_irq()代价是关中断时间变长注意别在里面做耗时操作。4. 核心模块拆解几个函数把双向透传跑起来4.1 端口对象与启动函数先定义一个结构体把一路串口需要的东西全装进去这样同一套代码对着两路串口各跑一遍不用写两份。/* app_uart.h */ #ifndef APP_UART_H #define APP_UART_H #include main.h #define RX_BUF_SZ 256 /* 单帧上限超过这个长度的数据会被硬件拆成多帧 */ #define TX_FIFO_SZ 1024 /* 必须是 2 的幂取模才能编译成按位与 */ typedef struct { UART_HandleTypeDef *huart; uint8_t rx_buf[RX_BUF_SZ]; /* DMA 直接写入不要放栈上 */ uint8_t tx_buf[TX_FIFO_SZ]; volatile uint16_t tx_head; /* 写侧推进只在接收回调里改 */ volatile uint16_t tx_tail; /* 读侧推进只在发送完成回调里改 */ volatile uint16_t tx_inflight; /* 当前这一笔 DMA 的长度 */ volatile uint8_t tx_busy; uint32_t drop_cnt; /* FIFO 满而丢弃的字节数调参时看这个 */ } uart_port_t; void app_uart_init(void); void port_forward(uart_port_t *dst, const uint8_t *data, uint16_t len); #endifvolatile加在tx_head/tx_tail/tx_inflight上不是形式主义。这几个变量在中断里改、在主循环或另一个中断里读不开优化时没问题一开-O2编译器就可能把它们缓存在寄存器里导致循环永远看不到变化——这类问题单步调试时完全正常全速跑就出问题非常难找。启动函数里做两件事关掉半满中断、挂上接收 DMA。void app_uart_init(void) { /* 关掉 DMA 半满中断我们只在 IDLE 时结算HT 中断纯属白跑一趟 */ __HAL_DMA_DISABLE_IT(huart1.hdmarx, DMA_IT_HT); __HAL_DMA_DISABLE_IT(huart2.hdmarx, DMA_IT_HT); HAL_UARTEx_ReceiveToIdle_DMA(huart1, port1.rx_buf, RX_BUF_SZ); HAL_UARTEx_ReceiveToIdle_DMA(huart2, port2.rx_buf, RX_BUF_SZ); }参数说明__HAL_DMA_DISABLE_IT的第一个参数是DMA_HandleTypeDef *直接从huart-hdmarx取第二个是中断类型。半满中断在“小包高频”的场景下会把中断次数翻倍而透传根本不需要知道“收到一半了”这件事关掉能省下可观的 CPU 时间。HAL_UARTEx_ReceiveToIdle_DMA第三个参数是缓冲区长度DMA 最多搬到这个长度就绕回。注意两个rx_buf必须是全局或静态存储放函数栈上会在 DMA 还在写的时候被回收。4.2 空闲事件回调取长度、转发、重挂 DMA这个回调是整个透传的核心所有分帧逻辑都在这里。void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { port_forward(port2, port1.rx_buf, Size); /* USART1 - USART2 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, port1.rx_buf, RX_BUF_SZ); } else if (huart-Instance USART2) { port_forward(port1, port2.rx_buf, Size); /* USART2 - USART1 */ HAL_UARTEx_ReceiveToIdle_DMA(huart2, port2.rx_buf, RX_BUF_SZ); } }Size是硬件算出来的“这一帧实际收了多少字节”从 1 到RX_BUF_SZ都可能。这里不做任何裁剪或拼接收到多少就转多少透传的语义就是原样搬运。末尾那次重挂不能省。HAL 的 ReceiveToIdle 在触发一次 IDLE 事件之后内部状态并不总是能自动续上——不同系列、不同 HAL 版本行为不一致。统一在回调最后重挂一次代价是一次函数调用换来的确定性很值。我见过有人只在初始化时挂一次结果就是“能收第一帧之后静默”现象极具迷惑性。4.3 发送软 FIFO 与 TxCplt 续传发送侧的关键是“永远不阻塞、也不丢”。写侧入队static uint16_t fifo_put(uart_port_t *p, const uint8_t *d, uint16_t len) { uint16_t n 0; while (n len) { uint16_t next (uint16_t)((p-tx_head 1) (TX_FIFO_SZ - 1)); if (next p-tx_tail) { /* 满了一圈只能丢 */ p-drop_cnt (len - n); break; } p-tx_buf[p-tx_head] d[n]; p-tx_head next; } return n; }环形队列留一个空位来区分“满”和“空”所以实际可用容量是TX_FIFO_SZ - 1。 (TX_FIFO_SZ - 1)这个写法依赖容量是 2 的幂编译器会直接生成一条 AND 指令如果改成% TX_FIFO_SZ在不支持硬件除法的 Cortex-M0 上会退化成软件除法耗时几十个周期在高频回调里很可观。泵函数负责把 FIFO 里的内容喂给 DMAstatic void fifo_pump(uart_port_t *p) { if (p-tx_busy || p-tx_head p-tx_tail) return; uint16_t len; if (p-tx_head p-tx_tail) { len p-tx_head - p-tx_tail; } else { len (uint16_t)(TX_FIFO_SZ - p-tx_tail); /* 先发到尾部回绕留给下一次 */ } p-tx_inflight len; p-tx_busy 1; if (HAL_UART_Transmit_DMA(p-huart, p-tx_buf[p-tx_tail], len) ! HAL_OK) { p-tx_busy 0; p-tx_inflight 0; } }回绕时只发到缓冲区尾部剩下那截等TxCpltCallback触发后再发。这样每次交给 DMA 的一定是一段连续内存HAL_UART_Transmit_DMA不需要关心环形结构。代价是多一次中断但正确处理了“头指针小于尾指针”这个绕回来的情况——不处理的话长度会算成一个巨大的值DMA 直接把整个缓冲区包括垃圾一起发出去。发送完成回调里推进尾指针然后立刻再泵一次void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { uart_port_t *p (huart-Instance USART1) ? port1 : port2; p-tx_tail (uint16_t)((p-tx_tail p-tx_inflight) (TX_FIFO_SZ - 1)); p-tx_inflight 0; p-tx_busy 0; fifo_pump(p); /* 还有余量就接着发形成流水线 */ }fifo_pump在这里被递归地调用严格说是被回调再触发回调不是真递归调用栈形成一条“发完一段接着发下一段”的流水线直到 FIFO 空了才停。4.4 错误回调ORE 之后怎么把串口救回来串口错误回调如果不写出问题之后就是永久性静默只能靠复位恢复。void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-ErrorCode (HAL_UART_ERROR_ORE | HAL_UART_ERROR_FE | HAL_UART_ERROR_NE)) { /* ORE 会让 RXNE 一直有效不清掉 DMA 永远等不到下一个字节 */ __HAL_UART_CLEAR_OREFLAG(huart); huart-ErrorCode HAL_UART_ERROR_NONE; } if (huart-Instance USART1) { HAL_UARTEx_ReceiveToIdle_DMA(huart1, port1.rx_buf, RX_BUF_SZ); } else if (huart-Instance USART2) { HAL_UARTEx_ReceiveToIdle_DMA(huart2, port2.rx_buf, RX_BUF_SZ); } }__HAL_UART_CLEAR_OREFLAG内部是“先读 SR 再读 DR”这个顺序是硬件手册规定的反了清不掉。清完标志还不够必须重新挂 DMA——出错时 HAL 会把RxState置成HAL_UART_STATE_READY或直接停掉 DMA不重挂的话后续数据全进不来。还有一点H7 系列带 D-Cache 的芯片rx_buf如果落在可缓存区域DMA 写进去的数据可能还在缓存里没落到内存CPU 读到的是旧值。解决办法是在读缓冲区之前调SCB_InvalidateDCache_by_Addr或者干脆用 MPU 把这块内存配成 non-cacheable。F4/F1 没有这个问题跨平台移植时特别容易忘。5. 避坑与常见问题排查这几处问题我都遇到过5.1 现象能收第一帧之后彻底静默原因通常是回调里没有重挂HAL_UARTEx_ReceiveToIdle_DMA。初始化时挂的那一次只覆盖一轮传输IDLE 事件处理完之后 HAL 内部状态就停了硬件层面 DMA 可能还在绕圈但 HAL 已经不再往里送回调。表现就是上电后第一条数据转发正常后面再也收不到。解决办法是在HAL_UARTEx_RxEventCallback的每个分支末尾都重挂一次包括HAL_UART_ErrorCallback里也要挂。别指望“DMA 是 Circular 就会自动续”HAL 的状态机和硬件是两套东西。5.2 现象快转慢时drop_cnt一直涨数据成段丢失这是缓冲区不够不是代码有 bug。921600 转 115200 是 8 倍速率差1024 字节的 FIFO 在出口满载时只能缓冲约 89 ms 的数据超过这个窗口进来的数据只能丢。按需处理第一优先是把TX_FIFO_SZ加到 2048 或 4096确认 RAM 够用第二是在协议层加应答/重传让上层感知到丢包第三才是开硬件流控用RTS/CTS让发送方在 FIFO 快满时停下来。前两条改代码第三条改硬件按项目阶段选。5.3 现象偶发整段乱码重启才能恢复八成是 ORE溢出错误没清。当上一帧还没被 DMA 搬走、下一个字节又到了硬件会置位 ORE此后RXNE保持有效但 DMA 已经不再响应请求。HAL 在检测到 ORE 时会进HAL_UART_ErrorCallback如果这个函数是空的CubeMX 默认生成的就是空的__weak版本那就永远清不掉。按 4.4 那样实现错误回调清标志加重挂问题就消失了。顺便提醒一句huart-ErrorCode手动清一下否则下次判断还是旧值。5.4 现象两个串口一起开数据错位或直接 HardFault中断优先级冲突。USART 中断里调用HAL_UART_Transmit_DMA这个函数会改gState、设置 DMA 回调指针、启动 DMA。如果 DMA 流中断的抢占优先级比 USART 高它可能在这中间插进来执行TxCpltCallback此时 HAL 的状态还没设完回调里读到的tx_inflight是上一笔的旧值尾指针就推错了。解决办法CubeMX 里把 USART 的抢占优先级设成数值更小即更高DMA 流设成数值更大。同一路串口的 RX 和 TX 用不同的 DMA 流时这两条流之间不要设成互相能抢占的优先级关系。5.5 现象单步调试一切正常全速跑就丢字节调试器打断点会暂停整个系统串口硬件和 DMA 却可能继续工作取决于调试配置恢复运行时缓冲区的状态已经和代码预期对不上了。这类“调试正常、运行异常”的问题几乎都跟时序有关单步没有参考价值。改用别的验证手段在drop_cnt上打断点或者用 SWO 打印计数、用 GPIO 翻转做时序标记、用逻辑分析仪抓两路串口的波形对齐时间轴。我一般会在port_forward里加一个空闲的 GPIO 置高、返回前置低示波器上看这一路的高电平宽度就知道转发耗时和触发频率比打日志准得多。6. 二次开发在透传层上接一个协议网关透传跑通之后下一步通常是“不想再原样搬了我想看懂中间的数据”。这时候不需要动app_uart.c的任何逻辑只要在port_forward中间插一个钩子。/* 返回值是处理后的输出长度返回 0 表示这一帧丢弃 */ typedef uint16_t (*frame_hook_t)(const uint8_t *in, uint16_t in_len, uint8_t *out, uint16_t out_max); static frame_hook_t g_hook NULL; /* 默认透传不装钩子就是原样搬 */ void port_forward(uart_port_t *dst, const uint8_t *data, uint16_t len) { uint8_t stage[RX_BUF_SZ]; uint16_t out_len len; if (g_hook) { out_len g_hook(data, len, stage, sizeof(stage)); if (out_len 0) return; /* 校验失败或过滤掉 */ data stage; } fifo_put(dst, data, out_len); fifo_pump(dst); }stage这块临时缓冲放在栈上没问题fifo_put会立刻把内容拷进 FIFO函数返回后就不再用。frame_hook里可以做 CRC 校验、帧头过滤、单位换算、协议转换——比如把 Modbus RTU 转成自定义二进制帧或者把一路的 ASCII 指令翻译成另一路的十六进制。因为钩子是函数指针装与不装只是运行时的一次判空默认不开时和原来的透传行为完全一致不会引入额外开销。验证方法我一般走这么一套PC 上开两个 USB 转串口模块分别接到板子的两路用一个脚本双向灌固定模式的数据递增序列最好用能直接看出丢在第几个字节。跑十分钟比对两端的接收内容同时读drop_cnt和huart-ErrorCode。递增序列比随机数据好用得多因为一旦丢包后面的序号全错位肉眼一眼就能看出从哪儿开始丢。最后说个血泪教训我早期写这类透传习惯性把drop_cnt当成“理论上不会增长的计数器”代码里不处理它只在调试时瞄一眼。结果有一次现场反馈“偶尔丢数据”我远程连上去一看计数器已经几百万了从第一分钟就在丢只是没人发现。从那以后我每次做串口透传都会在初始化时把drop_cnt和两路的ErrorCode挂到同一个调试结构体里用 SWO 或者一路空闲串口定时吐出来不敢再靠“看起来正常”来判断。希望这套东西能帮你少走一遍我走过的路。本文还有配套的精品资源点击获取