news 2026/9/2 9:41:39

GD32F103C8T6串口通信实战:从轮询到中断与DMA的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GD32F103C8T6串口通信实战:从轮询到中断与DMA的完整指南

简介:本资源是一份面向嵌入式初学者与GD32开发者的串口通信入门实践例程,基于国产GD32F103C8T6 Cortex-M3内核单片机,聚焦USART1外设在PA9(TX)与PA10(RX)引脚上的基础收发功能实现,解决调试阶段数据交互验证这一关键问题。压缩包共82个文件,涵盖29个头文件(.h)、27个源码文件(.c)、5个编译中间文件(._2i)、4个启动汇编文件(.s)及工程配置类文件(.uvprojx、.uvoptx、.hex等),总大小仅147KB,结构完整且轻量,便于快速导入Keil MDK环境运行。已有412人学习下载,资源包含标准外设库ST3.0.0适配代码、清晰的usart1.c/h模块封装、main函数主流程、中断服务与轮询两种接收逻辑参考,以及配套hex固件与bat一键编译脚本,特别适合用于裸机驱动学习、GD32替代STM32的迁移验证及串口调试工具联调实践。

1. 项目概述:从零开始玩转GD32F103C8T6的串口通信

如果你手头正好有一块GD32F103C8T6的开发板,或者正准备用它来做点小项目,那么串口通信几乎是你绕不开的第一个坎。这玩意儿就像是单片机的“嘴巴”和“耳朵”,无论是打印调试信息、和电脑上位机对话,还是连接蓝牙、GPS、Wi-Fi模块,都离不开它。我刚开始接触GD32的时候,也以为串口就是配置几个寄存器,发送接收就完事了,结果在实际项目里踩了不少坑,比如数据收不全、发送乱码、中断进不去等等。今天,我就以一个过来人的身份,把GD32F103C8T6的串口收发,从最基础的轮询到实用的中断和DMA,掰开揉碎了讲清楚。我会基于最常用的Keil MDK环境和官方标准外设库,带你写出一份稳定、可靠、可直接抄作业的例程。无论你是刚入门的新手,还是想从STM32无缝切换到GD32的老鸟,这篇内容都能让你少走弯路。

2. 硬件与工程环境准备

在动手写代码之前,得先把“战场”布置好。GD32F103C8T6和STM32F103C8T6是引脚兼容的,但内核和库有些差异,不能直接照搬STM32的代码,这点要特别注意。

2.1 核心硬件连接与原理

GD32F103C8T6通常有两个USART(通用同步异步收发器),我们最常用的是USART0,它的默认引脚是PA9(TX)和PA10(RX)。如果你的板子没有直接引出,需要检查原理图确认。

与电脑通信的连接方案:

  1. USB转TTL串口模块(最常用):这是个人学习和调试的首选。你需要一个像CH340、CP2102这类芯片的USB转TTL模块。
    • 连接方式:模块的TX引脚接GD32的PA10(RX),模块的RX引脚接GD32的PA9(TX)。这里是最容易接反的地方,一定要记住:TX接RX,RX接TX。模块和单片机的GND一定要共地,否则通信不稳定。模块的VCC通常接3.3V,为单片机供电或提供电平参考。
  2. 板载调试器/串口(如ST-Link的VCP):很多集成ST-Link的GD32开发板,其调试器本身就带了一个虚拟串口(Virtual COM Port)。你只需要用一根USB线连接板子的“Debug USB”口到电脑,在设备管理器中会多出一个COM口。这时,GD32的PA9/PA10通常已经和调试器的串口引脚连接好了,无需额外接线,非常方便。

注意:无论哪种方式,务必确保通信双方的电平一致。GD32是3.3V电平,如果你的USB转TTL模块是5V电平的(老款PL2303有些是),直接连接可能会损坏GD32的IO口。务必使用支持3.3V电平的模块,或者在TX线上串联一个1kΩ电阻进行限流(不推荐新手操作,直接买3.3V模块更省心)。

