达是酒中趣,琴上偶然音

把 SIM 卡变成一台服务器:树莓派上的短信与 eSIM 网关(二)

· Raspberry Pi · Technology

上一篇:它到底是什么 | 下一篇:从零实现 VoWiFi

上一篇搞清楚了那只 4G dongle 是什么:一台跑着 Linux 的 LTE 模组,能打电话、发短信、管 eSIM。这一篇让它真正开始干活。

目标是一台常年开机的树莓派,上面插一到两只模组,提供这些东西:

一个很实际的用途:一张不常用但不能停的号码——银行验证码、国外的号码、备用卡——不必占着手机卡槽,扔在这台树莓派上,短信自动推到手机上,需要时还能打电话。

通话和 Wi-Fi 通话是更大的话题,放在第三、四篇。

为什么不自己从头写

一开始确实自己写了一个 Go 守护进程。写着写着发现,在 Linux 上大部分脏活系统已经替你干了:串口驱动、网卡驱动、防火墙转发,都是现成的。剩下要写的是设备管理、短信解码、eSIM 流程、通知推送、网页后台——而这些有个叫 VoHive 的项目已经做得相当完整了,后端 Go,前端 Vue,eSIM 基于 euicc-go。

于是换了思路:用 VoHive 的后端作为底座,把自己在真机上验证过的东西(通话、Wi-Fi 通话的一堆修复、USB 话机支持)一个个移植上去。

这个决定本身没问题,但它埋了一个雷,值得先说:迁移时最容易丢的,恰恰是那几行没有测试覆盖的修复。 后来 Wi-Fi 通话的来电又一次全部进了语音信箱,外呼又一次没反应,查到最后都是同一个原因——旧代码里的单行修复没被带过来。第三篇会细说这两次重复劳动。

硬件

项目配置
主机Raspberry Pi 4 Model B,Debian 13(arm64)
模组QDC507 两只,挂在同一个 USB Hub 上(一张国外卡,一张国内卡)
供电必须足

供电那一条不是客套话。Pi 4 的几个 USB 口是联动的:断掉一个口,会把所有口一起断掉。后面讲到 USB 话机死机时会再提到这件事——很多”复位一下设备”的常规操作,在 Pi 4 上会顺手把模组也复位掉。

编译和部署

纯 Go,不需要 cgo,可以在 Mac 或者 Linux 开发机上交叉编译:

make build-arm64

第一个坑:没装 UPX 的话,make 直接报错。 UPX 是个压缩可执行文件体积的工具,Makefile 把它当成了必需品。不想装的话,把 go build 那几行单独拿出来跑就行:

npm ci --prefix web && npm run build --prefix web
rm -rf internal/web/dist && cp -R web/dist internal/web/dist
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -tags "with_utls nomsgpack" \
  -ldflags "-s -w" -o dist/vohive_linux_arm64 ./cmd/vohive

顺带一提,go build ./cmd/vohive 不带 -o 会在仓库根目录留下一个 87 MB 的二进制文件,一不小心就提交上去了。

部署目录长这样,配置、数据、日志都相对于二进制所在的位置:

/opt/vohive-call/
├── vohive                 # 二进制
├── config/config.yaml
├── data/                  # 数据库、证书、录音
├── logs/app.log
├── qdc507-route           # 语音路由脚本(第四篇)
└── qdc507/                # 模组语音驱动(自备,第四篇)

最小配置:

server:
  port: 7575
  debug: false          # 排查 eSIM 问题时改成 true,要重启才生效
web:
  username: admin
  password: 请改掉
devices:
  - id: us
    name: 主卡
    modem_imei: "86xxxxxxxxxxxxx"
    device_backend: at

配一个 systemd 服务让它开机自启:

# /etc/systemd/system/vohive-call.service
[Unit]
Description=VoHive gateway
After=network-online.target

[Service]
WorkingDirectory=/opt/vohive-call
ExecStart=/opt/vohive-call/vohive
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now vohive-call
tail -f /opt/vohive-call/logs/app.log

以后更新就是覆盖二进制再重启服务。

udev 规则:现在不做,以后一定出事

udev 是 Linux 管理设备节点的机制,可以给设备起固定名字、设权限、打标记。这里有两件事必须做。

让 ModemManager 别碰这只模组

ModemManager 是桌面 Linux 上管理蜂窝设备的系统服务。它的工作方式是往串口里写 AT 命令探测设备能力。问题在于,它的命令和你的命令会交错,回复也就错位了——你发 AT+CSQ 问信号,收到的是它那条命令的回复。

这种故障看起来和固件 bug 一模一样,能让人查很久。

不要直接停掉 ModemManager(机器上别的设备可能还要用),标记让它忽略就好:

SUBSYSTEM=="usb", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", ENV{ID_MM_DEVICE_IGNORE}="1"
SUBSYSTEM=="tty", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", ENV{ID_MM_DEVICE_IGNORE}="1"
SUBSYSTEM=="net", ATTRS{idVendor}=="2c7c", ATTRS{idProduct}=="0125", ENV{ID_MM_DEVICE_IGNORE}="1"

