☰
STM32 DMA串口收发实战:从原理到踩坑
2026/9/26 10:16:48 网站建设 项目流程

简介:这是面向STM32进阶开发者的DMA串口收发实验工程包,基于Keil MDK与STM32标准外设库实现USART1的DMA发送与接收,从GPIO的复用模式配置、串口波特率与数据位等参数设定,到DMA通道的内存地址关联、传输方向与完成中断处理,均有清晰示例代码,适合正在学习嵌入式通信或希望降低CPU负载的开发者。包内共149个文件,其中38个.h头文件与37个.c源文件覆盖多类外设驱动和实验逻辑,另含.s启动文件、uvprojx/uvoptx工程文件,以及hex/axf/bin等编译输出,配合map、lst、crf等文件可方便地进行工程分析、烧录与调试。整个压缩包约3.46MB,内容完整,规模适中。目前已有494人学习下载,通过实际操作可掌握DMA与串口中断的配合方式、接收缓冲区管理技巧,并能借助示例代码快速验证收发功能,为实时性要求较高的项目打下基础。 搞嵌入式的人,谁没被串口收发折腾过?尤其是在数据量稍大、中断频繁的场景下,传统的串口收发方式很容易让CPU陷入“中断地狱”,忙着搬运字节,正经的算法逻辑反而没时间跑。我在一个多传感器数据采集项目里就遇到这个瓶颈,串口波特率提到921600后,每接收一个字节就触发一次中断,主循环直接被拖垮,波形刷新都开始卡顿。后来把串口1的收发全部切换到DMA(Direct Memory Access,直接存储器访问)方式,CPU彻底从字节搬运中解放出来,才算真正把问题解决。这篇就完整拆解一下DMA串口1数据收发实验从原理到落地的全过程,涵盖DMA通道映射、收发缓冲设计、空闲中断配合实现不定长接收,以及我在实测中踩过的几个典型坑,适合正在用STM32做串口应用、想从轮询或中断方式升级到DMA收发的小伙伴参考。

1. 为什么串口收发必须用DMA:中断方式在高波特率下是伪方案

1.1 看似正常的轮询与中断方式,问题出在哪

先看最基础的轮询方式:主循环里不断读USART->SR寄存器,判断RXNE位是否置位,有数据就取走。这种方式在低波特率、数据量小的场景下没问题,但一旦波特率超过115200,或者数据帧是连续不断的流,主循环就会被“等待数据”这件事反复打断,导致其他任务得不到及时调度。

中断方式稍微好一点,每个字节到达时触发一次USART中断,在中断服务函数里读数据。表面看效率还行,我实测过,在115200波特率下,每秒钟大约有11520个字节到达,也就是每秒钟要触发11520次中断。如果中断服务函数里还做了数据存储、解析、哪怕只是几行逻辑操作,对CPU的占用率都会超过20%。波特率翻倍到921600,CPU几乎被中断占满,系统响应其他外设的能力大幅下降。

1.2 DMA的本质是“外设搬运工”,CPU只管验收结果

DMA的思路完全不同:它是一套独立于CPU的数据搬运引擎,可以在外设和内存之间、内存和内存之间直接搬移数据,搬完再通知CPU。以串口接收为例,DMA控制器会自动把USART接收寄存器里的数据搬到内存缓冲区,整个过程不走CPU,CPU只需要在DMA搬运完一批数据后处理一次。

这样设计带来的核心收益有两个。第一个是CPU占用率断崖式下降,同样921600波特率的连续接收,中断方式下CPU占用可能超过80%,DMA方式下几乎为0,只有在DMA传输完成或空闲中断触发时才介入。第二个是数据不易丢失,中断方式下如果CPU正在处理高优先级任务,来不及响应USART中断,数据就可能被后续字节覆盖;DMA搬运是硬件行为,没有这样的响应延迟问题。

我个人的建议是:只要串口波特率在115200以上,或者单次传输的数据量超过16字节,就该考虑DMA;如果做的是Modbus、GPS解析、传感器数据流这类不可中断的接收任务,DMA几乎就是必选方案。

2. STM32串口1的DMA通道映射与初始化配置细节

2.1 串口1和DMA1的固定姻缘关系

先说结论:在STM32F103这样的经典型号上,USART1的发送DMA请求固定在DMA1的通道4,接收DMA请求固定在DMA1的通道5。这是芯片设计时约定好的硬件映射,不是软件可以随意改的。查数据手册的DMA request mapping表就能看到,USART1_TX对应DMA1_Channel4,USART1_RX对应DMA1_Channel5。

这个映射关系在实际编程中有个影响:如果工程里多个外设复用了同一条DMA通道,比如USART1_TX和TIM1_CH4都可能映射到DMA1_Channel4,就需要做优先级仲裁,避免两个外设同时触发DMA请求。我在一个电机控制项目里就遇到过TIM1和USART1抢用DMA1_Channel4的问题,最后只能把其中一个功能挪到其他定时器或通道上。

