标签: GNSS

  • GNSS 双路径与系统时间:u-blox 与 Quectel 的选择与边界

    结论:GNSS 在这套系统里是二选一的双路径:存在指定串口设备节点且未设置强制 Quectel 开关时选 u-blox,由 pigeond 与 ubloxd 同时运行,其余情况选 qcomgpsd;两条路径输出的定位主题名不同——u-blox 走 gpsLocationExternal,Quectel 走 gpsLocation,系统时间进程按参数选其中一条,不能只改名字就视为等价。时间校正本身条件严格:所选主题刚更新、logMonoTime 距当前不超过两秒、hasFix=True 且 GPS 时间在允许范围内才调用设置时间,且差值小于十秒不改。本文为固定源码快照 c8fb906 的静态阅读,未连接 u-blox、Quectel、天线或实板。

    平台门与消息契约

    平台选择由设备节点与持久化参数共同决定:存在指定串口设备节点且未设置强制 Quectel 开关时选 u-blox,此时 pigeond 和 ubloxd 同时运行,ublox() 还更新 UbloxAvailable 参数;其他情况下选 qcomgpsd。三者均受硬件与行车条件限制,设备节点判断不是芯片健康检测。需要注意的是,管理器会按当前设备节点重评 GNSS 生产者,但系统时间进程常驻且只在启动时选订阅主题,因此驻车与行车切换、运行中设备切换并不等价。

    边界 已核对行为
    平台门 串口设备节点存在且未设强制 Quectel 开关时选 u-blox,否则选 qcomgpsd
    u-blox 定位主题 输出 gpsLocationExternal
    Quectel 定位主题 输出 gpsLocation
    原始/卫星主题 ubloxRaw 是串口字节包;ubloxGnss 与 qcomGnss 承载测量、星历、卫星状态与诊断
    订阅选择 系统时间进程按 UbloxAvailable 选一条,且只在初始化时决定

    ubloxRaw 是串口字节包,ubloxGnss 与 qcomGnss 承载测量、星历、卫星状态与诊断等结构,它们都不是 GpsLocationData 的替代品。两条服务的频率与字段来源不同,接入时不能假定可互换。

    对比项 u-blox 路径 Quectel 路径
    运行进程 pigeond 与 ubloxd 同时运行 qcomgpsd
    物理链路 电源、复位、安全启动引脚与串口设备节点 AT 端口与 DIAG 串口,共享互斥锁文件
    帧格式与校验 UBX 前导 B5 62、长度与 Fletcher 校验 HDLC 解转义与 CRC16-X25
    定位主题 gpsLocationExternal gpsLocation
    时间来源 NAV-PVT 的 UTC,异常时置零 固定 18 秒 GPS-UTC 差,留有闰秒待办

    u-blox:供电、串口、UBX 与辅助定位

    set_power() 与串口封装类管理电源使能、复位、安全启动引脚与串口设备节点。初始化先做电源循环,再由 init_baudrate() 把默认 9600 波特率切到 460800;init_pigeon() 用带 ACK 的配置报文设置输出频率、消息种类与导航参数,最多尝试十轮。run_receiving() 把串口块发布为 ubloxRaw,首字节为 0x00 时尝试重新初始化;后台线程等待有效系统时间与网络后,从 u-blox 服务或代理下载辅助数据并送设备,接收过程中每五分钟尝试保存星历备份。

    UbxFramer.add_data() 从跨块字节流寻找 UBX 前导 B5 62、长度与 Fletcher 校验,坏帧丢首字节后重找;解析器parse_frame() 只处理 NAV-PVT、RXM-SFRBX、RXM-RAWX、NAV-SAT、MON-HW/HW2 等已列类型,其余返回空。NAV-PVT 映射把经纬度、海拔、速度、NED 速度和精度换算到 gpsLocationExternal,根据 flags 置 hasFix,UTC 日期异常时 Unix 时间置零。

    有两个容易误判的静态边界:其一,init() 调用 init_pigeon() 却不检查其布尔返回,十轮失败后仍进入接收循环,因此应显式上报电源、UART、ACK、首定位与持续断流状态;其二,主循环记录上游包时间,却并未把它写回发布消息的 logMonoTime——后者由消息构造在解析时生成,所以日志时间不能代表卫星测量时刻,硬件替换时必须单独保留采样时刻与处理延迟。

    Quectel:AT 与 DIAG 路径

    wait_for_modem() 等 AT 端口及 GNSS 子系统应答;at_cmd() 在一个共享内存互斥锁文件上加锁,与蜂窝管理进程共享 AT 端口——这个锁是接口所有权边界,新板卡驱动不能绕过它并发发 AT 指令。setup_quectel() 通过 DIAG/NV 开启 OEMDRE 日志、关闭 GNSS 省电模式、设置 NMEA 输出与时间并启动 GNSS,cleanup() 在信号退出时关闭 GNSS 与日志并拉低电源引脚。

    ModemDiag.hdlc_decapsulate() 对 DIAG 串口数据解转义并校验 CRC16-X25;主循环再按内外长度、日志类型和版本解析 OEMDRE、GPS 与 GLONASS 测量及位置,位置报告要求位置来源字段等于 2 且 GPS 周不是无效哨兵,海拔精度等于 500 时置 hasFix=False。位置映射把 ENU 速度转换到 NED 输出 gpsLocation,源码按固定 18 秒 GPS-UTC 差计算 Unix 时间,并留有闰秒更新待办;若把该路径作为设备时间源,必须验证当前闰秒、模组固件与真实 UTC 的一致性。

    系统时间与硬件契约

    系统时间进程发布 clocks,仅在所选 GPS 主题刚更新、logMonoTime 距当前不超过两秒、hasFix=True 且 GPS 时间在允许范围内时调用设置时间,差值小于十秒不改系统时间;该流程只处理系统 UTC,不保证相机帧起始、IMU 中断、DIAG 日志时间与卫星测量已经共时钟。面向硬件与插件化的最低契约是:每条 GNSS 提供方须声明物理链路、供电与复位、安全关机、协议及校验、观测时钟域、UTC 与闰秒来源、定位有效位与精度、首次定位及断流超时;gpsLocationExternal 与 gpsLocation 只能通过受控适配器切换,不能让任意插件覆盖系统时间或伪造定位有效位。

    常见问题

    系统怎么决定用 u-blox 还是 Quectel?

    由设备节点与持久化参数共同决定:存在指定串口设备节点且未设置强制 Quectel 开关时选 u-blox,由 pigeond 与 ubloxd 同时运行并更新 UbloxAvailable;其他情况选 qcomgpsd。设备节点判断只是平台门,不是芯片健康检测。

    gpsLocationExternal 和 gpsLocation 可以互换吗?

    不能只改名互换。前者由 u-blox 的 NAV-PVT 映射产出,后者由 Quectel 的 DIAG 位置映射产出,两条服务频率与字段来源不同;系统时间进程按 UbloxAvailable 选其中一条,且只在初始化时决定,平台切换后要检查服务重启与参数同步。

    日志里的时间等于卫星测量时刻吗?

    不一定。u-blox 主循环并未把上游包时间写回发布消息的 logMonoTime,该字段由消息构造在解析时生成,因此日志时间不能代表卫星测量时刻,硬件替换时必须单独保留采样时刻与处理延迟。

    为什么 hasFix=False 会出现?

    两条路径都有置假条件:u-blox 根据 NAV-PVT 的 flags 置 hasFix 并在 UTC 日期异常时把 Unix 时间置零;Quectel 要求位置来源字段等于 2 且 GPS 周有效,海拔精度等于 500 时置 hasFix=False。因此有效位要连同来源与时间一起解读。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_TIME_DEEP_DIVE.md(取用了平台门、两条定位主题、u-blox 与 Quectel 解析链、系统时间条件与硬件契约)。
    • 项目 02 —— openpilot-research/GNSS_BACKEND_SELECTION_SUBSCRIPTION_LIFECYCLE_DEEP_DIVE.md(仅用于确认订阅选择生命周期与运行中切换的边界)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • UBX 分帧的 65535 字节阻塞:GNSS 二进制解析器的三类失效边界

    结论:u-blox UBX 接收链的主要风险不在「字段解不出来」,而在三个输入契约缺口:跨块的前导前缀可能被整段丢弃、16 位长度字段可让一条未完成的长帧长期挡住后续合法帧、字段 DSL 解析完成后不检查尾随字节。三者都不会让进程崩溃,只会让上层在「看起来一直在发布数据」时误判。以下基于源码快照 c8fb906 的静态阅读与隔离探针,未接模组、未做路测。

    从字节块到完整帧:分帧器怎么切

    链路起点是串口:pigeond.run_receiving() 把读取到的字节块发布为 ubloxRaw,ubloxd.main() 按消息接收后交给 UbxFramer.add_data()。分帧器查找前导 B5 62,按头部小端长度取出 6 + payload_len + 2 字节,再用 _checksum_ok() 核对 UBX 的 CK_A/CK_B。坏校验只丢当前第一个字节并重新搜索;好帧完整消费;长度不够则暂存。

    真正微妙的是跨块边界。如果缓存里只剩单字节 B5,add_data() 会在 len(buf)<2 时保留它,下一块可以正常接上。但如果当前块是 00 B5——长度已至少为 2,又找不到完整的 B5 62——find()==-1 分支会清空缓存;下一块即使以 62 接上,合法帧也无法识别。隔离探针用一条校验正确的 B5 62 01 07 00 00 08 19 遍历所有单处切块点均能还原,但 00 B5 后接余下字节返回零帧。

    缓存尾部情形 代码行为 后果
    单字节 B5 len(buf)<2 时保留等待拼接 下一块可正常接出完整帧
    00 B5 等带杂字节前缀 找不到完整前导,find()==-1 清空缓存 下一块的合法帧前缀被丢掉,无法识别
    坏校验帧后接好帧 逐字节丢弃并重新搜索 能滑动一字节并恢复到好帧

    长度阻塞:一条 FF FF 的长帧能挡住什么

    若缓存以 B5 62 01 07 FF FF 开始,长度字段声明了 65535 字节 payload,分帧器在收到总计 65543 字节之前,既不会验证校验,也不会重新搜索前导。此时即使随后追加一条完整合法的短帧,该帧也仍留在缓存里、没有立即输出——隔离探针确认了这个状态。由于长度字段是 16 位,单条候选等待有限,但串口异常或丢字节仍能让后续帧长期被挡住,直到足够字节到达或上层重置。这里没有按消息类型限制长度、等待时间或缓冲年龄的门。

    字段 DSL:解释器契约不等于协议一致

    UBX 报文的字段用一套声明式 DSL 描述,注册、读取、组合、约束各在不同的执行路径上:

    层次 关键入口 输入约束及边界
    schema 注册 BinaryStruct.__init_subclass__() 按类注解声明顺序收集字段并安装逐字段 _read;示例 schema 在 Ubx、Gps、Glonass。
    基础读取 _parse_field() 整数/浮点/字节按 struct 读取并检查剩余长度;位字段按高位先读,切回字节字段时对齐到下一字节。
    组合字段 _parse_field() Switch 用已解析字段选分支;Array 用前序计数逐项读;Substream 先按声明长度切片再解析内部对象。
    类型约束 _parse_field() ConstType 不符即拒绝;EnumType 遇未知枚举值返回原始整数。

    BinaryStruct.from_bytes() 返回 _read() 结果,没有检查 reader 是否恰好耗尽输入;SubstreamType 也不检查内部 reader 的剩余字节。探针 Demo(u8).from_bytes(bytes([7, 99])).field 返回 7,证实尾部多余的 99 不会导致失败。另有几处边界值得记录:未知枚举保留整数;未匹配且无默认值的 Switch 返回 None 且不消费其后输入;数组计数为 2 而第二个 u16 不完整时抛 EOFError;BinaryReader.read_bytes(-1) 会返回切片并把游标从 0 移到 -1——这是通用 DSL 自定义输入的边界,UBX 顶层 length 是无符号 u16,未证明生产可触达。

    还有一层容易误读:真实 Ubx 顶层 schema 的 from_bytes() 会拒绝错误 magic,但 checksum 只是读作 u16、并不计算比对;而生产路径的 parse_frame() 直接截取 payload 按消息类型解析,根本不调用顶层 Ubx.from_bytes()。所以不能把顶层 ConstType 当成接收链的校验依据。SFRBX 是特例:手工检查 len(payload)==8+4*num_words,未知 GNSS ID 会抛异常并被主循环静默跳过。

    星历缓存与时间语义

    GPS 星历路径要求十个 32 位字,把每字右移六位去掉奇偶/填充后得到 30 字节子帧,再按 TLM/HOW 子帧号选择内容。缓存按 sv_id 收集 1/2/3 子帧,三者齐全后检查 IODC 低字节与两个 IODE 相等,才发布并清空;该 GPS 子帧缓存没有时间过期,跨重启/掉线混合只由 IOD 检查部分阻断,不能当作完整的新鲜度证明。GLONASS 路径要求四个字,转 16 字节字符串后按 freq_id 缓存 1-5 号字符串,对已知 superframe 号检查一致性,号未知时用 last_log_time 做约 10 秒窗口相关性判断。

    UbxFramer.last_log_time 在每次 add_data() 开始时更新为当前 ubloxRaw.logMonoTime,一次返回多个帧时仍是同一个最近块时间,它不是逐帧硬件采样时间。而 ubloxd.main() 对解析异常直接 continue 且不记录原因,外层若只看发布频率,无法区分坏校验、schema 错误、未知消息和缓存未齐。

    常见问题

    UBX 分帧器为什么可能丢掉合法的后一帧?

    当缓存里已经有字节却找不到完整前导 B5 62 时(例如块以 00 B5 结尾),find()==-1 分支会清空缓存;下一块即使以 62 接上,也拼不出完整前导,合法帧无法被识别。只有精确剩下单个字节 B5 时才会保留待拼。

    16 位长度字段会带来什么风险?

    若缓冲以 B5 62 01 07 FF FF 开头,长度声明为 65535 字节 payload,分帧器要等累计 65543 字节才验证校验或重搜前导;期间追加完整合法短帧也不会立即输出,且代码没有按类型限制长度、等待时间或缓冲年龄。

    字段解析成功就说明报文版本匹配吗?

    不是。from_bytes() 不检查 reader 是否恰好耗尽输入,尾部多余字节不会导致失败;EnumType 未知值会以原始整数保留。帧层核对的是 UBX 头部声明长度与校验,不代表具体消息 schema 严格匹配 payload 版本或长度。

    星历缓存能证明数据新鲜吗?

    不能。GPS 子帧缓存处没有时间过期,只靠 IODC 与两个 IODE 的检查部分阻断跨重启/掉线混合;GLONASS 在未知 superframe 号时以约 10 秒窗口做相关性判断。last_log_time 是最近块时间,不是逐帧采样时间。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_UBX_BINARY_PARSER_DEEP_DIVE.md(UBX 分帧、字段 DSL 与星历缓存,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_ubx_framer_stream_boundaries.py(分帧跨块与长度阻塞的四项隔离探针)
    • 项目 02 —— openpilot-research/probes/test_ubx_binary_struct_contract.py(字段 DSL 六项契约探针)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • Quectel DIAG 记录契约:7E 分帧、双层长度与时间语义

    结论:Quectel DIAG 接收链的真实风险不在「能不能解出卫星数」,而在三处宽松的输入契约:AT 文本用子串判断状态、7E 分帧没有单帧长度上限与结束符超时、内外两层长度校验只保证封装边界而不保证 payload 与版本/星数匹配。这些缺口都不会让进程崩溃,只会让上层在「日志仍在发布」时误判。以下基于固定源码快照 c8fb906 的静态阅读与隔离探针,未打开 AT/DIAG 端口、未写 NV、未做实板定位测试。

    从 AT 端口到 DIAG 日志:初始化顺序与文本解析

    链路入口先等 AT 端口。qcomgpsd.main() 创建 ModemDiag 并调用 setup_quectel();AT 命令通过 at_cmd() 锁住 /dev/shm/modem.lock,与 modem、LPA 共享所有权。at_cmd() 先取进程间锁再打开串口,写入以 CR 结尾的命令、丢弃回显并返回数据行。DIAG 串口是 /dev/ttyUSB0,以 115200 波特率、RTS/CTS 与 DSR/DTR 硬件流控独占打开。初始化会依次请求 NV 写/读、日志掩码、可选关闭已有 GNSS、DPO/自启配置、可选辅助时间、NMEA 输出、GNSS 启动与 OEMDRE 命令;外层 main() 在 setup_quectel() 返回后才初始化并拉高 GNSS_PWR_EN,信号清理则先拉低该脚再调用 teardown_quectel()。

    这一层的文本解析相当宽松:ERROR 与 +CME ERROR 只作为终止行,不会转成异常;若没有数据行,函数会像收到 OK 一样返回空字符串。wait_for_modem() 只要求端口路径出现并在 AT 文本中找到 +QGPS:,因此它接受 +QGPS: 0 的关闭状态,也没有整体 deadline;gps_enabled() 用 "QGPS: 1" in response 判断,连 +QGPS: 10 都会返回真。这些是文本解析语义,不代表真实模组会给出该状态码。

    重试也是嵌套的:try_setup_logs() 的十次重试嵌在 setup_quectel() 的五次重试内。隔离探针用始终失败的假 setup_logs() 观察到 50 次调用与 55 次一秒睡眠,并且每轮外层重试前都会重复发送 NV 写/读请求。它证明的只是主机调用顺序与软件重试上限,真实串口阻塞、NV 写入效果与总耗时都未验证;两层通用异常还会替换故障报告,无法从最终异常反推底层 DIAG 失败原因。

    层次 字段/机制 核对内容
    分帧 7E / 7D / CRC16 发送附 CRC16/X-25,转义 7D/7E 并以 7E 结束;解码用断言检查结尾与 CRC
    外层封装 <BH> 外层长度须等于内层字节数
    内层封装 <HHQ> 内层长度须等于内层总字节数,并取出 log_type 与模组 log_time

    7E 分帧与一个没有上限的接收循环

    接收端 recv() 以结束符从串口块拼接,并把未配对部分留在 pend,hdlc_decapsulate() 解转义后用断言核对结尾和 CRC。真正的问题是 recv() 没有单帧最大长度,也没有结束符等待超时:连续不含 7E 的输入会持续累积在局部列表中,脚本无法从中判断它是「正在等一帧」还是「已经失控积压」。

    同样要注意容错的边界:CRC、短帧、外层长度或日志版本异常没有在 main() 循环内转成「坏帧计数并继续」,多数会抛出异常或断言,需要由进程监督重启。send_recv() 在等待命令应答时会跳过遇到的 DIAG_LOG_F,所以设置/清理日志期间不能假定每个测量都能被发布。另有一处不对称:setup_logs() 对首个「查询日志 ID 范围」响应只解析内层 operation/status,没有检查外层 opcode,而后续逐类别设置 mask 的响应则显式断言 opcode;这证明的是主机解析门缺失,而非模组会发出这种交错回包。

    三条测量记录的字节边界

    DIAG 帧取到操作码后只处理 DIAG_LOG_F,随后用 parse_struct() 把声明字符串转成无隐式对齐的小端 struct 格式;名字含 _Flt 时即使声明为 uint32 也按 IEEE-754 float 读,含 _Dbl 则按 double 读。dict_unpacker() 使用 unpack_from,只要求输入长度至少为 schema 大小,不校验恰好耗尽;因此插件若沿用 C 结构体的默认 padding,或只看类型名,会错读浮点与后续字段。

    记录 头部 / 单星长度 主循环中的检查与边界
    OEMDRE 测量 95 / 109 字节 检查版本 2,按 svCount 切片并逐星 unpack_from;切片前不验证 95+109*svCount == payload_len,短片抛解包异常、尾随字节可被忽略
    GPS 测量 28 / 70 字节 检查版本 0;svCount>0 时用 len(sats)//svCount == 70(非精确总长度,两星多 1 字节仍通过);svCount==0 时不检查剩余长度
    GLONASS 测量 29 / 70 字节 与 GPS 共用星数检查,两者状态位扩展不同

    上述长度来自对 dict_unpacker() 的纯 Python 探针:OEMDRE (95,109)、GPS (28,70)、GLONASS (29,70);同一探针确认头部末尾额外 1 字节仍可解出字典,且 (2*70+1)//2 == 70。这证明了解码器的具体边界行为,但不证明模组实际会发送异常帧。

    时间、定位与有效性语义

    qcomGnss 的 union 承载 DR 测量、SV 多项式与 GPS/GLONASS 测量等。主循环把 DIAG 内层的 log_time 原样写入 logTs,没有在此处转换成 Unix 或 Linux 单调纳秒;发布消息的 logMonoTime 是消息构造时间,不能替代模组的测量时间。

    gpsLocation 映射只接受 u_PosSource==2 且 GPS week 非 0xFFFF 的位置报告,把弧度经纬/航向转成角度、ENU 速度变为 NED;hasFix 以 verticalAccuracy != 500 判定,而该字段取自 q_FltVdop(垂直精度因子),能否当作以米计的垂直精度需要模组协议与实测核对,不能靠字段名推断。Unix 时间由 GPS 周与周内毫秒减固定 18 秒生成,随后由 timed 根据定位主题的新鲜度、hasFix 与时间范围决定是否校时。

    常见问题

    DIAG 接收为什么会持续积压?

    因为 recv() 没有单帧最大长度,也没有结束符等待超时;连续不含 7E 的输入会持续累积在局部列表中,脚本无法区分「正在等一帧」与「已经失控积压」。

    外层与内层长度校验通过就说明 payload 匹配吗?

    不是。两层长度只保证封装边界,不保证该类型 payload 与版本/星数匹配。OEMDRE 在切片前不验证 95+109*svCount == payload_len,尾随字节可被忽略。

    GPS 测量两颗星时多 1 字节为什么仍能通过?

    因为主循环用的是 len(sats)//svCount == 70,而不是精确总长度校验;整数除法让多出来的 1 字节被容忍,svCount==0 时甚至不检查剩余长度。

    verticalAccuracy 能当作以米计的垂直精度吗?

    不能直接这样用。它取自 q_FltVdop,其声明注释是垂直精度因子;是否等于以米计的垂直精度,需要模组协议与实测核对,不能仅凭 hasFix 的判定条件推断。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/GNSS_QUECTEL_DIAG_RECORD_CONTRACT_DEEP_DIVE.md(AT/DIAG 调用链、7E 分帧与三条记录的字节边界,含源码行号索引)
    • 项目 02 —— openpilot-research/probes/test_quectel_modemdiag_contract.py(CRC16/X-25、转义往返、分块保留与 send_recv() 跳帧六项探针)
    • 项目 02 —— openpilot-research/probes/test_quectel_nested_setup_retry_contract.py(内层十次嵌套外层五次的调用次数与睡眠统计)

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11