达是酒中趣,琴上偶然音

同一只 dongle 插在 Mac 上:没有驱动的平台怎么办(五)

· Technology

上一篇:USB 话机、浏览器通话与 ESP32 | 下一篇:iPhone 客户端

前四篇都在树莓派上。Linux 对这类设备很友好:串口有驱动,网卡有驱动,声卡有 ALSA,热插拔有 udev。

把同一只 dongle 插在 Mac 上,这些全都没有。

第一篇的结论在这里变成了具体的麻烦:模组的 AT 接口、网络接口、诊断接口都是 USB 厂商自定义类,macOS 不给它们绑任何驱动,/dev/cu.* 里连影子都看不到。

唯一够得着的通道是 ADB。安装很简单:

brew install android-platform-tools
adb devices -l

Mac 上有三种用法,由浅入深:

用法适合谁需要什么
A. 只上网出门给 Mac 用 4G切到 ECM 模式,什么软件都不装
B. 跑一份服务端想要和树莓派一样的网页后台、eSIM 管理编译一个 macOS 版本
C. 原生 App在 Mac 上直接打电话、发短信Xcode 构建,外加语音驱动

A. 只上网

AT+QCFG="usbnet",1
AT+CFUN=1,1

模组重启后,“系统设置 → 网络”里会多出一块有线网卡。模组自己做 DHCP 和 NAT,Mac 拿到一个 192.168.225.x 的地址。不用拨号,插上就有网。切回去用 usbnet,0

它会悄悄接管 Mac 的 IPv6

现象:Wi-Fi 看起来连得好好的,但浏览器的流量其实在走 SIM 卡。

原因:ECM 网卡会带来一个运营商的 IPv6 前缀。如果 Mac 当前的 Wi-Fi 没有 IPv6(很多家庭和办公网络都没有),这个前缀就成了整台机器唯一的 IPv6 默认路由。所有支持 IPv6 的网站——也就是今天大部分主流网站——全都从 SIM 卡走。

屏幕上没有任何提示。对一张漫游卡来说,这就是账单。

解决:原生 App 的默认策略是”不给 Mac 用 SIM 的数据”——连上模组时如果它处于 ECM 模式,就切到 QMI。因为 macOS 没有 QMI 驱动,也就不会出现任何网卡,自然无路可走。这个设置存在模组里,所以重启只会发生一次,而且从不在通话中发生。

在网络页手动切换会同时更新这个策略,下次连接不会被改回去。还加了一层防护,避免”切换没生效 → 重连 → 再切换”的死循环。

B. 服务端的 macOS 版本

make build-mac
./dist/vohive_<ver>_darwin_arm64

这棵代码树原本只为 Linux 写过,有四个子系统直接调用了 Linux 内核接口。移植的原则是:每一处都在最窄的接缝上拆开,Linux 那一侧的代码保持不变。

子系统macOS 上怎么办
把出站连接绑到指定网卡Linux 有专门的套接字选项,BSD 系有另一个
串口参数设置增加一条 BSD 路径
Wi-Fi 通话的加密数据面留空,所以 macOS 上没有 Wi-Fi 通话
热插拔监听留空,需要手动重新扫描
设备占用检测返回”未知”,走保守分支

还有一件事是移植前没想到的:设备发现方式也得换。 Linux 靠读 /sys/bus/usb/devices 和找 ttyUSB*,这两样在 macOS 上都不存在,结果就是后台的设备列表永远是空的。

解决办法是加一种”adb 传输”:系统路径不存在时,用 adb devices -l 来发现设备;端口名以 adb: 开头时,AT 管理器就打开一个由 adb 支撑的虚拟串口。上层——eSIM、短信、设备管理——完全不用改。

验证下来:能识别 IMEI,能看到 LTE 注册和 −65 dBm 的信号,eSIM 接口返回卡信息和已启用的配置文件。

几个 macOS 特有的坑

压缩过的二进制启动不了。 UPX 压缩过的 Mach-O 过不了代码签名检查,系统直接拒绝运行。macOS 版本不做压缩。

双击启动找不到配置文件:

初始化配置管理器失败: open config/config.yaml: no such file or directory

从 Finder 启动或者用绝对路径启动时,工作目录是主目录或者根目录,不是程序所在的目录。现在找不到配置时会改用二进制所在目录。

adb 传输的三个细节

不能用序列号选设备。 第一篇说过,序列号是 256 个 0xAAadb -s 用不了。只能用 transport id,但它每次重新枚举都会变(拔插、切换联网模式之后都会变)。所以持久身份用 USB 路径,每次使用时再解析出当前的 transport id。不这么做的话,一切换模式就满屏的”没有这个 transport id 的设备”。

