Linux HID 子系统实战:从虚拟键盘到 input 事件上报


文章目录

  • [Linux HID 子系统实战:从虚拟键盘到 input 事件上报](#Linux HID 子系统实战:从虚拟键盘到 input 事件上报)
    • [1. HID 子系统里有两类驱动:搬运数据的和理解设备的](#1. HID 子系统里有两类驱动:搬运数据的和理解设备的)
    • [2. Report descriptor 是 HID 的数据说明书](#2. Report descriptor 是 HID 的数据说明书)
      • [Report ID 用来区分同一设备里的多种 report](#Report ID 用来区分同一设备里的多种 report)
    • [3. 用键盘和触摸板看懂 report 的字节布局](#3. 用键盘和触摸板看懂 report 的字节布局)
    • [4. 创建一个虚拟 HID 设备:descriptor 先进入 HID core](#4. 创建一个虚拟 HID 设备:descriptor 先进入 HID core)
    • [5. 从 I2C 中断到 input_event:实时数据怎么走](#5. 从 I2C 中断到 input_event:实时数据怎么走)
    • [6. 调试 HID 问题时,先判断卡在哪一层](#6. 调试 HID 问题时,先判断卡在哪一层)
    • [7. 实战选择:遇到 HID 任务时怎么选路径](#7. 实战选择:遇到 HID 任务时怎么选路径)
    • [8. 常见坑](#8. 常见坑)
      • [把厂商外层包头传给 HID core](#把厂商外层包头传给 HID core)
      • [忘了实现 `raw_request`](#忘了实现 raw_request)
      • [把 LED Output 当成键盘 Input](#把 LED Output 当成键盘 Input)
      • [直接把 C 结构体当成协议格式](#直接把 C 结构体当成协议格式)
      • [忽略 `Array` 和 `Variable` 的区别](#忽略 ArrayVariable 的区别)
      • [只看 input,不看 hidraw/debugfs](#只看 input,不看 hidraw/debugfs)
    • 总结
    • 参考

Linux HID 子系统实战:从虚拟键盘到 input 事件上报

调 HID 设备时,最容易卡住的地方不是某一个 API,而是几个层次混在一起:总线驱动在收包,HID core 在解析 report descriptor,hidinput 在生成 input 事件,hidraw 又能绕过一部分 input 映射直接把原始 report 暴露给用户态。

所以这篇文章不从"结构体大全"开始,而是围绕一个真实工作里常见的场景展开:

  • 一个厂商桥接芯片通过 I2C 发来键盘、鼠标、触摸板包;
  • 驱动在内核里创建虚拟 HID 键盘/触摸板;
  • HID report descriptor 告诉内核每个字节、每个 bit 是什么含义;
  • 最后这些 report 变成 /dev/input/eventX 里的 EV_KEYEV_ABSBTN_TOUCH 等事件。

先记住一句话:HID 子系统不是帮你"发按键"的 API,它是把"设备声明的数据格式"和"设备实时送来的 report"对上号,然后把 usage 映射成 Linux input 事件的一套框架。

1. HID 子系统里有两类驱动:搬运数据的和理解设备的

HID 框架的核心对象是 struct hid_device。它里面同时保存了设备身份、报告描述符、解析后的 report/field/usage、底层传输驱动、上层 HID 驱动以及 input/hidraw/hiddev 等消费者。

公开博客里不需要把完整结构体背下来,先抓住这些字段就够了:

c 复制代码
struct hid_device {
        __u8 *dev_rdesc;
        unsigned dev_rsize;

        __u8 *rdesc;
        unsigned rsize;

        struct hid_collection *collection;
        unsigned maxcollection;
        unsigned maxapplication;

        __u16 bus;
        __u32 vendor;
        __u32 product;
        __u32 version;

        struct hid_report_enum report_enum[HID_REPORT_TYPES];

        struct hid_ll_driver *ll_driver;
        struct hid_driver *driver;

        unsigned claimed;
        unsigned quirks;

        struct list_head inputs;
        void *hidraw;
        void *hiddev;

        char name[128];
        char phys[64];
        char uniq[64];

        void *driver_data;
};

这里最重要的不是字段多,而是职责边界清楚:

对象 主要职责 典型问题
hid_ll_driver 底层传输,负责 start/open/close/parse/raw_request/output_report 设备数据从 USB、I2C、蓝牙或虚拟通道怎么进来
hid_driver 上层 HID 驱动,处理特定设备的 probe、raw_event、event、report 等 是否有厂商私有逻辑、quirk、特殊 report 处理
hid_device HID core 对一个 HID 设备的统一抽象 设备身份、描述符、report 枚举、input/hidraw 消费者
hidinput HID usage 到 Linux input 事件的映射 Usage(Keyboard) 怎么变成 EV_KEY/KEY_*
hidraw 原始 report 的用户态通道 想保留原始 HID report 时怎么读写

hid_ll_driverhid_driver 的区别可以这样理解:前者负责"怎么把 report 搬到 HID core",后者负责"这个 HID 设备有什么特殊行为"。USB HID、I2C HID、Bluetooth HID 都会有自己的底层传输路径;键盘、触摸板、游戏手柄或者厂商特殊设备,则可能需要不同的上层处理。

2. Report descriptor 是 HID 的数据说明书

HID 设备真正有意思的地方在 report descriptor。它不是普通配置表,而是一段小型声明语言,用来告诉主机:后续输入 report、输出 report、feature report 里,每个字段占多少 bit、代表什么 usage、数值范围是多少。

描述符条目大体分三类:

类型 作用 常见条目
Global Item 给后续字段设置默认上下文 Usage PageLogical Minimum/MaximumReport SizeReport CountReport ID
Local Item 描述当前字段对应的具体用途 UsageUsage Minimum/Maximum
Main Item 真正生成字段或集合 InputOutputFeatureCollectionEnd Collection

解析器按顺序扫描 descriptor。遇到 Global Item 时,它会更新当前状态,并且这个状态会影响后面的 Main Item;遇到 Local Item 时,它通常只服务于当前 Main Item;遇到 Input/Output/Feature 时,才会生成一个真正的 report field。

这解释了一个常见疑问:为什么一个 descriptor 里可以只写一次 Report SizeReport Count,后面多个字段都能继承?因为 Global Item 本来就是上下文,不是一次性的字段。

Report ID 用来区分同一设备里的多种 report

如果一个 HID 设备只有一种输入 report,可以没有 Report ID。一旦 descriptor 里出现了 Report ID,设备发来的每个 numbered report 第一个字节就必须是 report id。HID core 会先用这个字节找到对应的 struct hid_report,然后再解析后面的 payload。

例如一个键盘 report 使用 Report ID (5)

c 复制代码
0x05, 0x01,        /* Usage Page (Generic Desktop) */
0x09, 0x06,        /* Usage (Keyboard) */
0xA1, 0x01,        /* Collection (Application) */
0x85, 0x05,        /* Report ID (5) */
0x05, 0x07,        /* Usage Page (Keyboard/Keypad) */
0x19, 0xE0,        /* Usage Minimum (LeftControl) */
0x29, 0xE7,        /* Usage Maximum (Right GUI) */
0x15, 0x00,        /* Logical Minimum (0) */
0x25, 0x01,        /* Logical Maximum (1) */
0x75, 0x01,        /* Report Size (1) */
0x95, 0x08,        /* Report Count (8) */
0x81, 0x02,        /* Input (Data,Var,Abs) */

这个片段的意思不是"按键数据就是 8 字节",而是"从这里开始声明了一个 8 bit 的输入字段,每个 bit 对应 LeftControl 到 Right GUI 的一个 modifier key"。

Report ID 的价值在复合设备里更明显。比如一个桥接芯片同时上报键盘、consumer key、鼠标、触摸板,每类 report 可以有自己的 ID:

Report ID 典型含义 report 长度示例
0x05 Keyboard input id + modifier + reserved + 6 keycodes
0x06 Consumer control id + consumer key bitmap/value
0x02 Mouse input id + buttons + dx/dy/wheel
0x19 Touchpad input id + button + contacts + contact_count

只要 descriptor 和实时 report 对不上,后面所有 input 事件都会错。调 HID 时先看 descriptor,再看原始 report,这是最短路径。

3. 用键盘和触摸板看懂 report 的字节布局

键盘是最适合入门的 HID report 案例。一个常见键盘输入 report 可以抽象成下面这样:

c 复制代码
struct keyboard_input_report {
        __u8 report_id;     /* 0x05, only when descriptor uses Report ID */
        __u8 modifiers;     /* bit0..bit7: LeftCtrl..RightGUI */
        __u8 reserved;      /* constant padding byte */
        __u8 keycode[6];    /* up to 6 simultaneous normal keys */
} __packed;

如果底层收到的桥接包类似这样:

text 复制代码
57 2F 39 05 00 00 39 00 00 00 00 00

不要把整个包直接塞给 hid_input_report()。前面的 57 2F 39 是厂商外层协议头;真正的 HID input report 从 05 开始:

text 复制代码
05 00 00 39 00 00 00 00 00

这里 0x05 是 report id,0x00 表示没有 modifier,第二个 0x00 是 reserved,0x39 是 Caps Lock 的 HID usage code。松开时,keycode 区域变回 0:

text 复制代码
05 00 00 00 00 00 00 00 00

资料里也提到 LED 字段。这里要特别小心:键盘上的 Num Lock、Caps Lock 等灯通常属于 Output report,是主机发给设备的控制数据,不是设备上报给主机的 input report。讲布局时可以把它放在同一个 descriptor 里理解,但写驱动时不要把 input report 和 output report 的 payload 混在一个结构体里直接 memcpy

触摸板则更能体现 descriptor 的价值。一个三指触摸板输入 report 可以抽象成:

c 复制代码
struct touch_contact {
        __u8 state;       /* Tip Switch / In Range / Touch Valid + padding */
        __u8 contact_id;
        __le16 x;
        __le16 y;
} __packed;

struct touchpad_input_report {
        __u8 report_id;   /* 0x19 */
        __u8 button;      /* low 2 bits for buttons, high 6 bits padding */
        struct touch_contact contact[3];
        __u8 contact_count;
} __packed;

Contact Count Maximum 通常是 Feature report,用来描述或查询设备最大触点数;它和实时触摸输入不一定在同一个 input report 里。这个点很容易写错:InputOutputFeature 是三类 report,方向和使用场景不同。

4. 创建一个虚拟 HID 设备:descriptor 先进入 HID core

如果硬件不是标准 USB HID,而是一个 I2C 桥接芯片自己吐包,驱动仍然可以在内核里创建一个虚拟 HID 设备。关键是给 HID core 三样东西:

  1. 设备身份:bus/vendor/product/name/phys/uniq
  2. report descriptor:告诉 HID core 怎么解析后续 report。
  3. hid_ll_driver:告诉 HID core 如何启动、打开、关闭、解析描述符、处理 raw request/output report。

最小化后的创建流程如下:

c 复制代码
struct virtual_hid_desc {
        const char *name;
        const __u8 *rd_data;
        unsigned int rd_size;
        __u16 bus;
        __u32 vendor;
        __u32 product;
};

static struct hid_device *virtual_hid_create(struct virtual_hid_desc *desc)
{
        struct hid_device *hdev;
        int ret;

        hdev = hid_allocate_device();
        if (IS_ERR(hdev))
                return hdev;

        strscpy(hdev->name, desc->name, sizeof(hdev->name));
        hdev->ll_driver = &virtual_hid_ll_driver;
        hdev->bus = desc->bus;
        hdev->vendor = desc->vendor;
        hdev->product = desc->product;
        hdev->driver_data = desc;

        ret = hid_add_device(hdev);
        if (ret) {
                hid_destroy_device(hdev);
                return ERR_PTR(ret);
        }

        return hdev;
}

底层驱动的 parse() 回调通常把 report descriptor 交给 HID core:

c 复制代码
static int virtual_hid_parse(struct hid_device *hdev)
{
        struct virtual_hid_desc *desc = hdev->driver_data;

        return hid_parse_report(hdev, desc->rd_data, desc->rd_size);
}

static struct hid_ll_driver virtual_hid_ll_driver = {
        .start = virtual_hid_start,
        .stop = virtual_hid_stop,
        .open = virtual_hid_open,
        .close = virtual_hid_close,
        .parse = virtual_hid_parse,
        .raw_request = virtual_hid_raw_request,
        .output_report = virtual_hid_output_report,
};

hid_parse_report() 先把原始 descriptor 复制到 hid_device::dev_rdesc。后续 hid_open_report() 会真正解析 descriptor,生成 report_enumhid_reporthid_fieldhid_usage 等结构。也就是说,dev_rdesc 是原始说明书,report_enum 才是 HID core 用来解析实时 report 的索引结构。

设备注册后的主流程可以简化成:

text 复制代码
hid_allocate_device()
  -> hid_add_device()
     -> ll_driver->parse()
     -> hid_scan_report()
     -> device_add()
        -> hid_device_probe()
           -> hid_open_report()
           -> hid_hw_start()
              -> ll_driver->start()
              -> hid_connect()
                 -> hidinput_connect()
                    -> input_register_device()

这里有两个实战点:

  • hid_add_device() 不是简单挂个对象,它会触发 descriptor 读取、设备模型注册和 driver probe。
  • hidinput_connect() 会根据解析出的 usage 创建 input_dev,并设置支持的 EV_KEYEV_ABSBTN_*KEY_* 等能力位。

5. 从 I2C 中断到 input_event:实时数据怎么走

资料里的案例有一个典型的数据桥接路径:

text 复制代码
I2C IRQ
  -> threaded IRQ handler
  -> workqueue
  -> I2C read
  -> parse vendor packet
  -> choose keyboard / consumer / mouse / touch report
  -> hid_input_report()

裁剪后的伪代码如下:

c 复制代码
static int bridge_parse_packet(__u8 *data, size_t len)
{
        __u8 *p = data;
        __u8 first;
        __u8 seq;
        __u8 packet_group;
        __u8 report_id;

        first = *p++;
        if (first != 0x57)
                return -EINVAL;

        seq = *p++;
        packet_group = *p++;
        if (packet_group != 0x39 && packet_group != 0x4A &&
            packet_group != 0x5B && packet_group != 0x6C)
                return -EINVAL;

        while (p < data + len) {
                report_id = p[0];

                switch (report_id) {
                case 0x05:
                        hid_input_report(keyboard_hdev, HID_INPUT_REPORT,
                                         p, 9, 0);
                        p += 9;
                        break;
                case 0x19:
                        hid_input_report(touchpad_hdev, HID_INPUT_REPORT,
                                         p, 21, 0);
                        p += 21;
                        break;
                default:
                        return -EINVAL;
                }
        }

        return 0;
}

注意这个例子里传给 hid_input_report()p 指向 report id,而不是外层厂商包头。对于 numbered report,这是硬要求;如果把 0x57 当成 report id,HID core 找不到对应 report,后面自然没有 input 事件。

进入 HID core 后,主流程是:

text 复制代码
hid_input_report()
  -> hid_get_report()
  -> driver->raw_event()
  -> hid_report_raw_event()
     -> hid_get_report()
     -> skip report id if numbered
     -> hidraw_report_event()
     -> hid_input_field()
        -> hid_field_extract()
        -> hid_process_event()
           -> driver->event()
           -> hidinput_hid_event()
              -> input_event()

hid_input_report() 是底层传输进入 HID core 的入口。它会先做基本检查、加锁、调用上层驱动的 raw_event() 机会,然后进入 hid_report_raw_event()

hid_report_raw_event() 的核心动作有三个:

  1. 通过 report id 找到 struct hid_report
  2. 如果 report 是 numbered report,就跳过第一个字节,让后面的解析从 payload 开始。
  3. 对每个 hid_fieldhid_input_field(),按 bit offset 和 size 抽取数值。

hid_input_field() 不是简单转发字节。它会根据 descriptor 里形成的字段信息读取 bit 流:

c 复制代码
value[n] = hid_field_extract(hid, data,
                             field->report_offset + n * field->report_size,
                             field->report_size);

然后它会区分两类字段:

  • Variable 字段:第 n 个 usage 对应第 n 个值,例如 modifier bit、触摸点状态、X/Y 坐标。
  • Array 字段:值本身是 usage index,例如普通键盘 6 个 keycode 槽位。

最后到 hidinput_hid_event() 时,struct hid_usage 已经带上了 Linux input 映射:

c 复制代码
struct hid_usage {
        unsigned hid;    /* HID usage code */
        __u16 code;      /* Linux input code, e.g. KEY_CAPSLOCK */
        __u8 type;       /* Linux input type, e.g. EV_KEY */
};

所以它最终能调用:

c 复制代码
input_event(input, usage->type, usage->code, value);

这就是从 Usage(Keyboard Caps Lock)EV_KEY/KEY_CAPSLOCK 的桥。

6. 调试 HID 问题时,先判断卡在哪一层

HID 问题不要一上来改 descriptor,也不要一上来怀疑 input 子系统。按层观察,成本最低。

现象 优先观察点 常用办法
设备没有 /dev/input/eventX hid_add_device()hid_device_probe()hidinput_connect() 是否成功 dmesg、确认 descriptor 是否能被解析、确认 connect_mask
有 hidraw 没有 input usage 没有映射到 input,或只被 hidraw 消费 claimed、看 hidinput_connect() 是否返回成功
有 input 设备但没事件 原始 report 是否进入 hid_input_report() 在桥接解析处打印 report id 和长度
report 进来了但 key 不对 descriptor 的 Report Size/Count/Usage 与字节布局不一致 dump report descriptor,人工对 bit offset
modifier 正常,普通按键异常 VariableArray 类型理解错 检查 Input(Data,Var,Abs)Input(Data,Array,Abs)
触摸坐标范围怪 Logical Maximum、单位、大小端或 bit offset 错 对照 descriptor 的 X/Y 字段
Caps Lock 灯不亮 LED 是 Output report,不是 input report output_reportraw_request 实现

几个常用观察路径:

shell 复制代码
# 看内核识别出来的 HID 设备和 input 设备
dmesg | grep -i hid

# debugfs 下通常能看到 HID report descriptor 和事件
mount -t debugfs none /sys/kernel/debug
ls /sys/kernel/debug/hid

# 查看 input 事件
evtest /dev/input/eventX

# Android 环境常用
getevent -lt

如果是桥接芯片,建议在两个位置打日志:

  1. 厂商外层包解析后:打印外层头、report id、准备传给 HID core 的长度。
  2. hid_input_report() 前:确认 buffer 第一个字节就是 HID report id。

只要这两个点正确,后面的问题通常就收敛到 descriptor 和 usage 映射。

7. 实战选择:遇到 HID 任务时怎么选路径

任务 推荐路径 原因
标准 USB/I2C/Bluetooth HID 设备 优先走内核已有 HID bus driver 少写底层传输,复用 descriptor 解析和 hidinput
厂商桥接协议包里嵌了 HID report 写桥接解析层,再调用 hid_input_report() 让 HID core 负责 report 解析和 input 映射
只想用户态拿原始数据 提供 hidraw 或字符设备 不强行映射成 input 事件
report descriptor 有小错误 优先用 quirk 或 descriptor fixup 避免整套私有解析逻辑
需要支持键盘 LED 或 Feature 实现 raw_request / output_report 这些不是 input report 能解决的
输入事件重复或丢失 hid_input_field() 的差分逻辑和 usage 类型 HID core 会过滤部分未变化字段

第一性原理看,HID 的核心收益是"让设备自己声明数据格式"。如果你已经有标准 report descriptor,就不应该在驱动里手写一套 keycode/坐标解析再直接 input_event();那样短期看快,长期会丢掉 HID core 的 report id、field、usage、quirk、hidraw、debugfs 等能力。

8. 常见坑

把厂商外层包头传给 HID core

hid_input_report() 要的是 HID report,不是你的 I2C/SPI/UART 外层协议包。numbered report 的第一个字节必须是 report id。

忘了实现 raw_request

hid_add_device() 会检查底层驱动是否提供必要传输能力。即使你当前只上报 input report,也要认真处理 raw_request 的语义,至少让不支持的请求明确失败。

把 LED Output 当成键盘 Input

键盘 descriptor 里既有 Input 字段,也可能有 LED 的 Output 字段。它们都在 descriptor 里,但不是同一条数据流。Caps Lock 按键上报是 input,Caps Lock 灯控制是 output。

直接把 C 结构体当成协议格式

HID report 是 bit-packed 数据,descriptor 才是权威来源。C 结构体可以帮你理解布局,但不要忽略 padding、bit offset、大小端和 __packed

忽略 ArrayVariable 的区别

modifier bit 通常是 Variable,普通键盘 keycode 槽位常见是 Array。前者"位置就是 usage",后者"值代表 usage"。hid_input_field() 对这两类字段的事件生成逻辑不同。

只看 input,不看 hidraw/debugfs

如果怀疑 descriptor 或 report id 错,input 事件已经是后处理结果。先看原始 report 和 report descriptor,能少走很多弯路。

总结

HID 子系统可以按一条数据链来理解:

text 复制代码
report descriptor
  -> hid_parse_report() / hid_open_report()
  -> report_enum / report / field / usage
  -> bottom layer receives report bytes
  -> hid_input_report()
  -> hid_input_field()
  -> hidinput_hid_event()
  -> input_event()

写 HID 驱动时,底层驱动负责把正确的 report bytes 送进 HID core;descriptor 负责说明这些 bytes 怎么解释;hidinput 负责把 usage 翻译成 Linux input 事件。三者边界分清后,虚拟键盘、触摸板、游戏手柄、consumer key 的调试方法都是同一套:先看 descriptor,再看 report id,再看 field 解析,最后看 input 事件。

参考

  • Linux kernel source: include/linux/hid.h
  • Linux kernel source: drivers/hid/hid-core.c
  • Linux kernel source: drivers/hid/hid-input.c
  • Linux kernel source: drivers/hid/hidraw.c
  • USB HID Usage Tables
  • HID Descriptor Tool
  • USB descriptor parser tools
相关推荐
原来是猿1 小时前
【Socket编程预备知识】
linux·运维·服务器·网络
啧不应该啊2 小时前
Day1 python与c宏观区别
c语言·开发语言
OneT1me2 小时前
CVE-2026-31431 的C语言版本
c语言·开发语言·安全威胁分析
__beginner__2 小时前
CentOS 磁盘占用异常排查与处理手册(df 高、du/ncdu 低)
linux·运维·centos
爱编码的小八嘎3 小时前
C‘语言完美演绎9-11
c语言
坚持就完事了3 小时前
YARN资源管理器
大数据·linux·hadoop·学习
Joseph Cooper4 小时前
Linux regmap 子系统实战:在驱动中 dump PMIC 寄存器定位供电问题
linux·运维·服务器
一行代码一行诗++4 小时前
C语言中if的使用
c语言·c++·算法
来生硬件工程师4 小时前
【程序库】 MutiButton 按键库
c语言·笔记·stm32·单片机·mcu·嵌入式实时数据库