2.2 串口DMA初始化的完整代码与寄存器级解释

下面这段是我在实验里实际用通的配置代码,基于STM32标准外设库,逻辑以实际项目为准:先开时钟,再配置DMA通道,最后配置串口,顺序不要反。

void UART1_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; /* 使能DMA1时钟 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* -------------------- 发送通道 DMA1_Channel4 -------------------- */ DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)UART1_TX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = UART1_TX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &DMA_InitStructure); /* -------------------- 接收通道 DMA1_Channel5 -------------------- */ DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)UART1_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = UART1_RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); /* 使能串口1的DMA请求 */ USART_DMACmd(USART1, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); }

这里有几个关键配置我要单独说明。发送通道用Normal模式,因为一帧数据发完就停了,下一帧再重新配置缓冲区并启动;接收通道用Circular循环模式,因为接收是持续性的,数据不停进来,DMA自动回绕到缓冲区头,配合空闲中断来判断一帧数据是否结束。

外设地址不做增量(PeripheralInc_Disable),因为始终是往USART1->DR这一个寄存器写或读。内存地址要增量(MemoryInc_Enable),这样数据才能按顺序填进缓冲区。数据宽度全部设成Byte,因为串口寄存器是8位的,如果设成HalfWord会有数据错位风险。

2.3 启动DMA传输的时机:开一次还是反复开

很多初学者在这里犯迷糊。发送通道的启动时机很直观:每次要发数据时,先把数据填充到发送缓冲区,然后调用USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE)使能请求,再调用DMA_Cmd(DMA1_Channel4, ENABLE)启动通道。如果通道在Normal模式下传输完成后自动关闭,下次发送前重新使能即可。

接收通道则不同,它最好在系统初始化时就启动,并且一直保持使能状态。我见过有人写代码时每次接收完一帧数据就把接收DMA关了,结果下一帧数据来了没DMA搬运,串口接收寄存器溢出丢数据。正确做法是接收通道一旦启动就保持运行,用空闲中断或者DMA传输完成中断去处理数据,处理完后重置DMA的当前数据计数器即可,而不是关通道。

3. 接收链路的关键设计:空闲中断加DMA实现任意长度数据帧

3.1 固定长度数据帧的Flexible方案与短板

如果通信协议里的数据帧长度固定,比如每条命令都是16字节,那直接使用DMA接收完成中断就够了。配置DMA_BufferSize为16,每收满16字节触发一次DMA传输完成中断,在中断里把数据取走。这种方法最简单,但因为DMA的传输完成信号只在计数减到0时触发,如果数据帧长度不定,比如Modbus RTU最长256字节,最短才8字节,固定长度的DMA方案就无法准确判断一帧数据何时结束。

3.2 串口空闲中断:让“停一下”成为帧结束信号

处理不定长数据帧的标准方案是:DMA负责把数据搬到缓冲区,串口空闲中断负责判断“数据帧结束了”。这里的“空闲”不是指总线上完全没信号,而是指USART在一段时间内没有收到新的字符,也就是接收帧之间的间隔。

在STM32上开启空闲中断很简单,在USART_ITConfig中加入USART_IT_IDLE即可。当串口接收完一字节数据,之后总线保持空闲状态,硬件就会置位IDLE标志并触发中断。在中断服务函数里检查USART_GetITStatus(USART1, USART_IT_IDLE)是否置位,如果置位,就说明当前有一整帧数据已经到达,可以处理了。

3.3 完整的帧接收处理流程与缓冲区偏移计算

这是整个实验里我最想分享的部分。DMA在循环模式下接收数据,数据始终不停地写入缓冲区,缓冲区写满后自动回绕到开头覆盖旧数据。此时要用一个公式计算当前帧数据的起始位置和长度:

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 先读DR清USART_IT_IDLE标志 DMA_Cmd(DMA1_Channel5, DISABLE); // 关闭接收DMA,防止处理期间数据写入 // 计算当前DMA接收位置 u16 cur_pos = UART1_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 如果最新数据跨越了缓冲区尾部,需要分两段拷贝 if (cur_pos >= last_pos) { frame_len = cur_pos - last_pos; memcpy(frame_buf, UART1_RX_BUF + last_pos, frame_len); } else { frame_len = cur_pos + UART1_RX_BUF_SIZE - last_pos; memcpy(frame_buf, UART1_RX_BUF + last_pos, frame_len - (UART1_RX_BUF_SIZE - last_pos)); memcpy(frame_buf + (UART1_RX_BUF_SIZE - last_pos), UART1_RX_BUF, cur_pos); } last_pos = cur_pos; // 记录本次处理的位置 // 清空闲标志,重新开启DMA接收 USART_ClearITPendingBit(USART1, USART_IT_IDLE); DMA_SetCurrDataCounter(DMA1_Channel5, UART1_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }

