用双报文配对挡住GNSS轨迹里的旧坐标
定位模块还在吐数据,地图上的点却早已不是“现在”。这个故障很容易被误判成天线不好。实际上,串口缓冲里积压的旧报文、跨时刻拼接的经纬度,甚至重复播放的同一组数据,都能让轨迹看起来还活着。
轨迹记录器需要回答的第一件事,是这一个点能不能写入。本文给接收链路加一道门,把 RMC 与 GGA 按同一 UTC 历元配对,再检查状态、坐标和时间。完整程序可直接运行,不需要先焊板。

图1 LTE 与 GNSS 扩展板叠在 OrangePi Zero3 上的装配照片。它用于说明硬件结构,不代表下文程序已经做过接收机实测。
先把串口的归属弄清楚
板上 U3 是 Air780E,U4 是 LC29H。后者是双频 GNSS 系列,RTK 等能力与具体后缀、固件及配置有关。工程符号只写了 LC29H,不能凭这几个字就承诺厘米级定位。

图2 从实际 EDA 工程重新导出的 P2 总览,版本 V1.0,更新日期 2023-11-10。灰色器件保留了工程中的不装配显示;电源、LTE 和 GNSS 分区可在这张图中对应。

图3 U4 的 23 脚接 +3V3,21 脚 RXD 对应 GNSS_RXD,20 脚 TXD 对应 GNSS_TXD;右侧网络分别连到 UART5-TX 与 UART5-RX。
继续看 U3,会发现同一组 GNSS 网络还有另一条去路。

图4 U3 的 AUX_RXD 通过 R6 接 GNSS_TXD,AUX_TXD 通过 R7 接 GNSS_RXD,R6、R7 均标为 0Ω。
如果 OrangePi 的 UART5-TX 和 Air780E 的 AUX_TXD 同时作为输出,就可能争用 LC29H 的接收线。R7 是否装配、两端引脚实际模式如何,都要查清。软件校验修不了两个输出互相顶住的硬件问题。

图5 P1 的 U2 排针引出了 UART5-TX、UART5-RX。这里的网络名称不能直接换算成操作系统设备名,也不能把 P2 的 CN1 默认当成 GNSS 独立接口。
本文的软件入口是一台接收机已经正常输出的 ASCII NMEA 行。接实物前,应确认模块完整型号、接口电平、波特率、串口占用和唯一发送路径;不按照片直接接线,也不给未经确认的整板上电步骤。

图6 同一工程的顶铜与顶丝印,R6、R7 位于 U4 下方附近。完整板图用于对应器件位置,不能替代网表及通断核对。

