Skip to content

eBPF 教程:无需内核补丁修复故障的 HID 设备

你是否遇到过这样的情况:插入新鼠标或绘图板后发现在 Linux 上无法正常工作?也许 Y 轴反了,按钮映射错了,或者设备感觉完全坏了。传统的解决方法需要编写内核驱动,等待数周的审查,然后希望你的发行版能在明年某个时候提供这个修复。到那时,你可能已经买了另一个设备。

本教程将向你展示更好的方法。我们将使用 HID-BPF 创建虚拟鼠标设备,并使用 eBPF 动态修改其输入。在几分钟内,而不是几个月,你就能看到如何在不修改内核的情况下修复设备问题。这项技术已经在主线 Linux 内核中提供了 14+ 个设备修复。

完整源代码: https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/49-hid

HID 设备问题

HID(人机接口设备)是鼠标、键盘、游戏控制器和绘图板等输入设备的标准协议。该协议定义明确,但硬件供应商经常实现不正确或添加不符合规范的特性。当这种情况发生在 Linux 上时,用户就会遭殃。

假设你买了一个 Y 轴反转的绘图板。当你向上移动触控笔时,光标向下移动。或者你买了一个鼠标,其中按钮 4 和 5 报告为按钮 6 和 7,破坏了浏览器的后退/前进导航。这些错误非常令人沮丧,因为硬件在其他操作系统上完美运行,但 Linux 看到的是原始的错误数据。

传统的修复方法需要编写内核驱动或修补现有驱动。你需要了解内核开发,向 LKML 提交补丁,让它们被审查,等待下一个内核版本,然后再等待你的发行版发布该内核。对于硬件有问题的用户来说,这可能需要六个月或更长时间。大多数用户只是退回设备或双启动到另一个操作系统。

HID-BPF 登场

HID-BPF 通过让你使用加载到内核的 eBPF 程序在用户空间修复设备来改变一切。这些程序使用 BPF struct_ops 挂钩到 HID 子系统,在应用程序看到 HID 报告之前拦截它们。你可以修改报告数据、修复描述符问题,甚至完全阻止某些操作。

这种方法为你提供了内核代码的安全性(BPF 验证器确保不会崩溃)和用户空间开发的灵活性。编写修复、加载它、立即测试。如果有效,打包并在同一天发布给用户。Linux 内核已经包含了针对 14 种不同设备的 HID-BPF 修复,包括:

  • Microsoft Xbox Elite 2 控制器
  • Huion 绘图板(Kamvas Pro 19, Inspiroy 2-S)
  • XPPen 数位板(Artist24, ArtistPro16Gen2, DecoMini4)
  • Wacom ArtPen
  • Thrustmaster TCA Yoke Boeing
  • IOGEAR Kaliber MMOmentum 鼠标
  • 各种其他鼠标和游戏外设

每个修复通常是 100-300 行 BPF 代码,而不是完整的内核驱动。随着 udev-hid-bpf 项目提供了脚手架,使编写这些修复变得更加容易,生态系统迅速发展。

为什么使用虚拟设备进行学习?

本教程使用通过 uhid(用户空间 HID)创建的虚拟 HID 设备。你可能想知道为什么我们不直接附加到你的真实鼠标。虚拟设备非常适合学习,因为它们为你提供:

  • 完全控制: 我们精确地发送我们想要的事件,何时发送
  • 可重复性: 相同的测试事件每次都产生相同的结果
  • 安全性: 不会意外破坏你的真实输入设备
  • 无需硬件: 在任何具有内核 6.3+ 的 Linux 系统上都可以工作

我们创建的虚拟鼠标报告移动事件就像真实的 USB 鼠标一样。我们的 BPF 程序在输入子系统看到这些事件之前拦截并修改它们。在我们的例子中,我们将所有移动加倍,但同样的技术适用于修复反转的轴、重新映射按钮或任何其他转换。

实现:虚拟 HID 设备

让我们看看完整的实现,从创建虚拟鼠标的用户空间代码开始。这使用 uhid 接口,允许用户空间程序创建内核 HID 设备。

创建虚拟鼠标

