
1. 从“能用”到“好用”串口通信的进阶之路上次我们聊了用STM32的HAL库实现串口收发的基础操作算是把轮子装上了车能跑了。但如果你真拿那个最基础的HAL_UART_Transmit和HAL_UART_Receive去搞项目尤其是数据量稍大一点或者对实时性有点要求的大概率会卡得怀疑人生。阻塞式发送等在那里啥也干不了接收定长数据还得自己拼包这显然不是我们想要的“工业级”玩法。今天这篇我们就来拆解如何让STM32的串口真正“飞”起来核心就是两个东西DMA直接存储器访问和中断。别被名字吓到我会用最“人话”的方式结合CubeMX配置和代码让你明白它们到底是怎么把CPU解放出来以及如何优雅地处理那些烦人的“不定长数据”。简单说这一篇的目标是告别“轮询等待”实现“后台自动收发”让你的主程序可以专心去处理更重要的逻辑比如电机控制、算法运算或者响应其他传感器而不是被串口数据收发这种“体力活”一直拴着。我们会从CubeMX的图形化配置开始一步步走到代码实现最后分享几个我实际调试中踩过的坑和总结的经验。无论你是做平衡车、四轴飞行器还是和K210、K230这类AI芯片通信或者用Python写个上位机这套思路都是通用的。2. DMA中断解放CPU的黄金组合为什么是DMA中断我们得先理解串口通信的瓶颈在哪。假设你的STM32主频是72MHzCPU正忙着执行一个复杂的PID控制算法。这时上位机比如电脑通过串口发来了一帧100字节的数据。如果用最基本的HAL_UART_ReceiveCPU就必须停下手中的PID计算死等在这100个字节一个一个地从串口接收寄存器USARTx-DR搬到我们指定的内存数组里。搬完这100字节可能几微秒甚至几十微秒就过去了对于高速运行的电机控制环来说这足以导致控制周期波动性能下降。DMA就是为了解决这个“搬运工”问题而生的。你可以把它想象成CPU雇的一个“专职快递员”。我们只需要在程序初始化时告诉DMA“快递员这是收件地址内存数组这是发件地址串口数据寄存器有100个包裹字节要来你帮我搬过去搬完了告诉我一声。” 之后当串口每收到一个字节硬件会自动触发DMA控制器由DMA这个“快递员”不经过CPU直接完成数据从串口到内存的搬运。在此期间CPU的PID算法可以完全不受干扰地继续执行。那“搬完了告诉我一声”怎么实现这就是中断的作用。DMA搬运完成或者搬运一半可以产生一个中断信号CPU只需要在这个中断服务函数里处理一下“快递已签收”的后事即可比如设置一个标志位通知主程序“数据包收齐了快来处理吧。” 整个过程CPU只在数据真正到位时才被短暂打断一下效率极高。发送也是同理。我们要发送一大段数据时只需要把数据放进数组启动DMA发送剩下的搬运工作就交给DMA了发送完成也会产生中断通知我们。这就是“后台自动收发”的核心思想。2.1 CubeMX中的DMA与中断配置实战理论说再多不如动手配一遍。我们以STM32F103C8T6蓝色药丸板子的USART1为例假设我们想用DMA实现自动接收不定长数据并用DMA发送数据。首先在CubeMX中打开USART1模式选择“Asynchronous”异步。关键在下面两个标签页1. DMA Settings标签页点击“Add”为USART1_RX和USART1_TX分别添加DMA流不同型号STM32叫Stream或ChannelF1系列是Channel。对于USART1_RX接收:Direction: Peripheral To Memory 外设到内存Priority: 根据系统需求通常Medium即可。如果实时性要求极高设为High。Mode: Circular 循环模式还是 Normal 普通模式这是第一个关键选择。Normal模式DMA搬运一次指定长度比如100字节后就停止了需要软件重新启动才能进行下一次搬运。适合接收固定长度的数据包。Circular模式DMA会循环不断地搬运。当搬完设定的长度比如100字节后它会自动回到起始地址重新开始搬永不停止。这听起来很适合持续接收但有个问题如果数据包长度不定你无法知道一个包何时结束、下一个包何时开始容易把数据帧接错位。那不定长数据怎么办HAL库提供了一个巧妙的“串口空闲中断Idle Interrupt”来配合DMA。我们让DMA RX工作在Circular模式指向一个足够大的缓冲区比如200字节。串口在收到一帧数据后总线会保持空闲状态无数据超过一个字节的时间此时硬件可以检测到这个“空闲”状态并产生中断。在空闲中断里我们就能知道从DMA上次搬运停止的位置到当前DMA搬运的当前位置这之间的数据就是一帧完整的数据。然后我们处理这一帧并重置DMA的指针准备接收下一帧。这是目前处理不定长数据最主流、最高效的方法。对于USART1_TX发送:Direction: Memory To Peripheral 内存到外设Priority: MediumMode: Normal 普通模式。发送通常是按需触发发完一帧就停止等待下次发送指令。2. NVIC Settings标签页这里要开启相关的中断使能。务必勾选“USART1 global interrupt”。对于空闲中断需要在代码中额外开启。对于DMACubeMX通常会自动为你添加的DMA通道勾选中断。检查一下确保USART1_RX和USART1_TX对应的DMA通道中断如DMA1_Channel5_IRQn是Enabled状态。注意CubeMX生成的代码会把DMA和USART的中断服务函数IRQHandler都写好并且把DMA和USART的中断优先级Preemption Priority, SubPriority也配置好。除非你有复杂的多中断嵌套需求否则先用默认的不要乱改优先级否则可能导致中断无法响应或逻辑混乱。配置完成后生成代码。CubeMX会帮我们初始化好USART和DMA的硬件关联并在stm32f1xx_it.c文件中生成中断服务函数的框架。3. 代码实现不定长接收与高效发送生成了代码只是搭好了舞台戏还得我们自己来唱。我们主要需要编写三部分初始化后开启空闲中断和DMA接收、在中断回调函数中处理接收完成的数据、以及调用DMA发送函数。3.1 开启空闲中断与循环DMA接收在main.c的/* USER CODE BEGIN 2 */区域初始化完外设后我们添加以下代码// 定义接收缓冲区 #define RX_BUFFER_SIZE 200 uint8_t rx_buffer[RX_BUFFER_SIZE]; // 声明用于记录数据长度的变量 volatile uint16_t rx_len 0; // volatile很重要防止编译器优化 volatile uint8_t rx_flag 0; // 接收完成标志 // 在初始化后启动DMA循环接收并开启串口空闲中断 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 开启空闲中断HAL_UART_Receive_DMA这个函数启动了DMA并告诉DMA把从USART1数据寄存器来的数据循环地放到rx_buffer数组里这个数组大小是200字节。DMA会一直在这里等着搬数据。__HAL_UART_ENABLE_IT是HAL库提供的宏用于使能特定中断。这里我们使能了空闲中断UART_IT_IDLE。注意CubeMX的图形界面没有空闲中断的勾选项必须用代码开启。3.2 在中断服务函数中捕获“空闲事件”当一帧数据发送完毕串口总线空闲下来就会触发空闲中断。我们需要在串口全局中断服务函数USART1_IRQHandler在stm32f1xx_it.c中所调用的HAL_UART_IRQHandler处理完之后自己判断一下是不是空闲中断。更优雅的做法是重写HAL库的空闲中断回调函数。HAL库用__weak关键字定义了一个弱函数HAL_UART_IdleCallback我们可以自己实现一个强函数来覆盖它。但首先我们需要在中断里检测到空闲事件。一个常见的做法是在stm32f1xx_it.c的USART1_IRQHandler函数里添加自定义代码void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ // 检测是否是空闲中断 if((__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清除空闲中断标志非常重要否则会一直进中断。 // 暂时关闭DMA安全地计算接收到的数据长度 HAL_UART_DMAStop(huart1); // 计算本次接收到的数据长度 // __HAL_DMA_GET_COUNTER 获取DMA通道中剩余未传输的数据量 rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if(rx_len 0) { rx_flag 1; // 设置标志位通知主循环有数据待处理 } // 重新设置DMA传输数据量并启动DMA准备接收下一帧 // 注意需要重新设置内存地址和长度因为DMA停止后其内部寄存器可能失效 huart1.hdmarx-Instance-CNDTR RX_BUFFER_SIZE; // 重新设置传输数量 huart1.hdmarx-Instance-CMAR (uint32_t)rx_buffer; // 重新设置内存地址 huart1.hdmarx-Instance-CCR | DMA_CCR_EN; // 使能DMA通道 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 重新使能空闲中断如果被HAL库禁用 } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(huart1); // 调用HAL库的中断处理函数 /* USER CODE BEGIN USART1_IRQn 1 */ // 其他自定义中断处理可以放这里 /* USER CODE END USART1_IRQn 1 */ }这段代码是核心我解释几个关键点__HAL_UART_GET_FLAG和__HAL_UART_CLEAR_IDLEFLAG这是标准操作检测并清除硬件标志位。不清除空闲标志会导致程序不断进入中断卡死在这里。HAL_UART_DMAStop在计算长度前先停止DMA。因为DMA的计数器CNDTR在运行时会自动递减我们需要在一个“静止”的时刻去读取它才能得到准确的本帧数据长度。直接读运行中的计数器值是危险的。rx_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(...)DMA的计数器CNDTR表示还剩多少字节没传输。我们初始化时设置了要传输RX_BUFFER_SIZE200字节。假设这一帧收到了15个字节那么DMA就搬走了15个计数器剩下185。所以已接收长度 总长度(200) - 剩余长度(185) 15。重新配置DMA停止DMA后有些系列的STM32尤其是F1其DMA通道的配置寄存器如内存地址CMAR可能会失效需要手动重新设置并使能。这是一个大坑F4/H7系列的部分DMA流可能不需要但为了代码通用性和稳定性建议都加上。更稳妥的写法是调用HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE);来重启但要注意它内部可能会做一些额外的状态重置。3.3 主循环处理与DMA发送在主函数while(1)循环里我们只需要检查rx_flag标志位。while (1) { /* USER CODE END WHILE */ if(rx_flag 1) { rx_flag 0; // 清除标志 // 此时 rx_buffer 中前 rx_len 个字节就是收到的一帧数据 // 你可以在这里进行数据解析、处理... processUartData(rx_buffer, rx_len); // 示例用DMA将处理后的数据回环发送出去 HAL_UART_Transmit_DMA(huart1, rx_buffer, rx_len); // 注意这里直接用了原缓冲区如果处理时间较长而DMA发送速度慢 // 可能导致缓冲区被新数据覆盖。稳妥起见应使用单独的发送缓冲区或确保处理速度。 } // 主程序可以放心执行其他任务如按键扫描、LED闪烁、传感器读取等 HAL_Delay(1); }HAL_UART_Transmit_DMA函数非常省心它把数据地址、长度和串口句柄交给DMA后就直接返回了发送任务在后台由DMA完成。发送完成后DMA会产生一个发送完成中断HAL_UART_TxCpltCallback如果你需要知道什么时候发完了比如关闭发送使能、释放缓冲区可以重写这个回调函数。4. 避坑指南与性能优化要点上面给出了一个基本可用的框架但真想在实际项目里稳定跑起来还有不少坑要过。下面是我从好几个项目里总结出来的经验。4.1 DMA接收的缓冲区管理与溢出处理我们的rx_buffer是循环使用的。这里有一个致命的风险如果主程序处理数据processUartData的速度跟不上串口接收数据的速度会发生什么新收到的数据会覆盖掉还没来得及处理的旧数据导致数据丢失或错乱。解决方案1双缓冲Ping-Pong Buffer这是最经典的思路。准备两个一样大的缓冲区A和B。DMA当前正在往A缓冲区写数据。当A缓冲区收到一帧数据并触发空闲中断时我们做两件事立刻将DMA的目标地址切换到B缓冲区。通知主程序“A缓冲区数据好了快去处理”。 这样主程序处理A数据的同时DMA可以继续往B缓冲区接收新数据两者互不干扰。当B缓冲区满时再切回A。这需要更精细的中断管理和状态机但能极大提高数据吞吐的可靠性。解决方案2增大缓冲区与流控如果数据量不是特别大只是偶尔突发最简单的方法是显著增大RX_BUFFER_SIZE比如从200扩大到1024甚至更大给主程序留出足够的处理时间窗口。同时如果硬件支持USART有CTS/RTS引脚可以启用硬件流控让上位机在STM32来不及处理时暂停发送。解决方案3及时处理优化代码检查你的processUartData函数避免在里面使用HAL_Delay这类阻塞函数。解析协议要高效。如果处理确实耗时考虑将数据拷贝到一个临时队列Queue中中断函数只负责快速拷贝和置标志主循环再慢慢从队列里取数据处理。4.2 发送时的内存竞争与回调使用HAL_UART_Transmit_DMA是非阻塞的函数调用后立即返回。但如果你像示例中那样紧接着又修改了发送缓冲区的内容而DMA还在从那个缓冲区里读取数据发送就会导致发送出去的数据是混乱的一部分旧数据一部分新数据。重要原则在DMA发送完成中断HAL_UART_TxCpltCallback触发之前绝对不能修改或释放发送缓冲区。正确做法为发送准备一个专用的缓冲区tx_buffer或者动态分配内存。调用HAL_UART_Transmit_DMA后不要立即复用tx_buffer。在发送完成回调函数HAL_UART_TxCpltCallback中释放缓冲区或设置一个“发送空闲”标志通知程序可以准备下一包发送数据了。// 在文件顶部定义发送状态 volatile uint8_t uart_tx_busy 0; // 重写发送完成回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart-Instance USART1) { uart_tx_busy 0; // 发送完成释放“忙”状态 // 可以在这里释放动态分配的发送缓冲区 } } // 发送函数 uint8_t my_uart_send(uint8_t *data, uint16_t len) { if(uart_tx_busy) return 0; // 上次还没发完拒绝新的发送请求 // 这里最好将数据拷贝到专用的发送缓冲区而不是直接使用data指针 // memcpy(tx_buffer, data, len); uart_tx_busy 1; HAL_UART_Transmit_DMA(huart1, data, len); // 或用拷贝后的tx_buffer return 1; }4.3 中断嵌套与优先级配置如果你的系统里不止有串口中断还有定时器中断用于PID、外部中断用于编码器等就需要合理配置NVIC嵌套向量中断控制器的优先级。抢占优先级Preemption Priority数值小的优先级高。高抢占优先级的中断可以打断正在执行的低抢占优先级的中断。子优先级SubPriority当两个中断的抢占优先级相同时子优先级高的先执行但不能互相打断。配置建议实时性要求最高的中断如电机控制的PWM定时器中断、紧急故障检测的外部中断应设置为最高的抢占优先级数值最小。串口DMA传输完成中断、串口空闲中断这类数据通信中断可以设置为中等抢占优先级。因为它们需要及时响应否则可能丢失数据但又不至于要打断电机控制。SysTick定时器中断用于HAL_Delay通常设置为较低的抢占优先级。在CubeMX的NVIC配置界面可以直接设置。原则是确保最关键的任务不被阻塞同时避免高优先级中断长时间占用CPU导致低优先级中断“饿死”。4.4 调试技巧如何确认数据收发了当你费劲写好代码发现串口助手上一片空白或者全是乱码时别慌按顺序排查检查硬件TX/RX线接对了吗共地了吗USB转串口模块驱动装好了吗波特率、数据位、停止位、校验位和串口助手设置是否完全一致这是最常见的问题。简化测试先注释掉DMA和中断代码用最基础的HAL_UART_Transmit发送一个固定的字符串如Hello\r\n看串口助手能否收到。这能验证最基本的串口硬件和引脚配置是否正确。利用HAL库的状态和错误标志在调试时可以检查huart1.ErrorCode。也可以在DMA或USART的中断服务函数里设置断点看是否能进来。查看DMA寄存器在调试器如ST-Link配合Keil/IAR中查看DMA通道的CNDTR寄存器值是否在变化CMAR寄存器地址是否正确。这是判断DMA是否正常工作的直接证据。打印调试信息如果系统有其他输出方式如另一个串口、LED灯可以在关键步骤如进入空闲中断、计算长度后点亮LED或者发送调试信息帮助定位程序卡在哪一步。最后串口通信是嵌入式开发中最基础也最考验细节的外设之一。从阻塞式到中断DMA是从“功能实现”到“性能优化”的必经之路。理解其原理小心缓冲区管理和中断冲突这些坑你的STM32项目在通信上就能有一个扎实的基础。无论是和OpenMVK210进行图像数据交互还是向上位机Python、C#发送传感器数据流这套异步处理机制都能让你的系统更加从容和高效。