ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 GPS定位实战:串口DMA接收与NMEA解析全解析

STM32 GPS定位实战:串口DMA接收与NMEA解析全解析

简介:一套基于 STM32 的 GPS 实时定位工程,面向嵌入式开发新手与物联网实践者,适用于车载导航、户外运动、设备追踪等场景,围绕 UART 外设通信、NMEA 协议解析和 OLED 实时显示展开。压缩包共 152 个文件、约 2.09MB,包含 37 个 h 头文件与 34 个 c 源码文件,以及 Keil 工程配置(uvprojx/uvoptx)、hex 固件、axf 调试文件、sct 链接脚本和 bat 辅助脚本,结构完整,可直接编译烧录。已有 7480 人学习下载。示例程序演示了 GPS 接收后经串口输出的完整流程,涵盖 GPGGA/GPGLL 语句解析、经纬度与速度提取、OLED 显示刷新等模块实现方式,配套 keilkilll.bat 还可一键清理中间文件,便于二次开发与学习。适合需要快速搭建定位设备、系统理解 STM32 串口中断与协议解析的开发者参考。

1. 直接用STM32做GPS定位:为什么说难点全在解析上

想给小车、骑行记录仪或某个传感器节点加定位功能,GPS是性价比最高的方案。一块常见的串口GPS模块上电后会自动往外吐数据,STM32用一根串口线就能读进来。反直觉的是,真正的难点不在硬件连接,而在怎么把一帧帧不断涌来的NMEA字符串切碎、校验、转成经纬度,再画到OLED上。这个项目正好覆盖了串口DMA接收、状态机分帧、字符串解析、OLED显示这几块嵌入式基本功,适合已经会点灯、想往应用层走一步的开发者。读完你可以照抄一套能用的定位显示工程,也能把它改成记录轨迹或上报数据。

2. 系统架构与硬件选型:从卫星信号到OLED像素的完整链路

2.1 数据链路:一块GPS模块到STM32的必经之路

GPS模块的核心作用不是“联网”,而是接收卫星信号,在模块内部完成解算后,把定位结果通过UART引脚输出。常用的UART GPS模块输出的就是NMEA 0183文本,默认波特率常见的是9600,输出频率一般是1Hz,即每秒一整套定位数据。模块外接天线的质量直接决定定位速度和抗遮挡能力,陶瓷贴片天线和有源天线在室内场景差别很大,买模块时尽量选支持外接有源天线的版本。

数据从天线到OLED屏幕,经过的环节我列一下:

  1. 卫星信号被天线接收,GPS模块内部射频前端放大,基带处理解算出经纬度、时间和速度。
  2. 模块通过TX引脚,按波特率9600、8数据位、1停止位、无校验的格式,把NMEA语句逐字节发出来。
  3. STM32的USART1接收引脚RX把串行电平收进移位寄存器,DMA控制器把寄存器里的字节自动搬到内存缓冲。
  4. 解析模块从缓冲中切出以$开头、\r\n结尾的完整行,再对$GPRMC和$GPGGA做字段切分。
  5. 切分出来的字符串转成double经纬度,写入全局gps_info_t结构体。
  6. 主循环检测到新的定位数据后,把结构体内容格式化成字符串,通过OLED的I2C接口画到屏幕上。

从第3步开始就是嵌入式软件要管的事。为什么接收必须用DMA:GPS以1Hz或10Hz连续输出,一帧GPRMC约70字节,10Hz时每秒接近1KB数据流。如果每个字节都触发串口中断,CPU在中断里做拷贝,只要拷贝速度赶不上数据到达速度,USART的ORE溢出标志就会置位,后续字节全丢。DMA的意义在于让外设直接往内存写数据,CPU只在DMA到达半满或全满时处理一次,省出来的CPU时间留给解析和显示。

提示:GPS模块的TX必须接STM32的RX,两个模块的TX不能对接。接反了表现就是完全无数据,排查时第一反应往往是查这根线。

