# 同一只 dongle 插在 Mac 上：没有驱动的平台怎么办（五）

- URL: https://wuming.si/blog/4006/
- Author: Aaron
- Published: 2026-09-09
- Language: zh-Hans
- Categories: Technology
- Originally published at: https://blog.wo.ai/archives/4006

> 上一篇：[USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005) ｜ 下一篇：[iPhone 客户端](https://wuming.si/blog/4007)

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

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

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

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

```sh
brew install android-platform-tools
adb devices -l
```

Mac 上有三种用法，由浅入深：

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

## A. 只上网

```text
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 版本

```sh
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 版本不做压缩。

**双击启动找不到配置文件：**

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

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

### adb 传输的三个细节

**不能用序列号选设备。** 第一篇说过，序列号是 256 个 `0xAA`，`adb -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 驱动模组：打电话、发短信、切换联网模式、多模组、选耳机、静音。

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

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

AT 层有两个设计决定：

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

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

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

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

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

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

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

### 接通了，但完全没有声音

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

```text
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 自己的设备做时钟源**，其余子设备开启漂移补偿——这才是调和两颗不同晶振的正确方式。退出时同步销毁聚合设备，启动时清扫崩溃遗留下来的。

**已经中招的话**，不用重启：

```sh
sudo killall coreaudiod
```

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

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

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

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

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

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

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

### 静音不能停流

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

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

### 其他

- **多模组**：每只模组有独立的 AT 口、独立的语音路由（驱动加载在各自模组里）、独立的短信历史。任何一只来电都会接管窗口。模组按 USB 路径记住名字，只要插在同一个口。
- **耳机选择**：按 UID 记忆，因为 CoreAudio 的设备 ID 每次开机或拔插都会变。模组自己的两个音频接口不出现在列表里——选中它们会把通话路由回通话本身。
- **双向电平表**：只有下行电平表时，"麦克风没进通话"和"模组没把声音传出去"看起来完全一样。多一个表，少一半猜测。

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

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

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

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

## 平台差异一览

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

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

## 全系列

1. [它到底是什么](https://wuming.si/blog/4002)
2. [树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003)
3. [从零实现 VoWiFi](https://wuming.si/blog/4004)
4. [USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)
5. [插在 Mac 上](https://wuming.si/blog/4006)
6. [iPhone 客户端](https://wuming.si/blog/4007)
7. [安卓平板直接驱动](https://wuming.si/blog/4008)

---

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