蜂窝模组六态状态机:CREG 与 CGREG 的漫游门为何不对称

作者:

在

结论:蜂窝数据链的核心问题不是「有没有连上」,而是一组被代码门控范围和发布相位放大的信任缺口:漫游许可只门控 CREG=roaming、并不单独拒绝 CGREG=roaming;ICCID 只校验字符集、不校验长度;connected=True 只表示看到了 ppp0 的接口 IP,不等于外网端到端连通或 DNS 已成功。以下基于固定源码快照 c8fb906 的静态调用链与三组假依赖探针,未接 Quectel 模组、SIM 或运营商网络。

进程与接口边界

蜂窝链是一个常驻 Python 进程加三个对外接口:控制口、数据口与状态文件。它以「模组可用」为目标,而不是以 onroad 为前提;宿主还会在启动时临时接管系统网络服务,这一接管本身也带状态恢复风险。

层 入口 行为
生命周期 process_config.py、Modem.run()、stop() modem 是常驻进程,不以 onroad 为前提。启动时若服务文件存在则尝试临时屏蔽/停止 ModemManager;正常执行到 stop() 末才尝试解除屏蔽/启动,不记录其原状态,也不检查命令返回码
控制口 Modem._at() /dev/modem_at0 以 9600 波特率打开;抢不到 /dev/shm/modem.lock、收到错误或超时均返回空响应。该锁也由 qcomgpsd 和 LPA 使用
数据口 PPPSession.start() pppd 使用 /dev/modem_at1 拨号 CID 1;connected 不是由进程启动直接确认,而是在 ppp0 发现 IP 后更新
配置 _do_initializing()、_params_changed() 从持久参数读取 GsmApn、GsmRoaming;SIM 身份变更、APN/漫游变更引发重新初始化
状态输出 _publish_state()、HardwareComma.get_modem_state() 状态先写 /dev/shm 临时文件再原子替换为 /dev/shm/modem;硬件抽象读取 JSON 后由 hardwared 发布 deviceState

六态状态机与关键函数

状态机在 INITIALIZING → SEARCHING → CONNECTING → CONNECTED → DISCONNECTING 之间流转。_do_initializing() 清理旧 PPP/路由、配置 AT、读取 IMEI/ICCID/IMSI/版本,对 EG25 应用专用设置并写入 APN;身份不完整会留在初始化态。部分 AT 配置调用没有逐条检查返回值,因此不能仅凭进入 SEARCHING 判定所有模组配置成功——探针也确认,AT+CGDCONT 返回空列表仍会发布身份并进入搜索态。

_poll_iface() 从 ppp0 提取 IP/peer,并调用 maybe_install_routes() 配置默认路由与策略路由表 1000,成功后调用 maybe_install_dns()。所以 connected=True 表示看到了接口 IP,不是外网端到端连通或 DNS 已成功的证明。

注册门:CREG 与 CGREG 的不对称

搜索态先读 CREG,没有响应则不查询 CGREG;拨号要求两者均报告 home 或 roaming。但漫游禁止分支只在 CREG 为 roaming 时阻断:当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。这是代码门控范围的问题——它不等于运营商实际会建立数据链,也不证明资费策略正确。

此外,_check_iccid() 周期性发现换卡,ICCID 查询空响应不会触发换卡,收到非空且不同的值才设置标记。PPP 退出后的处理是:前两次失败各执行一次数据口 DTR 复位并再次启动 pppd,第三次转入断开、不再复位/重拨;若此时已有 SIM 变更标记则直接转断开,不增加失败计数。

身份与遥测的宽边界

身份解析与遥测采样的接纳规则普遍宽松,且错误常常以「整组不更新」而不是「逐字段报错」的形式出现。

