双系统下,ZMK 蓝牙键盘为什么换槽位也连不上
我最初的想法很自然:这把 ZMK 键盘有多路蓝牙设备槽位,Ubuntu 占一路;切到同一台电脑上的 Windows,再换一路给 Windows。两个系统各用各的,应该互不相干。
现实恰好相反。
Ubuntu 能用,其他 Mac 也能用;Windows 能看见键盘,别的蓝牙设备也都能正常添加和删除,唯独这把 Eyelash Sofle 在 Windows 里反复配对失败。重启、换槽位、清 Windows 缓存、换驱动,都没有改变结果。
最终的原因不是键盘固件,也不是某个“坏掉的槽位”,而是一个容易被图形界面掩盖的事实:双系统共享的是同一块物理蓝牙控制器,因此也共享同一个控制器地址;但 Windows 和 Ubuntu 各自生成、各自保存不同的绑定密钥。 对键盘而言,这不是两个干净的新主机,而是“同一个主机身份拿着两套互不相认的钥匙”。
本文记录完整的证据链,并给出一套可复用的 Windows 导出、Ubuntu 导入工具。它不修改键盘固件,也不要求每次切换系统都重新配对。
结论先行:清除键盘上对应 profile 的旧绑定,先在 Windows 完成一次可用的配对,再把 Windows 生成的 BLE bond 同步到 Ubuntu。此后两个系统共用同一套绑定。
1. 为什么“给 Windows 换一路”仍然会失败
ZMK 的 profile 很像“已保存主机”的槽位,却并不是给操作系统划分的虚拟蓝牙适配器。真正参与 BLE 绑定的,仍是主机蓝牙控制器的身份与配对过程中生成的密钥。
同一台电脑双启动时:
同一块蓝牙控制器
地址:AA:BB:CC:DD:EE:FF
│
┌───────────┴───────────┐
│ │
Windows Ubuntu
自己的绑定数据库 BlueZ 的绑定数据库
生成密钥 W 生成密钥 L
│ │
└───────────┬───────────┘
│
ZMK 看到同一主机地址
却收到两套不同密钥
换 profile 只能改变键盘把哪个 bond 设为当前输出目标;它不能把同一块物理控制器变成两个不同的 BLE 主机。ZMK 官方的连接故障文档对此写得很直接:双系统使用同一硬件地址时,不能像两个独立设备那样分别配对;可行的 workaround 是让两个系统共享配对密钥。ZMK:Issues With Dual Boot Setups
这也解释了为什么其他 Mac 没有问题。那些机器有不同的蓝牙控制器地址,在键盘看来确实是不同主机;同一台电脑上的 Windows 与 Ubuntu 则不是。
2. 现场症状与第一轮误判
故障机器是 Windows 11,蓝牙适配器为 MediaTek MT7925。键盘使用 ZMK,BLE 地址为静态随机地址。
表面现象很像 Windows 驱动故障:
- Windows 扫描得到键盘名称和较强的 RSSI;
- 其他蓝牙设备可以正常添加、移除;
- 键盘在 Ubuntu 和其他 Mac 上工作正常;
- Windows 发起配对后,很快返回失败;
- 配对失败后没有创建 HID 键盘节点。
因此我们先排除了常见的 Windows 侧因素:
- 检查蓝牙服务、企业策略与设备关联状态;
- 清理这一个设备的残留缓存,而不是重置全部蓝牙设备;
- 分别测试较新的 MediaTek 驱动与 Lenovo 官方驱动;
- 短暂测试 Microsoft 通用驱动,再恢复可正常启动的厂商驱动;
- 使用 Windows 标准配对、Custom Pairing 和 PairTool 复现。
结果始终一致。驱动版本会改变控制器实现,却不会改变键盘里已经保存的 bond;这条路自然无法解决身份与密钥冲突。
3. 抓包告诉我们的事
Windows 并非完全连不上键盘。链路层连接可以建立,失败发生在 SMP(Security Manager Protocol)配对阶段:Windows 发出配对请求,键盘返回配对失败,随后由 Windows 主动断开。
这点很重要。它把问题从“无线信号、天线、驱动是否能扫描”收敛为“双方对安全状态的理解不一致”。ZMK 使用 BLE Secure Connections,初次绑定会通过 ECDH 建立长期密钥 LTK;后续连接依靠已保存的 bond 加密通信。ZMK:Bluetooth Security
当键盘仍保存 Ubuntu 那次绑定,而 Windows 试图以同一个控制器地址建立一套新绑定时,失败就不再意外。
4. 为什么 Ubuntu → Windows 的密钥复制没有走通
既然 Ubuntu 已经能用,一个合理的尝试是:读取 Ubuntu 的 BlueZ bond,把 LTK、EDIV、ERand 等字段写进 Windows。
我们确实完整试过:
- 从 Ubuntu 的 ext4 分区只读提取
/var/lib/bluetooth/;/ /info - 校验两份 LTK 记录一致,确认是 16 字节 P-256 Secure Connections 密钥;
- 以
SYSTEM身份把 LTK、KeyLength、ERand、EDIV、地址类型和 AuthReq 写入 Windows 的 BTHPORT key store; - 重载蓝牙适配器,再尝试直接访问 HID GATT 服务和执行 Windows 设备关联。
注册表写入本身成功,但 Windows 仍报告:
IsPaired = False HID GATT = Unreachable PairAsync = Failed PnP/HID node = not created
这说明 Windows 的“拥有 LTK”不等于“完成设备配对”。在 Windows 的受支持路径中,设备关联由配对流程创建;Microsoft 的 DeviceInformationPairing.PairAsync 文档也将它定义为实际发起配对的 API,而不是导入一条现成关联记录。Microsoft:DeviceInformationPairing.PairAsync
Linux 的 BlueZ 可以在停止服务后加载修改过的存储文件;Windows 还需要设备关联服务与 PnP 枚举形成的一整套状态。手工伪造 Enum\BTHLE、BTHLEDevice 与 HID 节点既没有官方接口,也没有可靠的完整数据来源。继续向这个方向写注册表,风险远大于收益。
于是同步方向必须反过来:让要求更严格的 Windows 先正常配对,再让更容易迁移 bond 的 BlueZ 接受 Windows 密钥。
5. 可行方案:Windows 作为 bond 的基准
正确的数据流是:
清除键盘当前旧 bond
│
▼
Windows 正常配对 ──► 键盘保存 Windows bond
│
▼
Windows BTHPORT key store
│ 导出 LTK / IRK / CSRK / EDIV / ERand
▼
受限权限的 .secret 文件
│
▼
Ubuntu BlueZ info 文件
│ 重启 bluetooth.service
▼
键盘自动重连,Windows 与 Ubuntu 共用同一 bond
具体步骤如下。
第一步:保留退路
先备份 Ubuntu 的原始 BlueZ 记录。本文附带的导入脚本会自动完成这件事;如果要手动备份,目标通常是:
/var/lib/bluetooth/<适配器 MAC>/<键盘 MAC>/info
BlueZ 的存储格式包含 General、LongTermKey、PeripheralLongTermKey、IdentityResolvingKey 等分组。其格式与字段含义在 BlueZ 的 settings storage 文档和源码中都有定义。BlueZ settings storage
第二步:只清除键盘当前 profile 的旧绑定
在键盘当前 profile 上调用已有 keymap 中的 &bt BT_CLR。这是清除运行时保存的 bond,不是改固件,也不需要刷写 settings reset 固件。
不要使用 BT_CLR_ALL,除非你确实想清空所有主机。
第三步:先在 Windows 配对,并验证输入
Windows 显示“已连接”还不够。至少确认:
- 设备管理器出现
BTHLE\Dev_; - BLE HID 服务使用
hidbthle.inf; - 出现
HID Keyboard Device; - 实际按键可以输入。
只有走到这一步,Windows 才已经生成完整、可用的 bond 与设备关联。
第四步:从 Windows 安全导出 bond
在普通 PowerShell 中执行:
powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File .\scripts\export-zmk-bond-windows.ps1 ` -DeviceName "Eyelash Sofle"
也可以直接指定地址,避免同名设备歧义:
powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File .\scripts\export-zmk-bond-windows.ps1 ` -DeviceAddress "C2:11:22:33:44:55"
脚本会弹出一次 UAC,然后:
- 从已配对的
BTHLE设备解析地址; - 在受保护的 BTHPORT key store 中自动找到实际使用的适配器;
- 以一次性
SYSTEM任务读取 LTK、IRK、CSRK、EDIV、ERand 与 AuthReq; - 立即删除临时任务;
- 默认写入
C:\ZmkBondSync; - 对
.secret文件关闭 ACL 继承,只允许当前用户与SYSTEM读取。
密钥不会出现在终端输出或日志中。输出的 .meta.txt 只含地址、文件名与 SHA-256,可用于核验传输完整性。
第五步:在 Ubuntu 导入
挂载 Windows 分区后,运行:
sudo python3 ./scripts/import-zmk-bond-ubuntu.py \ /path/to/ZmkBondSync/zmk-bond-xxxxxxxxxxxx.secret
导入器会:
- 校验设备、适配器、密钥长度和 Secure Connections 属性;
- 自动按地址或唯一设备名查找旧 BlueZ 记录;
- 停止
bluetooth.service; - 把原记录备份到
/var/lib/bluetooth/.zmk-bond-sync-backups/<时间戳>; - 原子写入新记录并保留原有名称、Trusted、DeviceID、ConnectionParameters 等元数据;
- 重启蓝牙服务。
服务重启后,键盘应该自行重连,不需要额外执行 bluetoothctl connect。
首次导入前,Ubuntu 可能显示键盘“已连接”却无法输入。这只是链路或旧缓存仍在,HID 加密会话并未使用正确的新 LTK。脚本替换 bond 并重启 BlueZ 后,输入即恢复。正常情况下这一步只需执行一次;以后在 Windows 与 Ubuntu 之间切换不需要重复运行。
6. 为什么导入器同时写三份 LTK 分组
BlueZ 按本地/远端角色保存 LTK。当前源码把一类密钥写入 LongTermKey,另一类写入 PeripheralLongTermKey,并为了版本兼容继续复制一份到旧名称 SlaveLongTermKey。BlueZ adapter.c 的 LTK 存储逻辑
在双系统迁移中,旧文件可能来自不同 BlueZ 版本,也可能残留不同角色下的旧值。导入器因此把同一个 Windows LTK 写入:
[LongTermKey] [PeripheralLongTermKey] [SlaveLongTermKey]
这不是生成三把钥匙,而是避免 BlueZ 在不同连接方向或版本兼容路径中读到旧钥匙。
Authenticated 也不是简单的布尔值。BlueZ MGMT 接口把 0x02 定义为未经过 MITM 认证的 P-256 key,把 0x03 定义为经过认证的 P-256 key。导出器根据 Windows AuthReq 的 Secure Connections 与 MITM 位计算该字段。BlueZ MGMT:Long Term Key 类型
另一个容易踩坑的点是字节顺序:在这条 BLE 路径中,Windows 注册表里的 16 字节 LTK 应按原始字节顺序写入 BlueZ;不要把整个 key 反转。BlueZ 源码也是按 key 数组的顺序逐字节格式化为十六进制字符串。BlueZ device.c
7. 地址变化与 Adapter IRK:默认保守,必要时显式处理
本例的 ZMK 键盘使用稳定的静态随机地址,因此 Windows 和 Ubuntu 的设备目录相同。其他 BLE 设备可能在重新配对后改变身份地址;导入器不会悄悄迁移目录,而是要求显式确认:
sudo python3 import-zmk-bond-ubuntu.py bond.secret \ --existing-device-address AA:BB:CC:DD:EE:FF \ --allow-address-change
Windows 还可能保存适配器的 CentralIRK。BlueZ 的 identity 文件也支持 IdentityResolvingKey,但这是适配器级身份,修改它会影响所有 BLE bond,而不只是这一把键盘。因此脚本默认不动它。只有在设备使用定向广告或 RPA、普通 LTK/IRK 同步仍失败,并且已经备份其他 BLE 设备时,才考虑:
sudo python3 import-zmk-bond-ubuntu.py bond.secret \ --sync-adapter-irk --confirm-adapter-wide-change
对普通 ZMK 静态地址键盘,不要预先开启这个选项。
8. 安全、回滚与适用边界
.secret 是凭据,不是普通配置
LTK 与 IRK 足以代表一段 BLE 信任关系。应当把 .secret 当作密码文件处理。
本项目的 .gitignore 默认忽略 *.secret。
回滚
导入器会打印备份目录。如果导入后需要恢复:
sudo systemctl stop bluetooth.service sudo cp -a /var/lib/bluetooth/.zmk-bond-sync-backups/<时间戳>/<适配器>/<设备>/info \ /var/lib/bluetooth/<适配器>/<设备>/info sudo systemctl start bluetooth.service
适用范围
这套工具有意保持窄而可靠:
- 面向 ZMK 等现代 BLE Secure Connections HID 设备;
- 不处理 Classic Bluetooth 的 LinkKey;
- 要求 Windows 已经配对成功且实际可以输入;
- 两个系统必须使用同一块物理蓝牙适配器;
- 默认要求 Ubuntu 已存在该设备记录;如确实没有,可审阅后使用
--allow-create --address-type static; - 多适配器或同名设备环境应显式传入地址。
9. 这次排查真正教会我的事
最初“Ubuntu 一路、Windows 一路”的想法并不愚蠢。它把 ZMK profile 理解成了主机隔离层,而产品界面也很容易让人形成这种直觉。问题在于 BLE bond 的边界更低:它落在控制器身份、安全密钥和操作系统持久化状态之间,不以“当前启动的是哪个 OS”为界。
这也是 Windows 显得格外难处理的原因。BlueZ 把核心状态清晰地保存在可审计的文件里;Windows 则把密钥、设备关联、PnP 枚举与 HID 驱动状态分开管理。前者允许在停服务后迁移 bond,后者要求一次完整的系统配对流程。
因此,最稳妥的策略不是继续和两套状态机角力,而是承认它们的非对称性:
让 Windows 负责生成它必须亲自生成的关联;让 Ubuntu 接受这套已经成立的 bond。
键盘不需要改固件,系统也不需要每次重配。只需让两个操作系统终于拿着同一把钥匙。
