1. 为什么你需要串口DMA?从“轮询苦等”到“后台自动”
如果你玩过STM32的串口,肯定经历过这样的场景:你想用printf打印一句“Hello World”到串口助手上看看,结果程序卡在那里,等啊等,直到最后一个字节发出去,CPU才能干别的事。或者,你想接收一串不定长的数据,只能开个中断,每来一个字节就打断一次主程序,搞得系统支离破碎,实时性大打折扣。这种体验,就像你开车时每走一米就得停下来看一眼地图,效率极低。
这就是传统串口轮询或中断收发模式的痛点——CPU参与度过高。发送时,CPU要忙前忙后搬运数据;接收时,CPU要频繁响应中断。在需要处理复杂逻辑、电机控制、传感器融合的现代嵌入式项目里,让CPU困在串口通信这种“体力活”上,实在是巨大的浪费。
而DMA(直接存储器访问),就是来解决这个问题的“外挂”。你可以把它想象成一个专职的快递小哥。以前,你需要亲自(CPU)把数据从内存(你家)搬到串口(快递站),一字节一字节地跑。现在,你只需要告诉DMA小哥:“把这堆货(数据)从A地址送到B地址”,然后你就可以转身去干更重要的事了(执行主循环逻辑)。搬运过程中,CPU完全被解放,只有当整批货物送完,DMA小哥才会回来轻轻拍你一下(触发一个中断)告诉你:“活儿干完了”。
在STM32CUBEMX的加持下,配置这个“快递小哥”变得异常简单。今天,我就以最常用的USART1为例,带你从零开始,手把手配置串口DMA收发,并打造一个比传统printf重定义更高效、更聪明的自定义打印函数Usart1Printf。目标是实现:数据收发全自动后台运行,调试信息输出即发即走不等待,让你的主程序流畅得像德芙一样。
2. STM32CUBEMX工程与串口DMA基础配置
咱们先搭好舞台。我用的芯片是STM32H743,但H7、F4、F1等系列的操作大同小异,CUBEMX的界面逻辑基本一致。
2.1 新建工程与引脚激活
打开STM32CUBEMX,选择你的芯片型号。在Pinout视图里,找到USART1。对于大多数STM32,USART1的默认引脚是PA9(TX)和PA10(RX)。你直接点击PA9,在弹出的功能列表里选择“USART1_TX”;同样地,点击PA10选择“USART1_RX”。选好后,这两个引脚会变成黄色,这表示功能已选定,但外设还未完整配置。
2.2 串口模式与参数设置
接下来,在左侧分类栏找到“Connectivity” -> “USART1”。点击后,右侧会出现配置面板。
- Mode(模式):选择“Asynchronous”(异步通信),这是我们最常用的模式。
- Basic Parameters(基本参数):
- Baud Rate(波特率):设为115200。这是和电脑串口助手通信的“语速”,两边必须一致。
- Word Length(字长):8 bits。一个字节8位,最通用。
- Parity(校验位):None。无校验,简化通信。
- Stop Bits(停止位):1。标准配置。
- 其他:硬件流控制(Hardware Flow Control)选择Disable,我们用不到RTS/CTS这些。
配置完这些,你会发现之前的PA9和PA10引脚变成了绿色,这说明USART1外设已经被成功激活并配置。
2.3 核心步骤:DMA发送与接收通道配置
这才是重头戏。在USART1的配置页面,找到“DMA Settings”这个标签页,点击“Add”。
添加发送DMA(Tx):
- 在“Channel”或“Request”下拉框(不同系列芯片名称略有不同)中,选择“USART1_TX”。
- Direction(方向):Memory To Peripheral(存储器到外设)。意思是数据从内存搬到串口发送寄存器。
- Priority(优先级):默认Low即可,除非有多个DMA竞争。
- Mode(模式):Normal(普通模式)。发送通常是单次任务,发完即止。
添加接收DMA(Rx):
- 再次点击“Add”,选择“USART1_RX”。
- Direction:Peripheral To Memory(外设到存储器)。数据从串口接收寄存器搬到内存。
- Priority:默认Low。
- Mode(关键!):这里强烈建议选择Circular(循环模式)。这是实现“后台自动接收”的灵魂!
循环模式(Circular)到底妙在哪?在普通模式下,DMA接收完指定长度的数据(比如100字节)后,就停止了,需要你手动重新启动。而在循环模式下,DMA就像一个环形跑道。它接收完这100字节后,会自动回到缓冲区开头,覆盖旧数据,继续接收新的数据。这意味着,只要你初始化时开启一次接收,它就能7x24小时不间断地在后台监听串口,完全不用CPU操心。你只需要定期去缓冲区检查有没有新数据就行。
2.4 别忘了中断!
虽然DMA解放了CPU,但有些“通知”还是需要的。在“NVIC Settings”标签页里,找到“USART1 global interrupt”,把它勾选上(Enabled)。这样,当DMA传输完成、或者串口发生错误时,才能产生中断通知CPU。有些型号不开启这个,DMA传输可能会出问题。
至此,CUBEMX的图形化配置就完成了。点击“GENERATE CODE”,生成你熟悉的Keil或IAR工程吧。
3. 发送实战:HAL库DMA发送与第一个坑
打开生成的工程,进入main.c。我们先来试试最简单的DMA发送。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); uint8_t sendBuffer[] = "Hello DMA!\r\n"; // 使用DMA发送数据 HAL_UART_Transmit_DMA(&huart1, sendBuffer, sizeof(sendBuffer)); while (1) { // 主循环可以安心做其他事情,发送由DMA在后台完成 } }烧录程序,打开串口助手(如XCOM、SecureCRT),设置好115200波特率,你应该就能看到“Hello DMA!”出现了。是不是很简单?函数HAL_UART_Transmit_DMA的三个参数分别是:串口句柄、数据数组指针、数据长度。
但是,这里马上会遇到第一个坑:连续发送的冲突。如果你在while(1)里不加延时地连续调用HAL_UART_Transmit_DMA,程序大概率会卡死。为什么?因为DMA发送需要时间。当你发起第二次发送请求时,第一次的DMA传输可能还没结束,DMA通道正处于“忙碌”状态。HAL库的HAL_UART_Transmit_DMA函数内部会检查DMA状态,如果发现忙碌,它会返回HAL_BUSY错误。
所以,一个健壮的发送策略必须包含状态检查。HAL库为我们提供了一个查询状态的函数:HAL_UART_GetState(&huart1)。我们可以检查它是否等于HAL_UART_STATE_READY。
// 一个简单的非阻塞发送检查 if(HAL_UART_GetState(&huart1) == HAL_UART_STATE_READY) { HAL_UART_Transmit_DMA(&huart1, sendBuffer, sizeof(sendBuffer)); }这样,只有串口(及其DMA)就绪时,才会发起新的发送请求。这是编写高效、稳定DMA发送逻辑的第一步。
4. 优化革命:告别阻塞的printf,打造高效Usart1Printf
很多教程教的重定义printf,是类似这样的:
int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, 1000); // 阻塞等待1000ms return len; }这本质是调用了轮询发送函数HAL_UART_Transmit,那个1000是超时时间(毫秒)。这意味着每次printf,CPU都要死等最多1秒钟!这在DMA时代是绝对不能接受的。
我们的目标是创建一个非阻塞、即发即走的自定义打印函数。它应该像printf一样好用,支持格式化,但内部使用DMA发送,调用后立即返回。
4.1 构建Usart1Printf函数
我们不在_write上重定义,而是新建一个更灵活的函数。在usart.c文件的用户代码区(/* USER CODE BEGIN 0 */和/* USER CODE END 0 */之间)添加以下代码:
/* USER CODE BEGIN 0 */ #include <stdarg.h> #include <stdio.h> // 定义一个发送缓冲区,大小根据你的需求调整,比如256字节 #define UART_TX_BUF_SIZE 256 static uint8_t uartTxBuffer[UART_TX_BUF_SIZE]; /** * @brief 通过USART1和DMA发送格式化字符串,非阻塞。 * @param format: 格式化字符串,用法同printf。 * @retval 无 */ void Usart1Printf(const char *format, ...) { va_list args; uint16_t strLength; // 1. 获取可变参数 va_start(args, format); // 2. 使用vsnprintf将格式化的数据安全地写入缓冲区 // 注意:vsnprintf返回的是**假如缓冲区足够大**时会写入的字符数(不包括结尾的'\0') strLength = vsnprintf((char*)uartTxBuffer, UART_TX_BUF_SIZE, format, args); va_end(args); // 3. 安全检查:确保生成的字符串长度不超过缓冲区(vsnprintf已保证不会溢出,但可能被截断) if (strLength >= UART_TX_BUF_SIZE) { strLength = UART_TX_BUF_SIZE - 1; // 留一个位置给可能的错误提示或截断 // 可选:在缓冲区末尾添加截断提示,如"..." // uartTxBuffer[UART_TX_BUF_SIZE - 4] = '.'; // uartTxBuffer[UART_TX_BUF_SIZE - 3] = '.'; // uartTxBuffer[UART_TX_BUF_SIZE - 2] = '.'; // uartTxBuffer[UART_TX_BUF_SIZE - 1] = '\0'; } // 4. 核心:使用DMA发送缓冲区数据,函数调用后立即返回 // 注意:这里没有等待DMA完成!这就是非阻塞的精髓。 HAL_UART_Transmit_DMA(&huart1, uartTxBuffer, strLength); } /* USER CODE END 0 */别忘了在usart.h文件的用户代码区声明这个函数,这样其他.c文件才能调用。
/* USER CODE BEGIN Prototypes */ void Usart1Printf(const char *format, ...); /* USER CODE END Prototypes */4.2 使用体验与优势
现在,你可以在主程序中像使用printf一样使用它:
int main(void) { // ... 初始化代码 uint32_t counter = 0; float voltage = 3.3f; while (1) { Usart1Printf("系统运行中,计数器:%lu, 电压:%.2fV\r\n", counter++, voltage); HAL_Delay(1000); // 模拟执行其他任务 // 注意:这里的Delay不是为了等串口发完,而是为了控制打印频率。 // DMA发送在Usart1Printf调用瞬间就开始了,此时CPU已经在执行HAL_Delay了。 } }优势一目了然:
- 非阻塞:调用
Usart1Printf后瞬间返回,CPU无需等待数据发送完毕。 - 格式化支持:完整支持
%d,%f,%s,%x等所有printf格式化功能。 - 安全:使用
vsnprintf和固定大小的缓冲区,防止内存溢出。 - 高效:一次性将格式化好的整个字符串打包,通过DMA发送,效率远高于逐字节发送。
5. 接收实战:循环DMA实现后台自动接收与数据处理
发送搞定了,接收才是DMA发挥威力的地方。还记得我们在CUBEMX里把接收DMA设为Circular(循环)模式吗?现在来用它。
5.1 开启循环接收
在main函数初始化完所有外设后,开启DMA接收。
// 定义一个接收缓冲区 #define UART_RX_BUF_SIZE 200 uint8_t uartRxBuffer[UART_RX_BUF_SIZE]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); // 关键一步:启动串口DMA循环接收 // 函数调用一次,永久生效。DMA会持续在后台将串口数据搬运到uartRxBuffer中。 HAL_UART_Receive_DMA(&huart1, uartRxBuffer, UART_RX_BUF_SIZE); // ... 其他初始化 while (1) { // 主循环 } }就这么一行代码,你的串口接收就变成了全自动的。数据会源源不断地填充到uartRxBuffer这个环形缓冲区里,新数据会覆盖旧数据。
5.2 如何读取接收到的数据?—— 指针差法
数据在后台存好了,我们怎么知道收到了多少新数据呢?HAL库为我们管理了两个重要的计数器:
hdma_usart1_rx.Instance->CNDTR:这个寄存器表示DMA还剩多少数据要传输(当前传输计数)。在循环模式下,它会从你设置的长度(UART_RX_BUF_SIZE)开始递减,减到0又回到设定值,如此循环。- 我们可以通过计算,得知已经接收了多少数据。
一个常用的技巧是计算“写指针”的位置:
// 获取当前DMA的“写指针”位置(即下一个数据将存放的地址) uint32_t dma_write_index = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);__HAL_DMA_GET_COUNTER这个宏就是读取CNDTR寄存器。dma_write_index的值会在0到UART_RX_BUF_SIZE-1之间循环。
5.3 实现一个简单的数据帧解析例程
假设我们约定,一帧数据以回车符\r(0x0D)结束。我们可以在主循环里定期检查缓冲区,寻找完整的帧。
// 全局变量,记录上一次处理到的位置 static uint32_t lastProcessedIndex = 0; void ProcessUartData(void) { uint32_t currentWriteIndex = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 如果写指针超过了上次处理的位置 if (currentWriteIndex != lastProcessedIndex) { // 从lastProcessedIndex开始,到currentWriteIndex结束(注意环形缓冲区的回绕处理) uint32_t i = lastProcessedIndex; while (i != currentWriteIndex) { if (uartRxBuffer[i] == '\r') // 找到帧尾 { // 计算帧起始位置(这里简化处理,假设帧起始就是lastProcessedIndex) // 实际项目中可能需要更复杂的协议,如帧头、校验等 uint32_t frameStart = lastProcessedIndex; uint32_t frameLength = (i > frameStart) ? (i - frameStart) : (UART_RX_BUF_SIZE - frameStart + i); // 这里可以调用一个函数来处理这一帧数据,例如: // ParseFrame(&uartRxBuffer[frameStart], frameLength); Usart1Printf("[RX]收到一帧数据,长度:%lu\r\n", frameLength); // 更新处理位置为帧尾的下一个位置 lastProcessedIndex = (i + 1) % UART_RX_BUF_SIZE; break; // 处理完一帧,跳出循环 } i = (i + 1) % UART_RX_BUF_SIZE; // 环形递增 } // 如果没找到帧尾,可以更新lastProcessedIndex,或者等待更多数据 // 这里简单地将lastProcessedIndex移动到currentWriteIndex,等待下一次调用 lastProcessedIndex = currentWriteIndex; } } // 在主循环中调用 while (1) { ProcessUartData(); // 处理串口数据 // ... 执行其他任务 HAL_Delay(1); // 适当延时,避免过于频繁检查 }这个例程展示了如何在非中断、主循环轮询的方式下,处理DMA循环接收的数据。它没有使用串口接收中断,因此对主循环的打断几乎为零,实时性非常好。对于更复杂的协议,你只需要增强ParseFrame函数即可。
6. 进阶技巧与避坑指南
在实际项目中,仅仅能收发还不够,稳定性和可靠性是关键。下面分享几个我踩过坑后总结的进阶技巧。
6.1 发送完成回调与资源管理
我们之前的Usart1Printf函数有个潜在问题:如果上一次DMA发送还没结束,就立刻调用下一次,会因为DMA通道忙碌而失败(返回HAL_BUSY)。一个更完善的方案是利用发送完成回调函数。
HAL库允许我们注册一个回调函数,当DMA发送完成时,它会自动调用。我们可以在usart.c中重写这个回调:
/* USER CODE BEGIN 1 */ // 定义一个发送状态标志 volatile uint8_t uart1TxDmaReady = 1; // 1表示就绪 // DMA发送完成中断回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart1TxDmaReady = 1; // 发送完成,标志位置位 } } // 增强版的Usart1Printf,带发送状态检查 void Usart1Printf_Enhanced(const char *format, ...) { va_list args; uint16_t len; if (!uart1TxDmaReady) { // 如果DMA正在忙,可以选择丢弃本次数据、存入队列等待,或者直接返回。 // 这里简单返回,在实际项目中可能需要一个FIFO队列来缓冲数据。 return; } va_start(args, format); len = vsnprintf((char*)uartTxBuffer, UART_TX_BUF_SIZE, format, args); va_end(args); if (len >= UART_TX_BUF_SIZE) len = UART_TX_BUF_SIZE - 1; uart1TxDmaReady = 0; // 即将开始发送,标志位清零 HAL_UART_Transmit_DMA(&huart1, uartTxBuffer, len); } /* USER CODE END 1 */这样,只有当前一次发送完成后,才会启动下一次发送,避免了数据覆盖和发送失败。对于需要连续、高速打印日志的场景,你应该实现一个**环形缓冲区(FIFO)**作为发送队列,在TxCpltCallback中从队列取出下一包数据发送,这才是最专业的做法。
6.2 接收溢出与错误处理
串口通信可能受到干扰。DMA循环接收虽然省心,但如果主程序处理数据的速度跟不上接收的速度,新数据就会覆盖还未处理的旧数据,造成数据丢失。这就是溢出的一种形式。
HAL库提供了错误处理回调函数:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint32_t errorCode = huart->ErrorCode; if (errorCode & HAL_UART_ERROR_ORE) // 溢出错误 { // 发生溢出,可能需要清空缓冲区,重新启动DMA接收 __HAL_UART_CLEAR_OREFLAG(huart); // 清除溢出标志 // 注意:在H7等系列中,清除标志后可能需要重新启动接收 // HAL_UART_Receive_DMA(&huart1, uartRxBuffer, UART_RX_BUF_SIZE); } // 还可以处理帧错误、噪声错误等 huart->ErrorCode = HAL_UART_ERROR_NONE; // 清除错误码 } }良好的错误处理机制能让你的产品在恶劣的电气环境中也更稳定。
6.3 多串口与资源分配
如果你的项目需要多个串口(例如,一个用于调试打印,一个用于连接蓝牙模块,一个用于连接GPS),配置方法是类似的。在CUBEMX中为每个USART分别配置引脚和DMA通道即可。在代码中,你需要:
- 为每个串口定义独立的发送/接收缓冲区。
- 为每个串口编写独立的
UartxPrintf函数(或设计成传入句柄的通用函数)。 - 在各自的回调函数中通过判断
huart->Instance来区分是哪个串口产生的事件。 - 注意DMA通道和流(Stream)的分配不要冲突,CUBEMX通常会帮你安排好,但复杂项目需手动检查。
6.4 调试技巧:如何观察DMA缓冲区?
在调试时,你可以将uartRxBuffer数组添加到IDE的实时变量观察窗口(Watch)。结合dma_write_index的计算,你可以直观地看到数据是如何被填入这个环形缓冲区的,这对于调试通信协议非常有帮助。另外,可以在ProcessUartData函数中设置断点,观察帧解析逻辑是否正确。
7. 总结与项目集成建议
走到这里,你已经掌握了使用STM32CUBEMX配置串口DMA进行高效收发的全流程,并拥有了一个强大的非阻塞打印工具。让我们回顾一下核心收获:
- 配置流程固化:CUBEMX图形化配置(引脚、模式、DMA循环接收、中断)-> 生成代码 -> 一行代码开启接收。
- 发送优化:用
Usart1Printf替代传统重定义printf,实现格式化字符串的非阻塞DMA发送,彻底解放CPU。 - 接收自动化:利用DMA循环模式,实现“一次初始化,永久后台接收”,再通过指针差法在主循环中轮询处理数据,兼顾效率和实时性。
- 鲁棒性增强:通过回调函数管理发送状态,处理通信错误,构建稳定可靠的通信链路。
在实际项目集成时,我建议你:
- 将
Usart1Printf、缓冲区、回调函数等封装到一个独立的uart_driver.c/.h文件中,提高代码模块化程度。 - 对于发送,根据项目日志量评估是否需要引入FIFO队列。如果只是偶尔打印状态,简单的状态标志就够了;如果是高速数据流,队列是必须的。
- 对于接收,根据你的通信协议(Modbus、自定义二进制协议、文本协议等)精心设计
ProcessUartData函数。处理好环形缓冲区的回绕、半包、粘包问题。 - 在资源紧张的单片机上,合理分配DMA缓冲区大小。调试缓冲区可以大一些(如256字节),而用于稳定通信的协议缓冲区可能只需要几十字节。
最后,嵌入式开发没有银弹,DMA也不是万能的。它用硬件资源(DMA通道)换取了CPU时间。当你需要同时进行ADC采样、SPI通信、串口收发时,需要仔细规划DMA通道的使用优先级,避免冲突。多动手测试,结合逻辑分析仪或示波器观察时序,才能真正驾驭好DMA这把利剑,让你的STM32项目跑得既快又稳。