4、NMEA协议解析陷阱:$GPGGA、$GPRMC字段详解,常见解析错误
NMEA 0183 协议,说白了就是 GPS 模块的“普通话”。
我刚开始做定位的时候,觉得解析 NMEA 嘛,不就是按逗号切分字符串吗?太简单了。结果呢?被经纬度格式坑过,被校验和坑过,还被多线程下的野指针追着跑了好几个通宵。
今天咱们就把 $GPGGA 和 $GPRMC 这两个最常用的语句,掰开了揉碎了讲清楚。哪些地方容易掉坑,我一个个指给你看。
核心观点:NMEA 解析的难点不在协议本身,而在工程实现中的边界条件和并发安全。80% 的线上 bug 都出在经纬度转换、校验和计算和多线程数据竞争这三个点上。
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 很难复现,但一旦出现,定位数据就是错的。
解决方案:
- 双缓冲机制:接收线程写 buffer A,解析线程读 buffer B,写完交换指针
- 互斥锁:用 mutex 保护共享缓冲区,但注意不要在中断里加锁
- 原子操作:如果数据量小,可以用原子变量标记数据就绪状态
我个人比较推荐双缓冲 + 信号量的方式。接收线程写完一个完整语句后,释放信号量;解析线程等待信号量,拿到后直接处理,处理完再等下一个。这样既不用锁,也不会丢数据。
还有一个容易被忽略的点:strtok 函数不是线程安全的。它内部维护了一个静态指针,多线程同时调用会互相干扰。我习惯用 strtok_r(可重入版本),每个线程传自己的上下文指针。
4.5 实战中的解析流程
最后,我总结一下在实际项目中,我处理 NMEA 解析的标准流程:
- 数据接收:从 UART 逐字节读取,拼成完整行(以
\r\n结尾) - 初步过滤:检查第一个字符是不是
$,长度是否合理 - 校验和验证:提取
*后面的两位十六进制数,与计算值比对 - 语句类型判断:看
$后面 5 个字符是 GPGGA 还是 GPRMC - 字段拆分:用
strtok_r按逗号拆分 - 格式转换:经纬度转十进制度,UTC 转本地时间
- 数据发布:通过线程安全队列或回调函数,把解析好的结构体发给应用层
每一步都可能踩坑。但只要你把校验和、经纬度转换、多线程安全这三个点盯死了,80% 的问题都能提前规避。
最后提醒一句:不要相信任何 NMEA 数据。校验和通过只代表传输没出错,不代表数据本身是合理的。我习惯在解析完后加一层合理性检查——比如经纬度是否在合理范围内,速度是否超过物理极限。这些检查不费多少 CPU,但能救你一命。