字段 接纳规则 边界
IMEI 要求全数字且长度为 14 至 17 长度有门,格式受限
ICCID 只检查字符是否属于十六进制集合 没有长度门,单字符 A 也可作为非空身份穿过初始化门
_atv() 前缀 用子串判断前缀并按第一个冒号切分 示例 noise:+QCCID:12345 得到 +QCCID:12345,非精确解析所请求响应
mcc_mnc 仅取全数字 IMSI 的前六位 不单独判定 MCC/MNC 的实际长度
采样项 边界行为
CSQ CSQ=99 视为缺测,却未拒绝 -1 或大于 31 的值;-1 产生负质量,40 的质量被上限钳成 100
COPS 制式码 网络制式码无效时仍可更新运营商名
频段通道号 无法解析时整组频段/通道都不更新
温度 列表中的 255 被跳过,但任一非数字项会令整组返回空增量
流量计数 tx_bytes 或 rx_bytes 其中一个读取/解析失败,整组返回空增量

结合增量发布,这些「整组返回空增量」会让旧值继续保留,因此这些状态字段不是逐字段实时状态。

启动接管与退出恢复

run() 只以 ModemManager 单元文件是否存在决定执行 mask --runtime、stop,不检查两个命令的退出码;stop() 无条件尝试 unmask --runtime、start,不回忆服务接管前是否已停止或屏蔽。恢复顺序为杀 PPP、清路由、删除状态文件、恢复服务,若中途清理抛异常,后续恢复不会执行。main() 在 run() 与 stop() 之间没有 finally,所以循环外启动异常会跳过 stop()。

另一个值得记录的相位问题是:run() 的异常捕获只修改内存中的枚举态,不在该分支发布 DISCONNECTING;随后正常的断开处理器先发布带 INITIALIZING 字段的初始快照,返回时又发布 INITIALIZING。因此若没有其他正常转移,消费者可能完全看不到这次异常的 DISCONNECTING 阶段。

AT 初始化同样是「逐条调用、宽验收」:最终只依据 AT+CGMI 的首行是否有回显决定通过,前面 AT_INIT 的空/错误应答不会阻止前进;EG25 专用六条命令也不检查应答,非 EG25 直接跳过。此外,特定 Webbing ICCID 前缀会直接允许漫游而不读取 GsmRoaming——这是代码识别规则,不是实卡资费保证。

断开处理的顺序也值得注意:它先发布 connected=False、state=INITIALIZING 的初始快照,随后才杀 PPP、清路由和复位数据口,因此状态发布与资源撤销不是同一事务——消费方可能先看到「已断开」,而旧 PPP 与路由仍在。

常见问题

connected=True 就代表设备能上网吗?

不代表。它只表示在 ppp0 上看到了接口 IP,不是外网端到端连通,也不是 DNS 已成功的证明;路由与 DNS 是后续独立步骤。

漫游被禁止时为什么仍可能进入拨号?

因为漫游允许判断只门控 CREG=roaming,没有单独拒绝 CGREG=roaming。当 CREG 为 home、CGREG 为 roaming 时,代码仍进入 CONNECTING。

ICCID 的解析有什么风险?

它只检查字符是否属于十六进制集合,没有长度门,单字符 A 也能作为非空身份穿过初始化门;这属于代码接纳规则,不表示模组会给出这种异常应答。

为什么模组状态字段会显示旧值?

温度、流量等采样项在任一子项解析失败时会让整组返回空增量,结合增量发布机制,旧值可能继续保留,因此这些字段不是逐字段实时状态。

延伸阅读

参考资料

  • 项目 02 —— openpilot-research/MODEM_DEEP_DIVE.md(蜂窝模组六态状态机、身份/遥测解析与生命周期接管,含源码行号索引)
  • 项目 02 —— openpilot-research/probes/test_modem_state_machine_contract.py(十一项注册门与发布相位隔离探针)
  • 项目 02 —— openpilot-research/probes/test_modem_identity_telemetry_contract.py(八项身份与遥测字段解析探针)

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

更新记录

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

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注