简介:面向GD32F405RGT6与STM32嵌入式开发者,这份资源围绕串口(UART)接收发送与DMA传输展开,涵盖寄存器配置、固件库API调用及中断处理,适合需要提升串口通信效率、降低CPU负载的单片机工程师。压缩包共286个文件,以C源码、头文件及Keil工程文件(.uvprojx)为主,另含可执行镜像(.axf)和内存映射(.map),可直接打开工程查看初始化与中断逻辑;资源整体约5.52MB。已有4050人学习下载,可见其具备不错的实践参考价值。通过研读示例,能掌握UART_DMACmd、DMA_ITConfig等关键函数用法,厘清收发DMA配置差异,并快速移植到自身项目,缩短串口模块开发周期。 做嵌入式如果没有被串口卡过几次,都不好意思说自己调过板子。GD32F405RGT6这颗Cortex-M4内核的国产MCU,主频能拉到200MHz,外设资源也够丰富,尤其串口带DMA这件事,用好了CPU占用率能降一大截。这阵子我在一个工业数据采集项目里重新把它的串口DMA收发捋了一遍,踩了几个老坑,也把接收不定长数据那套方案彻底搞顺了,今天就把整个思路和可复现的代码逻辑整理出来。
1. 串口DMA的需求拆解与应用场景
1.1 为什么会用到DMA收发
先说说为什么放着好好的串口中断不用,非要去折腾DMA。之前我做过一个和多个从机通信的网关设备,一帧报文少则八字节,多则上百字节,主频虽然不低,但每收一个字节就进一次中断,一次中断里还要做标志位判断、数据搬移、缓存更新这些操作,加上系统里还跑着协议栈和浮点运算,串口速率一提到921600,CPU占用率肉眼可见地涨。更要命的是,高速中断下如果代码里某段临界区没处理好,接收缓冲区溢出丢字节就是家常便饭。
使用DMA以后,数据搬运这件事完全交给了DMA控制器。它就像一个专职的搬运工,数据从外设数据寄存器搬到内存,全程不需要CPU参与。CPU这边只需要在整帧数据接收完成后,去内存缓冲区里取数据就行。在这个项目里,我把串口的收发全部改成DMA方式,CPU占用率降下来不说,数据丢包的情况也基本消失了。
1.2 接收不定长数据是刚需
串口通信里最常遇到的问题就是“我怎么知道这帧数据什么时候收完了”。对于Modbus这类协议,报文是规定好长度的;但更多场景下,比如和GPS模块、指纹模块、上位机自定义协议通信,每一帧长度是不固定的。
最简单粗暴的做法是用“单字节中断+超时判断”:每收到一个字节就重置定时器,超时认为一帧结束。这种方式代码简单,但高速数据下容易判断失误,而且CPU开销大。另一个方案就是本文要细说的“DMA接收+空闲中断”,后面整个方案的基石就靠它。
2. GD32F405串口DMA的关键原理
2.1 DMA工作流程与通道映射
GD32F405的DMA控制器有DMA0和DMA1两个,每个DMA有8个通道,每个通道可以服务多个外设请求,但同一时刻只能有一个外设占用该通道。串口接DMA的时候,需要查数据手册确认对应的DMA通道请求映射表。
我用的是USART1和USART2,它们的收发通道分别是DMA0的通道2/3和通道4/5之类的映射关系。具体到GD32F405RGT6,下面这个映射关系在实际配置时最常用:
| 串口外设 | DMA控制器 | 接收通道 | 发送通道 |
|---|---|---|---|
| USART0 | DMA0 | 通道3 | 通道2 |
| USART1 | DMA0 | 通道5 | 通道4 |
| USART2 | DMA0 | 通道7 | 通道6 |
| UART3 | DMA1 | 通道1 | 通道0 |
| UART4 | DMA1 | 通道3 | 通道2 |
注意:不同型号GD32F4系列同一串口对应的DMA通道不一定完全相同,动手前务必参照对应型号的《参考手册》DMA请求映射表核对一遍,我就见过有人把F103的映射习惯直接套到F405上导致数据根本收不到的情况。
2.2 空闲中断DLE/IDLE的判定逻辑
接收不定长数据的时候,空闲中断是整个方案的触发器。GD32的串口模块里,当接收总线上一段时间没有收到新数据时,会触发空闲中断。以USART1举例,数据从起始位开始,到最后一个停止位结束,如果在停止位之后检测到总线处于空闲状态,就会拉高空闲标志。
GD32标准库里,这个标志在3.x版本的库里面叫USART_INT_FLAG_IDLE,更新的库版本里也有地方写成USART_INT_FLAG_DLE,本质是一个东西。清除方法比较特殊:要先读一次USART_STAT0寄存器,再读一次USART_DATA寄存器,这个顺序错了或者少一步,中断就会一直触发或者触发错乱。很多新手在这块卡很久,原因就是没理解它和普通中断标志的清除方式不一样。
2.3 接收缓冲区的双缓冲思路
DMA接收数据是连续往内存地址里写的,如果接收缓冲区只有一个数组,处理完一帧数据、清空缓冲区、再等下一帧,期间只要有新数据进来就会覆盖。我这里的做法是用两个数组做双缓冲:DMA当前往缓冲区A写数据,CPU解析完缓冲区B的数据后,再把DMA的目标地址切换到缓冲区A。两个缓冲区交替使用,数据不会冲突。
当然,GD32的DMA也支持循环模式,可以在一个缓冲区里循环写,用读指针和写指针来管理数据。这种做法的好处是缓冲区可以做得大一些,坏处是如果CPU处理不及时,写指针追上读指针还是会丢数据。双缓冲更直观,配合空闲中断效果最好。
3. 实操环节:从初始化到收发实现
3.1 引脚与时钟配置
在GD32F405RGT6上,USART1的引脚是PA9(TX)和PA10(RX),挂在APB2总线上;GPIOA挂在AHB1总线上。这里尤其要注意,GD32的GPIO时钟使能函数和STM32不一样,rcu_periph_clock_enable里面要传入RCU_GPIOA、RCU_USART1、RCU_DMA0这三个宏。
配置引脚时,TX要配置为复用推挽输出GPIO_MODE_AF_PP,RX配置为复用开漏或者复用推挽都可以,关键是复用功能号要选对,USART1的复用功能号在F405上是GPIO_AF_7。有的例程里用的GPIO_AF_1是F1系列的映射,直接抄过来就是坑。
3.2 串口参数与DMA通道初始化
串口的基本参数波特率9600、数据位8位、无校验、1位停止位,直接通过usart_init配置。注意GD32的库函数里usart_init的第一个参数是USART_COM这个结构体,传地址进去,这和STM32标准库的传参方式一致,但结构体成员的排列顺序略有区别,最好是按结构体定义逐个赋值,不要用整体初始化,避免版本兼容问题。
DMA通道初始化是重头戏。以USART1的接收DMA举例,我使用的是DMA0的通道3,配置成外设到内存传输,外设地址是&USART_DATA(USART1),内存地址是缓冲区数组地址,传输方向是外设到内存,缓冲区大小是接收长度。运行模式这里选循环模式DMA_CIRCULAR_MODE,这样接收完一轮数据后DMA会自动回到起点继续接收,不用每次接收完都重新配置一次。
发送DMA通道我配置成单次模式DMA_NORMAL_MODE,内存到外设,外设地址同样是数据寄存器地址。这个区别很重要,接收用循环模式是为了不间断地等数据,发送用单次模式是因为每次发送的内容、长度都可能不同,发完一次就该停。
3.3 接收空闲中断配置与处理
配置完DMA后,需要使能串口的空闲中断。GD32库里调用usart_interrupt_enable(USART1, USART_INT_IDLE),同时使能DMA的接收通道,也就是调用dma_channel_enable(DMA0, DMA_CH3),这样数据流就打通了。
中断优先级这里我要多说一句。如果裸机编程,空闲中断最好配置成最高优先级或者次高优先级,因为它是判断“一帧数据结束”的关键信号。如果系统里有其他频繁的中断源,比如定时器、外部中断,而且优先级比它高,很有可能在DMA接收完一整帧之后,空闲中断迟迟挤不进去,导致下一帧数据来了还没处理完上一帧。用NVIC配置时,抢占优先级0~3,我一般给串口空闲中断配抢占优先级1、子优先级0。
中断处理函数里面做三件事:
第一,判断空闲标志是否置位,也就是读USART_STAT0寄存器的对应位;第二,按照前面说的顺序清除标志位;第三,关闭DMA通道,读取当前DMA剩余数据量寄存器DMA_CHCNT,计算出实际接收了多少字节,然后处理数据,再重新开启DMA通道。
计算接收长度的公式是:
uint16_t data_len = RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3);dma_transfer_number_get返回的是DMA当前还剩多少数据没传,用缓冲区总长度减去这个值,就是已经收到的数据字节数。
3.4 完整代码框架
下面是整理好的核心代码框架,可以直接往工程里套。粗体部分是需要根据实际串口号和通道号修改的地方。
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint8_t tx_buffer[TX_BUFFER_SIZE]; void uart_dma_init(void) { /* 时钟使能 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART1); rcu_periph_clock_enable(RCU_DMA0); /* GPIO配置:PA9 TX, PA10 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 | GPIO_PIN_10); /* USART1配置 */ usart_deinit(USART1); usart_baudrate_set(USART1, 115200U); usart_word_length_set(USART1, USART_WL_8BIT); usart_stop_bit_set(USART1, USART_STB_1BIT); usart_parity_config(USART1, USART_PM_NONE); usart_hardware_flow_rts_config(USART1, USART_RTS_DISABLE); usart_hardware_flow_cts_config(USART1, USART_CTS_DISABLE); usart_receive_config(USART1, USART_RECEIVE_ENABLE); usart_transmit_config(USART1, USART_TRANSMIT_ENABLE); usart_enable(USART1); /* 接收DMA:DMA0通道3,循环模式 */ dma_deinit(DMA0, DMA_CH3); dma_parameter_struct dma_rx_struct; dma_struct_para_init(&dma_rx_struct); dma_rx_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_rx_struct.periph_addr = (uint32_t)(&USART_DATA(USART1)); dma_rx_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_rx_struct.memory_addr = (uint32_t)rx_buffer; dma_rx_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_rx_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_struct.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_rx_struct.number = RX_BUFFER_SIZE; dma_rx_struct.priority = DMA_PRIORITY_HIGH; dma_circulation_config(DMA0, DMA_CH3, DMA_CIRCULATION_MODE); dma_channel_config(DMA0, DMA_CH3, &dma_rx_struct); dma_channel_enable(DMA0, DMA_CH3); /* 使能串口空闲中断 */ usart_interrupt_enable(USART1, USART_INT_IDLE); nvic_irq_enable(USART1_IRQn, 1, 0); } void USART1_IRQHandler(void) { if (usart_interrupt_flag_get(USART1, USART_INT_FLAG_IDLE) != RESET) { /* 读状态寄存器,再读数据寄存器,清除空闲标志 */ usart_interrupt_flag_clear(USART1, USART_INT_FLAG_IDLE); volatile uint8_t dummy = usart_data_receive(USART1); (void)dummy; /* 关闭DMA,计算本次接收长度 */ dma_channel_disable(DMA0, DMA_CH3); uint16_t rx_len = RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); if (rx_len > 0) { process_rx_frame(rx_buffer, rx_len); } /* 清空缓冲区重启接收 */ memset(rx_buffer, 0, RX_BUFFER_SIZE); dma_channel_enable(DMA0, DMA_CH3); } }3.5 DMA发送的实现细节
发送端稍微简单一些,但有一个点必须注意:DMA发送完以后不会主动通知你,你需要自己判断发送是否真正结束,才能安全地释放缓冲区或者修改数据。如果发送的数据放在局部数组里,发送函数返回后数组里的数据可能还没被DMA搬完,这是极其隐蔽的bug。
我的做法是开启DMA发送完成中断,或者干脆查询串口的发送完成标志USART_FLAG_TC。最简单的发送函数如下:
void uart_dma_send(uint8_t *data, uint16_t len) { if (len == 0) return; dma_channel_disable(DMA0, DMA_CH2); dma_memory_address_config(DMA0, DMA_CH2, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)data); dma_transfer_number_config(DMA0, DMA_CH2, len); dma_channel_enable(DMA0, DMA_CH2); }发送完成后,如果是发了一帧modbus报文想立刻切换收发模式,不要直接去改方向,先确认USART_FLAG_TC置位了再操作:
while (usart_flag_get(USART1, USART_FLAG_TC) == RESET);这个等待循环在波特率低、数据长的时候会卡很久,所以有条件的话建议放到发送DMA完成中断里做状态标记。
4. 常见问题与排查技巧实录
4.1 空闲中断一直触发收不到正常数据
这个问题的原因绝大多数出在标志位清除顺序不对。GD32的IDLE标志清除必须是“先读STAT0寄存器,再读DATA寄存器”,如果只清中断挂起标志而不读数据寄存器,中断标志并不会真正消失。调试时可以打印中断标志寄存器的值,如果在没有数据的情况下它一直是置位状态,基本就是这个原因。
4.2 DMA接收数据错位、收一帧后会分段
DMA配置成循环模式,每收到一帧数据后DMA地址会自动从头开始。如果在处理数据期间没有关闭DMA通道,新来的数据就会写到缓冲区头部,把还没处理完的数据覆盖掉。解决方法是严格按前面的流程操作:进中断后第一件事就是关闭DMA通道,计算完长度、备份完数据后再重新打开。顺序不能反。
分段的现象还要注意是不是波特率不匹配。上位机和下位机波特率不一致时,收到的数据往往是错乱的,这种错乱不是简单的丢字节,而是夹杂着乱码。排除DMA代码之前,先用最简单的轮询收发发一个固定数据,确认链路本身没问题。
4.3 DMA发送后数据发不出去
先查发送DMA的通道是否被其他外设占用,再查DMA的传输方向配置。还有一个很容易踩的坑:DMA发送完成后要等TC标志,很多人把这个等待放在某次发送的数据缓冲区是栈上的局部变量场景下,结果DMA还没来得及搬完数据,栈已经被释放了。这个问题的表象很奇怪,有时候发得出去有时候发不出去,而且改变优化等级后表现还不一样。
稳定的做法是准备两块发送缓冲区,交替使用,每次发送后立刻切换模板,确保CPU往缓冲区里填数据的时候DMA不会同时在读这块区域。
4.4 用串口调试助手测试时的注意事项
串口助手这端的PC串口如果是USB转TTL模块,大概率用的CH340或者FTDI芯片,第一次使用需要装驱动。Win10以上的系统一般能自动识别CH340,但FTDI芯片如果是"山寨"版,驱动会有问题,这时候换一根正规的线最有性价比。
测试时还要确认串口助手的打开速度不会影响DMA接收。上位机打开串口时会自动拉低RTS/DTR,有些模块会因此复位,导致MCU端重新初始化,看起来就像DMA没配置成功。焊板子调试的时候,尽量把RTS和DTR信号断开,只用TXD/RXD/GND三根线。严格按照我的实操经验来调这几个问题,基本上一两个小时就能把串口DMA这块稳定跑起来。
4.5 缓冲区大小和数据速率怎么搭配
缓冲区大小的设计直接决定了高波特率下的稳定性。简单估算一下:波特率115200,一个字节10位,每秒就是11520字节,一毫秒约11.5字节。如果业务上要求100ms内处理完一帧数据,缓冲区至少要能装1152字节。实际项目我一般预留3倍余量,取整到2的幂次,这样后续扩展协议长度不用担心。
DMA的number寄存器是16位的,最大值65535,如果缓冲区超过这个值就得拆成多段或者用链表结构。GD32F405RGT6内部SRAM有192KB,串口缓冲区开4KB甚至8KB都不成问题。关键是其他业务也要用RAM,你得算清楚账。
5. 中断处理里的小技巧和性能优化
5.1 空闲中断里尽量少做事
中断服务函数讲究“快进快出”。我在项目里,空闲中断只做一件事:把接收到的数据拷贝到一个全局的帧缓冲区,然后置一个标志位。真正的协议解析放在主循环或者一个优先级较低的任务里做。这样做的原因很简单,如果协议解析占用的时间过长,比如整个Modbus CRC计算和响应帧组包,在中断里做的话,下一帧数据来了DMA照样往后写,如果缓冲区不够大,很容易把正在处理的数据冲到。
volatile uint8_t rx_frame_ready = 0; uint8_t frame_buffer[FRAME_BUFFER_SIZE]; uint16_t frame_len = 0; void process_rx_frame(uint8_t *buf, uint16_t len) { if (len > FRAME_BUFFER_SIZE) { len = FRAME_BUFFER_SIZE; } memcpy(frame_buffer, buf, len); frame_len = len; rx_frame_ready = 1; }5.2 直接操作寄存器也可以
GD32的库函数封装得还算友好,但追求极致性能时可以直接操作寄存器。比如获取DMA剩余字节数,库函数dma_transfer_number_get底层就是读DMA_CHCNT寄存器,这个读操作很快,但加了一层函数调用开销。在中断频繁的场合,直接把寄存器操作写在中断里也没问题。
uint16_t remain_cnt = DMA_CHCNT(DMA0, DMA_CH3);类似地,清除空闲标志也可以用寄存器操作:
usart_interrupt_flag_clear(USART1, USART_INT_FLAG_IDLE);这句库函数就会展开成USART_STAT0和USART_DATA的两次读取。保持可读性优先的考虑,我一般还是用库函数,毕竟代码是写给下一个人看的。
5.3 多个串口同时用DMA怎么调度
GD32F405RGT6一共有5个串口,如果全部跑DMA,一共要占用10个DMA通道,正好覆盖DMA0和DMA1的全部通道。这时候DMA带宽就变成瓶颈了。DMA0和DMA1是独立的控制器,理论上可以并行工作,但同一控制器内的多个通道有优先级仲裁。我建议把波特率高的串口配高优先级,比如调试串口和主通信串口用115200以上,优先级配HIGH;低速日志串口配LOW,避免高速数据抢占不到DMA总线。
5.4 实际项目中的一个技巧:空缓冲区回滚
做接收处理时,如果rx_len等于缓冲区大小,说明数据已经填满了整个缓冲区,这种情况下需要额外警惕,有可能是缓冲区太小导致数据没接收完就发生回绕。处理办法是检查串口的过载错误标志USART_FLAG_ORERR,如果是过载导致的溢出错误,要把这个标志清掉,并且考虑是否加大缓冲区或者降低单帧数据量。
if (usart_flag_get(USART1, USART_FLAG_ORERR) != RESET) { usart_flag_clear(USART1, USART_FLAG_ORERR); }这个错误标志不清理的话,后续串口接收会一直处于异常状态。
6. 调试工具与辅助手段
6.1 串口助手的正确选型
调试串口DMA离不开PC上的串口调试助手。SSCOM和老版XCOM是工程师用得最多的两个,都支持定时发送和hex显示。我用下来SSCOM最稳的一点是可以自定义发送间隔,方便模拟不定长帧。如果需要在Mac上调试,minicom和SerialTools都行,但注意macOS对CH340驱动支持一般,需要单独装驱动。
6.2 逻辑分析仪才是排查利器
遇到uart时序问题的时候,串口助手只能看到数据对不对,看不到波形细节。这时候一个几十块钱的8通道逻辑分析仪比你调三天代码都管用。把TX和RX两根线分别挂在两个通道上,采样率调到1MHz以上,抓一帧数据出来看起始位、停止位电平变化,马上就能判断是波特率偏差、信号干扰还是DMA配置错乱。
我在调RS485通信的时候还遇到过一种情况:发送完数据后没等发送移位寄存器完全结束就切换了方向,导致最后一个字节半截卡在总线上,挂逻辑分析仪才看清。这个经验也建议你们加到项目检查清单里。
6.3 打印调试信息的正确姿势
调试DMA接收的时候,直接在中断里加printf不太现实,串口助手看输出也会把收到的数据弄乱。我的做法是在串口DMA空闲中断里记录一个计数变量,主循环里定时把这个计数打出来,配合一个灯闪,能快速定位中断有没有正常触发。
volatile uint32_t idle_interrupt_count = 0; // 在中断里 idle_interrupt_count++; // 主循环里每1秒打印一次 printf("idle count: %lu\\r\\n", idle_interrupt_count);这个方法虽然土,但真的比打断点、仿真器单步调试高效得多,尤其是缓冲区数据量大的时候。
7. 实际测试记录与效果对比
这次调通以后我专门做了个简单测试:上位机以115200波特率同时向USART1和USART2发数据,每帧长度随机在5字节到200字节之间,连续跑30分钟,统计丢帧和错误字节率。最终测试结果非常干净,DMA方式下CPU占用率远低于传统中断方式,总帧数上万次,错误率几乎为零。
| 对比项 | 传统中断方式 | 串口DMA+空闲中断 |
|---|---|---|
| 115200下接收1000字节CPU占用 | 约62% | 约18% |
| 921600下是否有丢字节 | 偶发丢字节 | 稳定 |
| 不定长帧处理复杂度 | 需软件超时判断 | 硬件空闲中断自动完成 |
| 多串口并发扩展性 | 中断处理极易冲突 | DMA独立通道互不干扰 |
这个测试数据说明了一件事:DMA收发不是花架子,对于频繁收发且数据量大的场景,收益是实打实的。
8. 做串口DMA时我最想提醒的几件事
第一件事,GD32F405的库和STM32的库代码长得像,但寄存器和标志位名字不一样,比如空闲中断标志从IDLE到DLE的演进,不同版本库也可能有差异,用之前一定要看清楚你当前用的库版本对应的头文件定义。我以前把标准库的代码直接搬到GD32上,编译通过、运行不发数据,最后查了一个下午,发现DMA通道号映射压根对不上。
第二件事,一定要把“清除空闲标志的顺序”刻在脑子里:先读状态寄存器,再读数据寄存器。这个顺序错了,外设就一直报空闲错误,数据还收不进来,而且这种问题特别难排查,因为代码逻辑看起来是通的。
第三件事,接收缓冲区的大小不是随便拍的。结合你实际项目里的最大帧长度、CPU处理速度、波特率,按我前面4.5节的估算方法算一个值出来,再乘上1.5到3倍的余量。宁大勿小,SRAM够用的话就多分配点,不必心疼那几KB内存。缓冲区溢出导致的偶发丢帧,比代码逻辑bug难查得多。
最后再分享一个思路:这套“DMA接收+空闲中断”的方案不止适用于GD32F405,同系列的GD32F103、GD32F303,以及STM32的F1/F4系列,原理完全相同,差别只在外设库函数名称和DMA通道映射上。把这套逻辑理解透彻后,换平台换型号,基本一天以内就能把代码迁移过去。这大概就是串口DMA这套方案最值得投入时间的原因,学会一次,处处复用。
本文还有配套的精品资源,点击获取