4、NMEA协议解析陷阱:$GPGGA、$GPRMC字段详解,常见解析错误

NMEA 0183 协议,说白了就是 GPS 模块的“普通话”。

我刚开始做定位的时候,觉得解析 NMEA 嘛,不就是按逗号切分字符串吗?太简单了。结果呢?被经纬度格式坑过,被校验和坑过,还被多线程下的野指针追着跑了好几个通宵。

今天咱们就把 $GPGGA 和 $GPRMC 这两个最常用的语句,掰开了揉碎了讲清楚。哪些地方容易掉坑,我一个个指给你看。

核心观点:NMEA 解析的难点不在协议本身,而在工程实现中的边界条件和并发安全。80% 的线上 bug 都出在经纬度转换、校验和计算和多线程数据竞争这三个点上。

NMEA 协议解析陷阱知识体系 NMEA 解析核心 字段解析陷阱 校验和计算陷阱 多线程数据竞争 经纬度 ddmm.mmmm → dd.dddddd UTC 时间与本地时间转换 状态位 A/V 的误判 异或计算范围遗漏 $ 和 * 的边界处理 缓冲区被覆盖 strtok 不可重入 避坑核心:先校验 → 再解析 → 最后用锁保护共享数据,顺序不能乱

4.1 $GPGGA 与 $GPRMC 字段详解

先看一个典型的 $GPGGA 语句长什么样:

$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47

我习惯把它分成三段来记:头部 + 数据体 + 校验和。数据体里一共 14 个字段,但咱们嵌入式工程师真正关心的,其实就 5 个。

字段序号 示例值 含义 常见坑
1 123519 UTC 时间 12:35:19 不是北京时间,需 +8 小时
2 4807.038 纬度 ddmm.mmmm 不是十进制度!
3 N 北纬/南纬 大小写都可能出现
4 01131.000 经度 dddmm.mmmm 经度是 3 位度!
6 1 定位状态 0=无效 1=单点 2=差分 0 时其他字段不可信

再看 $GPRMC,它比 GGA 多了速度和航向信息:

$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A

这里第 2 个字段 A 是状态位,A 表示有效,V 表示无效。嗯,这里要注意——有些模块在刚上电搜星阶段,会频繁输出 V,你要是直接拿数据去算位置,算出来就是错的。

避坑指南:我曾经在一个车载项目中,直接用了 $GPRMC 的经纬度做地图匹配,结果车在隧道里出来之后,状态位还是 V,但经纬度数据已经更新了。后来我加了一条规则:状态位为 V 时,整个数据包直接丢弃,不参与任何计算。

4.2 经纬度格式:最容易翻车的坑

这个坑我几乎每次培训都要强调。NMEA 里的经纬度是 度分格式,不是我们习惯的十进制度。

举个例子:4807.038 表示 48 度 07.038 分。

转换成十进制度的公式是:

十进制度 = 度 + 分 / 60.0

纬度:48 + 07.038 / 60.0 = 48.1173
经度:11 + 31.000 / 60.0 = 11.5167

为什么说这是坑?因为很多人直接 atof 转成浮点数就用,结果差了 60 倍。你想想看,在地图上偏 60 倍是什么概念?

我的习惯做法:写一个专门的转换函数,先提取度(小数点前两位),再提取分(小数点后部分),最后做除法。不要在业务代码里到处写转换逻辑,封装好一次,单元测试覆盖到位。

还有一个细节——经度是 3 位度。比如 01131.000,度是 011,分是 31.000。有些新手按纬度 2 位度去解析,经度就永远不对。

4.3 校验和计算:$ 和 * 的边界

NMEA 的校验和算法很简单:从 $ 之后到 * 之前(不含这两个字符),所有字节做异或。

但简单归简单,我见过太多人在边界处理上翻车。

// 正确的校验和计算
uint8_t calc_nmea_checksum(const char *buf)
{
    uint8_t checksum = 0;
    // 从 $ 后面第一个字符开始
    const char *p = buf + 1;
    while (*p != '\0' && *p != '*')
    {
        checksum ^= (uint8_t)(*p);
        p++;
    }
    return checksum;
}

// 错误的做法:从 buf[0] 开始,把 $ 也算进去了
// 或者:遇到 \r\n 才停止,把 * 后面的校验和字符也算进去了

为什么会这样?因为很多人用 strchr 找 $ 和 * 的位置,但没注意 $ 本身不应该参与异或。

避坑指南:我曾经在一个无人机飞控项目里,发现 GPS 数据偶尔会跳变。查了两天,最后发现是校验和计算时把 $ 也异或进去了。有些模块校验和不通过会重发,但有些模块不会,直接丢出错误数据。从那以后,我写校验和函数一定会加注释:/* 从 $ 之后开始,到 * 之前结束 */。

4.4 多线程下的数据竞争

嵌入式里用 NMEA,通常是一个串口中断或一个线程收数据,另一个线程做解析和使用。这就引出了数据竞争的问题。

我见过最典型的场景:

  • 接收线程:从 UART 读数据,拼成完整 NMEA 语句,存入共享缓冲区
  • 解析线程:从共享缓冲区取数据,做校验和、字段拆分、格式转换
  • 应用线程:拿解析好的经纬度去做导航计算

问题出在哪?

接收线程刚写到一半,解析线程就开始读。读到的数据半新半旧,经纬度可能前半部分是旧数据,后半部分是新数据。这种 bug 很难复现,但一旦出现,定位数据就是错的。

解决方案:

  1. 双缓冲机制:接收线程写 buffer A,解析线程读 buffer B,写完交换指针
  2. 互斥锁:用 mutex 保护共享缓冲区,但注意不要在中断里加锁
  3. 原子操作:如果数据量小,可以用原子变量标记数据就绪状态

我个人比较推荐双缓冲 + 信号量的方式。接收线程写完一个完整语句后,释放信号量;解析线程等待信号量,拿到后直接处理,处理完再等下一个。这样既不用锁,也不会丢数据。

还有一个容易被忽略的点:strtok 函数不是线程安全的。它内部维护了一个静态指针,多线程同时调用会互相干扰。我习惯用 strtok_r(可重入版本),每个线程传自己的上下文指针。

4.5 实战中的解析流程

最后,我总结一下在实际项目中,我处理 NMEA 解析的标准流程:

  1. 数据接收:从 UART 逐字节读取,拼成完整行(以 \r\n 结尾)
  2. 初步过滤:检查第一个字符是不是 $,长度是否合理
  3. 校验和验证:提取 * 后面的两位十六进制数,与计算值比对
  4. 语句类型判断:看 $ 后面 5 个字符是 GPGGA 还是 GPRMC
  5. 字段拆分:用 strtok_r 按逗号拆分
  6. 格式转换:经纬度转十进制度,UTC 转本地时间
  7. 数据发布:通过线程安全队列或回调函数,把解析好的结构体发给应用层

每一步都可能踩坑。但只要你把校验和、经纬度转换、多线程安全这三个点盯死了,80% 的问题都能提前规避。

最后提醒一句:不要相信任何 NMEA 数据。校验和通过只代表传输没出错,不代表数据本身是合理的。我习惯在解析完后加一层合理性检查——比如经纬度是否在合理范围内,速度是否超过物理极限。这些检查不费多少 CPU,但能救你一命。