读和写必须分开。 一个长期运行的读进程负责收,写用一次性调用。不能用交互式 shell——它会分配伪终端并改写字节。也不能开第二个读进程——两个读者会把回复各分走一半,看起来就像命令没有回应。

探测超时要放宽。 调用方原本给了约 1.2 秒,这是按串口速度估的。但 adb 每写一次都是一次进程启动,读进程附着也要时间。超时的后果是连锁的:探测超时 → IMEI 视为未验证 → 设备被丢弃 → 后台什么都没有。现在 adb 端口的超时下限是 6 秒。

(这里有个测试拦住了一个偷懒的修法——“超时就沿用发现阶段拿到的 IMEI”。拦得对:只有经过验证的 AT 口给出的 IMEI 才可信。)

每次重启服务,模组里就多一个幽灵

现象:服务重启几次之后,设备发现失败,报告”没有匹配的硬件”,怎么都起不来。

原因:杀掉本地的 adb 客户端,不会杀掉模组里那个读进程。adbd 会把那个 shell 保留下来。于是下一个读者只能拿到一半的回复,看起来就像模组不响应了。

解决:打开端口时先清理残留的读者。这里有两个反直觉的细节:

清理脚本的命令行里包含了它要查找的路径,所以它会匹配到自己——必须靠一个自己的标记跳过自己,否则第一件事就是把正在执行清理的那个 shell 杀掉。

更重要的是,只在本进程第一次打开这个节点时清理。一开始写成了每次打开都清理,结果重新扫描设备时的探测过程,会杀掉健康模组的读者——正是这个修复本来要防止的那种故障,被这个修复亲手制造了出来。

网页通话在 Mac 上失败

报错是”蜂窝音频设备不可用”。原因是蜂窝语音会话有两处写死了 Linux:在 /proc/asound 里找 USB 声卡,用 ALSA 的命令行工具搬运数据。

现在平台相关的部分分成了不同文件,macOS 这边去找模组的两个 CoreAudio 设备,用 sox 转成 8 kHz 单声道。

还有一个顺序问题:模组的 USB 音频功能在语音路由建好之前根本不存在。 所以主机侧的音频设备必须在路由起来之后再去解析,否则模组重启后的第一通电话必然失败。

C. 原生 App

不需要网关,App 直接通过 ADB 驱动模组:打电话、发短信、切换联网模式、多模组、选耳机、静音。

cd mac/VoHiveMac
xcodegen generate
xcodebuild -scheme VoHiveMac -allowProvisioningUpdates build

语音驱动放在应用支持目录下,App 每次通话前自动搭路由。

AT 层有两个设计决定:

读必须是连续的。 来电和新短信的通知是不请自来的,靠轮询会错过来电。一个长期的读进程喂给解析器,写用单独的短调用。

同一时刻只允许一条命令在途。 AT 协议没有请求 ID,回复和请求靠顺序对应。用一个 actor 来保证这一点。那些夹在别的命令回复中间的主动通知,交给通话状态逻辑处理。

短信用文本模式,超出 GSM 字符集的内容(所有中文)切到 UCS2 编码。解码这里有个坑:手机号本身也是合法的十六进制字符串。所以 UCS2 的检测条件必须是”长度是 4 的倍数并且含有字母”,否则 13800138000 会被显示成一串汉字。这条有测试。

连上了,却看不到运营商、号码和信号

原因:超时逻辑写反了一个对象。它失败掉的是当前在途的那个请求,而不是给它起定时器的那个请求。

具体是这样:第一个状态查询早就成功返回了,但它的 5 秒定时器之后才触发,杀掉了当时正在进行的另一个请求;那个请求的定时器又杀掉了下一个……六个快速查询,只有第一个活了下来。

解决:每个请求带上自己的身份,定时器只能失败自己。回归测试精确重放了这个序列。

接通了,但完全没有声音

证据:模组那一侧全对——声卡已加载、音频使能、两个内部通道都在运行。但 USB 方向的两个流是这样的:

pcm5p PREPARED    上行,没人喂数据
pcm6c PREPARED    下行,没人读数据

“已准备好”但没有数据流动。问题在主机侧。

原因:macOS 上一个 AVAudioEngine 的输入和输出必须是同一个设备。而这里给每个引擎配了两个不同的设备(Mac 麦克风进、模组出;模组进、Mac 扬声器出)。它不报错,只是静默地不工作。

