普通人自建基于 WiFi Calling 的通讯网络能否行得通
Tag WiFi-Calling, 语音通话, 通讯, on by view 29

写于:2026 年中 一句话结论:不行。 普通人没有合法、可行的通道走”运营商级 WiFi Calling”。技术上被三重锁死,第一道且最硬的一道就是 eUICC 证书信任链——私有证书行不通,这是硅片级的死结。能做的最远边界是应用层 VoIP over WiFi,那不是 WiFi Calling。


0. 写这篇文章的动机

我最近研究esim,在尝试做一个 SGP.22 SM-DP+ 的开源实现,仓库叫 sm-dp,目标是把”用户从二维码触发 eSIM Profile 下载”这条链路完整跑通。几个月时间,我把 ES9+ 第一步 initiateAuthentication 写完、容器化、跑通 HTTPS、跑通 nginx 反代、修了一遍又一遍的协议头(大小写不敏感 + 多版本协议号支持),最后 MiniLPA 真的把请求发到我服务器上,sm-dp 返回 HTTP 200 + 标准失败 envelope,错误码 subjectCode 8.8.4 / reasonCode 3.7,信息是:

“The SM-DP+ has no CERT.DPauth.ECDSA signed by a CI Public Key supported by the eUICC”

这一行错误信息不是 bug,不是协议实现不对,不是网络问题。它就是答案:eUICC 里没有能信任我 SM-DP+ 证书的 CI 公钥。

撞墙的位置:eUICC 证书信任链。

我把这条墙讲清楚,再回答”那能不能自建 WiFi Calling”这个问题。


1. 先把两件事分清楚:运营商 WiFi Calling ≠ 应用层 VoIP over WiFi

读者里肯定有人把这两个当回事,所以我必须先拆开:

运营商 WiFi Calling(VoWiFi) 应用层 VoIP over WiFi
信令 IMS SIP(P-CSCF / S-CSCF / I-CSCF / TAS) App 自有协议(APNs / Signal Protocol / 自建 WS)
鉴权凭据 ISIM @ UICC(SIM 卡里那个 SIP Digest-AKA 凭据) App 账户 / 设备证书
寻址 E.164 MSISDN(手机号) App ID / UUID / 自编号
接入网 不可信 WiFi + IPsec/IKEv2 → ePDG → EPC/5GC → IMS 任何 IP 承载,裸 socket
通话对象 跨运营商经 IPX / IBCF 进 PSTN 私域;想进 PSTN 需 SIP Trunk
紧急呼叫 E.112 / E.911 路径(受 PLMN 管制) 通常无合规紧急呼叫能力
计费 / 合法监听 运营商 CDR + LI 合规 App 厂商服务器侧,不在运营商 CDR
在手机拨号盘里 是,作为运营商主叫 不是,仅 App 自己的来电界面

运营商WiFi Calling / VoWiFi 是一个 3GPP 规范的术语,本质是”把 IMS 语音搬到不可信 WiFi 上”。它不是”用 WiFi 打电话的 App”。两者在协议层完全平行,互不相通。

读者能做的最远边界——也是我自己正在做的——是右列那一条。那不是 WiFi Calling。这一点必须先说死,不然下面没法讲。


2. 第一道锁(也是最硬的一道):eUICC 证书信任链无法自建,私有证书就是不行

这一节是整篇文章最重要的一节,我会写得很慢。

2.1 我手里这块 eUICC 里到底有什么

我用的是一块 Eastcompeace(东信和平)生产、GSMA SAS 编号 ED-ZI-UP-0826 的消费级商用 eUICC。svn 是 2.2.2,euiccFirmwareVer 4.2.0。我用 MiniLPA 读出 EUICCInfo2,展开 euiccCiPKIdListForVerification:

euiccCiPKIdListForVerification: [
  "81370f5125d0b1d408d4c3b232e6d25e795bebfb",  // GSM Association - RSP2 Root CI1
]
euiccCiPKIdListForSigning: [
  "81370f5125d0b1d408d4c3b232e6d25e795bebfb",  // GSM Association - RSP2 Root CI1
]

就一项。不是”列表里主要是 GSMA,但也可以有别人”——是只有一项。

这一项是 GSM Association - RSP2 Root CI1 的公钥指纹。整张芯片一辈子就认这一个 CI。它的公钥在 eUICC 出厂时被一次性烧入 eUICC 内部的 ECASD(Embedded Certificate Authority Security Domain)—— ECASD 是 eUICC 的 JavaCard 安全元件里专门管理证书链的应用。这个烧入是 OTP(一次性可编程)或同等强度的写保护,软件层面改不了。

2.2 我试过”绕过”,结果就是不行

我 OpenSSL 生成了一对 ECDSA P-256 私钥,自签了一张 CI 证书,role OID 用 id-rsp-ci(1.3.6.1.4.1.46898.3)。再用这张自签 CI 给 SM-DP+ 签了一张 CERT.DPauth.ECDSA,role OID 用 id-rsp-dp-authentication。我把 SM-DP+ 跑起来,让 MiniLPA 触发下载。