// SPDX-License-Identifier: GPL-2.0
/* 创建虚拟 HID 鼠标并使用 BPF 修改其输入 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <poll.h>
#include <linux/uhid.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "hid-input-modifier.skel.h"

static volatile bool exiting = false;

static void sig_handler(int sig)
{
    exiting = true;
}

/* 简单的鼠标报告描述符 */
static unsigned char rdesc[] = {
    0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
    0x09, 0x02, /* USAGE (Mouse) */
    0xa1, 0x01, /* COLLECTION (Application) */
    0x09, 0x01, /*   USAGE (Pointer) */
    0xa1, 0x00, /*   COLLECTION (Physical) */
    0x05, 0x09, /*     USAGE_PAGE (Button) */
    0x19, 0x01, /*     USAGE_MINIMUM (Button 1) */
    0x29, 0x03, /*     USAGE_MAXIMUM (Button 3) */
    0x15, 0x00, /*     LOGICAL_MINIMUM (0) */
    0x25, 0x01, /*     LOGICAL_MAXIMUM (1) */
    0x95, 0x03, /*     REPORT_COUNT (3) */
    0x75, 0x01, /*     REPORT_SIZE (1) */
    0x81, 0x02, /*     INPUT (Data,Var,Abs) */
    0x95, 0x01, /*     REPORT_COUNT (1) */
    0x75, 0x05, /*     REPORT_SIZE (5) */
    0x81, 0x03, /*     INPUT (Cnst,Var,Abs) */
    0x05, 0x01, /*     USAGE_PAGE (Generic Desktop) */
    0x09, 0x30, /*     USAGE (X) */
    0x09, 0x31, /*     USAGE (Y) */
    0x15, 0x81, /*     LOGICAL_MINIMUM (-127) */
    0x25, 0x7f, /*     LOGICAL_MAXIMUM (127) */
    0x75, 0x08, /*     REPORT_SIZE (8) */
    0x95, 0x02, /*     REPORT_COUNT (2) */
    0x81, 0x06, /*     INPUT (Data,Var,Rel) */
    0xc0,       /*   END_COLLECTION */
    0xc0        /* END_COLLECTION */
};

此报告描述符定义了具有三个按钮和相对 X/Y 移动的标准 USB 鼠标。描述符使用 HID 描述符语言告诉内核设备将发送什么数据。每个报告将包含三个字节:字节 0 中的按钮状态、字节 1 中的 X 移动和字节 2 中的 Y 移动。

uhid 接口要求我们在创建设备时写入此描述符:

static int create_uhid_device(void)
{
    struct uhid_event ev;
    int fd;

    fd = open("/dev/uhid", O_RDWR | O_CLOEXEC);
    if (fd < 0) {
        fprintf(stderr, "无法打开 /dev/uhid: %m\n");
        return -errno;
    }

    memset(&ev, 0, sizeof(ev));
    ev.type = UHID_CREATE;
    strcpy((char*)ev.u.create.name, "BPF Virtual Mouse");
    ev.u.create.rd_data = rdesc;
    ev.u.create.rd_size = sizeof(rdesc);
    ev.u.create.bus = BUS_USB;
    ev.u.create.vendor = 0x15d9;
    ev.u.create.product = 0x0a37;
    ev.u.create.version = 0;
    ev.u.create.country = 0;

    if (uhid_write(fd, &ev)) {
        close(fd);
        return -1;
    }

    printf("已创建虚拟 HID 设备\n");
    return fd;
}

成功后,内核会创建一个新的 HID 设备,就像真实的 USB 鼠标一样出现在 /sys/bus/hid/devices/ 中。然后我们可以附加 BPF 程序来拦截其事件。

发送合成鼠标事件

创建虚拟设备后,我们可以注入鼠标移动事件:

static int send_mouse_event(int fd, __s8 x, __s8 y)
{
    struct uhid_event ev;

    memset(&ev, 0, sizeof(ev));
    ev.type = UHID_INPUT;
    ev.u.input.size = 3;
    ev.u.input.data[0] = 0; /* 按钮 */
    ev.u.input.data[1] = x; /* X 移动 */
    ev.u.input.data[2] = y; /* Y 移动 */

    return uhid_write(fd, &ev);
}

每个事件发送三个字节,与我们的报告描述符匹配。字节 0 包含按钮状态(全零表示未按下任何按钮),字节 1 是作为有符号 8 位值的 X 移动,字节 2 是 Y 移动。内核处理此事件的方式与处理来自真实 USB 鼠标的事件完全相同。

BPF 程序:拦截 HID 事件

现在是有趣的部分:修改鼠标输入的 BPF 程序。这在内核中运行,通过 struct_ops 附加到 HID 设备。

// SPDX-License-Identifier: GPL-2.0
/* HID-BPF 示例:修改来自虚拟 HID 设备的输入数据
 *
 * 此程序将鼠标的 X 和 Y 移动加倍。
 * 与用户空间程序创建的虚拟 HID 设备一起使用。
 */

