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` 的区别](#忽略 Array 和 Variable 的区别)
      • [只看 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_KEY、EV_ABS、BTN_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_driver 和 hid_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 Page、Logical Minimum/Maximum、Report Size、Report Count、Report ID
Local Item 描述当前字段对应的具体用途 Usage、Usage Minimum/Maximum
Main Item 真正生成字段或集合 Input、Output、Feature、Collection、End Collection

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

这解释了一个常见疑问:为什么一个 descriptor 里可以只写一次 Report Size 和 Report 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 里。这个点很容易写错:Input、Output、Feature 是三类 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_enum、hid_report、hid_field、hid_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_KEY、EV_ABS、BTN_*、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_field 调 hid_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 正常,普通按键异常 Variable 和 Array 类型理解错 检查 Input(Data,Var,Abs) 和 Input(Data,Array,Abs)
触摸坐标范围怪 Logical Maximum、单位、大小端或 bit offset 错 对照 descriptor 的 X/Y 字段
Caps Lock 灯不亮 LED 是 Output report,不是 input report 看 output_report 或 raw_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。

忽略 Array 和 Variable 的区别

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
相关推荐
浩浩测试一下6 小时前
Linux 底层知识点 1
linux·运维·服务器
Escalating_xu7 小时前
【Linux 网络编程】TCP 跨平台实战:用 Windows 客户端访问 Linux 服务器
linux·服务器·网络
忆挽篱笙歌8 小时前
环境变量,本地变量
linux·运维·服务器
吹什么轩10 小时前
linux系统复习:僵尸进程与孤儿进程
linux·运维·php
殷忆枫11 小时前
告别依赖地狱!在 Ubuntu 上用 Docker 极速部署 Home Assistant 与 MQTT,并接入 Siri 与嵌入式 C 终端
linux·ubuntu·docker
第404号工位11 小时前
手机到底有没有偷听我们?我抓包了 72 小时,抓到了真凶
linux·人工智能·架构
字节跳动开源11 小时前
ByteDance 2026 上半年 Linux 内核贡献回顾
linux·开源
小此方11 小时前
Linux网络(二十八):从NAT转换到正反向代理:图解内网通信、NAPT端口映射
linux·运维·网络
刚入门的大一新生12 小时前
Linux-进程池
linux·运维·服务器
鱼很腾apoc12 小时前
【MySQL】第4期 用户管理与 C 语言连接编程详解
linux·服务器·c语言·mysql·connector/c