起因

EHCI 立起来之后,这篇写键盘和鼠标的驱动——USB HID class 实战。键鼠对工控系统不是小事:没有它们就没有参数输入和菜单操作。这两样东西的历史也比 USB 长:1987 年 IBM 在 PS/2 机型上把键盘鼠标定成中断驱动的扫描码接口,驱动几十行就写完;USB 时代它们变成通用总线上的一个设备类,为了描述千奇百怪的输入设备,HID 规范设计了自描述的报表描述符——通用性的代价是把解析器变成一个小型语言实现。这篇走的是Boot Protocol 路线:绕开报表描述符解析,把 HID 驱动从"大工程"变"小函数"。

一、HID 的两条路线:选 Boot Protocol

HID 设备的"正统"用法是解析报表描述符(Report Descriptor)——一种自描述的栈式语言,一个完整解析器写下来不小。BIOS 当年也懒得解析它,所以 HID 规范为键盘鼠标留了后门:Boot Protocol 子集——子类代码 1、协议代码 1(键盘)/2(鼠标),数据格式固定死,8 字节一个报表,零解析。主板固件走的就是这条路,我们跟它走同一条。

通过控制传输切换(上一篇的 8 字节请求包):

1
2
3
4
5
// bmRequestType=0x21(class, 接收方=接口), bRequest=0x0B(SET_PROTOCOL)
// wValue=0(boot 协议), wIndex=接口号, wLength=0
setup(0x21, 0x0B, 0, iface, 0);
// 顺手关掉重复报表:SET_IDLE(0x0A),键一直按着也不会刷屏
setup(0x21, 0x0A, 0 << 8, iface, 0);

从接口描述符里找中断 IN 端点(bEndpointAddress bit7=IN,bInterval=轮询毫秒数,典型 8ms),挂进 EHCI 的周期列表——之后每 8ms,最新的键鼠状态自动落进我们给的缓冲区。

坑:设备复位后协议会退回 Report Protocol。 USB 复位、重新枚举、拔插之后,SET_PROTOCOL 白设了,收到的报表长度对不上(键盘变 16 字节之类的怪形状)就要想到这一层:枚举流程里把 SET_PROTOCOL/SET_IDLE 固定做进去,别只在第一次开机做。

键盘还有个小尾巴:三个 LED(Caps Lock / Num Lock / Scroll Lock)。不发输出报表功能照常,但用户按了 Caps Lock 灯不亮,现场会当成"键盘坏了"报修。补一条 SET_REPORT(bmRequestType=0x21、bRequest=0x09、wValue=0x0200 报告类型=输出)把 LED 位图(bit0 Num、bit1 Caps、bit2 Scroll)写回去,几十行内的事。

遇到极少数不支持 boot 的设备再说——工控键鼠是固定选型,为 1% 的兼容性写一个报表描述符解析器,不划算。

二、键盘驱动

Boot 键盘报表固定 8 字节:

字节 内容
0 修饰键位图:bit0 左Ctrl、bit1 左Shift、bit2 左Alt、bit3 左GUI、bit4-7 右侧对应
1 保留(OEM 用)
2–7 按键数组:当前按下的 6 个键的 Usage 码,0 = 空

注意 bytes2-7 是"当前按着谁"的数组快照,不是事件流——按下/弹起要靠前后两帧对比:上一帧没有、这一帧有的 Usage = 按下;反之 = 弹起。超过 6 键同时按会收到 Usage 0x01(rollover 错误码),全帧丢弃。

Usage→字符映射表(节选,全表照 USB HID Usage Tables 抄):

Usage 基本字符 +Shift
0x04–0x1D a–z A–Z
0x1E–0x26 1–9、0 !@#…)
0x28 / 0x29 / 0x2A 回车 / Esc / 退格
0x2C 空格
0x2D–0x38 - = [ ] \ ; ’ ` , . / _ + { } | : " ~ < > ?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn parse_keyboard(prev: &[u8; 8], cur: &[u8; 8], out: &mut InputQueue) {
    let shift = cur[0] & 0x22 != 0;           // 左右 Shift 任一
    let ctrl  = cur[0] & 0x11 != 0;
    for u in &cur[2..] {                      // 新出现的 Usage → 按下事件
        if *u != 0 && !prev[2..].contains(u) {
            out.push(Event::key_down(*u, shift, ctrl));
        }
    }
    for u in &prev[2..] {                     // 消失的 Usage → 弹起事件
        if *u != 0 && !cur[2..].contains(u) {
            out.push(Event::key_up(*u));
        }
    }
}

按键重复(长按连发)在 UI 层用定时器做,不在驱动做——驱动只报事实,不做策略。中文输入法属于应用层,路线:英文键码 → UI 焦点控件 → 输入法状态机(后续应用篇)。

三、鼠标驱动

Boot 鼠标报表(3 字节起步,带滚轮 4 字节):

字节 内容
0 按键位图:bit0 左键、bit1 右键、bit2 中键
1 dx(有符号 int8,相对位移)
2 dy(int8)
3 滚轮(可选)
1
2
3
4
5
6
7
8
9
fn parse_mouse(prev_buttons: u8, cur: &[u8; 4], out: &mut InputQueue) {
    let (dx, dy) = (cur[1] as i8 as i32, cur[2] as i8 as i32);
    out.push(Event::rel_move(dx, dy));                    // 光标位置由 UI 层维护累加
    for b in 0..3 {                                        // 按键边沿检测
        let (p, c) = (prev_buttons & 1 << b != 0, cur[0] & 1 << b != 0);
        if !p && c { out.push(Event::btn_down(b)); }
        if p && !c { out.push(Event::btn_up(b)); }
    }
}

两个坑:dx/dy 是补码有符号数as i8 转换别丢(快速移动会有负值);相对位移的光标坐标由 UI 维护并夹在屏幕范围内,驱动不管绝对位置。

四、事件管道与时间戳

EHCI 周期传输完成中断 → 底半部调 parse_*InputEvent 进 SPSC 队列 → 图形系统篇的 ui_step 消费。事件结构带 TSC 时间戳(APIC 篇):

1
2
pub struct InputEvent { pub kind: EvKind, pub code: u16,
                        pub value: i32, pub tsc: u64 }

时间戳在工控现场是真有用的:审计"什么时刻按了什么键"——加工参数修改记录、误操作追责,都靠它。

五、工控安全边界

  • 软件按键永远不承担安全职能:急停必须是硬件回路(按钮串进断丝/超程检测电路,电平直进 FPGA 卡的安全中断,走 IST1 那条路)。键盘上的"急停"最多算"请求停止"
  • 加工运行中的权限锁:自动运行状态下输入事件默认丢弃,仅放行停止/急停/坐标显示——在事件管道的分发层实现,不散落在各控件里
  • 键盘去抖:HID 报表自带硬件去抖,软件层可不做;UI 层对关键操作做双击确认

总结

实战清单:SET_PROTOCOL(0x0B) 切 boot 协议 + SET_IDLE、枚举流程固定重设(复位回退坑)、SET_REPORT 点亮键盘 LED、接口描述符里找中断 IN 端点挂周期列表、键盘 8 字节报表的前后帧对比法 + Usage 映射表、鼠标 int8 位移 + 按键边沿、InputEvent 带 TSC 进 SPSC、运行态输入锁在管道层收口。跑通标志:QEMU 里 -device usb-kbd -device usb-mouse,串口打出键码流,图形篇的 UI 能用键盘切焦点、鼠标点按钮(usb-tablet 是绝对坐标的报表设备,不走 boot 协议,测鼠标用 usb-mouse)。下一篇:NVMe 固态硬盘。

参考链接