简介:这份资源面向STM32嵌入式开发者,尤其是需要提升串口通信效率的初学者与中级工程师,核心解决传统中断或轮询方式在连续、不定长数据收发时CPU占用高、实时性差的问题。资源围绕UART1与UART2双串口,通过DMA实现数据互透传,涵盖串口初始化、DMA通道配置、中断服务函数编写、HAL_UART_Transmit_DMA与HAL_UART_Receive_DMA调用,以及HAL_UART_ErrorCallback错误处理等关键环节,帮助读者掌握外设与内存直接交换数据、降低CPU负担的实用技巧。压缩包共143个文件,约3.62MB,以c与h源码、o与d编译中间文件、crf与axf等工程产物为主,另含ioc、uvprojx等CubeMX与Keil工程配置,便于直接打开复现与二次开发。目前已有1872人学习下载,适合作为双串口DMA透传的参考工程与排错对照。
1. 双串口 DMA 互透传:为什么“能跑”和“跑得稳”是两码事
很多人第一次做双串口透传,思路都很直接:串口1中断收一个字节,往串口2的发送寄存器里塞一个字节,反过来也一样。在波特率9600、数据量小的时候,这套逻辑确实能跑,甚至跑得挺欢。但只要把波特率拉到115200以上,或者两边同时开始收发,你就会发现丢包、卡死、数据错位全来了。问题不在代码逻辑,在于你把CPU当成了搬运工,而搬运速度根本跟不上串口的节奏。
STM32CUBE 双串口 DMA 互透传要解决的就是这件事:让DMA当搬运工,CPU只负责配置和异常处理。串口1收到的数据通过DMA自动搬到内存缓冲区,内存缓冲区的数据再通过DMA自动搬到串口2的发送寄存器,反方向同理。整个过程CPU几乎不参与,只在半满/全满中断里做一次缓冲区切换。这套方案适合做协议转换网关、双机通信中继、调试口转发,或者任何需要两个串口之间透明传输数据的场景。如果你正在用中断方式硬扛高波特率透传,这篇文章就是写给你的。
2. 先把DMA和串口的配合关系理清楚:从外设请求到缓冲区切换
2.1 串口DMA请求是怎么产生的
STM32的USART外设内部有两个关键标志位:RXNE(接收数据寄存器非空)和TXE(发送数据寄存器空)。当DMA使能后,这两个标志不再触发中断,而是直接产生DMA请求。接收方向上,每收到一个完整字节,RXNE置位,DMA控制器自动从USART_DR寄存器读走数据,写到你指定的内存地址,然后地址指针自增。发送方向上,当TXE置位,DMA控制器自动从内存读一个字节写入USART_DR,地址指针同样自增。
这里有个容易忽略的细节:DMA的传输完成中断和串口的空闲中断是两回事。DMA传输完成中断只在DMA搬完你设定的数据量时触发一次,而串口空闲中断(IDLE)是在总线空闲一个字节时间后触发。做透传时,如果只靠DMA传输完成中断,你无法知道对方什么时候发完了一帧数据。常见做法是开启串口空闲中断,在IDLE中断里读取DMA剩余传输数量,算出这一帧实际收了多少字节,然后启动发送DMA。这个组合是双串口透传的核心。
2.2 缓冲区设计:双缓冲还是环形缓冲
透传场景下,缓冲区设计直接决定稳定性。最简单的做法是给每个方向分配一个固定大小的数组,DMA循环模式收,收到一半触发半满中断,收满触发全满中断。但循环模式下DMA会一直覆盖缓冲区,如果CPU来不及处理,旧数据就被冲掉了。
更稳妥的方案是双缓冲:准备两个缓冲区A和B,DMA填A的时候CPU处理B,DMA填B的时候CPU处理A。STM32的DMA支持双缓冲模式(DBM),在CUBE里配置DMA为循环模式,开启双缓冲,设置两个内存地址。但双缓冲模式对透传来说有个问题:两个缓冲区大小必须一致,且切换时机由硬件控制,不够灵活。
我一般会用环形缓冲加DMA半满/全满中断的方案。具体做法是:定义一个大小为2的幂次的环形缓冲区,DMA以循环模式往里面写,半满中断时处理前半段,全满中断时处理后半段。这样CPU有充足的时间处理数据,而且缓冲区大小可以根据波特率和处理速度灵活调整。下面是一个典型的配置结构:
// 串口1接收DMA缓冲区,大小256字节,2的幂次便于取模 #define UART1_RX_BUF_SIZE 256 uint8_t uart1_rx_buf[UART1_RX_BUF_SIZE]; // 串口2接收DMA缓冲区 #define UART2_RX_BUF_SIZE 256 uint8_t uart2_rx_buf[UART2_RX_BUF_SIZE]; // 透传方向标志,用于区分数据来源 typedef enum { DIR_UART1_TO_UART2 = 0, DIR_UART2_TO_UART1 } transfer_dir_t;参数说明:缓冲区大小选256是因为它既是2的幂次,又能容纳115200波特率下约22毫秒的数据量。在22毫秒内,即使主频72MHz的STM32F103也能执行上百万条指令,处理时间绰绰有余。如果你的主频更低或者波特率更高,把缓冲区加大到512或1024即可。
2.3 CUBE里的DMA配置要点
在STM32CUBE里配置双串口DMA,有几个地方容易翻车。第一,DMA请求要选对:USART1_RX对应DMA1_Channel5,USART1_TX对应DMA1_Channel4,USART2_RX对应DMA1_Channel6,USART2_TX对应DMA1_Channel7。这是F103系列的固定映射,选错了DMA根本不工作。
第二,DMA模式要选循环模式(Circular),不是普通模式。普通模式下DMA搬完设定数量就停了,透传需要持续搬运,必须用循环。第三,优先级设置:如果两个串口同时收发,建议把接收DMA优先级设为高,发送设为中,避免接收数据被发送操作阻塞导致溢出。
第四,中断使能:接收DMA要开半满中断和全满中断,发送DMA开传输完成中断。串口本身要开空闲中断(IDLE),这个在CUBE的NVIC里找不到,需要在代码里手动使能。下面是初始化后的手动配置代码:
// 使能串口1空闲中断,用于检测一帧数据结束 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 使能串口2空闲中断 __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); // 启动串口1接收DMA,循环模式 HAL_UART_Receive_DMA(&huart1, uart1_rx_buf, UART1_RX_BUF_SIZE); // 启动串口2接收DMA HAL_UART_Receive_DMA(&huart2, uart2_rx_buf, UART2_RX_BUF_SIZE);逻辑说明:HAL_UART_Receive_DMA会配置DMA并启动接收,但不会自动开启空闲中断,所以需要手动调用__HAL_UART_ENABLE_IT。空闲中断的使能位在USART_CR1寄存器的IDLEIE位,HAL库没有提供直接的宏,用这个宏可以设置。启动DMA接收后,数据会自动填入缓冲区,CPU不需要干预。
3. 透传逻辑实现:空闲中断里算长度,DMA负责搬运
3.1 空闲中断的处理流程
空闲中断是透传的“帧边界检测器”。当串口总线空闲一个字节时间后,IDLE标志置位,触发中断。在中断服务函数里,你需要做三件事:清除IDLE标志、计算本次接收了多少字节、启动发送DMA。
计算接收字节数的公式是:已接收字节数 = 缓冲区总大小 - DMA剩余传输数量。DMA的CNDTR寄存器保存着剩余要传输的数量,用__HAL_DMA_GET_COUNTER宏可以读取。下面是一个完整的空闲中断处理函数:
void USART1_IRQHandler(void) { // 检查空闲中断标志 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { // 清除空闲中断标志,先读SR再读DR __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 停止当前接收DMA,准备重新配置 HAL_UART_DMAStop(&huart1); // 计算本次接收到的数据长度 uint16_t recv_len = UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 如果收到数据,启动串口2的发送DMA if (recv_len > 0) { HAL_UART_Transmit_DMA(&huart2, uart1_rx_buf, recv_len); } // 重新启动串口1接收DMA,准备下一帧 HAL_UART_Receive_DMA(&huart1, uart1_rx_buf, UART1_RX_BUF_SIZE); } // 调用HAL库的默认中断处理 HAL_UART_IRQHandler(&huart1); }逻辑说明:先判断IDLE标志,清除它,然后停止DMA。停止DMA是为了读取准确的剩余传输数量,因为DMA在运行中CNDTR会不断变化。算出recv_len后,把uart1_rx_buf里的数据通过串口2的DMA发出去。最后重新启动串口1的接收DMA,等待下一帧。注意HAL_UART_DMAStop会同时停止接收和发送DMA,如果串口1同时在发送数据,这个操作会打断发送。更精细的做法是只停止接收DMA,用HAL_UART_AbortReceive代替。
3.2 发送DMA的启动与完成处理
发送DMA启动后,数据会自动从内存搬到串口发送寄存器。发送完成后,DMA传输完成中断触发,在中断里你需要做两件事:清除标志、通知上层发送完成。对于透传来说,发送完成中断里通常不需要做额外操作,因为数据已经发出去了。但如果你需要统计发送字节数或者做流控,这里是个合适的切入点。
void DMA1_Channel7_IRQHandler(void) { // 检查串口2发送DMA传输完成标志 if (__HAL_DMA_GET_FLAG(huart2.hdmatx, DMA_FLAG_TC7)) { // 清除标志 __HAL_DMA_CLEAR_FLAG(huart2.hdmatx, DMA_FLAG_TC7); // 发送完成,可以在这里做统计或状态切换 // 例如:g_uart2_tx_done = 1; } HAL_DMA_IRQHandler(huart2.hdmatx); }参数说明:DMA_FLAG_TC7对应DMA1通道7的传输完成标志,具体通道号根据你用的串口查参考手册。HAL_DMA_IRQHandler会处理HAL库内部的状态机,必须调用,否则HAL_UART_Transmit_DMA的后续调用会出错。
3.3 双向透传的完整数据流
把两个方向拼起来,完整的数据流是这样的:串口1收到数据,DMA自动填入uart1_rx_buf,空闲中断触发,算出长度,启动串口2发送DMA,数据从uart1_rx_buf搬到串口2的DR寄存器,发送完成中断触发。反方向同理,串口2收到数据填入uart2_rx_buf,空闲中断触发,启动串口1发送DMA。
这里有个资源冲突问题:如果串口1正在发送数据(DMA占用中),同时串口1又收到了数据,空闲中断里调用HAL_UART_Transmit_DMA会返回HAL_BUSY,导致这一帧数据丢失。解决办法是给每个方向加一个发送队列,或者用双缓冲让接收和发送使用不同的缓冲区。我一般会在空闲中断里判断发送DMA是否忙,如果忙就把数据暂存到另一个缓冲区,等发送完成中断里再启动下一次发送。
// 发送忙标志 volatile uint8_t uart2_tx_busy = 0; // 在空闲中断里判断 if (recv_len > 0) { if (uart2_tx_busy == 0) { uart2_tx_busy = 1; HAL_UART_Transmit_DMA(&huart2, uart1_rx_buf, recv_len); } else { // 发送忙,数据暂存到备用缓冲区 memcpy(uart2_tx_backup, uart1_rx_buf, recv_len); uart2_tx_backup_len = recv_len; } } // 在发送完成中断里检查备用缓冲区 if (uart2_tx_backup_len > 0) { HAL_UART_Transmit_DMA(&huart2, uart2_tx_backup, uart2_tx_backup_len); uart2_tx_backup_len = 0; } else { uart2_tx_busy = 0; }这段代码解决了发送冲突问题,但引入了新的复杂度:备用缓冲区也可能被覆盖。如果数据量特别大,建议用环形队列管理发送数据,而不是简单的备用缓冲区。环形队列的实现这里不展开,核心思路是生产者(空闲中断)往队列尾写,消费者(发送完成中断)从队列头读。
4. 避坑排查:透传丢包、卡死、数据错位的五个血泪经验
4.1 现象:低速能跑,高速丢包
原因:中断方式下CPU处理速度跟不上串口接收速度。115200波特率下每个字节间隔约87微秒,如果中断服务函数执行时间超过87微秒,下一个字节就会覆盖上一个字节。
解决:改用DMA接收,CPU只在空闲中断里处理整帧数据。同时检查中断优先级,串口中断优先级要高于其他非关键中断。如果用了RTOS,把串口中断优先级设到configMAX_SYSCALL_INTERRUPT_PRIORITY以上。
4.2 现象:第一帧正常,后续帧数据错位
原因:空闲中断里没有重新启动接收DMA,或者DMA停止后没有正确复位。HAL_UART_DMAStop会复位DMA的CNDTR寄存器,但如果你手动操作寄存器而没有重新配置,DMA会从错误的位置开始写。
解决:每次空闲中断处理后必须重新调用HAL_UART_Receive_DMA,确保DMA指针回到缓冲区起始位置。如果用的是HAL_UART_AbortReceive,也要重新启动接收。
4.3 现象:串口发送一半卡死,DMA传输完成中断不触发
原因:DMA传输完成标志没有清除,或者HAL_DMA_IRQHandler没有被调用。HAL库的DMA状态机依赖中断里的标志清除操作,如果中断服务函数里只清了自己的标志而没调用HAL_DMA_IRQHandler,HAL库内部状态会卡在BUSY。
解决:在DMA中断服务函数里先清除标志,再调用HAL_DMA_IRQHandler。顺序不能反,否则HAL库可能重复处理同一个标志。
4.4 现象:两个串口同时收发时,其中一个串口无响应
原因:DMA通道优先级配置不当,高优先级通道一直占用总线,低优先级通道得不到服务。或者两个串口共用了同一个DMA控制器,而DMA控制器同一时间只能服务一个通道。
解决:在CUBE里把接收DMA优先级设为High,发送设为Medium。如果两个串口的数据量都很大,考虑用DMA1和DMA2分别服务两个串口(如果芯片支持)。F103系列DMA1有7个通道,DMA2有5个通道,合理分配可以避免冲突。
4.5 现象:空闲中断触发过于频繁,CPU占用率高
原因:空闲中断的触发条件是总线空闲一个字节时间。如果对方发送的数据帧之间间隔很短,空闲中断会频繁触发,每次都要停止/启动DMA,开销不小。
解决:适当增大DMA缓冲区,让DMA在半满或全满中断里处理数据,而不是依赖空闲中断。或者设置一个超时阈值,空闲中断触发后延时一小段时间再处理,合并短帧。但延时处理会引入延迟,需要根据实际场景权衡。
5. 进阶技巧:用DMA双缓冲和空闲中断超时做零丢包透传
5.1 DMA双缓冲模式在透传中的正确用法
STM32的DMA双缓冲模式(DBM)允许你设置两个内存地址,DMA在填满第一个缓冲区后自动切换到第二个,同时触发传输完成中断。在透传场景下,这个特性可以用来实现“接收不停止”的效果:DMA一直在两个缓冲区之间交替填充,CPU在中断里处理已经填满的那个缓冲区,另一个缓冲区继续接收。
配置双缓冲需要在CUBE里把DMA模式设为循环,然后在代码里手动设置第二个内存地址:
// 启用DMA双缓冲模式 huart1.hdmarx->Instance->CR |= DMA_CCR_DBM; // 设置第二个内存地址 huart1.hdmarx->Instance->CMAR2 = (uint32_t)uart1_rx_buf2; // 重新启动DMA HAL_UART_Receive_DMA(&huart1, uart1_rx_buf, UART1_RX_BUF_SIZE);注意:HAL库对双缓冲模式的支持不完整,HAL_UART_Receive_DMA只会设置第一个内存地址。上面的代码直接操作寄存器来启用双缓冲,但HAL库的中断处理函数可能无法正确识别双缓冲的传输完成事件。更稳妥的做法是绕过HAL库,直接配置DMA寄存器,自己写中断处理。这超出了本文范围,但思路是:在DMA传输完成中断里读取当前DMA正在使用的内存地址(通过CT位判断),处理另一个缓冲区。
5.2 空闲中断超时机制:解决短帧频繁触发
空闲中断在数据帧间隔很短时会频繁触发,每次触发都要停止/启动DMA,开销不小。一个优化方案是在空闲中断里启动一个定时器,延时一段时间后再处理数据。如果在这段时间内又有新数据到达,重置定时器。这样可以把多个短帧合并成一个长帧处理。
// 在空闲中断里启动超时定时器 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 启动定时器,10ms后处理 HAL_TIM_Base_Start_IT(&htim6); } HAL_UART_IRQHandler(&huart1); } // 定时器中断里处理数据 void TIM6_IRQHandler(void) { HAL_TIM_IRQHandler(&htim6); // 停止定时器 HAL_TIM_Base_Stop_IT(&htim6); // 处理接收数据 process_uart1_data(); }参数说明:超时时间根据实际数据帧间隔设置。如果帧间隔在1ms以内,超时设5-10ms可以合并大部分短帧。但超时时间越长,透传延迟越大。对于实时性要求高的场景,不建议用超时合并。
5.3 验证透传稳定性的方法
透传做完了,怎么验证它稳不稳?我一般用三个测试:第一,用串口助手以最高波特率连续发送随机数据,持续10分钟,检查接收端数据是否完全一致。第二,两个串口同时以最高波特率对发,观察是否丢包。第三,发送特定模式的数据(如0x00到0xFF循环),用逻辑分析仪抓取两个串口的波形,对比时序和内容。
如果测试中发现丢包,先检查DMA缓冲区大小是否足够,再检查中断优先级是否合理,最后检查是否有其他地方禁用了全局中断导致DMA请求被延迟响应。透传的稳定性取决于最慢的那个环节,任何一个细节没处理好都会成为瓶颈。
从那以后我每次做双串口透传,都会先把DMA缓冲区和中断优先级表画出来,确认每个环节的余量,再开始写代码。希望帮到你。
本文还有配套的精品资源,点击获取