这是一款基于 WCH CH585(RISC-V,RV32IMC + Zba/Zbb/Zbc/Zbs/Xw)的无线游戏鼠标与 2.4G 接收器(dongle)的裸机固件。它没有用到任何操作系统:自定义链接脚本、厂商启动汇编、链接厂商静态库,连主机 CRT/libc 都不带。
有意思的地方不在某一个驱动,而在整机的约束如何被拆解干净:一块射频要撑起三种连接、一个工程要产出两个镜像、一堆外设要能被统一配置,还要在电池上把功耗压到个位数微安。本文从架构、传输路由、2.4G 空中协议、HID 配置通道到电源待机,完整走一遍。
IMU 姿态解算(Mahony / ZARU / 1 Euro 滤波)已另文详解,见 摆脱桌面的束缚:基于 IMU 的空中飞鼠算法。本篇聚焦系统与链路。
一、一个工程,两个固件
一个 CMake 工程构建两个镜像:
| 目标 | 固件 | 源码 |
|---|---|---|
wch-mouse | 鼠标本体 | src/ |
wch-dongle | 2.4G 接收器(RF 主机 → USB HID) | dongle/ |
两者共享 cmake/wch_sdk.cmake 里的厂商 SDK、HAL、USB 设备层与 RF 链路配置——一处驱动或描述符改动会同时到达两个固件。这种”共享底座 + 两个薄应用”的布局,避免了把 dongle 写成鼠标固件的复制品。
工具链是强制的:没有工具链文件时配置阶段会直接 FATAL_ERROR,而不是悄悄退化成本机编译:
if(NOT CMAKE_TOOLCHAIN_FILE)
message(FATAL_ERROR
"No toolchain file set. Configure with "
"-DCMAKE_TOOLCHAIN_FILE=cmake/riscv-wch-toolchain.cmake, or use the 'wch' preset.")
endif()
功能边界用构建选项表达,而不是散在源码里的 #if:
WCH_BLE_ENABLE— 链接 BLE(外设)传输。WCH_RF_ENABLE— 引入 2.4G RF 传输。两者共用同一块射频,同时开启会构建出”开机二选一”的双射频镜像。WCH_DCDC_ENABLE— 启用 CH585 内部 DC-DC(把运行电流压到默认直通 LDO 的约 60%),但要求板上有 VSW→VDCID 的 10 µH 电感;把两者短接的板子必须保持关闭。
二、传输路由:三链路,一块射频
鼠标支持三种连接:USB、自研 2.4G RF(需 dongle)、蓝牙 LE HID。路由逻辑集中在 src/transport/transport.c,本质是一个后端数组按顺序取第一个”链路可用”者:
/* Priority order: s_backends[0] wins. USB is wired and always present, so it
* outranks the radio backends added later. RF outranks BLE (lower latency) but
* RF and BLE share the radio, so only one of them may be enabled. */
static const transport_t *const s_backends[] = {
&transport_usb,
#ifdef WCH_RF_ENABLE
&transport_rf,
#endif
#ifdef WCH_BLE_ENABLE
&transport_ble,
#endif
};
优先级 USB > RF > BLE:USB 有线永远在;RF 延迟低于 BLE;而 RF 与 BLE 共用射频,只能启用其一。router_reselect() 在每次轮询时重选,链路变化时打日志;数据则通过 transport_router_publish() 交给当前后端:
bool transport_router_publish(const MouseReport_t *rpt) {
if (s_active && s_active->send) {
return s_active->send(rpt);
}
return false;
}
关键设计:transport_ble.c 与 transport_rf.c 始终参与编译,在对应宏缺失时退化成空实现——因此至多只有一个射频后端是”活”的,其余不带任何符号或副作用。这让单射频构建天然安全。
三、开机二选一:模式选择
既然只有一块射频,RF 与 BLE 就不能同时在线。厂商协议栈没有经过验证的运行时切换方案(RFRole_SwitchMode 没有示例,且要求射频空闲),所以模式在开机时应用。
切换方式是侧键1 + 滚轮点击,长按 3 秒后松开:
/* The switch is deferred to the release, so the reset never races the buttons
* still being down. */
#define MODE_BTN_HOLD_MS 3000u
松开时先存模式、再电机脉冲播报、最后复位进入;复位特意安排在脉冲结束后,避免复位与机械振动打架。模式记录存在 DataFlash 第一个 4 KiB 块里:
static const nvm_slot_t s_slot = {0x0000u, 0x5Au, 1u};
而厂商把 BLE 的 SNV 放在最后一块,两者不冲突。这里有一个容易被忽视的正确性点:只有双射频镜像才有”选择”;单射频构建在编译期固定模式并忽略存储字节,所以一条陈旧的记录永远无法关掉该构建唯一的后端。
四、2.4G 私有链路:一份共享的空中契约
src/transport/rf_cfg.h 被鼠标与 dongle 两个固件共享——鼠标是设备/TX,dongle 是主机/RX,两端参数必须完全一致:
#define RF_ACCESS_ADDRESS 0x71762345u
#define RF_CRC_INIT 0x555555u
#define RF_CHANNEL 16u
#define RF_PKT_TYPE 0x55u
上行帧在 RFIP 头之后紧跟鼠标报告,并把应答搭载在同一个帧里:
typedef struct __attribute__((packed)) {
uint8_t type; /* RF_PKT_TYPE */
uint8_t length; /* total frame length, incl. this 4-byte header */
uint8_t resp_id; /* response generation; 0 when idle */
uint8_t chunk; /* 0-based index of this piece into the response */
MouseReport_t report;
uint8_t reply[RF_REPLY_CHUNK]; /* 8 */
} RfPacket_t;
鼠标唯一的上行就是这个帧,所以主机命令的应答不再占用第二个方向:resp_id / chunk 索引分片,reply 携带内容,resp_id == 0 表示空闲。更微妙的是它的驱动方式——一旦有了应答,“即使没有运动也要发出去”,因此发送由新应答驱动,而不是由报告驱动。
反向:dongle 对每个收到的包回一个 ACK(RFIP 并未提供可装载 ACK 负载的硬件通道):
typedef struct __attribute__((packed)) {
uint8_t type; /* RF_ACK_TYPE = 0x7E, deliberately not RF_PKT_LEN */
uint8_t length;
uint8_t cmd_id; /* 0 when idle; changes per captured command */
uint8_t chunk; /* 0-based index of this piece */
uint8_t cmd_len; /* whole command length; 0 when idle */
uint16_t rx_count; /* packets received so far; the mouse differences it */
uint8_t payload[RF_CMD_CHUNK]; /* 16 */
} RfAck_t;
- 丢包感知:
rx_count由鼠标做差分,就能感知丢包,而无需硬件 ACK。 - 命令通道:主机发送的仍是同一条 Feature 报告;dongle 自己跑不了该协议,于是把帧抓下来,按 16 字节分片随 ACK 轮转发出。鼠标在每个 chunk 0 重启组装——丢一个分片只是再传一遍;
cmd_id则防止重复执行(一次 BIO 采集会让鼠标阻塞 50 ms)。
这里还有一个用光标漂移换来的教训,直接写进了头文件:
/* Both ends must point the RFIP's RX DMA at a buffer this size. It is NOT the
* frame length: the vendor example documents a 264-byte minimum whatever
* rxMaxLen is, and a buffer sized for the frame lets the DMA run past it. That
* stays invisible until the tail lands on something live - sizing the reply
* buffer for its 6 bytes put ACK bytes into the TX buffer, which showed up as
* the cursor drifting steadily in one direction. */
#define RF_RX_BUF_LEN 264u
一句话:RX DMA 缓冲不是帧长,按帧长开会被越界写,直到它的尾巴落到活数据上才暴露——表现为光标朝一个方向匀速漂移。
五、配置通道:HID Feature 报告与两步事务
鼠标暴露一个厂商自定义的 HID Feature 报告:usage page 0xFF00、Report ID 0x02、63 字节数据。由于 HID 的 GET_FEATURE 不向主机返回负载,每次交互都是”先 SET_FEATURE 写请求,再 GET_FEATURE 读设备准备好的应答”两步。
帧格式紧凑且自成一体:
[0] cmd (响应时置 bit 7)
[1] seq (设备回显)
[2] len (载荷字节数)
[3..] payload
[3+len] sum8 (字节 0..2+len 的累加和)
rest 0
src/app/proto.c 就是一张命令表:DPI、传感器模式、抬升高度、air-mouse 灵敏度/ODR、RGB、OLED、回报率、电池、外设电池策略、马达……响应把命令字节 | 0x80 回写,载荷首字节是状态码(OK / unknown / bad length / bad checksum / unsupported)。
几处体现”不信任主机、也不信任硬件”的细节:
- 马达请求封顶 30 秒(
PROTO_MOTOR_MAX_MS)——一个卡住的 ERM 会持续 85 mA 且发热。 - 电量用 3.00–4.20 V 线性估算,注释直言传感器引脚只是粗测。
- 回报率命令的第一个载荷字节选链路(
0USB /1RF /2BLE)。 - 模块本身与传输无关:
proto.c注释写着同一张表将来可以直接经 BLE 驱动。
六、上位机:CLI + Tauri,协议只写一遍
项目提供两个上位机:scripts/mouse_proto.py(命令行)与 host/(Tauri v2 + React/TypeScript 桌面端)。二者使用同一条 HID Feature 通道,无论有线(PID 0xFE0C)还是经 dongle 无线(0xFE0D)。
桌面端的协议层 host/src-tauri/src/protocol.rs 刻意做成零依赖(不用 serde、不用 hidapi、连 thiserror 都不用),以便脱离 webkit/Tauri 工具链单独 cargo test。它同时是 mouse_proto.py 与 src/app/proto.c 的忠实移植,甚至带有已知帧向量的黄金测试:
#[test]
fn known_set_dpi_frame_vector() {
// Command 0x11 SET_DPI, seq 1, payload [0x40, 0x06] => CPI 1600.
let frame = build_frame(0x11, 0x01, &[0x40, 0x06]).expect("frame builds");
// 0x11 + 0x01 + 0x02 + 0x40 + 0x06 = 0x5A
assert_eq!(frame[5], 0x5A);
}
超时按链路自动选择——这正是 RF 链路”多一跳”的真实代价:
pub const REPLY_WAIT_MS: u64 = 30; // 有线 USB
/// A command has to cross the RF link, run, and have its answer streamed back;
/// the panel bring-up alone NACKs for ~120 ms, so the window has to be generous.
pub const REPLY_WAIT_RF_MS: u64 = 400; // 经 dongle
此外还有 BIO 实时监视:鼠标是复合设备,face 0/1 是 USB CDC 串口(/dev/ttyACM*),承载健康模块 88 字节、以 0xFF 开头的 JFC103 包(38400 8N1)。dongle 没有 CDC 接口,所以 BIO 只在线时有线时可用;上位机用 canvas 画最近约 10 秒的心率波形与各项指标。
七、电源与待机:低到 2.6 µA 的取舍
鼠标一生大部分时间都闲置。src/app/power.c 在电池供电、非 air-mouse、且运行 RF 时,空闲 30 秒后停外设并进入低功耗睡眠:
#define POWER_IDLE_MS 30000u
唤醒源只有两个,且各自有明确理由:
- PA7(PAW3395 的 MOTION):传感器自己按 Run→Rest1→Rest2→Rest3 逐级降耗,仍能驱动 MOTION。代价是响应时间(从 Rest2 约 200 ms),换来的是它掉到 11 µA。
- BAT(充电器/插线):也是”逃生舱”——即使唤醒配置写错,插上线缆也一定能把它救回来。
进入睡眠前,standby_park() 在时钟、I2C、UART 都还活着的时候把该关的强制关掉:马达、LED 供电轨、面板、BIO 测量——因为一次 2.6 µA 的睡眠不能带着一块亮着的面板。然后才关掉 ADC 参考与时钟。
睡眠函数用 __HIGH_CODE 标注,从 RAM 执行:LowPower_Sleep() 会关掉 flash 并切换系统时钟,进入之后路径上的代码不能再来自 flash。两个真实且致命的坑,都写在注释里:
/* SYS_DisableAllIrq() clears EVERY PFIC enable, the motion pin's and the
* buttons' included, and a PMU wake source whose interrupt is disabled never
* fires. Without this the mouse sleeps and never wakes on input - put those
* interrupts straight back. */
PFIC_EnableIRQ(GPIO_A_IRQn);
PFIC_EnableIRQ(GPIO_B_IRQn);
/* The flash is powered again but needs a few hundred microseconds before it can
* be read ... This function runs from RAM, so spin here rather than let the next
* flash fetch come back as garbage. */
for (spin = 0u; spin < 60000u; spin++) { __nop(); }
另外:不用 RTC 唤醒——传感器、按键和线缆才是该唤醒它的东西,为了它们之外的东西留着 32K 只是白耗电。BLE 没有待机(连接事件每 7.5–10 ms 保活,协议栈无法从这里重建;而且面板/LED/BIO 停掉后也没什么可停的);air-mouse 模式会停掉 PA7 中断,也就跳过了待机。
唤醒后的 standby_restore() 顺序很讲究:先恢复外设时钟 → 重建射频 → settings_apply() → 按线缆/策略恢复外设 → 重装电池采样 → 解除运动中断停放并排空——每一步的先后都能在注释里找到原因。
结语
从双固件工程、三链路路由、一份共享的 2.4G 空中契约,到 HID 配置通道与两套上位机,再到 2.6 µA 的待机——这台鼠标的固件把”一块射频、三种连接、一堆外设”的约束拆解得相当干净。最值得借鉴的,是那些契约集中(一份 rf_cfg.h、一份 protocol)与边界明确(传输后端数组、单射频编译期固定、待机 park/restore)的做法:把复杂度收进少数几个头文件与状态机里,而不是散落在各处。