STM32 DMA串口通信实战:从原理到避坑指南
在嵌入式开发中当需要处理大量数据的高速传输时比如采集传感器数据流、刷新LCD屏幕或进行音频播放如果让CPU亲自搬运每一个字节会严重消耗其计算资源导致系统响应变慢甚至卡顿。DMA直接存储器访问技术就是为了将CPU从这种繁琐的“搬运工”角色中解放出来而生的。然而对于初次接触DMA的开发者尤其是使用STM32等MCU的初学者配置过程往往伴随着各种“坑”中断不触发、数据传输出错、配置顺序混乱等网上资料虽多但零散不成体系。本文将以STM32的HAL库为例结合串口USART这一最常用外设为你梳理一份从核心概念到实战避坑的完整指南。无论你是刚接触STM32的新手还是在使用DMA过程中遇到问题的开发者都能从中找到清晰的步骤、可复用的代码以及那些容易忽略的关键细节。我们将重点拆解DMA的配置流程、中断处理机制并针对“数据到底传完没有”、“如何接收不定长数据”等高频痛点提供解决方案。1. DMA核心概念与工作原理在深入代码之前必须理解DMA是什么以及它是如何工作的。这将帮助你从根源上理解配置项的含义而不是机械地复制粘贴。1.1 DMA是什么DMA全称Direct Memory Access直接存储器访问是一种允许特定硬件子系统外设直接读写系统内存而无需中央处理器CPU介入的技术。你可以把它想象成一个高效的“快递员”。没有DMA时CPU搬运外设如串口收到一个字节 - 触发中断 - CPU暂停当前工作 - CPU从外设数据寄存器读取该字节 - CPU将其写入目标内存地址 - CPU恢复之前工作。这个过程对于单个字节尚可但对于连续不断的字节流CPU将疲于奔命。有DMA时DMA搬运外设如串口收到一个字节 - 通知DMA控制器 - DMA控制器直接从外设数据寄存器读取该字节 - DMA控制器直接将其写入预先设定好的内存地址 - 整个过程中CPU可以继续执行主程序。只有当一整批数据例如100个字节传输完成DMA才会通知CPU“快递送完了”CPU再来处理这批数据。核心价值解放CPU提高系统整体效率和实时性。1.2 DMA的关键组件与工作流程以STM32的DMA控制器为例理解以下几个关键概念至关重要通道ChannelDMA控制器有多个通道每个通道专门服务于一个或一组特定的外设如USART1的发送、ADC1等。配置时必须为你的外设选择正确的通道。这是第一个容易出错的地方。流Stream仅存在于STM32F4/F7/H7等系列在更高级的系列中DMA控制器被组织为“流”。每个流可以配置到不同的通道上提供了更大的灵活性但配置也稍复杂。源地址Source Address数据从哪里来。可能是外设的数据寄存器地址如USART1-DR也可能是内存中的某个数组地址。目标地址Destination Address数据到哪里去。与源地址相反。传输计数器Data Number要传输的数据量单位可以是字节、半字、字。这是决定“快递员跑多少趟”的关键参数。传输模式外设到内存Peripheral-to-Memory例如串口接收源是串口数据寄存器目标是内存数组。内存到外设Memory-to-Peripheral例如串口发送源是内存数组目标是串口数据寄存器。内存到内存Memory-to-Memory在两个内存区域间搬运数据某些DMA控制器支持。中断InterruptDMA传输过程中可以产生多种中断最常用的是传输完成中断Transfer Complete Interrupt当传输计数器减到0时触发。常用于发送完成通知或接收定长数据完成。半传输中断Half Transfer Interrupt当传输计数器减到一半时触发。常用于双缓冲乒乓缓冲模式实现数据的无缝处理。传输错误中断Transfer Error Interrupt传输发生错误时触发。理解了这些再看配置代码就不会觉得是一堆神秘的魔法数字了。2. 环境准备与工程搭建我们以STM32F103C8T6Blue Pill板和STM32CubeMX Keil MDK开发环境为例。这套组合在初学者中非常普及原理同样适用于其他型号和HAL库环境。2.1 硬件与软件清单MCUSTM32F103C8T6。开发环境STM32CubeMX用于图形化配置引脚、时钟、外设和中间件并生成初始化代码。版本建议使用较新的稳定版如6.9.x。Keil MDK-ARM或STM32CubeIDE用于编写业务代码、编译和调试。本文示例代码基于Keil。串口工具一根USB转TTL串口线用于连接MCU的USART1和电脑以及一个串口调试助手如XCOM、SecureCRT等。2.2 使用STM32CubeMX生成基础工程新建项目打开CubeMX选择STM32F103C8T6。配置时钟在RCC配置中将HSE设置为Crystal/Ceramic Resonator为外部晶振。配置USART1在Connectivity下找到USART1。将模式Mode设置为Asynchronous异步通信。配置基本参数波特率Baud Rate设为115200字长Word Length8 Bits停止位Stop Bits1无校验ParityNone。关键步骤在DMA Settings选项卡中点击Add添加DMA请求。对于发送TX选择USART1_TX模式Mode设为Normal普通模式优先级Priority设为Medium。内存地址自增Increment Address外设地址不增。对于接收RX选择USART1_RX模式同样设为Normal优先级Medium。内存地址自增外设地址不增。注意STM32F1系列只有DMA1通道映射是固定的。USART1_TX对应DMA1 Channel4USART1_RX对应DMA1 Channel5。CubeMX会自动选择。配置NVIC中断控制器在NVIC Configuration中使能USART1 global interrupt串口本身的中断用于空闲中断等。使能DMA相关的中断。找到DMA1 channel4 global interruptUSART1 TX和DMA1 channel5 global interruptUSART1 RX并勾选使能。生成代码在Project Manager中设置项目名称、路径、选择MDK-ARM作为Toolchain/IDE。在Code Generator中选择Copy all used libraries into the project folder并勾选Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral这样外设代码会更清晰。点击GENERATE CODE生成工程。至此一个包含了USART1和其DMA发送/接收基本初始化的Keil工程就生成了。接下来我们进入核心的代码编写与避坑环节。3. DMA发送数据详解与避坑发送数据相对简单核心是启动DMA传输并知道数据何时发送完毕。3.1 发送流程与代码实现在生成的工程中打开main.c我们首先定义发送缓冲区。/* Private variables ---------------------------------------------------------*/ UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_tx; DMA_HandleTypeDef hdma_usart1_rx; /* 定义发送缓冲区 */ uint8_t tx_buffer[] Hello, DMA!\r\n; #define TX_BUFFER_SIZE (sizeof(tx_buffer) - 1) // 注意减去字符串结尾的\0 /* 用户代码开始 */在main函数初始化完成后/* USER CODE BEGIN 2 */之后我们可以启动一次DMA发送。/* USER CODE BEGIN 2 */ // 启动一次DMA发送 if (HAL_UART_Transmit_DMA(huart1, tx_buffer, TX_BUFFER_SIZE) ! HAL_OK) { // 发送启动失败处理例如点亮错误LED Error_Handler(); } /* USER CODE END 2 */3.2 关键避坑点如何判断发送完成这是新手最常遇到的问题。调用HAL_UART_Transmit_DMA后函数立即返回但数据还在由DMA慢慢发送。如何知道它发完了方法一轮询标志位不推荐在主循环中阻塞// 在主循环中轮询会阻塞CPU while(__HAL_DMA_GET_FLAG(hdma_usart1_tx, DMA_FLAG_TC4) RESET) { // 等待发送完成期间CPU空转 } // 发送完成清除标志位 __HAL_DMA_CLEAR_FLAG(hdma_usart1_tx, DMA_FLAG_TC4);缺点失去了使用DMA解放CPU的意义。方法二使用发送完成中断推荐这是最正确的方式。当DMA传输计数器归零会触发传输完成中断TC。我们需要在中断回调函数中处理。确保中断已使能在CubeMX中我们已经使能了DMA通道的中断。实现回调函数HAL库使用弱定义__weak的回调函数我们需要在用户文件中重新实现它。通常在main.c或单独的uart.c文件中。/* USER CODE BEGIN 4 */ /** * brief Tx 传输完成回调函数. * param huart: UART句柄指针 * retval None */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // USART1 DMA发送完成可以在这里进行后续操作 // 例如点亮一个LED或者准备下一包数据 // HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 重要如果需要连续发送不要在这里直接再次调用 HAL_UART_Transmit_DMA // 因为中断上下文不宜执行耗时操作。更好的方法是设置一个标志在主循环中处理。 // tx_complete_flag 1; } } /* USER CODE END 4 */避坑重点不要在中断回调中执行耗时任务如HAL_Delay、复杂的计算或再次启动可能阻塞的DMA传输。应设置标志位在主循环中处理。区分中断源一个工程可能有多个串口回调函数第一件事就是判断是哪个串口触发的中断。发送完成后DMA通道可能被禁用对于Normal模式一次传输完成后DMA通道会自动禁用。如果你想再次使用同一个DMA通道发送直接调用HAL_UART_Transmit_DMA即可HAL库会重新配置并启动DMA。但务必确保上一次传输确实已完成否则可能导致配置冲突。4. DMA接收数据详解与高阶应用接收数据尤其是不定长数据是DMA应用的难点和重点。4.1 定长数据接收定长接收和发送类似调用HAL_UART_Receive_DMA并指定长度即可在传输完成中断HAL_UART_RxCpltCallback中处理数据。/* 定义接收缓冲区 */ uint8_t rx_buffer[100]; #define RX_BUFFER_SIZE 100 /* 在main初始化后启动DMA接收 */ if (HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); } /* 实现接收完成回调函数 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 收到了RX_BUFFER_SIZE个字节的数据 // 处理 rx_buffer 中的数据... // 处理完后如果想继续接收必须重新启动DMA接收 // 因为Normal模式下DMA传输完成后通道会禁用 if (HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); } } }问题如果一帧数据不是固定的100字节比如有时是10字节有时是20字节定长接收就不适用了。数据可能分多次接收解析困难。4.2 不定长数据接收空闲中断IDLE DMA这是处理串口通信协议如Modbus的黄金组合。原理是DMA始终在后台循环接收数据存放到一个环形缓冲区。串口总线在数据包之间会有空闲时间停止位后持续1个字节时间的高电平。串口检测到这种“空闲”状态时会产生一个空闲中断IDLE。在空闲中断中我们可以通过计算DMA当前传输计数器的变化推算出从DMA开始接收到空闲发生时一共收到了多少个字节从而截取出一帧完整的数据。实现步骤开启串口空闲中断CubeMX默认不会开启。需要在代码中手动开启。/* 在UART初始化函数MX_USART1_UART_Init的最后用户代码区域添加 */ __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);创建环形接收缓冲区并启动DMA接收#define RX_DMA_BUFFER_SIZE 256 // DMA缓冲区可以设大一些 uint8_t rx_dma_buffer[RX_DMA_BUFFER_SIZE]; // 在main初始化后启动DMA接收让DMA一直循环接收 if (HAL_UART_Receive_DMA(huart1, rx_dma_buffer, RX_DMA_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); }注意这里DMA配置为Circular循环模式更合适但CubeMX为USART RX生成的通常是Normal模式。我们可以在代码中修改句柄或者直接在CubeMX的DMA配置里将RX的模式改为Circular。循环模式下DMA收到缓冲区末尾后会自动回到开头无需手动重启。编写串口全局中断服务函数我们需要在中断中检测空闲中断标志。 HAL库的中断服务函数USART1_IRQHandler在stm32f1xx_it.c中已经写好它会调用HAL_UART_IRQHandler。我们需要在HAL_UART_IRQHandler处理完基础中断后检查空闲中断。/* 在stm32f1xx_it.c的USART1_IRQHandler函数中或更好的做法是 */ /* 重写HAL_UART_IRQHandler中被调用的UART空闲中断处理部分但更简单的方法是 */ /* 在main.c中利用HAL库的回调机制但HAL库没有直接的空闲中断回调。 */ /* 因此通常做法是自定义一个函数在HAL_UART_IRQHandler之后被调用。 */ /* 更清晰的做法在main.c的USER CODE BEGIN 4区域编写一个函数并在stm32f1xx_it.c中调用它 */更常见的实践是在main.c中编写一个空闲中断处理函数然后在stm32f1xx_it.c的USART1_IRQHandler末尾调用。在main.c中/* 外部声明DMA句柄 */ extern DMA_HandleTypeDef hdma_usart1_rx; void USART1_IDLE_Handler(UART_HandleTypeDef *huart) { if(huart-Instance USART1) { // 1. 检查是否是空闲中断 if(__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) ! RESET) { // 2. 清除空闲中断标志通过读SR寄存器再读DR寄存器 __HAL_UART_CLEAR_IDLEFLAG(huart); // 3. 暂停DMA防止处理过程中数据被覆盖 HAL_UART_DMAStop(huart); // 4. 计算本次接收到的数据长度 // DMA当前剩余传输计数 当前CNDTR寄存器的值 uint16_t remain_cnt __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 本次接收到的数据长度 总缓冲区大小 - 剩余计数 uint16_t recv_len RX_DMA_BUFFER_SIZE - remain_cnt; if(recv_len 0) { // 5. 处理数据rx_dma_buffer[0] 到 rx_dma_buffer[recv_len-1] 是本次收到的数据 // user_process_data(rx_dma_buffer, recv_len); // 6. 重置DMA缓冲区指针和计数器重新启动DMA接收 // 注意因为之前暂停了DMA需要重新设置内存地址和数据长度 hdma_usart1_rx.Instance-CNDTR RX_DMA_BUFFER_SIZE; // 重置传输计数器 hdma_usart1_rx.Instance-CMAR (uint32_t)rx_dma_buffer; // 重置内存地址 __HAL_DMA_ENABLE(hdma_usart1_rx); // 使能DMA通道 // 或者更简单地使用HAL库函数重启对于Circular模式重启可能更简单 // HAL_UART_Receive_DMA(huart, rx_dma_buffer, RX_DMA_BUFFER_SIZE); } } } }在stm32f1xx_it.c中修改/** * brief This function handles USART1 global interrupt. */ void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ /* 调用自定义的空闲中断处理 */ USART1_IDLE_Handler(huart1); /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* 也可以在这里处理但需注意顺序 */ /* USER CODE END USART1_IRQn 1 */ }避坑重点清除空闲标志必须通过先读SR寄存器再读DR寄存器的方式来清除IDLE标志直接操作寄存器__HAL_UART_CLEAR_IDLEFLAG(huart)封装了这个操作。暂停DMA在计算长度和处理数据前务必暂停DMA (HAL_UART_DMAStop)否则DMA可能在你处理数据时继续修改缓冲区。长度计算CNDTR寄存器是递减计数器表示剩余要传输的数据量。已传输的数据量 初始设置的长度 - 当前CNDTR值。缓冲区溢出如果数据接收过快而处理速度慢可能导致DMA缓冲区被覆盖。循环模式DMA双缓冲是更高级的解决方案。重启DMA处理完数据后必须正确重置DMA的指针和计数器并重新使能通道。对于Circular模式有时简单的重启HAL_UART_Receive_DMA即可。5. 常见问题与排查思路问题现象可能原因排查思路与解决方案DMA发送/接收无法启动返回HAL_ERROR1. DMA或UART外设时钟未使能。2. DMA句柄或UART句柄未正确初始化。3. 内存缓冲区地址非法如NULL。4. 上一次DMA传输未完成通道仍处于忙碌状态。1. 检查CubeMX中相关外设的时钟配置确保__HAL_RCC_DMA1_CLK_ENABLE()和__HAL_RCC_USART1_CLK_ENABLE()被调用。2. 确保MX_DMA_Init()和MX_USART1_UART_Init()在HAL_UART_Transmit_DMA之前被调用。3. 检查传入的缓冲区指针是否有效。4. 在启动新传输前检查hdma_state是否为HAL_DMA_STATE_READY或使用HAL_DMA_GetState()。数据发送/接收不完整1. 传输计数器Data Number设置错误。2. 内存或外设地址递增模式配置错误。3. 缓冲区大小不足以容纳数据。4. 中断优先级冲突导致DMA传输被其他高优先级中断长时间阻塞。1. 确认调用HAL函数时传入的长度参数是否正确。注意字符串长度是否包含结束符\0。2. 发送时内存地址应递增外设地址USART-DR不递增。接收时同理。3. 确保接收缓冲区大小 预期数据长度。4. 检查NVIC优先级分组和分配确保DMA中断有合适的优先级。发送完成中断或接收完成中断不触发1. 中断未使能NVIC配置。2. 中断服务函数IRQHandler未实现或未正确映射。3. 回调函数未重写仍是弱定义。4. DMA工作在循环模式Circular不会产生传输完成中断。1. 在CubeMX的NVIC Configuration中确认DMA通道中断已勾选。2. 确认启动文件startup_stm32f103xb.s中中断向量表正确且stm32f1xx_it.c中的中断服务函数名正确。3. 在main.c等用户文件中实现HAL_UART_TxCpltCallback或RxCpltCallback。4. 若需要完成中断将DMA模式改为Normal。空闲中断IDLE不触发1. 空闲中断未使能。2. 空闲中断标志未正确清除导致后续中断被屏蔽。3. 串口通信线路有持续噪声导致总线从未进入空闲状态。1. 在UART初始化后调用__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)。2. 确保在中断服务函数中正确清除了IDLE标志使用__HAL_UART_CLEAR_IDLEFLAG。3. 检查硬件连接确保TX/RX线稳定无干扰。使用DMA空闲中断接收数据长度计算错误1. DMA传输计数器CNDTR理解错误。2. 在计算长度前DMA仍在运行计数器已变化。3. 缓冲区为循环模式但计算方式未考虑指针回绕。1. 牢记已接收长度 初始设置长度 - 当前CNDTR值。2.必须先暂停DMA (HAL_UART_DMAStop)再计算长度。3. 对于循环缓冲区需要结合当前DMA的读/写指针位置来计算有效数据区间逻辑更复杂。建议初学者先用Normal模式重启的方式实现不定长接收。同时使用DMA发送和接收时冲突1. 发送和接收使用了同一个DMA通道不可能硬件固定。2. 内存缓冲区定义在不可被DMA访问的区域如CCM内存对于某些型号。3. 发送/接收函数在中断中嵌套调用导致状态机混乱。1. STM32的USART_TX和USART_RX有独立的DMA通道硬件上不冲突。2. 确保缓冲区定义在普通SRAM区全局数组即可。3. 避免在中断回调如发送完成中断中直接调用可能阻塞或需要等待的HAL_DMA函数。通过标志位在主循环中处理。6. 最佳实践与工程建议掌握了基础操作和排错方法后遵循以下最佳实践能让你的DMA应用更稳定、更高效。初始化顺序在main函数中先初始化DMAMX_DMA_Init再初始化依赖DMA的外设如MX_USART1_UART_Init。因为外设初始化函数中可能会配置DMA句柄。缓冲区对齐对于要求数据对齐的外设如某些ADC或者为了提升DMA传输效率可以考虑将缓冲区进行内存对齐。例如使用GCC/ARMCC的__attribute__((aligned(4)))。uint8_t rx_buffer[256] __attribute__((aligned(4)));双缓冲乒乓缓冲对于高速连续数据流如音频、图像采集使用DMA双缓冲可以几乎消除数据处理带来的延迟。原理是DMA在两个缓冲区之间切换当其中一个缓冲区满半满时触发中断CPU处理此缓冲区数据同时DMA向另一个缓冲区填充数据。通过使能DMA的“半传输中断”和“传输完成中断”来实现。错误处理始终检查HAL库函数的返回值HAL_OK,HAL_ERROR,HAL_BUSY,HAL_TIMEOUT。对于DMA启动函数如果返回HAL_BUSY说明上一次传输未结束应等待或采取错误恢复策略。资源管理与状态机在复杂的应用中不要简单地在中断里重启DMA。设计一个清晰的状态机用标志位来通知主循环“发送完成”、“收到一帧数据”、“DMA错误”等事件在主循环中统一调度处理。这能提高系统的可维护性和稳定性。使用volatile关键字被DMA和CPU共同访问的缓冲区变量应使用volatile关键字声明防止编译器进行激进的优化如将读操作缓存到寄存器导致CPU看不到DMA更新后的数据。volatile uint8_t dma_shared_buffer[1024];文档与注释在配置DMA时特别是通道、流、优先级这些硬件相关的选择上添加注释说明其对应关系如// DMA1 Channel4 for USART1_TX。这会在后期调试或移植代码时带来巨大便利。从理解DMA作为“高效快递员”的核心思想到一步步配置STM32CubeMX生成代码再到实现发送完成中断和“空闲中断DMA”这一经典的不定长接收方案我们不仅跨越了配置的坑也深入到了机制原理层面。嵌入式开发中像DMA这样的底层硬件特性是提升系统性能的关键。建议你亲手在开发板上实践本文的每一个示例观察现象并尝试修改参数如缓冲区大小、DMA模式来加深理解。当你熟练掌握了这些便可以进一步探索更高级的应用如DMA双缓冲、内存到内存传输、以及配合其他外设如ADC、SPI、I2S的使用从而为你更复杂的嵌入式项目打下坚实的基础。如果在实践中遇到新的问题不妨回头从DMA的基本工作原理和配置流程重新梳理往往能更快地找到突破口。