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:
这会编译 BPF 程序和用户空间加载器,生成 hid-input-modifier
可执行文件。使用 sudo 运行它,因为 HID-BPF 需要 CAP_BPF 和 CAP_SYS_ADMIN 权限:
你会看到类似这样的输出:
已创建虚拟 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 跟踪输出以查看正在进行的修改:
这显示了来自 BPF 程序的 bpf_printk()
消息,确认事件正在被拦截和修改。
实验修改
这种方法的美妙之处在于实验是多么容易。想要反转鼠标方向而不是加倍它?只需更改 BPF 代码:
或者交换轴,使水平变为垂直:
你可以为老化的操纵杆实现死区过滤:
或修复绘图板上常见的 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/。