news 2026/8/30 12:21:08

STM32CUBEMX实战指南(九):串口DMA高效收发与自定义打印优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32CUBEMX实战指南(九):串口DMA高效收发与自定义打印优化

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”。点击后,右侧会出现配置面板。

  1. Mode(模式):选择“Asynchronous”(异步通信),这是我们最常用的模式。
  2. 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了。 } }

优势一目了然

  1. 非阻塞:调用Usart1Printf后瞬间返回,CPU无需等待数据发送完毕。
  2. 格式化支持:完整支持%d,%f,%s,%x等所有printf格式化功能。
  3. 安全:使用vsnprintf和固定大小的缓冲区,防止内存溢出。
  4. 高效:一次性将格式化好的整个字符串打包,通过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通道即可。在代码中,你需要:

  1. 为每个串口定义独立的发送/接收缓冲区。
  2. 为每个串口编写独立的UartxPrintf函数(或设计成传入句柄的通用函数)。
  3. 在各自的回调函数中通过判断huart->Instance来区分是哪个串口产生的事件。
  4. 注意DMA通道和流(Stream)的分配不要冲突,CUBEMX通常会帮你安排好,但复杂项目需手动检查。

6.4 调试技巧:如何观察DMA缓冲区?

在调试时,你可以将uartRxBuffer数组添加到IDE的实时变量观察窗口(Watch)。结合dma_write_index的计算,你可以直观地看到数据是如何被填入这个环形缓冲区的,这对于调试通信协议非常有帮助。另外,可以在ProcessUartData函数中设置断点,观察帧解析逻辑是否正确。

7. 总结与项目集成建议

走到这里,你已经掌握了使用STM32CUBEMX配置串口DMA进行高效收发的全流程,并拥有了一个强大的非阻塞打印工具。让我们回顾一下核心收获:

  1. 配置流程固化:CUBEMX图形化配置(引脚、模式、DMA循环接收、中断)-> 生成代码 -> 一行代码开启接收。
  2. 发送优化:用Usart1Printf替代传统重定义printf,实现格式化字符串的非阻塞DMA发送,彻底解放CPU。
  3. 接收自动化:利用DMA循环模式,实现“一次初始化,永久后台接收”,再通过指针差法在主循环中轮询处理数据,兼顾效率和实时性。
  4. 鲁棒性增强:通过回调函数管理发送状态,处理通信错误,构建稳定可靠的通信链路。

在实际项目集成时,我建议你:

  • Usart1Printf、缓冲区、回调函数等封装到一个独立的uart_driver.c/.h文件中,提高代码模块化程度。
  • 对于发送,根据项目日志量评估是否需要引入FIFO队列。如果只是偶尔打印状态,简单的状态标志就够了;如果是高速数据流,队列是必须的。
  • 对于接收,根据你的通信协议(Modbus、自定义二进制协议、文本协议等)精心设计ProcessUartData函数。处理好环形缓冲区的回绕、半包、粘包问题。
  • 在资源紧张的单片机上,合理分配DMA缓冲区大小。调试缓冲区可以大一些(如256字节),而用于稳定通信的协议缓冲区可能只需要几十字节。

最后,嵌入式开发没有银弹,DMA也不是万能的。它用硬件资源(DMA通道)换取了CPU时间。当你需要同时进行ADC采样、SPI通信、串口收发时,需要仔细规划DMA通道的使用优先级,避免冲突。多动手测试,结合逻辑分析仪或示波器观察时序,才能真正驾驭好DMA这把利剑,让你的STM32项目跑得既快又稳。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/14 17:16:22

解放双手!用这个Node.js脚本保留Word模板样式批量生成100+文档

解放双手&#xff01;用Node.js脚本保留Word模板样式批量生成100文档 最近在帮一个做设计的朋友处理一批合同文档&#xff0c;他那边有上百份设计服务协议需要根据不同的客户信息生成&#xff0c;每份都要保持公司统一的专业排版和格式。最初他尝试用Word的邮件合并功能&#x…

作者头像 李华
网站建设 2026/7/14 17:16:33

Android13 SurfaceFlinger合成流程深度解析:从HWC到GPU的混合合成策略

1. 理解Android图形合成的核心&#xff1a;SurfaceFlinger与HWC 大家好&#xff0c;我是老张&#xff0c;在Android图形系统这块摸爬滚打了十来年。今天咱们来啃一块硬骨头&#xff1a;Android 13的SurfaceFlinger合成流程。很多朋友一听到SurfaceFlinger、HWC这些词就头大&…

作者头像 李华
网站建设 2026/7/14 17:16:31

信号与系统实战:用Python搞定拉普拉斯变换与Z变换(附完整代码)

信号与系统实战&#xff1a;用Python搞定拉普拉斯变换与Z变换&#xff08;附完整代码&#xff09; 如果你正在学习信号与系统&#xff0c;或者在工作中需要处理滤波器设计、控制系统分析&#xff0c;那么“复频域”这个词一定让你又爱又恨。爱的是&#xff0c;它能把复杂的微分…

作者头像 李华
网站建设 2026/7/14 17:16:33

WIN11必备!QTTabBar中文优化版安装配置全攻略(附常见问题解决方案)

WIN11文件管理新境界&#xff1a;QTTabBar中文优化版深度配置与效率革命 如果你和我一样&#xff0c;每天要在Windows资源管理器里处理成百上千个文件&#xff0c;那么对系统自带的单窗口、无标签页的原始界面&#xff0c;恐怕早已积怨已久。频繁地在多个文件夹窗口间切换&…

作者头像 李华
网站建设 2026/7/14 17:16:31

Kafka集群SASL/Kerberos认证实战:从零配置到生产环境避坑指南

Kafka集群SASL/Kerberos认证实战&#xff1a;从零配置到生产环境避坑指南 如果你在企业里负责过数据平台的运维&#xff0c;大概率遇到过这样的场景&#xff1a;业务部门要求把Kafka集群从“裸奔”状态升级到具备企业级安全认证。一开始你可能觉得&#xff0c;不就是加个认证嘛…

作者头像 李华