这段代码里最值得琢磨的是last_pos这个变量。DMA在循环模式下,没有“一帧结束”的概念,DMA自己也不知道数据被消费到哪里了,所以必须用软件维护一个消费位置。初始时last_pos为0,每次空闲中断到来时,用cur_pos减去last_pos得到本次新接收的数据量,处理完后再更新last_pos为cur_pos。

还有个需要特别注意的边界情况:如果cur_pos小于last_pos,说明数据已经绕过缓冲区尾部,回绕到缓冲区头部写入,此时新数据在缓冲区里呈两段分布,分别位于[last_pos, buf_size)和[0, cur_pos),需要分两次拷贝到连续的帧缓冲区。这个跨边界处理是环形缓冲区常见的一个经典问题,也是很多人在DMA接收上丢数据的根源所在。

注意:读取USART1->DR这一步不能省,清空闲中断标志的标准操作就是先读一下数据寄存器。如果直接清除中断标志位而不读DR,在某些固件版本上会导致空闲标志无法正确清除,进而频繁触发中断。

4. 发送链路与DMA完成中断的处理逻辑

4.1 用DMA发送不定长数据的正确姿势

DMA发送的配置相对简单,核心逻辑是把待发送数据复制到发送缓冲区,设置DMA的数据长度,然后启动通道。但因为每次发送的数据长度可能不同,需要更新DMA_BufferSize,也就是DMA_Cmd之前要调用DMA_SetCurrDataCounter(DMA1_Channel4, len)。

一个容易踩的坑是:修改DMA_BufferSize之前,要先确认上一次发送已经完成。否则在上一次传输还在进行时修改计数器,会导致本次数据长度异常或数据错位。所以发送函数的开头要检查DMA_GetFlagStatus(DMA1_FLAG_TC4)是否置位,如果没有,等到传输完成再开始下一次发送。

