# 没有信号也能打电话：在树莓派上从零实现 VoWiFi（三）

- URL: https://wuming.si/blog/4004/
- Author: Aaron
- Published: 2026-08-26
- Language: zh-Hans
- Categories: Technology
- Originally published at: https://blog.wo.ai/archives/4004

> 上一篇：[树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003) ｜ 下一篇：[USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)

手机设置里有个开关叫"Wi-Fi 通话"，打开之后，在地下室、电梯、信号盲区，只要有 Wi-Fi 就照样能打电话收短信。这个功能的正式名字是 VoWiFi。

它的原理不是把电话转成微信语音。手机做的事情是：通过任意一条宽带网络，加密连回运营商的核心网，然后用 SIM 卡证明"我是这个号码"，之后所有通话和短信都和走蜂窝时一模一样——对方看到的还是你的手机号，来电还是打到你的号码上。

这一篇要在树莓派上把这套东西实现出来。SIM 卡插在 4G 模组里，但**只用它做身份认证**，数据全部走树莓派的有线网。蜂窝射频可以一直停在飞行模式。

对一张常年漫游的卡来说，这意味着：不产生任何漫游费，在家里稳定收短信、打电话、接电话。

这是整个系列里坑最多的一篇。一路踩下来的十几个坑，几乎每一个都对应着一份规范里的一句话。

## 它是怎么运作的

整件事分三层，从下往上：

```
 ┌───────────────────────── 第三层：业务（SIP）─────────────────────────┐
 │  REGISTER 注册 → 401 挑战 → 200 成功     MESSAGE 短信    INVITE 通话  │
 └────────────────────────── 受保护通道 ───────────────────────────────┘
      第二层：IPsec 加密 + 用户态网络协议栈
 ┌────────────────────────── 第一层：SWu 隧道 ──────────────────────────┐
 │  IKEv2 握手 + 用 SIM 卡做 EAP-AKA 认证 → 加密隧道 → 拿到一个内层地址   │
 └──────────── 公网 UDP 500/4500 连到运营商的 ePDG 网关 ─────────────────┘
```

**第一层，建隧道。** 运营商在公网上开着一台叫 ePDG 的网关，专门接收从任意网络过来的手机。和它建立一条 IPsec 加密隧道，用的是 IKEv2 协议，而身份认证不用密码，用 SIM 卡——这套叫 EAP-AKA。SIM 卡里有一个运营商也知道的密钥，双方各自算一遍，对上了就认证通过。隧道建好后，会拿到一个内层地址，从运营商的角度看，这台设备就"在它的网络里"了。

**第二层，加密。** 隧道内部再套一层 IPsec 保护，专门保护上面的信令。

**第三层，业务。** 在受保护的通道里跑 SIP 协议。SIP 是互联网电话的通用信令协议，注册用 `REGISTER`，短信用 `MESSAGE`（里面装的是 3GPP 的短信数据），通话用 `INVITE`，语音流用 RTP。

运营商这一整套核心网叫 IMS。手机日常打电话（VoLTE）走的也是它，只不过接入方式是蜂窝而不是 Wi-Fi。

## 选库：顺带上了一堂许可证课

自己从零写 IKEv2 不现实，先找现成的库：

| 库 | 结果 |
|---|---|
| 一个公开的 VoWiFi 库 | 握手第一步的密钥交换算法写死成 X25519，而运营商的网关要的是传统的 MODP。对面直接回"没有可接受的提议"。而且它是 AGPL-3.0 |
| 一个公开的 SWu 隧道库 | 打三个补丁之后能建起真隧道，但它上面那层走不通 |
| 一个完整的 UE 侧实现 | 隧道和 IMS 都能跑通真实运营商，许可证是 PolyForm Noncommercial |

最后用了第三个，项目许可证也跟着改成 PolyForm Noncommercial，同时**主动把那个 AGPL 的库移除了**——AGPL 第 7 条不允许在分发时附加"仅限非商业使用"这种限制，两者不能合在同一个分发作品里。这一点做开源集成的时候值得留心：许可证不是装饰，它会决定你能不能把两个库放进同一个仓库。

### 一类很隐蔽的 bug：库提供了自己用不了的算法

