简介:面向GD32F303固件库开发者与嵌入式入门人群,这套资料围绕USART通过DMA收发这一典型场景,解决串口中断频繁占用CPU的问题,提升数据收发效率,适用于需要低功耗、高吞吐通信的MCU项目。压缩包内含1251个文件,压缩后仅4.18MB,其中以466个h头文件和431个c源文件为主体,覆盖外设驱动、系统配置与用户应用;同时提供uvproj/ewp等工程文件、hex/bin可执行文件及文档说明,便于直接编译烧录与二次开发。已有3329人学习浏览,具备一定热度。资料不仅包含完整的固件库工程源码和USART配置示例,还附带LCD、LED等外设相关文件,可帮助读者快速理解串口DMA收发机制,并结合工程模板迁移到自己的项目。整体目录结构清晰,适合结合CSDN教程与B站视频边学边练。
1. 用DMA接管串口,到底解决了什么问题
1.1 没有DMA时,串口收发有多“费人”
做过嵌入式串口通信的朋友应该都有体会,如果设备需要频繁和上位机、传感器、Wi-Fi模块之类的打交道,只用最原始的“中断收发”方式,写起来并不难,难的是不知道它什么时候会出问题。
用传统方式收数据时,程序的逻辑通常是:串口收到一个字节,进一次中断,把数据拷贝到缓冲区,然后继续等下一个字节。这套逻辑在数据量小、波特率低的时候完全够用,但一旦波特率上了115200甚至更高,数据包又是几十上百个字节连续进来,你会发现CPU大部分时间都被打断去处理串口中断了。更麻烦的是,如果主循环里恰好在做耗时操作(比如Flash擦写、浮点运算),中断响应稍微慢半拍,后面的字节就可能被覆盖,导致整个数据包错位、丢数据。
这时候就要轮到DMA(Direct Memory Access,直接存储器访问)出场了。简单说,DMA就是一个专门的“搬运工”,它能在不需要CPU介入的情况下,把外设数据寄存器里的数据直接搬到内存缓冲区,或者把内存里的数据直接搬到外设发送寄存器里。CPU只需要在“搬运工”干完活之后收到一个完成通知就行。
1.2 为什么这个方案在GD32上值得单独写一篇
GD32F303是国产Cortex-M4内核的MCU,主频最高能到120MHz,硬件配置上对标的是STM32F103系列。固件库的开发方式和STM32的标准外设库很像,很多函数名、结构体定义甚至可以直接平移过去。但如果你真的把STM32的代码直接搬过来,十有八九会在DMA这块碰到问题。
原因很简单:GD32的DMA控制器虽然整体结构类似,但通道映射、对应关系甚至某些寄存器的位定义都和ST不完全一致。举个例子,某些ST型号上USART1_TX对应的DMA通道,在GD32F303上可能就不是同一个数字。这种细节不亲自查手册或者跑一遍,根本不知道坑在哪里。
所以我这篇的重点就放在:基于GD32F303固件库,把USART通过DMA收发这件事完整跑通,讲清楚每一步为什么要这么配,以及在实测中会遇到的典型问题。适合正在从STM32往GD32迁移的工程师,也适合刚接触GD32固件库、想做串口高效通信的开发者参考。
2. 硬件环境与基础配置
2.1 我用到的硬件平台
我测试用的板子是GD32F303VET6核心板,主控芯片内置512KB Flash和64KB SRAM,资源对串口DMA这种应用来说绰绰有余。调试工具用的是DAP-Link,IDE选择的是Keil MDK(5.37版本),固件库用的是GigaDevice官方提供的GD32F30x_Firmware_Library。
如果你手里是GD32F303的其他型号,比如CBT6、RCT6,代码逻辑基本不用改,只需要根据芯片型号调整启动文件、宏定义这些基础配置即可。固件库版本建议优先用官方最新的,因为老版本里确实有一些DMA相关的bug修过。
在开始之前,先把需要的文件准备好。工程目录下的关键文件包括:
gd32f30x_usart.c/gd32f30x_usart.h(串口驱动)gd32f30x_dma.c/gd32f30x_dma.h(DMA驱动)gd32f30x_gpio.c(引脚复用配置)gd32f30x_rcu.c(时钟使能)
这些文件在官方固件库包里都是现成的,不用自己写,但要确保添加进工程了。
2.2 引脚、时钟和中断的基础准备
我这次用的是USART1,对应引脚是PA9(TX)和PA10(RX)。为什么选USART1?因为它的DMA接收通道和发送通道刚好分布在两个DMA控制器上,方便我分开说清楚映射关系,实际项目里也很多用USART1做调试串口的。
引脚复用要打开AFIO时钟,并且把PA9、PA10配置为复用推挽输出和浮空输入:
rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); 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);注意,GD32的GPIO初始化和ST标准库有个小区别:gpio_init函数的参数里把模式、速度、引脚号分开传,不像ST那样用一个结构体打包。我见过不少从ST转过来的同事在这卡了一下,倒不是多难,就是容易顺手写错。
USART1和DMA0、DMA1的时钟都要打开:
rcu_periph_clock_enable(RCU_USART1); rcu_periph_clock_enable(RCU_DMA0); rcu_periph_clock_enable(RCU_DMA1);关于中断配置,发送方向如果用DMA的话,主循环通常不需要进发送中断;接收方向建议开启USART的空闲中断(IDLE)或者直接轮询DMA接收完成标志。后面在讲接收方案时我会细说这两种方式的取舍。
3. 串口参数与DMA通道映射
3.1 USART1参数配置
串口参数可以先用标准库函数一次性配置到位。我习惯把波特率设为115200、数据位8位、无校验、1位停止位:
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);这里大部分配置都是常规操作,我要特别提醒一个看起来不起眼但影响很大的点:usart_receive_config和usart_transmit_config一定要记得配置,否则收发数据会被硬件屏蔽掉。有些例程里写得太精简,可能只调用了usart_enable,如果遇到“串口完全没反应”的问题,先检查这两行有没有配置。
3.2 GD32F303的DMA通道映射关系
GD32F303有两个DMA控制器,DMA0和DMA1,每个控制器下有多个通道(这个系列具体是DMA0有6个通道、DMA1有6个通道)。我的实测数据表明:USART1_TX映射到DMA1的通道4,USART1_RX映射到DMA0的通道3。这一点要和具体型号的手册里面的DMA request table对照确认,不同子系列之间确实存在差异。
下面是GD32F303系列中USART相关DMA映射的参考表:
| 外设请求 | DMA控制器 | 通道 |
|---|---|---|
| USART0_TX | DMA0 | 通道4 |
| USART0_RX | DMA0 | 通道3 |
| USART1_TX | DMA1 | 通道4 |
| USART1_RX | DMA0 | 通道3 |
| USART2_TX | DMA0 | 通道2 |
| USART2_RX | DMA0 | 通道3 |
把映射关系搞清楚,是整个DMA配置里最关键的一步。我一开始就是用STM32F103的经验,默认USART1_TX在DMA1的通道4、USART1_RX在DMA1的通道5,结果初始化后RX死活不动。后来查了GD32F303的数据手册才发现,接收通道居然跑到了DMA0上面。所以强烈建议,哪怕你已经在ST平台上有很熟的经验,用到GD32时也务必打开官方手册的DMA request table核对一遍。
4. DMA通道初始化配置解析
4.1 接收通道DMA0_Channel3配置
DMA初始化在GD32固件库里是通过dma_init配合dma_parameter_struct结构体来实现的。参数看起来不少,但拆开看就几件事:方向、地址增量、数据宽度、缓冲区大小、优先级。
先看接收方向:
dma_parameter_struct dma_rx_para; dma_deinit(DMA0, DMA_CH3); dma_struct_para_init(&dma_rx_para); dma_rx_para.direction = DMA_PERIPHERAL_TO_MEMORY; dma_rx_para.peripheral_addr = (uint32_t)(&USART1_DATA); dma_rx_para.memory_addr = (uint32_t)rx_buffer; dma_rx_para.peripheral_inc = DMA_PERIPH_INCREASE_DISABLE; dma_rx_para.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_rx_para.peripheral_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_para.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_rx_para.number = RX_BUFFER_SIZE; dma_rx_para.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, &dma_rx_para); dma_circulation_enable(DMA0, DMA_CH3); dma_channel_enable(DMA0, DMA_CH3);逐个解释一下这几个关键参数:
direction:方向设为从外设到内存,即DMA把USART1的接收数据寄存器内容搬到我们指定的缓冲区。peripheral_addr:外设地址要填USART1的数据寄存器地址。注意GD32固件库里这个寄存器不叫USART1->DR那种写法,而是提供了一个宏USART1_DATA,用起来更直观。peripheral_inc:外设地址不自增,因为每次都是从同一个数据寄存器取数据。memory_inc:内存地址自增,这样连续收到的字节会依次放到缓冲区的不同位置。peripheral_width和memory_width:都是8位,因为串口一个字节就是8位。number:一次DMA传输的总字节数,也就是要收多少个字节后产生完成事件。dma_circulation_enable:开启循环模式。这是一个非常重要的配置,开启后DMA接收完一轮数据会自动把缓冲区指针拉回起点重新开始接收,不需要CPU重新配置,特别适合连续不断的串口数据流。
4.2 发送通道DMA1_Channel4配置
发送方向的配置逻辑正好反着来,方向是内存到外设,其他大部分参数相同:
dma_parameter_struct dma_tx_para; dma_deinit(DMA1, DMA_CH4); dma_struct_para_init(&dma_tx_para); dma_tx_para.direction = DMA_MEMORY_TO_PERIPHERAL; dma_tx_para.peripheral_addr = (uint32_t)(&USART1_DATA); dma_tx_para.memory_addr = (uint32_t)tx_buffer; dma_tx_para.peripheral_inc = DMA_PERIPH_INCREASE_DISABLE; dma_tx_para.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_tx_para.peripheral_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_para.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_tx_para.number = 0; dma_tx_para.priority = DMA_PRIORITY_HIGH; dma_init(DMA1, DMA_CH4, &dma_tx_para);这里注意一个细节:发送通道的number我先设置为0,因为在没有数据要发的时候,不需要DMA搬运任何内容。实际要发送数据时,再调用一次dma_transfer_number_config设置本次要发送的字节数,然后dma_channel_enable启动传输。
如果想让发送也能在完成后自动拉回起点,可以同样调用dma_circulation_enable(DMA1, DMA_CH4)。但发送方向一般不建议开循环,因为发送是“一次性动作”,每次要发的内容长度都不一样,循环模式反而容易把上一次的数据重复发出去。
5. 代码实现:收发逻辑与主循环
5.1 发送一个数据包的正确姿势
DMA发送的最小执行单元是“发起一次搬运任务”。实际使用中,最常见的是这个场景:主程序里准备好了要发的一包数据,然后调用发送函数,让DMA把它们全部发出去,发完通知一声。
一个基础版本的发送函数可以这样写:
uint8_t tx_buffer[256]; void usart1_dma_send(uint8_t *data, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { tx_buffer[i] = data[i]; } dma_channel_disable(DMA1, DMA_CH4); dma_transfer_number_config(DMA1, DMA_CH4, len); dma_memory_address_config(DMA1, DMA_CH4, DMA_MEMORY_INCREASE_ENABLE, (uint32_t)tx_buffer); dma_channel_enable(DMA1, DMA_CH4); }这段代码的意思是:先把要发送的数据拷到tx_buffer(这个缓冲区要保证在DMA传输期间不会被其他地方改写),然后关闭DMA通道——重新配置传输字节数——重新设置内存地址——最后使能通道,启动搬运。
提示:每次重新配置DMA通道前,一定要先执行
dma_channel_disable。这不是多此一举,而是手册里明确要求的一个操作。如果你在通道仍处于使能状态时去改number等参数,行为是不可预知的,实测中经常出现发送长度错误或者直接卡死的情况。
如果你想在DMA发送完成时得到通知,可以打开发送完成中断:
dma_interrupt_enable(DMA1, DMA_CH4, DMA_INT_FTF); nvic_irq_enable(DMA1_Channel4_IRQn, 0, 0);在中断服务函数里可以设置一个标志变量,主循环检测到这个标志后就知道上一包数据已经发出去了。不过要提醒一句:如果发送的速度很快、数据量很大,中断频率会非常高,建议先想清楚你在完成中断里要做的事是不是真的那么紧急,否则直接用标志位轮询反而更省心。
5.2 接收方向的两种典型方案
接收方向是串口DMA方案里比较讲究的部分。我把它拆成两种常见场景来讲。
场景一:定长数据包
如果你的通信协议是固定长度的,比如每包数据固定16字节,那用DMA就非常舒服。接收通道初始化时把number设为16,DMA每收满16个字节就会置上传输完成标志。主循环或者中断里只要检测这个标志,就知道一包完整的数据已经到位,直接把rx_buffer里的内容拿去解析就行。
这种方式的优点是完全不占CPU,接收过程中CPU该干嘛干嘛,数据满了才通知一次。缺点也很明显:协议必须是定长的,一旦出现粘包、半包,处理起来反而麻烦。
场景二:不定长数据包(空闲中断)
如果数据包长度不固定,纯DMA定长接收就不够用了。常用的做法是DMA + USART空闲中断(IDLE Line Interrupt)。
所谓空闲中断,就是检测到串口总线上一个字节都没有传输的时间超过了一个字节周期长度,硬件会认为这一帧数据已经结束,触发IDLE中断。在这个中断里,我们可以计算本次一共收到了多少字节:
// 假设这是USART1的中断处理函数 void USART1_IRQHandler(void) { if (usart_interrupt_flag_get(USART1, USART_INT_FLAG_IDLE) != RESET) { usart_interrupt_flag_clear(USART1, USART_INT_FLAG_IDLE); rx_len = RX_BUFFER_SIZE - dma_transfer_number_get(DMA0, DMA_CH3); rx_complete_flag = 1; } }这个代码的核心是利用了循环模式下DMA计数器的值来判断本次接收长度:dma_transfer_number_get返回的是“还剩多少字节没搬完”,用缓冲区总大小减去这个剩余值,就是已经收到的字节数。
注意:这里有个常见的坑,就是
usart_interrupt_flag_clear是否真正能清掉IDLE标志。GD32F303固件库里清IDLE标志的时序和相关寄存器位和ST略有区别。如果出现“第一次触发之后第二次就进不来中断”的现象,多半是标志没清干净或者清标志的顺序不对。建议实际操作时用示波器或逻辑分析仪观察一下RX引脚波形,确认硬件确实产生了完整的一帧停止位。
5.3 主循环的完整处理框架
把发送和接收串起来,主循环的框架大概是这样的:
uint8_t rx_buffer[128] = {0}; volatile uint8_t rx_complete_flag = 0; volatile uint16_t rx_len = 0; int main(void) { // 系统时钟、GPIO、USART、DMA初始化代码省略 // ... while (1) { if (rx_complete_flag) { rx_complete_flag = 0; // 把收到的数据原样回发,验证通路是否正常 usart1_dma_send(rx_buffer, rx_len); // 实际项目中这里可以做协议解析、帧校验、业务处理等 } // 其他任务可以继续在这里跑 } }这个回环测试是调通DMA收发最快的方式:电脑发一串数据给GD32,GD32收到后原样发回,电脑上能看到完全一致的内容,就证明收发链路已经打通。
6. 实测中遇到的典型问题与排查方法
6.1 高频率踩坑点的速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 串口完全无输出 | 发送DMA通道未使能或映射错误 | 检查DMA通道编号与USART1映射表是否一致 |
| 接收缓冲区全是什么都没变 | 接收DMA未开启循环模式,或number参数配置为0 | 单步调试查看DMA寄存器中的CHxCNT值 |
| 收上来的数据整体错位 | 内存地址增量未开启,后到的数据覆盖了前面的 | 检查memory_inc是否配置为DMA_MEMORY_INCREASE_ENABLE |
| 数据丢后半段 | DMA搬运还没完成,缓冲区就被上一层逻辑读取走了 | 判断DMA通道完成标志后再读取数据 |
| 发送完一次后第二次发不出去 | 重复配置时未先dma_channel_disable | 在dma_transfer_number_config之前先关闭通道 |
| 空闲中断只触发一次 | IDLE标志清除时序不对 | 参考官方勘误手册,按规定顺序读取状态寄存器和数据寄存器 |
这些基本都是DMA开发里最高频的几个坑。我当年调试第一个GD32串口DMA工程时,至少在上面四个问题里来回打转,尤其是“第二次发不出去”这个,困扰了我整整一个下午,最后就是因为在使能状态下直接改了number导致通道状态异常。
6.2 两个容易被忽略的关键细节
第一个细节是缓冲区生命周期管理。DMA传输是异步的,CPU把“发送任务”交给DMA之后,DMA还在慢慢搬运,如果此时主循环里把tx_buffer的内容给覆盖了,那发出去的数据就会变成乱七八糟的值。同理,接收方向,DMA还在往rx_buffer里写数据,如果主循环提前把数据取走去解析,可能会读到半包。所以收发缓冲区一定要做好“生产者-消费者”的同步管理,最简单的方式就是使用完成标志配合memcpy把数据及时拷走。
第二个细节是DMA请求的优先级和仲裁。GD32的DMA0和DMA1每个通道都有优先级设置,如果一个DMA控制器上挂了多个外设请求(比如USART0和USART1都在用DMA0),优先级设置不当可能导致某个外设长时间得不到DMA服务,数据被丢弃。建议把实时性要求高的通道优先级设高一些,比如本项目中的接收通道设为最高。
6.3 一个实用的验证方法
在调通基本功能后,我强烈建议做一个压力测试:让电脑以115200波特率持续发送100KB到500KB的随机数据,GD32收到后用DMA回发同样长度的数据,电脑端对比收发是否完全一致。只有在这个测试中保持零错误,才能认为这套DMA链路在真实环境中是可靠的。
如果错误率高,不要急着改DMA配置,先用逻辑分析仪看波形。常见的原因是引脚接触不良、地线干扰、波特率误差偏大这些物理层问题。DMA寄存器配置反而不太可能是主因,因为它本身的配置就是“黑盒”的,逻辑对就全对,逻辑错就全错,很少出现概率性错误。
7. 个人经验小结
这个项目做到后面,我个人最大的体会是:DMA说白了就是一个“外设数据快递员”,你只要把源地址、目的地址、搬运数量、搬运方向这四个核心参数配置清楚,它就按照约定把数据来回搬好。真正花时间的部分,不是把一次收发调通,而是把收发过程中各种边界情况想明白,比如缓冲区什么时候可以写、什么时候不能写、DMA什么时候算完成、标志位什么时候该清。
最后再分享一个小技巧:GD32的DMA通道编号和USART的搭配关系,建议直接做成一个表格贴在代码注释里,或者写成一个宏定义。哪怕是同一个系列的不同型号,这个映射关系也可能不一样,光靠记忆真的不可靠。把映射表写清楚,下次换型号或者换串口的时候,排查起来能省一大半时间。
本文还有配套的精品资源,点击获取