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

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


0. 写这篇文章的动机

我最近在做一个 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-ci1.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 100

在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::threadsleep方法进行休眠,而是应该使用tokiosleep方法休眠。我这里是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 172

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 234

数字类型转换是所有语言中存在的一项操作。比如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 276

参考上一篇《初学rust,如何实现在运行时对全局变量设置和读取》,文章中说,可以用OnceCell来定义全局变量,然后就可以对全局变量进行读写,实际上,我发现OnceCellset()方法只能调用一次,如果你试图第二次调用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 228

先看一段代码

#[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 408

最近尝试在一个项目中引用一个私有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 127

最近入手了一块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 147

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)返回。这样一来,错误的抛出和处理就变得优雅多了。