图7 底铜与底丝印,从顶面方向观察、未镜像,因此底层文字反向;这是 EDA 图而非翻面实物照。图像只做保真缩放及轻度提亮。
两条都像正常数据,拼起来仍可能是错的
RMC 提供 UTC、有效状态、经纬度和日期;GGA 提供 UTC、定位质量、经纬度等信息。它们都包含坐标,但不能取“最近一条 RMC”和“最近一条 GGA”随手拼起来。
例如 RMC 为 12:00:00.000,GGA 为 12:00:01.000,两条都通过校验和,也属于不同历元。移动设备在一秒里已经可能走出一段距离。
程序把 120000、120000.0、120000.000 归一成相同的整数微秒键。只有键完全相同才配对,不做“差不多一秒”的近邻匹配。GGA 没有日期,完整日期取自配对 RMC;午夜 23:59:59 到次日 00:00:00 不能靠主机当天日期拼接。
这里有两道不同的时间检查:
- 绝对时效使用可信的主机 UTC,允许报文最多落后 2 秒、超前 0.5 秒,挡住串口里快速涌出的旧缓存。
- 本地存活使用单调时钟。一个配对被接纳后,1.5 秒没拿到更新历元就失效。重复同一 UTC 不延长这个期限。
只用“最近收到数据的时间”会被旧缓存骗过;只用系统时间又容易受到校时跳变影响。程序同时观察两种时钟的增量,差异超过 0.5 秒就清除当前点和未配对缓存,等待新的数据。
这些秒数是面向 1Hz 日志输入的应用策略。接收机频率、传输延迟改变后,需要重新预算,不能照搬到任何更新率。
校验和之外,还得挑掉不能写入的点
校验和计算 $ 与 * 之间所有 ASCII 字符的异或值,不包括两端符号。它能发现一部分传输错误,不能证明定位准确,也不提供身份认证。
本例要求 RMC 状态为 A,FAA 模式为 A、D、R、F 之一;GGA 质量只接纳 1、2、4、5。推算、手动和模拟数据不进入这份“真实卫星轨迹”记录。若带有额外导航状态,只允许空值或 S。缺少 FAA 模式的旧版 RMC 会被拒绝,这是保守的应用约束,不是宣称所有 NMEA 接收机都必须这样输出。
坐标也要查。3000.0000,N 表示北纬 30 度;3060.0000,N 的分钟已达 60,应拒绝。度数、半球、极点和经线边界分别检查,不把 NaN、负号或错误格式交给地图组件处理。
同历元的两组经纬度每一轴差值不得超过 0.00001°。这是报文一致性阈值,纬向约 1.1 米,经向距离随纬度变化,不是定位误差指标。HDOP 无量纲,也不能直接当成几米误差。通过这些门槛,仍不等于通过了 RTK 精度验收。
可以直接运行的完整程序
环境为 Python 3.12,依赖全部来自标准库。把下面内容保存为 epoch_gate.py,执行 python epoch_gate.py 即可。演示在程序内注入固定的合成 UTC 和坐标,不使用电脑实际位置,也不要求电脑时钟等于演示日期。
"""GNSS epoch gate v1.0.0. Python 3.12, standard library, MIT.
One Gate per physical receiver. Input is complete ASCII RMC/GGA lines.
This application policy requires RMC FAA mode A/D/R/F and GGA quality 1/2/4/5.
"""
from collections import OrderedDict
from datetime import datetime, timezone
from decimal import Decimal
from functools import reduce
from operator import xor
import re
def sentence(body):
return f"${body}*{reduce(xor, body.encode('ascii'), 0):02X}"
def fields(line):
line = line.rstrip("\r\n")
if len(line) > 160 or not re.fullmatch(r"\$[\x20-\x7e]+\*[0-9A-Fa-f]{2}", line):
raise ValueError("frame")
body, check = line[1:].rsplit("*", 1)
if "*" in body or reduce(xor, body.encode("ascii"), 0) != int(check, 16):
raise ValueError("checksum")
return body.split(",")
def utc_key(text):
if not re.fullmatch(r"\d{6}(?:\.\d{1,6})?", text):
raise ValueError("UTC format")
h, m, s = int(text[:2]), int(text[2:4]), Decimal(text[4:])
if h > 23 or m > 59 or s >= 60:
raise ValueError("UTC range; leap second not supported")
return int((h * 3600 + m * 60 + s) * 1000000)
def coordinate(text, hemi, latitude):
n = 2 if latitude else 3
if not re.fullmatch(r"\d{" + str(n + 2) + r"}(?:\.\d+)?", text):
raise ValueError("coordinate format")
d, minutes = int(text[:n]), Decimal(text[n:])
limit = 90 if latitude else 180
if hemi not in (("N", "S") if latitude else ("E", "W")):
raise ValueError("hemisphere")
if minutes >= 60 or d > limit or (d == limit and minutes != 0):
raise ValueError("coordinate range")
return float((d + minutes / 60) * (-1 if hemi in ("S", "W") else 1))
def position(f, i):
return (coordinate(f[i], f[i + 1], True),
coordinate(f[i + 2], f[i + 3], False))
class Gate:
def __init__(self):
self.pending = OrderedDict()
self.current = None
self.last_epoch = None
self.clock = None
def _tick(self, mono, wall):
if self.clock is not None:
dm, dw = mono - self.clock[0], wall - self.clock[1]
if dm < 0 or abs(dw - dm) > 0.5:
self.current = None
self.pending.clear()
self.clock = (mono, wall)
for key in list(self.pending):
if mono - self.pending[key]["start"] > 1.5:
del self.pending[key]
def read(self, mono, wall):
self._tick(mono, wall)
if self.current is None:
return None
fix, accepted = self.current
if mono - accepted > 1.5 or not -0.5 <= wall - fix["utc"] <= 2.0:
self.current = None
return None
return dict(fix)
def feed(self, line, mono, wall):
self._tick(mono, wall)
try:
f = fields(line)
if not re.fullmatch(r"(?:GP|GN|GB|BD|GL|GA)(?:RMC|GGA)", f[0]):
return None
kind, key = f[0][-3:], utc_key(f[1])
if kind == "RMC":
if len(f) < 13 or f[2] != "A" or f[12] not in ("A", "D", "R", "F"):
self.current = None
self.pending.clear()
return None
if len(f) > 13 and f[13] not in ("", "S"):
self.current = None
self.pending.clear()
return None
p = position(f, 3)
if not re.fullmatch(r"\d{6}", f[9]):
raise ValueError("date")
# Deliberate 2000..2099 policy; freshness check rejects other dates.
date = datetime(2000 + int(f[9][4:]), int(f[9][2:4]),
int(f[9][:2]), tzinfo=timezone.utc)
data = (date.timestamp() + key / 1000000, p)
if not -0.5 <= wall - data[0] <= 2.0:
return None
else:
if len(f) < 10 or f[6] not in ("1", "2", "4", "5"):
self.current = None
self.pending.clear()
return None
data = (position(f, 2), int(f[6]))
bucket = self.pending.setdefault(key, {"start": mono})
bucket[kind] = data
self.pending.move_to_end(key)
while len(self.pending) > 8:
self.pending.popitem(last=False)
if "RMC" not in bucket or "GGA" not in bucket:
return None
stamp, rp = bucket["RMC"]
gp, quality = bucket["GGA"]
del self.pending[key]
if not -0.5 <= wall - stamp <= 2.0:
return None
if self.last_epoch is not None and stamp <= self.last_epoch:
return None # Duplicate/reordered epochs never extend the deadline.
if max(abs(rp[0] - gp[0]), abs(rp[1] - gp[1])) > 0.00001:
self.current = None
return None
self.last_epoch = stamp
fix = {"utc": stamp, "lat": rp[0], "lon": rp[1], "quality": quality}
self.current = (fix, mono)
return dict(fix)
except (ValueError, IndexError, ArithmeticError):
return None # Malformed input cannot refresh current.
def demo():
# Synthetic coordinates, not a person's location or field measurement.
rm = "GNRMC,120000.000,A,3000.0000,N,12000.0000,E,0.0,0.0,071126,,,A"
gg = "GNGGA,120000,3000.0000,N,12000.0000,E,1,10,0.9,12.0,M,0.0,M,,"
wall = datetime(2026, 11, 7, 12, tzinfo=timezone.utc).timestamp()
gate = Gate()
print("synthetic input, UTC 2026-11-07T12:00:00Z")
print("RMC only:", gate.feed(sentence(rm), 0.0, wall))
print("same-epoch GGA:", gate.feed(sentence(gg), 0.1, wall + 0.1))
gate.feed(sentence(rm), 1.0, wall + 1.0)
print("duplicate pair:", gate.feed(sentence(gg), 1.1, wall + 1.1))
print("at 1.7 s:", gate.read(1.7, wall + 1.7))
old = Gate()
old.feed(sentence(rm), 0.0, wall + 60)
print("60 s stale:", old.feed(sentence(gg), 0.1, wall + 60.1))
if __name__ == "__main__":
demo()
每个物理接收机单独创建一个 Gate。GP、GN、GB 等前缀表示 talker,不是设备唯一编号;不能把两台接收机的数据先混合,再交给同一个对象。
实物程序接入时,对每一条完整行调用 feed(line, time.monotonic(), time.time()),只把返回的新点写入轨迹。上层要限制串口行缓冲长度;没有新行时也应定期调用 read(time.monotonic(), time.time()),例如每 200ms 一次,返回 None 就清掉界面上的“当前定位”状态。不要仅在收到数据时检查超时。
本例只处理 RMC/GGA 的位置有效性,不处理海拔、速度及全部 NMEA 私有句。时间支持微秒精度、日期按 2000~2099 年解释;闰秒 23:59:60 明确拒绝。主机时间必须可信,这段代码也不抵御恶意伪造报文或 GNSS 欺骗。
让重复帧和旧缓存自己露出问题
演示首先只送入 RMC,结果为 None。随后送入同历元 GGA,得到一个有效点。到 1.0 秒再次送同一组报文,程序不产生新点;在 1.7 秒读取,旧点已失效。把同一组输入整体延后 60 秒,则从一开始就不被接纳。