2.2 软件开发环境搭建

  1. 安装Keil MDK:从Arm官网或国内镜像下载并安装Keil MDK-ARM(例如V5.36版本)。安装后需要激活,社区版有32K代码限制,但对于学习例程完全够用。
  2. 安装GD32设备支持包:这是让Keil认识GD32芯片的关键。前往兆易创新(GigaDevice)官网,找到GD32F10x系列的资料,下载“GigaDevice.GD32F10x_DFP.x.x.x.pack”文件。双击安装,Keil中就会自动添加GD32F103C8T6的芯片型号和启动文件。
  3. 获取标准外设库:同样在官网下载GD32F10x Firmware Library。这个库包含了所有外设的驱动函数和例程,是我们编程的基础。我建议在工程中直接使用库文件,而不是用CubeMX生成,这样对理解底层更有帮助。
  4. 创建工程
    • 打开Keil,Project -> New uVision Project,选择存放路径,命名为GD32_UART_Demo
    • 在Select Device窗口,搜索并选择GD32F103C8(注意是C8,不是C8T6,系列选对即可)。
    • 在弹出的Manage Run-Time Environment窗口,可以直接关掉,我们手动添加文件更清晰。
    • 在工程目录下,新建UserGD32F10x_Firmware_Library(将官方库的Firmware文件夹内容拷贝进来)、Doc等文件夹。
    • 在Keil的Project窗口,右键Target 1,选择Manage Project Items。新建Groups:UserGD32F10x_StdPeriphCMSISStartup
    • 向对应Group添加文件:
      • Startup: 添加GD32F10x_Firmware_Library\CMSIS\GD\GD32F10x\Source\ARM下的启动文件startup_gd32f10x_hd.s(对于C8T6,属于高密度产品,用hd)。
      • CMSIS: 添加GD32F10x_Firmware_Library\CMSIS\GD\GD32F10x\Source下的system_gd32f10x.c
      • GD32F10x_StdPeriph: 添加GD32F10x_Firmware_Library\GD32F10x_standard_peripheral\Source下所有.c文件,或者至少添加gd32f10x_gpio.c,gd32f10x_rcu.c,gd32f10x_usart.c,gd32f10x_misc.c(中断相关)。
      • User: 暂时空着,我们接下来创建main.c
    • 配置工程选项(魔术棒按钮):
      • Target: 勾选Use MicroLIB,这是一个针对嵌入式优化过的小型C库,可以节省空间,串口printf重定向需要它。
      • C/C++: 在Define框中输入GD32F10X_HD(高密度定义)。在Include Paths中添加所有头文件路径:../GD32F10x_Firmware_Library/CMSIS/GD/GD32F10x/Include,../GD32F10x_Firmware_Library/GD32F10x_standard_peripheral/Include,../User
      • Debug: 选择你的调试器(如ST-Link),在Settings中确认SWD接口和速度。
      • Utilities: 勾选Use Debug Driver,并选择对应调试器,在SettingsFlash Download选项卡中,添加GD32F10x的Flash编程算法。

环境搭好了,就像厨师备好了灶台和刀具,接下来我们开始处理“食材”——编写串口驱动代码。

3. 串口基础驱动与轮询收发实现

轮询方式是最简单、最直观的串口使用方法,适合在初始化、发送简单状态或对实时性要求不高的场景。它的核心思想就是:“我准备好了,你好了吗?我发给你了,你收到了吗?”不断地查询状态寄存器。

3.1 GPIO与USART初始化配置

首先,我们需要配置用于串口的GPIO引脚和USART外设本身。在main.c中开始编写。

#include "gd32f10x.h" #include <stdio.h> // 为后续printf重定向准备 // 定义使用的串口和引脚 #define USART0_TX_PIN GPIO_PIN_9 #define USART0_RX_PIN GPIO_PIN_10 #define USART0_GPIO_PORT GPIOA #define USART0 USART0 void usart0_gpio_config(void) { /* 第一步:开启GPIOA和USART0的时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_USART0); /* 第二步:配置PA9为复用推挽输出(TX) */ gpio_init(USART0_GPIO_PORT, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, USART0_TX_PIN); /* 第三步:配置PA10为浮空输入(RX) */ gpio_init(USART0_GPIO_PORT, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, USART0_RX_PIN); } void usart0_config(void) { /* 串口参数配置结构体 */ usart_parameter_struct usart_init_struct; /* 第四步:填充初始化参数 */ usart_struct_para_init(&usart_init_struct); // 先初始化为默认值,这是个好习惯 usart_init_struct.baud_rate = 115200; // 波特率,常用115200 usart_init_struct.word_length = USART_WL_8BIT; // 8位数据位 usart_init_struct.stop_bits = USART_STB_1BIT; // 1位停止位 usart_init_struct.parity = USART_PM_NONE; // 无奇偶校验 usart_init_struct.hardware_flow_control= USART_RTS_DISABLE; // 禁用硬件流控 usart_init_struct.mode = USART_MODE_RX | USART_MODE_TX; // 收发模式 /* 第五步:初始化USART0 */ usart_init(USART0, &usart_init_struct); /* 第六步:使能USART0 */ usart_enable(USART0); }

