Air780E 多卡短信中枢(三):PDU、URC 与“允许重复但不能丢”

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

Air780E 能返回 OK,不代表短信链路已经可靠。真正困难的部分藏在细节里:中文为什么不能偷懒用文本模式,GSM7 为什么不是普通 7 位字符串,URC 为什么会插进命令响应,长短信缺一段时该丢弃还是保留,以及网络断开后如何既不丢消息、又不产生重复数据。

这一篇从最底层的 PDU 一直讲到 Agent 与 Server 的确认协议。

一、为什么坚持使用 PDU 模式

AT 短信通常有文本模式和 PDU 模式。文本模式看起来最简单:设置字符集后直接发送正文。但它在不同固件上的中文、Unicode、长度计算和长短信行为并不稳定。

PDU 模式更难实现,却能明确控制:

  • GSM 03.38 默认字母表和扩展表;
  • UCS2 中文与 Unicode;
  • SMS-DELIVER 与 SMS-SUBMIT;
  • 短信中心时间戳 SCTS;
  • 用户数据头 UDH;
  • 长短信分段编号和引用号;
  • 字母数字发件人。

项目因此没有依赖一个黑盒 PDU 库,而是自己实现最小所需编解码。目的不是重复造轮子,而是 Air780E 的响应和边界一旦出现差异,可以精确知道错误发生在哪一位。

二、GSM7 最容易错在“看起来像字符串”

GSM7 使用 septet,也就是每个字符 7 bit。编码后要连续打包到 8 bit 字节中,并且还有扩展转义表。

简单理解:

字符 A  字符 B  字符 C
7 bit   7 bit   7 bit
└────────连续拼接────────┘ → 每 8 bit 输出一个字节

长短信加上 UDH 后,正文 septet 的起始位置不一定落在字节边界,还要计算填充位。最常见的 bug 是单条英文短信正常,一加长短信头就从第二个字符开始乱码。

因此测试不能只覆盖 hello,至少要包含:

  • 默认字母表;
  • ^{} 等扩展字符;
  • 非整字节边界;
  • 带 UDH 的 GSM7;
  • 编码后再解码的往返不变量。

中文和其他不能用 GSM7 表示的内容则切换 UCS2。发送前按编码和 UDH 开销计算每段容量,而不是按 Python 字符串长度粗暴切片。

三、长短信缺段时,完整性和可见性怎么取舍

长短信由多个独立 PDU 组成,UDH 中带引用号、总段数和当前段号。接收端通常按以下键分组:

(发件人, 拼接引用号)

所有段到齐后按序合并。但真实世界中可能永远缺一段:模块复位、存储满、运营商重发或某段解析失败都可能发生。

项目采用超时兜底策略:

  1. 段到达后先缓存;
  2. 同组所有段齐全时立即合并;
  3. 超时仍不完整时,输出已收到的内容并标记残缺,而不是永久等待或整条丢弃。

验证码短信通常很短,但通知、账单或运营商公告可能是长短信。对于这类系统,“看见一条不完整消息”通常比“什么都看不见”更可诊断。

四、AT 是半双工的,但 URC 会随时插队

同一串口上的 AT 命令必须串行:一个命令收到终止响应 OK / ERROR 前,不能发送下一个命令。

问题在于模块还会主动上报 URC,例如:

+CMTI: "SM",3

URC 可能出现在任何时刻,甚至夹在当前命令的多行响应中间。驱动必须同时维护两条逻辑流:

串口字节流
  ├── 当前命令的响应
  └── 主动 URC 事件

如果简单地“读到 OK 为止全部塞给当前命令”,短信通知会被吞掉;如果看到 + 开头就当 URC,查询命令自身的响应又会被误分流。

坑一:+CEREG: 既可能是响应,也可能是 URC

AT+CEREG? 的结果以 +CEREG: 开头,而网络注册变化的主动上报也使用同一前缀。

最初注册 URC handler 后,查询响应被优先路由给 URC,调用方只等到一个孤零零的 OK,注册状态永远为空。

修复规则是:

有命令在途时,先匹配该命令声明的响应前缀;只有不属于当前命令的行才进入 URC 分发。

坑二:前缀匹配会互相遮蔽

如果用 startswith 判断,+CMT 会吞掉 +CMTI,结果还取决于 handler 注册顺序。

正确匹配要检查协议边界,例如前缀后必须是冒号、空格、逗号或行尾,而不是任意后续字符。解析器规则不应依赖注册顺序。

五、提示符不是普通的一行

发送短信时,AT+CMGS 不会立刻返回 OK,而是先输出:

> 

驱动看到提示符后才写入 PDU 和 Ctrl+Z,再等待最终 +CMGSOK

> 不一定带标准换行。如果底层只按 \r\n 切行,发送流程会永远等待。因此 transport 除了行解析,还要识别提示符状态。

