GD32F303串口不定长接收:DMA+空闲中断方案详解 做串口通信的工程十有八九都绕不过去一个问题对方发过来的数据长度不固定你怎么知道一帧数据什么时候结束用固定长度接收稍微一变长就崩用接收中断一个字节一个字节收低波特率还行跑高波特率时CPU基本就烧在中断里了。我第一次在GD32F303上调这个事的时候也经历了一段从“逐字节中断加定时器超时”到“DMA加空闲中断”的折腾过程实测下来这套组合拳在保证接收实时性的前提下几乎不占CPU特别适合modbus、传感器上报、自定义协议这类不定长帧场景。这篇文章就把整个方案的原理、选型、代码和坑完整过一遍代码可以直接抄到工程里改。1. 为什么选DMA加空闲中断方案选型与核心思路1.1 传统接收方式的两座大山先说为什么不定长数据接收在很多新人手里会翻车。最常见的写法是开启串口接收中断每来一个字节进一次中断把数据一个个塞进数组然后用某种方式判断“这一帧收完了”。这个方式本身没有错但它有个天然瓶颈字节来了要进中断中断里要做压栈、读寄存器、写数组、出栈CPU参与度非常高。波特率不高的时候没事一旦跑到460800甚至921600一个字节的间隔只有大约1到2微秒中断刚出来下一个字节又到了系统里其他任务基本就别想跑了。另一种常见做法是定时器辅助超时判断收到第一个字节后启动一个短定时器连续收到数据就不断重置定时器定时器溢出就认为一帧结束。这个方案能把不定长帧的边界划分得很准但代价是多占一个定时器资源而且代码耦合度高定时窗口设大了影响响应速度设小了高速率下容易误判拆包。还有一类场景更尴尬主控被要求同时跑协议栈、显示任务和控制系统CPU本来就紧张如果串口接收还把CPU打得满负荷系统整个实时性都会受影响。这时候就需要思考能不能让硬件把数据先搬走CPU只在“数据到了一整段”之后才介入。1.2 空闲中断这套组合拳为什么香这套方案的核心就两个硬件特性DMA负责把串口收到的字节自动搬运到内存缓冲区空闲中断则告诉你“总线已经安静下来了之前这一串数据就是完整的一帧”。打个比方DMA就相当于一个搬家公司你的串口接收数据寄存器是货物出口内存缓冲区是仓库。以前的做法是每个货物到货你都亲自跑一趟仓库现在你雇了搬家公司它自己一件一件搬搬多少件都记在账上。你什么时候去仓库清点呢等送货的人按喇叭告诉你“这段货搬完了”再去。这个按喇叭的动作就是空闲中断。空闲中断的触发条件是串口总线上检测到空闲状态即从收到第一个字节开始到后面超过一个字节时间没有再收到新字节。这天然契合“一帧数据结束”的语义因为你不知道对方会发多少字节但你知道他发完一段后会停下来。这个停顿可能是几微秒也可能是几十毫秒只要是“超过一个字节时间没有新数据”硬件就会帮你标记出来。方案的价值还在于CPU介入次数极低每帧数据不管8个字节还是80个字节CPU只处理一次剩下的字节搬运全部由DMA完成。实测下来在115200波特率下如果一帧20字节左右CPU占用几乎可以忽略不计。1.3 GD32F303平台特点与注意事项选GD32F303来做这个方案有个天然优势它的USART、DMA和中断系统在结构上和STM32F1系列非常接近很多用标准外设库的代码可以快速移植。但接近不等于相同我在移植过程中撞过不少细节差异。首先是时钟树GD32F303的USART0挂在APB2总线上DMA0挂在AHB总线上初始化时要知道对应的外设时钟宏比如RCU_USART0、RCU_DMA0。很多人从STM32迁移过来后会下意识找APB1上的USART2却忘了USART0在APB2上结果时钟没使能寄存器怎么配都不通。另外GD32F303的APB1最高频率是60MHzAPB2最高可以到120MHzUSART0挂在APB2上如果APB2时钟配置不对会直接影响波特率精度。其次是库函数命名和参数细节GD32标准外设库和STM32标准外设库风格相似但函数参数和宏定义有区别。例如GPIO初始化用gpio_initDMA初始化用dma_init中断标志清除用usart_interrupt_flag_clear这几个API的形参顺序和宏名都需要对照库头文件确认很多网上例程是STM32代码直接搬过来的连宏都没改编译能过但功能不对。很多人问GD32能不能直接用STM32的DMA加空闲中断例程我的经验是大框架可以但要逐行核对库函数的实现尤其寄存器底层操作GD32的USART寄存器布局和STM32F1有差异例如USART_CTL寄存器中DMA请求使能位的位置、空闲中断标志的清除逻辑不能想当然照搬。2. 核心原理解读DMA搬运和空闲中断是怎么配合的2.1 DMA在串口接收里到底充当什么角色DMA全称是Direct Memory Access直接存储器访问。它的作用就是在外设和内存之间搬运数据不需要CPU一条条指令地执行加载和存储操作。串口接收场景下外设就是USART的数据寄存器内存就是你自己定义的一个缓冲区数组。你需要告诉DMA几件事数据从哪里来比如USART0-DATA这个寄存器地址数据搬到哪里去比如rx_buffer数组的首地址一次传输多少数据比如256字节搬运方向是从外设到内存源地址和目的地址是否自动递增串口数据寄存器是同一个地址所以源地址不递增内存缓冲区是连续空间所以目的地址要递增还有数据宽度串口寄存器是8位所以按字节搬运。这些参数配置好以后DMA通道立刻开工。每收到一个字节硬件会把数据从USART数据寄存器搬到缓冲区同时DMA计数器减一。这个过程完全独立于CPU哪怕CPU正在跑其他任务也不会漏掉数据。这也是为什么GDB调试时你会发现即使断点停在中断服务函数里串口数据仍然在往缓冲区里堆因为DMA还在干活。关键点在于“DMA计数器减一”这个计数器值你可以随时通过dma_transfer_number_get读出来。你只要用缓冲区大小减去当前计数器剩余值就能算出已经收到多少个字节。这一个数字就是后面提取不定长帧的依据。2.2 空闲中断的触发机制一帧结束的天然标志空闲中断的完整名称是USART IDLE interrupt它的触发逻辑不是定时器也不是引脚电平变化而是USART模块内部的接收线路状态检测。USART接收数据时如果在一段时间内没有再收到新的起始位就认为总线进入空闲状态硬件会置一个IDLE标志位同时向NVIC发出中断请求。这里要特别强调一个容易被误解的点空闲中断的“空闲时间”不是固定毫秒数而是和波特率相关的一个字节时间。比如115200波特率下一个字节大约87微秒那么接收完最后一个字节后大约再过一个字节的时间没有新数据就会触发空闲中断。如果波特率是9600这个时间就会变成大约1毫秒。所以它不是绝对时间超时不要拿它当时基用。还有一个细节值得注意空闲中断和接收中断的触发时机不同。接收中断是每个字节到达时触发空闲中断是总线从“忙着收数据”切到“空闲”的那一刻触发。也就是说如果一帧数据是连续的多个字节硬件会在最后一个字节之后才置起空闲标志CPU收到的通知是“整帧已到齐”而不是“来了一个字节”。2.3 循环模式与“剩多少没搬”的数学关系DMA有普通模式和循环模式之分。普通模式下DMA搬运完设定长度的数据后自动停止计数器归零通道关闭。如果此时串口还有数据进来没人搬运了数据直接丢失。循环模式下DMA搬运完一轮之后自动把计数器重置回初始值从头开始继续搬运相当于在缓冲区上循环写入。循环模式的好处是不怕数据多缺点是会覆盖旧数据。所以你要保证在一帧数据到来后、下一帧覆盖之前把有效数据从缓冲区取走。这里就引出一个核心计算逻辑缓冲区大小RX_BUF_SIZE减去DMA当前剩余计数就是当前DMA已经写入的字节位置。为了说清楚我举个例子。假设RX_BUF_SIZE是256DMA搬运起点是缓冲区下标0初始剩余计数是256。收到3个字节后剩余计数变成253那么当前写入位置就是3数据在缓冲区下标0到2。再收到5个字节剩余计数变成248当前写入位置就是8。如果一直收数据到超过256字节DMA会绕回缓冲区头部继续写剩余计数可能从某个值突然变大这时候当前写入位置小于上次记录的位置就需要用“256减上次位置加当前位置”来计算新收到的字节数。这个绕回处理是循环模式实现中必须考虑清楚的一个数学关系。很多初学的人在循环模式下只算了“当前位置”没算“上次位置和当前位置之差”结果每次收到一帧都重复解析整段缓冲区要么数据重复处理要么把上一帧残留数据也带进去了。真正稳妥的做法是记录last_pos用当前位置和它的差值确定新增数据区间差值为负时用缓冲区大小补差。3. 完整代码实现从初始化到中断处理3.1 硬件准备与工程配置我调试用的平台是GD32F303VET6主频配置到了120MHz。串口用的USART0引脚是PA9做TX、PA10做RXDMA用的是DMA0的通道3对应USART0的接收方向。如果你的板子上USART0引脚不是PA9/PA10一定先看原理图和芯片数据手册的引脚复用表。工程基于GD32F30x标准外设库建议直接用官方库里的模板工程不要自己在裸机上从零搭寄存器文件库函数维护起来省心很多。项目里需要添加的头文件主要就是gd32f30x.h、gd32f30x_usart.h、gd32f30x_dma.h、gd32f30x_gpio.h、gd32f30x_rcu.h有系统文件的话把systick也加上方便后面做超时统计。3.2 USART和DMA初始化代码下面这段是我实际项目里抽出来的完整初始化代码包含GPIO、DMA、USART三部分我标了注释方便你对照自己的库版本。#include gd32f30x.h #include string.h #define RX_BUF_SIZE 256U uint8_t rx_buffer[RX_BUF_SIZE]; volatile uint16_t rx_start 0; volatile uint16_t rx_len 0; volatile uint8_t rx_complete_flag 0; static void usart_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); /* PA9: USART0_TX, 复用推挽输出 */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); /* PA10: USART0_RX, 浮空输入 */ gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); } static void dma_config(void) { dma_parameter_struct dma_init_struct; /* 复位DMA通道确保干净状态 */ dma_deinit(DMA0, DMA_CH3); dma_init_struct.periph_addr (uint32_t)USART0-DATA; dma_init_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.memory_addr (uint32_t)rx_buffer; dma_init_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.periph_memory_width DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.number RX_BUF_SIZE; dma_init_struct.direction DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, dma_init_struct); /* 关键点必须开启循环模式 */ dma_circulation_enable(DMA0, DMA_CH3); /* 启动DMA通道 */ dma_channel_enable(DMA0, DMA_CH3); } static void usart_config(void) { usart_deinit(USART0); usart_baudrate_set(USART0, 115200U); usart_word_length_set(USART0, USART_WL_8BIT); usart_stop_bit_set(USART0, USART_STB_1BIT); usart_parity_config(USART0, USART_PM_NONE); usart_hardware_flow_rts_config(USART0, USART_RTS_DISABLE); usart_hardware_flow_cts_config(USART0, USART_CTS_DISABLE); /* 开启发送和接收 */ usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_receive_config(USART0, USART_RECEIVE_ENABLE); /* 使能USART0接收方向的DMA请求 */ usart_dma_receive_config(USART0, USART_DENR_ENABLE); /* 开启空闲中断 */ usart_interrupt_enable(USART0, USART_INT_IDLE); usart_enable(USART0); /* 配置USART0中断优先级按需调整 */ nvic_irq_enable(USART0_IRQn, 0, 0); } void uart_dma_init(void) { usart_gpio_config(); dma_config(); usart_config(); }初始化顺序有一定讲究。我习惯先配置GPIO再配置DMA最后配置USART因为USART使能前DMA通道要已经处于待命状态否则可能漏掉最开始的数据。dma_channel_enable要在usart_enable之前打开但USART的DMA接收请求要先使能两者顺序不能搞反否则DMA不会响应USART的请求。这里还要提醒一句dma_parameter_struct是GD32库定义的结构体类型如果你是在STM32标准库基础上改代码字段名和结构体成员可能不同比如direction、periph_memory_width这类字段名是GD32自己定义的直接搬代码会编译报错。3.3 中断服务函数与不定长数据提取中断服务函数是整个方案的核心。在USART0_IRQHandler里先判断空闲中断标志然后计算DMA当前写入位置再和上次记录的位置做差值得到本次新增的数据量。void USART0_IRQHandler(void) { static uint16_t last_pos 0; if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE) ! RESET) { /* 清除空闲中断标志 */ usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE); /* 当前DMA写入位置 缓冲区大小 - DMA剩余计数 */ uint16_t cur_pos RX_BUF_SIZE - (uint16_t)dma_transfer_number_get(DMA0, DMA_CH3); /* 计算本次新增的数据量需要考虑环形绕回 */ uint16_t len; if (cur_pos last_pos) { len cur_pos - last_pos; } else { len RX_BUF_SIZE - last_pos cur_pos; } if (len 0) { rx_start last_pos; rx_len len; rx_complete_flag 1; } last_pos cur_pos; } }这段代码的逻辑很直接但有三个容易踩的地方。第一dma_transfer_number_get拿到的是DMA计数器的当前值不同库版本这个函数名可能有差异有的叫dma_transfer_number_get有的是dma_counter_get一定以你的库头文件为准。第二cur_pos last_pos这个判断里如果出现这两个值相等说明没有收到新数据len为0这是正常的比如对方发了两个字节的间隔超过了一个字节时间就会触发两次空闲中断中间一次没新数据不用处理。第三rx_buffer里的有效数据不一定是从下标0开始的它是从rx_start位置开始的rx_len个字节。主循环里处理时要从这个起始位置去读。如果rx_start rx_len跨过了缓冲区末尾那么数据是分两段的一段从rx_start到缓冲区末尾另一段从缓冲区头到(rx_start rx_len) % RX_BUF_SIZE处理时注意分两次拷贝或分两次解析。我项目里的做法是把处理函数放在主循环里用rx_complete_flag做门闩尽量不在中断里做耗时操作以保证串口中断服务函数的执行时间足够短。中断里只记录位置和长度置标志后立刻退出。3.4 主循环使用示例主循环的代码我给一个最小可跑的例子你可以根据自己的协议在上面扩展int main(void) { /* 初始化时钟、systick等系统资源 */ systick_config(); /* 串口DMA初始化 */ uart_dma_init(); while (1) { if (rx_complete_flag) { rx_complete_flag 0; /* 有效数据从 rx_buffer[rx_start] 开始长度为 rx_len */ /* 这个demo里直接把收到的数据原样发回 */ for (uint16_t i 0; i rx_len; i) { uint16_t idx (rx_start i) % RX_BUF_SIZE; while (RESET usart_flag_get(USART0, USART_FLAG_TBE)); usart_data_transmit(USART0, rx_buffer[idx]); } } } }如果你要做的不是回环测试而是解析协议就把循环里的处理替换成你的帧解析逻辑。注意一点如果处理时间较长而串口还在持续接收数据DMA会继续往缓冲区里写可能把你正在处理的旧数据覆盖掉。这个问题在持续不间断通信的场景下尤其明显后面我会讲怎么通过双缓冲或环形队列解决。4. 踩坑实录与常见问题排查4.1 空闲中断标志清不掉的坑这是我调试时遇到的第一个大坑。现象是进入空闲中断后再也没有第二次空闲中断或者连续进来大量的空闲中断中断标志怎么清都清不干净。查来查去发现问题出在清除方式和顺序上。USART的空闲标志清除方式比较特殊它不是写一个位就能清的而是需要按照“先读状态寄存器再读数据寄存器”的顺序来清除。如果在中断里只调用了一个简单的清标志函数而这个函数内部没有按照这个顺序操作标志就一直置着。GD32标准外设库里的usart_interrupt_flag_clear已经封装了这个过程但有些裁减过的库或者自己写的清标志代码容易忽略这点。我建议的做法是清单标志前再用一次usart_interrupt_flag_get确认标志确实置起然后调用库函数清除清除后如果还担心可以再读一次标志位看是否真的变成了RESET。4.2 DMA剩余计数读不稳数据对不上第二个坑出现在高速率通信时。115200波特率下不明显跑到921600甚至2M波特率时进入空闲中断后立即读dma_transfer_number_get偶尔会读出比预期多或者少1的值导致解析出来的长度偶尔多一字节或者少一字节。这个问题的根因是串口硬件收到最后一个字节后数据可能还在移位寄存器或者接收缓冲里DMA搬运还没完成空闲中断就已经触发了。也就是说空闲中断和DMA搬完最后一个字节之间存在一个极短的时序窗口在这个窗口里读取计数器得到的是中间态。处理办法也不复杂我在中断里读取计数器后可以连续读两次如果两次一致就使用不一致读第三次不会消耗多少时间。另外也可以在空闲中断触发后什么都不做直接返回等下一个数据帧触发下一次中断时再一起处理不过这在需要实时响应的场景下不太适用。4.3 连续收发丢包怎么排查还有一个高频问题低速通信一切正常但对方以较高频率连续发帧时丢包或者数据错位。这种情况八成是主循环处理数据的速度赶不上数据到达的速度缓冲区没有被及时消费新数据写过来就把旧数据覆盖了。我一般按照这样的顺序排查先确认DMA循环模式有没有开没开的话收满256字节后通道就停了再确认空闲中断是不是被其他更高优先级的中断频繁打断导致处理不及时接着看主循环里处理逻辑有没有耗时操作比如在中断里做了耗时打印、处理、发送之类最后检查缓冲区大小如果协议里一帧最大是300字节缓冲区只有256那肯定不行至少要到一帧最大长度的两倍。4.4 常见问题速查表我把平时在群里看到和实际踩过的坑整理成了一个小表方便你对照排查。问题现象可能原因解决办法一个字节都收不到USART时钟或DMA时钟没使能检查RCU_GPIOA/RCU_USART0/RCU_DMA0使能确认挂在哪个总线收几帧后DMA停止循环模式没开DMA搬满后通道关闭调用dma_circulation_enable确认计数回绕进入中断后无法再次触发空闲标志没清干净或清除顺序不对使用库函数usart_interrupt_flag_clear或手动先读STAT再读DATA数据长度偶尔多1或少1空闲中断触发时DMA还没搬运完最后一位连续读两次计数器取稳定值或在中断里加极短延时再取样高帧率通信丢数据主循环处理过慢DMA数据被覆盖加大缓冲区或加双缓冲和队列把处理逻辑移出中断帧解析错位记录last_pos的逻辑不完整没有考虑环回检查cur_pos和last_pos差值计算增加对环回条件的处理波特率有偏差APB2时钟配置和波特率分频组合不对确认系统主频和分频器配置用逻辑分析仪实测波形5. 工程化扩展建议从能跑到好用5.1 给不定长帧加上协议边界空闲中断能告诉你“这一串数据结束了”但它不能告诉你“这一串数据是不是合法的一帧”。实际项目里如果总线和对方设备只传这一种协议问题不大但多设备组网或者有数据噪声的时候仅仅靠空闲中断是不够的。最常用的做法是在数据帧里加帧头、长度和校验字段。比如我习惯定义一个简单的帧格式帧头两个字节固定0xAA 0x55接下来一个字节是数据长度然后是数据体最后加一个字节的累加和校验。接收端在空闲中断拿到整段数据后先找帧头再核对长度最后算校验和全部通过才认为这是一帧有效数据否则丢弃。这样即使对方断电重启发了一半数据或者总线上出现干扰噪声也不会污染业务逻辑。5.2 双缓冲和环形队列的升级路径如果通信频率很高或者主循环处理任务很重简单地在中断里置标志、主循环处理的方式会出现缓冲区覆盖问题。这时候我建议上双缓冲准备两个大小相同的缓冲区DMA在第一个缓冲区搬运数据的同时主循环处理第二个缓冲区里的旧数据处理完再交换。这个方案能用软件在中断处理开销和内存占用之间取得平衡。更进一步的做法是用环形队列加空闲中断。DMA把数据写入环形缓冲区业务侧从队列头部取数据队列可以开得比较大。这样缓冲区覆盖问题从机制上被解决了但代价是代码复杂度明显上升包括队列空满判断、读写指针同步、临界区保护等等。如果是做产品而不是学习demo这一步还是值得投资的。5.3 实测性能数据参考最后给一组我做测试时的数据供参考。MCU主频120MHzUSART0波特率115200缓冲区256字节DMA循环模式加空闲中断。用协议分析仪连续发送1000帧、每帧20字节、间隔10毫秒的数据设备解析零丢帧主循环整体负载增加不到5%。把波特率打到921600每帧64字节连续传输时系统CPU占用也基本可控没有出现中断风暴。对比一下我之前用逐字节接收中断实现同样功能时115200波特率下连续传输CPU中断开销已经让主循环的实时性明显下降而DMA方案几乎感觉不到串口接收的存在。这也是为什么我后来在多个项目里都坚持用这套方案的原因。最后再分享一个调试小技巧刚开始调这个方案时别急着接真实设备先用USB转串口模块连接电脑打开串口助手手动发送几帧数据观察中断标志、DMA计数器和rx_len的变化。我一般会在中断处理函数里临时加一个翻转GPIO的代码用示波器看进入中断的频率和持续时间这样能直观判断中断处理是不是过长、是不是有异常触发。调试通过后把这个临时引脚代码删掉即可。这套方法帮我多次快速定位问题比盯着调试器的变量窗口有效率得多。