
简介这是一套基于STM32F407ZG芯片的NMEA-0183协议北斗GPS报文解析完整工程面向嵌入式开发、单片机学习以及导航定位应用开发者。资源支持多模卫星定位模块输出数据解析覆盖GNGGA、GPGSA、BDGSA、GPGSV、BDGSV、GNRMC、GNVTG等常用报文可帮助快速获取经纬度、时间、速度、卫星状态等定位信息并提供了清晰的外设配置与报文处理流程便于在其他主控上自行移植。压缩包总体积为2.89MB共包含95个文件。除14个C源码与19个头文件外还包含Keil MDK工程文件uvprojx/uvoptx以及编译产生的o、d、crf、axf、hex、map等中间文件可对照学习编译链接过程目录中External/User/System等模块划分也很清楚。工程将串口接收、定时器超时判断、NMEA字段拆分与校验等关键环节拆分清晰适合对照报文规范逐步理解也可作为课程设计、毕业设计或产品预研的基础模板。该资源已有3711人学习下载能显著减少协议适配的时间成本是学习STM32与定位模块解析的实用参考资料。 从最开始我用探索者板子做车载定位终端那会儿就琢磨着把手头这个ATGM336H模块吐出来的原始报文直接拿去用。结果打开串口助手一看满屏的$GNGGA,092750.000,5321.6802,N,00630.3372,W,1,8,...——数据是有了可这堆逗号串到底哪段是纬度、哪段是时间不搞清楚根本没法干活。后来我索性不折腾现成库直接在STM32F407上从头写了一套完整的NMEA-0183协议北斗GPS报文解析工程把所有解析逻辑、串口接收方案、时间坐标换算全部捋顺了。这篇文章就是把整个工程从设计到调试的过程完整记录下来适合正在做GPS/北斗定位、惯导数据采集、车载终端这类项目的嵌入式开发者参考。不管你是刚接触STM32想跑通第一个定位工程还是已经在做多模定位但被报文解析卡住都可以从这里面找到可以直接照搬的思路。1. 为什么要自己解析NMEA-0183现成库之外的思路网上确实有TinyGPS、MicroNMEA这类现成库封装得挺完整调个接口就能拿到经纬度。但实际做工程时我建议大家仔细想一下“直接用库”的成本到底在哪。1.1 现成库的几个“隐形账单”第一很多库的解析是拿“完整帧”喂进去的这意味着你在串口这一层就必须先把报文攒齐、切开。可STM32F407串口接收如果只靠一轮HAL_UART_Receive死等对实时性影响很大所以往往还得自己管理缓存。第二这类库面向的是“通用NMEA”对北斗特有的BD语句支持不够灵活。比如$BDGSV这种北斗星历信息老版本库基本是丢掉的。我在项目里需要判断“当前到底锁了几颗北斗星、几颗GPS星”用于评估定位模式现成库很难满足这种细粒度需求。第三也是最关键的——解析可控性。商用项目里报文出现半个帧、校验失败、甚至模块刚启动时吐出来的空字段这些边缘情况必须由你控制怎么处理。用库相当于把异常处理策略交给了别人。我在自研解析工程里还碰到过一种情况模块输出电压不稳导致GGA语句在*校验和后面直接丢了回车换行。这种半个帧的垃圾数据没有一套自己完全掌控的状态机排查起来非常痛苦。1.2 解析层应该放在哪一层从工程分层来说我最开始画过一版框图用文字表述硬件驱动层负责串口字节流入 → 协议层负责组帧和校验 → 应用层负责字段提取和业务使用。这三层严格分离单测也好写排查问题也好定位。实际代码里我用一个环形缓冲区承接串口中断收到的字节然后协议层在定时器任务里不停ring_buffer_read喂给状态机。这样比直接多字节中断省心也不容易丢字节。这里的核心思路就是协议解析永远不要阻塞在串口中断里面——STM32F407在168MHz主频下做逐字节判断倒是快但中断里一旦做复杂计算周边定时器优先级就会被拖累。2. NMEA-0183报文结构拆解帧头、字段与异或校验NMEA-0183这个东西虽然是航海电子协会定的老协议但它的文本化设计让所有开发设备都很好接。做解析之前先把报文结构彻底啃明白。2.1 从$到*HH再到\r\n的完整链路一条标准语句长这样$GNGGA,092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,*75拆开来看存在几个固定部分起始符$代表一帧的开始。地址域GNGGA其中GN代表多模北斗GPSGGA代表全球定位系统定位数据。如果模块只输出GPS这里就是GPGGA只有北斗时则是BDGGA。解析时务必区分这三种前缀。数据体逗号分隔的各个字段。注意NMEA的字段是“变长”的因为有些字段可能为空。比如上面的,,之间就是空字段解析时不能按固定偏移去读内存必须数逗号。校验和*后面跟两位十六进制是对$和*之间所有字符做按位异或得到的。你看到的*75就是前边从GNGGA到55.2,M这部分字符逐字节异或的结果。帧结束\r\n。这是很多新手忽略的边界条件。串口助手显示不出来但在字节流里非常关键。你的状态机如果没把\r\n当作完整帧的终止判定就有可能在半截帧处误触发解析。2.2 校验和的算法这个必须自己写不用任何库校验和就是一次循环异或uint8_t nmea_check_sum(const char *buf, int len) { uint8_t sum 0; for (int i 0; i len; i) { sum ^ (uint8_t)buf[i]; } return sum; }调用时注意区间$和*之间的字符不包含$和*本身。我在工程里把帧头之后的地址域开始位置记录下来然后一直读到*之前把这中间的长度传进去和*后面两位ASCII转出来的hex做对比。有个细节校验和失败一定要丢弃整帧。我之前试过“明明校验失败但字段还能用就继续解析”结果在车经过高架桥下方信号遮挡时解析出了偏离几十米的“假坐标”。从那以后校验失败直接置无效标志绝不用于后续定位。2.3 常用语句字段速查我实测用得最多的是这几条语句英文全称关键字段从0开始数GGAGlobal Positioning System Fix Data0UTC时间1纬度2N/S3经度4E/W5定位状态(1有效)RMCRecommended Minimum Specific GNSS Data0UTC时间1定位状态(A/V)2纬度3N/S4经度5E/W6速度(节)8日期GSAGNSS DOP and Active Satellites1定位模式15PDOP16HDOP17VDOPGSVGNSS Satellites in View4仰角5方位角6信噪比其中GGA的定位状态字段特别重要0代表未定位1代表单点定位2代表差分定位。我在终端界面上用这个字段控制指示灯只有等于1或2时才认为坐标有效。3. STM32F407串口接收层的搭建从寄存器到中断缓冲报文解析的前提是能稳定收到完整字节流。STM32F407有多个USART资源充足但配置上有一堆容易踩的细节。3.1 时钟与引脚配置以及开发板V2/V3的区分探索者开发板现在市面上有V2和V3两个版本很多人拿到手不知道怎么区分。最简单的判断方法是看丝印和接口布局V3版本的板子右下角接口丝印更紧凑而且JLINK口旁边多了一排扩展排针另一个更靠谱的办法是看主控芯片丝印批次V3板子常用后缀带ZGT6的新批次芯片但这需要拆芯片上的字不直观。我建议直接通过板载串口CH340枚举后的设备名来辅助判断或者看配套资料的页面。在咱们项目里我用USART1接GPS模块的TXD引脚USART1的发送脚PA9和接收脚PA10。配置时注意STM32F407引脚的复用功能GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_9 | GPIO_PIN_10; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);这里GPIO_AF7_USART1就是USART1的引脚复用编号写错的话串口完全不出数据。新手最容易卡在这一步我刚开始就为了引脚复用号查了好半天手册。3.2 UART参数波特率96008N1——别信某些模块默认配置大部分消费级GPS/北斗模块比如ATGM336H、NEO-M8N出厂默认NMEA输出波特率就是96008数据位、无校验、1停止位。但个别模块会被配置成115200所以拿到模块先看厂家文档或者先用串口助手扫描一下波特率。我当时图省事直接按9600接结果串口助手打开全乱码后来才发现模块是被上一手配置成了38400。串口初始化用HAL库很方便UART_HandleTypeDef huart1; huart1.Instance USART1; huart1.Init.BaudRate 9600; 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);注意GPS模块的TXD接STM32的RX交叉接线如果模块是5V供电但它的串口电平有时是3.3V也有的是TTL 5V。探索者板子的USART1这几个脚是3.3V电平遇到5V电平的GPS模块一定要加电平转换或者确认模块串口兼容3.3V。我就是疏忽了一次把一个5V输出的老模块直接怼到PA10上发热严重还一直收不到数据。3.3 中断接收与环形缓冲区的配合9600波特率算下来每个字节约1.04ms对STM32F407来说完全没压力。我用HAL_UART_Receive_IT回调函数的方式接收每收到一个字节就把它塞进环形缓冲区。环形缓冲区实现很简单核心是读写指针和长度#define RING_BUF_SIZE 1024 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; void ring_write(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % RING_BUF_SIZE; if (next ! rb-tail) { rb-buffer[rb-head] data; rb-head next; } } uint8_t ring_read(ring_buffer_t *rb, uint8_t *data) { if (rb-head rb-tail) { return 0; } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RING_BUF_SIZE; return 1; }串口回调void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { ring_write(gps_ring, gps_rx_byte); HAL_UART_Receive_IT(huart1, gps_rx_byte, 1); } }启动时调用一次HAL_UART_Receive_IT(huart1, gps_rx_byte, 1);之后每个字节都会进回调。这种“单字节中断环形缓存主循环消费”的结构比在中断里直接跑解析状态机要干净得多。4. 逐字节状态机解析器的实现思路与完整代码走读拿到字节流之后核心任务就是把$到\r\n之间的一整帧提取出来并且提取出每个字段。直接按“先攒frame再strtok”的方案当然能跑但遇到半个帧、多余字符时容易崩。更稳的做法是状态机。4.1 状态机的状态划分我定义了这几种状态typedef enum { NMEA_STATE_WAIT_START, // 等待 $ NMEA_STATE_READ_ADDR, // 读取地址域比如 GGA、RMC NMEA_STATE_READ_DATA, // 读取数据字段 NMEA_STATE_WAIT_CHECKSUM, // 遇到 *开始等校验和 NMEA_STATE_READ_CHECKSUM, // 读取两位校验和 NMEA_STATE_DONE // 一帧完整结束等待下一次 $ } nmea_state_t;状态机的好处是每个字节只做一次简单的case判断不会因为“帧太长”“帧头缺失”而卡死。比如GPS模块刚上电时可能吐出半句话这时候状态还停在READ_DATA下一个字节是新一帧的$。我专门在状态机里加了处理如果在非WAIT_START状态又读到$强制回到READ_ADDR同时丢弃之前暂存的半截帧。4.2 核心解析循环代码void nmea_parser_process_byte(parser_t *parser, uint8_t byte) { uint8_t checksum_hex; switch (parser-state) { case NMEA_STATE_WAIT_START: if (byte $) { parser-state NMEA_STATE_READ_ADDR; parser-frame_len 0; parser-checksum 0; parser-field_idx 0; parser-field_len 0; } break; case NMEA_STATE_READ_ADDR: if (byte ,) { // 地址域结束进入数据字段 parser-frame[parser-frame_len] byte; parser-state NMEA_STATE_READ_DATA; parser-field_idx 0; parser-field_len 0; } else if (byte *) { parser-state NMEA_STATE_WAIT_CHECKSUM; } else { parser-frame[parser-frame_len] byte; parser-checksum ^ byte; } break; case NMEA_STATE_READ_DATA: if (byte ,) { nmea_store_field(parser); parser-field_idx; parser-field_len 0; } else if (byte *) { nmea_store_field(parser); // 存最后一个字段 parser-state NMEA_STATE_WAIT_CHECKSUM; } else { parser-frame[parser-frame_len] byte; parser-checksum ^ byte; if (parser-field_len FIELD_BUF_SIZE - 1) { parser-field_buf[parser-field_len] byte; } } break; case NMEA_STATE_WAIT_CHECKSUM: // 第一个校验字符 if (is_hex_char(byte)) { parser-checksum_hex hex_to_nibble(byte) 4; parser-state NMEA_STATE_READ_CHECKSUM; } else { parser-state NMEA_STATE_WAIT_START; // 非法重新等 } break; case NMEA_STATE_READ_CHECKSUM: if (is_hex_char(byte)) { parser-checksum_hex | hex_to_nibble(byte); if (parser-checksum_hex parser-checksum) { parser-frame_valid 1; } else { parser-frame_valid 0; } parser-state NMEA_STATE_DONE; nmea_dispatch(parser); // 丢给上层业务 } else { parser-state NMEA_STATE_WAIT_START; } break; case NMEA_STATE_DONE: if (byte $) { parser-state NMEA_STATE_READ_ADDR; parser-frame_len 0; parser-checksum 0; parser-field_idx 0; parser-field_len 0; } break; } }这个状态机的精华在于校验和的异或计算是随字节流推进同步完成的不需要等到整帧结束再重新遍历一遍。字段的存储则是通过nmea_store_field把当前field_buf按语句类型的关键位置填入结构体。4.3 字段提取重点是“空字段”的处理NMEA语句里空字段太常见了。比如GGA语句在没有差分信号时第11个字段DGPS station ID就是空的。用strtok按逗号切分虽然直观但遇到连续两个逗号5.0,M,,*75时strtok会默认跳过空字符串导致字段号错位。我解决的办法是自己维护field_idx每当读到逗号就执行nmea_store_field即使field_len 0也照常把索引加一。这样一个空字段就表示成一个长度为0的缓冲区后面通过strlen(field_buf)判断是否为空。static void nmea_store_field(parser_t *parser) { if (parser-field_idx MAX_FIELDS) { memcpy(parser-fields[parser-field_idx], parser-field_buf, parser-field_len); parser-fields[parser-field_idx][parser-field_len] \0; } }所有字段以字符串形式存下来之后再根据语句类型调用不同的解析函数。比如GGA和RMC都含有经纬度但字段位置不同两个函数单独实现不搞“通用解析”便于调试和维护。5. 从报文到有用数据UTC时间转换、经纬度换算与儒略日解析出字符串只是第一层真正让数据可用还需要后续换算。这一节把我在工程里反复验证过的几个转换算法写出来。5.1 UTC时间转北京时间NMEA报文里的时间是UTC时间格式是hhmmss.sss要转成东八区北京时间需要加上8小时。这个“加8小时”涉及跨日处理不能直接对分秒做模运算。我在工程里的做法是先整体转成秒数uint32_t hhmmss_to_seconds(uint32_t hhmmss) { uint32_t hh hhmmss / 10000; uint32_t mm (hhmmss / 100) % 100; uint32_t ss hhmmss % 100; return hh * 3600 mm * 60 ss; }然后加8 * 3600秒再模24小时转回hhmmss。日期字段在RMC语句的第8个字段ddmmyy如果跨日日期也要加一天。判断“是否跨日”可以直接看加完后秒数是否大于等于86400。注意一下模块输出时间的精度到毫秒但NMEA在GGA语句里一般是hhmmss.sss如果直接用sscanf解析成整数毫秒会丢。我把字符串直接拆开精确到毫秒的字段用字符串保存显示时再格式化成2024-05-20 18:30:25.250这样的格式。5.2 经纬度从度分格式换算成十进制度数NMEA里的纬度是ddmm.mmmm格式比如3108.1234表示31度08.1234分。要转成十进制度公式是dec_deg degrees minutes / 60.0代码实现double nmea_lat_to_decimal(const char *field, char ns) { char degree_str[4] {0}; double minutes; double degrees; if (strlen(field) 5) { // 纬度 ddmm.mmmm 至少5位数字 memcpy(degree_str, field, 2); degree_str[2] \0; minutes atof(field 2); degrees atoi(degree_str); } else { return 0.0; } double decimal_deg degrees minutes / 60.0; if (ns S || ns W) { decimal_deg -decimal_deg; } return decimal_deg; }经度的处理逻辑相同只是度取前3位分从第4位开始。需要注意的坑是NMEA字段里的度分是整数部分和小数部分连在一起的不像十进制直接带小数点。我曾见过有人把10631.2345直接当成十进制度用结果偏了一百多公里。5.3 儒略日与GPS周内秒的关联有段时间我在做定位数据后处理要把时间戳换算成儒略日Julian Day。GPS系统和北斗系统都用“周周内秒”形式表示时间而儒略日是一个连续的时间计数这两个概念容易混。从公历日期推算儒略日有个经典公式uint32_t date_to_julian_day(uint32_t year, uint32_t month, uint32_t day) { if (month 2) { year--; month 12; } int32_t A year / 100; int32_t B 2 - A A / 4; return (uint32_t)(365.25 * (year 4716)) (uint32_t)(30.6001 * (month 1)) day B - 1524; }这只是把日期转成儒略日如果要得到“带时间的儒略日”再加上时/24 分/1440 秒/86400即可。GPS系统的起始时刻是1980年1月6日0时对应的儒略日2444244.5北斗系统起始是2006年1月1日0时对应的儒略日2453736.5。做多模时间对齐时这个转换就派上用场了。我在工程里的实际用途是这样的车载终端上报定位数据时服务器需要按UTC秒排序这些数据但设备端的本地时钟可能偏差。所以我直接在每个GGA有效帧里计算一个“本地当前秒数”和GPS周内秒然后通过串口上报服务端统一转成儒略日排序。这个方法在日志回溯和轨迹重放场景中非常管用。6. 实测环节开发板版本识别、常见坑位与调试验证写完了代码真正跑起来才发现一堆问题。这一节不是理论全是我在实验台上一个个试出来的。6.1 探索者开发板V2/V3对这次工程的影响V2和V3在USART1引脚上其实没有差异PA9/PA10都一样。差异主要体现在板载外设的默认分配——V3把部分引脚让给了新的音频接口如果你的工程同时用到PA2/PA3之类的引脚可能跟板载外设冲突。我这次工程只用了USART1和USART2用USART2做调试输出在V2和V3上都跑过代码通用。判断板子版本最方便的还是看配套资料或者串口设备描述不用太纠结。真正要注意的是V3板子的电源位置有改动如果用杜邦线外接GPS模块5V和GND最好从板子左侧的排针取别从右下角的扩展口取因为部分V3版本的扩展口5V在默认跳线配置下是断开的。6.2 调试输出与日志分级排查定位问题时最重要的还是把解析中间状态打出来。我用USART2做调试日志口波特率115200用一个宏控制日志等级#define LOG_D(fmt, ...) printf([DBG] fmt \r\n, ##__VA_ARGS__) #define LOG_E(fmt, ...) printf([ERR] fmt \r\n, ##__VA_ARGS__)在状态机里每解析完一帧如果校验失败就打印错误以及当前状态、帧长度、期望的校验和。这样子一上电就能看到类似输出[DBG] Frame: $GNGGA,...,*75 [DBG] Checksum OK, fields15 [DBG] GGA: lat31.1234, lon106.5678, fix1你千万别小看这个日志我第一次接上真实GPS模块时因为天线没放到窗边一直打fix0人还以为是代码解析错了。后来把$GNGSA的星数打出来才发现是可见卫星太少。6.3 几个实际踩过的坑第一个坑是天线方向。陶瓷贴片天线的正面一定要朝上且朝天空我一开始把它平放在金属桌面上结果好不容易出来GGA定位状态一直是0。把天线移到窗边朝上秒变1。第二个坑是GGA和RMC的定位状态不一致。模块冷启动时GGA可能先给fix1但RMC还是A这时候到底信谁的我在工程里的策略是以RMC的A/V作为移动状态判定以GGA的fix作为坐标有效性判定。如果RMC显示V哪怕GGA有坐标也不写入轨迹。第三个坑是PPS脉冲和串口数据不是同一时刻的。PPS上升沿代表整秒但串口里输出的GGA语句其时间字段其实是上一个整秒的世界时。想做精确时间同步的话需要把PPS引脚接到STM32的外部中断引脚用定时器捕获PPS上升沿再和NMEA时间字段对齐。我后来做4G北斗渔船定位终端时就是靠这套“PPS串口时间戳”把时间同步精度稳定在毫秒级。第四个坑是上电瞬间的乱码帧。模块刚上电的几百毫秒内串口可能输出$GNGSA的半个帧或者全零坐标帧。状态机虽然能自动恢复但我还是在应用层加了一个“定位稳定却少于3秒不记录”的滤波器避免把无效坐标写进日志。6.4 验证效果与工程复用建议最终工程跑通后我用一个简易OLED屏每秒刷新一次显示当前时间、纬度和经度、可见卫星数、定位状态。在宿舍窗户边放了三分钟成功稳定输出一串轨迹[INFO] 18:30:25.250, 31.12345, 106.56789, fix1, sat11 [INFO] 18:30:26.250, 31.12346, 106.56788, fix1, sat12 [INFO] 18:30:27.250, 31.12345, 106.56787, fix1, sat11这个工程后来被我直接移植到了好几个衍生项目里车载OBD定位盒、手持巡检终端、甚至一个无人机数传模块的地面站数据接口。只要是串口输入NMEA文本这套状态机都能无缝复用。如果你想加RTK差分数据RTCM的解析只需要在状态机里另起一个分支因为RTCM是二进制帧不能跟NMEA混在同一个状态机里处理这一点提前设计好接口会省下很多扩展时间。最后再分享一个小技巧调试定位模块时用一个“GPS信号状态”LED——PPS脉冲引脚直接驱动一个LED每锁一次秒脉冲闪一下。这个方法可以让你不打开串口调试器就知道模块工作是否正常排查硬件故障时特别管用。我后期所有带定位功能的产品硬件上都会留一个PPS指示灯这已经是我的习惯了。本文还有配套的精品资源点击获取