一个键盘三台电脑:用 nRF52840 自制 Unifying 无线 KVM

背景

我有一把 ikbc c87 的有线键盘,服役好多年了一直没坏,没动力换把新的。
但是我经常要在不同的设备间切换:PC / 树莓派 / MBP。每次都将线插来插去的,很麻烦。

尝试弄了一个有线的 usb 切换器:

以为能彻底解决问题,结果发现桌面上多了很多线:

  • 每个设备 1 条线 x 3
  • 供电线 x1
  • 切换按钮线 x1

这也太不优雅了吧。

无线键盘协议

无线键盘主要有两种传输形式:蓝牙 和 2.4G 私有协议。

蓝牙的问题:容易被干扰、多设备切换慢(经常要等好几秒才连上)、需要目标电脑有蓝牙。

2.4G 私有协议各家有自己的方案。我的鼠标是罗技的,用的是优联(Unifying)接收器。优联接收器最多支持 6 个设备,插到电脑上就是即插即用,不需要装驱动。

那能不能模拟一个优联键盘,把有线键盘变成无线的呢?

优联协议

Logitech Unifying 是罗技在 2009 年推出的 2.4GHz 无线协议。一个接收器配对最多 6 个设备(键盘、鼠标、触控板),设备端用的是 Nordic 的 nRF24 系列芯片。

看到社区已经有成熟的研究和方案实现:
decrazyo/unifying: FOSS re-implementation of the Logitech Unifying protocol
在Arduino + nRF24L01 上跑通了完整的配对和加密键击

我基于它用 Rust 的 embedded 生态重写了协议层(rust-unifying),架构上做了硬件无关的抽象:

graph TD
    A[UnifyingDevice 协议状态机] --> B[trait UnifyingRadio]
    A --> C[trait Clock]
    A --> D[trait AesEncryptor]
    B --> E[nRF24L01 / SPI]
    B --> F[nRF52840 / 片上射频]
    C --> G[std::time / embassy-time]
    D --> H[aes + ctr crate]

协议逻辑(配对握手、AES 密钥派生、加密键击、保活、HID++ 应答)全部封在 UnifyingDevice 里,只通过三个 trait 触及硬件。这为后面的迁移省了大量工作——换硬件只需要重新实现一个 trait。

这套方案我在 树莓派 GPIO + nRF24L01上面跑通了,实现了配对和键盘输入,完整模拟了键盘的行为。
但 nRF24L01 跟 树莓派 SPI 通讯用的是杜邦线接起来的,不太稳定且不优雅。

迁移到 nRF52840 片上射频

nRF52840 的片上 2.4GHz RADIO 可以配置成与 nRF24L01+ 完全兼容的 ESB 模式

要是能实现一块 U 盘大小的 nRF52840 直接插在 树莓派 的 USB 口,整洁多了。

手写 ESB 驱动

现有的 esb crate(2020 年)跟 embassy 的 PAC 版本冲突,没法直接用。
所以我对着 nRF52840 的 PAC 手写了一个最小的 ESB PTX(主发射端)驱动。主要配置:

  • 2Mbit Nordic 私有模式
  • 6-bit 长度字段 + 3-bit S1(PID + no_ack)
  • CRC-16(多项式 0x11021,初值 0xFFFF)
  • 大端、关闭白化

最难的点是确定地址编码。nRF52 的 BASE/PREFIX 拆分方式、字节序、位序都跟 nRF24L01+ 不一样,有好几种”看似合理”的组合。

靠猜 + 重刷来试错太慢(每轮编译+OTA 要 3 分钟)。我换了个思路:
编译一份包含四种候选编码的固件,通过诊断命令让其在配对信道上轮流发送,并统计各自收到的 ACK 数量。
最终,编码 1 收到了 ACK,其余全为 0

保活

优联连接靠心跳包维持跳频同步。真实键盘每 ~20ms 发一个保活包,几秒不发接收器就判定设备掉线,之后即使重连成功也要重新建立信道同步。

刚开始 AI 的思路是”断了就重连”——发送失败后调 connect() 重新扫频。结果越改越差:多轮重试互相打断接收器正在重建的同步,成功率反而更低。方向错了,补丁打得越多越烂。

正确方向是从源头就不让它断:用 embassy 的 select(read_packet, Timer::after(8ms)),在等命令的间隙持续发保活包,跟真实键盘一样一直维持连接。

端到端验证

将 nRF52840 和优联接收器都插入树莓派,让 opencode 在树莓派上面自己一直调试,跑了一个晚上跑通了:
原生射频 → 配对 → AES 密钥派生 → 加密键击 → 接收器解密 → HID 注入。

上位机:KVM 转发器

