公司动态
STM32 DMA串口收发实战:从原理到踩坑
简介这是面向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的收发全部切换到DMADirect Memory Access直接存储器访问方式CPU彻底从字节搬运中解放出来才算真正把问题解决。这篇就完整拆解一下DMA串口1数据收发实验从原理到落地的全过程涵盖DMA通道映射、收发缓冲设计、空闲中断配合实现不定长接收以及我在实测中踩过的几个典型坑适合正在用STM32做串口应用、想从轮询或中断方式升级到DMA收发的小伙伴参考。1. 为什么串口收发必须用DMA中断方式在高波特率下是伪方案1.1 看似正常的轮询与中断方式问题出在哪先看最基础的轮询方式主循环里不断读USART-SR寄存器判断RXNE位是否置位有数据就取走。这种方式在低波特率、数据量小的场景下没问题但一旦波特率超过115200或者数据帧是连续不断的流主循环就会被“等待数据”这件事反复打断导致其他任务得不到及时调度。中断方式稍微好一点每个字节到达时触发一次USART中断在中断服务函数里读数据。表面看效率还行我实测过在115200波特率下每秒钟大约有11520个字节到达也就是每秒钟要触发11520次中断。如果中断服务函数里还做了数据存储、解析、哪怕只是几行逻辑操作对CPU的占用率都会超过20%。波特率翻倍到921600CPU几乎被中断占满系统响应其他外设的能力大幅下降。1.2 DMA的本质是“外设搬运工”CPU只管验收结果DMA的思路完全不同它是一套独立于CPU的数据搬运引擎可以在外设和内存之间、内存和内存之间直接搬移数据搬完再通知CPU。以串口接收为例DMA控制器会自动把USART接收寄存器里的数据搬到内存缓冲区整个过程不走CPUCPU只需要在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_Channel4USART1_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转串口模块连接到电脑用串口调试助手发送数据。程序上在收到一帧数据后原样返回这样在调试助手里能直接看到回显数据。测试步骤按下面这个顺序来每一步都能快速定位问题先用轮询方式发送固定字符串确认串口硬件和USB转串口驱动正常再用DMA发送固定字符串观察发送数据和预期是否一致接着用DMA接收固定长度数据发一条接收一条确认DMA搬运正常最后把空闲中断加进去用不定长数据帧测试重点观察连续多发几条不同长度的数据是否都能准确识别。这样一层层往上加功能出了问题能很快锁定是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状态三者配合关系的理解。建议刚开始接触的人不要直接照抄大工程的代码先用最小工程实验把发送和接收分开验证跑通后再叠加空闲中断和复杂缓冲区逻辑。多备一台逻辑分析仪它会帮你看到很多调试助手看不到的信号时序细节。做嵌入式就是这样每个看起来高级的功能拆到寄存器层面都是朴素的硬件逻辑把这个逻辑理顺了难啃的项目也会变得可拆解。本文还有配套的精品资源点击获取