那个 SWu 库建隧道失败的三个原因，本质上是同一个问题：**协商时报了自己实现不了的算法，对面一选中就死。**

协议握手通常是"我支持 A、B、C，你挑一个"。如果 A 只是列在清单上但代码里没实现，而对面偏偏挑了 A，后面每一步都会以看起来毫不相干的方式失败。

| 位置 | 现象 | 修法 |
|---|---|---|
| 握手的密钥交换组 | 清单里有 MODP-3072 和 1024，实现只有 group 14。对面选了 3072，回了"密钥载荷无效"，而库不处理这个重试，报的错是"响应中缺少强制性载荷" | 只提议 group 14 |
| 握手的加密算法 | 提议了 AES-GCM，但密钥派生有误，解密时报 `cipher: message authentication failed` | 只提议 AES-CBC |
| 数据隧道的加密 | 首选 AES-GCM，结果发出去正常，收进来的每个包认证都失败 | 只提议 AES-CBC |

最终协商出来的组合是 AES-CBC-256 加 SHA2-256 加 MODP-2048，握手耗时 1.5 到 1.9 秒。

排查这类问题有个通用办法：**先把提议清单砍到只剩一个你确信实现正确的算法**，能通就说明问题在协商，不通再往别处找。

## "就绪"这个词要小心

后台里有四个状态：

```text
vowifi_enabled = true      # 只表示配置里开了这个功能
tunnel_ready   = true      # 隧道建好了
ims_ready      = true      # 注册成功了
sms_ready      = true
```

**只有后三个同时为 true，才真的能收短信、接电话。** 服务重启之后，隧道要 10 到 130 秒才能走到这一步。在这之前测试失败，什么都说明不了——这一点浪费过不少时间，因为人总是重启完就立刻去试。

### 坑：`Start` 返回成功，其实什么都没做

**现象**：日志明明白白写着已就绪，但去看系统：没有隧道网卡，加密状态表是空的，连端口都没监听。

**原因**：库里有两行这样的代码——"没有传注册器，就当注册成功"，"短信状态直接写死为真"。这在逻辑上叫**空真**：条件为空所以判断通过。另外，数据面模式没配置的话，它会直接跳过建隧道这一步，然后返回成功。

**解决**：必须显式配置数据面模式并传入注册器；判断"已注册"必须同时看隧道和注册两个标志。更重要的一条是：**任何"已就绪"都要用带外的方式确认**——去看网卡在不在、加密状态表有没有条目、端口有没有监听。这条经验在后面几篇里会反复救命。

### 坑：库自带的"自动恢复"会重启射频

**现象**：一个对着假接口跑的单元测试跑了 14 秒，命令日志里出现了四轮射频关闭再打开。

**原因**：库在读取 SIM 卡身份失败时，默认的恢复策略是给射频断电再上电。在真机上这会挂断正在进行的通话，让模组重新附着网络——对一张漫游卡来说，正是最该避免的动作。

**解决**：实现一个恢复钩子，直接返回错误，让库退回到从配置推导身份。

**顺带一提**：一个单元测试跑了 14 秒本身就是信号。测试通过不代表没问题，测试变慢往往意味着代码在偷偷做真实的、昂贵的事情。

## 注册阶段的坑

注册就是向运营商说"我在这里，有电话请打到这个地址"。这一步不对，来电就永远不会来。

### MNC 是两位还是三位

IMSI 是 SIM 卡的身份号码，前面几位是国家码和运营商码。运营商码在北美是三位，在很多其他地方是两位。库只要 IMSI 够长就按三位切，切错了身份就构造错了。解决办法是用 `AT+CRSM` 读卡里的一个文件，拿到真实位数，不要猜。

### `400 Bad Request` 和 `421 Extension Required`

两条协议细节，各让人查了半天：

- 第一次注册请求必须带一个**空的认证头**（里面写上身份，密码留空）。这是 RFC 3310 的要求，服务器靠它知道该发什么挑战。库只在收到挑战之后才填这个头，于是第一条请求就被拒了。
- 服务器回 `421` 并回显 `Require: sec-agree` 时，意思是"你还得跟代理也说一声"。因为对面是个代理服务器，除了 `Require` 还需要 `Proxy-Require`（RFC 3329）。

这类问题的共同点是：规范里写了，代码里没写，而错误码只告诉你"不行"，不告诉你缺什么。