给每只模组一个固定的名字

现象:两条线路同时掉线,一掉就是好几天,日志里一直在刷 ATE0: command timed out

原因:两只模组的 ttyUSB 编号整段互换了。第一只从 ttyUSB2 变成了 ttyUSB3,第二只从 ttyUSB6 变成了 ttyUSB5。程序还对着老编号发命令,而那个位置现在是另一只模组的诊断口——诊断口当然不会回应 AT 命令。

为什么不能靠常规办法:两只模组的厂商 ID、产品 ID、序列号完全一样(序列号还是上一篇说的那 256 个 0xAA)。唯一能区分它们的只有插在哪个物理端口上。

网上到处能搜到的这种写法根本不会生效

SUBSYSTEM=="tty", ATTRS{bInterfaceNumber}=="02", ATTRS{idVendor}=="2c7c", ...

原因是 udev 要求一条规则里所有 ATTRS 匹配的是同一个父设备,而 bInterfaceNumberidVendor 在设备树的不同层级上,永远凑不到一起。这是个很常见的误解,抄来的规则不报错,只是静静地不匹配。

正确做法是直接匹配接口的设备名,格式是 Hub端口:配置.接口

SUBSYSTEM=="tty", KERNELS=="1-1.1:1.2", SYMLINK+="modem-a-at"
SUBSYSTEM=="tty", KERNELS=="1-1.2:1.2", SYMLINK+="modem-b-at"
sudo udevadm control --reload && sudo udevadm trigger
ls -l /dev/modem-a-at /dev/modem-b-at

之后所有地方都用这两个符号链接。永远不要在配置里写 /dev/ttyUSB2

网页后台和证书

后台地址是 https://<树莓派的IP>:7575,默认用自签名证书。

:浏览器弹出安全警告,点了”继续访问”,页面能打开,但部分功能仍然不工作,服务端日志不停地出现 tls: unknown certificate

原因是那个”继续访问”只对当前页面生效,页面里后续建立的 WebSocket 连接(网页通话要用)仍然会被拒绝。

真正的解决办法是把证书加进系统信任,然后完全退出浏览器再打开:

# macOS
security add-trusted-cert -d -r trustRoot \
  -k "$HOME/Library/Keychains/login.keychain-db" server.crt

这条后面还会再出现一次——第四篇的网页通话、第六篇的 iPhone App,都栽在同一件事上。

短信

短信的编解码(PDU 格式、中文的 UCS2 编码)VoHive 已经处理好了,收到之后入库、按会话展示、按规则推送。

真正遇到的问题都不在编解码,而在短信根本没到程序手里

SIM 卡存满,短信被静默拒收两周

现象:从某天起一条短信都收不到。没有报错,没有告警,程序日志一切正常。

一查:

AT+CPMS?
+CPMS: "ME",23,23,"ME",23,23,"ME",23,23
AT+CNMI?
+CNMI: 2,1,0,0,0

第一行的意思是:存储容量 23 条,已用 23 条,满了。

原因+CNMI 的第二个参数是 1,意思是”新短信先存进存储,再上报一个通知”。存储满了,投递在短信层就被拒绝,网络重试一阵子之后放弃。

而程序只在收到通知时才去读短信、读完删除。那些程序没运行时到的、换卡之前留下的短信,永远没人读,也就永远没人删。那 23 条全是上一张卡收到的营销短信。

这个坑的杀伤力在于:一张卡留下的积压,会让之后插进来的每一张卡都收不到短信。

解决:加一个定期清扫——AT+CPMS? 看占用,AT+CMGL=4 把存储里的全部读出来,每条都走和实时短信相同的解码与回调,然后 AT+CMGD 逐条删掉。存储为空时只花一条命令,所以可以放心频繁跑。

清扫看起来有效,其实是巧合

现象:清扫挂在”SIM 卡就绪”事件上,新短信确实能进来了。但仔细一看,新短信通知的路径一次都没有真正触发过。

原因:就是上一篇那个坑——URC 端口是 usbat,通过 ADB 读 /dev/smd7 的程序收不到通知。日志里那些看起来像通知的 +CPIN+CSQ,其实是轮询命令的回复被误认成了主动上报,恰好触发了清扫。

一个 bug 掩护了另一个 bug,整件事看起来是工作的。

解决:收件箱改成独立的 20 秒定时轮询,不再依赖任何事件;同时在初始化时检查 URC 端口,不是 "all" 就改成 "all"(先读再改,避免每次启动都写一次掉电保存的参数)。能收到通知的模组立刻收到短信,收不到的最多晚 20 秒。

验证的时候看到一条运营商已经重试了几个小时的验证码,终于落进了数据库。

同一个号码开了两个会话

138xxxxxxxx+86138xxxxxxxx 被当成两个不同的人。解决办法是统一折叠国家码前缀,短号码(运营商服务号)和国外号码保持原样。

eSIM

eSIM 不是一张特殊的卡,而是一张能装配置文件的卡。运营商给你的那个二维码或激活码,本质上是让卡去某台服务器下载一份配置。这个流程叫 SGP.22,是 GSMA 的标准。

