GD32F303 USART+DMA收发详解:从原理到代码实现
2026/9/2 17:02:10 网站建设 项目流程

简介:这套资源是“GD32F303固件库开发”系列第9篇的配套工程代码,面向使用兆易创新GD32F303进行嵌入式开发的工程师与学生,重点演示通过DMA方式实现USART串口收发,可有效降低串口通信时的CPU占用,并减少数据量大时可能出现的丢包问题。压缩包共包含1251个文件,以C源码和H头文件为主体,同时提供Keil、IAR等常见IDE的工程文件,以及编译生成的hex、bin固件,便于直接对照学习或烧录验证;整体仅4.18MB,结构紧凑、易于下载使用。目前已有3329人学习下载。代码中已配置好串口与DMA通道,并给出清晰的初始化与中断处理流程,读者可结合配套CSDN文字教程和B站教学视频,快速理解外设时钟、DMA请求映射、缓冲区管理等关键知识点,进而轻松迁移到自己的实际项目中。

1. 为什么USART要配DMA:先搞清楚这件事值不值得做

1.1 串口收发的痛点到底在哪

做GD32F303开发的朋友应该都有过这种经历:用轮询方式发一串日志数据,CPU就在那死等,一个字节一个字节往数据寄存器里塞,期间什么事情都干不了;用中断方式稍微好一点,但来一个字节进一次中断,如果波特率跑到115200以上,加上系统里还有定时器、ADC、显示刷新这些任务,中断嵌套一多,整个系统的实时性很容易被拖垮。

我之前有一个项目,用GD32F303做数据采集和上报,串口跑到460800波特率,每秒大约46000字节,如果用传统中断方式,每字节进一次中断意味着每秒几万次中断响应,光是保存现场、恢复现场就得消耗大量CPU周期。后来我把接收改成DMA+空闲中断,发送改成DMA完成中断,从那个版本开始,CPU占用率肉眼可见地降下来了。

所以这个标题里"USART通过DMA收发"这件事,本质上解决的问题是:怎么用最少的CPU介入,把串口数据从外设搬到内存,再从内存搬到外设。GD32F303的DMA控制器就是专门干这个的,它不需要CPU逐字节搬运,配置好源地址、目的地址、传输长度之后,硬件自己就把活儿干完,干完了再通知你一声。

1.2 DMA在这里扮演的角色

GD32F303内置的DMA控制器有多个通道,每个通道可以关联不同的外设请求,USART的发送和接收都有独立的DMA请求线。当串口接收寄存器里有数据时,硬件会自动触发DMA搬运,把数据从串口数据寄存器搬到你在内存里开辟的缓冲区;当你的程序往发送缓冲区写入数据并启动DMA后,DMA会一个个字节把数据发出去,全部发完产生一个传输完成中断。

你可以把DMA理解成一个跑腿的:你告诉它"把这筐苹果从A搬到B",它就一趟一趟搬,搬完回来跟你说一声。搬运期间你自己该干嘛干嘛,完全不用管。这对于需要频繁收发、或者数据量不固定的场景,效果立竿见影。

不过GD32F303的USART+DMA有一个很关键的细节:USART的接收DMA在接收到固定长度数据后才能触发完成中断。也就是说,如果你不知道对方会发多少字节,单纯靠"DMA传输完成"来判断一帧数据结束了,很可能等不到这个中断。常规做法是开启串口的空闲中断(IDLE),用"DMA搬运+空闲中断"组合判定一帧接收完成。后面我会详细说这个实现。

2. 动手前的硬件与固件库基础

2.1 引脚与串口初始化

我用的是GD32F303系列,不同型号引脚和串口号有差异,但用固件库初始化串口的写法基本一致。以USART0为例,它默认复用PA9作为TX、PA10作为RX,需要使能GPIOA和USART0的时钟,然后配置引脚为复用推挽输出和浮空输入(或者上拉输入,看硬件电路)。

/* 使能时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_DMA0); /* 配置PA9为USART0_TX,PA10为USART0_RX */ gpio_af_set(GPIOA, GPIO_AF_7, GPIO_PIN_9 | GPIO_PIN_10); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_PULLUP, GPIO_PIN_9 | GPIO_PIN_10); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9);

然后配置串口参数,9600、115200、460800都可以,根据自己的需求来。

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);

