简介:这是一份面向STM32初学者的GPS开发例程,基于STM32F103ZET6与STM32CubeMX配置工具,演示从时钟、GPIO、UART到中断的完整初始化流程,并配有NMEA协议解析与应用层实现。压缩包共188个文件,约8.58MB,包含ioc工程配置、c/h源码、uvprojx工程文件、hex烧录文件及map/lst等编译输出,覆盖从CubeMX生成代码到Keil MDK编译调试的完整链路。已有220人学习下载。这一例程覆盖CubeMX图形化配置、HAL驱动调用、NMEA数据解析等关键环节,读者可据此掌握UART中断收发、GPS数据读取与经纬度时间信息提取方法;配套的Hardware、MDK-ARM等目录结构,也便于对照硬件连接与生成代码理解工程组织。适合正在学习STM32裸机开发、想要快速上手CUBE工具与GPS模块联调的人群。
1. 一个把 STM32 跑成导航仪的项目:CubeMX 下的 GPS 串口解析实战
很多人调 GPS 模块时都遇到过这种场景:串口助手刷出来一堆$GPRMC开头的数据,看着像有信号,但程序里死活提不出经纬度。这个基于 STM32F103ZET6 的例程,把从串口收 NMEA 报文、逐字段解析、度分转换到 printf 打印坐标的完整链路都打通了,配合 STM32CubeMX 生成工程,HAL 库代码量很小,适合想快速验证定位功能、又不想啃协议栈的开发者。项目本身不复杂,核心就两件事:把 GPS 的 TTL 串口接进 F103 的 USART,再写一个可靠的解析器把$GPRMC里的坐标和时间抠出来。往下拆,你会发现难点反而不是串口,而是 NMEA 格式的边界处理和模块的“脾气”。
2. 硬件规划与 CubeMX 工程搭建:引脚分配和时钟树配置
2.1 硬件连接:USART1 接 GPS,USART2 接调试口
STM32F103ZET6 这颗芯片资源对 GPS 解析来说绰绰有余:Cortex-M3 内核、512KB Flash、64KB RAM,跑 72MHz 主频,一个 9600 波特率的串口数据流只占它不到千分之一的处理能力。板子上最常用的串口是 USART1,挂在 APB2 总线上,PA9 是 TX、PA10 是 RX,我把 GPS 接在这组引脚上;调试输出用 USART2,PA2 和 PA3,通过板载调试器或 USB-TTL 转接线连到电脑。
| GPS 模块引脚 | STM32F103ZET6 引脚 | 说明 |
|---|---|---|
| VCC | 3.3V(部分模块可接 5V) | 注意模块标称电压 |
| GND | GND | 必须共地 |
| TX | PA10(USART1_RX) | GPS 发,单片机收 |
| RX | PA9(USART1_TX) | 单片机发,配置指令用 |
| PPS | 不接(可选) | 秒脉冲,测时用 |
这里有个血泪经验:很多 GPS 模块默认波特率是 9600,也有部分是 38400 或 115200。接线前先把模块单独用 USB-TTL 接到电脑上,用串口助手看一眼它到底输出什么速率、什么语句,再决定 CubeMX 里怎么配。我见过有人在 115200 下收到一堆$GPRMC乱码,折腾一下午最后发现只是波特率没对上。
2.2 CubeMX 配置步骤:时钟树、USART1、中断
打开 STM32CubeMX 新建工程,芯片选 STM32F103ZET6,按以下顺序配置:
RCC 里把 HSE 设为外部晶振,时钟树里把主频拉到 72MHz(HSE 8MHz 时 PLL 倍频 9x)。F103 最高只有 72M,别往上超频。然后把 USART1 模式选为异步收发,波特率填 9600,数据位 8、无校验、停止位 1——这是绝大多数 GPS 模块的默认帧格式。
USART2 同样设成异步模式,波特率用 115200,专门做调试打印。两个串口都要在 NVIC 设置里勾选全局中断,因为后面接收数据要用HAL_UART_Receive_IT。
| 参数 | USART1(GPS) | USART2(调试) |
|---|---|---|
| Baud Rate | 9600 | 115200 |
| Word Length | 8 Bits | 8 Bits |
| Parity | None | None |
| Stop Bits | 1 | 1 |
| Mode | Asynchronous | Asynchronous |
生成工程时在 Project Manager 里选 MDK-ARM V5 工具链,勾选生成外设初始化文件 .c/.h 分离模式。这样会得到usart.c、gpio.c、main.c这些基础文件,我们自己的 GPS 解析代码另外建一个 User 目录放进去,不跟 CubeMX 生成的文件混在一起,后续升级重新生成工程时不容易被覆盖。
2.3 CubeMX 生成的目录结构
工程目录大致长这样,重点看注释标注的几个文件:
STM32GPS/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── usart.h │ │ └── gpio.h │ └── Src/ │ ├── main.c // 主循环、优先级初始化 │ ├── usart.c // USART1/USART2 初始化 │ └── gpio.c // 引脚配置 ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ // HAL 库源码,一般不用改 ├── User/ │ ├── gps.h // GPS 结构体与接口声明 │ ├── gps.c // NMEA 解析器 │ └── debug.c // printf 重定向(可选) └── MDK-ARM/ // Keil 工程文件我一般习惯把gps.c/h放在 User 目录,CubeMX 重新生成代码不会动这个文件夹。调试输出相关的fputc重定向可以放 main.c,也可以单独放 debug.c,看个人习惯。
3. 核心代码拆解:从串口字节流到经纬度坐标
3.1 先看懂 NMEA 语句在说什么
GPS 模块上电后,会按约定频率从 TX 脚吐出 NMEA 0183 协议文本,一条典型$GPRMC长这样:
$GPRMC,023456.000,A,3016.1234,N,12007.5678,E,0.5,135.2,251024,,,D*66按逗号拆开看:第一个字段是 UTC 时间023456.000,即 02 点 34 分 56 秒;第二个字段A表示定位有效,V表示无效;第三四个字段是纬度3016.1234,N,意思是 30 度 16.1234 分北纬;第五六个字段是经度12007.5678,E,即 120 度 7.5678 分东经;后面是速度(节)、航向和日期251024。
这里最容易翻车的是度分格式。3016.1234不是 30.161234 度,而是 30 度加 16.1234 分。转十进制的公式是:
十进制纬度 = 30 + 16.1234 / 60 = 30.2687233如果你直接把atof("3016.1234")当经纬度用,定位点会偏差到完全不可信的程度。这个转换函数是后续所有代码的基石。
3.2 串口接收:环形缓冲区 + HAL_UART_Receive_IT
CubeMX 生成的usart.c只做了外设初始化,真正的接收逻辑要自己写。常见做法是每次启动一个字节的中断接收,收到后丢进环形缓冲区,主循环再从缓冲区取数据喂给解析器。
/* 定义环形缓冲区 */ #define RING_BUF_SIZE 512 static volatile uint8_t ring_buf[RING_BUF_SIZE]; static volatile uint16_t ring_head = 0; static volatile uint16_t ring_tail = 0; /* 接收缓冲字节 */ uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 存入环形缓冲区,头指针后移 */ ring_buf[ring_head] = rx_byte; ring_head = (ring_head + 1) % RING_BUF_SIZE; /* 重新启动单字节接收 */ HAL_UART_Receive_IT(huart, &rx_byte, 1); } }这段代码的逻辑是:HAL 库每收到一个字节,就会触发一次回调。回调里把字节放进环形缓冲,然后立刻重新调用HAL_UART_Receive_IT,让串口进入下一轮接收等待。环形缓冲区的作用是缓冲主循环来不及处理的字节,避免 9600 波特率下偶尔丢失数据。
参数说明:HAL_UART_Receive_IT的第三个参数是每次接收的字节数,这里固定为 1,表示单字节中断模式。要注意rx_byte必须是全局变量,因为它要跨越中断上下文存活。回调里绝对不能做耗时操作,比如字符串解析或 printf,只负责搬运数据,解析全部放到主循环。
3.3 NMEA 解析器:按行切分、按逗号提取
GPS 数据是文本流,解析器要做两件事:一是按$起始、换行结束切出完整的一行;二是在这一行内部按逗号分段提取字段。我用的解析入口是一个逐字节喂入的状态机:
/* gps.h 中定义的结构体 */ typedef struct { uint8_t valid; /* 定位有效标志,1 有效 0 无效 */ float lat; /* 十进制度,北纬为正 */ float lon; /* 十进制度,东经为正 */ float speed_knot; /* 速度,单位节 */ float course; /* 航向角 0-359.9 度 */ uint8_t hour, minute, second; uint8_t day, month, year; } gps_fix_t; /* gps.c 中逐字节喂入 */ void gps_parse_byte(gps_fix_t *fix, uint8_t c) { static uint8_t line_buf[128]; static uint8_t idx = 0; if (c == '$') { idx = 0; /* 新语句开始,重置缓冲 */ } if (idx < sizeof(line_buf)) { line_buf[idx++] = c; } if (c == '\n') { line_buf[idx] = '\0'; parse_rmc(fix, (char *)line_buf); idx = 0; } }这个函数的关键在静态变量line_buf和idx,它们跨多次调用保存状态。每收到一个$就认为新语句开始,每收到换行符就认为语句结束,把缓冲区交给parse_rmc处理。缓冲区设置 128 字节足够装下$GPRMC最长的情况,超过的字节直接丢弃,防止溢出。
parse_rmc是真正的字段提取逻辑,我用手写逗号计数而不是sscanf——原因是 GPS 模块在未定位时某些字段是空的,sscanf遇到空字段很容易错位,手写字段提取对异常数据的容忍度更高:
static void parse_rmc(gps_fix_t *fix, char *line) { char *p = line; int field = 0; char fbuf[16]; int fi = 0; /* 不是 GPRMC 语句就直接返回 */ if (strncmp(line + 1, "GPRMC", 5) != 0) { return; } while (*p != '\0' && *p != '*') { if (*p == ',') { fbuf[fi] = '\0'; fi = 0; switch (field) { case 0: /* UTC 时间 hhmmss.sss */ if (strlen(fbuf) >= 6) { fix->hour = (fbuf[0] - '0') * 10 + (fbuf[1] - '0'); fix->minute = (fbuf[2] - '0') * 10 + (fbuf[3] - '0'); fix->second = (fbuf[4] - '0') * 10 + (fbuf[5] - '0'); } break; case 1: /* 定位状态 A 或 V */ fix->valid = (fbuf[0] == 'A') ? 1 : 0; break; case 2: /* 纬度 ddmm.mmmm */ fix->lat = dm_to_deg(atof(fbuf)); break; case 3: /* N / S */ if (fbuf[0] == 'S') fix->lat = -fix->lat; break; case 4: /* 经度 dddmm.mmmm */ fix->lon = dm_to_deg(atof(fbuf)); break; case 5: /* E / W */ if (fbuf[0] == 'W') fix->lon = -fix->lon; break; case 6: /* 速度 */ fix->speed_knot = atof(fbuf); break; case 7: /* 航向 */ fix->course = atof(fbuf); break; case 8: /* 日期 ddmmyy */ if (strlen(fbuf) >= 6) { fix->day = (fbuf[0] - '0') * 10 + (fbuf[1] - '0'); fix->month = (fbuf[2] - '0') * 10 + (fbuf[3] - '0'); fix->year = (fbuf[4] - '0') * 10 + (fbuf[5] - '0'); } break; } field++; } else { if (fi < (int)sizeof(fbuf) - 1) { fbuf[fi++] = *p; } } p++; } }这段代码的核心逻辑是:遇到逗号就把当前收集到的字段内容交给switch处理,然后字段号加一。手写提取的好处是字段缺了也不影响后面的字段继续解析——比如某些模块没输出速度时field_buf为空字符串,atof("")返回 0,但不会破坏整条语句解析。
配套的度分转换函数:
/* ddmm.mmmm 转十进制度 */ static float dm_to_deg(float dm) { int deg = (int)(dm / 100.0f); float minute = dm - deg * 100.0f; return (float)deg + minute / 60.0f; }参数说明:dm是 NMEA 原始值,比如3016.1234。先用整除拿到度30,剩下16.1234是分,除以 60 换成度,最后相加。南纬和西经的负号处理放在调用方(case 3 和 case 5),不在这个函数里做,职责更清晰。
3.4 printf 重定向与主循环调试输出
解析器有了,怎么把坐标打出来?HAL 库没有默认的printf通路,需要手动重定向到调试串口。最简单的方式是重写fputc:
/* 重定向 printf 到 USART2 */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, 0xFFFF); return ch; }主循环里每收到一条有效定位就打印一次:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); gps_fix_t gps = {0}; uint8_t last_valid = 0; /* 启动 USART1 中断接收 */ HAL_UART_Receive_IT(&huart1, &rx_byte, 1); while (1) { /* 从环形缓冲取字节 */ while (ring_head != ring_tail) { uint8_t c = ring_buf[ring_tail]; ring_tail = (ring_tail + 1) % RING_BUF_SIZE; gps_parse_byte(&gps, c); } /* 定位状态从无效变成有效时打印 */ if (gps.valid && gps.valid != last_valid) { printf("FIX: %02d:%02d:%02d\r\n", gps.hour, gps.minute, gps.second); printf("LAT: %.6f LON: %.6f\r\n", gps.lat, gps.lon); printf("SPD: %.2f kn CRS: %.1f\r\n", gps.speed_knot, gps.course); last_valid = gps.valid; } if (!gps.valid) last_valid = 0; } }注意printf里的%.6f格式化浮点数。MDK 工程默认的 C 库对浮点 printf 支持不完整,常见表现是输出 0.00 或直接不输出。解决办法是在 Keil 的魔术棒设置里勾选 Use MicroLIB,这个微库对浮点转字符串的支持是完整的。如果是 GCC 工具链,加-u _printf_float链接选项。
4. 编译烧录与联调:从串口到真实坐标
4.1 编译配置与下载步骤
CubeMX 生成的 MDK 工程打开后,第一件事检查三个地方:芯片型号是否认成 STM32F103ZE,Flash 大小是否识别到 512K;C/C++ 编译器选项里宏定义有没有USE_HAL_DRIVER,STM32F103xE,这是 HAL 库编译裁剪用的;Debug 选项里的下载器是否选成板载的 SWD 调试器。
下载方式我一般直接在 Keil 里点下载按钮,SWD 四根线——SWDIO、SWCLK、GND、3.3V——大多数板子都引出排针。下载失败时先看能不能识别到芯片,如果报 no target connected,先检查供电和 SWD 线序,ZET6 的大板子经常有人把排针方向插反。
编译前还要确认一个细节:fputc重定向是否引用了stdio.h。MDK 下如果不包含头文件,编译会报隐式声明警告,虽然能编过,但运行时会出奇怪问题。
4.2 先看原始 NMEA,再上解析器
联调第一步不是跑程序,而是验证接线和模块状态。把板子上电后,用 USB-TTL 转接线接到调试串口 USART2,打开串口助手,波特率设 115200,观察有没有输出。
如果什么都收不到,怀疑方向依次是:GPS 模块上电没初始化、USART1 那根杜邦线接触不良、GPS 模块的 TX 没接到 PA10。最直接的排查办法是拔掉 STM32 板子上的 GPS 接线,把 GPS 模块的 TX 直接接 USB-TTL 的 RX,GND 共地,用电脑串口助手看原始数据。如果电脑上能看到$GPRMC,说明模块没问题,问题出在单片机的串口配置或杜邦线上。
我一般会在这一步顺手确认模块输出的是哪些语句、频率是多少。默认配置下大多是 1 秒输出一组,包含 GPRMC、GGA、GSA、GSV 四条,够用就行。
4.3 联调结果判断:定位有效和坐标演算
程序跑起来后,串口助手可能先看到一段时间的沉默,这是正常现象——GPS 模块冷启动时需要搜星,几十秒到几分钟不等。等模块上的指示灯开始闪烁(通常表示已定位),单片机会打印出完整定位信息:
FIX: 02:34:56 LAT: 30.268723 LON: 120.126130 SPD: 0.58 kn CRS: 135.3看到这组数据,说明整条链路已经通了。但有个很容易被忽略的验证点:把打印出的经纬度和手机地图对比一下,偏差应该在几十米到一百米范围内,方向也别差太离谱。如果坐标落在海里或另一个城市,大概率是度分转换写错了,或者 N/S、E/W 的符号处理反了。
另一个验证点是手动换算:把3016.1234按公式转成30.268723,在解析器里打断点看dm_to_deg的返回值是不是这个数。这种肉眼验证比什么都管用,能帮你快速分清是硬件问题还是软件 bug。
5. 避坑指南:GPS 例程最常见的 5 个翻车现场
5.1 串口助手一片乱码
现象:printf 输出的内容在串口助手里显示为一堆乱码,偶尔有几个能看清的字母。
原因:大概率是波特率没对上。GPS 模块默认 9600,但 CubeMX 里把 USART1 配成了 115200,或者反过来——模块被配置成 115200,单片机还在用 9600 收。另一种可能是 USB-TTL 驱动异常或杜邦线太长,信号劣化导致丢位。
解决:先用 USB-TTL 直连 GPS 模块,确认模块实际输出的波特率;再把 CubeMX 里 USART1 的波特率改成跟模块一致。调试串口 USART2 不用改,保持 115200 就好,两个串口波特率不一致不影响。
5.2 时间总是差 8 小时
现象:定位没问题,但打印出的时间是凌晨两三点,而本地时间已经是上午十点多。
原因:NMEA 协议里的时间是 UTC 时间,也就是英国格林尼治标准时间,不包含时区信息。东八区需要自己加 8 小时。
解决:拿到hour字段后加 8,超过 24 就减 24 并让日期加一。简单实现就是在parse_rmc的 case 0 处理后追加调整:
fix->hour += 8; if (fix->hour >= 24) { fix->hour -= 24; fix->day += 1; /* 严格还要处理月底进位,但多数场景够用 */ }注意这个“够用”是日常调试级别,如果要拿去做正式产品,跨月和跨年的进位处理必须完整实现,否则 3 月 1 日凌晨的时间会变成 2 月 31 日。
5.3 定位标志一直是 V,valid 始终为 0
现象:$GPRMC 里的 A/V 字段一直显示 V,程序永远走不进打印分支。
原因:模块没搜到足够卫星。室外开阔地通常十几秒到一分钟冷启动就能定位;窗边、楼道、金属物体附近信号衰减明显,定位时间会拉长到几分钟甚至超时。
解决:把模块拿到窗边或室外,天线平面朝上,不要紧贴金属板。看模块上的 PPS 或定位指示灯——大多数模块定位成功后 LED 闪烁频率会变化。同时检查天线型号和接口是否匹配,陶瓷天线的馈线很细,摔过容易断芯,肉眼看不出但信号质量极差。
5.4 printf 打印浮点数输出 0.00
现象:printf("LAT: %.6f", gps.lat)输出0.000000,但打断点看gps.lat是有值的。
原因:MDK 默认 C 库对浮点 printf 的格式支持不完整,编译链裁剪掉了浮点转换代码。GCC 下则是没有链接浮点 printf 支持。
解决:Keil 工程在 Options → Target 里勾选 Use MicroLIB。如果是 GCC 工具链,链接参数加-u _printf_float。改完重新编译,如果还输出 0.00,先打印一个常量printf("%.3f\r\n", 1.234)验证工具链是否正常,再做变量排查。
5.5 板子上电后 GPS 长时间没有输出
现象:USART1 中断接收已经开启,但串口助手半个多小时没收到任何数据,换电脑直连模块也是静默。
原因:这往往跟软件无关。部分 GPS 模块的复位脚是低有效,悬空或外部干扰可能让模块一直处于复位状态;还有的国产模块需要把 PPS 脚之外的一个使能脚拉高才会开始定位。另一个常见原因是模块背面电池没电了——冷启动状态需要重新下载星历,时间会明显变长。
解决:先查模块数据手册,看有没有复位脚和使能脚需要初始化。我一般会在 GPIO 初始化里先把复位脚和使能脚拉到正确电平,避免悬空。如果模块是二手或库存,尝试上电保持 10 分钟以上,让冷启动完成。
6. 二次开发:从例程到带显示和轨迹记录的整机
6.1 改造一:把定位信息显示在 OLED 上
串口打印只是调试,做产品就要加显示。用 I2C OLED 是最省事的方案,SPI 屏也行但多两根控制线。I2C 驱动 OLED 时,在系统里显示的字符要先用sprintf格式化再放进显存:
char line[24]; sprintf(line, "LAT:%.5f", gps.lat); oled_show_string(0, 0, line); sprintf(line, "LON:%.5f", gps.lon); oled_show_string(0, 1, line);把sprintf的精度从 6 位小数改成 5 位,屏幕宽度有限时显示更友好。更新频率不用太快,1 秒刷新一次就行,OLED 刷新太快反而容易烧屏残影。
6.2 改造二:把有效定位写进 SD 卡生成轨迹
记录轨迹是 GPS 项目最实用的延伸。用 FatFs 挂 SD 卡,在定位有效时向 CSV 文件追加一行:
FIL file; if (f_open(&file, "track.csv", FA_OPEN_APPEND | FA_WRITE) == FR_OK) { f_printf(&file, "20%02d-%02d-%02d %02d:%02d:%02d,%.6f,%.6f,%.1f\r\n", gps.year + 2000, gps.month, gps.day, gps.hour, gps.minute, gps.second, gps.lat, gps.lon, gps.speed_knot * 1.852); f_close(&file); }注意f_printf是 FatFs 提供的格式化输出,跟标准printf的参数基本一样,但写入速度取决于 SD 卡,频繁写会卡住主循环。我的做法是只在valid从 0 变 1 的时刻记录,或者把数据攒进数组每 5 秒批量写一次,不污染串口中断收数据的节奏。
6.3 改造三:不同 GPS 模块的适配思路
这套例程换到别的模块上,先确认三件事:模块的默认波特率、默认输出语句集、TTL 电平是否 3.3V。有些模块输出的是$GNGGA或$BDGGA前缀,解析器里的strncmp(line + 1, "GPRMC", 5)就匹配不上——这时候改成同时识别GNRMC和多模版本的前缀,或者干脆只判断语句类型不分星座。
从那以后我每次接到新的 GPS 模块,第一件事永远是把它单独接 USB-TTL 看原始流,确认波特率、语句类型、定位状态再写代码。这套流程帮我避开了至少一半的“玄学定位”问题。希望帮到你。
本文还有配套的精品资源,点击获取