### 内核加密是条死路

Linux 内核自己能做 IPsec，看起来正是现成的工具。实际上走不通：空加密算法被渲染成空密钥，内核直接拒绝；勉强装上之后，第二条注册请求石沉大海。

后来读那个能跑通的实现才明白，**真正对接得上运营商的做法根本不用内核**：IPsec 在用户态自己做，上面跑一个用户态的 TCP/IP 协议栈，再把它当成普通网络连接交给 SIP 库。

花在"为什么内核没转发第二条请求"上的时间，全部浪费在了错误的架构上。这种时候值得停下来问一句：**别人是怎么做的？** 而不是继续在自己选的路上加力。

### 认证适配器的两个集成陷阱

- 运行时对**适配器类型本身**做接口断言，靠嵌入字段实现不算数，报的错是"配置要求 ISIM 但提供方不支持"。
- 认证要用卡里的 ISIM 应用，而不是默认的 USIM。这两个应用都在同一张卡里，选错了算出来的东西就是错的。

自己实现的这一侧：在逻辑通道上打开 ISIM，读出身份标识，跑认证命令，成功时卡会返回一个标签 `0xDB` 带着结果和密钥，同步失败时返回 `0xDC` 带着一个重同步参数。

### 每次都停在同一个词上

**现象**：

```text
IMS REGISTER failed: status=0 result=auth_phase_reached
```

**原因**：这是一条串起来的错误链，很典型。

卡返回了 `0xDC`，要求序列号重新同步。这在协议里是完全正常的一步——卡和网络对不上账，重来一次就好。但代码把它当成"正常事件"返回了 **nil 错误**。而上层判断"是否需要重同步"的方式是检查错误类型，nil 当然匹配不上，于是被当成"认证成功但没拿到密钥"，接着用空结果去算摘要，最后死在密钥检查上。

更糟的是两件事：这条错误恰好匹配了库里"脱敏错误信息"的规则，真实原因被改写成了一个毫无意义的词；而且**有一个单元测试锁死了这个 bug**，它断言这里应该返回 nil。测试不是真理，它只是把某一时刻的理解固定下来了。

**解决**：同步失败时返回专门的错误类型和参数。之后流程就顺了：请求重同步 → 发送 → 第二轮挑战 → 认证完成 → 装载加密 → 200 成功。

**调试经验**：这个库故意对注册失败做了脱敏。在认证命令外面自己包了一层日志（只记录结果类型和数据长度，绝不记录密钥材料），一次就找到了问题。注册失败的时候，先看这一层。

成功的完整链路长这样：

```text
initial_response 401 → auth_challenge → auth_success aka_complete
→ ipsec_install installed → protected_send → complete 200 ok
```

## 短信：只收到一部分，重启后越来越糟

注册成功之后，短信就走 SIP 的 `MESSAGE` 消息。

**原因**：注册时要带一个设备实例标识，代码每次启动都随机生成一个。注册服务器按这个标识区分设备，所以每次重启不是**替换**旧的注册，而是**新增**一条。同一个号码下攒了 10 条注册记录，网络投递短信时挑中的，多半是某个早就死掉的进程留下的地址。

**解决**：按 RFC 7255 的规定，用设备的 IMEI 生成一个稳定标识。拿不到 IMEI 的话（射频停在飞行模式时 IMEI 就是空的，这里恰好就是这种情况），按 IMSI 生成一个 UUID 存进数据库，以后一直用它。

**清理旧的注册也有讲究**：旧记录只能等注册服务器自己过期，最长约一小时，这期间**不要重启服务**（每次重启内层地址都会变，又多一条）。有些运营商拒绝用通配符一次性注销。想看真实的注册列表，要用订阅的方式去查——注册成功响应里回显的过期时间是"被授予的时长"，不是"剩余时长"，看着像还有一小时，其实早就快过期了。

## 通话：库里根本没有语音

读代码才发现，那个库做了短信和 USSD，**语音是一个 52 行的空壳**，调用拨号会返回"尚未实现"。

于是语音这部分是自己写的，大约 2700 行，带测试：