这里有个细节:如果只做发送,不使能接收寄存器非空中断,接收功能仍然可以正常由DMA搬运。接收DMA的触发条件是串口接收数据寄存器非空,这个和中断使能是独立的,不要混淆。

2.2 DMA通道映射关系

GD32F303有DMA0和DMA1两个控制器,不同串口对应的请求通道号不一样,我一开始就没看手册,想当然地用了DMA0_Channel3,结果数据根本不进缓冲区,查了半天才发现通道映射错了。

以USART0为例:

  • 发送请求:DMA0的通道4
  • 接收请求:DMA0的通道3

如果是USART1,通道映射又不一样。这个一定要去看GD32F303用户手册里的DMA请求映射表,不要凭经验猜。固件库提供了dma_request_config之类的接口来设置请求来源,但这个并不代表通道一定匹配,通道和请求的对应关系是芯片厂家设计死的,选错了就收不到数据。

2.3 固件库配置DMA的常规写法

无论是发送还是接收,DMA的配置项都差不多:外设基地址、存储器地址、传输方向、缓冲区大小、传输宽度、优先级、是否开启循环模式。发送和接收的区别主要在传输方向和是否循环上。

发送DMA的方向是"存储器到外设",接收DMA的方向是"外设到存储器"。发送通常不开启循环模式,一帧数据发完就停;接收通常会开启循环模式,这样DMA在缓冲区满了之后会自动回到起始地址继续写,配合空闲中断可以连续接收不定长数据。

3. 代码实现:DMA发送与DMA接收完整流程

3.1 DMA发送的实现与注意事项

发送的逻辑相对简单。定义一个发送缓冲区,当需要发送数据时,把数据拷到缓冲区,然后启动一次DMA传输,传输完成中断里做清理工作。

#define USART0_TX_BUFFER_SIZE 256 uint8_t usart0_tx_buffer[USART0_TX_BUFFER_SIZE]; volatile uint8_t usart0_tx_busy = 0; void usart0_dma_send(uint8_t *data, uint16_t len) { if (len == 0 || len > USART0_TX_BUFFER_SIZE) { return; } while (usart0_tx_busy) { /* 等待上一次发送完成 */ } memcpy(usart0_tx_buffer, data, len); dma_channel_disable(DMA0, DMA_CH4); dma_memory_address_config(DMA0, DMA_CH4, DMA_MEMORY_0, (uint32_t)usart0_tx_buffer); dma_transfer_number_config(DMA0, DMA_CH4, len); dma_channel_enable(DMA0, DMA_CH4); usart0_tx_busy = 1; } void DMA0_Channel4_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH4); usart0_tx_busy = 0; } }

这里有个细节值得说:发送DMA的触发条件是串口的发送数据寄存器为空,DMA会自动把下一个字节写入到串口数据寄存器。但需要在启动DMA之前,先确保串口的发送功能已经使能。另外,DMA发出第一个字节之前,串口数据寄存器需要处于"空"状态,如果之前有残留数据,最好先清一下USART_STAT里的TC标志。

实际调试中我踩过一个坑:如果DMA传输完成中断里不先失能通道,再次启动前直接改传输长度,会出现第一次正常、第二次卡死的问题。原因是DMA还在使能状态时,配置通道寄存器会冲突,所以每次启动前都要先失能通道,配置完成后再使能,这个顺序不能省。

3.2 DMA接收:固定长度与不定长两种思路

接收分两种情况:一种是你提前知道对方会发多少字节,比如读取传感器时固定返回8个字节,那直接配好DMA传输长度为8,接收完成中断来了就处理;另一种是不知道对方发多少字节,比如串口调试指令、Modbus报文、GPS数据,这种就需要"空闲中断"来辅助判断一帧结束。

固定长度的实现:

dma_channel_disable(DMA0, DMA_CH3); dma_periph_address_config(DMA0, DMA_CH3, (uint32_t)&USART_DATA(USART0)); dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_0, (uint32_t)usart0_rx_buffer); dma_transfer_number_config(DMA0, DMA_CH3, 8); dma_channel_enable(DMA0, DMA_CH3);

只要收到8个字节,DMA通道3的传输完成中断就会触发,在中断里处理数据即可。但问题来了:如果对方发了7个字节就不发了,DMA永远等不到第8个字节,你这个接收就卡死了。所以严格来说,固定长度接收只适合帧长度完全固定的协议。