2.2 硬件选型与引脚分配,避开的两个坑

围绕这个项目我用的是一套很常规的组合:STM32F103C8T6最小系统板、UART输出的GPS模块(带陶瓷天线)、0.96寸SSD1306 OLED屏。三个器件加起来成本不高,但覆盖了串口、DMA、I2C、中断这几类嵌入式核心外设。如果手上是其他型号的STM32也没关系,只要串口外设支持DMA接收就能照搬这套思路。

引脚分配表:

STM32引脚外设功能连接目标说明
PA9USART1_TXGPS模块RX发送配置命令用,平时可以不用
PA10USART1_RXGPS模块TXGPS数据入口,必须接
PB6I2C1_SCLOLED SCLOLED时钟线
PB7I2C1_SDAOLED SDAOLED数据线
3.3V电源OLED VCCOLED供电
5V/3.3V电源GPS模块VCC根据模块是否带板载稳压选择
GND电源GPS/OLED GND所有地线共地

接线时有两个坑必须注意。第一是供电电压:带板载稳压的模块可以直接接5V,省事;没有板载稳压的裸模块,VCC过高会发热甚至永久损坏,老老实实用3.3V。第二是电平匹配:GPS模块TX通常是3.3V,对STM32是安全的;而STM32的TX是3.3V,如果模块IO是5V且没有电平转换,通信一时能用但长期运行不稳,建议在RX线上串一个1k电阻分压。陶瓷天线要朝上,尽量避开金属面,模块的PPS指示灯在固定后会规律闪烁,这个灯是调试时最直观的状态信号。

我一般会先在电脑上用USB转TTL模块单独验证GPS模块,串口助手设置9600,确认能看到$GPRMC输出后再接STM32。这个习惯能排除“模块本身没输出”和“波特率被改过”两个最常见的干扰,不然ST端代码写完了,问题其实出在模块侧,容易白调一晚上。

2.3 NMEA 0183:解析前必须懂的报文结构

NMEA 0183报文用ASCII文本传输,每行以$开头,以\r\n结尾,字段用逗号分隔。做定位显示项目不需要认识全部语句,但要重点搞明白两类:

  • $GPRMC:推荐最小定位信息,包含UTC时间、定位有效标志A/V、纬度、经度、速度、航向、日期。经纬度显示和速度显示全靠它。
  • $GPGGA:全球定位系统固定数据,包含定位质量、卫星数、海拔。卫星数显示和海拔显示用它。

打开串口助手看到的一条典型RMC语句长这样:

$GPRMC,083559.00,A,2236.5488,N,11356.5488,E,0.00,0.00,010419,,,A*7C

按逗号依次拆开,第2个字段083559.00是UTC时间08:35:59,第3个字段A表示定位有效,第4和第6个字段分别是纬度和经度,但要注意它们的格式是“度分”而不是十进制度:2236.5488表示22度36.5488分,转成十进制度要除以60换算。第5和第7字段的N/E表示北纬和东经;第8字段是速度,单位是节,1节等于1.852公里每小时。行尾的7C是校验和,把$和之间所有字符做异或得到0x7C。

两条语句都要注意一个兼容点:现在的多模模块输出语句前缀不一定是GP,可能是GN,比如$GNRMC、$GNGGA。如果解析代码用strncmp精确匹配$GPRMC开头,新的模块就会一路漏光所有数据。所以我写的解析函数都用子串匹配RMC,而不是匹配$GPRMC,这个细节在后面的避坑章节还会再遇到。

校验和的计算逻辑很简单,模块发出的每一帧最后两位就是校验值,正确的解析器会先算一次校验,过滤掉校验错误的帧。实际项目里很多同学不校验直接解析也能跑,因为GPS数据出错概率低,一旦出错反映为某字段乱跳。我的建议是调试阶段先不校验,把功能跑通,稳定后再补校验逻辑,避免一开始就面对两个未知变量。