解决:每个方向创建一个私有的聚合设备(Aggregate Device),把需要的输入和输出配成一对,引擎跑在聚合设备上。私有的意思是它不出现在系统声音设置里,随进程消亡。

另一个静默失败:设置当前设备的那个属性,输入输出都是全局作用域的元素 0。对输入写元素 1 不会报错,只是悄悄地留在默认设备上。

对方听不到我,而且没有任何报错

原因:没有麦克风权限时,采集照常运行,只是输出全是静音。表现是单通,而不是一个权限错误。

系统的隐私日志说明了一切——App 的标识是”无效代码的标识”。因为 App 没有签名,就没有稳定的身份,麦克风授权既无法有意义地申请,也无法保存。

解决:用开发者团队签名(不要用关闭代码签名的方式构建);启动时就申请权限,而不是等到通话接通;在设置页和通话页显示权限状态。开发签名大约一周过期,过期后重新构建即可。

用过 App 之后,Mac 自己的扬声器坏了

这是整篇里最离谱的一个。

现象:其他 App 的声音开始卡顿、失真,一直持续到重启电脑。

证据:系统音频服务记录了 671 条 IO 错误,点名是这个 App,带着”安全违规”和”过载”标记,设备是内置扬声器,而且同时存在 48000 Hz 和 8000 Hz 两种采样率的客户端。

原因:上行的聚合设备把模组的 8 kHz 输出设成了主设备,也就是时钟源,又把 Mac 的麦克风加了进来。而 Mac 的扬声器、麦克风、编解码器同属一个时钟域、一个硬件引擎。于是整台机器的内置音频引擎,被拉到了一颗外部的 8 kHz 晶振上。

而且 App 退出时没有销毁聚合设备,这个状态就一直保持着。

解决:两个聚合设备都用 Mac 自己的设备做时钟源,其余子设备开启漂移补偿——这才是调和两颗不同晶振的正确方式。退出时同步销毁聚合设备,启动时清扫崩溃遗留下来的。

已经中招的话,不用重启:

sudo killall coreaudiod

查证据用 log show --start <时间> --predicate 'process == "coreaudiod"'

调试器停止 App 之后,下一次运行看不到通话

原因:读进程是 App 的子进程,而 macOS 上子进程会比父进程活得久。调试器的”停止”是强制终止,清理代码没有机会运行,那个读进程被系统收养,继续读着。

下一次运行又开一个读者,两个读者瓜分回复:查询通话状态的回复落到了上一次运行留下的管道里,于是通话永远到不了”已接通”,音频桥也就永远不启动。

曾经找到过一个凌晨 4 点 24 分启动、5 点 46 分还在读的进程。

解决:在 actor 之外记录每个读者的进程号(终止回调里不能等待异步操作),退出时杀掉,启动时清扫。

同理,App 连着模组的时候,永远不要在终端里再跑一个 AT 脚本。 两边都在读同一个设备,会互相偷字节。

静音不能停流

静音时上行引擎要继续运行,只是喂静音数据。停掉引擎会让模组的上行流中断,而有些网络会把上行停滞当作掉话处理。

通话界面明确显示”已静音”也是必要的——一个被忘掉的静音,看起来和刚修好的单通 bug 一模一样。

其他

一个附带的实验:电话机器人

App 里还有一个实验性的页面:拨号,然后听、想、答。语音识别用 SenseVoice,合成用 Kokoro,大模型是局域网里 Ollama 上跑的 qwen3:30b-a3b(首个 token 0.2 到 0.4 秒)。通话内容不离开局域网。

有个意外的好处:因为模组把远端和本端分成了两个独立设备,机器人听到的声音里天然没有自己的声音。打断(说话时让它闭嘴)只是一个能量门限,完全不涉及回声消除。

这部分自己的坑够单独写一篇——音频引擎在 8 kHz 设备上的各种静默失败、语音活动检测吃掉第一个字、逐块重采样引入的不连续。这里不展开。

平台差异一览

Linux 上有的Mac 上的替代
串口adb 进模组,走内部设备节点
sysfs / udevadb devices -l 加 USB 路径
ALSACoreAudio 聚合设备
Wi-Fi 通话数据面没有,留在树莓派上
不需要签名必须签名,否则拿不到麦克风

下一篇回到树莓派网关,给它做一个 iPhone 客户端——让网关上插着的每一张 SIM 卡,都变成手机上的一条线路。

全系列

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

这个系列是个人学习和技术研究记录。模组的语音驱动是 GPL 衍生物,不随项目分发,需要自行准备。