简介面向物联网、车载导航与户外运动设备等应用场景这份资料提供了一套基于STM32微控制器的GPS定位系统开发方案。内容以STM32与GPS模块的串口通信、NMEA协议解析及OLED实时显示为核心适合正在学习嵌入式定位技术的中初级开发者。压缩包共152个文件大小仅2.09MB其中以C语言源程序、头文件、Keil工程文件、编译生成的HEX烧录文件为主另有AXF调试文件、工程备份及辅助脚本等结构上区分了标准外设库与用户应用代码便于直接导入工程或按需引用。资料中包含了“GPS解析后串口输出”的示例程序完整演示了串口初始化、GPS数据接收、NMEA语句拆分以及经纬度、速度、时间等信息的提取与格式化输出并涉及OLED屏的驱动与刷新控制可帮助开发者理解从硬件配置到上层显示的一整套流程。已有7480人学习下载经过众多学习者实践检验适合作为STM32GPS项目开发的起步模板和参考设计。1. STM32 接 GPS 再点 OLED这份工程资源把定位显示链路一次性打通把 STM32、GPS 模块和一块 0.96 寸 OLED 拼在一起做成实时定位显示器这个需求在课程设计和毕设里很常见也是很多人第一次接触 NMEA 协议时最容易翻车的地方。这份工程资源的核心是从串口收到的$GPGGA文本出发一路拆到 OLED 屏上显示经纬度、速度和卫星数。它把底层串口接收、NMEA 解析、屏幕刷新的完整链路拆开讲还带可直接烧录的工程代码。适合正在做 GPS 定位项目的开发者也适合刚学会串口但不知道怎么做数据解析的新手。2. 硬件选型与接线为什么 GPS 走串口、OLED 走 I2C2.1 模块选型串口 GPS 模组 I2C OLED省引脚也省心GPS 模块的选择逻辑其实很简单绝大多数市面常见的串口 GPS 模组上电后就会以固定波特率持续输出 NMEA 0183 格式的文本流。这个输出天然就是串口数据所以 STM32 这边只需要一组 USART 就能把数据接进来不需要额外写复杂的驱动时序。OLED 我建议优先选 SSD1306 控制的 0.96 寸 I2C 接口屏而不是 SPI 版本。原因有两个一是 I2C 只要两根线SCL、SDASPI 版本至少占用 SCK、MOSI、DC、CS 四根线有些还要 RST二是定位显示这种场景刷新量很小128x64 单色屏每秒更新一次I2C 的 400K 速率完全够用不会出现刷新瓶颈。显示内容方面这类屏幕能稳定显示四到六行文字。我一般把第一行留给日期时间第二行纬度第三行经度第四行速度和卫星数。如果要显示中文需要准备好字模工程里带的是 ASCII 字库英文数字显示足够清晰16x16 中文点阵会占掉四行中的两行信息密度反而下降。2.2 接线对照与电源注意接线是整个项目里最不该出错但最容易出错的一步。GPS 模块输出的是 TTL 电平直接和 STM32 的 USART1 相连即可。OLED 的 I2C 地址默认是 0x3C很多模块背面标注的是写地址 0x78这两个数字经常把人绕晕后面避坑章节会专门展开。功能GPS 模块引脚STM32 引脚OLED 引脚说明电源VCC3.3VVCC共用 3.3V 电源轨地GNDGNDGND必须共地串口发送 TXPA10USART1_RX—GPS TX 接 STM32 RX串口接收 RXPA9USART1_TX—GPS RX 接 STM32 TXI2C 时钟—PB6I2C1_SCLSCL4.7K 上拉可复用板载I2C 数据—PB7I2C1_SDASDA4.7K 上拉可复用板载电源这里有个容易被忽略的细节GPS 模块在搜星瞬间电流会比平时高一截部分国产模组峰值能到 80mA 左右。如果直接用 STM32 的 3.3V LDO 供电电压跌落可能导致模块重启或者输出乱码。常见做法是给 GPS 单独供电或者把模块电源接到外部 3.3V 稳压输出上和 MCU 的电源轨分开走线。OLED 功耗低一些但也要避免和 GPS 共用同一根细杜邦线串联电阻造成的压降会在搜星瞬间被放大成复位故障。2.3 串口初始化与开机自检先确认数据有没有进来工程里用的 HAL 库串口初始化配置一版如下。波特率的设置必须和 GPS 模块规格一致9600 和 115200 都是常见值先查清楚模块默认值再改代码。void MY_USART1_Init(uint32_t baudrate) { /* 使能 GPIOA 和 USART1 时钟 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Alternate GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, gpio); huart1.Instance USART1; huart1.Init.BaudRate baudrate; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); }这段配置把 PA9、PA10 复用为 USART1 的 TX、RX8 位数据位、无校验、1 位停止位是 NMEA 串口最标准的参数组合。波特率参数单独拎出来是因为不同模块默认值差异很大初始化后最好做一个自检。自检逻辑不复杂上电后 GPS 模块只要有电就会持续往外吐$GPGGA、$GPRMC这类语句哪怕是没定位成功也会有带空字段的帧。所以开机后先等一两秒然后从接收缓冲区里找有没有$GP前缀的字符串。找到说明串口链路通了找不到问题大概率出在波特率或 TX/RX 接反。int GPS_SelfCheck(void) { uint32_t start HAL_GetTick(); uint16_t len 0; while (HAL_GetTick() - start 2000) { if (RingBuf_GetLine(rx_buf, temp_line, len)) { if (strncmp(temp_line, $GP, 3) 0) { return 1; /* 串口链路正常 */ } } } return 0; /* 两秒内没收到 NMEA 帧 */ }这里RingBuf_GetLine从环形缓冲区里按行取出字符串判断是否以$GP开头。两秒的窗口足够覆盖 GPS 模块的典型输出周期因为模组普遍是每秒发一帧完整数据。如果自检失败先别急着调解析代码大概率是硬件参数问题。3. 数据链路与 NMEA 解析从一串字符到经纬度3.1 串口接收中断里只做搬运不做解析NMEA 语句每帧最长 82 字节左右波特率 9600 时一帧传输时间接近 90ms115200 时约 7ms。如果用阻塞式的HAL_UART_Receive在主循环里等数据CPU 会被拖死而且 GPS 数据是持续流必须靠中断或 DMA 在后台接收。工程里的做法是初始化时调用HAL_UART_Receive_IT让每个字节进来都触发中断回调回调里只做一件事把数据塞进环形缓冲区。解析逻辑放到主循环里从缓冲区读完整行再处理。这样中断执行时间极短不会影响系统其他任务。#define RING_BUF_SIZE 256 typedef struct { uint8_t buf[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuf; void RingBuf_Push(RingBuf *rb, uint8_t data) { uint16_t next (rb-head 1) % RING_BUF_SIZE; if (next ! rb-tail) { /* 留一格判断满 */ rb-buf[rb-head] data; rb-head next; } /* 满了就丢弃新数据保证读写指针不追尾 */ } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { RingBuf_Push(rx_buf, rx_data_byte); HAL_UART_Receive_IT(huart, rx_data_byte, 1); } }环形缓冲区大小 256是因为一帧 NMEA 最长也就 82 字节留三帧余量足够。head和tail都加了volatile防止编译器优化导致读写不一致。缓冲满时直接丢新数据虽然会丢帧但能保证读写指针永远不会越界。GPS 数据每秒都有新帧丢一帧影响不大缓冲区永远不会因为满而卡死。这个设计把这层串口接收变成黑匣子上层只关心有没有完整行可读不关心时序。3.2 GGA 帧解析字段位置、校验和定位质量NMEA 0183 协议里我只关注两帧$GPGGA和$GPRMC。GGA 帧包含经纬度、定位质量、卫星数和海拔高度RMC 帧包含经纬度、速度、日期和有效状态。两帧都带相同的经纬度数据但 GGA 没有速度RMC 没有卫星数所以两个都解析最稳妥。GGA 帧的典型格式如下字段示例含义帧头$GPGGA全球定位系统定位数据1123519UTC 时间 12:35:1924807.038纬度度分格式3N北纬401131.000经度度分格式5E东经61定位质量1 表示 GPS 定位708可见卫星数81.0水平精度因子 HDOP关键是第 2 和第 4 字段它们不是十进制度数而是度分格式4807.038表示 48 度 07.038 分。这个格式如果直接当小数度用误差会放大到几十公里后面会专门讲转换。校验规则是$和*之间的所有字符按位异或得到的结果转成两位十六进制 ASCII放在*后面。解析时先把校验算了算不过直接丢帧能从源头过滤掉大部分串口乱码。typedef struct { float latitude; float longitude; uint8_t quality; uint8_t sat_num; float altitude; uint8_t hour, minute, second; } GpsData; int GPS_ParseGGA(char *line, GpsData *data) { char *p line 1; /* 跳过 $ */ char chk_str[3] {0}; uint8_t calc 0; for (char *q p; *q ! * *q ! \0; q) { calc ^ (uint8_t)(*q); } char *star strchr(line, *); if (star NULL) return 0; chk_str[0] *(star 1); chk_str[1] *(star 2); if (strtoul(chk_str, NULL, 16) ! calc) return 0; char *token strtok(line, ,*); int field 0; float lat_deg 0.0f, lon_deg 0.0f; while (token ! NULL) { switch (field) { case 1: /* UTC 时间 hhmmss.sss */ >int GPS_ParseRMC(char *line, GpsData *data) { char *token strtok(line, ,*); int field 0; while (token ! NULL) { if (field 2 token[0] V) return 0; /* 无效数据 */ if (field 7) { >void OLED_Init(void) { HAL_Delay(100); /* 上电稳定时间 */ uint8_t cmd[] { 0xAE, /* 关闭显示 */ 0x20, 0x00, /* 水平寻址模式 */ 0xB0, /* 第 0 页起始 */ 0xC8, /* 扫描方向从上到下 */ 0x40, /* 显示起始行 0 */ 0x81, 0x7F, /* 对比度数值可调 */ 0xA1, /* 段重映射保证左右正确 */ 0xA6, /* 正常显示非反显 */ 0xA8, 0x3F, /* 复用率 1/64 */ 0xD3, 0x00, /* 显示偏移 0 */ 0xD5, 0x80, /* 时钟分频 */ 0xD9, 0x22, /* 预充电周期 */ 0xDA, 0x12, /* 引脚配置 */ 0xDB, 0x20, /* VCOMH 电压 */ 0x8D, 0x14, /* 电荷泵开启 */ 0xAF /* 开启显示 */ }; OLED_WriteCmd(cmd, sizeof(cmd)); }这段初始化序列是 SSD1306 的标准配置其中最关键的是0x8D, 0x14这个电荷泵开启命令漏掉它屏幕永远是黑的因为内部升压电路没工作。以0xC8和0xA1的组合决定了画面方向如果显示出来左右颠倒或上下颠倒改这两个参数即可不用动硬件。字符绘制采用 6x8 点阵字模一个字符占 6 字节每字节对应一列的 8 个像素。写显示函数时先计算字符在显存中的页地址和列地址再调用OLED_WriteData一次性写入。void OLED_ShowString(uint8_t page, uint8_t col, char *str) { while (*str) { for (uint8_t i 0; i 6; i) { uint8_t data font_6x8[(*str - ) * 6 i]; OLED_SetPos(page, col i); OLED_WriteData(data, 1); } col 6; str; } }page范围是 0 到 7对应屏幕从上到下的 8 个页面每页 8 像素高。font_6x8是 ASCII 字模数组字符取模时低位在上所以*str - 计算出该字符在字体表中的偏移每个字符占 6 字节。这里对字符串里的每个字符循环写列地址写完一个字符列坐标加 6布局逻辑清楚。4.2 显示布局与刷新的坑屏幕总共 8 页、128 列。如果每行用 2 页高度显示 16 像素大字只能放 4 行如果每行只占 1 页最多 8 行。定位数据是数字为主字体大一点更清晰。我一般按下面的布局分配显示行页范围内容第一行页 0~1北京时间 HH:MM:SS第二行页 2~3纬度 N/S第三行页 4~5经度 E/W第四行页 6~7速度 km/h 卫星数刷新策略是个容易翻车的地方。新手常犯的错误是每秒把整屏 1024 字节全部重写一遍I2C 速率不够时画面会闪烁而且屏幕会像流水一样从左上角刷到右下角。常见做法是先比较新旧显示内容只有变化的部分才写。typedef struct { char time_str[12]; char lat_str[20]; char lon_str[20]; char status_str[20]; } DisplayData; void OLED_UpdateDisplay(DisplayData *dd) { static DisplayData prev; if (strcmp(dd-lat_str, prev.lat_str) ! 0) { OLED_ClearArea(2, 0, 16, 128); /* 清第二行区域 */ OLED_ShowString(2, 0, dd-lat_str); /* 重绘第二行 */ strcpy(prev.lat_str, dd-lat_str); } /* 经度、速度行同理 */ if (strcmp(dd-time_str, prev.time_str) ! 0) { OLED_ClearArea(0, 0, 16, 128); OLED_ShowString(0, 0, dd-time_str); strcpy(prev.time_str, dd-time_str); } }OLED_ClearArea只把指定区域清零不会动其他页。这里用静态变量保存上一次显示内容只有字符串变化才重画对应区域。GPS 一秒更新一次每秒最多重画两三个区域屏幕刷新负担很小肉眼看不到闪烁。静态变量在函数内声明要注意多个显示项共用时各自保存自己的上一帧内容结构上更清晰但如果逻辑分支多这种写法容易漏更新我会把prev提成全局结构体来维护。5. STM32 GPS 调试避坑五个高发问题复盘5.1 室内怎么都搜不到星现象把 GPS 模块放在桌面OLED 上卫星数一直是 0等了五分钟还是没定位成功。拿到窗边一两分钟内出 3 颗星。原因GPS 信号是 1.5GHz 左右的微波信号穿透能力差室内衰减非常明显。模块自带的陶瓷天线是贴片式对方向也敏感。解决开发调试阶段务必把天线放在窗台或阳台陶瓷面朝上远离金属物体。如果你们实验室在楼层中间直接测试会浪费大量时间我通常先用串口工具在窗边确认信号再回室内做代码调试。另一点很关键冷启动首次定位需要时间常见模组的 TTFF 在 30 秒到 2 分钟之间不是接上电就有定位耐心等一分钟再判断。5.2 串口出来全是乱码或直接没数据现象自检函数一直返回失败用逻辑分析仪抓 USART1_TX 引脚能看到波形但解析出来的字符全是0xFF或莫名奇妙的符号。原因八成是波特率不匹配模块默认工作在 9600你的代码却初始化成了 115200。另一个高发原因是 TX/RX 接反GPS 的 TX 必须接 STM32 的 RX很多人习惯性同名牌对接结果一路全错。解决先用串口调试助手接模块手动测试它能稳定输出 NMEA 的波特率再回代码里改成一致值。接线上遵循交叉原则模块 TX → MCU RX模块 RX → MCU TX。如果模块是 5V 电平还要加电平转换或者串电阻分压直接接 3.3V 的 MCU 引脚会损坏输入级。稳妥的做法是上电前用万用表量一下模块 TX 引脚的静态电平3.3V 左右正常5V 就要加电路。5.3 OLED I2C 扫描不到 0x3C 地址现象I2C 总线用 HAL 库自带的扫描函数返回的地址只有 0x78 或者 0x7A怎么都找不到 0x3C。原因SSD1306 的 7 位 I2C 地址是 0x3C但很多库和教程用的是 8 位写地址 0x78也就是把 7 位地址左移一位再补 0。0x3C 和 0x78 其实是同一个器件。解决确认你用的函数期望的是 7 位原始地址还是 8 位写地址。标准 HAL 驱动里地址要左移一位即填 0x78第三方库里可能直接用 0x3C。判断方法很简单看代码里有没有 1的移位有就填 0x3C没有就填 0x78。少数模块的地址引脚被配置成 0x3D这种情况要检查模块背面的电阻位置而不是怀疑代码。5.4 定位成功但 OLED 上显示的时间差 8 小时现象GPS 明明已经锁定卫星数也出来了OLED 上的时间却比本地时间慢了 8 个小时。原因NMEA 协议里的时间是 UTC 时间不是北京时间。很多刚接触 GPS 的开发者不知道这条直接原样显示就出现了时差问题。UTC 是协调世界时北京时间是 UTC8。解决解析完时分秒后做时区转换小时加 8超过 24 就减 24 并进位日期。注意这里边界条件要处理比如 23:00 UTC 转换后是次日 07:00日期要加一天。如果只显示时间不显示日期那直接小时加 8 取模 24 就行如果要连日期一起显示需要把年月日也取出来统一转换为本地日期不然跨天时日期会错一天。void UTC_To_Beijing(GpsData *gps) { uint32_t total_sec gps-hour * 3600 gps-minute * 60 gps-second; total_sec 8 * 3600; /* UTC8 */ total_sec % 24 * 3600; gps-hour total_sec / 3600; gps-minute (total_sec % 3600) / 60; gps-second total_sec % 60; }强制用秒数累加的方式做时区偏移会得到正确的跨小时结果。total_sec加 8 小时后取模 24 小时保证结果落在有效范围。这个函数在 OLED 显示前调用一次后续再做字符串格式化比直接对时分秒各自加 8 要严谨得多。5.5 定位数据一直为 0但卫星数不为零现象窗口边测试卫星数显示 8 颗以上但经纬度一直是 0.000000OLED 上速度也一直不动。原因GPS 模块处于“搜到星但未锁定”状态。GGA 帧的定位质量字段是 0RMC 帧的有效性标志是 V。解析函数虽然读到了经纬度字段但字段内容其实是空字符串atof转出来就是 0。解决解析逻辑里必须把定位质量当作门禁。定位质量为 0 或者 RMC 状态为 V 时直接丢弃整帧不更新显示。很多从入门到放弃的项目都栽在这个细节上把“有卫星”当成“有定位”损失了几个晚上的调试时间。你可以在串口调试助手里多抓几帧数据看未定位时 GGA 第 7 字段仍然是00但第 2、4 字段已经为空解析时不做有效性判断就会用 0 覆盖掉上一次的有效坐标。6. 从经纬度到地图坐标一颗更精准的验证技巧6.1 度分格式转十进制度别直接喂给地图NMEA 输出的坐标是ddmm.mmmm格式但地图 API 要的是十进制度。很多人把4807.038直接当 48.07038 用结果地图上偏移到几十公里外。十进制度的转换公式是度取整数部分分除以 60 加到度上。这个转换前面解析代码里已经做了但考验的是你有没有理解它背后 11 米精度的概念。纬度方向上0.0001 度约等于 11.1 米。所以如果 OLED 显示的纬度小数位有 4 位显示精度大概是米级如果只有 2 位那就是百米级。定位模块本身的精度在 2 到 5 米显示 4 位小数足够没必要显示 6 位小数后面几位全是噪声。屏幕上显示 5 位小数会频繁跳动4 位则相对稳定视觉上也更舒服。6.2 WGS-84 与 GCJ-02国内地图的坐标偏移不是玄学GPS 模块输出的是 WGS-84 坐标系而国内地图产品普遍使用 GCJ-02 坐标系两者之间存在几十到几百米的偏移。调试时如果只是看一眼显示数据WGS-84 直接显示没问题如果要把坐标落到地图上标注轨迹就必须先做坐标系转换。转换公式是公开的算法网上有完整的实现输入 WGS-84 经纬度输出 GCJ-02 经纬度。我一般建议把这个转换写成一个独立的函数放到工程里验证方法很简单找一个已知地标用模块实测坐标和地图标注坐标对比误差应该从几百米降到十米以内。这个验证步骤能帮你确认解析出的坐标到底是可信还是错得离谱值得在代码里加一步。6.3 PPS 引脚判断“锁定”的最直观手段部分 GPS 模块还有一个 PPS 引脚它在定位锁定后每秒输出一个脉冲。这个信号不需要解析 NMEA 就能直接判断模块有没有进入锁定状态。用示波器能看到每秒一次稳定的电平跳变没有锁定时要么没脉冲要么频率不规律。调试时把这个引脚接一个 LED秒闪就代表锁定比反复看屏幕上的卫星数直观得多。从那以后我每次拿到新 GPS 模块都会先确认三件事模块默认波特率、PPS 引脚位置、天线朝向。先硬件自检再写代码整个项目从串口数据到 OLED 显示的链路就会顺畅很多避坑也变成了例行流程。希望帮到你。本文还有配套的精品资源点击获取