尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

STM32双串口DMA空闲中断实现全双工透传方案详解

发布时间:2026/9/10 0:59:36

资讯中心
01
ARTICLE

STM32双串口DMA空闲中断实现全双工透传方案详解

STM32双串口DMA空闲中断实现全双工透传方案详解
简介基于STM32CubeMX与HAL库实现的双串口DMA互透传完整工程面向需要高效串口数据转发的嵌入式开发者。通过UART1与UART2的DMA收发配合解决传统中断或轮询方式在连续不定长数据下CPU负担重、吞吐率低的问题适用于设备间双向中继、Modbus桥接等场景也可作为串口透传模块的基础模板。资源包共143个文件以.c和.h源文件为主包含STM32F1系列HAL库驱动、串口与DMA配置代码以及.ioc图形化工程配置和.uvprojx等Keil工程文件另有.o、.d、.crf等编译中间文件整体3.62MB目录结构完整。目前已有1868人学习浏览。工程可直接导入Keil查看重点演示DMA接收完成中断回调、发送完成事件处理、串口错误回调等关键环节并给出双串口互透传的实现思路对理解DMA半满中断、数据缓存切换、双工转发机制以及HAL库的UART-DMA链路配置都有明确参考价值。 最近调试一块板子碰到个挺实在的需求两个串口设备之间要做透明的数据互转一边是STM32做主控另外两个模块都是TTL串口要求数据能双向实时转发。网上搜了一圈要么是单串口收发示例要么是中断方式实现的转发一旦数据量上来满双工互传的性能就撑不住了。干脆自己用STM32CubeMX加HAL库基于双串口DMA加空闲中断做了一套完整方案顺便把环形队列和发送通路也理顺了。这篇就把整个思路、配置、代码和踩过的坑完整拆出来。先说结论这套方案实测在115200波特率双向同时跑满的情况下CPU占用很低数据不丢、不粘包两个模块之间的通信就跟直连一样。适合做串口桥接、RS485转TTL网关、蓝牙/WiFi透传模块这种场景。1. 方案思路与整体架构设计1.1 为什么必须用DMA而不是传统中断收发串口接收用中断方式每个字节进一次中断如果波特率是115200大概8.68微秒就有一个字节中断。这个频率看起来不高但如果在中断里还要做协议解析、搬运数据、调用HAL库函数一旦处理时间超过一个字节的间隔下一字节就会触发中断重入或者直接覆盖数据造成丢字节。高波特率下这个问题尤其明显。DMA的方案是把串口收到的数据由外设直接搬运到内存缓冲区不需要CPU逐字节介入只有一帧结束后才触发一次中断告诉CPU“数据到了”。这样CPU从反复进中断的泥潭里解放出来可以把精力放在数据转发、协议处理上。对于双串口互透传这种场景两个方向的数据流都是持续不断的DMA的优势就是成倍放大。1.2 双串口互透传的数据流架构整个系统的数据流其实很简单串口1收到数据原样交给串口2发出去串口2收到数据原样交给串口1发出去。难点在于要做到双向“同时”传输而不是收完一段再发另一段。打个比方单向传输就像人流进了一个单向闸机而双向互传相当于两拨人分别从两个门进出闸机系统得同时处理两个方向的流量。我的做法是每个串口各分配一条DMA接收通道和一条DMA发送通道接收方向用环形缓冲区承接DMA搬运来的数据发送方向用一个发送队列管理待发出的数据包。主循环只负责检查环形缓冲区里有没有新数据有就投递到对面的发送队列发送完成后由DMA中断通知底层可以继续发下一包。整个链路是异步的、非阻塞的两边速率即使不一样也能靠缓冲区平滑掉瞬时压力。2. STM32CubeMX配置细节与参数解析2.1 串口参数初始化我用的是STM32F103C8T6的小板两个串口配置成UART1和UART2波特率都先按115200设置8位数据、无校验、1位停止位。在CubeMX里分别选中这两个USARTMode选择Asynchronous然后在DMA Settings选项卡里给每个串口添加发送和接收两条DMA通道。这里有个关键点要提一下DMA的接收通道一定要把Mode选成Circular循环模式。这个参数的意思是DMA搬完一帧数据后自动回到缓冲区起始位置继续接收在不打断DMA传输的前提下实现连续接收。如果选成Normal模式接收完一帧缓冲区就停了除非重新调用接收函数否则后续数据全部丢失。DMA方向配置上接收通道选择PeripheralToMemory发送通道选择MemoryToPeripheral数据宽度Memory和Peripheral都必须是Byte。有人图省事把数据宽度选成HalfWord结果收到的数据全是乱的这里要吃透原因外设寄存器每次只能吐一个字节数据宽度不匹配会导致DMA搬运时字节错位。2.2 中断优先级分组和NVIC设置串口和DMA的中断优先级需要统一规划。我的做法是把DMA的发送完成中断优先级调低接收空闲中断优先级调高一点因为接收中断处理的是数据入队如果延迟太长环形缓冲区可能被后续数据覆盖。发送完成中断只是通知底层可以发下一包晚一点处理不影响数据完整性。NVIC设置里要确保DMA中断和串口全局中断都已使能。串口接收需要打开UART的全局中断因为空闲中断IDLE是挂在UART中断向量上的。这一点很多时候会漏掉——DMA通道中断勾了但UART本身的中断没打开结果数据到了一直不触发接收回调。2.3 时钟配置对DMA的影响时钟配置里有一个容易忽略的关联DMA和串口的时钟频率直接影响波特率精度和DMA传输速率。在CubeMX的Clock Configuration里如果APB2总线时钟设置为72MHzUSART1挂载在APB2上USART2挂载在APB1上APB1被2分频成36MHz。波特率发生器的工作时钟是串口时钟除以分频系数如果APB1和APB2的频率差太多两个串口实际的波特率误差就不一样高速转发时可能偶发乱码。我的建议是确保APB1和APB2的时钟尽量一致或者在CubeMX里生成代码后检查两个串口的波特率寄存器实际值用逻辑分析仪验证波形的误差是否在可接受范围内。一般误差不超过2%问题不大但超过5%在长帧传输时就会出现异常。3. 核心代码实现与缓冲区管理3.1 环形缓冲区的实现在开始写逻辑之前我先整理出两块环形缓冲区分别缓存两个串口收到的原始数据。环形缓冲区的好处是可以配合DMA的Circular模式做无锁读写写入方是DMA硬件读取方是主循环软件只要保证读取速度不慢于写入速度就不会有数据覆盖问题。#define RX_BUF_SIZE 512 typedef struct { uint8_t buffer[RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; ring_buffer_t uart1_rx_ring; ring_buffer_t uart2_rx_ring;注意这里的head写成volatile因为它是DMA中断回调里更新的主循环这边通过它判断有没有新数据。tail由主循环自己维护每次读走数据后更新。这个结构没有加锁因为在整个系统里只有一个写者DMA中断和一个读者主循环不需要复杂的互斥机制但不允许在中断里调用读函数也不可以在主循环里修改head变量。3.2 使用空闲中断接收不定长数据串口通信里最烦人的就是数据长度不确定。如果使用固定长度接收要么等数据凑够才处理实时性差要么反复设置接收长度代码绕来绕去。STM32HAL库提供了一个好用的机制HAL_UARTEx_ReceiveToIdle_DMA它能实现“收到一字节之后就启动DMA搬运总线空闲时触发空闲中断然后把已接收的数据长度告诉用户”。在初始化完成后启动接收HAL_UARTEx_ReceiveToIdle_DMA(huart1, uart1_rx_ring.buffer, RX_BUF_SIZE); HAL_UARTEx_ReceiveToIdle_DMA(huart2, uart2_rx_ring.buffer, RX_BUF_SIZE);数据到达后在回调函数里更新环形缓冲区头部索引。HAL库提供的回调是HAL_UARTEx_RxEventCallback注意不是普通的接收完成回调它的参数里带有接收长度信息。void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { uart1_rx_ring.head (uart1_rx_ring.head Size) % RX_BUF_SIZE; } else if (huart huart2) { uart2_rx_ring.head (uart2_rx_ring.head Size) % RX_BUF_SIZE; } HAL_UARTEx_ReceiveToIdle_DMA(huart, huart-pRxBuffPtr, RX_BUF_SIZE); }这里有一个绝对不要踩的坑处理完数据之后必须重新调用HAL_UARTEx_ReceiveToIdle_DMA否则DMA只接收一帧数据就处于停止状态后续的串口数据全都进不来。很多人第一次用这个接口都会漏掉这一步导致功能只正常一次。3.3 发送队列的实现与异步发送接收缓冲区的数据如何送到对面串口发送出去最简单粗暴的方式就是检测到缓冲区有数据后直接调用HAL_UART_Transmit_DMA。但HAL库的DMA发送是异步的如果上一包数据还没发完就开始下一次发送会返回HAL_BUSY这个时候如果选择忽略直接覆盖就会造成数据丢失。为了保证发送链路不断流我实现了一个发送队列每个串口对应一个队列。主循环把待发送的数据块拷贝进队列的节点中发送完成中断里再弹出队列头继续发送下一块。#define TX_QUEUE_DEPTH 8 #define TX_QUEUE_SIZE 128 typedef struct { uint8_t data[TX_QUEUE_SIZE]; uint16_t len; } tx_node_t; typedef struct { tx_node_t pool[TX_QUEUE_DEPTH]; volatile uint8_t head; volatile uint8_t tail; volatile uint8_t count; } tx_queue_t; tx_queue_t uart1_tx_queue; tx_queue_t uart2_tx_queue;发送通道的启动逻辑放在主循环里检查发送队列是否有数据并且对面串口的DMA发送状态是空闲的就取出队头调用HAL_UART_Transmit_DMA。发送完成回调HAL_UART_TxCpltCallback里再把队列弹出并启动下一个待发数据块。void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { uart1_tx_queue.tail (uart1_tx_queue.tail 1) % TX_QUEUE_DEPTH; uart1_tx_queue.count--; } else if (huart huart2) { uart2_tx_queue.tail (uart2_tx_queue.tail 1) % TX_QUEUE_DEPTH; uart2_tx_queue.count--; } }3.4 主循环里的数据转发逻辑主循环的核心逻辑其实非常短就是检查接收环形缓冲区有没有新数据有就投递到对面串口的发送队列。while (1) { // 串口1收到的数据转发给串口2 while (uart1_rx_ring.tail ! uart1_rx_ring.head) { uint8_t byte uart1_rx_ring.buffer[uart1_rx_ring.tail]; uart1_rx_ring.tail (uart1_rx_ring.tail 1) % RX_BUF_SIZE; tx_queue_push(uart2_tx_queue, byte, 1); } // 串口2收到的数据转发给串口1 while (uart2_rx_ring.tail ! uart2_rx_ring.head) { uint8_t byte uart2_rx_ring.buffer[uart2_rx_ring.tail]; uart2_rx_ring.tail (uart2_rx_ring.tail 1) % RX_BUF_SIZE; tx_queue_push(uart1_tx_queue, byte, 1); } uart1_send_flush(); uart2_send_flush(); // 其它协议处理放在这里 }逐字节转发这个方式在数据量不大时性能没问题但两个方向同时快速传数据时主循环会频繁执行压入操作导致效率偏低。更高效的做法是空闲中断回调里记录好本次接收的数据块起始地址和长度把整块数据一次性压入发送队列而不是在主循环里逐字节处理。不过考虑到代码简单性和可读性逐字节版在115200波特率下实际压测完全没有瓶颈如果把波特率提到460800以上建议改成整块搬运方案。4. 常见问题与排查技巧实录4.1 接收空闲中断回调触发一次后不再触发这个现象基本上都是因为回调里没有重新调用HAL_UARTEx_ReceiveToIdle_DMA。很多人在初始化时调用一次收到数据后回调里只做数据处理没有重新启动接收导致DMA停在停止状态。处理方法上面已经说了在回调最后一行必须重新调用接收启动函数否则整个链路就断掉了。另外还有一种隐蔽情况是重新调用之后回调立即又触发一次但Size为0。这是因为在启动命令发出后数据线处于空闲状态硬件立即产生一次空闲事件。处理方法是判断Size是否为0为0时直接返回不做逻辑处理。4.2 DMA发送出现HAL_BUSY导致丢包如果主循环调用发送函数时上一帧还没有发送完成HAL库会返回HAL_BUSY。很多实现图省事直接忽略返回值结果那一帧数据就无声无息地消失了。我在测试时就遇到过这种情况低速设备通过串口1发一段长数据给高速设备串口2串口2的发送还没跑完串口1又转来新数据中间就丢了一段。解决思路就是发送队列。队列深度需要根据最坏情况估算缓冲区大小至少能容纳两侧各一包最大数据块的叠加。如果经常传大数据包把发送队列的节点大小和深度都调大代价是RAM占用多一些。对于F103这种48KB RAM的芯片512字节的队列足够大多数场合使用。4.3 两个串口波特率不一致时的处理做透明桥接时两个串口的设备波特率往往不同。比如串口1接一个9600的传感器串口2接上位机设的115200中间涉及速率匹配问题。低速侧发数据过来高速侧能很快发出去基本无压力但高速侧发数据过来低速侧发送速度跟不上数据就会在发送队列里堆积积满后只能丢弃新数据。这个问题没有一个完全无损的方案因为低速通道的物理吞吐上限摆在那里。我的处理策略是“丢新保旧”即当发送队列满时新来的数据直接丢弃保证已经接收的数据能完整发出去。对于实时控制类场景旧数据的价值通常大于新数据因为控制命令过期了再发没有意义。如果需要“保新丢旧”就反过来从队尾覆盖数据执行效率差一点但可以实现。4.4 DMA和Cache一致性问题F4/H7系列如果你用的是带Cache的芯片比如STM32H743DMA搬运的数据并不会自动同步到CPU的Cache中。CPU去读取缓冲区时可能读到的是Cache里过期的数据导致接收内容不对或者转发数据错乱。这就跟快递柜一样——快递员把包裹塞进柜子后忘记通知收件人收件人还以为柜子是空的。解决方法是确保DMA缓冲区所在的内存区域配置为不缓存比如用MPU把SRAM区设置为Device或Strongly Ordered属性或者在读取DMA数据之前调用SCB_InvalidateDCache_by_Addr函数清除缓存。F1系列没有Cache不需要考虑这个问题但如果你用的芯片带Cache这部分必须处理。4.5 逻辑分析仪辅助排查乱码问题在实际联调过程中我用逻辑分析仪抓了两个串口的TX/RX波形。发现一个有意思的现象串口1接收正常但串口2发出的数据偶发乱码。排查到最后问题出在共地——两个模块直接相连但没有共地导致参考地电位漂移信号电平不稳定。这是我做串口联调时总会提醒自己的一条经验任何串口连接先确认共地再检查接线最后才查软件配置。很多看起来像程序和DMA配置问题的情况其实都是物理层的接触不良或电平偏移。5. 工具链与调试辅助5.1 串口调试助手和驱动选型联调过程中免不了要用串口调试助手。Windows下我习惯用XCOM或者SSCOM界面简单、支持HEX和ASCII切换发送间隔可以自定义做压力测试特别方便。Linux下用minicom配合ttyUSB0设备节点注意先确认CH340或FTDI驱动已正确加载设备节点权限是个很常见的问题——用chmod 777 /dev/ttyUSB0临时解决或者把用户加入dialout组彻底解决权限问题。5.2 DMA调试心得调试DMA的时候不要直接堆功能先把两个串口分别用轮询方式打通确认底层物理链路没问题。然后单个串口调试DMA接收和发送最后才把两个串口联动起来做互透传测试。这样一旦出错能迅速定位到是哪一层的故障而不是一头扎进复杂的报错信息里。如果遇到DMA通道抢占或者收发中断冲突优先检查CubeMX生成的DMA请求映射是否正确。STM32F103的DMA1有7个通道每个通道可以服务多个外设请求但同一时刻同一个通道只能配置给一个外设。双串口四通道刚好占满DMA1的全部通道如果配置时勾错了通道就会出现一个串口正常另一个串口完全收不到数据的现象。6. 扩展思路与后续优化方向这套双串口DMA互透传的框架出去之后可以很自然地扩展出不少功能。比如在转发路径中间加一个协议解析层做帧格式识别、校验、过滤就能变成一个智能串口网关把FIFO队列改成基于链表结构的动态缓冲区就能应对更加极端的不定长数据突发如果加上RS485方向控制引脚和收发切换逻辑就能直接适配RS485总线场景。我后来在这个框架上跑过Modbus RTU从机协议用的是同样的DMA接收加空闲中断方式半双工轮询逻辑只需要在发送通道加上方向控制和等待机制即可整体改造量不大。如果读者有类似需求建议先把底层的环形缓冲区和发送队列吃透再去套具体的业务协议会发现很多通信模块开发都能复用这套基础设施。多说一句DMA这块真的值得好好折腾一下。我见过很多人学串口中断方式玩得很溜一碰到DMA就觉得复杂宁可用老办法硬扛。但实际上只要把“搬运工”这个思路理清楚DMA反倒比中断方式省心尤其是数据量上来之后回报非常明显。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。