简介:本资源是面向嵌入式初学者与GD32单片机开发者的串口通信入门实践例程,基于国产GD32F103C8T6芯片(兼容STM32F103系列),聚焦USART1基础收发功能实现——通过PA9(TX)与PA10(RX)与PC端调试软件交互,实现“接收即回传”的实时响应逻辑,适用于裸机驱动学习、外设验证及调试接口搭建等典型场景。压缩包共82个文件,含29个头文件(.h,定义寄存器、函数接口与配置宏)、27个源文件(.c,涵盖系统初始化、GPIO/USART/RCC驱动、中断服务及主循环逻辑)、4个汇编启动文件(.s)、2个可执行hex固件及Keil MDK工程配置文件(.uvprojx/.uvoptx),结构完整,符合标准CMSIS+GD32外设库(v3.0.0)开发范式。目前已有412人学习下载,配套readme.txt说明清晰,工程可直接编译烧录运行,无需额外配置,是掌握GD32串口底层驱动与中断处理流程的实用参考模板。
1. 项目概述:为什么从GD32F103的串口开始?
如果你刚拿到一块GD32F103C8T6的开发板,或者从STM32转过来想试试国产替代,第一个要跑的例程大概率就是串口收发。这几乎成了嵌入式开发的“Hello World”。但别小看这个简单的串口,它背后牵扯到时钟树配置、GPIO复用、中断管理、数据缓冲等一系列核心概念,搞通了它,单片机开发的大门才算真正推开。
GD32F103C8T6作为一款与STM32F103C8T6高度兼容的国产ARM Cortex-M3内核单片机,以其出色的性价比和供货稳定性,在消费电子、工业控制等领域应用广泛。它的串口(USART)功能强大,支持同步/异步通信、多处理器模式、硬件流控等。我们今天的任务,就是写一个最基础、最实用的异步全双工收发例程,让你不仅能收到数据,还能稳定地发回去,并且理解每一步配置的用意。
这个例程的目标很明确:实现PC通过USB转串口工具发送任意数据给GD32,GD32收到后原样回传,同时在调试助手界面上显示。我们会采用中断方式接收数据,查询方式发送,这是最常用也最易于理解的组合。过程中,我会把那些数据手册里一笔带过、但实际调试中坑最多的细节都掰开讲清楚。
2. 工程搭建与核心外设初始化解析
2.1 开发环境与工程创建要点
工欲善其事,必先利其器。对于GD32,主流的选择是Keil MDK-ARM(我后面简称Keil)和GCC+Makefile/VSCode。考虑到大多数初学者的使用习惯,这里以Keil为例。首先,你需要从兆易创新官网下载GD32F10x系列的Device Family Pack(DFP)支持包,安装后Keil的芯片列表里就会出现GD32的选项。
新建工程时,选择芯片型号“GD32F103C8T6”。这里有个关键点:务必勾选“Copy System Startup Code to Project Folder”。GD32的启动文件(startup_gd32f10x_hd.s)和STM32的虽然相似,但在时钟初始化部分有差异,使用GD32专用的启动文件能避免一些诡异的时钟问题。接着,需要把GD32标准外设库(Firmware Library)中的核心文件添加到工程。至少需要这几个分组:
CMSIS: 包含内核相关的头文件和启动文件。Firmware/StdPeriph_Driver/src: 外设驱动源文件,我们至少需要gd32f10x_gpio.c,gd32f10x_rcu.c,gd32f10x_usart.c。Firmware/StdPeriph_Driver/inc: 对应的头文件目录。User: 存放我们的main.c,gd32f10x_it.c(中断服务函数文件)等。
在魔术棒(Options for Target)设置里,“C/C++”选项卡的“Include Paths”一定要把上述inc目录和CMSIS目录添加进去。Define一栏通常需要填写GD32F10X_HD,因为C8T6属于高密度产品线。这些步骤看似繁琐,但一次配好,后续开发就顺畅了。
2.2 时钟系统配置:一切稳定运行的基础
GD32F103的时钟树比51单片机复杂得多,也是性能优化的关键。系统时钟(SYSCLK)最高可达108MHz,来源可以是内部8MHz RC振荡器(IRC8M)、外部晶振(HXTAL)或者PLL倍频。为了获得精确的串口波特率,强烈推荐使用外部晶振。
我们的板子上通常有一颗8MHz的晶振。初始化时钟的第一步,是在system_gd32f10x.c文件(或直接在main函数开头)里调用rcu_clock_config函数进行配置。一个典型的108MHz配置流程如下:
- 使能外部高速晶振(HXTAL):
rcu_osc_on(RCU_HXTAL); - 等待晶振稳定:
while(SUCCESS != rcu_osc_stab_wait(RCU_HXTAL)); - 配置AHB、APB2、APB1分频器。APB1总线时钟最高36MHz,串口1挂在APB2上(最高108MHz),串口2/3挂在APB1上。
- 配置PLL:选择HXTAL作为PLL时钟源,进行倍频。例如,8MHz * 27 = 216MHz,然后经过2分频得到108MHz的PLL输出。
- 选择PLL作为系统时钟源。
为什么这么麻烦?因为串口波特率发生器USART_BRR寄存器的值,是根据给定的波特率和当前串口所在的APB总线时钟频率计算出来的。如果系统时钟源不稳定或者频率不准,计算出的分频系数就会有误差,导致通信乱码。使用外部晶振+PLL,能获得稳定且精确的时钟源,这是通信可靠性的第一道保障。
注意:GD32F103的PLL配置范围与STM32F103略有不同,务必参考GD32的数据手册和库函数说明,直接套用STM32的代码可能会导致时钟配置错误,系统无法运行。
2.3 GPIO复用配置:让引脚变身串口功能
GD32F103C8T6的串口1(USART0)默认复用引脚是PA9(TX)和PA10(RX)。我们需要把这两个普通的GPIO引脚配置成复用推挽输出(TX)和浮空输入(RX)模式。
void gpio_config(void) { // 开启GPIOA时钟 rcu_periph_clock_enable(RCU_GPIOA); // 配置PA9为复用推挽输出 (USART0 TX) gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); // 配置PA10为浮空输入 (USART0 RX) gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); }这里有几个细节:
- 为什么要使能GPIO时钟?这是ARM Cortex-M系列芯片的特点,所有外设时钟默认是关闭的以节能,使用前必须手动打开。
- GPIO速度:设置为50MHz。对于串口这种低速外设,其实10MHz也够用,但设为50MHz也无妨。对于TX引脚,输出速度会影响信号边沿的陡峭程度,在高速或长线传输时有一定影响。
- 为什么RX是浮空输入?因为RX引脚是接收端,由外部设备驱动。浮空输入模式下,引脚电平完全由外部信号决定,内部既不拉高也不拉低。如果板子上没有外部上拉电阻,在空闲状态下RX引脚可能处于不稳定状态,此时可以考虑使用上拉输入模式(
GPIO_MODE_IPU),通过内部电阻拉到高电平,避免干扰。
3. 串口驱动深度配置与中断管理
3.1 串口参数化配置与波特率计算内幕
配置完引脚,接下来就是核心的串口初始化。我们需要配置波特率、数据位、停止位、校验位和硬件流控等。这里我们使用最常见的8位数据位、1位停止位、无校验位。
void usart_config(void) { // 开启USART0时钟 rcu_periph_clock_enable(RCU_USART0); // 初始化USART参数 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_control_set(USART0, USART_RTS_DISABLE, USART_CTS_DISABLE); usart_receive_config(USART0, USART_RECEIVE_ENABLE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); // 使能USART usart_enable(USART0); }重点看usart_baudrate_set(USART0, 115200U)。这个库函数内部帮我们计算并设置了USART_BRR寄存器。这个寄存器的值决定了波特率分频系数。计算公式是:BRR = (f_APBx) / baudrate其中f_APBx是串口所在APB总线的时钟频率。对于USART0,它在APB2上,假设我们系统时钟是108MHz,APB2不分频,那么f_APB2 = 108MHz。计算108000000 / 115200 = 937.5。
BRR寄存器是一个16位的寄存器,高4位用于小数部分,低12位用于整数部分。所以937.5的整数部分937放在低12位,小数部分0.5需要转换:0.5 * 16 = 8,放在高4位。最终BRR = (8<<12) | 937 = 0x8 3A9。库函数usart_baudrate_set就是帮你完成了这个计算和赋值。理解这个过程很重要,当通信波特率出现偏差时,你可以手动计算并核对一下这个寄存器的值是否正确。
3.2 中断配置与优先级管理实战
我们希望单片机在收到数据时能立即响应,而不是不停地去查询状态寄存器,这就需要使用中断。串口接收中断是必须开启的。
void nvic_config(void) { // 配置USART0全局中断 nvic_irq_enable(USART0_IRQn, 0, 0); // 抢占优先级0,子优先级0 } // 在usart_config函数中,使能中断后添加: usart_interrupt_enable(USART0, USART_INT_RBNE); // 使能接收缓冲区非空中断USART_INT_RBNE中断是当接收数据寄存器(RDR)非空,即收到一个字节的数据时触发。这是最常用的接收中断方式。为什么不使用“接收完成”中断?因为“接收完成”中断在某些情况下可能不如“非空”中断及时。
配置中断时,还有一个关键步骤是编写中断服务函数(ISR)。这个函数必须放在gd32f10x_it.c文件中,并且函数名要严格按照向量表定义来写,对于USART0,就是USART0_IRQHandler。
// 在gd32f10x_it.c中 uint8_t usart_rx_buffer[256]; // 接收缓冲区 uint16_t usart_rx_index = 0; // 缓冲区写入索引 void USART0_IRQHandler(void) { if(RESET != usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE)) { // 读取接收到的数据,该操作会自动清除RBNE中断标志位 usart_rx_buffer[usart_rx_index++] = usart_data_receive(USART0); // 简单的缓冲区溢出保护 if(usart_rx_index >= 256) { usart_rx_index = 0; } // 可以在这里设置一个标志位,通知主循环处理数据 // usart_rx_flag = 1; } // 理论上还应处理其他中断标志,如发送完成、错误中断等,但此例程简化处理 }在中断服务函数里,有黄金法则:快进快出。不要在里面做复杂的运算或调用可能阻塞的函数(如printf)。我们的做法通常是:读取数据 -> 存入缓冲区 -> 更新索引或设置标志位 -> 清除中断标志 -> 立即退出。具体的数据处理(如协议解析、回传)放到主循环中根据标志位去执行。
实操心得:中断优先级
nvic_irq_enable的参数(抢占优先级和子优先级)在简单系统中可以都设为0。但在复杂系统中,如果串口通信实时性要求高,需要给它分配一个较高的抢占优先级(数字越小优先级越高),防止被其他低优先级中断长时间阻塞,导致数据丢失。
4. 主循环逻辑与数据收发实现
4.1 主函数框架与数据回传机制
外设和中断都配置好后,主函数的任务就清晰了:初始化,然后在一个死循环里检查是否有数据需要处理并回传。
#include "gd32f10x.h" #include <stdio.h> // 可选,用于printf重定向 // 声明外部缓冲区(实际在it.c中定义) extern uint8_t usart_rx_buffer[256]; extern uint16_t usart_rx_index; volatile uint8_t usart_rx_flag = 0; // 接收完成标志,在中断中置位 int main(void) { // 系统时钟、GPIO、串口、中断初始化 system_clock_config(); // 假设此函数配置了108MHz时钟 gpio_config(); usart_config(); nvic_config(); // 可选:printf重定向到串口,方便调试 // usart_printf_redirect(USART0); while(1) { // 方式1:轮询标志位(推荐) if(usart_rx_flag) { usart_rx_flag = 0; // 处理接收缓冲区中的数据 for(uint16_t i = 0; i < usart_rx_index; i++) { // 回传数据 usart_data_transmit(USART0, usart_rx_buffer[i]); // 等待上一个字节发送完成 while(RESET == usart_flag_get(USART0, USART_FLAG_TBE)); } usart_rx_index = 0; // 处理完清空索引 } // 方式2:也可以直接轮询发送(简单但低效) // if(usart_flag_get(USART0, USART_FLAG_RBNE) != RESET) { // uint8_t data = usart_data_receive(USART0); // usart_data_transmit(USART0, data); // while(RESET == usart_flag_get(USART0, USART_FLAG_TBE)); // } // 其他后台任务... } }在主循环中,我们采用“中断接收+主循环查询发送”的模式。中断负责快速“抢下”数据存好,主循环检测到usart_rx_flag标志后,再将缓冲区里的数据一个一个取出来,通过usart_data_transmit函数发送出去。每发送一个字节,都要通过while循环等待USART_FLAG_TBE(发送缓冲区空)标志置位,确保上一个字节已从移位寄存器移出,才能发送下一个,否则数据会覆盖丢失。
4.2 发送的阻塞与非阻塞策略选择
上面的代码使用的是阻塞式发送,即while循环等待发送完成。这在单任务或者发送数据量不大的简单应用中没问题,但会占用CPU时间。更高效的做法是采用非阻塞式发送,利用发送完成中断(TC)或DMA。
发送完成中断(TC)方式:
- 使能发送完成中断:
usart_interrupt_enable(USART0, USART_INT_TC); - 在中断服务函数中,检查TC标志,如果置位,表示一个字节或一帧数据已完全发出,此时可以发送下一个字节或清除忙标志。
- 主循环中,只需将数据放入发送缓冲区并启动发送,无需等待。
DMA方式: 对于大量连续数据的发送(如发送一帧图像数据),DMA是最佳选择。它可以自动将内存中的数据搬运到串口数据寄存器(TDR),完全解放CPU。
- 配置DMA通道,源地址是内存中的数组,目标地址是
USART0_TDATA寄存器。 - 设置传输数据量。
- 使能USART的DMA发送请求。
- 启动DMA传输,传输完成后会产生DMA中断。
对于初学者例程,阻塞式发送最简单直观。但在实际项目中,尤其是需要处理多个任务时,非阻塞式或DMA方式是必须掌握的技能。
5. 调试技巧与典型问题排查实录
5.1 硬件连接与软件调试工具使用
首先确保硬件连接正确:
- GD32 TX(PA9) --->USB转串口模块 RX
- GD32 RX(PA10) --->USB转串口模块 TX
- 两地共地:GD32的GND ---> USB转串口模块的GND
踩过的坑:TX接TX,RX接RX是新手最常犯的错误,结果就是两边都收不到数据。记住“交叉互联”原则。另外,如果使用CH340、CP2102这类USB转串口模块,电脑端需要安装对应的驱动程序,设备管理器中出现正确的COM口后才算连接成功。
软件端,推荐使用功能丰富的串口调试助手,如SSCOM、XCOM、AccessPort等。关键设置:
- 选择正确的COM口。
- 波特率设置为115200(与代码一致)。
- 数据位8,停止位1,校验位无,流控制无。
- 勾选“十六进制显示”可以方便查看原始字节数据。
5.2 常见问题速查与解决方案
即使代码看起来没问题,第一次调通串口也常常会遇到各种问题。下面这个表格是我总结的常见故障现象、可能原因和排查步骤:
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 完全无收发 | 1. 电源未接通或电压不对。 2. 晶振未起振,系统没运行。 3. 串口线接错(TX/RX反接)。 4. USB转串口模块损坏或驱动未安装。 5. 代码中串口或GPIO时钟未使能。 | 1. 测电压,确认单片机供电正常(3.3V)。 2. 用示波器测晶振引脚(OSC_IN/OUT),看是否有波形。最简单的办法:写一个让LED闪烁的程序,先确认单片机最小系统是否工作。 3. 交换TX和RX线序试试。 4. 换一个模块,或检查设备管理器。 5. 检查代码,确认 rcu_periph_clock_enable已调用。 |
| 能发不能收,或能收不能发 | 1. 单向的线接错或虚焊。 2. 代码中只使能了发送或接收。 3. 中断配置错误(仅影响接收)。 4. 对方设备流控设置不对。 | 1. 重点检查单向不通的那根线。 2. 检查 usart_receive_config和usart_transmit_config。3. 检查NVIC和USART中断是否使能,中断服务函数名是否正确。 4. 确保调试助手和代码都关闭了硬件流控(RTS/CTS)。 |
| 收发乱码 | 1.波特率不匹配(最常见)。 2. 时钟源配置错误,系统时钟频率不对。 3. 外部晶振频率与代码中PLL配置不符。 4. 电源噪声大,信号质量差。 | 1. 核对代码和调试助手的波特率是否精确一致。尝试降低波特率(如9600)测试。 2. 检查 system_clock_config函数,确认最终系统时钟频率。用示波器测量一个GPIO翻转的频率来反推系统时钟。3. 确认板载晶振是8MHz还是12MHz?修改PLL倍频系数。 4. 在TX/RX线上串联一个22-100欧姆的电阻,或在信号线对地加10-50pF电容滤除高频噪声。 |
| 只能接收一次或几次 | 1. 接收中断标志未清除。 2. 接收缓冲区溢出后未正确处理。 3. 中断服务函数执行时间过长,导致丢失后续中断。 | 1. 在中断中,读取usart_data_receive会自动清除RBNE标志,确保你用了这个函数。2. 检查缓冲区大小和索引管理逻辑,防止写穿。 3. 优化中断服务函数,只做最必要的操作(存数据、设标志)。 |
| 发送数据丢失最后几个字节 | 1. 在发送循环后立即复位或进入低功耗模式。 2. 未等待最后一字节发送完成就关闭了串口或单片机。 | 1. 发送完所有数据后,增加一个等待TC(发送完成)标志的循环。 2. 确保主循环持续运行,不要在发送后立即执行 __WFI()等休眠指令。 |
5.3 进阶调试:逻辑分析仪与printf重定向
当问题比较复杂时,串口调试助手的信息可能不够。这时可以请出硬件调试利器——逻辑分析仪(或者带逻辑分析仪功能的示波器)。用它同时抓取TX和RX引脚上的波形,可以直观地看到:
- 波形是否符合UART协议(起始位低电平、8位数据、停止位高电平)。
- 波特率是否准确(测量一个位的时间宽度,倒数即为实际波特率)。
- 数据内容是否正确。
另一个极其有用的调试方法是printf重定向。将标准C库的printf函数输出重定向到串口,就可以像在PC上编程一样,随时打印变量值、程序状态等信息。
// 在main.c中实现fputc函数,重定向到USART0 #include <stdio.h> 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("System Clock: %d Hz\r\n", SystemCoreClock);来输出了。注意,使用printf会显著增加代码体积,并且不是线程安全的,在中断中调用要特别小心,通常不建议这么做。
6. 从例程到项目:稳定性与扩展性优化
一个能跑通的例程只是起点,要用于实际项目,还需要考虑更多。
首先,数据缓冲区的管理需要加强。我们例程中用的简单线性缓冲区有溢出风险。更稳健的做法是使用环形缓冲区(FIFO)。创建两个索引:读索引和写索引。当写索引追上读索引时,表示缓冲区满;当读索引追上写索引时,表示缓冲区空。中断服务函数只负责向环形缓冲区写,主循环从中读。这样可以高效、安全地处理数据流。
其次,要考虑通信协议。裸串口数据流是没有边界的,你发送“123”,对方可能一次收到“123”,也可能分三次收到“1”、“2”、“3”。因此,需要定义简单的应用层协议,例如:
- 帧头+长度+数据+校验:例如
0xAA 0x55 [长度L] [数据...] [校验和]。接收方只有检测到正确的帧头和长度,并且校验和通过,才认为收到一帧有效数据。 - 特定结束符:例如以
\r\n作为一帧的结束。这在AT指令等场景中很常见。
最后,错误处理机制必不可少。使能串口的错误中断(如噪声错误、帧错误、溢出错误),在中断服务函数中检测并清除这些错误标志,记录错误日志,必要时进行复位或重连操作。一个健壮的通信模块,必须能应对线路干扰等异常情况。
通过这个GD32F103C8T6的串口收发例程,我们不仅实现了功能,更深入理解了时钟、GPIO、中断、缓冲区、协议这些嵌入式通信的基石。下次当你需要调试传感器、连接蓝牙模块、与上位机通信时,这套流程和排查思路都能直接套用。记住,调试硬件就是和物理世界对话,耐心和逻辑比代码本身更重要。
本文还有配套的精品资源,点击获取