免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

STM32F103 GPS定位实战:NMEA 0183串口解析与坐标转换

STM32F103 GPS定位实战:NMEA 0183串口解析与坐标转换 简介一份基于STM32F103与ATGM332D-5N的GPS定位完整例程包采用C/C编写覆盖RCC时钟配置、GPIO初始化、UART串口通信、NMEA协议解析GPGGA/GPRMC以及定位数据提取与显示等关键环节适合嵌入式初学者、物联网开发者及GPS追踪系统设计人员参考。压缩包内共133个文件以.h头文件与.c源码为核心另含Keil工程配置文件uvproj/uvopt、编译生成的hex/axf/map以及调试辅助文件整体仅1.4MB结构紧凑。代码按主函数、串口驱动、NMEA解析等功能模块划分便于对照学习与二次开发。目前已有2430人学习下载对于理解STM32与GPS模块的交互方式、掌握嵌入式定位数据处理流程具有不错的实用价值。1. GPS STM32F103例程从NMEA数据到定位坐标的一次串口解码实战GPS 模块在 STM32F103 这类 M3 内核单片机上跑起来核心工作不是读串口而是把 NMEA 0183 协议里那堆$GPGGA、$GPRMC文本串拆成能用的经纬度和 UTC 时间。很多新人在这一步被卡住串口助手能看到数据LCD 上却出不来坐标问题基本都出在字符串解析和缓冲区管理上。这篇文章将要解决从 GPS 模块接线、USART 配置到 NMEA 语句解析的完整链路全程用 C 语言实现附带可以直接参考的代码结构和参数设置。无论你是正在做毕设的 STM32 初学者还是要给无人机、车载终端或者定位胸牌接 GPS 的嵌入式工程师这套基于 STM32F103 的 GPS 定位设计思路都能直接迁移到你的项目里。2. 硬件连接与数据链路GPS 模块与 STM32F103 的 USART 接线与电平适配2.1 GPS 模块接口选型UART TTL 输出是主流市面上常见的 GPS 模块比如 u-blox NEO-6M、NEO-8M 或者国产的 ATK-S1216F8绝大多数都直接引出 UART TTL 电平接口默认波特率 9600输出 NMEA 0183 语句。选择这类模块最大的好处是STM32F103 的 USART 外设可以无缝对接不需要额外的电平转换芯片。个别模块支持 SPI 或 I2C 接口但嵌入式应用里用串口始终最省事调试也最直观。需要注意的是GPS 模块的供电电压有的是 3.3V有的是 5V。NEO-6M 这类典型模块虽然标称 3.3V但很多开发板上的模块已经集成了稳压电路可以直接接 5V。而 STM32F103 的 GPIO 和 USART 引脚都是 5V 容忍的但为了稳妥起见建议先看模块手册确认其 TX、RX 引脚的电气特性。如果模块是 3.3V 供电直接和 STM32 的 3.3V 系统连接即可如果模块板载了 5V 稳压那信号电平仍然可能是 3.3V请用万用表实测确认。2.2 USART 引脚分配USART1 与 USART2 的选择逻辑STM32F103 有多个 USART做 GPS 定位设计时一般用两个串口一个接 GPS 模块另一个做调试信息输出。我习惯把 USART1PA9/PA10留给调试把 USART2PA2/PA3接 GPS 模块原因在于很多 STM32F103 开发板的板载 USB 转串口直接连在 USART1 上调试时不用额外接线。如果只有一块板子没有板载调试口那就把 USART1 接 GPSUSART2 接 USB 转 TTL 工具。以 USART2 接 GPS 模块为例接线对应关系如下STM32F103 的 PA2USART2_TX连接 GPS 模块的 RXPA3USART2_RX连接 GPS 模块的 TXGND 共地。这个连接方式在绝大多数 GPS 定位设计中是通用的只要你的板子把 USART2 引脚引出来了照着接就行。典型接线表STM32F103 引脚外设功能连接目标PA2USART2_TXGPS 模块 RXPA3USART2_RXGPS 模块 TXGND电源地GPS 模块 GND5V 或 3.3V电源正极GPS 模块 VCC提示GPS 模块的天线接口不要悬空。无源陶瓷天线需要放在模块上方尽量靠近窗口有源天线则要通过馈线连接并确认模块是否具备天线馈电能力。天线位置不佳直接表现为搜星数少或者一直无法定位。2.3 USART 初始化代码9600 波特率与中断接收配置GPS 模块上电后默认输出波特率一般是 9600部分模块可以通过配置命令改成 38400 甚至 115200但初次调试别改先用 9600 跑通数据链路。STM32F103 的 USART 初始化代码按下面的方式写可以直接用在你的工程里。void GPS_USART_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; // TX 引脚 GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; // RX 引脚 GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate 9600; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, USART_InitStructure); NVIC_InitStructure.NVIC_IRQChannel USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 使能接收中断 USART_Cmd(USART2, ENABLE); }这段代码的逻辑是先打开 USART2 和 GPIOA 的时钟把 PA2 配成复用推挽输出做 TXPA3 配成浮空输入做 RX然后按 9600-8-N-1 的典型参数初始化 USART2最后开接收中断。RXNE 中断是接收数据寄存器非空中断每收到一个字节就进一次中断不会丢数据。注意GPS 模块默认波特率若是 9600而模块之前被配置命令改过上电后可能不是 9600此时串口收不到有效 NMEA 数据。常见做法是模块上电后手动发送$PMTK220,9600*1C这类命令恢复波特率或者直接用模块上的 RESET 按键复位。2.4 接收缓冲区设计环形队列应对不定长 NMEA 语句NMEA 语句是可变长度的文本最长大概 80 字节GPS 模块每秒输出多条语句。如果每收到一条完整语句就立刻解析并刷新屏幕主循环容易被刷卡住。更稳的做法是中断只负责收字节并写入环形缓冲区主循环里检查一帧数据是否收齐然后再做解析。这里给出一个简单的环形缓冲区实现。#define GPS_RX_BUF_SIZE 256 volatile uint8_t gps_rx_buf[GPS_RX_BUF_SIZE]; volatile uint16_t gps_rx_head 0; volatile uint16_t gps_rx_tail 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART2); gps_rx_buf[gps_rx_head] data; gps_rx_head (gps_rx_head 1) % GPS_RX_BUF_SIZE; } }缓冲区的读写指针都按取模方式循环中断只动gps_rx_head主循环只读gps_rx_tail两者互不阻塞。当gps_rx_head和gps_rx_tail相等时缓冲区为空说明当前还没有新数据进来。3. NMEA 0183 协议解析GGA 语句的字段拆分与 C 语言实现3.1 NMEA 语句类型GGA、RMC、GSV 分别负责什么GPS 模块输出的原始数据是 ASCII 文本以$开头以回车换行结束。其中跟定位最相关的是$GPGGA和$GPRMC两类语句。$GPGGA是固定定位数据包含 UTC 时间、纬度、经度、定位质量指示、卫星数、水平精度因子、海拔高度等信息$GPRMC是最简推荐传输数据包含时间、定位状态、经纬度、地面速度、航向和日期很多做轨迹记录的设计喜欢用 RMC因为日期也在里面。在 GPS STM32F103 例程里通常只需要解析其中一条语句。如果你只需要经纬度和定位状态GGA 就够如果需要速度、航向和日期那就解析 RMC。下面以$GPGGA为例给出字段拆分方法这套拆分逻辑同样适用于 RMC。3.2 GGA 语句格式与字段索引一条标准的$GPGGA语句长这样$GPGGA,085419.000,3101.4893,N,12126.2198,E,1,8,1.03,12.5,M,-2.6,M,,*5E按逗号拆分的字段含义如下字段索引示例值含义0GPGGA语句标识1085419.000UTC 时间 08:54:19.00023101.4893纬度DDMM.MMMM 格式3N北纬412126.2198经度DDDMM.MMMM 格式5E东经61定位质量0 无定位1 单点定位2 差分定位78正在使用的卫星数量81.03水平精度因子 HDOP912.5海拔高度单位米解析的关键点有两个一是用逗号把语句拆开二是把第 2 和第 4 个字段的度分格式换算成十进制度。下面是完整的 C 解析函数基于标准 C 库string.h实现不依赖任何第三方库适用于 Keil 或 GCC 环境。3.3 GGA 解析函数实现从原始缓冲到结构体数据#include string.h #include stdlib.h #include stdio.h typedef struct { uint8_t valid; // 定位是否有效 uint8_t hour; uint8_t minute; uint8_t second; float latitude; // 十进制度格式纬度 float longitude; // 十进制度格式经度 uint8_t sat_num; // 可见卫星数 float altitude; // 海拔高度米 } GPS_Info_t; GPS_Info_t g_gps_info {0}; void GPS_ParseGGA(char *line) { char *token; char *p line; int field_idx 0; while ((token strsep(p, ,)) ! NULL) { switch (field_idx) { case 1: // 时间字段 HHMMSS.mmm if (strlen(token) 6) { g_gps_info.hour (token[0] - 0) * 10 (token[1] - 0); g_gps_info.minute (token[2] - 0) * 10 (token[3] - 0); g_gps_info.second (token[4] - 0) * 10 (token[5] - 0); } break; case 2: // 纬度 DDMM.MMMM if (strlen(token) 4) { double mm atof(token 2); double dd (token[0] - 0) * 10 (token[1] - 0); g_gps_info.latitude dd mm / 60.0; } break; case 3: // 南北纬 if (token[0] S) g_gps_info.latitude -g_gps_info.latitude; break; case 4: // 经度 DDDMM.MMMM if (strlen(token) 3) { double mm atof(token 3); double dd (token[0] - 0) * 100 (token[1] - 0) * 10 (token[2] - 0); g_gps_info.longitude dd mm / 60.0; } break; case 5: // 东西经 if (token[0] W) g_gps_info.longitude -g_gps_info.longitude; break; case 6: // 定位质量 g_gps_info.valid (token[0] ! 0); break; case 7: // 卫星数 g_gps_info.sat_num atoi(token); break; case 9: // 海拔 g_gps_info.altitude atof(token); break; default: break; } field_idx; } }这段代码逻辑是用strsep按逗号切分整条 GGA 语句每切出一个字段就通过field_idx定位到具体含义。经纬度先按度分格式解析再统一转成十进制度方便后续距离计算或坐标系转换。时间字段拆出时、分、秒注意这里拿到的是 UTC 时间显示给用户时要加 8 小时转成北京时间。3.4 校验和验证避免脏数据污染定位结果NMEA 语句在*后面带两位十六进制校验和是$和*之间所有字符按位异或的结果。解析前做校验和能过滤掉串口干扰产生的错误帧尤其是 GPS 模块用长导线连接、或者和电机驱动共享电源的场合这个检查能省去大量排查时间。int GPS_CheckSum(char *line) { uint8_t calc 0; char *p line 1; // 跳过 $ char *star strchr(line, *); if (star NULL) return 0; while (p star) { calc ^ (uint8_t)*p; p; } uint8_t recv (uint8_t)strtol(star 1, NULL, 16); return (calc recv) ? 1 : 0; }校验和函数先跳过行首的$把*之前的字符逐个异或再和*后面的两位十六进制数比较。如果一致就返回 1表示这一帧有效。在主循环里只有校验和通过且语句类型为$GPGGA的数据才进入解析流程。4. 定位数据应用坐标系转换、时间校准与显示刷新策略4.1 度分格式转十进制度距离计算的前提GPS 模块输出的纬度和经度是度分格式比如3101.4893表示 31 度 01.4893 分。嵌入式显示或者上传云平台前一般要转成十进制度31.0248217度。转换公式是十进制度 度 分 / 60这个工作在第 3 章的解析函数里已经做了。如果要做两个坐标点之间的距离计算十进制度可以直接套用球面距离公式如果直接用度分格式容易把分钟当成小数度算出来的距离差出几十倍。4.2 UTC 时间转北京时间时区偏移的避坑点GGA 里的时间字段是 UTC 时间和北京时间相差 8 小时。直接加 8 会有两个边界问题小时加完后大于等于 24 需要回绕日期跨天时日期也要跟着变。如果只显示时分秒忽略日期那 GPS 定位系统在 UTC 23 点以后会显示错误的时间到了凌晨会让人误判日期。一个简单的做法是只在显示层做时区转换底层数据继续存 UTC这样在计算时间间隔时不会出错。转换代码如下void GPS_UTC2Local(GPS_Info_t *info) { int hour info-hour 8; if (hour 24) { hour - 24; // 日期加一天的操作这里省略需要调用方处理 } info-hour hour; }提示NEO-6M 这类模块的 RMC 语句里带有日期字段如果做轨迹回放需要把日期也解析出来不能用 GGA 的时间强行推算因为 GGA 语句里没有日期信息跨天时日期无法自洽。4.3 主循环调度解析、刷新与串口输出互不阻塞主循环的逻辑不复杂但要注意别让 LCD 刷屏阻塞了解析流程。常见的做法是主循环每 100ms 检查一次缓冲区里有没有完整的一行 NMEA 语句有则校验并解析LCD 刷新频率控制在 1Hz 左右即可没必要更快因为 GPS 模块本身定位数据的输出频率一般也就是 1Hz 到 10Hz。int main(void) { char line_buf[128]; uint16_t line_len 0; GPS_USART_Config(); LCD_Init(); LCD_Clear(BLACK); while (1) { while (gps_rx_tail ! gps_rx_head) { uint8_t ch gps_rx_buf[gps_rx_tail]; gps_rx_tail (gps_rx_tail 1) % GPS_RX_BUF_SIZE; if (ch \n) { line_buf[line_len] \0; if (strncmp(line_buf, $GPGGA, 6) 0 GPS_CheckSum(line_buf)) { GPS_ParseGGA(line_buf); } line_len 0; } else if (ch ! \r line_len sizeof(line_buf) - 1) { line_buf[line_len] ch; } } if (g_gps_info.valid) { char display_str[32]; snprintf(display_str, sizeof(display_str), LAT:%.6f, g_gps_info.latitude); LCD_ShowString(0, 0, display_str, WHITE, BLACK); // 经度、卫星数、海拔同理 } else { LCD_ShowString(0, 0, No Fix..., WHITE, BLACK); } Delay_ms(100); } }主循环先检查环形缓冲区按行尾换行符切出完整的 NMEA 语句再判断语句类型和校验和通过后解析。LCD 显示和定位解析在时间上是解耦的即使显示有延迟也不影响数据采集。这样做的另一个好处是后续如果要加 SD 卡存储或者 4G 上传只需要在这段逻辑里增加相应处理函数不需要改动串口接收部分。5. 定位误差排查与高频问题定位从天线到配置参数的落地经验5.1 固定位置漂移的排查顺序GPS 设备放在固定位置坐标仍然在小范围漂移是正常现象一般民用 GPS 的精度在 2.5 米到 5 米之间。如果漂移范围达到几十米甚至上百米先别急着改代码按下面的顺序排查硬件天线位置是最常见的误差来源。无源陶瓷天线需要朝上放置不能贴在金属表面上也不能被外壳里的锂电池完全盖住。可以先到室外开阔处测试排除室内遮挡导致的定位质量下降。其次是电源纹波。GPS 模块对电源比较敏感如果和电机、蜂鸣器共用电源且没有滤波电容定位质量会周期性变差。在模块 VCC 和 GND 之间加一个 100uF 电解电容和一个 0.1uF 陶瓷电容往往比改代码更有效。5.2 gps 误差的时间维度观测时长与平均值滤波如果软件上要做误差处理常见做法是对连续有效的经纬度做滑动平均。注意要先把经纬度转成十进制度再做平均不要在度分格式里直接平均否则会出现进位错误。#define FILTER_NUM 10 float lat_buffer[FILTER_NUM]; float lon_buffer[FILTER_NUM]; uint8_t filter_idx 0; void GPS_Filter_Update(float lat, float lon, float *out_lat, float *out_lon) { lat_buffer[filter_idx] lat; lon_buffer[filter_idx] lon; filter_idx (filter_idx 1) % FILTER_NUM; float lat_sum 0, lon_sum 0; for (int i 0; i FILTER_NUM; i) { lat_sum lat_buffer[i]; lon_sum lon_buffer[i]; } *out_lat lat_sum / FILTER_NUM; *out_lon lon_sum / FILTER_NUM; }滑动平均的逻辑是维护一个固定长度的环形缓存每次新数据进来替换最老的数据然后对窗口内所有数据取平均。这个滤波器能明显降低静止状态下的坐标抖动但车辆运动时会产生滞后所以如果做的是移动载具定位设备窗口取 5 个点就够不要取到 20 个以上。5.3 搜不到星的快速检查命令模块上电后如果一直No Fix先用串口调试工具直接看模块原始输出。正常来说每秒至少有一条$GPGGA和一条$GPGSA。如果连原始数据都没有问题在硬件接线如果有数据但定位质量字段一直是 0说明天线收不到足够卫星需要挪位置或者换天线。可以发送$GPGSV相关的查询命令让模块输出可视卫星列表通过这条语句能看到上空卫星的分布情况。5.4 GPS 翻转补丁的兼容性说明部分老款 GPS 模块在 2019 年 4 月 6 日遇到 GPS 周数翻转问题导致日期回溯到 1999 年一些模块厂商发布了对应的固件补丁。如果你用的是库存较老的模块并且发现解析出的 UTC 日期在 1999 年附近那么大概率是模块固件没打翻转补丁需要在模块厂商官网找对应的固件升级工具。这个问题属于模块固件层面和 STM32 侧代码无关换模块后代码不需要改动。5.5 一个值得保留的调试技巧串口直通模式调试阶段建议在代码里保留一个直通功能把 GPS 模块原始 NMEA 数据原样从调试串口转发到电脑。这样既可以用串口助手观察 GPS 模块的实际输出又可以对比 STM32 解析后打印的数据快速定位是接收问题还是解析问题。做法很简单在 USART2 的中断里把收到的字节同时通过 USART1 发出去相当于做了一个串口桥。GPS 定位设计的大部分难题都是在这一层肉眼观察时找到突破口的直接从前面的例程代码里抽出接收中断里面加上一行USART_SendData(USART1, data);就能看到完整数据流。本文还有配套的精品资源点击获取
返回列表