
简介这是基于STM32F103ZET6与STM32CubeMX配置工具实现的GPS解析例程面向嵌入式初学者和正在学习STM32 HAL库开发的工程师。例程展示了从CubeMX图形化配置时钟、GPIO、UART及中断到生成Keil工程并编写应用层代码的完整流程重点解决GPS模块NMEA协议数据接收与解析问题。压缩包共188个文件以h、c源文件为主配合uvprojx、uvoptx等Keil工程文件与ioc配置文件还包含axf调试文件、hex烧录文件、map映射文件等整体约8.58MB目录结构清晰Core、Hardware、MDK-ARM等模块便于按需查阅。已有220人学习下载。借助该例程开发者可掌握CUBE工具快速初始化工程的方法理解HAL库UART中断收发机制并学会编写NMEA语句解析函数将经纬度、速度等数据提取为可用的数值信息。资源还附带编译输出与中间文件非常适合对照调试过程和排查通信故障是一份可直接上手的STM32外设交互实践参考。1. 一个能直接跑通的 GPS 定位例程STM32F103ZET6 CubeMX 从零到输出坐标拿到一块 STM32F103ZET6 开发板和一颗常见的 GPS 模块比如串口输出的 NEO 系列我第一件事不是点灯也不是点亮屏幕而是先让开发板把纬度和经度打印出来。这个例程做的事很朴素用 CubeMX 配好时钟、串口和 DMA接上 GPS 模块解析 NMEA 语句最终通过串口把定位结果一行一行吐出来。它特别适合两类人一是刚接触 STM32 的入门者想在最小代码量下看到定位数据的完整链路二是做车载、物流或无人机项目的开发者拿它当采集端的地基代码。整篇例程不依赖复杂 RTOS一个主循环加两三个回调函数就能把 GPS 跑起来。2. 技术选型和工程骨架为什么是 F103ZET6 CubeMX 串口 DMA2.1 技术选型从 GPS 模块到 MCU 的完整数据链路一个 GPS 定位系统的数据流是卫星信号进 GPS 模块模块完成射频接收和基带解调通过串口输出标准 NMEA 0183 语句MCU 负责收取、解析并输出结果。常见的语句有$GPRMC、$GPGGA、$GPGSV等一秒钟输出一帧波特率默认 96008 位数据位、无校验、1 位停止位。对 MCU 来说接收和解析并不复杂难点在接收效率和解析的健壮性。选 STM32F103ZET6 有几层考虑。它是 F103 系列里的大容量型号512KB Flash 加 64KB SRAM比很多小板子的 64KB Flash 宽裕太多后续如果要加 OLED 显示、SD 卡存储、文件系统或简单的通信协议栈都不会捉襟见肘。其次是串口数量F103ZET6 有 5 个 USART一个接 GPS一个接调试输出还能留一个接 WiFi 或蓝牙模块不用来回拆线。再加上它的引脚是 LQFP144 封装PB、PC 口大量空闲外接设备时不用挤在同一个引脚上。接收方案我选 DMA 串口空闲中断而不是最直接的逐字节中断接收。GPS 每秒输出的多行帧数据长度不定$GPRMC一帧大概 60 到 80 字节如果每个字节都进一次串口中断72MHz 主频下虽然也能跑但系统扩展之后 CPU 利用率就会吃紧。DMA 负责把字节自动搬进内存CPU 只在一整帧数据收完、总线上出现空闲时醒一次做解析就行。这个做法在 HAL 库工程里是相当标准的串口接收姿势尤其是新版固件包直接提供了HAL_UARTEx_ReceiveToIdle_DMA这个接口一行代码就能把整套机制跑起来。2.2 CubeMX 工程配置时钟树、串口和 DMA 的关键设置接线是第一步也是最容易翻车的一步。GPS 模块的 TX 要接 STM32 的 RXRX 接 STM32 的 TX必须交叉连接。我常用 USART1对应 PA9 和 PA10 引脚。GPS 模块引脚STM32F103ZET6 引脚说明VCC5V 或 3.3V以模块手册为准NEO 系列多为 5V 供电GNDGND必须共地否则数据容易乱码TXPA10USART1_RXGPS 发STM32 收RXPA9USART1_TX可选一般调试才需要PPSPB0可选秒脉冲做时间同步用CubeMX 配置按下面几步走都是我实际建工程时反复确认过的点。新建工程选 STM32F103ZETx封装 LQFP144。在 RCC 里把 HSE 设为 Crystal/Ceramic Resonator这是外部晶振入口在 SYS 里把 Debug 设为 Serial Wire否则部分调试器连不上芯片。打开 Clock ConfigurationHSE 按板载晶振填 8MHzPLL Source 选 HSE倍频设为 x9得到 72MHz SYSCLK。USART1 挂在 APB2 上波特率由 PCLK2 计算所以 APB2 保持 72MHz 不要低估。把 PA9 和 PA10 分别配置为 USART1_TX、USART1_RX模式选 Asynchronous参数填 9600、8N1。在 USART1 的 DMA Settings 里 Add USART1_RXDirection 选 Peripheral To MemoryMode 选 Circular数据宽度都选 Byte。在 NVIC 里勾上 USART1 global interruptDMA 的 global interrupt 也勾上。工程设置里 Toolchain 选 MDK-ARM生成代码。提示DMA 的 Mode 一定要是 Circular。用 Normal 模式收一次 DMA 就停了后面数据再也不进来这是新手最常踩的坑。另外注意很多开发板板载的 USB 转串口已经占用了 PA9/PA10 这对引脚。如果你外接 GPS 模块要先确认板子上有没有跳线帽连接把它断开否则 GPS 的信号和板载调试芯片会同时驱动 RX 引脚数据互相打架。2.3 生成的代码结构与文件的职责CubeMX 生成的工程目录结构一般是这样的项目目录/ ├── Core/ │ ├── Inc/ // main.h、usart.h、gpio.h、dma.h │ └── Src/ // main.c、usart.c、gpio.c、dma.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── MDK-ARM/ // Keil 工程文件 └── User/ // 自己新建的代码目录放解析逻辑几个文件的分工很明确usart.c负责MX_USART1_UART_Init和串口句柄初始化dma.c负责 DMA 句柄gpio.c负责 LED 和引脚初始化main.c里是主循环。我的习惯是把 GPS 解析的代码单独放到User/gps.c和User/gps.h里不要在main.c里堆一堆解析函数。原因很简单这个例程后期要么加 OLED要么加 SD 卡业务代码独立成文件才方便在不同工程之间搬。文件职责本次例程需要改动Core/Src/main.c初始化外设、主循环加 DMA 接收启动和轮询解析Core/Src/usart.c串口参数初始化一般不动Core/Src/gpio.cLED、按键引脚可加 PPS 外部中断Core/Src/dma.cDMA 初始化一般不动User/gps.cNMEA 解析本次的核心工作区3. 核心模块拆解串口 DMA 接收和 NMEA 解析的完整链路3.1 串口 DMA 接收为什么用空闲中断方式主循环里要做的事情很少真正的工作在回调函数里。初始化完成后调用一次接收启动函数之后的每一帧 GPS 数据都会自动触发回调。#define GPS_RX_BUF_SIZE 256 uint8_t gps_rx_buf[GPS_RX_BUF_SIZE]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); // 启动 DMA 接收数据到齐后总线空闲即触发事件 HAL_UARTEx_ReceiveToIdle_DMA(huart1, gps_rx_buf, GPS_RX_BUF_SIZE); while (1) { gps_poll(); // 每轮循环尝试解析最新一帧 } }HAL_UARTEx_ReceiveToIdle_DMA做的事情是把串口数据和 DMA 通道绑定DMA 连续把收到的字节搬进gps_rx_buf当总线上超过一个字节时间没有新数据就认为一帧结束触发空闲中断并调用回调。参数第一项是串口句柄第二项是接收缓冲区第三项是缓冲区长度。缓冲区设 256 字节是因为$GPRMC加上其他语句通常不超过这个数留足余量。回调函数里拿到的是本次空闲中断对应的这一帧数据void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { if (huart-RxEventType HAL_UART_RXEVENT_IDLE) { // Size 是本帧实际长度 // Circular 模式下缓冲区继续接收不用重新启动 DMA gps_parse_frame(gps_rx_buf, Size); } } }这里要注意RxEventType的判断。HAL 库的 RxEvent 回调有多种触发源不只有空闲中断还有一种传输完成事件。加了判断可以过滤掉不需要的事件。Circular 模式的好处在这里体现出来缓冲区是循环的收完一帧后 DMA 会自动从缓冲区头部继续写应用程序不需要在回调里再次调用接收启动函数。代价是如果解析速度太慢新数据可能覆盖还没处理完的内容。NMEA 数据量小、帧短这个场景下单缓冲完全够用。 如果后续要处理高速数据流再做双缓冲切换也不迟。3.2 NMEA 解析从$GPRMC提取坐标和时间GPS 模块输出的语句很多解析的时候不能每行都做完整处理。我只关心$GPRMC这条语句里包含定位状态、纬度、经度、速度、航向和 UTC 时间信息最全。#include string.h #include stdio.h typedef struct { uint8_t valid; // 1 有效0 无效 float lat; // 纬度十进制角度 float lon; // 经度十进制角度 uint8_t ns; // N 或 S uint8_t ew; // E 或 W float speed_knot; // 速度节 float course; // 航向度 uint8_t hour, min, sec; // UTC 时间 } gps_t; gps_t gps_data; // NMEA 纬度/经度格式转十进制角度 static double nmea_to_deg(double value, char dir) { int deg (int)(value / 100.0); // 度纬度取前2位经度取前3位 double min value - deg * 100.0; // 分小数点后的部分 double out deg min / 60.0; // 分转度 if (dir S || dir W) out -out; return out; } void gps_parse_frame(uint8_t *buf, uint16_t len) { if (len 7) return; // 最短也要 $GPRMC 加几个字段 if (memcmp(buf, $GPRMC, 6) ! 0) return; // 只处理推荐定位语句 char *p strtok((char *)buf, ,); uint8_t idx 0; double lat_v 0.0, lon_v 0.0, spd_v 0.0, crs_v 0.0; uint32_t utc 0; char ns ?, ew ?, status V; while (p ! NULL) { switch (idx) { case 1: utc (uint32_t)atof(p); break; // UTC 时间 HHMMSS case 2: status p[0]; break; // A 有效V 无效 case 3: lat_v atof(p); break; // 纬度 ddmm.mmmm case 4: ns p[0]; break; // N/S case 5: lon_v atof(p); break; // 经度 dddmm.mmmm case 6: ew p[0]; break; // E/W case 7: spd_v atof(p); break; // 速度节 case 8: crs_v atof(p); break; // 航向度 default: break; } p strtok(NULL, ,); idx; } gps_data.valid (status A); gps_data.lat nmea_to_deg(lat_v, ns); gps_data.lon nmea_to_deg(lon_v, ew); gps_data.speed_knot spd_v; gps_data.course crs_v; gps_data.hour (utc / 10000) % 100; gps_data.min (utc / 100) % 100; gps_data.sec utc % 100; }解析逻辑里最关键的是经纬度换算。NMEA 语句里的纬度是ddmm.mmmm格式以4029.5432为例前两位 40 是度后面的29.5432是分换算成十进制角度就是40 29.5432 / 60 40.492386。经度是dddmm.mmmm前三位是度同样把分除以 60。南北纬和东西经的判断不能漏否则北半球的数据放到南半球就会偏出去这是一个很隐蔽的坐标错误。注意一个细节strtok会直接修改传入的缓冲区。如果正在解析时 DMA 又往同一个缓冲区写入新数据可能出现字段被改到一半的情况。单任务裸机例程里因为 DMA 接收和解析都发生在空闲中断的回调中时机上不会交错所以这个写法能安全运行。一旦以后改成多线程或带 RTOS就要先把帧拷贝到局部数组再解析。3.3 时间处理与串口输出GPS 模块输出的是 UTC 时间比北京时间慢 8 小时。如果你只是显示定位信息不改时间也没关系但要做轨迹记录时间不对就很难对齐。我的做法是把 UTC 时间转成北京时间再打印void gps_to_beijing_time(gps_t *gps, uint8_t *hour, uint8_t *min, uint8_t *sec) { uint16_t utc_total gps-hour * 3600 gps-min * 60 gps-sec; uint16_t bj_total utc_total 8 * 3600; // 加 8 小时时区偏移 bj_total % 86400; // 跨天取模 *hour bj_total / 3600; *min (bj_total % 3600) / 60; *sec bj_total % 60; }这个函数只处理时分秒日期跨天的部分要单独加判断比如 UTC 时间 23:30 转北京时间已经是第二天早上 7:30日期要进位。日志文件里如果只记录时间不记录日期跨天后会显得数据乱序补上日期逻辑更完整。printf 重定向是 HAL 库工程里绕不开的一步。标准的做法是重写fputc把标准输出指向串口int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 10); // 阻塞发送超时 10ms return ch; }在 Keil 里勾上 Use MicroLIBprintf 就能直接往串口打印浮点数。这里有一个实践上的习惯我通常不让 GPS 数据和调试 printf 混在同一个串口上。 如果 GPS 接 USART1调试输出就用 USART2两者分开。混用的话调试日志会穿插在 GPS 数据流里解析器读到带日志的脏数据轻则丢帧重则解析出乱码坐标。3.4 状态指示让 LED 反映定位是否有效主循环里除了轮询解析我还会加一个 LED 状态灯让定位状态一眼可见。开发板上常见的 PC13 接了一个 LED低电平点亮用它做定位指示非常方便。uint32_t last_check 0; void gps_poll(void) { if (HAL_GetTick() - last_check 500) // 每 500ms 刷新一次 { last_check HAL_GetTick(); if (gps_data.valid) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 定位成功点亮 } else { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 未定位熄灭 } } }这个 500ms 的轮询用HAL_GetTick()做非阻塞延时避免了HAL_Delay把主循环卡死。LED 的状态在调试时很有用上电后如果在室外通常十几秒内 LED 会亮起来代表valid变成A如果过了很久还没亮大概率不是代码问题而是模块根本没有定位条件。4. 常见问题避坑GPS 例程最容易踩的五个坎这一章的内容是我在实际调试里反复遇到过的故障记录每一条都按现象、原因、解决三步说清楚。GPS 例程翻车往往不在解析代码本身而在配置和接线这些看起来不起眼的地方。4.1 乱码问题电平、波特率和接线的三重可能现象是串口助手打开一片乱码字符或者完全收不到任何数据。排查顺序很重要我习惯先把 GPS 模块单独接到 USB 转 TTL 上不用 STM32 参与直接用电脑串口助手看模块原始输出。如果 9600 下能看到$GPRMC开头的一行行文本说明模块没问题如果乱码把波特率轮流试 4800、9600、19200、115200找出模块实际工作的波特率。原因通常有三种GPS 模块默认波特率不是 9600而是被改过或模块出厂就是 115200TX 和 RX 接成了同向连接GPS 的 TX 接到了 STM32 的 TXGPS 模块电平不是 3.3V TTL有的模块串口输出是 5V 电平和 STM32 的引脚电压不匹配导致数据畸变。解决的办法分步来先用电平转换器或确认模块手册弄清串口电平再用串口助手确认波特率最后检查接线是否交叉。这三步走完90% 的乱码都能解决。剩下 10% 的地线问题检查 GPS 模块的 GND 和开发板 GND 是否连在同一个网络不共地时乱码时有时无表现非常像波特率不对。4.2 定位无效问题valid 一直是 V坐标全是 0现象是数据帧能收到解析也没报错但gps_data.valid一直是 0串口打印出来的纬度和经度都是 0。这个问题在室内调试时最常见。原因是 GPS 模块没有完成定位。冷启动时模块要从卫星星历开始下载可能需要 30 秒到 2 分钟在窗边或室内时间更久如果模块放在金属外壳里、天线被压住或者天线接口松动信号强度不足会一直定位失败。另外有些模块板载天线是陶瓷片天线的朝向也有讲究陶瓷天线正面带文字那面要朝上对着天空。解决办法很简单把模块拿到窗边或室外让天线正上方无遮挡等待一到两分钟观察板载 PPS 指示灯的闪烁状态。多数 NEO 系列模块在定位成功后 PPS 灯会每秒闪一次没定位时灯常亮或闪得很慢。等 PPS 规律闪烁后再看代码里的valid会发现已经变成A了。 我排查这类问题时总是先看灯再看代码因为代码在整个系统的确定性最高反而是卫星环境最不可控。4.3 DMA 接收坑只收到一帧就再也不进回调或一帧被拆成两半现象是上电后串口打印出第一条有效的 GPS 数据之后就再也没动静或者一帧$GPRMC被切成了两段分别触发回调解析时因为头部不完整被直接丢弃。原因基本集中在 DMA 配置上。Mode 选了 Normal 而不是 CircularDMA 搬运完设定的字节数后停止不会继续接收或者只勾了串口中断没勾 DMA 中断导致传输完成事件没有回调入口还有一种情况是在初始化之后又调用了HAL_UART_Receive_DMA和HAL_UARTEx_ReceiveToIdle_DMA两个 API 混用两条接收通路互相干扰。解决方法是回到 CubeMX把 DMA 的 Mode 改为 CircularNVIC 里勾上 DMA 对应通道的全局中断代码里只保留一次HAL_UARTEx_ReceiveToIdle_DMA启动调用之后不要在回调里再手动启动。如果发现一帧数据被拆开优先检查hal_uart_rx_buffer_size是不是小于一帧长度或者把RxEventType的判断条件放宽一些有些 HAL 版本在传输完成事件和空闲中断之间还会插入额外事件需要打印RxEventType具体值来确认。4.4 printf 重定向翻车打印空白或直接进 HardFault现象是代码能跑到 while 循环LED 也正常闪烁但串口就是没有任何打印输出严重的情况是调用 printf 时进入 HardFault 死循环。原因有几个方向fputc没有重定向标准库不知道往哪个串口输出Keil 工程没勾 Use MicroLIB浮点 printf 会使用较大堆栈F103 默认的 0x400 大小不够用或者 printf 的参数和格式符不匹配比如用%d去打印 float 类型在浮点参数传递的规则下会拿到错误的寄存器值。解决方法是四步检查工程设置里勾上 Use MicroLIB实现fputc并确认真实调用到printf的浮点格式符用%f或%.2f不要用%d强转如果还进 HardFault把启动文件里的堆栈大小改成 0x800 再试。浮点打印对 MCU 的资源开销不小嵌入式里常用定点数来避免这个问题但在例程阶段直接用 printf 最直观。4.5 坐标偏差问题位置在地图上偏了几公里现象是坐标解析出来不是 0放地图上却离实际位置很远甚至跑到海里。这类问题绝大多数不是 GPS 模块坏了而是坐标系或符号处理错了。原因一是经纬度转换时把分忘了除以 60直接把ddmm.mmmm整个当作度数这样纬度会多出接近 40 度的误差经度会偏更多原因二是南北纬和东西经的符号没处理把南纬当成了北纬原因三是 GPS 输出的是 WGS-84 坐标系而很多手机地图应用使用的是 GCJ-02 坐标系两者之间在城市区域有几百米到几公里的偏差这是坐标系定义不同导致的不是设备故障。排查方法是先把解析后的原始坐标打印出来和 GPS 模块自己输出的 NMEA 语句逐字段比对确认换算公式无误。再把 WGS-84 坐标输入一个已经校准的工具或算法库看转成 GCJ-02 后和手机地图上的位置是否接近。 做坐标转换要使用经过验证的算法不要自己拍脑袋写偏移公式这个环节出错很隐蔽而且只有到了实际场地才能发现。5. 二次开发把例程改成自己的定位数据采集器5.1 三个最顺手的改造方向这个例程跑通之后离一个实用的定位采集终端只差一步。我列过三个最容易扩展的方向改造方向需要的硬件主要改动OLED 实时显示定位信息I2C 或 SPI 接口 OLED 屏新增显示驱动每秒刷新坐标、速度、UTC 时间轨迹记录到 SD 卡SPI 接口 SD 卡模块引入 FATFS每秒把一行 CSV 追加到文件定位数据上云或上报串口 WiFi 模块复用另一个 USART定时把坐标打包上传加 OLED 是最低成本的改动只要在 main 循环里调用一个显示刷新函数即可。要注意的是 OLED 的 I2C 刷新慢不能把HAL_Delay用在刷新循环里否则会拖慢 GPS 数据的解析节奏。加 SD 卡要留意 DMA 缓冲区和文件系统缓冲区的职责划分GPS 接收中断只负责填缓冲区FATFS 的写操作放在主循环里做避免在中断里调用耗时的磁盘操作。5.2 验证方法和习惯二次开发完成后的验证我习惯分两步走。静态验证把设备放在阳台固定位置连续观察 5 分钟要求valid一直为 A坐标抖动在合理的范围内。动态验证拿着设备沿街区步行一圈每隔几米记录一次坐标把轨迹导出来看是否平滑有没有明显跳点。这两步能快速暴露符号错误、坐标系没转干净、以及天线安装位置不佳的问题。从那以后我每次拿到一个新的 GPS 模块第一件事一定不是接 MCU而是先上电接电脑串口助手看它的默认语句、默认波特率和输出频率。把模块这个黑匣子打开再动手写代码省掉过太多无谓的排查时间。GPS 例程本身不难难的是在接线、配置、坐标系这些细枝末节上保持清醒。希望帮到你。本文还有配套的精品资源点击获取