协议层面一切正常。POST /gsma/rsp2/es9plus/initiateAuthentication 200 OK,所有握手都过,serverCertificate 也下发了。但真正的 Profile 下载在 ES10b 阶段被 eUICC 拒接——eUICC 拿到 serverCertificate 后会沿着 DPauth → CI → GSMA Root CI1 做链式验签,发现”这张 DPauth 是由一张不在我信任集的 CI 签的”,链路到我的自签 CI 时断了,回退到根时对不上,于是拒接。

报错就是上面那个 8.8.4 / 3.7:”无 CI 信任的 ECDSA 证书”。

2.3 一个常见误区:”装个根证书到手机能不能绕过”

有人问过我:”那我把 GSMA Root CI1 的证书装到手机的系统信任根里,不就解决了吗?”

不行。这是一个典型的认知误区,原因有两个:

第一,iOS / Android 的”系统信任根”只管 TLS / HTTPS 客户端校验,跟 eUICC 完全无关。iOS 上你装一个企业 CA 根证书,它影响的是 Safari / Mail / 第三方 App 怎么验 HTTPS 服务器。Android 7+ 默认连用户 CA 都不信,要 root 才能让 App 默认信用户 CA。但 eUICC 是独立的 JavaCard 安全元件,跟主系统隔离,主系统的”信任根”管不到它。eUICC 验签用的是它自己烧死在 ECASD 里的那张 GSMA Root CI1 公钥,只此一份。

第二,eUICC 的 ECASD 没有外部 APDU 写接口。ECASD 是 eUICC OS 厂商(Eastcompeace / Idemia / Thales 等)在工厂模式下用厂商签名固件装入的,运行态下不允许任何 APDU 修改它的信任列表。你能换的只有 Profile(普通应用),碰不到 ECASD。能”修改” ECASD 信任列表的唯一合法途径是 eUICC 固件更新——但固件更新包也得由 EUM(eUICC 厂商)私钥签发,EUM 的根又是 GSMA Root CI。走到哪里都绕回 GSMA。

2.4 那”开发 / 仿真 eUICC”呢?—— 能绕,但用途有限

必须诚实补一刀:“绕”对部分 eUICC 是可以的。少数开发 / 仿真 eUICC 的 ECASD 是”软”的,工厂模式允许加载额外 CI 信任锚:

  • osmocom 系列(swp-euicc 等)
  • 某些 Idemia / Valid 测试卡(需通过厂商渠道申请,明确”测试模式”)
  • 纯软件仿真器(如某些 JavaCard 仿真器)

在这些卡上,你可以把自己生成的自签 CI 公钥塞进信任集,eUICC 就会信。但这些卡不是消费级 eUICC,不能用于任何商用场景(运营商商用网不识别它们)。它们的用途是:

  • 协议层验证(”我的 SM-DP+ 协议实现对不对”)✓
  • 端到端加密路径验证(”签名验签链路通不通”)✓
  • 真实商用 Profile 下载 ✗
  • 让消费级手机原生电话 App 把你的 SM-DP+ 当运营商 ✗

我手头这块 Eastcompeace 不在列。本文后面所有讨论都以”商用 eUICC”为前提。开发卡的”绕过”是另一篇文章的话题,不能混淆。

2.5 GSMA PKI 的真相

把上面这些事实落到 GSMA PKI 体系里看:

GSMA Root CI(公开信任锚,固化在每张合规 eUICC 的 ECASD)
    │
    │ 由 GSMA Root CI 私钥签发
    ▼
Sub-CI(也叫 CI)
    │ - EUM 厂商(Eastcompeace / Idemia / Thales / Valid / G+D 等)持有
    │ - 这些 CI 的公钥被它们出厂的 eUICC 信任
    ▼
End-entity 证书:SM-DP+ / SM-DS / EUM

这张图的三个特征决定了”个人不可能”:

  1. Root CI 私钥不卖:GSMA Root CI 私钥物理隔离在 GSMA 金库里,没有任何合法出售渠道。任何说”出售 GSMA Root CI 私钥”的网站都是骗子。

  2. Sub-CI 只发给通过 SAS-SM / SAS-SDS 现场审计的企业:SAS 是 GSMA 的安全认证体系,SM(Subscription Management)针对 SM-DP+ 服务,SDS 针对 SM-DS。审计员是 GSMA 授权的第三方(BSI / atsec / TÜV 等),要查你的:

    • HSM(硬件安全模块,FIPS 140-2 Level 3 或同等),必须,不能把 SM-DP+ 私钥存成 PEM 文件塞进容器
    • 物理安保(双人控制、门禁、监控)
    • 密钥管理与备份方案
    • 站点隔离、网络隔离
    • 人员背景调查
    • 年度复审
  3. 审计周期和钱:从启动到拿证 6 到 12 个月,钱 六位数 USD 起步(HSM 几万到十几万 + 审计费 + 持续合规运维人力)。

任何个人 / 小团队,没有这条路。

2.6 结论(这一节最重要的一句话)

eUICC 证书信任链是个人根本无法触碰的硅片级约束,私有证书行不通,这不是软件问题。

我在 sm-dp 里跑通 ES9+ 协议层之后撞上的墙,就是这堵墙。这堵墙在我手里这块 Eastcompeace 芯片上不可逾越,对任何商用 eUICC 都不可逾越,对所有 GSMA 体系内的”自建 SM-DP+“尝试都不可逾越。这是 eSIM 安全模型的核心:如果信任锚能由软件改写,整个体系就崩了。


