GD32F103串口DMA无中断收发实战:环形缓冲区与轮询标志处理
1. GD32F103串口DMA无中断方案设计背景在嵌入式开发中串口通信是最基础也最常用的外设之一。传统的中断方式虽然简单直接但在处理大量数据时存在明显瓶颈——每个字节收发都会触发中断导致CPU频繁响应严重影响系统整体性能。我在开发工业数据采集器时就遇到过这种情况当传感器数据量增大时系统响应明显变慢甚至出现数据丢失。DMA直接内存访问技术正好能解决这个问题。它允许外设直接与内存交换数据无需CPU介入。GD32F103作为一款高性能Cortex-M3内核MCU其DMA控制器支持多种外设包括USART。但直接使用DMA会遇到两个关键问题接收数据可能被新数据覆盖以及发送时机如何确定。这就是我们采用环形缓冲区轮询标志方案的出发点。实测表明在115200波特率下这套方案能让CPU占用率从原来的70%降至不足5%同时保证数据零丢失。特别适合日志记录、传感器数据采集等对实时性要求不高但需要稳定传输的场景。2. 硬件初始化与DMA配置详解2.1 GPIO和USART基础配置先来看硬件初始化部分。以USART1为例需要先使能相关时钟// 使能GPIOA时钟USART1默认引脚PA9/PA10 rcu_periph_clock_enable(RCU_GPIOA); // 使能USART1时钟 rcu_periph_clock_enable(RCU_USART1);引脚配置要注意输出模式选择。发送引脚需要推挽输出接收引脚则配置为浮空输入gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); // TX gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); // RXUSART参数设置中波特率计算是个易错点。GD32的波特率计算公式为波特率 CK_APB1 / (16 * USARTDIV)其中USARTDIV是写入寄存器的值。建议直接使用库函数设置usart_baudrate_set(USART1, 115200); usart_word_length_set(USART1, USART_WL_8BIT); usart_stop_bit_set(USART1, USART_STB_1BIT);2.2 DMA通道映射与初始化GD32的DMA通道与外设的映射关系需要查数据手册确定。USART1_TX对应DMA0通道6USART1_RX对应DMA0通道5。配置时要注意方向设置// 发送DMA配置 dma_init_struct.direction DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.periph_addr (uint32_t)USART_DATA(USART1); // 接收DMA配置 dma_init_struct.direction DMA_PERIPHERAL_TO_MEMORY;一个关键区别是循环模式设置。接收DMA需要开启循环模式防止数据丢失而发送DMA则关闭循环模式以便手动控制dma_circulation_enable(DMA0, DMA_CH5); // RX循环 dma_circulation_disable(DMA0, DMA_CH6); // TX非循环3. 环形缓冲区设计与实现3.1 环形缓冲区数据结构环形缓冲区的核心是三个指针读指针、写指针和缓冲区大小。定义如下typedef struct { uint8_t *buffer; uint16_t size; volatile uint16_t head; // 写指针 volatile uint16_t tail; // 读指针 } RingBuffer;初始化时需要注意内存对齐问题。如果使用DMA缓冲区地址最好4字节对齐__align(4) uint8_t rx_buf[256]; RingBuffer rb { .buffer rx_buf, .size sizeof(rx_buf), .head 0, .tail 0 };3.2 DMA接收数据填充策略接收数据的核心是定期检查DMA的当前写入位置。通过对比上次记录的位置计算新数据长度uint16_t curr_pos rb.size - dma_transfer_number_get(DMA0, DMA_CH5); uint16_t new_len (curr_pos - rb.head) % rb.size;这里有个坑我踩过当DMA计数器溢出时curr_pos会突然变小需要特殊处理。正确的数据拷贝方式if(rb.head new_len rb.size) { memcpy(rb.buffer[rb.head], dma_rx_buf[rb.head], new_len); } else { uint16_t first_part rb.size - rb.head; memcpy(rb.buffer[rb.head], dma_rx_buf[rb.head], first_part); memcpy(rb.buffer, dma_rx_buf, new_len - first_part); } rb.head (rb.head new_len) % rb.size;4. 无中断发送的实现技巧4.1 轮询DMA传输完成标志发送的关键是正确判断上次传输是否完成。GD32提供了多个DMA标志位最可靠的是FTC完全传输完成标志if(dma_flag_get(DMA0, DMA_CH6, DMA_FLAG_FTC) ! RESET) { dma_flag_clear(DMA0, DMA_CH6, DMA_FLAG_FTC); // 可以开始新传输 }实测发现在GD32中直接重新初始化DMA通道比修改参数更可靠。虽然效率稍低但稳定性更好dma_deinit(DMA0, DMA_CH6); // 重新初始化结构体 dma_init(DMA0, DMA_CH6, dma_init_struct);4.2 发送缓冲区管理建议采用双缓冲机制一个后台缓冲区用于准备数据一个前台缓冲区用于DMA发送。切换时使用指针交换if(tx_dma_idle) { uint8_t *temp tx_buf_active; tx_buf_active tx_buf_ready; tx_buf_ready temp; // 启动DMA传输 }5. 性能优化与问题排查5.1 缓冲区大小选择经验缓冲区大小需要平衡内存占用和性能。根据实测经验115200波特率下256字节缓冲区可支持约22ms的数据缓存对于突发数据建议缓冲区能存储最大预期突发量的2倍日志场景推荐512字节高速数据采集建议1KB以上5.2 常见问题排查指南数据错位检查DMA内存/外设地址对齐确保都是8位或都16位丢失数据确认接收DMA开启了循环模式缓冲区足够大发送卡死添加超时机制超过预期时间强制重启DMA校验错误在DMA传输前后加入CRC校验我常用CRC8节省资源一个实用的调试技巧在缓冲区头尾放置特殊标记如0xAA、0x55定期检查是否被覆盖。6. 完整代码框架示例以下是经过项目验证的核心代码框架// 初始化部分 void uart_dma_init(void) { // GPIO和USART初始化 // DMA接收配置 dma_init(DMA0, DMA_CH5, dma_rx_config); dma_channel_enable(DMA0, DMA_CH5); // DMA发送配置 dma_init(DMA0, DMA_CH6, dma_tx_config); } // 主循环处理 void main_loop(void) { // 接收数据处理 uint16_t new_data check_dma_receive(); if(new_data) { process_rx_data(new_data); } // 发送处理 if(need_send_data()) { start_dma_transmit(); } }实际项目中我会在process_rx_data中加入数据分包处理因为DMA接收是流式的需要自己处理协议帧。