软件模拟器跑通以后,我原本以为真机接入只剩“把 /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
真机上接口号 02 和 06 都会响应部分 AT 指令,但新短信通知 +CMTI 走的是 02。这意味着“能返回 OK”还不够,真正的验证必须包含:
- 查询模块身份;
- 查询 SIM 与网络状态;
- 给这张卡发一条短信;
- 确认该口能收到
+CMTI; - 用
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,而是三个小问题叠加:
- 串口读失败发生在事件循环的 reader 回调里,异常只到了 asyncio 的兜底处理器;
- transport 想用空字节
b""通知上层 EOF,但_feed看到空数据直接返回; - 周期性的信号和存储查询又吞掉了
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 模块,我会按这个顺序验证:
uname -r、/lib/modules和cdc_acm;dmesg确认 VID/PID、接口和重枚举;- 检查串口属组,Arch 常见
uucp,Debian / Ubuntu 常见dialout; - 排除 ModemManager 抢口;
- 对每个 ACM 口执行完整 AT 探测;
- 用真实短信确认 URC 从哪个口出来;
- 查询 IMEI、ICCID、SMSC、注册状态和存储容量;
- 插入第二块模块,确认 USB 序列号是否唯一;
- 做热拔插、换 USB 口和断电重连;
- 最后再安装 systemd 常驻服务。
硬件接入最重要的不是记住某个 ttyACM 编号,而是建立一套能推翻假设的验证流程。下一篇会继续向上一层,讲 PDU、URC、长短信,以及 Agent 与 Server 如何做到“允许重复,但不能丢失”。