3. 第二道锁:运营商 IMS 核心网不是你想接就能接

即便你解决了证书链(你做不到,但假设做到了),下一个墙是运营商 IMS 核心网。

运营商 WiFi Calling 的完整链路是:

UE(手机)
   │
   │  IPsec/IKEv2 隧道(SWu 接口,TS 33.402)
   ▼
ePDG(运营商侧加密网关,Cisco / Ericsson / Nokia / Mavenir 商用产品)
   │
   │  GTP / PMIPv6(3GPP TS 23.402 S2b / TS 23.501 非授信接入)
   ▼
EPC / 5GC(核心网:MME / SGW / PGW / AMF / SMF / UPF)
   │
   │  SIP(IMS 接入)
   ▼
IMS 核心:P-CSCF → I-CSCF → S-CSCF → TAS → SBC
   │                                    │
   │                                    │ 媒体:RTP / SRTP(受 QoS 控制)
   ▼                                    ▼
HSS / UDR(用户数据库,存 ISIM 凭据)
   │
   │ 跨运营商经 IBCF 进 IPX → 对端运营商 IMS → PSTN

相关 3GPP 规范锚点:

  • TS 23.228(IMS 总体)
  • TS 23.402(非授信非 3GPP 接入,ePDG / SWu / S2b)
  • TS 23.501 / 23.502(5G 系统架构,非授信接入归入 5GC)
  • TS 24.502(非授信接入的会话管理)
  • TS 33.402(IKEv2 / IPsec 安全)

IMS 凭据在 ISIM @ UICC——也就是 SIM 卡里的 SIP Digest-AKA 凭据。这意味着”号码”绑在 SIM 卡上,不绑在 App 上。你给朋友发个”我的 WiFi Calling 号码是 13800138000”,那个号码必须在运营商 HSS 里登记,必须有 ISIM 凭据,必须有对应的 SM-DP+ Profile 已下发到某张 UICC。这条链路没有任何一个环节是开放的。

  • ePDG 是运营商专有设备
  • HSS/HLR 是运营商核心数据库
  • IBCF 是运营商间边界
  • IPX 是 GSMA 运营的 IP 交换网络(Equinix / Etisalat / Tata 是主要运营商),不接入散户

这是企业基础设施级别的封闭,普通人连”租一个 IMS”都做不到。


4. 第三道锁:iOS / Android WiFi Calling 入口是运营商 entitlement,不是用户开关

假设(不可能的假设)你前两关都过了,你的 SM-DP+ 把 Profile 下发到了某张 eUICC 上,你还需要 iOS / Android 在系统设置里亮出”WiFi 通话”开关。

4.1 iOS

iOS 的 WiFi Calling 开关不是用户能强制打开的。它出现的条件是:

  1. 系统读到一张经过 Apple 审核的 carrier bundle / IPCC(carrier.plist + 配套 APN、IMS entitlement、号码映射)
  2. Modem 固件支持 IMS VoWiFi
  3. eUICC 上有对应运营商下发的 Profile

参考 Apple 官方文档:Use Wi-Fi calling on your iPhone。Apple 维护一个运营商审核流程,运营商必须提交自己的 carrier bundle 经 Apple 审核。散户的 carrier bundle 不会被审核。

4.2 Android

Android 类似但更复杂。Android 11+ 的 IMS API(ImsService / ImsManager)是 AOSP 提供的接口框架,但实现必须由运营商提供:

  • 运营商的 IMS 服务二进制(OEM / modem 厂商预装或系统镜像里)
  • carrier_config.xml / carrier_settings 配置文件(描述 PLMN、APN、IMS entitlement)
  • Modem HAL(QMI / RIL / IMS 调制解调器层)支持 IMS

参考 Android 官方文档:Wi-Fi calling。

裸 AOSP / 第三方 ROM 没有这些运营商二进制,永远显示不出 WiFi Calling 开关。LineageOS / GrapheneOS / Pixel Experience 上你都不会看到这个开关。

4.3 平台权力

这一道锁的本质是平台权力——苹果和 Google 决定”哪个运营商能被点亮”。这不是开放接口,不是 SDK 调一调就能开。运营商必须:

  • 走 Apple 的 Carrier Bundle 审核流程
  • 与 Google 签 carrier entitlement 协议
  • 接受苹果 / 谷歌的合规审计

普通开发者、创业公司想绕过去?没门。


5. 应用层 VoIP 进不去 IMS

行,我们退一万步——不追求运营商 WiFi Calling,就做个 App 让你和朋友能语音通话。是不是就行了?

可以。但要清醒地知道,这条路是和 IMS 完全平行的另一条路:

  • FaceTime:信令走 APNs,媒体 P2P(ICE/STUN/TURN),完全不进运营商 IMS / ePDG / SIP / ISIM
  • WhatsApp / Signal / Telegram:应用层 E2EE(Signal Protocol 等),UDP/TCP/TLS 上的 proprietary 信令,不注册到 IMS,不用 ePDG,不读 ISIM
  • 我的 rtpd:自建 WebSocket 信令 + UDP RTP 媒体,phone-android / phone-ios 通过 UniFFI 调用 rtpd-sdk,跑的是 48 kHz Opus / G.711 PCMU over UDP RTP