| 模块 | 内容 |
|---|---|
| SDP | 媒体能力协商，兼容运营商应答里的各种附加属性 |
| RTP | 语音包的封装和解析。时间戳按**采样数**递增（每包 160），不是字节数也不是毫秒 |
| 媒体 | RTP 和内部音频总线之间的搬运，自己每 20 毫秒打一次拍子 |
| SIP 消息 | 构造和解析，多值头要保序，长度字段永远重算 |
| 对话 | 完整的呼叫状态机：临时响应、早期媒体、确认、取消、挂断 |

一个好消息：测试的运营商接受 G.711 编码，不需要 AMR。这意味着整个项目可以保持纯 Go，不需要引入 C 依赖，一条命令就能交叉编译到树莓派。

### 拨号发出去，什么回复都没有

**现象**：外呼一直停在"拨号中"，连一个错误码都没有。而同一条通道上的注册请求一切正常。

```text
INVITE sip:+1877xxxxxxx SIP/2.0
To: <sip:+1877xxxxxxx>
```

**原因**：这个地址是非法的。SIP 地址必须有主机部分（就像邮箱必须有 `@` 后面那一截），没有主机的地址代理服务器直接丢弃，**不回任何东西**。规范要求用 `tel:` 格式，或者带上归属域的完整 SIP 地址。

**解决**：写成 `sip:<号码>@<归属域>;user=phone`，收件人头也一样。改完之后立刻收到 `100 Trying`，九秒后接通。

**这里有个通用的排查方法**：日志里只有"发出"没有"收到"，说明请求被网络默默丢掉了。这种情况优先怀疑**消息本身格式错误**，而不是路由、签约或者注册状态。网络设备对不合法的消息通常是沉默，不是报错。

### `603 Decline`，理由是"用户未知"

看起来像签约问题。但同一张卡在手机上用 Wi-Fi 通话完全正常，所以不是。

**原因**：卡里存了多个公开身份，代码直接取了第一个，而这张卡的第一个是从 IMSI 推导出来的内部身份。计费系统按这个身份查不到用户。

**解决**：优先选归属域内、用户部分不是 IMSI 的那一个——也就是手机号码那个。

### `408 Request Timeout`，其实对方已经接受了

```text
SIP/2.0 183 Session Progress
Require: 100rel
RSeq: 2
```

**原因**：`183` 是一条临时响应，而 `Require: 100rel` 表示这是一条**可靠临时响应**，按 RFC 3262 必须用 `PRACK` 确认收到。代码在请求里声明了支持这个特性，却从来不发确认。网络重传几次之后放弃，整个请求超时。

日志里没有任何一处提到"缺少确认"。只有对着规范一条条读，才会注意到自己声明了一个没有实现的能力——**和前面那个"提议了实现不了的算法"是同一类错误**。

**解决**：收到带序号的临时响应就发确认，重传的不重复发。结果：请求 → 临时响应 → 确认 → 接通，四秒。

### 几个容易写错的协议细节

- 上游库的请求函数在收到第一个响应时就结束事务。这对短信是对的，对通话是错的：`100 Trying` 会吃掉事务，后面真正的 `200 OK` 被当成不匹配丢掉。通话需要一个能跨越临时响应的版本。
- 成功响应的确认是独立事务，要用**新的分支标识**；失败响应的确认沿用原来的；取消请求则必须重复原请求的分支标识和序号。
- 通话请求也要带接入网信息头。这一条在下一节会变得非常重要。

### 拨出去听到一段录音

呼叫立刻被一个网络设备接听并播放了一段录音，没有振铃过程。当时怀疑是紧急呼叫地址没登记，还去研究了相关模块。

**最后发现是测试号码拨错了。**

写下来是因为这个错误很典型：在用一个已知可用的号码复测之前，不要从一段听不懂的录音里推导任何结论。

### 通话声音丢一半

**现象**：连续三次测试，音频以 20 毫秒为单位丢掉大约一半。听起来是断断续续的机器音。

**原因**：隧道库的"取内层数据包"函数，给所有调用者返回的是**同一个 channel**。通话时有两个协议栈同时在读它：信令栈和媒体栈。Go 的 channel 一次只把一个值送给一个接收者，于是每个包随机落到其中一个——落到信令栈那一半，因为那边没有对应的接收端口，被静默丢弃了。

**解决**：改成订阅模式，每个消费者拿一个独立的带缓冲 channel 和一份数据拷贝，非阻塞地扇出。