关键点解析:

  • 时钟使能:GD32的任何外设使用前必须先开启其时钟。RCU(复位和时钟单元)模块管理所有时钟。
  • GPIO模式:TX引脚需要输出串行数据,因此配置为GPIO_MODE_AF_PP(复用推挽输出)。RX引脚是输入,配置为GPIO_MODE_IN_FLOATING(浮空输入)即可,外部模块会驱动它。
  • 波特率计算115200这个值是如何来的?它依赖于APB1APB2总线的时钟(PCLK)。对于USART0,挂在APB2上。默认情况下,系统时钟是108MHz,APB2也是108MHz。库函数usart_init内部会根据你传入的波特率值和当前PCLK,自动计算并设置波特率寄存器USART_BAUD。如果你发现通信波特率不对,首先检查系统时钟配置是否正确。

3.2 轮询方式发送与接收函数

初始化完成后,就可以进行数据收发了。我们先实现最基本的轮询函数。

// 轮询发送一个字节 void usart0_send_byte(uint8_t data) { usart_data_transmit(USART0, data); // 将数据写入TDR寄存器 while(RESET == usart_flag_get(USART0, USART_FLAG_TBE)); // 等待发送缓冲区空 // 注意:这里等待的是TBE(Transmit Buffer Empty),表示数据已从TDR转移到移位寄存器,可以写入下一个数据。 // 如果要等待整个字节发送完成,应等待TC(Transmission Complete)标志。 } // 轮询发送字符串 void usart0_send_string(char *str) { while(*str != '\0') { usart0_send_byte(*str++); } } // 轮询接收一个字节(阻塞式) uint8_t usart0_receive_byte(void) { while(RESET == usart_flag_get(USART0, USART_FLAG_RBNE)); // 等待接收到数据 return (uint8_t)usart_data_receive(USART0); // 读取RDR寄存器并返回 }

3.3 重定向printf函数便于调试

直接调用usart0_send_string虽然可以,但远不如printf格式化输出方便。我们可以重写C库的fputc函数,将printf的输出重定向到串口。

/* 重定向C库的printf函数到USART0 */ int fputc(int ch, FILE *f) { usart_data_transmit(USART0, (uint8_t)ch); while(RESET == usart_flag_get(USART0, USART_FLAG_TBE)); return ch; }

实操心得:重定向printf后,在代码中就可以直接使用printf("Value: %d\n", value);了,极大方便调试。但要注意,printf函数本身比较耗时,在中断服务函数中尽量避免使用,以免导致中断执行时间过长。

3.4 主函数与测试

现在,我们将所有部分组合起来,完成一个简单的轮询测试程序。

