资讯动态

STM32+GPS定位终端实战:NMEA解析与OLED显示

发布时间:2026/10/20 13:02:27 来源:尧图企业网站定制
简介这份资源面向嵌入式开发初学者与物联网、车载导航方向的开发者提供一套基于STM32微控制器的GPS定位系统完整工程。核心思路是STM32通过UART接收GPS模块输出的NMEA-0183数据流解析出经纬度、时间、速度、高度与卫星数量等关键信息再经I2C或SPI驱动OLED屏实时刷新显示帮助读者打通从硬件接口配置到数据解析、屏幕显示的完整链路。压缩包共152个文件约2.09MB以37个h头文件与34个c源文件为主体涵盖STM32外设驱动与GPS解析逻辑另含15个o、15个d及13个crf等Keil编译中间文件以及uvprojx工程、sct分散加载、hex与axf固件等工程结构完整可直接参考。目前已有7482人学习下载适合对照示例程序理解串口接收、NMEA语句提取与OLED显示适配快速搭建自己的定位设备原型。1. 从一块 GPS 模块说起STM32 定位终端到底能干什么前阵子帮朋友做一个户外设备的小玩意需求很朴素设备开机后自动搜星把经纬度、UTC 时间、速度、航向这些信息实时显示在一块小 OLED 屏上同时通过串口把 NMEA 报文吐给上位机做记录。听起来简单但真上手才发现STM32 和 GPS 模块之间的串口时序、NMEA 语句解析、OLED 刷新节奏每一个环节都能让人翻车。这份STM32GPS 定位资源就是围绕这个场景做的完整工程核心是 STM32 通过 UART 接收 GPS 模块输出的 NMEA 0183 协议数据解析出定位信息后驱动 OLED 显示同时保留串口输出通道方便调试和二次开发。它适合两类人一类是刚接触 STM32 外设、想找一个「有真实数据流」的项目练手的开发者另一类是要做车载终端、户外追踪器、资产定位器这类产品原型的工程师。整套代码不依赖操作系统裸机跑HAL 库驱动拿到手改改引脚和波特率就能用。2. 硬件链路与工程结构从 GPS 模块到 OLED 的数据通路2.1 硬件连接与模块选型这个项目的硬件链路很清晰GPS 模块通过 UART 把 NMEA 报文发给 STM32STM32 解析后通过 I2C 或 SPI 驱动 OLED 显示。常见的 GPS 模块比如基于 ATGM336H、NEO-6M、L76X 这类芯片的模组默认波特率是 9600输出标准 NMEA 0183 语句供电 3.3V 或 5V 都有接线就四根VCC、GND、TX、RX。STM32 这边我一般会选带多个 USART 的型号比如 STM32F103C8T6 或者 STM32F401CCU6。一个 USART 接 GPS另一个 USART 接 USB-TTL 做调试输出这样调试的时候不会和 GPS 数据抢通道。OLED 用 0.96 寸 I2C 屏最省事两根线搞定SSD1306 驱动芯片市面上资料最多。模块接口引脚说明GPS 模块UARTPA10(RX) / PA9(TX)接 USART1波特率 9600OLED 屏I2CPB6(SCL) / PB7(SDA)SSD1306地址 0x78调试串口UARTPA3(RX) / PA2(TX)接 USART2波特率 115200注意GPS 模块的 TX 接 STM32 的 RXRX 接 STM32 的 TX别接反了。我见过不止一个新手在这上面卡了半天。2.2 工程目录结构拿到源码后目录大概长这样STM32_GPS_OLED/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── gps.h # GPS 解析头文件 │ │ ├── oled.h # OLED 驱动头文件 │ │ └── stm32f1xx_hal_conf.h │ └── Src/ │ ├── main.c # 主循环 │ ├── gps.c # NMEA 解析实现 │ ├── oled.c # OLED 显示实现 │ └── stm32f1xx_it.c # 中断服务 ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── GPS_OLED.ioc # CubeMX 工程文件 └── README.md这个结构是 CubeMX 生成的标准骨架gps.c 和 oled.c 是项目自己加的业务代码。用 .ioc 文件的好处是引脚配置、时钟树、外设初始化都能在图形界面里改改完重新生成代码不会覆盖你自己写的业务逻辑前提是你把代码写在 USER CODE BEGIN 和 END 之间。2.3 串口接收与中断配置GPS 模块是持续往外吐数据的9600 波特率下每秒大概输出 5 到 10 条 NMEA 语句每条几十个字节。如果用轮询方式读主循环会被拖死所以必须用中断或者 DMA 接收。我一般用「空闲中断 DMA」的方式STM32 的 USART 支持 IDLE 中断一帧数据接收完毕后触发配合 DMA 把数据搬到缓冲区CPU 占用极低。CubeMX 里配置 USART1 为异步模式开启 DMA 接收通道然后在代码里启动// 启动 GPS 串口 DMA 接收缓冲区大小 256 字节 uint8_t gps_rx_buffer[256]; volatile uint8_t gps_rx_len 0; volatile uint8_t gps_frame_ready 0; void GPS_UART_Init(void) { // 启动 DMA 接收空闲中断在 HAL_UARTEx_ReceiveToIdle_DMA 里自动开启 HAL_UARTEx_ReceiveToIdle_DMA(huart1, gps_rx_buffer, sizeof(gps_rx_buffer)); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); // 关闭半传输中断减少干扰 } // 空闲中断回调一帧数据接收完成 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { gps_rx_len Size; gps_frame_ready 1; // 标记一帧数据就绪 // 重新启动接收准备下一帧 HAL_UARTEx_ReceiveToIdle_DMA(huart1, gps_rx_buffer, sizeof(gps_rx_buffer)); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); } }这里的关键点是HAL_UARTEx_ReceiveToIdle_DMA它同时开启了 DMA 传输和空闲中断。Size参数是本次接收到的字节数回调触发时说明一帧数据已经完整到达。DMA_IT_HT是半传输中断GPS 数据帧短不需要半传输通知关掉可以减少中断次数。缓冲区给 256 字节足够因为单条 NMEA 语句最长不超过 82 个字符一帧里可能包含多条语句256 字节能覆盖大部分情况。3. NMEA 报文解析从原始字节到经纬度坐标3.1 NMEA 0183 协议格式GPS 模块输出的数据遵循 NMEA 0183 协议每条语句以$开头以\r\n结尾字段之间用逗号分隔。最常用的几条语句$GPGGA全球定位数据包含 UTC 时间、纬度、经度、定位质量、卫星数、HDOP、海拔$GPRMC推荐最小定位信息包含时间、日期、纬度、经度、速度、航向$GPGSV可见卫星信息$GPGSA当前卫星使用情况实际解析时我一般只取$GPRMC和$GPGGA两条前者拿时间和速度后者拿定位质量和卫星数。下面是一条典型的$GPRMC语句$GPRMC,123519.00,A,3958.12345,N,11618.54321,E,0.5,45.2,230394,,,A*6A字段含义依次是UTC 时间 12:35:19、状态 A(有效)、纬度 39°58.12345、北纬、经度 116°18.54321、东经、速度 0.5 节、航向 45.2°、日期 23/03/94。3.2 解析函数实现解析的核心思路是先按\r\n切分出一行判断语句类型再用strtok按逗号拆分字段最后把度分格式的经纬度转成十进制。#include string.h #include stdlib.h typedef struct { float latitude; // 十进制纬度 float longitude; // 十进制经度 float speed_knots; // 速度节 float course; // 航向 uint8_t hour, min, sec; // UTC 时间 uint8_t day, month, year; uint8_t fix_valid; // 定位是否有效 uint8_t satellites; // 卫星数 } GPS_Info_t; GPS_Info_t gps_info; // 把度分格式转成十进制ddmm.mmmmm - dd.dddddd static float nmea_to_decimal(const char *nmea, const char *dir) { if (nmea NULL || strlen(nmea) 4) return 0.0f; float raw atof(nmea); int deg (int)(raw / 100); float min raw - deg * 100; float dec deg min / 60.0f; if (dir (*dir S || *dir W)) dec -dec; return dec; } // 解析 GPRMC 语句 static void parse_gprmc(char *line) { char *p line; char *fields[12] {0}; int idx 0; // 按逗号拆分注意 strtok 会修改原字符串 char *token strtok(p, ,); while (token ! NULL idx 12) { fields[idx] token; token strtok(NULL, ,); } if (idx 10) return; // fields[0]$GPRMC, [1]时间, [2]状态, [3]纬度, [4]N/S, [5]经度, [6]E/W if (fields[2] fields[2][0] A) { gps_info.fix_valid 1; gps_info.latitude nmea_to_decimal(fields[3], fields[4]); gps_info.longitude nmea_to_decimal(fields[5], fields[6]); gps_info.speed_knots atof(fields[7]); gps_info.course atof(fields[8]); // 时间字段格式 hhmmss.ss if (fields[1] strlen(fields[1]) 6) { gps_info.hour (fields[1][0]-0)*10 (fields[1][1]-0); gps_info.min (fields[1][2]-0)*10 (fields[1][3]-0); gps_info.sec (fields[1][4]-0)*10 (fields[1][5]-0); } } else { gps_info.fix_valid 0; } }nmea_to_decimal是解析里最容易写错的地方。NMEA 的纬度格式是ddmm.mmmmm前两位是度后面是分所以要先除以 100 取整得到度余数除以 60 得到小数部分。经度是dddmm.mmmmm前三位是度逻辑一样但位数不同用raw / 100取整对经度也成立因为经度最大 180 度除以 100 后整数部分就是度数。方向字段S和W要取负值这个别漏。3.3 主循环里的解析调度中断回调只负责标记数据就绪真正的解析放在主循环里做避免在中断里跑耗时操作。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); OLED_Init(); GPS_UART_Init(); char line_buf[128]; uint16_t line_pos 0; while (1) { if (gps_frame_ready) { gps_frame_ready 0; // 逐字节扫描按 \n 切分成行 for (uint16_t i 0; i gps_rx_len; i) { char c gps_rx_buffer[i]; if (c \n) { line_buf[line_pos] \0; if (strstr(line_buf, $GPRMC)) { parse_gprmc(line_buf); } else if (strstr(line_buf, $GPGGA)) { parse_gpgga(line_buf); } line_pos 0; } else if (c ! \r line_pos sizeof(line_buf) - 1) { line_buf[line_pos] c; } } } OLED_ShowGPSInfo(gps_info); HAL_Delay(200); // 200ms 刷新一次太快屏幕会闪 } }这里用了一个行缓冲区line_buf因为 DMA 一次收到的可能包含多条 NMEA 语句需要按\n切分。strstr判断语句类型比逐字符比较更直观。HAL_Delay(200)控制 OLED 刷新率200ms 一次人眼看起来足够流畅太快反而会因为 I2C 写入频繁导致屏幕闪烁。4. OLED 显示驱动把经纬度画到 0.96 寸屏上4.1 SSD1306 初始化与显存管理0.96 寸 OLED 用的是 SSD1306 驱动芯片128x64 分辨率I2C 接口。它的显存是 1024 字节按页组织8 页每页 128 字节。驱动的基本流程是初始化寄存器序列、清屏、写显存、刷新。#define OLED_ADDR 0x78 // 默认 I2C 地址有些模块是 0x7A static uint8_t oled_buffer[8][128]; // 显存缓冲8 页 x 128 列 void OLED_Init(void) { HAL_Delay(100); static const uint8_t init_cmds[] { 0xAE, 0x20, 0x00, 0xB0, 0xC8, 0x00, 0x10, 0x40, 0x81, 0xFF, 0xA1, 0xA6, 0xA8, 0x3F, 0xA4, 0xD3, 0x00, 0xD5, 0xF0, 0xD9, 0x22, 0xDA, 0x12, 0xDB, 0x20, 0x8D, 0x14, 0xAF }; for (uint8_t i 0; i sizeof(init_cmds); i) { OLED_WriteCmd(init_cmds[i]); } OLED_Clear(); OLED_Refresh(); } void OLED_Refresh(void) { for (uint8_t page 0; page 8; page) { OLED_WriteCmd(0xB0 page); // 设置页地址 OLED_WriteCmd(0x00); // 列低地址 OLED_WriteCmd(0x10); // 列高地址 OLED_WriteData(oled_buffer[page][0], 128); } }初始化命令序列里0xAE关显示0x8D 0x14开启电荷泵必须开否则屏幕不亮0xAF开显示。0xC8是 COM 扫描方向0xA1是段重映射这两个决定了屏幕的显示方向如果显示出来是镜像的改这两个值就行。显存用二维数组oled_buffer[8][128]刷新时按页写入比每次直接写屏效率高得多。4.2 字符显示与信息布局OLED 上要显示的信息有经纬度、UTC 时间、速度、卫星数、定位状态。128x64 的屏幕用 6x8 或 8x16 的 ASCII 字库一行能放 21 个 6x8 字符或 16 个 8x16 字符。我一般这样布局行1: LAT: 39.9687 N 行2: LON: 116.3090 E 行3: UTC: 12:35:19 行4: SPD: 0.5kn CRS:45 行5: SAT: 08 FIX: OK字符显示函数的核心是把字库里的点阵数据按位写到显存对应位置// 显示一个 6x8 的 ASCII 字符 void OLED_ShowChar(uint8_t x, uint8_t page, char ch) { if (x 122 || page 7) return; uint8_t idx ch - ; // 字库从空格开始 for (uint8_t i 0; i 6; i) { oled_buffer[page][x i] F6x8[idx][i]; } } // 显示字符串 void OLED_ShowString(uint8_t x, uint8_t page, const char *str) { while (*str) { OLED_ShowChar(x, page, *str); x 6; if (x 122) break; } } // 显示 GPS 信息 void OLED_ShowGPSInfo(GPS_Info_t *info) { char buf[24]; OLED_ClearBuffer(); if (info-fix_valid) { snprintf(buf, sizeof(buf), LAT:%.4f%c, info-latitude 0 ? info-latitude : -info-latitude, info-latitude 0 ? N : S); OLED_ShowString(0, 0, buf); snprintf(buf, sizeof(buf), LON:%.4f%c, info-longitude 0 ? info-longitude : -info-longitude, info-longitude 0 ? E : W); OLED_ShowString(0, 1, buf); snprintf(buf, sizeof(buf), UTC:%02d:%02d:%02d, info-hour, info-min, info-sec); OLED_ShowString(0, 2, buf); snprintf(buf, sizeof(buf), SPD:%.1fkn, info-speed_knots); OLED_ShowString(0, 3, buf); snprintf(buf, sizeof(buf), SAT:%02d FIX:OK, info-satellites); OLED_ShowString(0, 4, buf); } else { OLED_ShowString(0, 2, Waiting for fix...); } OLED_Refresh(); }snprintf格式化时要注意浮点数的精度经纬度保留 4 位小数大约对应 10 米左右的精度够用。OLED_ClearBuffer只清显存不清屏配合OLED_Refresh一起用避免闪烁。如果屏幕显示乱码先检查字库数组的索引是否正确ch - 这个偏移量要和字库定义一致。5. 避坑与排查GPS 定位项目里最容易翻车的几个点5.1 串口收不到数据现象OLED 一直显示 Waiting for fix...调试串口打印发现gps_rx_len始终为 0。原因最常见的是 TX/RX 接反或者 GPS 模块波特率不是 9600。有些模块出厂默认 4800 或 115200需要先用上位机确认。还有一种情况是 DMA 接收没有正确启动HAL_UARTEx_ReceiveToIdle_DMA在初始化之后被其他代码覆盖了。解决先用 USB-TTL 直接接 GPS 模块用串口助手看有没有 NMEA 数据出来确认波特率和接线。然后在GPS_UART_Init里加一句HAL_UART_Transmit打印调试信息确认函数被调用了。如果用的是 HAL 库检查MX_DMA_Init是否在MX_USART1_UART_Init之前调用顺序反了 DMA 时钟没开。5.2 定位数据一直是无效的现象能收到 NMEA 语句但fix_valid始终为 0$GPRMC的状态字段是V而不是A。原因GPS 模块还没完成定位。冷启动时搜星需要 30 秒到几分钟取决于天线质量和天空视野。室内基本定不了位必须到室外或窗边。解决耐心等待同时观察$GPGSV语句里的卫星数如果卫星数在增加说明正在搜星。有源天线的话检查供电是否正常天线接口是否拧紧。如果长时间定不了位试试给模块断电重启有时候是模块内部状态卡住了。5.3 OLED 显示闪烁或花屏现象屏幕内容刷新时闪烁或者出现乱码、半屏不显示。原因I2C 速率太高导致数据丢失或者刷新频率太快。SSD1306 的 I2C 速率一般不超过 400kHz有些模块只支持 100kHz。另外如果在OLED_Refresh过程中被中断打断也可能导致数据错位。解决把 I2C 速率降到 100kHz 试试。刷新频率控制在 200ms 以上不要在主循环里无延时地刷。如果用了 RTOS给 OLED 操作加互斥锁。检查oled_buffer的写入和刷新是否在同一个任务上下文里避免并发访问。5.4 经纬度数值不对现象显示出来的经纬度是3958.1234这种格式没有转成十进制。原因nmea_to_decimal函数没被调用或者atof解析失败返回了 0。还有一种可能是字段索引搞错了$GPRMC和$GPGGA的字段位置不一样用同一个解析函数会串位。解决确认$GPRMC的纬度在 fields[3]经度在 fields[5]$GPGGA的纬度在 fields[2]经度在 fields[4]。分别写解析函数不要混用。在nmea_to_decimal里加打印看输入字符串和输出值对不对。5.5 时间日期差 8 小时现象OLED 上显示的 UTC 时间比北京时间慢 8 小时。原因NMEA 输出的是 UTC 时间不是本地时间。这不是 bug是协议规定。解决如果要显示北京时间在解析后给小时字段加 8然后处理跨天的情况小时超过 24 就减 24日期加 1。注意月份和年份的进位闰年二月这些边界情况。我一般会在GPS_Info_t里加一个local_hour字段显示的时候用本地时间存储和传输还是用 UTC。6. 二次开发改造从能跑到好用几个实用的优化方向拿到这个工程跑通之后下一步就是按自己的需求改。我梳理了几个最常改的方向每个都给出具体的改法和验证方式。改定位上报方式。现在的工程是把数据画到 OLED 上如果要接 4G 模块或者 LoRa 做远程上报可以在主循环里加一个上报任务。我的习惯是定义一个GPS_Report函数把gps_info结构体序列化成 JSON 或者自定义二进制帧通过另一个串口发出去。验证方法是先用 USB-TTL 接电脑用串口助手看上报的数据格式对不对确认无误后再接无线模块。// 上报数据帧示例$REPORT,lat,lon,speed,course,utc*checksum void GPS_Report(GPS_Info_t *info) { char frame[128]; int len snprintf(frame, sizeof(frame), $REPORT,%.6f,%.6f,%.1f,%.1f,%02d%02d%02d, info-latitude, info-longitude, info-speed_knots, info-course, info-hour, info-min, info-sec); // 计算校验和$和*之间的字符异或 uint8_t checksum 0; for (int i 1; i len; i) checksum ^ frame[i]; len snprintf(frame len, sizeof(frame) - len, *%02X\r\n, checksum); HAL_UART_Transmit(huart2, (uint8_t *)frame, len, 100); }加数据记录功能。如果要记录轨迹可以外挂一个 SPI Flash 或者 SD 卡。我一般用 FatFS 文件系统每隔几秒把经纬度和时间写一行 CSV。这里有个坑SD 卡的写入延迟不稳定如果直接在主循环里写会阻塞 GPS 解析。常见做法是开一个环形缓冲区主循环只往缓冲区里塞数据另开一个低优先级任务或者定时器慢慢往卡里写。优化电源管理。户外设备对功耗敏感STM32 和 GPS 模块都是耗电大户。可以在定位成功后让 STM32 进入 STOP 模式GPS 模块用 PMTK 命令切到省电模式定时唤醒检查一次位置。验证功耗要用万用表串在电源线上测电流STOP 模式下 STM32 大概几十微安GPS 模块省电模式下几毫安整体续航能从几小时拉到几天。换更精确的解析库。现在的解析是手写的够用但不够健壮。如果要支持多星座GPS北斗GLONASS和更多语句类型可以移植一个轻量的 NMEA 解析库比如 minmea 或者自己写状态机。移植的时候注意库的内存占用STM32F103 只有 20KB RAM别把缓冲区开太大。加看门狗和异常恢复。GPS 模块偶尔会死机串口不再输出数据。我一般会加一个软件看门狗如果超过 10 秒没有收到有效的$GPRMC就拉低 GPS 模块的电源使能引脚重新上电。这个功能在无人值守的设备上特别重要血泪经验。从那以后我每次做串口通信的项目都强制走一遍「先确认物理链路再确认波特率最后看协议解析」的排查流程不再一上来就怀疑代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