3. 核心代码拆解:串口DMA接收、NMEA解析与OLED渲染的逐段实现

3.1 串口与DMA初始化:让GPS数据自动进入内存

GPS模块上电即输出,不需要发送配置命令也能拿到数据,所以工程里的USART1初始化只需要三件事:设波特率9600、使能接收DMA、打开空闲中断。需要注意GPS模块输出电平是3.3V,跟STM32的IO电平匹配,可以直接连接。

void GPS_UART_Init(void) { USART_InitTypeDef usart; GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_7); // PA9 -> USART1_TX GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_7); // PA10 -> USART1_RX gpio.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_AF; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = 9600; // 与GPS模块默认输出一致 usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); }

这段代码用的是标准外设库风格,CubeMX生成的代码结构略有不同但配置思路一致。USART_BaudRate设为9600是因为大多数GPS模块默认就是这个值;如果你在串口助手里确认模块是38400,这里就改成38400。USART_IT_IDLE空闲中断是串口一帧数据结束后触发一次中断,用它配合DMA可以做到“一整条数据到了再去处理”,效率比每字节中断高很多。

接下来是DMA通道配置,把USART1接收到的数据自动搬运到内存缓冲:

#define GPS_RX_BUF_SIZE 256 static uint8_t gps_rx_buf[GPS_RX_BUF_SIZE]; void GPS_DMA_Init(void) { DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)gps_rx_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = GPS_RX_BUF_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Circular; // 环形模式避免手动复位DMA dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &dma); // 通道5对应USART1_RX DMA_ITConfig(DMA1_Channel5, DMA_IT_TC | DMA_IT_HT, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE); }

DMA_Mode_Circular是关键参数:DMA传输完整个缓冲后会自动把写入地址绕回开头,不需要在中断里重新配置传输长度,适合GPS这种持续不断的数据流。DMA_IT_TC和DMA_IT_HT分别是传输完成和传输一半中断,我在两个中断里各计算一次“本次批量到达的字节数”,按顺序写入一个更大的应用缓冲。这样解析模块看到的始终是连续的数据流,不会因为DMA绕回而漏掉两个批次之间的数据。

对应的中断处理:

void DMA1_Channel5_IRQHandler(void) { static uint16_t last_pos = 0; if (DMA_GetITStatus(DMA1_IT_TC5)) { uint16_t current = GPS_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); GPS_FeedData(&gps_rx_buf[last_pos], current - last_pos); last_pos = current; DMA_ClearITPendingBit(DMA1_IT_TC5); } // HT中断处理逻辑相同,只是last_pos从0开始 }

current计算的是DMA当前写到了缓冲的哪个位置,用缓冲总大小减去剩余计数器就得出来。GPS_FeedData把这段新数据复制到解析层自己的缓冲区中,并标记“有数据待处理”。参数里需要注意,环形DMA下last_pos要记得绕回,当current小于last_pos时说明DMA已经回卷,这时候要分段拷贝。

3.2 NMEA解析实现:从字节流里切出完整行

DMA把数据接进来之后,解析层要解决两个问题:一是从字节流里切出以$开头、\r\n结尾的完整行;二是把行里的字段按逗号拆出来。我做解析时习惯把这两个问题分开,先写一个分帧函数,把所有拿到的字节喂进去,它负责把完整行挑出来交给下一个函数。