| | 收到的包 | 补偿的空洞 | 最大单帧间隙 |
|---|---|---|---|
| 修复前 | 521 | 531 | 395 |
| 修复后 | 1052 | 0 | 3 |

**被推翻的努力**：在此之前试过加抖动缓冲、解耦播放、改成 40 毫秒一帧——全都打在错误的层上，一个都没有改善这三个数字。

**破案的方法**：把隧道层收到的包数和媒体层收到的包数放在一起对比。包到了隧道却没到媒体，丢失的位置立刻就定位了。**同一条数据通路上，在两个不同的层各埋一个计数器，往往比任何猜测都快。**

## 来电：一度以为是运营商的问题

**现象**：短信收得到，外呼打得出，**来电全部进语音信箱**。协议追踪里看不到任何呼叫请求。

当时排除了一圈本地原因，写下了这样一句结论："这是运营商侧的设备签约或策略问题，不是我们的协议栈。"

**这句话是错的。** 两个本地缺陷叠在一起造成了它。

**第一个**：注册请求里没有带接入网信息头。规范（GSMA IR.51）要求 Wi-Fi 接入的终端必须带这个头，里面写明自己接在哪个 Wi-Fi 上。网络在有来电时要靠它做一个叫"终结接入域选择"的判断——这个号码现在应该往蜂窝送还是往 Wi-Fi 送。没有这个头，网络不认为 Wi-Fi 这条路可用。

而外呼不走这个判断，短信有独立的终结逻辑——**这正好解释了"外呼行、短信行、来电不行"这个奇怪的组合**。

**第二个**：受保护连接上只保存了一条回复通路，第一个响应发出后就清掉了。对短信没问题（一问一答），但通话要在同一条连接上依次发出临时响应、振铃、接通，最后那条接通写不出去，报"没有源通路"。网络等不到接通，就取消呼叫转去了语音信箱。改成按呼叫标识和序号记录，临时响应保留，最终响应才释放。

**教训**：当网络只扣下**一种**业务，而同一个注册地址上的其他业务都正常时，应该把自己的注册消息和规范逐项对比，而不是去猜签约。缺失的那个头就在早就抓到的注册消息里，从头到尾都在屏幕上。

还有一句：**"iPhone 能用"只证明这条线路有签约，不证明自己的实现合规。** 这两件事经常被混为一谈。

### 同一个问题修了两次

后来把整个东西迁移到新的后端底座上时，那个"稳定设备标识"的修复只存在于旧代码里，没人带过来——来电又一次进了语音信箱。

**没有测试覆盖的补丁，就是会在迁移中丢掉的补丁。** 现在的验证方式很简单：重启前后各拨一次，确认注册消息里的设备标识没变。

## 还没解决的一个问题

**隧道重建之后外呼失败，只有重启服务才能恢复。**

```text
SWu outbound inner packet rejected {"error": "netstack dataplane closed", "packet_len": 220}
```

旧隧道那一代的网络栈没有被关闭，而语音路径还钉在它上面。更麻烦的是，状态接口里隧道、注册、就绪全都是 true——注册确实已经在新隧道上重建好了——唯一的线索是语音那一项是空的。

思路是让语音客户端重新绑定当前的隧道会话，或者在会话关闭时一并拆掉旧的网络栈。

它和"声音丢一半"其实是同一家族的问题：**一个共享的底层资源，被多个生命周期不同的上层同时持有。** 这个模式在后面几篇里还会出现两次。

## 这一篇踩的坑，按协议层排

| 协议层 | 踩的坑 |
|---|---|
| 隧道握手 | 提议了自己不支持的算法 |
| SIM 认证 | 序列号重同步被当成了成功 |
| 加密 | 内核方案走不通，要在用户态做 |
| 注册 | 空认证头、代理要求、接入网信息头、稳定的设备标识 |
| 呼叫 | 地址缺主机、身份选错、没发确认、回复通路只存一条 |
| 媒体 | 共享 channel 被两个协议栈抢包 |

信令通了，声音正在树莓派里以 8 kHz PCM 的形式流动。下一篇解决一个更朴素的问题：这些声音怎么才能真的从一只听筒里出来。

## 全系列

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)

---

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