达是酒中趣,琴上偶然音

没有信号也能打电话:在树莓派上从零实现 VoWiFi(三)

· Technology

上一篇:树莓派上的短信与 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 Request421 Extension Required

两条协议细节,各让人查了半天:

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

内核加密是条死路

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

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

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

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

自己实现的这一侧:在逻辑通道上打开 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 确认收到。代码在请求里声明了支持这个特性,却从来不发确认。网络重传几次之后放弃,整个请求超时。

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

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

几个容易写错的协议细节

拨出去听到一段录音

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

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

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

通话声音丢一半

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

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

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

收到的包补偿的空洞最大单帧间隙
修复前521531395
修复后105203

被推翻的努力:在此之前试过加抖动缓冲、解耦播放、改成 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 的形式流动。下一篇解决一个更朴素的问题:这些声音怎么才能真的从一只听筒里出来。

全系列

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

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