在这里,整个流程走的是 AT 命令的逻辑通道,不依赖任何专用驱动:

AT+CCHO="A0000005591010FFFFFFFF8900000100"   -> +CCHO: 1          # 打开卡里的管理程序
AT+CGLA=1,22,"81E2910006BF3E035C015A"        -> +CGLA: 4,"6115"   # 问 EID(卡的唯一编号)
AT+CGLA=1,10,"81C0000015"                    -> BF3E125A10<EID>9000
AT+CCHC=1                                    -> OK

后台里能看到卡的厂商、固件、证书、已装的配置文件,可以用激活码下载新的,也可以启用、停用、重命名、删除。

AT+CGLA 的三个怪癖

这三条哪一条不对都是直接报错,而错误信息不会告诉你原因:

怪癖表现
十六进制参数必须加引号不加引号直接 ERROR
长度写的是十六进制字符数,不是字节数5 个字节的数据,长度要写 10
逻辑通道 1 上取回数据要用 0x81 开头按 ISO 标准写 0x01 会返回 6F00

另外,这类命令先回一个 61XX,表示”有 XX 字节数据等着取”,要再发一条取数据的命令才拿得到。

普通 SIM 卡(不是 eUICC)没有这个管理程序,AT+CCHO 会直接 ERROR。这是正常的,不是坏了。

每次下载都死在同一句话上

现象

下载 eSIM profile 失败 ... err="tag encoding with less than one byte\nEOF"

这句话翻译过来只是”解析器拿到了 0 个字节”,完全没说为什么是 0 个字节。

怎么查的:把 server.debug 打开重启,库会记录每一条和卡之间的往返,以及和运营商服务器的完整请求响应。又额外在每次卡命令往返上加了一行日志,只记命令头、长度和状态字。

关键在于:只有分块级别的状态字能看出是在哪一块放弃的,上层只能看到拼好的整条响应,而那条响应是空的。

81E21100 9000   81E21101 9000   ...   81E21106 9000     <- 一直是"继续",没有"结束"

原因:这一次的数据恰好是 840 字节,库按 120 字节一块切开,正好 7 块。而它判断”哪一块是最后一块”用的是 长度 / 120,840 除以 120 等于 7,但块的下标只到 6。于是每一块都被标成”后面还有”,卡就一直回”好的,继续”,等着永远不会来的下一块。上层读到一条空响应。

一个正好卡在整除边界上的差一错误。数据长度不是 120 的整数倍时,一切正常。

解决:上游已经修了(blockCount = 1 + (len-1)/mss),升级依赖即可。修完之后第 7 块终于发出了”结束”标志,流程第一次真正走到运营商服务器。

修好之后,服务器说”不”

8.1.1 / 3.8  EID does not match the expected value, or there is no pending RSP Session for this eUICC.

这次是一个真实的拒绝:那个激活码已经在一台手机上用掉了。

激活码通常是一次性的,有些还绑定了卡的 EID。测试时要准备一个没用过的码,或者换一张卡。

值得高兴的是,一个真实的业务拒绝,比一个解析错误有价值得多——它说明前面几十步全对了。

把 4G 共享给局域网

每个模组可以起一个代理实例,出站流量严格绑定到该模组的网卡上,不会串。

共享之前,先确认这张卡是不是在漫游。 测试用的一张卡注册状态就是漫游(AT+CEREG? 返回 0,5,5 表示已注册且漫游)。这种状态下,一条建立数据连接的命令,或者一次 DHCP,都可能产生很贵的流量费。

这张卡后来只用来做 Wi-Fi 通话(下一篇),蜂窝射频甚至可以一直停在飞行模式。

几个小坑

现象原因解决
不在安装目录下启动就报 open config/config.yaml: no such file or directory配置路径相对于工作目录工作目录下找不到时,改用二进制所在目录
unable to open database file: out of memory (14)data/ 目录不存在。这个错误信息完全在误导人打开数据库前先建目录
调试时 AT 口被占用服务正在运行并独占了它调试开始时停一次服务,调完再起,不要每一步都重启
一个读进程被遗留在模组里,之后的程序”收不到回复”杀掉本地的 adb 客户端不会杀掉模组里的读进程第一次打开设备节点时清理残留(第五篇详述)

现在有什么了

一台插着电、常年开机的树莓派,管着两张 SIM 卡,收短信、发短信、下载 eSIM、共享 4G。短信推到手机上,整个过程不占任何一个手机卡槽。

下一篇是整个系列里最硬的一篇:不用蜂窝信号,让 SIM 卡通过 Wi-Fi 连上运营商的核心网,照样打电话收短信。

全系列

  1. 它到底是什么
  2. 树莓派上的短信与 eSIM 网关
  3. 从零实现 VoWiFi
  4. USB 话机、浏览器通话与 ESP32
  5. 插在 Mac 上
  6. iPhone 客户端
  7. 安卓平板直接驱动

这个系列是个人学习和技术研究记录。请遵守所在地法律法规和运营商服务条款。