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控制器为例,理解以下几个关键概念至关重要:
- 通道(Channel):DMA控制器有多个通道,每个通道专门服务于一个或一组特定的外设(如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控制器支持。
- 中断(Interrupt):DMA传输过程中可以产生多种中断,最常用的是:
- 传输完成中断(Transfer Complete Interrupt):当传输计数器减到0时触发。常用于发送完成通知或接收定长数据完成。
- 半传输中断(Half Transfer Interrupt):当传输计数器减到一半时触发。常用于双缓冲(乒乓缓冲)模式,实现数据的无缝处理。
- 传输错误中断(Transfer Error Interrupt):传输发生错误时触发。
理解了这些,再看配置代码就不会觉得是一堆神秘的魔法数字了。
2. 环境准备与工程搭建
我们以STM32F103C8T6(Blue Pill板)和STM32CubeMX + Keil MDK开发环境为例。这套组合在初学者中非常普及,原理同样适用于其他型号和HAL库环境。
2.1 硬件与软件清单
- MCU:STM32F103C8T6。
- 开发环境:
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 Length)8 Bits,停止位(Stop Bits)1,无校验(Parity)None。 - 关键步骤:在
DMA Settings选项卡中,点击Add添加DMA请求。- 对于发送(TX):选择
USART1_TX,模式(Mode)设为Normal(普通模式),优先级(Priority)设为Medium。内存地址自增(Increment Address),外设地址不增。 - 对于接收(RX):选择
USART1_RX,模式同样设为Normal,优先级Medium。内存地址自增,外设地址不增。 - 注意:STM32F1系列只有DMA1,通道映射是固定的。USART1_TX对应DMA1 Channel4,USART1_RX对应DMA1 Channel5。CubeMX会自动选择。
- 对于发送(TX):选择
- 在
- 配置NVIC(中断控制器):
- 在
NVIC Configuration中,使能USART1 global interrupt(串口本身的中断,用于空闲中断等)。 - 使能DMA相关的中断。找到
DMA1 channel4 global interrupt(USART1 TX)和DMA1 channel5 global interrupt(USART1 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_ERROR | 1. 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函数中,先初始化DMA(MX_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的基本工作原理和配置流程重新梳理,往往能更快地找到突破口。