这些应用在 App 层面 用 CallKit / VOIP Push 让锁屏体验接近原生电话——iOS 上 App 可以在锁屏弹出来电 UI,点击接听,CallKit 让通话显示在系统的”通话中”界面里。但媒体始终在 App 自己控制的实时连接里,不进运营商核心网,不被运营商 CDR 计费,不走 ePDG,不使用 ISIM 凭据。

GSMA 在 2018 年的一份白皮书里把这类服务统称为 OTT(Over-The-Top)VoIP,明确区别于运营商 VoWiFi:GSMA blog - OTT Calling Protocol Analysis。

互通 PSTN 仍然需要 SIP Trunk + 落地号,回到运营商身份,绕不开。


6. GSMA SGP.22 “999” 是不是个人通道?

不是。这是个常见误解。

GSMA 在 SGP.22 体系里确实给非传统运营商 / 私有网络 / MVNO 保留了一个简化通道,对应 Apple iOS 上的”999 标识”。它的本质是:

  • 简化版 SAS-SM 合规(不是免除,仍然要 GSMA 注册 + 当地监管备案)
  • iOS PLMN 白名单(”999”是一个 MCC/MNC 范围,由 Apple 审核)
  • 走标准 SGP.22 协议(仍然是 GSMA Root CI 签发的 CI 链)

它的目标客户是:

  • 企业 / 园区 / 港口 / 矿区 / 校园的私有 5G/LTE 网络运营商
  • 有 ICP/ISP 资质的 MVNO
  • 政府 / 应急 / 国防通信

不是个人。仍然要法人主体,仍然要 GSMA 注册,仍然要合规审计。它降的是合规门槛,不是消除 GSMA PKI——这点要讲清楚。


7. “那我做私有 IMS 网不就完了?”——是的,企业可行,个人不是合适主体

有些读者看到上面可能会想:”既然问题在 IMS 核心网,那我自建一个 IMS 私有网给园区用不就行了?”

这条路技术上完全可行,且已经有很多商用产品。具体拓扑:

私有基站(small cell / 5G gNB)
    │
    ▼
私有 5GC:AMF / SMF / UPF
    │
    ▼
私有 IMS:CSCF / TAS / SBC / HSS
    │
    ▼
私有 ISIM / USIM 凭据 → 行业终端(防爆手机 / 三防 PDA / 工业模组)

商用方案提供商:Athonet、Celona、CoreNetwork Dynamics、Mavenir、华为、中兴、京信、佰才邦等。

能做什么:

  • ✅ 园区内部打通:工人 A 给工人 B 走 IMS VoNR / VoLTE
  • ✅ 行业终端之间互联互通
  • ✅ 私有网络的 QoS / 紧急呼叫 / 计费 / 调度

不能做什么:

  • ✗ 出园区打电话给北京/纽约/伦敦的朋友:仍要公网运营商 SIP Trunk + 落地号
  • ✗ 让消费级 iPhone / 普通 Android 原生电话 App 显示为你的私有运营商:需要 Apple / Google 审核 carrier bundle,要 GSMA PKI 颁发合法 SM-DP+ 证书链
  • ✗ 在淘宝上卖给个人用户:合规不允许

个人适不适合:

  • 50–500 用户的企业场景:可走 Athonet / Celona / Mavenir 这类 SaaS 化方案,半年到一年部署完成,百万级人民币预算
  • 个人 / 小作坊:不合适。HSM + 现场审计 + GSMA 注册 + 当地工信部备案(频谱 + 电信业务资质),百万级设备和合规起步
  • 不要混淆:私有 IMS 网不是”WiFi Calling 替代品”,它是企业自建专网方案

结论:私有 IMS 是企业选项,不是个人 “WiFi Calling” 的替代品。


8. 我手头这套栈在整张图里是什么位置

让我把我自己跑的东西摆出来,给读者一个具象的”普通人能做的最远边界”:

┌──────────────────────────────────────────────────────────────┐
│ phone-android (Kotlin / Compose, 48 kHz Opus over UDP RTP)   │
│ phone-ios     (SwiftUI, G.711 PCMU over UDP RTP)            │
│       │                                                        │
│       │ UniFFI bindings                                        │
│       ▼                                                        │
│ rtpd-sdk (Rust)                                                │
│       │                                                        │
│       │ WebSocket signaling + UDP RTP                         │
│       ▼                                                        │
│ rtpd (Rust) ──── PostgreSQL(短号注册、状态)                  │
└──────────────────────────────────────────────────────────────┘

        完全在 IP 层之上,不进运营商 IMS / ePDG / ISIM
        类比:FaceTime / WhatsApp / Signal / 自建 SIP PBX
┌──────────────────────────────────────────────────────────────┐
│ MiniLPA (Kotlin 桌面 GUI)                                      │
│       │                                                        │
│       │ GSMA RSP 流程                                          │
│       ▼                                                        │
│ sm-dp (Rust / Rocket, SGP.22 SM-DP+)                         │
│   - ES9+ /gsma/rsp2/es9plus/initiateAuthentication ✓          │
│   - ES9+ /gsma/rsp2/es9plus/authenticateClient (待实现)        │
│   - ES9+ /gsma/rsp2/es9plus/getBoundProfilePackage (待实现)   │
│   - 卡在:ProfilePackagingProvider = UnavailableProvider      │
│   - 原因:没有 GSMA 签发的 CERT.DPauth.ECDSA                 │
└──────────────────────────────────────────────────────────────┘

