跳到主要内容
FallenSigh
返回

从裸机到上位机:WCH CH585 无线鼠标的固件架构与三条链路

编辑此页

这是一款基于 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-dongle2.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:


二、传输路由:三链路,一块射频

鼠标支持三种连接: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;

这里还有一个用光标漂移换来的教训,直接写进了头文件:

/* 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)。

几处体现”不信任主机、也不信任硬件”的细节:


六、上位机: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

唤醒源只有两个,且各自有明确理由:

进入睡眠前,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)的做法:把复杂度收进少数几个头文件与状态机里,而不是散落在各处。


编辑此页
分享这篇文章:

上一篇
摆脱桌面的束缚:基于 IMU 的空中飞鼠算法与实现原理解析
下一篇
SpikeYOLO:从 YOLOv8 到脉冲目标检测的微观架构解析