固件通过 USB-CDC 串口暴露命令接口。上位机是一个 Python 脚本,用 evdev 抓取物理键盘,把每个按键状态变化转成 UKEYDOWN 命令转发给 nRF52。

架构

graph LR
    KB[物理键盘] -->|evdev grab| KVM[kvm.py]
    KVM -->|CDC 串口| NRF[nRF52840]
    NRF -->|ESB 2.4GHz| RX1[接收器 PC]
    NRF -->|ESB 2.4GHz| RX2[接收器 Mac]
    KVM -->|uinput| LOCAL[Pi 本机]
    SL[Scroll Lock] -.->|切换| KVM

多设备切换

Scroll Lock 这个按键和对应的指示灯应该是键盘上最摸鱼的部分了,把它们利用起来,作为目标设备切换和状态指示:
Scroll Lock 循环切换目标(PC → 本机 → Mac → PC …)。切换时 Scroll Lock 灯闪烁指示当前目标(1闪=PC,2闪=本机,3闪=Mac)。

固件支持 4 个独立的配对 slot,可以在 4 个设备中循环切换

持久化与防重放

优联用 AES-128-CTR 加密键击,每帧的 counter 单调递增。接收器记住见过的最大 counter,拒绝任何不大于它的帧(防重放)。

真实罗技键盘用电池供电,一直有电的设备,counter 放在 RAM 里问题不大;但我们的方案是 USB 供电,断电重启很常见,所以必须考虑 counter 持久化。

三重保护:

  1. 启动前跳 +512:每次从 flash 加载 counter 后立即加 512。即使断电前有未存的增量,跳 512 也能解决一大部分问题
  2. 每 128 帧存一次:正常打字约每 10-30 秒触发一次写入,flash 磨损可以接受
  3. POFCON 掉电紧急存:VDD 跌破 2.7V 时触发中断,具体没测电容能顶多久,应该几百微秒的窗口是有的,够写 32 字节记录。

为减少 flash 磨损,存储用追加式日志:一页 4K 分成 128 个槽位,每次追加一条,写满才擦页。

最终效果

graph TD
    KB[物理键盘 ikbc c87] -->|USB| PI[树莓派 + nRF52840 dongle]
    PI -->|2.4GHz ESB| PC[PC]
    PI -->|2.4GHz ESB| MAC[MacBook]
    PI -->|uinput| LOCAL[Pi 本机]
    SL[Scroll Lock] -.->|循环切换| PI

现在桌面终于清爽多了。

键盘只插在树莓派上,PC 和 Mac 各插一个优联接收器。平时想切到哪台机器,就按一下 Scroll Lock:闪一下是 PC,闪两下是树莓派本机,闪三下是 Mac。切完之后直接打字,目标机器那边就像接了一把普通罗技键盘一样。

用了几天,感觉已经可以当日常工具用了。切换目标不到 300ms;打字延迟大概 3–8ms,反正我自己感觉不出来。上位机用 epoll 等键盘事件,不做轮询,所以 CPU 占用基本可以忽略。

整套系统都是复用手边的材料:长期吃灰的树莓派、nRF52840 dongle,再加两个现成的优联接收器。物料成本忽略不计,当然,真要算时间成本 / AI Agent 成本的话就另说了(

复现

硬件清单:

  • 任意Linux 机器,带 usb 就行
  • nRF52840 开发板/dongle(支持 USB CDC)
  • Logitech Unifying 接收器,每台远程电脑一个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 克隆项目
git clone --recursive https://github.com/ZHLHZHU/nrf52-unifying.git
git clone https://github.com/ZHLHZHU/unifying-kvm.git

# 编译固件
cd nrf52-unifying
cargo build -p nrf-demo-app --release

# 首次 SWD 烧录后,后续走 OTA
python3 tools/cdc_dfu.py --port /dev/ttyACM0 --image app.bin

# 配对接收器(接收器侧先进入配对模式)
python3 tools/unifying.py raw "UPAIR 00" # PC
python3 tools/unifying.py raw "UPAIR 01" # Mac

# 启动 KVM
sudo python3 unifying-kvm/kvm.py --keyboard VID:PID --targets "pc:0,local:_,mac:1"

写在最后

整个项目的代码量不大(固件 ~800 行 Rust,KVM ~400 行 Python),但涉及的层次很多:USB CDC、embedded Rust、射频协议、AES 加密、flash 持久化、Linux evdev/uinput、systemd。最有意思的部分是射频层——把一个”不确定正确编码”的问题塞进一份固件让硬件投票,以及从”事后自愈”走错路到”源头保活”的认知转折。

三个仓库都在 GitHub 上,欢迎 star / issue:


本项目仅供个人学习和自用设备互操作。Logitech、Unifying 是罗技的注册商标。协议细节参考自公开的安全研究文献。