# 把 SIM 卡变成一台服务器：树莓派上的短信与 eSIM 网关（二）

- URL: https://wuming.si/blog/4003/
- Author: Aaron
- Published: 2026-08-19
- Language: zh-Hans
- Categories: Raspberry Pi, Technology
- Originally published at: https://blog.wo.ai/archives/4003

> 上一篇：[它到底是什么](https://wuming.si/blog/4002) ｜ 下一篇：[从零实现 VoWiFi](https://wuming.si/blog/4004)

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

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

- 一个网页后台，能看模组状态、信号、SIM 卡信息；
- 短信收发和会话记录，重要短信自动转发到 Telegram、邮件或者其他地方；
- eSIM 配置文件的下载、启用、停用、删除；
- 把 4G 网络以代理的形式共享给局域网。

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

通话和 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 开发机上交叉编译：

```sh
make build-arm64
```

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

```sh
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 的二进制文件，一不小心就提交上去了。

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

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

最小配置：

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

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

```ini
# /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
```

```sh
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（机器上别的设备可能还要用），标记让它忽略就好：

```udev
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`）。唯一能区分它们的只有插在哪个物理端口上。

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

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

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

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

```udev
SUBSYSTEM=="tty", KERNELS=="1-1.1:1.2", SYMLINK+="modem-a-at"
SUBSYSTEM=="tty", KERNELS=="1-1.2:1.2", SYMLINK+="modem-b-at"
```

```sh
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 连接（网页通话要用）仍然会被拒绝。

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

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

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

## 短信

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

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

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

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

一查：

```text
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 命令的逻辑通道，不依赖任何专用驱动：

```text
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`。这是正常的，不是坏了。

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

**现象**：

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

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

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

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

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

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

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

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

### 修好之后，服务器说"不"

```text
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. [它到底是什么](https://wuming.si/blog/4002)
2. [树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003)
3. [从零实现 VoWiFi](https://wuming.si/blog/4004)
4. [USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)
5. [插在 Mac 上](https://wuming.si/blog/4006)
6. [iPhone 客户端](https://wuming.si/blog/4007)
7. [安卓平板直接驱动](https://wuming.si/blog/4008)

---

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