实际项目中更实用的是不定长接收:DMA开启循环模式,缓冲区大小定义为最大帧长度(比如256字节),然后使能串口的空闲中断。每当总线上出现一个字节间隔(空闲状态),就认为一帧数据发完了,在空闲中断里根据DMA当前写到了缓冲区的哪个位置,算出收到的数据长度,然后把数据拷贝走。

3.3 空闲中断+DMA循环模式:不定长接收的标准姿势

先看一下初始化部分:

#define USART0_RX_BUFFER_SIZE 256 uint8_t usart0_rx_buffer[USART0_RX_BUFFER_SIZE]; volatile uint16_t usart0_rx_last_pos = 0; void usart0_dma_rx_init(void) { dma_channel_disable(DMA0, DMA_CH3); dma_deinit(DMA0, DMA_CH3); dma_periph_address_config(DMA0, DMA_CH3, (uint32_t)&USART_DATA(USART0)); dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_0, (uint32_t)usart0_rx_buffer); dma_transfer_number_config(DMA0, DMA_CH3, USART0_RX_BUFFER_SIZE); dma_priority_config(DMA0, DMA_CH3, DMA_PRIORITY_HIGH); dma_transfer_direction_config(DMA0, DMA_CH3, DMA_PERIPH_TO_MEMORY); dma_memory_width_config(DMA0, DMA_CH3, DMA_MEMORY_WIDTH_8BIT); dma_periph_width_config(DMA0, DMA_CH3, DMA_PERIPHERAL_WIDTH_8BIT); dma_circulation_enable(DMA0, DMA_CH3); dma_channel_enable(DMA0, DMA_CH3); /* 使能串口空闲中断 */ usart_interrupt_enable(USART0, USART_INT_IDLE); /* 注意:清空闲标志的方法是先读USART_STAT,再读USART_DATA */ if (usart_flag_get(USART0, USART_FLAG_IDLE)) { usart_flag_clear(USART0, USART_FLAG_IDLE); } nvic_irq_enable(USART0_IRQn, 1, 0); nvic_irq_enable(DMA0_Channel3_IRQn, 0, 0); }

接收处理的重点在串口中断服务函数里。GD32F303的清空闲标志和STM32一样,需要先读状态寄存器,再读数据寄存器,否则清不掉。

void USART0_IRQHandler(void) { if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_IDLE)) { /* 先读状态,再读数据,清除IDLE标志 */ usart_flag_clear(USART0, USART_FLAG_IDLE); /* 计算当前DMA写到了哪个位置 */ uint16_t cur_pos = USART0_RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); /* 处理一帧新数据 */ if (cur_pos != usart0_rx_last_pos) { uint16_t recv_len; if (cur_pos > usart0_rx_last_pos) { recv_len = cur_pos - usart0_rx_last_pos; handle_uart_frame(&usart0_rx_buffer[usart0_rx_last_pos], recv_len); } else { /* 缓冲区回绕了 */ recv_len = USART0_RX_BUFFER_SIZE - usart0_rx_last_pos + cur_pos; handle_uart_frame(&usart0_rx_buffer[usart0_rx_last_pos], USART0_RX_BUFFER_SIZE - usart0_rx_last_pos); handle_uart_frame(&usart0_rx_buffer[0], cur_pos); } usart0_rx_last_pos = cur_pos; } } }

这段代码里有几个关键逻辑:

  • dma_transfer_number_get返回的是DMA剩余要传输的字节数,拿缓冲区总大小减去剩余数,就能算出DMA已经写了多少个字节。
  • 因为是循环模式,DMA写完256字节后会自动回绕到0,所以计算新数据长度时要考虑"当前位置小于上次位置"的回绕情况。
  • 帧处理函数里,建议先把数据拷贝到自己的协议缓冲区再做解析,不要直接在DMA缓冲区上解析。不然数据还没处理完,DMA又写入新数据把缓冲区覆盖了。

3.4 发送完成中断里该做什么

发送DMA完成中断的优先级通常不需要很高,因为发送本身不着急。但要注意一点:发送完成中断触发的前提是DMA把所有字节都搬到了串口的发送数据寄存器,但这时候最后一个字节可能还没有完全从移位寄存器发出去。如果紧接着就要切换RS485的收发方向,或者关闭串口,你需要额外等待USART的TC标志置位。

对于RS485方向切换,我习惯的做法是在DMA发送完成中断里,不清DMA,而是等TC标志:

