没有信号也能打电话:在树莓派上从零实现 VoWiFi(三)
上一篇:树莓派上的短信与 eSIM 网关 | 下一篇:USB 话机、浏览器通话与 ESP32
手机设置里有个开关叫”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 秒。
排查这类问题有个通用办法:先把提议清单砍到只剩一个你确信实现正确的算法,能通就说明问题在协商,不通再往别处找。
“就绪”这个词要小心
后台里有四个状态:
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 带着一个重同步参数。
每次都停在同一个词上
现象:
IMS REGISTER failed: status=0 result=auth_phase_reached
原因:这是一条串起来的错误链,很典型。
卡返回了 0xDC,要求序列号重新同步。这在协议里是完全正常的一步——卡和网络对不上账,重来一次就好。但代码把它当成”正常事件”返回了 nil 错误。而上层判断”是否需要重同步”的方式是检查错误类型,nil 当然匹配不上,于是被当成”认证成功但没拿到密钥”,接着用空结果去算摘要,最后死在密钥检查上。
更糟的是两件事:这条错误恰好匹配了库里”脱敏错误信息”的规则,真实原因被改写成了一个毫无意义的词;而且有一个单元测试锁死了这个 bug,它断言这里应该返回 nil。测试不是真理,它只是把某一时刻的理解固定下来了。
解决:同步失败时返回专门的错误类型和参数。之后流程就顺了:请求重同步 → 发送 → 第二轮挑战 → 认证完成 → 装载加密 → 200 成功。
调试经验:这个库故意对注册失败做了脱敏。在认证命令外面自己包了一层日志(只记录结果类型和数据长度,绝不记录密钥材料),一次就找到了问题。注册失败的时候,先看这一层。
成功的完整链路长这样:
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 依赖,一条命令就能交叉编译到树莓派。
拨号发出去,什么回复都没有
现象:外呼一直停在”拨号中”,连一个错误码都没有。而同一条通道上的注册请求一切正常。
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,其实对方已经接受了
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 能用”只证明这条线路有签约,不证明自己的实现合规。 这两件事经常被混为一谈。
同一个问题修了两次
后来把整个东西迁移到新的后端底座上时,那个”稳定设备标识”的修复只存在于旧代码里,没人带过来——来电又一次进了语音信箱。
没有测试覆盖的补丁,就是会在迁移中丢掉的补丁。 现在的验证方式很简单:重启前后各拨一次,确认注册消息里的设备标识没变。
还没解决的一个问题
隧道重建之后外呼失败,只有重启服务才能恢复。
SWu outbound inner packet rejected {"error": "netstack dataplane closed", "packet_len": 220}
旧隧道那一代的网络栈没有被关闭,而语音路径还钉在它上面。更麻烦的是,状态接口里隧道、注册、就绪全都是 true——注册确实已经在新隧道上重建好了——唯一的线索是语音那一项是空的。
思路是让语音客户端重新绑定当前的隧道会话,或者在会话关闭时一并拆掉旧的网络栈。
它和”声音丢一半”其实是同一家族的问题:一个共享的底层资源,被多个生命周期不同的上层同时持有。 这个模式在后面几篇里还会出现两次。
这一篇踩的坑,按协议层排
| 协议层 | 踩的坑 |
|---|---|
| 隧道握手 | 提议了自己不支持的算法 |
| SIM 认证 | 序列号重同步被当成了成功 |
| 加密 | 内核方案走不通,要在用户态做 |
| 注册 | 空认证头、代理要求、接入网信息头、稳定的设备标识 |
| 呼叫 | 地址缺主机、身份选错、没发确认、回复通路只存一条 |
| 媒体 | 共享 channel 被两个协议栈抢包 |
信令通了,声音正在树莓派里以 8 kHz PCM 的形式流动。下一篇解决一个更朴素的问题:这些声音怎么才能真的从一只听筒里出来。
全系列
这个系列是个人学习和技术研究记录。请遵守所在地法律法规和运营商服务条款。