图8 由本次程序真实运行日志排版的结果图,输入为合成报文,未连接接收机。23 项自动测试全部通过,覆盖跨午夜、报文乱序、不同历元、错误校验和、坐标越界、无效状态、重复续命、时钟跳变、断流和缓存上限等情况。
未配对缓存最多 8 个历元,超过 1.5 秒清除,避免坏输入把内存越攒越多。带有效校验和的明确无效定位会立即清空当前点;格式损坏的行只丢弃,不刷新期限,现有点仍按期限失效。这两种情况处理不同,排错时也值得分别计数。
如果接入后一直没有点,先保留原始文本,按顺序查校验和、RMC 模式字段、两句 UTC 是否相同、主机 UTC、坐标格式和状态字段。不要为了让地图动起来,把门槛全部关掉。接口稳定、报文同历元、时效受控之后,才值得继续谈轨迹平滑或差分定位。
工程与资料
硬件工程及装配图采用大奔的 pirtk,页面许可 CC0 1.0。原理图为 Schematic1 V1.0,P1/P2 更新于 2023-11-10;本文从实际嘉立创 EDA 工程重新导出原理图和 PCB 顶底层,局部裁剪、保真缩放,未修改电路或补绘连线。本文独立实现数据门控程序,代码按 MIT 许可提供;没有复用第三方固件,完整源码已在正文中。
项目及可打开的 EDA 工程入口 https://oshwhub.com/32170z/pirtk
LC29H 官方产品资料 https://www.quectel.com.cn/product/gnss-lc29h-2
NMEA 字段参考 https://gpsd.io/NMEA.html
验证范围为工程静态核对及 Python 合成输入测试,不包含整板上电、真实卫星轨迹、射频性能和 RTK 精度实测。
技术交流,欢迎关注微信公众号:美男子玩编程。