typedef struct { char line_buf[128]; uint16_t line_len; uint8_t in_frame; } nmea_parser_t; static nmea_parser_t g_parser; void GPS_FeedData(uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uint8_t c = data[i]; if (c == '$') { g_parser.in_frame = 1; g_parser.line_len = 0; } if (g_parser.in_frame) { if (g_parser.line_len < 127) { g_parser.line_buf[g_parser.line_len++] = c; } } if (c == '\n' && g_parser.in_frame) { g_parser.line_buf[g_parser.line_len - 1] = '\0'; // 去掉\r g_parser.in_frame = 0; GPS_ParseLine(g_parser.line_buf); // 完整的NMEA行 } } }

这段分帧逻辑的思路是:见到$说明新帧开始,清空行缓冲重新累计;见到换行符说明一帧结束,去掉结尾的\r后把整行交给解析函数。保险系数比较高的写法是同时记录帧内字节数,超过127字节就强制丢弃,防止异常数据把缓冲撑爆。实际调试时如果发现解析出来的字段乱,第一件事是打开debug串口把line_buf原样打印出来,看切帧有没有切对。

接下来是字段解析。很多同学第一反应是用strtok按逗号切,但strtok会修改原字符串且不可重入,在单线程工程里虽然能用,却容易在后续扩展时埋雷。我更推荐只用sscanf配合%[^,]格式,一次性把需要的字段都拿干净:

int GPS_ParseLine(char *line) { if (strstr(line, "RMC") == NULL) { return 0; // 只处理RMC语句,兼容$GPRMC和$GNRMC } gps_info_t *info = &g_gps_info; char utc_time[16], lat_str[16], lon_str[16], speed_str[8], date_str[8]; char status, lat_dir, lon_dir; int matched = sscanf(line, "$%*[^,],%[^,],%c,%[^,],%c,%[^,],%c,%[^,],%[^,],%[^,]", utc_time, &status, lat_str, &lat_dir, lon_str, &lon_dir, speed_str, date_str); if (matched < 8) { return 0; } info->time_hh = (utc_time[0] - '0') * 10 + (utc_time[1] - '0'); info->time_mm = (utc_time[2] - '0') * 10 + (utc_time[3] - '0'); info->time_ss = (utc_time[4] - '0') * 10 + (utc_time[5] - '0'); info->status = status; // 'A' 有效, 'V' 无效 info->lat = GPS_ConvertDegMin(lat_str); // 转十进制度 info->lon = GPS_ConvertDegMin(lon_str); info->speed_knot = atof(speed_str); // 节,1节=1.852km/h if (lat_dir == 'S') info->lat = -info->lat; if (lon_dir == 'W') info->lon = -info->lon; info->valid = (status == 'A'); return 1; }

sscanf的格式串里,$%*[^,]用来跳过以$开头的语句类型字段,后面的%[^,]按逗号分隔依次捕获UTC时间、定位状态、纬度字符串等。注意返回值的判断别太严格:如果模块输出的字段数不完全一致,matched >= 8基本就够用了,再往上加会偶发漏解析。

度分转换函数单独写,便于单测:

double GPS_ConvertDegMin(const char *dm_str) { char degree_str[4] = {0}; double minute; const char *dot_pos; // 找到小数点位置,小数点前的全部字符是"度" dot_pos = strchr(dm_str, '.'); if (dot_pos == NULL) { return atof(dm_str); } memcpy(degree_str, dm_str, dot_pos - dm_str); minute = atof(dot_pos); return atoi(degree_str) + minute / 60.0; }

这里有一个很容易被忽略的细节:纬度范围是0-90,度占两位;经度范围0-180,度可能占三位。如果写死取前两位做度,在经度上会出错,所以我按小数点位置来切:找到小数点在字符串中的位置,小数点前是度,小数点后是分。这样纬度、经度即使模块输出的位数有变化,转换结果也稳定。

3.3 OLED显示:刷新策略与页面布局

显示部分用的是SSD1306,0.96寸128x64。驱动源码网上很多,这里只讲和GPS数据绑定的逻辑。OLED初始化后,我做了一个独立的显示函数,按“状态栏、经纬度、速度”三块来排。

static void OLED_ShowGPSPage(gps_info_t *gps, uint8_t sat_num) { char buf[24]; OLED_ClearBuffer(); if (gps->status == 'A') { OLED_DrawLine(0, 12, 128, 12); // 分隔线 sprintf(buf, "LAT %08.5f", gps->lat); OLED_ShowString(4, 18, buf); sprintf(buf, "LON %08.5f", gps->lon); OLED_ShowString(4, 30, buf); sprintf(buf, "SPD %5.1f km/h", gps->speed_knot * 1.852f); OLED_ShowString(4, 42, buf); } else { OLED_ShowString(4, 0, "GPS WAIT FIX"); OLED_ShowString(4, 16, "NO SATELLITE"); OLED_ShowString(4, 32, "KEEP ANTENNA"); OLED_ShowString(4, 48, "FACING SKY"); } OLED_SendBuffer(); }

这里的关键不是驱动代码本身,而是刷新策略。GPS数据1Hz更新时,如果直接用OLED_ClearBuffer加OLED_SendBuffer全刷,128x64的缓冲一次要写1024字节,I2C在400kHz下大概要几毫秒,肉眼可见的闪烁还伴随I2C总线占用,拖慢其他外设。所以我把页面拆成几个区域,状态区固定,经纬度区整块变化,速度区整块变化。每次刷新只重画变化区域,固定元素比如分隔线不重画。这样显示稳定很多。

OLED刷新放在主循环里通过数据有效标志触发,而不是在解析函数里直接调用。GPS解析和显示属于不同优先级,解析中断里只更新结构体和标志位,主循环检测到标志变化才去驱动I2C,避免在中断上下文里做耗时操作。

3.4 主循环状态机

int main(void) { SystemInit(); OLED_Init(); GPS_UART_Init(); GPS_DMA_Init(); while (1) { if (g_gps_info.valid) { static uint32_t last_refresh = 0; if (HAL_GetTick() - last_refresh > 500) { OLED_ShowGPSPage(&g_gps_info, g_sat_num); last_refresh = HAL_GetTick(); } } } }

主循环的状态很简:有有效定位数据时,每500毫秒刷新一次OLED。真正常见的改法是给这个循环加一个串口透传,把原始NMEA帧通过USART2打印到电脑,方便调试时对照解析结果。有条件的话还可以加一个“假数据注入”调试入口,在PC上用串口助手手动发一条写死的GPRMC语句,验证解析器逻辑,不用等到卫星信号稳定。

4. 工程结构梳理:一份能编译能下地的GPS项目应该怎么组织

4.1 目录结构与文件职责

网上找STM32 GPS例程,最常见的问题是所有代码堆在main.c里,一个文件一千多行,改起来头大。我的习惯是把项目拆成三层,驱动层管硬件寄存器、应用层管解析和UI、入口层只做初始化和主循环:

路径文件职责
Core/Srcmain.c系统时钟、各模块初始化、主循环
Appgps_parser.c / gps_parser.hNMEA分帧、字段提取、度分转换
Appdisplay.c / display.hOLED页面布局、刷新控制
Drivers/SSD1306oled.c / oled.hSSD1306底层驱动、ASCII字模
Drivers/GPSTypegps_info.h全局数据结构体定义
Bspbsp_uart.cUSART1+DMA初始化、中断处理

gps_info.h里定义核心结构体:

typedef struct { uint8_t time_hh; uint8_t time_mm; uint8_t time_ss; char status; // 'A' 有效 / 'V' 无效 double lat; double lon; float speed_knot; uint8_t valid; } gps_info_t;

这套划分的好处是,以后想切换显示方式,比如换成TFT屏,只需要替换display.c内容,解析层不用动;想升级成双模GPS模块,改的也只是解析函数里对语句前缀的判断,其他文件零改动。如果你用的是CubeMX生成工程,也可以把App和Bsp两层直接挂进去,CubeMX负责管引脚和时钟,App目录只管业务逻辑,两个工具各管一摊,冲突最少。

4.2 函数调用关系

从复位到画面出来,实际执行顺序是这样的:

系统时钟初始化 → OLED_Init初始化I2C和SSD1306寄存器 → GPS_UART_Init配置串口 → GPS_DMA_Init开启DMA搬运 → 进入while主循环。此后系统处于完全被动状态:DMA把数据搬进gps_rx_buf,空闲中断触发后调用GPS_FeedData做分帧,分帧完成调用GPS_ParseLine更新全局结构体,主循环发现valid字段更新后调用OLED_ShowGPSPage刷新屏幕。

这里有一个容易被忽略的设计点:解析和数据搬运的职责边界。中断里只做数据和标志位的搬移,不碰I2C、不做耗时计算,保证GPS数据以10Hz连续进来时不会被处理逻辑阻塞。主循环做显示这类“慢操作”,就算I2C卡了一下,也只是显示慢半拍,不会丢GPS数据。

4.3 缓冲区规划建议

踩过内存坑之后我要提醒一句:DMA缓冲、分帧缓冲、SSD1306相关缓冲加起来会占掉不少RAM,F103C8T6只有20KB SRAM,要精打细算。我常用的分配方案:

内存区域大小说明
DMA接收缓冲256字节环形模式,只存原始字节
NMEA行缓冲128字节一帧RMC语句约70字节,够用
gps_info_t几十字节全局结构体,存解析结果
SSD1306显示缓冲1KBI2C逐页发送时可不分配完整帧缓冲

如果后续加SD卡写轨迹,还要预留FATFS的FAT表空间,建议先把GPS相关几块内存统计出来,不要等编译报RAM溢出再回头砍代码。F103在开优化后Flash也紧张,字符串字模尽量只保留用到的部分,中文字模能不塞就不塞。

4.4 快速验证工程完整性的自测清单

工程烧录后按这个顺序自查,能快速定位问题出在哪一层:

  1. OLED是否亮起并显示WAIT FIX等默认文案。不亮先查I2C地址和电源,别碰GPS。
  2. 打开USART2透传,确认串口助手能看到原始NMEA语句。看不到说明GPS模块接线、供电或波特率有问题。
  3. 把串口助手收到的原始NMEA和解析后打印的数据对比,确认时间、经纬度、速度对上。
  4. 挪动天线位置,观察状态位是否从V变成A,经纬度是否有小幅变化。

这套自测顺序的好处是每步只验证一个变量,屏幕、模块、解析、显示依次解锁,不会出现四个环节同时出问题却不知道从哪查起的情况。

5. 避坑指南:GPS模块接上STM32后的常见翻车现场

5.1 串口有乱码或完全没有数据

现象:用串口调试工具连接模块,输出一堆乱码,或者干脆没反应。

原因来自两个地方:波特率不匹配,或TX/RX接反了。GPS模块出厂默认波特率大多是9600,但二手模块或之前被调试过的模块可能存成了38400甚至115200的配置,直接按9600初始化自然全是乱码;有些模块在无信号时会静默,看起来像坏了。

解决:先用USB转TTL在电脑上单独验证模块,串口助手从9600开始,逐个试38400和115200,直到看到稳定输出的$GPRMC语句。确认波特率后,再接STM32,同时确保模块TX接STM32的RX,模块RX接STM32的TX,GND务必相连。我一般会先把串口助手跑通这一步再做STM32端的联调,能省掉大量排查时间。

5.2 数据能收到,但定位状态一直是V

现象:OLED或串口里有NMEA数据,但状态位始终是V,经纬度不更新。

原因:模块在室内,卫星信号被天花板和墙体削弱,尤其陶瓷天线方向性较明显,放在机箱里或贴在桌面上几乎收不到有效信号。另一个隐蔽原因是,如果买的是带外部有源天线版本,模块的供电没有正确给天线馈电,天线就只是个摆设。

解决:把天线贴到窗边或室外,模块上电后先观察PPS秒灯——有效定位后秒灯会稳定每秒闪一次,没定位前是间歇闪。冷启动首次定位经常要30秒到两分钟,不要上电10秒没数据就怀疑代码坏了。如果模块上有备用电池,冷启动时间会更短,但注意存放久了电池会耗空,首次定位仍然要久一点。

5.3 解析代码能跑但位置不更新,或者经纬度明显异常

现象:程序跑起来,OLED显示一串固定数值,跟实际位置差很离谱;或者明明在移动,速度始终是0。

原因:大概率解析错了语句。很多例程只写$GPRMC匹配,而现在多模模块输出的是$GNRMC,前缀不同直接漏掉全部数据;还有的模块输出顺序特殊,第一条RMC是无效定位V,后续才是有效定位,解析器如果处理完第一条就直接break,就会永远用无效数据覆盖有效数据。

解决:匹配语句时用strstr(line, "RMC")而不是strncmp(line, "$GPRMC", 6);每条有效定位数据都要看status字段,解析完成后只有status == 'A'才能更新结构体,V状态来了要跳过而不是覆盖。另外建议打开透传,把原始NMEA行打印出来跟自己写的解析结果对一遍,很多问题对着原始数据就能看出来。

5.4 OLED初始化失败或显示乱码

现象:OLED黑屏、花屏,或者明明有数据却显示满屏雪花。

原因:I2C地址不对,SSD1306有0x3C和0x3D两种地址,默认0x3C但部分定制屏是0x3D;另一个原因是I2C时钟配置过高,SSD1306在400kHz以上的I2C时序下会随机丢字节。

解决:先用I2C扫描函数打印设备地址,确认屏幕地址后再决定OLED_Init里往哪个地址写。时钟配置I2C速度选100kHz到400kHz之间,别图快直接拉满。如果还花屏,检查一下OLED复位引脚有没有正确接高电平,很多驱动库默认不复位,但部分屏必须拉高RES才能正常初始化。

6. 二次开发思路:从显示定位到轨迹记录与数据上报的三种改法

6.1 方向一:SD卡记录轨迹

解析器已经把经纬度、时间、速度都整理成了gps_info_t结构体,要记录轨迹只需要做一件事:把有效定位数据按固定格式写入SD卡。常见做法是SPI接口接一个MicroSD模块,文件系统用FatFs,每次定位有效时往CSV文件里追加一行,包含时间、纬度、经度、速度。CSV的好处是后续可以直接导入地图软件或数据处理脚本。注意写入要防频繁擦写,SD卡在GPS以1Hz更新时每秒一次写入虽然不多,但建议积攒几秒再flush一次,延长寿命。

6.2 方向二:把定位数据上报,做一个简单的位置节点

如果不想用屏幕,可以把OLED换成可选模块,把串口腾出来接ESP8266或其他无线模块。我在类似项目里一般是把解析结果拼成一条自定义短帧,通过USART2输出。数据格式越短越好,GPS模块本身输出的NMEA语句冗长,二次封装成类似"$LOC,时间,纬度,经度,速度"的自定义格式,在弱网环境下传输效率高,下游解析也简单。

6.3 方向三:把UTC时间转成北京时间做RTC校准源

GPS模块输出的UTC时间精度很高,很多设备把GPS当授时源。改造时把解析出的time_hh、time_mm、time_ss直接加8小时,注意跨天要进位,然后写入STM32内部RTC。这个改法在现有代码里工作量最小,但价值很大,适合做有时间戳需求的采集设备。

验证方法要提一下:改动解析代码后,先用串口助手回放一段真实的NMEA日志,确认解析结果和日志一致,再拿到户外实测。我从那次在某个项目里把V状态的无效定位直接写进轨迹、拉出一屏乱点之后,就养成习惯,每次改解析代码都强制过一遍“离线NMEA回放”流程,确认A/V判断、经纬度符号、单位换算都在,再上真机。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进