左边那套(rtpd + phone-*)是”应用层 VoIP over any-IP”,我可以做,可以给你用,可以做完整加密协议,可以用 iOS CallKit 让体验接近原生电话。但它永远进不了运营商拨号盘。

右边那套(sm-dp + MiniLPA)是”eSIM 个人化通道”,我可以做完整协议层,但永远拿不到 GSMA 签发的 SM-DP+ 证书,所以商用 eUICC 永远不信我。

这两个栈的交集:没有。它们解决不同的问题,互不替代。


9. 结论

TL;DR(再说一遍):普通人自建”运营商级 WiFi Calling”——不行。三重锁死:

  1. eUICC 证书信任链(首道锁,最硬) —— GSMA Root CI1 公钥烧死在 ECASD,私有证书行不通,软件改不了,这是硅片级的死结。
  2. 运营商 IMS 核心网 —— 凭据在 ISIM @ UICC,ePDG / HSS / IBCF / IPX 全部是企业级专有基础设施。
  3. iOS / Android WiFi Calling 入口 —— carrier entitlement + carrier bundle + modem 固件,平台权力决定谁能被点亮。

普通人能做的最远边界:应用层 VoIP over WiFi。它不是 WiFi Calling,不能进运营商拨号盘,不能跨运营商互通 PSTN 出园区,但仍能在 IP 范围内提供良好体验——我手头的 rtpd + phone-android / phone-ios 就是这个边界的一个真实案例。

“那能不能做私有 IMS 网?”——企业可以,个人不行,仍要 GSMA 注册 + 合规审计 + 法人主体。

“那 GSMA SGP.22 ‘999’ 是不是个人通道?”——不是。简化通道仍是企业通道,仍然走 GSMA PKI。

“那把 GSMA Root CI 装到手机系统信任根里能不能绕过?”——不能。iOS / Android 系统信任根管不到 eUICC,ECASD 是独立的 JavaCard 安全元件。

如果你想做”普通人能用的语音通话网络”,我的真实建议:

  • 不要碰 eSIM / SM-DP+ 这一条路,除非你是企业、愿意投百万级 + 半年到一年走 SAS-SM
  • 走应用层 VoIP——IP 网络上能跑通就好,iOS CallKit 让体验接近原生电话,体验上限就在这里
  • 要合法合规:不要碰真实 PSTN 互通,不要碰紧急呼叫责任,不要碰 PLMN 标识

如果你想做”私有 5G/LTE + 私有 IMS”——这是个百万级起步的企业项目,不是个人项目,请按企业路径走。


10. 参考资料


11. 写在最后

我搭 sm-dp 的初衷是”我想搞清楚 eSIM 到底是怎么工作的”。结果搞清楚之后,发现这条路对个人没有商用落点。

但搞清楚的过程本身有价值。我跑完了协议层,撞上了 eUICC 信任链这堵墙,写下这篇文章,至少能让下一个想”自建 eSIM 运营商”的朋友少走三个月弯路。

对普通开发者来说,应用层 VoIP over IP 仍然是 IP 网络上能做的事情里体验最好的一条路。iOS CallKit 让它看起来像电话,Signal Protocol 让它端到端加密,WebRTC 让浏览器免装 App。它不是运营商级 WiFi Calling,但它是真正可用的语音通信。

这是我能给出的最诚实的答案。


蒹葭苍苍,白露为霜,又是一年霜降
Tag 故乡, 霜, on by view 0

862vbg8b

没有冬季的严寒,又怎么懂得春暖花开
没有夏季的酷暑,又怎么理解秋高气爽
我不喜欢南方的一年一季
只怀念故乡的春夏秋冬


初学rust,tokio::spawn中loop-sleep无法使用ctrl+c终止进程的原因
Tag tokio, ctrl-c, 终止进程, on by view 117

在tokio::spawn中起了一个新协程,然后在代码中执行sleep死循环,发现程序运行时,主线程中无法通过ctrl+c终止进程。代码如下

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // 解析命令行参数
    let args = Arguments::parse();

    if args.version {
        print_ver();
        return Ok(());
    }

    // 初始化摄像头
    spawn(async move {
        info!("camera capture started");
        camera_capture().await;
    });

    let c = config::get_config();
    let listen = c.tlps.as_ref().unwrap().listen.as_ref().unwrap();
    println!("listen http://{}", listen);
    HttpServer::new(|| {
        App::new().service(camera_settings).service(
            SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", ApiDoc::openapi()),
        )
    })
    .bind(listen)?
    .run()
    .await
}

async fn camera_capture() {
    let tlps_cfg = config::get_config().tlps.as_ref().unwrap();

    loop {
        std::thread::sleep(std::time::Duration::from_secs(1));
        print_ver();
    }
}

现象,在终端中按下Ctrl+C无法退出进程,如下

hg6ndsox

最后发现原因是tokio拉起的线程(协程)中不能使用std::thread的sleep方法进行休眠,而是应该使用tokio的sleep方法休眠。我这里是actix-web,虽然它是基于tokio的,但是,他也有对应的sleep方法,应该改为如下方式

loop {
    actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
    print_ver();
}

