简介:基于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里分别选中这两个USART,Mode选择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总线时钟设置为72MHz,USART1挂载在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的芯片,比如STM32H743,DMA搬运的数据并不会自动同步到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反倒比中断方式省心,尤其是数据量上来之后,回报非常明显。
本文还有配套的精品资源,点击获取