void UART1_DMA_Send(u8* data, u16 len) { while (DMA_GetFlagStatus(DMA1_FLAG_TC4) == RESET); // 等上一次发送完成 DMA_Cmd(DMA1_Channel4, DISABLE); memcpy(UART1_TX_BUF, data, len); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); }

4.2 发送完成中断:判断批量数据真正发完的时机

使用DMA发送时,有些开发者会直接在发送函数末尾把发送缓冲区释放掉,或者紧接着修改缓冲区内容。这其实是个严重隐患:CPU执行速度快,DMA搬运数据也需要时间,如果发送函数返回后立即修改发送缓冲区,DMA可能还没搬完数据,导致发出的是被覆盖后的错误内容。

正确做法是开启发送DMA的传输完成中断,在中断服务函数里做缓冲区资源的释放或状态标记。配置方式是在DMA_ITConfig中使能DMA_IT_TC,然后在DMA1_Channel4_IRQHandler中断服务函数中检查传输完成标志并进行后续处理。这样能确保缓冲区在DMA真正搬运完毕后才允许被复用。

void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4)) { DMA_ClearITPendingBit(DMA1_IT_TC4); uart1_tx_busy = 0; // 标记发送完成,允许下一帧发送 } }

5. 实测中踩过的坑:从缓冲区错位到中断丢失的完整排查链路

5.1 现象一:偶发性数据错位与丢字节

第一次跑通DMA串口收发时,我用串口调试助手以115200波特率发送连续数据流,发现接收数据偶尔出现错位或缺失,尤其在一帧数据跨越缓冲区回绕点时更明显。排查思路从三个方向同时展开:先检查DMA配置是否把内存和外设数据宽度都设成了Byte,再确认接收缓冲区地址是否按对齐要求分配,最后分析空闲中断处理函数中关闭DMA和读取数据计数器的时机。

最终定位到问题根因:在空闲中断里调用DMA_GetCurrDataCounter时,DMA还在运行,当前数据计数器随时可能被新到达的数据改变,导致计算出的位置不准确。解决办法是在读取计数器之前先关闭DMA,让DMA停在当前位置,再读取计数器,读完后再重新启动。虽然关闭和重新启动DMA会有几个时钟周期的开销,但相比数据错位,这点代价完全值得。

5.2 现象二:空闲中断标志无法清除

这个问题在STM32F1系列上比较常见。按常规操作调用USART_ClearITPendingBit(USART1, USART_IT_IDLE)清除空闲标志,但中断仍然反复触发。查阅参考手册后发现,空闲中断标志的清除条件是“先读USART_SR,再读USART_DR”,也就是必须在软件上先访问状态寄存器,再访问数据寄存器。

在中断服务函数里加上USART_ReceiveData(USART1)这一步,问题就解决了。这一步的本质是利用读DR操作触发硬件对IDLE标志的自动清除。此外在项目实践中还发现,在主循环里如果也读取了USART1->DR会导致DMA接收数据异常——记住,DMA接收模式下,DR的读取应该完全交给DMA控制器,CPU不要直接读DR,除非是清中断标志这种特定场景。

5.3 现象三:缓冲区越大越稳?结论反直觉

我在项目初期把接收缓冲区设到1024字节,想着越大越不容易丢数据。实测下来却发现,缓冲区越大,跨边界处理逻辑越复杂,出错的概率反而更高。后来把实验改成256字节的环形缓冲区,和Modbus RTU最大帧长度匹配,反而稳定了许多。

核心原因在于:缓冲区太大时,last_pos和cur_pos之间的计算很容易漏掉半帧数据,尤其是当上位机发送两帧连续数据且帧间隔极短时,空闲中断可能只触发一次,导致两帧数据被合并处理。解决这个问题需要结合Modbus的帧间隔要求,或者采用DMA双缓冲机制——这正是DMA双缓冲存在的意义:DMA在搬运第一块缓冲区时,CPU可以同时处理第二块缓冲区,两者互不干扰。

6. 验证实验效果:串口收发回环测试与可视化数据监测

6.1 回环测试:从理论到工程实践的判断标准

搭建验证环境我建议从“回环测试”开始:将USART1的TX和RX用杜邦线短接,或者通过USB转串口模块连接到电脑,用串口调试助手发送数据。程序上在收到一帧数据后原样返回,这样在调试助手里能直接看到回显数据。

测试步骤按下面这个顺序来,每一步都能快速定位问题:

  1. 先用轮询方式发送固定字符串,确认串口硬件和USB转串口驱动正常;
  2. 再用DMA发送固定字符串,观察发送数据和预期是否一致;
  3. 接着用DMA接收固定长度数据,发一条接收一条,确认DMA搬运正常;
  4. 最后把空闲中断加进去,用不定长数据帧测试,重点观察连续多发几条不同长度的数据是否都能准确识别。

这样一层层往上加功能,出了问题能很快锁定是DMA配置问题、空闲中断问题还是缓冲区处理逻辑问题。我在实验时还特意用另一块板子以周期性连续数据流发送,观察长时间运行是否出现缓冲区覆盖和丢帧情况。

6.2 VOFA软件监测:串口调试助手的进阶替代方案

常见串口调试助手适合做简单的透传验证,但要对DMA接收的数据进行波形观测或协议调试,建议试试VOFA+协议助手。它支持文本和十六进制模式,可以可视化地观察串口数据流,还能以绘图方式显示数据变化。我调试传感器数据时习惯用这个工具,配合DMA高速接收,能把数据曲线实时绘制出来,相比传统串口助手的“一片十六进制数字”,排查异常数据高效得多。

如果用的是CH340这类USB转串口模块,记得先装好驱动,Windows下自动识别,Linux下可能需要手动编译驱动模块。还有一个常被忽略的细节:串口调试助手的接收缓冲区大小要和DMA缓冲区匹配,某些助手默认缓冲区太小,DMA发送太快时助手会显示数据丢失,这不是单片机的问题,是上位机软件的限制。

6.3 性能对比实测:中断方式与DMA方式CPU占用差距直观数据

我在同一个工程里分别用中断方式和DMA方式接收921600波特率的连续数据流,通过一个GPIO引脚翻转计时,用逻辑分析仪测量中断服务函数对主循环的挤压情况。结果很直观:中断方式下主循环几乎被挤占到只剩30%的执行机会,DMA方式下主循环的执行占比接近99%。

这不是说所有场景都无脑上DMA,它是把数据搬运延迟从CPU转移到了DMA上。如果只是低速点对点发送,轮询方式反而更简单;但需要处理持续数据流的项目,DMA拉开的性能差距是质变级的。我在STM32F103、STM32G031上分别测试过,核心思路完全一致,只是寄存器或库函数名称略有差异,GD32的类似型号同样适用。

写在最后的几句实在话

DMA串口收发这套逻辑,我前前后后在不同芯片平台上写过不下五次,每次重写都有新的体会。最强的感受是:DMA配置本身并不难,难的是对环形缓冲区、空闲中断、DMA状态三者配合关系的理解。建议刚开始接触的人不要直接照抄大工程的代码,先用最小工程实验,把发送和接收分开验证,跑通后再叠加空闲中断和复杂缓冲区逻辑。多备一台逻辑分析仪,它会帮你看到很多调试助手看不到的信号时序细节。做嵌入式就是这样,每个看起来高级的功能,拆到寄存器层面都是朴素的硬件逻辑,把这个逻辑理顺了,难啃的项目也会变得可拆解。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询