资讯动态

STM32F407串口DMA接收详解:空闲中断实现不定长帧解析

发布时间:2026/9/28 17:50:11 来源:尧图企业网站定制
做嵌入式开发这些年串口一直是我用得最多、也最容易被细节坑到的外设。早期用STM32F103做设备时我习惯靠接收中断一字节一字节地解析命令功能简单时问题不大可一旦数据量上来——比如4G模组回传、GPS报文、Modbus轮询——CPU就开始被频繁打断主循环里处理业务的节奏全乱。后来把项目切到STM32F407机会来了Cortex-M4主频更高外设更豪华光是DMA控制器就有两个、16条Stream。既然硬件资源摆在这我决定干脆把串口接收全部改成DMA方式再配合USART的空闲中断来实现不定长帧识别。整个配置踩了不少坑尤其是空闲中断到底该怎么清标志这个问题网上资料一堆但很多都没说到点上。这篇文章就完整记录一下我从零开始、用标准库在STM32F407上配串口DMA接收与发送的过程重点把空闲中断的用法讲透覆盖初始化、中断服务、长度计算、常见坑位和应用扩展。1. 为什么最终选了标准库 DMA 空闲中断这套组合1.1 标准库不是过时是够用很多新手入坑STM32F407第一反应是去装STM32CubeMX然后用HAL库生成工程。这没问题HAL库的抽象层确实方便点几下鼠标就能把时钟、引脚、外设初始化代码全部生成。但如果你要做一个对实时性、代码体积、执行效率都有要求的裸机项目或者你所在团队的老项目里还全是标准库代码那HAL库有时候反而有点隔靴搔痒。标准库的优势是离寄存器近每个函数的调用逻辑都能让你大概猜到它操作了哪些寄存器。调试的时候一旦出问题直接打开参考手册对照寄存器就能快速定位不用在HAL库的多层封装里翻来翻去。尤其在串口DMA这种涉及外设联动、中断标志、数据指针的场景下标准库能把所有事情都摊开在你面前学一遍之后你对芯片内部的工作机制理解会深很多。当然不代表HAL库不能做DMA加空闲中断很多工程师用CubeMX也能配出来。只是标准库在代码的直观性、可控性方面有天然优势这也是我这次分享选择标准库的原因。官方对F407的标准库早已停止更新但成熟的东西不需要频繁升级它的稳定性经过了太多项目验证。1.2 串口接收的三种方案与选型对比STM32接收串口数据常见做法无非三种阻塞轮询、单字节中断、DMA加空闲中断。我拿一个实际场景来说——单片机通过串口接收一个不定长的通信协议帧比如上位机下发一条命令命令长度可能是10个字节也可能是100个字节。阻塞轮询最简单主循环不断去读USART的RXNE标志有数据就取走。这种方式在系统只有一个任务、数据量很小的时候完全能跑。可一旦主循环里还有其他耗时操作串口数据可能被漏掉。因为串口波特率固定比如115200一个字节大约耗时86.8微秒如果你的主循环某个分支执行时间超过这个值数据就丢了。单字节接收中断是目前最常见的做法。每个字节到来都会触发一次USART中断你在中断里把数据存进数组。1秒钟接收11520个字节就意味着1秒钟有11520次中断CPU平均每次中断要压栈、跳转、判断、出栈。数据量一大CPU忙不过来是常态而且如果主程序还在做浮点运算或者大量数据处理频繁的中断会打乱时序极端情况下甚至导致中断服务函数里处理不过来相互打断丢失数据。DMA加空闲中断是另一个思路。DMA负责把串口接收寄存器里的数据自动搬到内存缓冲区全程不需要CPU干预而当一帧数据发完之后总线上出现一段空闲时间USART外设会自动置起IDLE标志并触发中断。CPU这时候只需要在中断里计算DMA收了多少字节然后把整帧数据拿去做解析。我用一个表来对比会更直观方案CPU占用实时性不定长帧处理适合场景阻塞轮询最高差需要自己计时单任务、极低速单字节中断较高好需要配合状态机中小规模数据、帧结构固定DMA加空闲中断极低极好天然适合空闲即帧结束高速率、大流量、不定长协议我当时选第三套方案核心原因有两个一是F407的DMA资源足够丰富串口1接收可以单独占用一条Stream不会和别的外设抢资源二是我们的设备需要挂4G模组、GPS、激光雷达每路串口的数据都可能突发而且在主循环里还要跑PID控制算法和状态机不能把CPU耗在串口搬运上。DMA加空闲中断相当于把搬砖的活全部外包给了硬件CPU只在一车砖送到的时候点个数、入库效率自然高很多。2. DMA与空闲中断到底是怎么配合工作的2.1 DMA的工作模型与F407的Stream/Channel概念DMA全称Direct Memory Access直接存储器访问。你可以把它理解成一个专职的搬运工它的工作就是把数据从一个地址搬到另一个地址搬完一个单位的数据之后源地址和目标地址可以选择自动递增也可以保持不变。这个搬运工不需要CPU指挥每一次动作你只需要提前告诉它从哪个地址搬、搬到哪个地址去、搬多少、什么时机开始搬然后打开开关它就开始自己干活。STM32F4系列的DMA和F1系列有个明显区别F1叫通道F407引入了Stream和Channel两个维度。16条Stream就是16个搬运工每个Stream可以服务多个外设具体服务谁由Channel决定。举个例子USART1_RX可以挂在DMA2的Stream2上Channel选择4USART1_TX挂在DMA2的Stream7上Channel同样选择4。这样设计的好处是灵活一个Stream可以动态切换到不同外设但坏处就是配置的时候要特别小心Stream和Channel任何一个搞错数据都动不起来。我把自己常用的几个串口DMA映射整理成了表方便你查外设请求StreamChannelUSART1_TXDMA2_Stream74USART1_RXDMA2_Stream24USART2_TXDMA1_Stream64USART2_RXDMA1_Stream54USART3_TXDMA1_Stream34USART3_RXDMA1_Stream14USART6_TXDMA2_Stream65USART6_RXDMA2_Stream15刚接触F407的时候我在这张表上吃过亏USART1的接收被我不小心配到DMA2_Stream5上结果调试了一天数据纹丝不动。后来养成习惯每写一个串口DMA配置先查参考手册的DMA request mapping表再动手写代码。2.2 USART空闲中断一本看不见的帧结束信号USART的空闲中断英文叫IDLE Interrupt它的原理是当USART接收线上检测到一整个字节时间的高电平空闲后硬件会自动把IDLE标志位置1。这里的关键点是一个字节时间比如波特率115200时1位的时间约8.68微秒一个字节含起始位、8个数据位、停止位共10位所以一个字节时间大约86.8微秒。只要总线在这段时间里没有新的起始位出现USART就认为线路处于空闲状态。这个特性用来做不定长帧识别简直是绝配。上位机发给单片机的数据帧只要帧和帧之间有哪怕一两个字节时间的间隔USART就能感知到一帧结束了然后触发IDLE中断。CPU在中断里只要去计算DMA已经接收了多少个字节就等于知道这帧数据有多长。我最初用单字节中断做帧识别的时候需要在每个字节中断里保存时间戳再用定时器判断两帧之间的间隔代码啰嗦不说还容易踩到定时器溢出的边界条件。换成空闲中断之后这些状态机全都不需要了。需要注意一点IDLE中断和RXNE中断是独立的IDLE标志位置1时RXNE不一定置位而DMA模式下RXNE通常会由DMA及时清掉所以IDLE事件就纯粹表示总线空闲了一整个字节时间。2.3 两者联手后的分帧逻辑把DMA和空闲中断放在一起整个接收流程是这样的DMA配置成循环模式缓冲区大小假设是256字节串口接收寄存器的地址作为源地址DMA每收到一个字节就自动搬运到缓冲区。当一帧数据发送完毕总线上空闲了一个字节时间USART触发IDLE中断。你在中断服务函数里读取DMA当前剩余计数寄存器NDTR用缓冲区总长度减去剩余长度就是刚才这一帧的实际长度。这里有一个很容易绕晕的概念就是DMA的循环模式和普通模式。普通模式下DMA搬运完设置的字节数之后就停下来了NDTR变成0如果此时串口又来新数据没有重新设置NDTR和DMA使能位的话数据就不会再搬运。循环模式则不同DMA搬运完一批数据后缓冲区地址自动回到起点NDTR恢复成初始值继续等待下一批数据。对串口接收来说循环模式是必须的因为没有人知道下一帧数据什么时候来。我用一个简单的图来解释位置关系缓冲区起始地址 缓冲区结束地址 |--------------------------------| ^ 当前写入位置由DMA维护在循环模式下DMA会像一支笔一样不停地写写到缓冲区末尾就跳回开头继续写。空闲中断触发时CPU通过读取DMA_GetCurrDataCounter函数得到还有多少个字节没写从而算出当前这一轮已经写了多少字节。这种设计天然支持连续不断的串口数据流不用每收一帧就重新初始化一次DMA省了很多麻烦。3. 全流程代码配置从零到跑通3.1 初始化流程与时钟、GPIO配置我以USART1为例引脚是PA9作为TXPA10作为RX。编写代码之前要理清初始化顺序否则很容易出现时钟没开就操作外设、DMA没配置好就使能串口之类的问题。我的习惯顺序是先开时钟再配GPIO然后配置DMA和串口最后使能中断。F407的时钟树分好几路USART1挂载在APB2总线上GPIOA挂载在AHB1总线上DMA2也挂载在AHB1总线上。这三路时钟都要打开一个都不能省。用标准库的RCC函数来操作代码如下void Usart1_DMA_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 1. 开启时钟 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_DMA2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); /* 2. GPIO引脚配置 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd GPIO_PuPd_UP; GPIO_Init(GPIOA, GPIO_InitStructure); /* 引脚复用功能映射 */ GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); }这里有一个非常常见的坑只配置了GPIO_Mode_AF但忘了调用GPIO_PinAFConfig。这两个函数都是必须的GPIO_Mode_AF只是把引脚设为复用模式真正告诉引脚你复用到哪个外设的是GPIO_PinAFConfig。漏掉之后串口TX和RX引脚不会正常工作但又不会报错排查起来很费劲。3.2 USART和接收DMA的完整配置代码GPIO配好之后先创建接收缓冲区和相关状态变量再配置USART和DMA。接收缓冲区建议定义成全局数组并且让编译器自然对齐到4字节边界这对DMA的访问效率有帮助。#define RX_BUF_SIZE 256 uint8_t Usart1_RxBuf[RX_BUF_SIZE]; volatile uint16_t Usart1_RxLen 0; volatile uint8_t Usart1_RxComplete 0; void Usart1_DMA_Init(void) { /* ... 上述时钟和GPIO配置 ... */ /* 3. 串口参数配置 */ USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); /* 4. 接收DMA配置 */ DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_Channel DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)Usart1_RxBuf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize 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_High; DMA_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold DMA_FIFOThreshold_Full; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream2, DMA_InitStructure); /* 5. 使能USART的DMA接收请求 */ USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); /* 6. 使能接收DMA */ DMA_Cmd(DMA2_Stream2, ENABLE); /* 7. 配置并开启USART空闲中断 */ USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); /* 8. NVIC配置 */ NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); /* 9. 使能串口 */ USART_Cmd(USART1, ENABLE); }这段代码里有几个细节值得展开说一下。首先是DMA_PeripheralBaseAddr它填的是USART1-DR寄存器的地址也就是串口数据寄存器而不是某个内存变量。DMA要从这个外设寄存器里读取数据源地址必须指向它。其次是DMA_DIR设置为PeripheralToMemory方向是外设到内存这个方向对应的是接收。如果是发送方向就是MemoryToPeripheral。有些人把这两个方向搞反了结果接收一直没反应。还有一个关键是打开USART的DMA请求也就是USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE)。不少人在这一步漏了导致DMA配置得很完整但串口数据就是不进内存。原因在于USART外设虽然产生了RXNE事件但它并不知道需要把数据交给DMA必须由软件显式允许USART发出DMA请求。3.3 空闲中断处理与数据长度计算接下来是重头戏也是网上最容易讲含糊的地方——USART1_IRQHandler中断服务函数。标准库中USART1的所有中断事件都汇聚到同一个IRQHandler里你要在这里判断到底是哪种中断发生了。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { /* 先读SR再读DR清除IDLE标志 */ USART1-SR; USART1-DR; /* 计算本次接收的数据长度 */ Usart1_RxLen RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA2_Stream2); if (Usart1_RxLen 0) { Usart1_RxComplete 1; } } }这段代码的精髓在于清除IDLE标志的方法。USART的IDLE标志位有点特殊你不能像清TXE或者TC那样通过软件置0来清除正确方法是在IDLE标志置位之后依次读取USART_SR寄存器和USART_DR寄存器硬件会自动清掉这个标志。也就是代码里那两行看似什么都没做的语句USART1-SR; USART1-DR;有些朋友想省事只读了SR不读DR或者只读DR不读SR结果发现IDLE中断触发一次之后再也不触发了或者一直进入中断。这是我在多个技术群里被问到最多的问题之一。正确做法就是SR和DR都要读一次顺序也不能错。标准库里其实有USART_ReceiveData函数你也可以用它读DRUSART_ReceiveData(USART1);效果和直接读USART1-DR是一样的。需要注意的是调用USART_GetITStatus这个函数本身会读取USART_SR但这只是为了判断中断状态它不会清除IDLE标志。清除动作必须靠自己读SR和DR来实现。接下来是长度计算。DMA_GetCurrDataCounter返回的是DMA当前还剩余多少个数据传输完成也就是NDTR寄存器的值。循环模式下DMA刚使能时NDTR等于RX_BUF_SIZE每接收一个字节NDTR减1减到0之后自动恢复成RX_BUF_SIZE。所以用缓冲区总大小减去当前NDTR就能得到这一轮DMA已经写入的字节数。举个例子缓冲区256字节某一帧发送了32个字节后总线空闲触发IDLE中断此时NDTR等于224那么32就是从256减去224得来的。3.4 发送端DMA配置示例接收用DMA解放了CPU发送端同样可以用DMA。发送DMA和接收DMA的差异在于方向相反、数据源不同而且发送最好用普通模式不要用循环模式。因为发送是一次性动作发完一帧就该停下来如果用循环模式DMA会不停地把同一段缓冲区内容重复发出去。#define TX_BUF_SIZE 128 uint8_t Usart1_TxBuf[TX_BUF_SIZE]; void Usart1_SendDMA(uint8_t *data, uint16_t len) { /* 等待上一次发送DMA传输完成 */ while (DMA_GetCmdStatus(DMA2_Stream7) ENABLE) { if (USART_GetFlagStatus(USART1, USART_FLAG_TC) ! RESET) { break; } } DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_Channel DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)data; DMA_InitStructure.DMA_DIR DMA_DIR_MemoryToPeripheral; DMA_InitStructure.DMA_BufferSize len; 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_FIFOMode DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold DMA_FIFOThreshold_Full; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream7, DMA_InitStructure); /* 使能发送DMA */ DMA_Cmd(DMA2_Stream7, ENABLE); /* 使能USART的DMA发送请求 */ USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }发送函数的特点是DMA_InitTypeDef结构体里的DMA_DIR改了外设地址还是USART1-DR内存地址变成了要发送的数据缓冲区。每次调用都会重新设置DMA的BufferSize和内存地址因为是Normal模式所以发完一次必须重新配置才能再发。主循环里使用接收数据的方式也很简单int main(void) { /* 系统初始化 */ Usart1_DMA_Init(); while (1) { if (Usart1_RxComplete) { /* 对Usart1_RxBuf的前Usart1_RxLen个字节做协议解析 */ ProcessFrame(Usart1_RxBuf, Usart1_RxLen); /* 清标志准备接收下一帧 */ Usart1_RxComplete 0; Usart1_RxLen 0; } } }这里有一个容易忽略的边界问题。假设某一帧数据长度恰好等于RX_BUF_SIZE比如缓冲区256字节上位机一帧刚好发了256个字节那么DMA循环模式收满一轮后NDTR会从0恢复成256空闲中断触发时DMA_GetCurrDataCounter返回的值就是256用256减去256得到0看起来像没有收到数据。实际项目中帧长度一般不会恰好等于缓冲区大小如果确实可能出现可以在DMA传输完成中断TC里做一个补偿标记一旦发生长度算出来为0但TC中断触发过就按一整帧RX_BUF_SIZE处理。这个问题我放在后面的常见问题里详细讲。4. 进阶用空闲中断做协议分包与稳定性设计4.1 Modbus等协议下的分帧策略串口通信中Modbus RTU是非常典型的协议。它定义帧结束条件为静默时间达到3.5个字符以上。有些初学者看到这个条件以为自己要在软件里实现定时器超时判断其实完全可以用空闲中断来近似完成。Modbus的3.5字符时间在波特率115200下大约是3.5乘以86.8微秒约304微秒。USART空闲中断判定的空闲时间是一个字节时间86.8微秒。如果一帧Modbus数据内部连续发送帧内部两个字节之间几乎没有间隔那么空闲中断只会在整帧数据发完之后触发。如果你的主站设备发送节奏比较连贯可以在空闲中断触发后再延迟一段时间判断一下后续是否还有数据。但绝大多数情况下直接利用空闲中断做分包是够用的因为主站下发命令和从站返回响应之间一定有一段明显大于1个字节时间的空闲。我在实际项目中就是用空闲中断来做Modbus RTU从站的接收分帧。帧头、功能码、数据、CRC全部在DMA缓冲区里一帧拿到省去了很多状态切换的代码。需要注意的是Modbus RTU对时间敏感如果波特率很低比如9600一个字节时间约1.04毫秒此时3.5字符时间约3.64毫秒。如果主机发送的数据在帧中间有一点小抖动空闲中断可能在帧内被误触发造成一帧被拆成两半。这种情况就要在应用层做粘包处理判断帧长和CRC如果CRC错误并且缓冲区里可能残留半帧数据就做移位重同步。4.2 环形缓冲区与双缓冲思路DMA循环模式加空闲中断已经能处理大多数场景但有一个潜在问题如果在软件还没有来得及处理上一帧数据的时候下一帧数据又来了DMA会继续往缓冲区里写可能把上一帧的数据覆盖掉。这个问题在数据流量比较大或者主循环任务较重时会致命。解决办法之一是引入环形缓冲区把DMA接收缓冲区当成一个生产者的暂存区空闲中断触发时立即把DMA里这一轮的数据拷贝到另一个更大的、由软件维护的环形队列中。主循环再从环形队列里取数据解析。这样即使主循环忙串口数据也不会被覆盖只是数据在环形队列里排队等待处理。还有一种思路是双缓冲也叫乒乓缓冲。F407的DMA支持双缓冲区模式你可以配置两个内存缓冲区DMA先往缓冲区A写写满后自动切到缓冲区B写同时触发传输完成中断让软件去处理缓冲区A的数据。这样CPU处理数据的时间和DMA接收数据的时间完全重叠吞吐量可以提升不少。不过双缓冲和空闲中断同时使用判断当前DMA写到哪个缓冲区需要额外注意新手可以先从环形缓冲区开始。4.3 缓冲区溢出的防护策略无论用不用空闲中断缓冲区溢出都是串口DMA接收必须考虑的问题。假设DMA缓冲区256字节一帧数据也可能超过256字节比如某次OTA升级或者固件下发上位机一次性发了几百字节。此时DMA循环模式会循环写入空闲中断触发时你算出来的长度是最近一轮的字节数之前的数据可能已经被覆盖了。我的做法分两层第一层是把DMA缓冲区大小设置得比协议允许的最大帧长更大比如协议规定最大帧长128字节我就把DMA缓冲区设成512字节留出足够余量。第二层是在空闲中断里加判断如果计算出的Usart1_RxLen大于协议规定的最大帧长就当作错误帧丢弃并且复位DMA计数器重新开始接收。这样可以防止因为异常数据导致的逻辑混乱。另外如果使用串口DMA接收4G模组的AT指令回复或者GPS的NMEA语句这类数据通常是文本流帧和帧之间间隔很短缓冲区溢出风险更高。建议在DMA缓冲区之外再加一个物理层流控机制比如使用硬件流控RTS/CTS或者通过降低单次上报数据量来缓解。5. 常见问题排查表与调试技巧实录5.1 高频问题速查表我在多个项目里反复踩过同一类坑这里整理成一张速查表遇到问题可以先对照排查问题现象可能原因解决办法串口发送正常接收完全无数据漏开USART_DMAReq_RxDMA Stream或Channel配置错误检查USART_DMACmd调用对照参考手册DMA映射表接收数据错乱、字节顺序不对缓冲区地址未对齐DMA和串口时钟开启顺序不对缓冲区定义成全局数组先配置DMA再使能串口空闲中断只触发一次之后不再触发中断服务里没有正确清IDLE标志务必先读SR再读DR空闲中断一直频繁触发RXNE标志残留DR里还有数据没被读走在IDLE中断里判断RXNE如果置位先读DRDMA收满一帧后停止接收DMA_Mode设置为Normal而非Circular接收DMA必须使用DMA_Mode_Circular发送DMA死等或重复发送同一段数据使用了循环模式TC标志未清除发送用Normal模式发送前清TC标志串口DMA接收偶尔丢一个字节DMA优先级过低FIFO模式未开启调高DMA优先级到VeryHigh尝试开启FIFO模式长帧数据被截断或错位缓冲区溢出下一帧覆盖上一帧增大缓冲区增加环形队列做数据缓存这表里的每一个问题我都遇到过不止一次。尤其是空闲中断只触发一次我当初调试了整整一个晚上最后发现是中断服务函数里读SR之后没有读DR。后来我习惯在任何用到IDLE标志的代码里都保持读SR、读DR的次序形成一个肌肉记忆。5.2 调试工具与排查实例串口调试方面工具选对能省一半时间。我用得比较多的是XCOM和友善串口助手两者都支持定时发送和HEX显示。调试DMA接收时我建议一定要用定时发送功能可以模拟不同的帧间隔验证空闲中断触发的边界条件。举个例子我用XCOM每隔50毫秒发送一帧数据固定内容为AA 55 01 02 03 04 FF。在空闲中断里我加了一行临时调试代码把计算出来的Usart1_RxLen通过另一个调试串口打印出来。如果每次打印结果都是7说明DMA接收和长度计算没问题。然后把定时发送间隔改成10毫秒、5毫秒、2毫秒观察是否会出现丢帧或长度错误。实测在2毫秒间隔下115200波特率数据包之间已经靠得非常近这时候空闲中断仍然能正确识别因为一个字节时间约86.8微秒2毫秒的间隔远大于这个值。如果怀疑DMA没有搬运数据可以在调试器里直接查看DMA2_Stream2的NDTR寄存器值。正常接收一帧后NDTR应该从初始值256变成256减去接收字节数。如果NDTR一直不变说明DMA根本没有响应USART的请求这时优先检查USART_DMACmd和DMA_Channel配置。还有一个小技巧在空闲中断服务函数入口翻转一个GPIO用示波器或者逻辑分析仪观察这个引脚的波形。每次IDLE中断都是一次下降沿或上升沿波形密集程度能直观反映串口数据的实时到达情况。这个方法在排查空闲中断是否被其他高优先级中断长时间阻塞时非常有效。如果单片机连接的是USB转串口模块也要确认一下驱动是否正常。CH340这类芯片在Windows下偶尔出现驱动异常表现为串口号找不到或者发送数据无响应。这属于开发环境问题有时候会被误判成单片机程序问题。先打开设备管理器看看串口号是否出现再用回环测试把TX和RX短接通过调试助手自发自收能快速排除USB转串口模块的问题。6. 双缓冲扩展与最后的调试心得前面提到双缓冲这里再多说一句它的现实价值。在数据速率超过1Mbps的串口应用中比如用F407和外部高速传感器通信DMA单缓冲加空闲中断虽然也能工作但主循环一旦被某个优先级更高的任务抢占几十微秒缓冲区数据就可能被新数据覆盖。双缓冲模式下DMA硬件在缓冲区之间自动切换软件在传输完成中断里处理已经写完的缓冲区可以把接收和处理完全并行丢数据概率大幅降低。F407标准库配置双缓冲需要操作DMA_InitTypeDef里的DMA_Memory0BaseAddr和DMA_Memory1BaseAddr同时使用DMA_DoubleBufferModeCmd函数使能门槛不高值得一试。我个人在实际操作中的体会是串口DMA加空闲中断这套方案真正难的从来不是把配置代码跑通而是搞清楚每个标志位背后的硬件时序逻辑。清IDLE标志要读SR再读DR这个结论如果你不理解原理很容易当成玄学可一旦你理解了USART状态寄存器的工作原理就会明白这是硬件设计的必然要求。后续做项目如果再遇到USART、DMA、甚至SPI和CAN的数据搬运问题按这个思路去查参考手册、看标志位时序大部分都能迎刃而解。最后再分享一个小经验新工程第一次调串口DMA的时候不要急着做复杂协议先用HEX发送几个固定字节确认接收长度计算正确再逐步增加数据量和复杂度。一步一步验证远比一次性写完代码然后对着现象猜问题要高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