GD32F303串口DMA收发:空闲中断实现不定长数据接收 📅 发布时间:2026/9/3 23:53:52 👁 浏览次数: 简介串口通信是嵌入式系统中最基础也最常用的数据交互方式但在高波特率和大数据量场景下传统每字节中断会频繁打断CPU严重影响系统实时性。DMA直接存储器访问技术应运而生它让外设与内存之间的数据搬运不再依赖CPU逐字节干预仅在传输完成或空闲时产生中断从而大幅降低CPU负载。这一机制在Modbus通信、多路模拟量采集、LCD刷新等并发任务场景中尤为关键。GD32F303作为主流ARM Cortex-M4微控制器其USART结合DMA循环模式与空闲中断可优雅实现不定长数据接收同时兼顾发送效率。文章基于GD32F30x固件库V2.x讲解USART DMA收发的完整配置流程与工程实现细节。 做单片机开发这几年串口可以说是打交道最多的外设。早年间我习惯一个字节进一次中断直到接手一个要同时跑Modbus主机、刷新LCD、处理多路模拟量采集的项目115200波特率下每来一个字节就打断一次CPU整个系统的实时性一下就被拖垮了。后来我把USART改成DMA收发中断频率从“每字节一次”降到“每帧一次”CPU占用肉眼可见地降下来了。这篇是GD32F303固件库开发系列的第9篇主题就是USART通过DMA收发代码基于GD32F30x_Firmware_Library V2.x适合已经被串口中断烦到的朋友也适合刚接触DMA、想弄明白接收不定长数据怎么做的初学者。1. 为什么串口收发要上DMA一个真实场景引发的思考1.1 中断方式到底耗在哪里传统串口收发发送和接收都依赖USART中断。接收时每收到一个字节触发RBNE中断发送时每发送一个字节触发TBE中断。以115200波特率、8N1格式计算一秒钟最多传输11520字节也就是约11520次中断。如果项目里还有其他中断源比如定时器、按键检测、通信协议解析CPU大部分时间都在进出中断真正干活的窗口被严重挤压。GD32F303主频有120MHz算力并不弱但算力再强也不该浪费在“把一个字节从寄存器搬到内存”这种机械操作上。DMA外设的价值就在这里它独立于CPU内核负责把外设寄存器和内存之间的数据搬运做好只有在整块数据传输完成、半传输或者出错这类关键时刻才需要CPU介入。这个机制像一个熟练的仓库管理员你把“把这一整箱货搬到3号货架”的任务交代下去他就自己去搬了搬完再喊你验收。1.2 DMA收发带来的实际改变接收场景下我会把DMA配置成循环模式DMA不断地把USART收到的字节写入内存数组CPU不用管。等到一帧数据接收完毕USART会产生空闲中断CPU在空闲中断里一次性处理整帧数据。发送场景更直接CPU把待发送数据缓冲区的地址和长度交给DMADMA自动按长度把数据搬到USART数据寄存器搬运完成后再通过标志位或中断通知CPU。从代码维护角度看DMA收发让协议层和硬件层解耦得非常舒服。上层协议只需要维护缓冲区底层由DMA负责搬运不再需要在中断服务函数里一个字节一个字节地拼帧。这个改动在波特率越高、数据量越大的项目里收益越明显。实测下来同样的串口数据收发任务中断方式和DMA方式的CPU占用可以差出20%到30%对系统实时性敏感的项目来说这已经是天壤之别。2. GD32F303的DMA通道映射与USART结合点2.1 通道分配不能凭经验必须查手册GD32F303内部有DMA0和DMA1两个DMA控制器每个控制器下面有若干通道。USART外设的接收、发送请求并不是随便接到哪个通道都行而是由芯片内部的硬件互联决定。这个映射关系没有固定规律必须以参考手册里面的“DMA request mapping”表格为准。千万不要拿STM32的使用经验直接套GD32和STM32虽然外设思路相似但DMA请求映射并不完全一致。以GD32F303搭配USART0为例常见映射关系是USART0_RX对应DMA0的通道3USART0_TX对应DMA0的通道4。如果你的型号是GD32F303CBT6或者RCT6这两个通道号是一致的。但如果你用的是USART1或者USART2通道号就会变化所以我项目里做初始化时会把所有DMA通道宏单独放到一个头文件里这样换型号时只需要改一处。2.2 USART_DATA寄存器与DMA的配合关系不管用哪个通道配置DMA时都要告诉它外设地址在哪里。对USART来说外设地址就是数据寄存器的地址。GD32固件库提供了USART_DATA(USARTx)这个宏实际使用时要取它的地址也就是(uint32_t)USART_DATA(USART0)。当方向配置成外设到内存时DMA每次收到USART的DMA请求就会从这个寄存器读取一个字节写入内存数组。内存地址自动加1外设地址保持不变。当方向配置成内存到外设时DMA从内存读取一个字节写入这个寄存器。USART硬件会自动处理移位、起始位、停止位DMA完全不需要关心比特级时序。这一点和很多人的直觉不同DMA并不参与协议解析它只负责“搬数据”协议怎么解析还是由CPU在中断或者主循环里完成。2.3 为什么接收用循环模式发送用普通模式DMA的循环模式circular mode是在传输结束后自动把计数器重装、内存地址回到初始值继续下一轮传输。接收场景非常适合这种模式因为我们不知道数据什么时候来、来多少循环模式可以让DMA一直待命数据来了就写进缓冲区。发送场景则相反发送长度是事先知道的用普通模式传完就停符合自然逻辑。如果发送也配成循环模式同一段数据会被反复发送出去这通常不是我们想要的行为。3. 从零配置GPIO、USART、DMA三步走3.1 时钟和GPIO初始化我习惯先开时钟再配GPIO。以USART0、PA9做TX、PA10做RX为例void uart_dma_init(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); }TX引脚要配置成复用推挽输出因为数据由USART外设驱动RX引脚可以配置成浮空输入或带上拉输入具体看外部电路。如果板子上有RS485收发器TX/RX的上下拉还要根据收发器方向控制引脚再调整。3.2 USART参数配置GD32固件库配置USART的接口非常清晰直接按顺序设置波特率、字长、停止位、校验位、硬件流控和收发使能即可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_receive_config(USART0, USART_RECEIVE_ENABLE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_enable(USART0);这里的顺序不要乱。先配置参数再使能收发最后调用usart_enable让外设真正工作。如果先使能再改参数可能出现波特率或帧格式错乱。GD32和STM32标准库风格有些神似但函数名完全不同后续代码移植时要特别留意。3.3 DMA参数配置DMA初始化时GD32固件库使用dma_parameter_struct结构体这一点和ST标准库差别很大。接收通道配置如下dma_parameter_struct dma_init_struct; dma_deinit(DMA0, DMA_CH3); dma_struct_para_init(dma_init_struct); dma_init_struct.direction DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.memory_addr (uint32_t)rx_buffer; dma_init_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number RX_BUFFER_SIZE; dma_init_struct.periph_addr (uint32_t)USART_DATA(USART0); dma_init_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, dma_init_struct);发送通道方向改成DMA_MEMORY_TO_PERIPHERAL内存地址指向发送缓冲区传输数量改为发送数据长度dma_deinit(DMA0, DMA_CH4); dma_init_struct.direction DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.memory_addr (uint32_t)tx_buffer; dma_init_struct.number sizeof(tx_buffer) - 1; dma_init(DMA0, DMA_CH4, dma_init_struct);注意这里缓冲区指针用了强转成uint32_t因为固件库要求的是地址值。number在GD32里是剩余传输数据量对8位宽度来说就是字节数不要填错成字word数。3.4 使能USART的DMA请求和中断DMA配置完成后还必须让USART知道“收发都用DMA”通过下面两个函数使能usart_dma_transmit_config(USART0, USART_DMA_TRANSMIT_ENABLE); usart_dma_receive_config(USART0, USART_DMA_RECEIVE_ENABLE);接收通道还要开启循环模式再使能通道。空闲中断需要注册到NVIC这样才能在帧结束时唤醒CPUdma_circulation_enable(DMA0, DMA_CH3); dma_channel_enable(DMA0, DMA_CH3); nvic_irq_enable(USART0_IRQn, 0, 0); usart_interrupt_enable(USART0, USART_INT_IDLE);使能顺序上必须先配置好DMA参数、再使能DMA通道否则通道在错误配置下就开始搬运数据。中断使能放在最后避免初始化过程中来一个没处理完的IDLE中断。4. 不定长接收的惯用解法空闲中断DMA循环缓存4.1 空闲中断原理USART的空闲中断IDLE是接收不定长数据的利器。它表示在一段连续时间内总线上没有新的字符传输硬件会置位IDLE标志。一般来说一帧数据结束后总线必然会出现空闲所以这个中断天然就是“一帧数据接收完成”的通知。配合DMA循环模式可以做到不用预先知道帧长度来多少收多少帧结束自动感知。很多初学者会纠结“如果帧和帧之间的间隔非常短空闲中断还会触发吗”。实际上只要USART在收到最后一个字节后完整经过一个字节周期没有新数据IDLE标志就会被硬件置位。GD32的硬件设计保证了这一点不用软件额外判断。4.2 中断服务函数完整实现接收中断处理代码可以这样写#define RX_BUFFER_SIZE 256 __IO uint8_t rx_buffer[RX_BUFFER_SIZE]; __IO uint16_t rx_len 0; __IO uint8_t rx_complete_flag 0; void USART0_IRQHandler(void) { if(RESET ! usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE); dma_channel_disable(DMA0, DMA_CH3); rx_len RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); if(rx_len 0) { rx_complete_flag 1; } dma_transfer_number_config(DMA0, DMA_CH3, RX_BUFFER_SIZE); dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)rx_buffer); dma_channel_enable(DMA0, DMA_CH3); } }这段代码的核心逻辑是进入中断后先关DMA通道然后读取DMA剩余计数用缓冲区总大小减去剩余计数就是本次帧的实际字节数。处理完之后把DMA的传输计数和内存地址重新设置一遍再使能通道等待下一帧数据。4.3 为什么用“总大小减剩余计数”计算长度很多人第一次看到这个公式会疑惑。DMA的计数器是一个递减计数器当它配置成接收256字节时初始值是256每接收一个字节减1。当IDLE中断发生时如果计数器停留在200说明已经接收了56个字节因此256 - 200 56就是本次帧长度。这种计算方式不需要额外维护接收指针非常简洁。4.4 清除IDLE标志的坑GD32F303上清除IDLE标志不能图省事直接软件置位。按照参考手册的要求标准流程是“先读状态寄存器再读数据寄存器”。固件库虽然封装了usart_interrupt_flag_clear但在某些型号上如果只清标志不读数据寄存器可能导致IDLE标志一直残留下一帧到来时误触发中断。我实际调试时遇到过一次第一帧数据正常后续每一帧长度都会多出上一次的残值排查到最后就是IDLE标志没清干净。稳妥的做法是在清标志之前手动读一次USART的数据寄存器if(RESET ! usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE); (void)usart_data_receive(USART0); ... }这段读取本身不影响DMA接收到的数据因为DMA已经把数据搬走了DR寄存器中通常没有有效数据。这一步纯粹是为了满足硬件的清标志时序。5. DMA发送的两种做法与发送完成判断5.1 轮询DMA传输完成标志发送相对接收要简单一些。最直接的做法是把待发送数据的地址和长度配置到DMA通道使能后轮询DMA传输完成标志DMA_INT_FLAG_FTFuint8_t tx_buffer[] GD32F303 DMA UART Demo\r\n; void uart_dma_send(uint8_t* buffer, uint16_t len) { dma_channel_disable(DMA0, DMA_CH4); dma_transfer_number_config(DMA0, DMA_CH4, len); dma_memory_address_config(DMA0, DMA_CH4, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)buffer); dma_channel_enable(DMA0, DMA_CH4); while(RESET dma_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)); dma_flag_clear(DMA0, DMA_CH4, DMA_FLAG_FTF); }这里的dma_flag_get查询的是DMA通道的“全传输完成”标志当DMA把指定长度的数据全部搬到USART数据寄存器后这个标志会置位。注意DMA把数据搬到数据寄存器并不代表USART已经把数据全部发送到总线上如果后面还要操作RS485方向控制引脚需要额外等待USART的TC发送完成标志。5.2 发送完成中断方式如果不想在发送时阻塞CPU可以用DMA传输完成中断。初始化时使能DMA通道的FTF中断并注册DMA0的中断处理函数dma_interrupt_enable(DMA0, DMA_CH4, DMA_INT_FTF); nvic_irq_enable(DMA0_IRQn, 0, 1);在DMA0_IRQHandler里判断通道4的标志即可。用中断方式时发送函数只需要配置好DMA并使能不需要轮询等待void uart_dma_send_it(uint8_t* buffer, uint16_t len) { while(RESET dma_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)) { /* 等待上一次发送真正完成避免覆盖缓冲区 */ } dma_channel_disable(DMA0, DMA_CH4); dma_transfer_number_config(DMA0, DMA_CH4, len); dma_memory_address_config(DMA0, DMA_CH4, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)buffer); dma_channel_enable(DMA0, DMA_CH4); }我实际项目里会更倾向中断方式因为发送长数据时CPU可以继续做其他事情。但无论哪种方式发送前都要确保上一次发送已经完成否则DMA会直接修改当前正在传输的寄存器导致数据截断或错位。5.3 发送缓冲区生命周期问题使用DMA发送时缓冲区必须在整个发送过程中保持有效。如果缓冲区是函数内部的局部变量函数退出后数据就会被栈回收DMA还在搬运旧地址结果就是乱码。发送缓冲区要么定义成全局数组要么用静态变量要么做动态内存管理并保证在发送完成前不释放。这个坑在刚上手DMA时非常常见我至少见过三个同事在这里翻过车。6. 移植和调试中容易被坑的细节6.1 GD32与STM32标准库的函数名差异如果你是从STM32标准库迁过来的代码不能直接复制。GD32固件库的函数名风格统一为“小写下划线”而且结构体定义也完全不同。对比几个常用接口功能STM32标准库GD32固件库DMA初始化DMA_Initdma_initDMA设置传输数量DMA_SetCurrDataCounterdma_transfer_number_configDMA获取剩余数量DMA_GetCurrDataCounterdma_transfer_number_getUSART发送一字节USART_SendDatausart_data_transmitUSART接收一字节USART_ReceiveDatausart_data_receive使能DMA通道DMA_Cmddma_channel_enable循环模式DMA_ITConfig / DMA_Modedma_circulation_enable移植时用一个脚本批量替换函数名并不难难的是结构体字段对不上比如GD的dma_parameter_struct把方向和地址、数量打包在一起字段命名和ST库差异很大这一块需要手工逐项核对。6.2 缓冲区对齐与DMA访问DMA搬运数据时如果缓冲区地址没有对齐某些总线配置下可能出现效率下降甚至HardFault。GD32F303的DMA对8位宽度访问要求不高但稳妥起见接收缓冲区建议做四字节对齐__ALIGNED(4) __IO uint8_t rx_buffer[RX_BUFFER_SIZE];另外缓冲区一定要定义为全局变量或静态变量不要让栈地址参与DMA长时间传输。栈地址本身是合法的但如果在传输过程中栈指针移动数据可能被其他函数调用覆盖。6.3 接收缓冲区长度与16位计数器上限DMA的传输计数器是16位的最大只能填65535。如果你把RX_BUFFER_SIZE设成70000配置时直接被截断。实际项目里缓冲区长度一般不会超过4KB但你要是做大数据量采集就得把缓冲区拆成多段或者用半传输中断把一个长缓冲区拆成两个“轮次”处理也就是双缓冲思想。6.4 低功耗模式下的DMA时钟问题如果系统有低功耗需求进入休眠模式前必须关闭DMA通道并处理挂起的DMA请求否则唤醒后可能发生意外传输。这个细节在我做电池供电设备时踩过坑休眠前没关DMA唤醒瞬间串口缓冲区多了几个随机字节排查了好久才发现是DMA残留请求。6.5 实测性能对比与个人建议我在GD32F303上做过一组简单对比115200波特率一次发送1KB数据。用传统中断方式CPU需要在每个字节的发送/接收中断里进出整体负载大约在25%到35%之间改成DMA收发之后只有帧空闲中断和发送完成中断会唤醒CPU整体负载降到5%以下。对需要同时处理多个任务的系统来说DMA不是可选项而是必需品。个人经验是接收不定长数据优先采用“DMA循环模式USART空闲中断”这套组合代码量不大稳定性好适合Modbus、自定义协议帧等绝大多数场景。如果后续数据吞吐量进一步增大再考虑双缓冲和半传输中断但那是另一个进阶话题了。本文还有配套的精品资源点击获取