再使用ctrl-c,能够正常终止进程了。


初学rust,trait的一些使用限制
Tag rust, trait, on by view 187

rust中,trait相当于其他语言中的接口。例如json序列化,需要实现Serialize这个trait,但是,这里存在如下三个限制:

  1. 要实现的 trait 和类型都在当前 crate 中定义。
  2. 要实现的 trait 在当前 crate 中定义,而类型在外部 crate 中定义。
  3. 要实现的类型在当前 crate 中定义,而 trait 在外部 crate 中定义。

也就是说,你想要对一个结构体去实现某个trait,要么,这个结构体是你的(也就是在你当前crate包中),要么,这个trait是你定义的,两者至少有一个是你自己的,你才能对这个结构体实现这个指定的trait。

如果,trait和结构体都不是你实现的,你将无法为这个外部结构体实现外部trait。例如,我想为TcpStream实现Serialize,直接写,会报错

sfgdut7l

`mio::net::TcpStream` is not defined in the current craterustcE0117
main.rs(24, 1): original diagnostic

只能通过包裹TcpStream来实现了,总之,为外部结构体实现外部trait就是行不通。


初学rust,rust数字类型转换不安全
Tag rust, 类型转换, on by view 250

数字类型转换是所有语言中存在的一项操作。比如golang的float64转float32,用float32(v64)即可实现,但是这个转换在v64的值只要不超过float32的上限,就可以安全转换。但是,这在rust中似乎行不通。

我在一个实践程序里在结构体中定义了一系列的f32,i32字段,然后用parse::<f32>()从字符串解析出f32,这一切都正常,我单独单测代码打印过这个结果,正确的读取了数字;但是当我将其进行了一系列的累加之后,转为f64再json序列化,发现居然变成了null,然后我debug打印发现,数字变成inf了,也就是f64::INFINITY,我类型转换过程中用的就是as f64。

后来我索性将定义的f32改为了f64,最后它又正常了。这说明as操作并没有想象中那么安全。


初学rust,多线程中读写全局变量
Tag 全局变量, 读写, rust, on by view 286

参考上一篇《初学rust,如何实现在运行时对全局变量设置和读取》,文章中说,可以用OnceCell来定义全局变量,然后就可以对全局变量进行读写,实际上,我发现OnceCell的set()方法只能调用一次,如果你试图第二次调用set()来修改已经设定好的值,将会报错,设置值就失败了。

由于这一切都是为了读写的并发安全。所以OnceCell是无法实现读写全局变量的。要实现读写全局变量,应该使用RwLock。如下

定义全局变量

static WORKER_PID: RwLock<Option<u32>> = RwLock::new(None);

读写全局变量

// 写入
fn store_worker_pid(pid: u32) {
    let mut data = WORKER_PID.write().unwrap();
    *data = Some(pid);
}

// 读取
let data = WORKER_PID.read().unwrap();
let cpid = data.unwrap();

这里的读写就可以多次进行了。可以看到,这里使用了读写锁,写的时候会先通过write()方法获取写入锁实例,然后对其赋值写入。读取的时候会先通过read()方法获取读取锁实例,然后取里面的值。这样一来就实现了全局变量的读写。


初学rust,不允许遍历过程中修改HashMap
Tag rust, HashMap, 遍历, 修改, on by view 241

先看一段代码

#[test]
fn test_hash_map() {
    let mut mp: HashMap<&str, &str> = HashMap::new();
    mp.insert("k", "v");
    mp.insert("k1", "v1");
    let x = mp.keys().clone();
    for k in x {
        mp.insert("k2", "v1");
        mp.insert("k3", "v1");
    }
    mp.insert("k2", "v1");
    mp.insert("k3", "v1");
}

看,它报错

error[E0502]: cannot borrow `mp` as mutable because it is also borrowed as immutable
   --> src/process.rs:153:9
    |
151 |     let x = mp.keys().clone();
    |             -- immutable borrow occurs here