void DMA0_Channel4_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH4); /* 等待最后一个字节完全发送出去 */ while (!usart_flag_get(USART0, USART_FLAG_TC)); usart0_tx_busy = 0; /* 在这里切换RS485方向或做其他收尾操作 */ } }

等待TC标志这个操作很快,一般也就几微秒,但对RS485通信来说,这一个细节决定了你最后一字节会不会被截断。

4. 实测遇到的那些坑与排查技巧

4.1 常见问题速查表

我整理了这段时间调试USART+DMA遇到的高频问题,按"症状→原因→解法"列个表:

症状大概率原因解决办法
DMA接收完全没数据DMA通道和串口请求不匹配查手册确认USART0对应DMA0的通道3/4
收到数据是乱的外设/内存宽度配置不一致统一配置为8bit宽度
第一帧正常,后续卡死DMA未关闭就重新配置dma_channel_disable再配置再enable
空闲中断一直触发IDLE标志没清干净读状态寄存器后再读数据寄存器
DMA发送完成但数据不完整没有等待USART TC标志在DMA完成中断里轮询TC
数组越界/跑飞接收数据长度超过缓冲区缓冲区大小要按最大帧长留余量,处理函数做长度校验
循环模式接收乱序未处理缓冲区回绕cur_pos < last_pos判断回绕,分两段处理

4.2 关于USART空闲标志的清除顺序

GD32固件库里清IDLE标志有一个很隐蔽的坑。如果你直接调用usart_flag_clear(USART0, USART_FLAG_IDLE),有些库版本会正常清掉,但有些版本因为库的封装方式问题,清不掉,导致一进中断就出不来的假象。稳妥写法是:

usart_interrupt_flag_clear(USART0, USART_INT_FLAG_IDLE);

或者用最原始的寄存器操作:

uint32_t temp = USART_STAT(USART0); temp = USART_DATA(USART0); (void)temp;

先读STAT再读DATA,这是GP32和STM32系通用的清IDLE逻辑。我建议在中断里直接用寄存器读的方式,最不容易踩坑。

4.3 缓冲区大小与DMA传输计数器的配合

还有一个容易被忽视的地方:DMA循环模式下,dma_transfer_number_get返回的值在传输过程中是动态变化的,但它是递减计数:初始化时是缓冲区大小,每搬运一个字节减1,搬完一轮后重新装载为初始值。

所以计算已接收字节数时,用"缓冲区大小-当前传输计数"在大多数时刻是对的。但如果恰好赶上DMA刚完成一轮回绕、计数器重新装载,而数据还没来得及写入缓冲区,你算出来的位置可能暂时是0或缓冲区大小。这种极短时间内偏差可以接受,因为你的处理函数有长度判断,最多把一帧数据切分成两段处理。自己写协议时,建议用帧头+帧尾+长度字段的方式做二次校验,不要把DMA位置计算当成100%准确的帧边界。

4.4 实测中的性能提升体验

项目里我实际测过一组对比数据:MCU主频120MHz,串口460800波特率,每50ms向主机发送一帧约200字节的波形数据。用传统逐字节中断发送时,CPU在发送期间的占用率大约能到30%(中间还夹杂着其他中断);改成DMA发送后,CPU只需把数据拷到缓冲区并启动DMA,剩下的时间全部释放给主循环做算法处理,CPU占用率在发送期间掉到5%以内。

接收侧更是明显。之前是每字节进一次接收中断,在高速数据流场景下经常丢字节,因为CPU来不及读;改成DMA+空闲中断后,数据直接进内存,中断频率从"每秒几万次"降为"每帧一次",几乎不存在丢字节的问题了。

5. 最后想分享的一个排障技巧

调试DMA类问题,最忌"凭感觉改代码"。我调试USART收不到数据时,一定会先用逻辑分析仪或者串口助手确认:串口本身有没有波形数据进来?这个确认完之后,再分两步排查:先关掉DMA,用轮询方式读取串口数据,确认串口外设工作正常;再配DMA,但把DMA中断关了,只在主循环里查询DMA传输计数是否在变化。如果计数在变,说明DMA搬运是通的,问题在中断处理上;如果计数不变,说明DMA根本没被触发,基本就是通道映射或者外设请求配置的问题。

这个排查思路帮我避开了很多弯路。你如果第一次调USART+DMA,建议也按这个顺序走一遍,比自己瞎猜哪里的配置不对要高效得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询