#include "vmlinux.h"
#include "hid_bpf_defs.h"
#include "hid_bpf.h"
#include "hid_bpf_helpers.h"
#include <bpf/bpf_tracing.h>

SEC("struct_ops/hid_device_event")
int BPF_PROG(hid_double_movement, struct hid_bpf_ctx *hctx, enum hid_report_type type)
{
    __u8 *data = hid_bpf_get_data(hctx, 0, 9);
    __s8 x, y;

    if (!data)
        return 0;

    /* 鼠标 HID 报告格式(简化):
     * 字节 0: 报告 ID
     * 字节 1: 按钮
     * 字节 2: X 移动(有符号字节)
     * 字节 3: Y 移动(有符号字节)
     */

    x = (__s8)data[2];
    y = (__s8)data[3];

    /* 移动加倍 */
    x *= 2;
    y *= 2;

    data[2] = (__u8)x;
    data[3] = (__u8)y;

    bpf_printk("已修改: X=%d Y=%d -> X=%d Y=%d",
           (__s8)data[2]/2, (__s8)data[3]/2,
           (__s8)data[2], (__s8)data[3]);

    return 0;
}

SEC(".struct_ops.link")
struct hid_bpf_ops input_modifier = {
    .hid_device_event = (void *)hid_double_movement,
};

char _license[] SEC("license") = "GPL";

程序挂钩到 hid_device_event,内核为每个 HID 输入报告调用它。hctx 参数提供有关设备和报告的上下文。我们调用 hid_bpf_get_data() 来获取指向实际报告数据的指针,我们可以读取和修改它。

报告数据遵循我们的描述符定义的格式。对于我们的简单鼠标,字节 2 包含 X 移动,字节 3 包含 Y 移动,均为有符号 8 位整数。我们读取这些值,将它们加倍,然后写回。然后内核将修改后的报告传递给输入子系统,应用程序看到加倍的移动。

bpf_printk() 调用将我们的修改记录到内核跟踪缓冲区。这对调试非常宝贵,让你准确地看到 BPF 程序如何转换每个事件。

理解 struct_ops

SEC(".struct_ops.link") 部分创建一个 struct_ops 映射,将我们的 BPF 程序连接到 HID 子系统。Struct_ops 是一个 BPF 功能,允许你在 BPF 代码中实现内核接口。对于 HID,这意味着提供内核在 HID 处理期间调用的回调。

hid_bpf_ops 结构定义了我们正在实现的回调。我们只需要 hid_device_event 来拦截报告,但 HID-BPF 还支持:

  • hid_rdesc_fixup: 修改报告描述符本身
  • hid_hw_request: 拦截对设备的请求
  • hid_hw_output_report: 拦截输出报告

用户空间代码加载此 BPF 程序并通过将 hid_id 字段设置为我们的虚拟设备的 ID 来附加它,然后调用 bpf_map__attach_struct_ops()

综合应用

主函数协调一切:

int main(int argc, char **argv)
{
    struct hid_input_modifier_bpf *skel = NULL;
    struct bpf_link *link = NULL;
    int err, hid_id;

    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    /* 创建虚拟 HID 设备 */
    uhid_fd = create_uhid_device();
    if (uhid_fd < 0)
        return 1;

    /* 查找 HID 设备 ID */
    hid_id = find_hid_device();
    if (hid_id < 0) {
        fprintf(stderr, "无法找到虚拟 HID 设备\n");
        destroy_uhid_device(uhid_fd);
        return 1;
    }

    /* 打开并加载 BPF 程序 */
    skel = hid_input_modifier_bpf__open();
    if (!skel) {
        fprintf(stderr, "无法打开 BPF skeleton\n");
        destroy_uhid_device(uhid_fd);
        return 1;
    }

    skel->struct_ops.input_modifier->hid_id = hid_id;

    err = hid_input_modifier_bpf__load(skel);
    if (err) {
        fprintf(stderr, "无法加载 BPF skeleton: %d\n", err);
        goto cleanup;
    }

    /* 附加 BPF 程序 */
    link = bpf_map__attach_struct_ops(skel->maps.input_modifier);
    if (!link) {
        fprintf(stderr, "无法附加 BPF 程序: %s\n", strerror(errno));
        err = -1;
        goto cleanup;
    }

    printf("BPF 程序附加成功!\n");
    printf("BPF 程序将使所有鼠标移动加倍\n\n");
    printf("发送测试鼠标事件:\n");

    /* 发送一些测试事件 */
    for (int i = 0; i < 5 && !exiting; i++) {
        __s8 x = 10, y = 20;
        printf("发送: X=%d, Y=%d (BPF 将加倍为 X=%d, Y=%d)\n",
               x, y, x*2, y*2);
        send_mouse_event(uhid_fd, x, y);
        sleep(1);
    }

    printf("\n按 Ctrl-C 退出...\n");
    while (!exiting)
        sleep(1);

cleanup:
    bpf_link__destroy(link);
    hid_input_modifier_bpf__destroy(skel);
    destroy_uhid_device(uhid_fd);
    return err < 0 ? -err : 0;
}