152 |     for k in x {
    |              - immutable borrow later used here
153 |         mp.insert("k2", "v1");
    |         ^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

报错告诉我们,不允许将mp作为mutable,因为它已经用于immutable了。难道,HashMap插入数据完毕,开始读取数据之后,不能再次插入数据了?我一开始这么怀疑,不应该啊,于是代码改成这样

#[test]
fn test_hash_map() {
    let mut mp: HashMap<&str, &str> = HashMap::new();
    mp.insert("k", "v");
    mp.insert("k1", "v1");
    mp.keys().clone();
    mp.insert("k2", "v1");
    mp.insert("k3", "v1");
}

正常了,没报错了。看样子问题出在这个for ..in..中。仔细分析代码,我在for中遍历了它的key,或者说我正在将这个HashMap中的数据拿出来,这时候,我在for中尝试往这个HashMap中写入数据,写入数据会让这个HashMap发生变更,这里第一感让我觉得可能有问题。比如我HashMap中有10个元素,我在遍历它,然后在for中间插入新元素,那么是不是有下列问题:1,我的HashMap会不会越遍历越多,会不会永远无法遍历完;2,HashMap是无序的,我将新元素插入HashMap中,会不会导致我已经遍历过的数据由于插入新数据,导致再次被读取出来,因为它可能位置发生变化了嘛。

其实这就是数据竞争和它带来的不确定性问题,rust作为一个内存安全第一的编程语言,编译器会教你做人。

于是,我再改

#[test]
fn test_hash_map() {
    let mut mp: HashMap<&str, &str> = HashMap::new();
    mp.insert("k", "v");
    mp.insert("k1", "v1");
    let x: Vec<&str> = mp.keys().map(|k| *k).collect();
    for k in x {
        mp.insert("k2", "v1");
        mp.insert("k3", "v1");
    }
    mp.insert("k2", "v1");
    mp.insert("k3", "v1");
}

这回正常了。可以看到我的操作let x: Vec<&str> = mp.keys().map(|k| *k).collect();是将keys()拿到的Keys迭代器(仍旧从前面的HashMap里迭代)通过.collect()方法将迭代器里的元素“倒”入到Vec<&str>,这样这个Vec就是一个独立与HashMap内存空间之外的变量,再基于这个Vec进行遍历,就可以避免“边遍历边修改的”的情况了。

那么大家可以思考一下,其他语言,比如golang,遇到这种情况是怎么处理的呢。


初学rust,踩坑私有依赖
Tag rust, 依赖, git, on by view 431

最近尝试在一个项目中引用一个私有crate,这个crate是一个lib,然后存储在私有git仓库中,并且这个git平台不支持rust的crate-index.

  • 第一坑,引用git依赖

于是我在我的项目中这样引用外部依赖包

[package]
name = "las"
version = "0.1.1"
authors = ["...."]
edition = "2018"

[dependencies]
elasticsearch = { version = "8.5.0-alpha.1", default-features = false, features = [
    "rustls-tls",
] }
logwatcher = "0.1.1"
json = "*"
reqwest = { version = "0.11.12", default-features = false, features = [
    "blocking",
    "rustls-tls",
] }
toml = "0.8.6"
serde_derive = "1.0.177"
serde = "1.0.177"
serde_json = "1.0.104"
once_cell = "1.18.0"
futures = "0.3"
tokio = { version = "*", features = ["full"] }
uuid = { version = "1.4", features = ["v4", "fast-rng", "macro-diagnostics"] }
chrono = "0.4"
core_affinity = "0.8.0"
geoip = { git = "ssh://git@git.xxxx.com/xxxx/tgeoip.git", branch = "master", package = "geoip" }

nix = "0.26"
libc = "0.2.146"
clap = { version = "4.4.2", features = ["derive"] }
log4rs = { version = "1.2.0" }
log = { version = "0.4.20" }
[build-dependencies]
regex = "1.6.0"

需要注意的是,我git@git.xxxx.com:xxxx/tgeoip.git仓库中是一个workspace,里面有2个子项,一个是可执行bin项目,叫做regen,一个是外部可依赖包,叫做geoip(后续实践表明同一个workspace里面的子项引用就应该这么干,git路径是同一个,使用package指定子项的包名),我这样引入之后,发现死活拉取不了私有git依赖。报错没权限拉取,git鉴权失败

error: failed to get `geoip` as a dependency of package `las v0.1.1 (/data/code/rust/las)`

Caused by:
  failed to load source for dependency `geoip`

Caused by:
  Unable to update ssh://git@git.xxxx.com/xxxx/tgeoip.git?branch=master#e41c5279

Caused by:
  failed to clone into: /root/.cargo/git/db/tgeoip-9094aceea5940357

Caused by:
  failed to authenticate when downloading repository

  * attempted ssh-agent authentication, but no usernames succeeded: `git`

  if the git CLI succeeds then `net.git-fetch-with-cli` may help here
  https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli

Caused by:
  no authentication methods succeeded

各方查询后,都是建议添加这个选项,我开始还没搞清楚加在哪儿,但是发现使用环境变量好使。各方尝试后:

添加到~/.cargo/config文件中,如下

[net]
git-fetch-with-cli = true

然后再次执行cargo build无需环境变量即可。(前提是你本地已经配置好了访问ssh://git@git.xxxx.com/xxxx/tgeoip.git的ssh key)

  • 第二坑,外部依赖包要申明为rlib

我在这个bin项目中引用geoip,发现一直报错

failed to resolve: use of undeclared crate or module `geoip`

百思不得其解,最后发现是我依赖包申明类型的问题,我Cargo.toml申明的是

[package]
....

[lib]
name = "geoip"
crate-type = ["staticlib", "cdylib"]

[dependencies]
bincode = "1.3.3"
serde = { version = "1.0.193", features = ["derive"] }

实际lib应该申明rlib类型,才能被正常引用,改为如下,更新依赖,就没报错了

[package]
....

[lib]
name = "geoip"
crate-type = ["staticlib", "cdylib", "rlib"]

[dependencies]
bincode = "1.3.3"
serde = { version = "1.0.193", features = ["derive"] }

初试riscv机器
Tag riscv64, linux, on by view 135

最近入手了一块riscv64的开发板,是某宝上入手的StarFive2,准备拿它当做riscv64架构的服务器用来编译程序。开始刷上的是官方给的debian系统,成功启动了,nvme正常,但是有两个问题,第一个是apt update会报签名错误,第二个是不支持docker,想要让它支持docker,需要自己编译升级内核,非常麻烦。于是决定换用ubuntu版本的镜像。

根据官方教程,需要先将板载固件刷新到ubuntu社区的指定版本,从这里下载,需要注意的是,目前(2023/12/18)最新版的固件(u-boot-starfive-2024.01~rc4-0ubuntu1~ppa1这个)会导致kernel panic,需要下载下面的版本

2flhl8vj,展开后,用wget下载deb安装包,找个ubuntu机器,用dpkg -x解包,将里面的下面两个文件上传到sdcard.img刷机包启动的系统里面

g80xfqba

更新SPL

flashcp -v u-boot-spl.bin.normal.out /dev/mtd0

更新U-Boot

flashcp -v u-boot.itb /dev/mtd2

然后安装ubuntu系统,注意从这里选择下面版本的系统

jerua3mn

使用etcher将固件写入到sd卡,插入sd卡,启动,正常情况就可以启动成功。

在这中间我踩过一些坑,在此也一起分享一下。首先是hdmi插在4k屏无法显示,这样一来,我就不知道当前系统启动得怎么样了,因此kernel panic了,我也不知道,后来只能使用usb转串口用电脑连接串口,将串口控制台当做显示器用,才知道启动过程中发生了什么,串口连接如下图

3atnih7j

从串口控制台看到如下图(学到了,串口还能当显示器用)

y2m6970v

可以看到,启动过程卡死在kernel panic,我这才知道是内核崩溃了。看起来是u-boot-starfive-2024.01~rc4-0ubuntu1~ppa1这个版本的固件,对现有的23.04版本的系统兼容性有问题,所以我换了上个版本的固件,成功启动。

到此,在StarFive2上成功的安装上了ubuntu系统,docker也成功的安装,不过这个版本的板载固件 有个问题,就是无法识别nvme硬盘,希望下个正常版本能够支持。


初学rust,错误处理
Tag 错误处理, rust, on by view 172

rust中如何比较优雅的进行错误处理,这是一直以来困扰我的一个问题。最近写了一个ip地址库查询包,于是在其中实践了一下自定义错误、错误抛出等处理。

rust中,如果一个函数需要返回错误,那么应该用Result包裹返回值,Result定义如下

enum Result<T, E> {
    Ok(T),
    Err(E),
}

通常,Result中的第二个参数就是错误。比如Result<String, String>,那么它的错误就是一个String类型的值,如下

// 返回了正常值hi
fn find() -> Result<String, String> {
    Ok("hi".to_string())
}

// 返回了错误信息error
fn find() -> Result<String, String> {
    Err("error".to_string())
}

那么有另外一个问题,就是,我们如何处理其他库或者第三方函数抛出给我们的错误呢?我们可以用expect(),unwrap()来解决可以解决的错误,但是有时候我们不希望处理错误,希望能够将错误抛出给上层,让上层调用者去处理。这时候,我们应该怎么定义错误类型?你当然可以在你的函数中进行错误处理之后,抛出String类型的错误,以便继续使用String类型作为错误类型。

#[test]
fn test_find() {
    let x = foo();
    println!("{:?}", x.unwrap())
}

fn foo() -> Result<File, String> {
    let x = bar();
    match x {
        Err(e) => Err(e.to_string()),
        Ok(f) => Ok(f),
    }
    ...
}

fn bar() -> Result<File, io::Error> {
    File::open("regions.txt")
}

你可以看到,我在foo()中调用bar(),bar()中返回了一个包含io::Error的错误,而我的foo()中要求返回的是String,我在foo中用match处理了错误的情况,并且foo中可能调用其他第三方库函数,返回的错误类型不尽相同,我每种错误类型都可以使用match解开,然后返回String类型的错误。但是这样处理起来,代码看起来就非常乱,到处都是match错误。

其实我们有另一种方法,可以自己定义一个Error类型,然后对Error类型进行扩展,让它兼容其他类型,如下

use std::{fmt, io};

#[derive(Debug)]
pub enum Error {
    ParseError,
    ReadError,
    InvalidIPError,
}

impl std::error::Error for Error {}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Error::ParseError => write!(f, "Parse Error"),
            Error::ReadError => write!(f, "Read Error"),
            Error::InvalidIPError => write!(f, "Invalid IP Error"),
        }
    }
}

impl From<io::Error> for Error {
    fn from(_: io::Error) -> Self {
        Error::ReadError
    }
}

impl From<bincode::Error> for Error {
    fn from(_: bincode::Error) -> Self {
        Error::ParseError
    }
}

impl From<std::net::AddrParseError> for Error {
    fn from(_: std::net::AddrParseError) -> Self {
        Error::InvalidIPError
    }
}

我们的Error类型,实现了fmt::Display,让它能够处理我的包项目中可能出现的第三方错误,并且针对这些错误实现了各自的From<T>的from方法,这样,这些第三方错误就可以直接以我的Error返回,使用?简写之后,代码就改成这样了

#[test]
fn test_find() {
    let x = foo();
    println!("{:?}", x.unwrap())
}

fn foo() -> Result<File, Error> {
    let x = bar()?;
    Ok(x)
}

fn bar() -> Result<File, io::Error> {
    File::open("regions.txt")
}

对比可以看到,我在foo中处理bar抛出的错误变得简单了,直接一个问号就将可能得错误抛出到Error了,然后再将Ok(x)返回。这样一来,错误的抛出和处理就变得优雅多了。