同理,+CME ERROR+CMS ERROR 不能只归一成一个布尔失败。保留错误码和可读映射,才能区分 SIM 未就绪、SMSC 错误、存储问题和网络注册问题。

六、真机才暴露的 PDU 截断问题

AT+CMGR 响应头会给出 TPDU 字节数,下一行才是十六进制 PDU。最初的实现直接拿下一行解码,没有核对声明长度。

真机测试中,一条响应出现了短 PDU:解码器没有立刻报错,而是得到半截正文。日志也没有明显异常,危险程度比直接失败更高。

修复后流程变成:

  1. 解析 +CMGR 中声明的 TPDU 长度;
  2. 按 SMSC 长度和 TPDU 长度计算期望十六进制字符数;
  3. 不足时重新读取;
  4. 重试后仍不足则明确报错,不把截断正文当成功消息。

这里的经验是:

协议里已有的长度、校验和与序号必须真正参与验证;只解析内容、不验证元数据,相当于主动放弃错误检测。

七、Agent 是事实源,Server 是可重建镜像

硬件事件先在 Agent 本地发生。如果网络断开后只把它保存在内存里,进程重启就会丢失;如果直接发送、不做确认,连接在边界时刻断开就无法判断 Server 是否收到。

项目采用持久化 seq + ack

  1. Agent 把事件写入 SQLite,并分配单调递增 seq
  2. seq 顺序发送,不并发;
  3. Server 入库后返回累计确认;
  4. Agent 收到确认后标记已送达;
  5. 重连后从最小未确认序号继续重放。
Agent SQLite            Server
seq=1421 sms_in  ──────> 入库
             <────────  ack=1421
标记已确认

为什么选择“至少一次”而不是“恰好一次”

如果 Server 已经入库,但 ack 在网络中丢失,Agent 必然重发。这是正确行为:重复可以去重,丢失无法恢复。

Server 用 (agent_id, seq) 建唯一约束,重复帧只返回确认,不重复创建短信。于是端到端语义是:

  • 传输允许重复;
  • 入库保持幂等;
  • 短信不因 ack 丢失而丢失。

所谓“恰好一次”通常只是把去重藏在某一层。明确接受至少一次,反而更容易验证。

八、队列满时,什么可以丢

Agent 的本地事件队列有上限,避免极端故障吃满磁盘。但不同事件价值不同:

  • 短信和任务结果不可替代;
  • 每分钟状态采样是可重复观测数据。

因此达到上限时,只淘汰最旧的状态事件,短信事件永不主动删除。状态上报还会先去噪:信号变化很小且其他字段未变时不生成新事件。

这是一个通用设计方法:

不要只定义容量上限,还要先给数据分级,明确压力下牺牲谁。

九、下行命令和持久化任务不能共用一种语义

发送一条短信、查询状态、执行原始 AT,都是即时命令:Server 分配 cmd_id,等待 cmd_result。断线时命令失败,由操作者重试即可。

保号任务不同。任务是长期配置,必须在 Agent 本地持久化,而且 Server 断线时继续执行。

最初只在用户修改任务时下发。问题是:如果 Agent 离线期间在 Server 删除了任务,Agent 永远不知道,旧任务会一直执行。

最终改成:

  • 每次 Agent hello 后,Server 都全量下发当前任务;
  • Agent 用全量替换收敛本地状态;
  • 未出现在列表中的任务删除;
  • 重复下发没有副作用。

连接时这次同步不等待 cmd_result。因为发送和接收都在同一个 WebSocket 循环里,如果循环发送后原地等待一个只能由自己读取的回执,就会形成逻辑死锁。

同样,编辑任务的 HTTP 请求也不等待远端 Agent:Server 数据库是配置真相,下发是尽力而为;下一次连接会再次全量对齐。

十、为什么假模块值得认真写

项目在真实硬件到手前实现了一个 PTY 假模块,支持:

  • 常用查询与初始化指令;
  • +CMTI 短信注入;
  • PDU 收发;
  • 长短信;
  • 存储容量和满后丢弃;
  • 信号变化与离线场景。

它不是为了追求“测试覆盖率好看”,而是把可重复实验带进硬件项目。中文短信、400 字长短信、断网补传、存储清理都可以在 CI 或本机稳定重现。

真机测试仍然不可替代,但职责不同:

  • 模拟器验证自己的状态机和协议实现;
  • 真机验证手册、固件和电气环境是否符合假设。

最终 Agent 有 179 项测试,Server 有 99 项测试。最有价值的测试都来自真实踩坑后的回归:URC 前缀遮蔽、PDU 长度不符、断线重放、任务全量同步、正文不得进入日志。

下一篇转向中心服务:多渠道推送为什么不能只看 HTTP 状态码,时区为何会在 slim 镜像里失效,以及一个处理验证码的系统应该怎样设计认证、日志和备份。