int main(void) { /* 系统时钟初始化,默认是108MHz */ system_clock_config(); // 如果官方例程里有这个函数,可以调用。或者自己配置。 /* 初始化串口GPIO和参数 */ usart0_gpio_config(); usart0_config(); /* 发送启动信息 */ usart0_send_string("\r\n***** GD32F103C8T6 UART Polling Demo Start *****\r\n"); printf("System Clock: %ld Hz\r\n", SystemCoreClock); // 打印系统时钟频率 uint8_t received_char = 0; while(1) { printf("\r\nPlease input a character (Press Enter in serial tool): "); received_char = usart0_receive_byte(); // 阻塞等待接收一个字符 printf("\r\nYou sent: %c (ASCII: 0x%02X)\r\n", received_char, received_char); // 简单的回显功能 printf("Echo: "); usart0_send_byte(received_char); printf("\r\n"); } }

编译、下载与测试:

  1. 在Keil中编译工程(F7),确保0错误,0警告。
  2. 连接好硬件(USB转TTL模块或板载调试器),给开发板上电。
  3. 在Keil中点击下载按钮(LOAD),将程序烧录到GD32中。
  4. 打开电脑上的串口调试助手(如SSCOM、XCOM、Putty等)。
    • 选择正确的COM口(在设备管理器中查看)。
    • 波特率设置为115200,数据位8,停止位1,无校验,无流控。
    • 打开串口。
  5. 如果看到开发板发来的启动信息,说明发送成功。在串口调试助手的发送框输入一个字符(如A),点击发送,应该能看到开发板回显你发送的字符及其ASCII码。

轮询方式的优缺点:

  • 优点:代码简单,逻辑清晰,易于理解。
  • 缺点usart0_receive_byte函数是阻塞的。程序会一直卡在while循环里等待数据,CPU无法执行其他任务,效率极低。发送虽然也等待,但时间极短。因此,轮询接收在实际项目中很少使用,除非系统非常简单。接下来,我们就要引入更高效的中断方式。

4. 中断接收方式实现与环形缓冲区应用

中断方式是处理异步事件(如串口收到数据)的黄金标准。当串口收到一个字节后,硬件会自动触发一个中断,CPU暂停当前工作,跳转到中断服务函数(ISR)中处理这个字节,处理完再返回。这样CPU在等待数据时就可以去处理其他任务,效率大大提升。

4.1 中断接收的初始化与配置

中断接收的核心是配置NVIC(嵌套向量中断控制器)和USART的中断使能。

#define RING_BUFFER_SIZE 128 // 定义环形缓冲区大小 // 定义一个简单的环形缓冲区结构 typedef struct { uint8_t buffer[RING_BUFFER_SIZE]; volatile uint16_t head; // 写指针(生产者) volatile uint16_t tail; // 读指针(消费者) } ring_buffer_t; ring_buffer_t usart0_rx_buffer = {0}; // 声明一个全局的接收缓冲区 void usart0_interrupt_config(void) { /* 在usart0_config()函数使能USART后,添加以下代码 */ /* 1. 使能USART0的接收缓冲区非空中断 */ usart_interrupt_enable(USART0, USART_INT_RBNE); // RBNE: Receive Buffer Not Empty /* 2. 配置NVIC */ nvic_irq_enable(USART0_IRQn, 0, 0); // 使能USART0全局中断,优先级组0,子优先级0 // USART0_IRQn是中断号,在gd32f10x.h中定义 // 优先级参数(0,0)暂时用默认值,在多中断系统中需要仔细规划 } // 环形缓冲区辅助函数 static uint16_t ring_buffer_free(ring_buffer_t *rb) { if(rb->head >= rb->tail) { return (RING_BUFFER_SIZE - (rb->head - rb->tail) - 1); } else { return (rb->tail - rb->head - 1); } } static void ring_buffer_put(ring_buffer_t *rb, uint8_t data) { uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE; // 简单的满缓冲区检查,如果满了就丢弃最旧的数据(也可以选择丢弃新数据) if(next_head != rb->tail) { rb->buffer[rb->head] = data; rb->head = next_head; } // else { /* 缓冲区满,处理策略 */ } } static int ring_buffer_get(ring_buffer_t *rb, uint8_t *data) { if(rb->head == rb->tail) { return -1; // 缓冲区空 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE; return 0; }

4.2 编写中断服务函数(ISR)

中断服务函数的名字是固定的,在启动文件startup_gd32f10x_hd.s中已经声明。我们需要在main.c中实现它。

/* USART0全局中断服务函数 */ void USART0_IRQHandler(void) { /* 1. 判断是否是RBNE中断(接收中断) */ if(RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE)) { /* 2. 读取接收到的数据(这个操作会自动清除RBNE标志位) */ uint8_t received_data = (uint8_t)usart_data_receive(USART0); /* 3. 将数据存入环形缓冲区 */ ring_buffer_put(&usart0_rx_buffer, received_data); // 你可以在这里添加简单的协议头判断,例如判断是否是帧头'$' // if(received_data == '$') { frame_start_flag = 1; } } /* 可以在这里添加其他中断源的判断,如发送完成中断、错误中断等 */ // if(RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_TBE)) { ... } // if(RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_ERR)) { ... } }

注意事项:中断服务函数要尽可能短小精悍。它的核心任务就是“快进快出”:读取数据、存入缓冲区、清除标志。绝对不要在ISR里做复杂计算、调用printf或进行长时间延时。所有数据处理逻辑都应该放在主循环中,基于缓冲区里的数据来进行。

4.3 主循环中的数据处理

配置好中断后,主函数就不再需要阻塞等待了。它只需要定期检查环形缓冲区里是否有数据,然后取出来处理。

int main(void) { // ... 之前的时钟、GPIO、USART、中断初始化 ... usart0_gpio_config(); usart0_config(); usart0_interrupt_config(); // 新增中断配置 printf("\r\n***** GD32F103C8T6 UART Interrupt Demo Start *****\r\n"); uint8_t rx_data = 0; uint16_t data_count = 0; while(1) { /* 非阻塞地从环形缓冲区读取数据 */ if(ring_buffer_get(&usart0_rx_buffer, &rx_data) == 0) { data_count++; printf("Received[%d]: %c (0x%02X)\r\n", data_count, rx_data, rx_data); // 示例:简单协议处理,遇到换行符('\n')则认为一帧结束 if(rx_data == '\n') { printf("-> A frame ended.\r\n"); // 这里可以调用协议解析函数 // parse_frame(); } } // 主循环可以安心做其他事情,例如闪烁LED、读取传感器等 // delay_ms(100); // gpio_bit_toggle(LED_GPIO_PORT, LED_PIN); } }

中断方式的优点:

  • 高效:CPU利用率高,在等待数据时能执行其他任务。
  • 实时性好:数据一到就能立刻响应,响应延迟确定(取决于中断优先级)。
  • 结构化清晰:中断收数据,主循环处理数据,程序结构良好。

环形缓冲区的作用:中断可能在任何时候发生,如果ISR直接处理数据,可能会打断主循环中重要的时序逻辑。环形缓冲区充当了**生产者(ISR)消费者(主循环)**之间的解耦队列。ISR只管快速写入,主循环定期读取处理。即使数据短时间内爆发式到来(如一长串数据),只要缓冲区足够大,就不会丢失数据。

5. DMA传输方式实现高效数据搬运

当需要传输大量数据(如文件、图像、音频帧)时,即使使用中断,每个字节都进一次中断,开销也显得太大。这时,DMA(直接存储器访问)就是终极武器。DMA可以在不占用CPU资源的情况下,在外设(如USART的接收数据寄存器RDR)和内存(如我们定义的数组)之间自动搬运数据。CPU只需要在DMA传输完成时被通知一下即可。

5.1 DMA接收的初始化配置

我们以USART0的DMA接收为例。GD32的USART_RX对应DMA0的通道5(具体映射需查数据手册)。

#define DMA_RX_BUFFER_SIZE 256 uint8_t dma_rx_buffer[DMA_RX_BUFFER_SIZE] = {0}; // DMA目标缓冲区 volatile uint8_t dma_rx_complete_flag = 0; // DMA接收完成标志 void usart0_dma_rx_config(void) { dma_parameter_struct dma_init_struct; /* 1. 使能DMA0时钟 */ rcu_periph_clock_enable(RCU_DMA0); /* 2. 初始化DMA通道5(对应USART0_RX) */ dma_deinit(DMA0, DMA_CH5); // 复位通道5 dma_struct_para_init(&dma_init_struct); dma_init_struct.periph_addr = (uint32_t)&USART_DATA(USART0); // 外设地址:USART数据寄存器 dma_init_struct.memory_addr = (uint32_t)dma_rx_buffer; // 内存地址:我们的缓冲区 dma_init_struct.direction = DMA_PERIPH_TO_MEMORY; // 传输方向:外设到内存 dma_init_struct.number = DMA_RX_BUFFER_SIZE; // 传输数据项数量 dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; // 外设地址不递增 dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; // 内存地址递增 dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BITS; // 外设数据宽度8位 dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BITS; // 内存数据宽度8位 dma_init_struct.priority = DMA_PRIORITY_HIGH; // 通道优先级 dma_init(DMA0, DMA_CH5, &dma_init_struct); /* 3. 配置DMA循环模式(可选,对于持续流数据很好用) */ dma_circulation_enable(DMA0, DMA_CH5); // 开启循环模式,缓冲区满了自动从头开始,不会停止 /* 4. 使能DMA通道5 */ dma_channel_enable(DMA0, DMA_CH5); /* 5. 配置USART0,使其RX请求连接到DMA */ usart_dma_receive_config(USART0, USART_DENR_ENABLE); // 使能接收DMA }

5.2 DMA传输完成中断与数据处理

我们通常希望知道DMA什么时候传完了一定数量的数据,或者缓冲区是否满了。这可以通过DMA传输完成中断(TCI)来实现。

void dma_interrupt_config(void) { /* 使能DMA通道5的传输完成中断 */ dma_interrupt_enable(DMA0, DMA_CH5, DMA_INT_FTF); // FTF: Full Transfer Finished /* 配置DMA中断的NVIC */ nvic_irq_enable(DMA0_Channel5_IRQn, 1, 0); // 使能DMA0通道5中断 } /* DMA0通道5中断服务函数 */ void DMA0_Channel5_IRQHandler(void) { /* 判断是否是传输完成中断 */ if(RESET != dma_interrupt_flag_get(DMA0, DMA_CH5, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH5, DMA_INT_FLAG_FTF); // 清除中断标志 /* 设置完成标志,通知主循环 */ dma_rx_complete_flag = 1; /* 在循环模式下,FTF中断会在每次传输完`number`个数据后触发。 此时,dma_rx_buffer里已经存满了最新的一批数据。 我们可以在这里计算当前数据的写入位置。 */ } }

5.3 主循环中处理DMA数据

主循环中,我们检查dma_rx_complete_flag,当它被置位时,说明DMA缓冲区已经填满了一次(或者在非循环模式下传输完成)。我们需要处理这些数据,并可能重新启动DMA传输。

int main(void) { // ... 初始化 ... usart0_dma_rx_config(); dma_interrupt_config(); printf("\r\n***** GD32F103C8T6 UART DMA Rx Demo Start *****\r\n"); printf("DMA Buffer Address: 0x%08X, Size: %d\r\n", (uint32_t)dma_rx_buffer, DMA_RX_BUFFER_SIZE); uint16_t last_cndtr = 0; // 用于记录DMA通道剩余数据计数 uint16_t data_len = 0; while(1) { if(dma_rx_complete_flag) { dma_rx_complete_flag = 0; printf("[DMA TC] Buffer full! Processing data...\r\n"); // 处理整个缓冲区数据 for(int i = 0; i < DMA_RX_BUFFER_SIZE; i++) { if(dma_rx_buffer[i] != 0) // 简单打印非零数据 { printf("%c", dma_rx_buffer[i]); } } printf("\r\n--- End of Buffer ---\r\n"); // 如果是非循环模式,需要在这里重新设置内存地址和数据数量,并重新使能通道 // dma_memory_address_config(DMA0, DMA_CH5, (uint32_t)dma_rx_buffer); // dma_transfer_number_config(DMA0, DMA_CH5, DMA_RX_BUFFER_SIZE); // dma_channel_enable(DMA0, DMA_CH5); } // 另一种更灵活的方式:定期检查DMA的剩余传输计数(CNDTR),计算已接收的数据长度 // 这在循环模式下非常有用,可以实现“不定长”数据的实时处理。 uint16_t current_cndtr = dma_transfer_number_get(DMA0, DMA_CH5); if(current_cndtr != last_cndtr) { // 已接收数据长度 = 总长度 - 剩余长度 data_len = DMA_RX_BUFFER_SIZE - current_cndtr; if(data_len > 0) { // 计算缓冲区中“新数据”的起始索引(在循环缓冲区中需要小心计算) // 一个简单的策略是只处理从上次位置到当前写入位置的数据 // 这里省略了复杂的环形缓冲区索引计算,建议结合之前的中断环形缓冲区思想 // 将DMA缓冲区当作一个大的环形缓冲区来管理。 // printf("Data received via DMA: %d bytes\r\n", data_len); } last_cndtr = current_cndtr; } // 主循环其他任务... } }

DMA方式的精髓:

  • 零CPU开销:数据搬运过程完全由DMA硬件完成,CPU被彻底解放。
  • 适合大数据量:无论是高速连续数据流,还是不定长的数据包,DMA都能高效处理。
  • 编程模型稍复杂:需要管理缓冲区、处理中断、计算数据位置。对于简单的串口指令交互,可能“杀鸡用牛刀”。但对于GPS模块持续输出、无线模块数据透传、与FPGA高速通信等场景,DMA是必不可少的。

6. 常见问题排查与实战技巧

理论讲完了,最后分享一些我踩过的坑和总结的技巧,这些在官方手册里不一定找得到。

6.1 通信乱码或根本不通

这是最常见的问题,按以下顺序排查:

  1. 检查硬件连接:TX-RX交叉连接了吗?GND共地了吗?USB转TTL模块的VCC是3.3V吗?模块的驱动安装好了吗?设备管理器里COM口出现了吗?
  2. 检查波特率:这是软件层面首疑点。确保单片机程序设置的波特率和串口调试助手的波特率完全一致。115200和9600差很多,但115200和128000可能只是偶尔能收到几个正确字符,大部分是乱码。
  3. 检查时钟源:GD32的USART波特率依赖于PCLK。如果你修改了系统时钟(比如没用默认的108MHz,而是用外部晶振8MHz倍频到72MHz),那么波特率计算的基础就变了。确保system_clock_config()正确,并且SystemCoreClock这个全局变量的值是正确的。可以在程序开始时用printf打印出来核对。
  4. 检查串口配置:数据位(8位)、停止位(1位)、校验位(无)是否和上位机匹配?硬件流控是否被误开启?
  5. 检查代码逻辑:发送函数是否真的在发送?可以在usart_data_transmit后加一个LED翻转语句,每发一个字节LED闪一下,用肉眼观察是否在发送。

6.2 中断服务函数进不去

  1. 中断使能了吗?双重检查usart_interrupt_enablenvic_irq_enable是否被调用,参数是否正确。
  2. 中断函数名对吗?必须与启动文件中定义的向量表名称完全一致。USART0_IRQHandler,一个字母都不能错。最好的方法是直接从库文件gd32f10x_it.c中复制函数名。
  3. 全局中断开启了吗?main函数一开始,调用了system_clock_config()之后,应该调用nvic_vector_table_set(NVIC_VECTTAB_FLASH, 0x0);nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2);(根据你的优先级分组设置),最后还要__enable_irq();(Cortex-M内核指令)开启全局中断。标准库的system_clock_config例程里通常已经做了。
  4. 中断标志清除了吗?在ISR中,读取数据usart_data_receive(USART0)通常会清除RBNE标志。但如果是其他中断(如溢出错误ORE),需要手动清除usart_interrupt_flag_clear(USART0, USART_INT_FLAG_ERR)

6.3 DMA数据不更新或处理不对

  1. DMA通道映射对吗?USART0_RX对应DMA0_CH5,USART0_TX对应DMA0_CH4。USART1/2/...的映射不同,务必查数据手册。
  2. 外设和内存地址对吗?periph_addr必须是&USART_DATA(USART0),这是一个宏,指向数据寄存器。memory_addr必须是你的数组首地址(uint32_t)dma_rx_buffer
  3. 传输数量(number)设置了吗?必须配置dma_init_struct.number
  4. DMA通道使能了吗?dma_channel_enable必须在所有配置完成后最后调用。
  5. 外设的DMA请求使能了吗?usart_dma_receive_config(USART0, USART_DENR_ENABLE)这步不能少。
  6. 缓冲区管理问题:在循环模式下,DMA会周而复始地写缓冲区。你的主程序读取速度必须快于DMA写入速度,否则新数据会覆盖未处理的旧数据。这就需要用到“双缓冲区”或“环形缓冲区+读写索引”的算法。通常的做法是:在DMA传输完成中断(FTF)中,并不直接处理数据,而是切换到一个“备用缓冲区”让DMA写入,然后主循环处理刚刚被填满的“当前缓冲区”。

6.4 printf重定向后无法打印浮点数

这是一个经典的坑。Microlib对浮点数的支持是有限的。如果你用printf(“float value: %f\n”, 3.14);,可能会卡死或者什么都不打印。

解决方案:

  1. 避免在嵌入式环境频繁使用浮点printf:这是最好的建议。将浮点数乘以一个系数转换成整数再打印。例如,温度25.6°C,可以转换成整数256打印,上位机再除以10。
  2. 使用完整的标准C库:在Keil的Target选项中去掉Use MicroLIB的勾选,并添加--u_printf_float链接器参数(在Linker->Misc controls框中输入)。但这会显著增加代码体积。
  3. 使用自定义的轻量级格式化函数:自己写一个只支持%d%x%s的简单printf,或者用sprintf格式化成字符串,再用自己的串口发送函数发送字符串。

6.5 提高通信可靠性的小技巧

  • 添加软件超时机制:即使在中断或DMA模式下,对于“等待一个特定响应”的场景,也要加超时。用一个定时器,在开始等待时启动,在收到数据或超时时停止。防止程序因意外丢包而永远卡住。
  • 协议设计:单纯的字节流很难区分帧的边界。设计一个简单的应用层协议,例如:帧头(如0xAA, 0x55)+ 长度 + 数据 + 校验和(如CRC8或累加和)。接收方只有收到完整且校验正确的帧才进行处理,能极大提高抗干扰能力。
  • 硬件滤波:如果环境干扰大,可以在USART的RX线上对地加一个20-50pF的小电容,滤除部分毛刺。
  • 隔离保护:如果串口连接外部设备,考虑使用光耦或磁耦隔离芯片(如ADM2483、MAX3485),保护单片机免受外部电压冲击。

从轮询到中断再到DMA,串口通信的三种方式各有其适用场景。对于简单的调试输出,轮询发送足矣;对于交互式命令,中断接收是标准选择;而对于高速、连续的数据流,DMA则是性能保障。希望这份结合了原理、代码和实战经验的总结,能帮你把GD32F103C8T6的串口用得得心应手。在实际项目中,往往需要根据需求灵活组合这些技术,例如用DMA接收+空闲中断来高效处理不定长数据包,这才是嵌入式编程的乐趣所在。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 9:41:26

2026通州靠谱搬家公司推荐:各区域搬家特点+收费标准+搬家攻略

在北京通州搬家&#xff0c;很多人都会遇到各式各样的难题&#xff1a;老小区无电梯搬大件家具费力又磕家、商务区写字楼搬家物业管控严格、宋庄贵重艺术品搬运易受损、产业片区厂房仓库转运流程繁琐。通州下辖多个街道和片区&#xff0c;不同区域的小区环境、物业规定、路况条…

作者头像 李华
网站建设 2026/9/2 9:40:26

基于本地大语言模型的离线PII擦除实战:PrivateRedact项目全解析

在数据安全和隐私保护日益重要的今天&#xff0c;处理包含个人身份信息&#xff08;PII&#xff09;的文档是一项高风险且繁琐的任务。无论是开发日志分析、客户支持记录整理&#xff0c;还是内部审计报告生成&#xff0c;我们常常需要在分享或分析数据前&#xff0c;手动或借助…

作者头像 李华
网站建设 2026/9/2 9:40:14

FreeCAD 插件安装避坑:3 个高频故障的排查路径

FreeCAD 插件安装避坑:3 个高频故障的排查路径 【免费下载链接】FreeCAD Official source code of FreeCAD, a free and opensource multiplatform 3D parametric modeler. 项目地址: https://gitcode.com/GitHub_Trending/fr/FreeCAD FreeCAD 插件是扩展这款参数化 3D …

作者头像 李华
网站建设 2026/9/2 9:39:37

基于Fo-Dicom实现MPPS与MWL服务可视化:DICOM工作流协议调试实战

简介&#xff1a;本资源是一个基于Fo-Dicom开源库开发的C#可视化DICOM服务程序&#xff0c;面向医学影像系统开发者、PACS工程师及放射科IT支持人员&#xff0c;用于直观实现与调试MPPS&#xff08;设备执行步骤&#xff09;和MWL&#xff08;模态工作列表&#xff09;两类核心…

作者头像 李华
网站建设 2026/9/2 9:38:32

Pikachu靶场全级别通关教程详解

pikach通关 暴力破解 Cross-Site Scripting XSS&#xff08;跨站脚本&#xff09;概述 XSS是一种发生在Web前端的漏洞&#xff0c;所以其危害的对象主要是前端用户。XSS漏洞可以用来进行钓鱼攻击&#xff0c;前端js挖矿&#xff0c;获取用户cookie&#xff0c;甚至可以结合…

作者头像 李华