Air780E 多卡短信中枢(二):三个 ttyACM、重复序列号与热拔插陷阱

最后更新:2026-08-04
所属系列:Air780E 多卡短信中枢实战 · 第 2 / 6 篇
文章目录 12 个章节

软件模拟器跑通以后,我原本以为真机接入只剩“把 /dev/pts/* 换成 /dev/ttyACM*”。实际插上两块 Air780E 后,硬件立刻推翻了几个看似合理的假设:一个模块不是一个串口,USB 序列号不能标识设备,会响应 AT 的口不一定会上报短信,模块存储也远小于预期。

更隐蔽的是,有些问题不是立刻报错,而是让系统保持在一个“看起来还在线”的假状态。下面按实际排查顺序复盘。

一、第一关:为什么没有 /dev/ttyACM*

Air780E USB 版通常以 19d1:0001 枚举,并由 Linux 的 cdc_acm 驱动创建串口。如果插上以后什么都没有,第一反应很容易是线材、供电或模块故障。

但我遇到过另一个更基础的原因:系统刚升级内核但没有重启,当前运行内核与 /lib/modules/ 中已安装的模块版本不一致。内核找不到配套的 cdc_acm,自然也不会出现设备节点。

排查顺序应该是:

uname -r
ls /lib/modules/
modprobe -n -v cdc_acm

如果运行中的内核版本已经不在 /lib/modules/,先重启进入新内核,再讨论硬件。

这次经历提醒我:

“设备节点不存在”只是现象,USB 枚举、内核驱动和用户态权限是三层不同的问题,不要从最贵的硬件故障开始猜。

二、一个模块为什么有三个串口

Air780E 接入后会同时出现 RNDIS 接口和三个 CDC-ACM 接口。参考布局大致如下:

RNDIS 控制 + 数据
CDC-ACM #1
CDC-ACM #2
CDC-ACM #3

因此一个模块可能对应三个 /dev/ttyACM*。不能看到 ttyACM3 就认定它是 AT 口,最直接的方法仍然是逐个发 ATI

for port in /dev/ttyACM*; do
  echo "--- $port"
  echo 'ATI' | socat - "$port"
done

真机上接口号 0206 都会响应部分 AT 指令,但新短信通知 +CMTI 走的是 02。这意味着“能返回 OK”还不够,真正的验证必须包含:

  1. 查询模块身份;
  2. 查询 SIM 与网络状态;
  3. 给这张卡发一条短信;
  4. 确认该口能收到 +CMTI
  5. AT+CMGR 读出短信。

只做 ATI 探测,可能认领到一个能查询、却不能实时收信的口。

三、最意外的坑:两个模块序列号完全相同

Linux 下固定串口名的常见方式是 /dev/serial/by-id。它通常根据 USB 序列号生成稳定链接,看起来正好适合多模块。

但两块同型号模块实测都报告:

SerialNumber=000000000001

这个值不是设备唯一身份,而是通用占位值。于是 by-id 直接失去意义:两个模块会碰撞,无法稳定区分。

退一步:按 USB 物理端口绑定

可以用 udev 的端口路径和接口号生成符号链接:

SUBSYSTEM=="tty", KERNELS=="1-3", \
  ATTRS{bInterfaceNumber}=="02", SYMLINK+="air780e-a"

这个方案能工作,但身份变成了“插在哪个孔”。只要换 USB 口、换 Hub 层级或调整接线,就要重新配置。

更根本的问题是:真正唯一的 IMEI 存在 AT 层,而 udev 只能看 USB 描述符。udev 永远无法知道当前串口后面是哪块模块。

最终方案:让 Agent 自己认设备

Agent 启动和重连时枚举所有候选串口,逐个查询:

ATI
AT+CGSN
AT+ICCID

然后按照配置中的 IMEI 或 ICCID 认领匹配端口:

[[devices]]
name = "modem-a"
imei = "000000000000001" # 示例值

这样做以后:

  • ttyACM 编号变化不影响;
  • 模块换 USB 口不影响;
  • 两块模块 USB 序列号相同不影响;
  • USB 复位后重新枚举也能自动找回来;
  • 不响应完整探测的其他 ACM 口会自然被排除。

这里最大的经验是:

稳定身份必须来自设备自身,而不是操作系统临时分配的路径。路径适合定位,IMEI / ICCID 才适合认领。

四、ModemManager:串口上还有一个看不见的竞争者

GNOME、KDE 或部分服务器发行版可能预装 ModemManager。它会主动探测 /dev/ttyACM* 并发送 AT 指令。

如果自己的 Agent 同时操作同一端口,症状通常不是明确的“端口被占用”,而是:

  • 响应偶尔错位;
  • 命令莫名超时;
  • 一条命令收到另一条命令的结果;
  • URC 与响应混在一起,表现不稳定。

处理方式可以是停用服务:

sudo systemctl mask ModemManager

也可以保留 ModemManager,但让它忽略对应 VID/PID:

SUBSYSTEM=="usb", ATTR{idVendor}=="19d1", \
  ATTR{idProduct}=="0001", ENV{ID_MM_DEVICE_IGNORE}="1"

即使开发机上没有安装 ModemManager,部署包里仍值得保留忽略规则,因为换一台桌面 Linux 主机后,问题可能马上出现。

五、短信存储不是历史库,甚至只有 10 条

设计阶段参考常见 SIM 容量,预估短信存储能放 20~50 条。真机执行 AT+CPMS? 后得到的结果却是:

"SM": 0/10
"ME": 0/10

SIM 和模块内存都只有 10 条,切到 ME 没有任何收益。

这彻底确定了收信流程:

+CMTI 到达
  → AT+CMGR 读取
  → 写入 Agent SQLite
  → 生成待确认事件
  → AT+CMGD 删除模块中的原短信

模块存储只能当临时收件箱,绝不能当历史记录。主机关机时最多只能靠这 10 个槽位兜底,超过以后,新短信可能直接丢失。

因此“收到即读即删”不是性能优化,而是数据完整性的硬约束。

六、供电不足会伪装成软件重连问题

Air780E 发射瞬间电流可能接近 2A。前置 USB、细线材或无源 Hub 可能导致模块复位、掉网或重新枚举。

软件上看到的是:

串口消失 → WebSocket 仍在线 → Worker 重连 → ttyACM 编号变化

如果只盯着 Python 日志,很容易把它当成发现逻辑或重试策略的问题。实际部署中应优先使用:

  • 主板后置 USB;
  • 带独立供电的 Hub;
  • 质量可靠的短线;
  • 能观察供电和 USB 重枚举的系统日志。

硬件不稳定时,重试只能降低影响,不能创造足够的电流。

七、一次热拔插暴露出的三个软件缺陷

真机测试时直接拔掉模块,页面却一直显示在线。这个问题最终不是一个 bug,而是三个小问题叠加:

  1. 串口读失败发生在事件循环的 reader 回调里,异常只到了 asyncio 的兜底处理器;
  2. transport 想用空字节 b"" 通知上层 EOF,但 _feed 看到空数据直接返回;
  3. 周期性的信号和存储查询又吞掉了 ATError,Worker 没有得到明确离线信号。

结果是:底层已经失联,上层状态机却没有任何事件触发状态迁移。

修复方向不是“把在线超时调短”,而是打通错误传播链:

文件描述符 EOF / 读错误
  → transport 明确关闭
  → 当前命令失败
  → Worker 标记离线
  → 释放端口
  → 进入带抖动的重新发现

这个案例非常典型:

可靠系统不只要设计成功路径,还要明确“谁负责宣布失败”。如果每层都觉得错误会由下一层处理,最后就会留下僵尸状态。

八、同步串口打开不能堵住事件循环

serial.Serial() 是同步阻塞调用。探测一个存在但不应答的端口时,它可能卡住较长时间。

最初把它直接放在异步发现流程里,结果一个坏端口就能阻塞整个 Agent:

  • 其他模块不能采样;
  • WebSocket 心跳发不出去;
  • Server 因 ping 超时断开连接;
  • 原本只是串口探测慢,最后表现成全系统离线。

最终把阻塞打开和探测移到线程:

transport = await asyncio.to_thread(open_serial_transport, port)

异步代码里最危险的往往不是 await 写错,而是某个看似普通的同步库调用悄悄占住事件循环。

九、并发启动还会制造一次状态竞争

Agent 的 ServerLink 和多个 Worker 并行启动。连接很快时,hello 可能先于端口发现发送,Server 因此收到空端口或旧端口。

等待所有硬件发现完再连接看似能解决,但会让一块坏模块拖住整个链路。最终选择让后续 status 帧也携带当前端口,允许 hello 先建立会话,再由状态事件收敛到真实值。

这比人为规定启动顺序更稳健:

  • 链路和硬件互不阻塞;
  • 晚到的信息可以修正早期快照;
  • USB 重枚举后的端口变化也会自然更新。

十、一份更可靠的接入顺序

以后再接类似 USB AT 模块,我会按这个顺序验证:

  1. uname -r/lib/modulescdc_acm
  2. dmesg 确认 VID/PID、接口和重枚举;
  3. 检查串口属组,Arch 常见 uucp,Debian / Ubuntu 常见 dialout
  4. 排除 ModemManager 抢口;
  5. 对每个 ACM 口执行完整 AT 探测;
  6. 用真实短信确认 URC 从哪个口出来;
  7. 查询 IMEI、ICCID、SMSC、注册状态和存储容量;
  8. 插入第二块模块,确认 USB 序列号是否唯一;
  9. 做热拔插、换 USB 口和断电重连;
  10. 最后再安装 systemd 常驻服务。

硬件接入最重要的不是记住某个 ttyACM 编号,而是建立一套能推翻假设的验证流程。下一篇会继续向上一层,讲 PDU、URC、长短信,以及 Agent 与 Server 如何做到“允许重复,但不能丢失”。