简介:面向物联网、车载导航与户外运动设备等应用场景,这份资料提供了一套基于STM32微控制器的GPS定位系统开发方案。内容以STM32与GPS模块的串口通信、NMEA协议解析及OLED实时显示为核心,适合正在学习嵌入式定位技术的中初级开发者。压缩包共152个文件,大小仅2.09MB,其中以C语言源程序、头文件、Keil工程文件、编译生成的HEX烧录文件为主,另有AXF调试文件、工程备份及辅助脚本等,结构上区分了标准外设库与用户应用代码,便于直接导入工程或按需引用。资料中包含了“GPS解析后串口输出”的示例程序,完整演示了串口初始化、GPS数据接收、NMEA语句拆分,以及经纬度、速度、时间等信息的提取与格式化输出,并涉及OLED屏的驱动与刷新控制,可帮助开发者理解从硬件配置到上层显示的一整套流程。已有7480人学习下载,经过众多学习者实践检验,适合作为STM32+GPS项目开发的起步模板和参考设计。
1. STM32 接 GPS 再点 OLED:这份工程资源把定位显示链路一次性打通
把 STM32、GPS 模块和一块 0.96 寸 OLED 拼在一起,做成实时定位显示器,这个需求在课程设计和毕设里很常见,也是很多人第一次接触 NMEA 协议时最容易翻车的地方。这份工程资源的核心,是从串口收到的$GPGGA文本出发,一路拆到 OLED 屏上显示经纬度、速度和卫星数。它把底层串口接收、NMEA 解析、屏幕刷新的完整链路拆开讲,还带可直接烧录的工程代码。适合正在做 GPS 定位项目的开发者,也适合刚学会串口但不知道怎么做数据解析的新手。
2. 硬件选型与接线:为什么 GPS 走串口、OLED 走 I2C
2.1 模块选型:串口 GPS 模组 + I2C OLED,省引脚也省心
GPS 模块的选择逻辑其实很简单:绝大多数市面常见的串口 GPS 模组,上电后就会以固定波特率持续输出 NMEA 0183 格式的文本流。这个输出天然就是串口数据,所以 STM32 这边只需要一组 USART 就能把数据接进来,不需要额外写复杂的驱动时序。
OLED 我建议优先选 SSD1306 控制的 0.96 寸 I2C 接口屏,而不是 SPI 版本。原因有两个:一是 I2C 只要两根线(SCL、SDA),SPI 版本至少占用 SCK、MOSI、DC、CS 四根线,有些还要 RST;二是定位显示这种场景刷新量很小,128x64 单色屏每秒更新一次,I2C 的 400K 速率完全够用,不会出现刷新瓶颈。
显示内容方面,这类屏幕能稳定显示四到六行文字。我一般把第一行留给日期时间,第二行纬度,第三行经度,第四行速度和卫星数。如果要显示中文,需要准备好字模,工程里带的是 ASCII 字库,英文数字显示足够清晰,16x16 中文点阵会占掉四行中的两行,信息密度反而下降。
2.2 接线对照与电源注意
接线是整个项目里最不该出错但最容易出错的一步。GPS 模块输出的是 TTL 电平,直接和 STM32 的 USART1 相连即可。OLED 的 I2C 地址默认是 0x3C,很多模块背面标注的是写地址 0x78,这两个数字经常把人绕晕,后面避坑章节会专门展开。
| 功能 | GPS 模块引脚 | STM32 引脚 | OLED 引脚 | 说明 |
|---|---|---|---|---|
| 电源 | VCC | 3.3V | VCC | 共用 3.3V 电源轨 |
| 地 | GND | GND | GND | 必须共地 |
| 串口发送 TX | PA10(USART1_RX) | — | GPS TX 接 STM32 RX | |
| 串口接收 RX | PA9(USART1_TX) | — | GPS RX 接 STM32 TX | |
| I2C 时钟 | — | PB6(I2C1_SCL) | SCL | 4.7K 上拉可复用板载 |
| I2C 数据 | — | PB7(I2C1_SDA) | SDA | 4.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、RX,8 位数据位、无校验、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 时一帧传输时间接近 90ms,115200 时约 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 | 全球定位系统定位数据 |
| 1 | 123519 | UTC 时间 12:35:19 |
| 2 | 4807.038 | 纬度,度分格式 |
| 3 | N | 北纬 |
| 4 | 01131.000 | 经度,度分格式 |
| 5 | E | 东经 |
| 6 | 1 | 定位质量,1 表示 GPS 定位 |
| 7 | 08 | 可见卫星数 |
| 8 | 1.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 是协调世界时,北京时间是 UTC+8。
解决:解析完时分秒后做时区转换,小时加 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; /* UTC+8 */ 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.000000,OLED 上速度也一直不动。
原因:GPS 模块处于“搜到星但未锁定”状态。GGA 帧的定位质量字段是 0,RMC 帧的有效性标志是 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 显示的链路就会顺畅很多,避坑也变成了例行流程。希望帮到你。
本文还有配套的精品资源,点击获取