流程很简单:创建虚拟设备、查找其 ID、使用该 ID 加载 BPF 程序、附加程序、发送测试事件。BPF 程序在内核中运行,在每个事件到达输入层之前拦截并修改它。

理解 HID 报告格式

要有效地修改 HID 数据,你需要理解报告格式。我们的简单鼠标使用此结构:

字节 0: 报告 ID(对于我们的单一报告类型始终为 0)
字节 1: 按钮状态
        位 0: 左键
        位 1: 右键
        位 2: 中键
        位 3-7: 未使用
字节 2: X 移动(有符号 8 位,-127 到 +127)
字节 3: Y 移动(有符号 8 位,-127 到 +127)

真实设备通常具有更复杂的报告,具有多个报告 ID、更多按钮、滚轮数据和其他轴。你可以通过检查设备的报告描述符来确定格式,可以从 sysfs 读取或查看类似设备的现有内核驱动。

编译和执行

构建示例很简单。导航到教程目录并运行 make:

cd src/49-hid
make

这会编译 BPF 程序和用户空间加载器,生成 hid-input-modifier 可执行文件。使用 sudo 运行它,因为 HID-BPF 需要 CAP_BPF 和 CAP_SYS_ADMIN 权限:

sudo ./hid-input-modifier

你会看到类似这样的输出:

已创建虚拟 HID 设备
找到 HID 设备 ID: 8
BPF 程序附加成功!
BPF 程序将使所有鼠标移动加倍

发送测试鼠标事件:
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)
发送: X=10, Y=20 (BPF 将加倍为 X=20, Y=40)

按 Ctrl-C 退出...

在另一个终端中,你可以查看 BPF 跟踪输出以查看正在进行的修改:

sudo cat /sys/kernel/debug/tracing/trace_pipe

这显示了来自 BPF 程序的 bpf_printk() 消息,确认事件正在被拦截和修改。

实验修改

这种方法的美妙之处在于实验是多么容易。想要反转鼠标方向而不是加倍它?只需更改 BPF 代码:

/* 反转方向 */
x = -x;
y = -y;

或者交换轴,使水平变为垂直:

/* 交换轴 */
__s8 temp = x;
x = y;
y = temp;

你可以为老化的操纵杆实现死区过滤:

/* 忽略小移动 */
if (x > -5 && x < 5) x = 0;
if (y > -5 && y < 5) y = 0;

或修复绘图板上常见的 Y 轴反转问题:

/* 仅反转 Y 轴 */
y = -y;

任何更改后,只需运行 make 并再次执行。无需重建内核、无需模块签名、无需等待。这就是 HID-BPF 的力量。

总结

HID-BPF 改变了我们在 Linux 上处理古怪输入设备的方式。我们可以编写小型 BPF 程序立即修复设备,而不是需要数月才能到达用户的内核补丁。由于 BPF 验证器,程序在内核中安全运行,并且可以像任何其他软件一样打包和分发。

本教程通过创建虚拟鼠标并修改其输入向你展示了基础知识。你看到了 uhid 如何让用户空间创建 HID 设备,BPF struct_ops 如何将程序连接到 HID 子系统,以及简单的转换如何修复常见的设备问题。相同的技术适用于真实硬件,无论你是修复反转的平板轴还是实现自定义游戏控制器映射。

Linux 内核已经提供了 14 个 HID-BPF 设备修复,并且每个版本都在增加。像 udev-hid-bpf 这样的项目使编写和分发修复变得更加容易。如果你有一个损坏的 HID 设备,你现在有工具可以自己修复它,只需几个小时而不是几个月。

如果你想深入了解 eBPF,请查看我们的教程代码仓库 https://github.com/eunomia-bpf/bpf-developer-tutorial 或访问我们的网站 https://eunomia.dev/tutorials/

参考资料

Share on Share on