去年年底帮朋友折腾一把客制化键盘,问题看着不大:USB HID 键盘能识别,但 Fn 组合键映射全乱,几个功能键死活没反应。按老思路走就是查驱动源码、翻 hid-quirks 表、准备重新编译内核,可真到了那一步才发现,为了一个键位差异去维护一整份内核补丁,成本高得离谱。那阵子我正好在研究 eBPF,就抱着试试看的心态把思路换成“运行时注入 BPF 程序”,问题解决得意外干净:不用打补丁、不用重启,加载即生效,卸载即还原。这篇文章就把这套完整思路写出来,既适合被 HID 设备兼容性问题折磨的 Linux 用户,也适合想了解 eBPF 在内核驱动领域怎么落地的人。
1. 为什么一个坏键鼠会让你想重新编译内核
1.1 数据从硬件到应用的完整链路
HID(Human Interface Device)是键盘、鼠标、触摸板这类人机交互设备的通用协议。设备并不是直接告诉系统“我按下了 A 键”,而是通过报告描述符(Report Descriptor)声明自己的数据格式,再通过 Input Report 把实际键值、位移、状态变化发给主机。
以 USB 键盘为例,一次按键的完整路径是:
- 键盘扫描到按键,固件组装一份 Input Report;
- 报告通过 USB 中断端点发送,usbhid 驱动接收;
- usbhid 调用
hid_input_report(),把原始报告交给 hid-core; - hid-core 按照报告描述符解析出一个个字段,映射成内核 input subsystem 的事件;
- evdev 节点把 input 事件交给用户态程序,桌面环境最终呈现“按下”“抬起”。
这条链路上任何一环出问题,表现都是“设备行为不正常”。最常见的是第 4 步解析错位——报告描述符本身有错误,或者设备固件的实现和标准不完全一致。比如报告里声明了 8 个按键,但每个字段的 Usage Page 给成了 Vendor-defined,内核就不知道这些数据代表键盘还是别的什么,结果按键全部无效。
1.2 传统修法的痛点:改驱动、打补丁、重新编译内核
传统修法分成几档:
- 针对已知品牌型号,在内核
drivers/hid/下加一个专属驱动,或者给 hid-quirks 表加条目,把设备的异常行为“校准”掉; - 某些设备只差一个模块参数或 UDEV 规则就能绕过去;
- 如果问题出在报告描述符解析,往往得改
hid-core或usbhid的公共逻辑。
无论哪一档,都要经过“改源码 → 构建内核模块 → 安装 → 重启或重载驱动”这个流程。自己维护一套带补丁的内核,意味着每次上游更新都要 rebase;如果设备是同事或客户的,你还得把补丁发给对方,对方还得会编译内核。为了一只鼠标重编内核,怎么想都不划算。
1.3 eBPF 的入场姿势:不动源码,改行为
eBPF 的思路完全不同:它把内核的某些执行点做成可编程插槽,在不修改驱动源码、不重编内核的前提下,向这些插槽挂入一段“小程序”,由内核虚拟机安全地执行。小程序可以读取内核数据结构,也可以修改某些行为;加载后对当前运行中的内核立即生效,卸载后完全还原。
对于 HID 设备来说,这条路径天然合适:因为设备故障大部分发生在“内核解析 HID 报告”这个动态过程中,而 eBPF 恰好可以在这个过程的关键函数入口挂上逻辑。更妙的是,内核 6.3 起已经有了面向 HID 的 BPF 机制(HID-BPF),不再需要把通用 kprobe 工具硬掰成修复工具,而是提供了直接操作报告描述符和输入报告的编程接口。这也是标题里“无需内核补丁”的底气所在。
2. eBPF 不是外挂,是内核预留的微创手术台
2.1 可选钩子:kprobe、tracepoint、HID-BPF
eBPF 能接管 HID 数据流,靠的是几个不同的挂载点:
- kprobe:在任意内核函数的入口或返回处插入探针,适合观测和轻度修改。比如挂
hid_input_report()的入口,能看到每次从设备来的原始报告数据。 - tracepoint:内核开发者预埋的稳定事件点,更安全,但可做的操作相对少。
- HID-BPF:内核 6.3 合入的专用机制,允许 BPF 程序附着到某个 HID 设备,在设备注册、报告解析、报告输出等阶段执行,支持修改报告描述符和输入报告。
这三种方式并不是互相替代的关系。日常排查问题,我基本先用 kprobe 加 bpftrace 做观测;确定要动手改数据,就会考虑 HID-BPF。kprobe 适合“看”,HID-BPF 适合“改”。
2.2 观测与修改:理解 eBPF 的“可修改”边界
很多人担心 eBPF 动内核数据会不会搞崩系统。实际上 eBPF 的“可修改”是有限制的:写内存的操作要经过 verifier 检查,越界的、可能出现野指针的写操作基本在加载阶段就被拒绝。它不像内核模块那样拥有无限权力,更像是“允许你在固定切口上做有限干预”。这种约束反而让它在生产环境里可接受。
通用 kprobe 即使能改,verifier 也很难接受“往参数指向的缓冲区乱写”这种操作——因为内核无法验证那个缓冲区的生命周期。HID-BPF 则把“从设备获取报告数据 / 写回修改后数据”封装成专用 helper,保证访问范围合法,这才是真正面向修复场景的接口。所以我的结论是:如果只是看,用 bpftrace;如果要改报告本身,优先用 HID-BPF,而不是强行在 kprobe 里写内存。
2.3 内核 6.3+ 的 HID-BPF 到底改了什么
早期的 eBPF 修 HID 设备很别扭,因为你要通过通用的 kprobe 挂到 hid-core 函数上,靠“碰运气”找到合适的挂载点。HID-BPF 出现后,事情变得正规化:它定义了一套专门给 HID 用的程序类型和上下文结构,BPF 程序可以拿到设备对象、报告数据,并且有明确的返回动作语义(继续处理、丢弃等)。
你可以把它理解为:内核给 HID 驱动留了几个标准的“微创手术入口”,BPF 程序通过这些入口干预设备行为。好处是接口稳定、权限模型清晰、verifier 检查更友好。坏处是它很新,API 还在演进,后面我会单独讲这个风险。
3. 动手前的三类检查:内核、权限、设备故障层
3.1 检查内核版本与 BPF 特性
第一步先看内核版本,HID-BPF 需要 6.3 以上:
uname -r然后检查 BPF 相关配置项:
zcat /proc/config.gz | grep -E 'CONFIG_BPF|CONFIG_BPF_EVENTS|CONFIG_DEBUG_INFO_BTF'如果你用的发行版内核开启了 BTF(几乎主流发行版都开),eBPF 程序可以携带类型信息,跨内核版本迁移会方便很多。如果CONFIG_DEBUG_INFO_BTF没开启,跑 CO-RE(Compile Once – Run Everywhere)程序会遇到障碍,建议先解决内核配置。
3.2 安装工具链
Debian/Ubuntu 系:
apt install bpfcc-tools bpftrace linux-tools-common libbpf-dev clangFedora 系:
dnf install bpftrace bcc libbpf-devel clang安装后确认版本:
bpftrace --version bpftool version如果bpftool没装,通常发行版会提供独立的linux-tools-generic或者linux-tools-$(uname -r)包。这是一个容易忽略的点:很多人装了 bpftrace 但没装 bpftool,后面想管理 BPF 程序时就抓瞎。
3.3 判断设备卡在哪一层
在写任何 BPF 程序之前,先回答一个关键问题:设备到底坏在哪一层?我的排查顺序是:
lsusb看设备是否被 USB 层枚举;dmesg | grep -i hid看 hid-core 是否注册了设备;ls /sys/bus/hid/devices/列出已注册的 HID 设备;ls /dev/input/event*看设备是否生成了 input 节点;- 用
evtest去读 event 事件,看有没有按键数据。
通过这几步能大致定位故障层:
- USB 层看不到设备 → 硬件、线缆、供电问题,eBPF 管不了;
- HID 注册了但 event 节点没反应 → 大概率是报告描述符解析问题,eBPF 可以介入;
- event 有事件但桌面无响应 → 系统映射问题,优先考虑用户态方案,不一定要上内核态。
eBPF 在这个体系里主要管中间两层:报告解析和事件产生。
4. 故障定位第一步:用 bpftrace 把 HID 报告捞出来看
4.1 偷看内核里的 HID 输入报告
先上一个最实用的观测脚本。hid_input_report()是 usbhid 把原始报告交给 hid-core 的入口,函数签名是:
int hid_input_report(struct hid_device *hdev, int type, u8 *data, u32 size, int interrupt);用 bpftrace 挂这个函数的入口:
bpftrace -e 'kprobe:hid_input_report { $h = (struct hid_device *)arg0; printf("vid=%04x pid=%04x type=%u size=%u data: %02x %02x %02x %02x\n", $h->vendor, $h->product, arg1, arg3, *(u8 *)arg2, *(u8 *)(arg2 + 1), *(u8 *)(arg2 + 2), *(u8 *)(arg2 + 3)); }'参数对应关系:arg0是struct hid_device *,arg1是 type,arg2是 data 指针,arg3是 size。这样每次按键、移动鼠标,你都能看到内核到底拿到了什么原始数据。
有一个细节要注意:HID 设备的 vendor/product 会出现在不少内核结构体里,但不同内核版本字段名可能略有差异。如果$h->vendor加载报错,先用pahole或者直接看/usr/src/linux-headers-$(uname -r)/include/linux/hid.h确认字段名。
4.2 在设备枚举阶段查看报告描述符
有些问题发生在设备刚插上时,报告描述符解析阶段出了问题。内核里负责解析描述符的是hid_parse_report():
int hid_parse_report(struct hid_device *hdev, __u8 *rdesc, unsigned int rsize);bpftrace 挂入口:
bpftrace -e 'kprobe:hid_parse_report { $h = (struct hid_device *)arg0; $r = (u8 *)arg1; printf("parse %04x:%04x size=%u rdesc[0..3]=%02x %02x %02x %02x\n", $h->vendor, $h->product, arg2, $r[0], $r[1], $r[2], $r[3]); }'这里的$r[0]到$r[3]就是报告描述符开头四个字节。对照 HID 协议规范,前几字节通常包含 Usage Page、Usage、Collection 等关键声明。如果这段描述符内容和设备声称的功能对不上,故障根因基本就锁定了。
4.3 从输出反推故障层
当你跑起 bpftrace 后,常见的典型情况有这么几种:
- 完全没有任何输出:
hid_input_report()根本没被调用,说明问题在 usbhid 之前,比如设备没有被正确枚举、中断管道异常。eBPF 在这里帮不上太大忙,先查 USB 层。 - 有输出但数据值是 0:比如按下 A 键,data 里却是
0x00。这通常意味着设备固件没有把正确键值填进报告,或者报告偏移对不上。这种情况可以考虑在 eBPF 里做修正映射。 - 数据正确但按键没反应:说明内核在解析报告描述符时,把字段的含义理解错了。例如 Usage Page 被声明成 Vendor-defined,导致 hid-core 不知道该把数据送到键盘事件还是消费类事件。这种情况最适合用 HID-BPF 修改报告描述符。
很多看起来玄乎的“兼容性问题”,用 bpftrace 一看就原形毕露:要么是报告根本没进来,要么是描述符声明错误。先定位再动手,省下大量盲目尝试的时间。
5. 修复实战一:动态修正报告描述符,让设备恢复“身份”
5.1 一个典型的描述符错误案例
假设你手头有一块走 Mac 兼容路线的第三方键盘,多媒体按键在 Linux 下全部失效。用evtest看 event 节点完全没有动静,但用 bpftrace 挂hid_input_report又能看到 data 里有数值。
把设备在枚举阶段的报告描述符打印出来后,发现问题在于:多媒体按键集合的 Usage Page 被固件错误声明为0xFF00(Vendor-defined),而不是0x000C(Consumer Page)。内核不认识这个私有页面,直接把整个集合当未知数据处理,桌面自然收不到媒体键事件。
传统修复手段是在内核源码里给这个设备加 quirk,强制修正描述符,但那意味着维护一份私有内核补丁。这里用 HID-BPF 可以在设备还没有完成解析之前,就把描述符里的错误字节改掉。
5.2 HID-BPF 程序怎么写
下面是一段示意代码,用来把报告描述符偏移 9 处的错误 Usage Page 改成键盘页面0x07。实际设备的偏移要根据你的报告描述符计算,这里只展示核心逻辑:
// fix_rdesc.bpf.c —— 示意代码,API 以当前内核版本为准 #include <linux/bpf.h> #include <bpf/bpf_helpers.h> SEC("hid/device") int fix_usage_page(struct hid_bpf_ctx *ctx) { __u8 *rdesc = bpf_hid_get_data(ctx, 0, 0); if (!rdesc) return HID_BPF_ACTION_CONTINUE; // 把偏移 9 处的错误字节改写为 Keyboard Usage Page rdesc[9] = 0x07; return HID_BPF_ACTION_CONTINUE; } char LICENSE[] SEC("license") = "GPL";编译命令:
clang -O2 -g -target bpf -c fix_rdesc.bpf.c -o fix_rdesc.bpf.o加载和挂载可以用 libbpf 编写一个很小的 loader,也可以参考内核tools/bpf目录下的 HID-BPF 示例。加载到内核后,把程序 attach 到目标 HID 设备上。如果设备已经注册完成,改动报告描述符需要先让设备重新探测一次,比如通过 sysfs 的 bind/unbind 触发重新枚举:
echo "0003:0000:0000.000X" > /sys/bus/hid/drivers/hid-generic/unbind echo "0003:0000:0000.000X" > /sys/bus/hid/drivers/hid-generic/bind注意:这段示意代码的 API 会随着内核版本演进发生变化,不能指望它原封不动在 6.3 和 6.11 上都编译通过。我的建议是把 HID-BPF 当做一个能力参考,正式使用时一定要查你当前内核版本的Documentation/bpf和samples/bpf示例。
5.3 和“传统内核补丁”的差别在哪里
用下面这张表来对比会更直观:
| 维度 | 传统内核补丁 | HID-BPF |
|---|---|---|
| 生效时机 | 重新编译、安装、重启后 | 加载后立即生效,可随时卸载 |
| 内核维护 | 每次内核升级都要 rebase | 程序独立,和内核版本解码相对解耦 |
| 依赖环境 | 内核源码、工具链、重启条件 | clang + libbpf + BPF 权限 |
| 回滚成本 | 重新换内核/卸载模块 | 卸载 BPF 程序就还原 |
| 适用范围 | 所有内核逻辑都能改 | 只在预定义钩子上修改 |
从这个表能看出来,eBPF 不是要取代内核补丁,而是把它应用场景收窄到“值得长期维护正式补丁”的范围。对于手头一个特定设备、特定固件的兼容问题,跑一个 BPF 程序简直是高射炮打蚊子——不对,是高效率解决问题。
6. 修复实战二:按键重映射与功能禁用的正确姿势
6.1 两条路线:改 Input Report 还是改 input_event
除了修描述符,另一个常见需求是重映射按键。比如说鼠标侧键默认上报的是“前进/后退”,你希望它在某个环境里变成“Ctrl+W”。
这里其实有两条路线:
- HID-BPF 改 Input Report:在 hid-core 拿到原始报告后、解析成 input 事件前,修改报告内容。优点是一改全系统生效,所有程序看到的数据都是修改后的;缺点是你需要理解设备报告格式,而且有些数据依赖关系很复杂。
- 用户态 evdev 方案:比如
input-remapper这类工具,通过读 evdev 事件再写回 uinput 虚拟设备实现重映射。优点是好调试、易上手;缺点是依赖用户态服务运行,桌面环境换掉就可能失效。
我的建议是:桌面个人使用优先考虑用户态方案。eBPF 的价值在于“不依赖桌面环境、不依赖用户态服务”,适合嵌入式场景、服务器场景,或者在启动早期就需要修正设备行为的场景。
6.2 用 HID-BPF 屏蔽一个烦人按键
如果你就是想用 HID-BPF 做,逻辑也很直接。比如有个设备在 report ID0x03下会上报侧键数据,你希望把这一组按键全部屏蔽:
SEC("hid/device") int ignore_side_buttons(struct hid_bpf_ctx *ctx) { __u8 *data = bpf_hid_get_data(ctx, 0, 0); if (!data) return HID_BPF_ACTION_CONTINUE; if (data[0] == 0x03) data[1] = 0x00; return HID_BPF_ACTION_CONTINUE; }上面这段代码在 report ID 为0x03时,把数据区第一个字节清 0。具体要清哪些字节,取决于设备的报告格式。你可以先跑 bpftrace 观察按下不同按键时data数组的变化,再回来精确修改对应字节。
6.3 别为了炫技去重造轮子
这里我得说点实在的:eBPF 能改 HID 数据不代表所有重映射都应该用它。改键位这种事,xmodmap、keyd、input-remapper已经做得很成熟,调试还方便。eBPF 的优势是在“没有用户态环境”的地方发挥,比如:
- 系统启动早期、家目录加密未挂载时;
- 嵌入式 Linux 设备上没有桌面环境,但 HID 行为又需要修正;
- 公共服务器的 USB 外设需要统一策略,不想为每台机器装一堆用户态工具。
在这些场景里,BPF 程序跟内核一起活,环境干净、可复现、容易审计。如果你只是在笔记本上玩,老老实实用用户态工具,省心太多了。
7. 边界与风险:这些故障别指望 eBPF 兜底
7.1 eBPF 修不了硬件问题
一定要分清故障层。如果设备在lsusb里消失、或者插上后 dmesg 一堆device descriptor read/64, error -110,这是典型的 USB 枚举失败,可能是线缆质量问题、供电不足、硬件损坏。eBPF 程序再聪明,也无法让一个物理上无法通信的设备恢复通信。对此类问题,优先查线材、端口、供电、固件刷新。
7.2 HID-BPF 接口还在演进,别把鸡蛋全放一个篮子里
Linux 6.3 引入 HID-BPF 后,相关接口在后续版本里有调整,这不是什么稀奇事——一个新的内核子系统刚出来时总会有 API 打磨期。我的经验是:
- 先把要用的 BPF 程序固定到一个你知道兼容的内核版本上;
- 升级内核前,先确认 HID-BPF 相关 helper 和结构体有没有变化;
- 用 CO-RE 和 libbpf 而不是 BCC,后者在跨版本兼容上更费劲。
如果你是在给别人做方案,最好做一个“BPF 程序版本 + 内核版本”的兼容性检查清单,别让别人拿去以后在别的内核上编不过。
7.3 生产环境的操作纪律
最后聊聊生产环境。加载 BPF 程序不是危险操作,但修改内核行为这件事,无论如何都要有纪律:
- 代码审查:跑在内核态的程序必须经过严格 review,尤其是涉及写内存的逻辑;
- 最小权限:不要在普通服务进程上挂 CAP_BPF / CAP_SYS_ADMIN,用独立的 systemd service 管理 BPF 程序生命周期;
- 加载前先观测:先用 bpftrace 看足够多的数据,确认修改逻辑正确后再上;
- 保留卸载路径:确保程序能随时从设备上 detach,并测试过 detach 后设备行为恢复正常。
这几条不是教条。我见过不止一次,有人把 BPF 程序写得“差不多能用”就挂到生产环境,结果设备行为在特定场景下变得更糟,最后排查了半天才发现是 BPF 程序自己把数据改错了。先观测、再修改、留后路,这个顺序永远不要反。
最后分享一个我在实际折腾中的顺序:先lsusb看枚举,再dmesg看 hid-core 认不认,然后 bpftrace 挂hid_input_report看报告,最后才决定用 HID-BPF 改描述符还是用用户态工具改映射。这套流程帮我省下了大量重编内核的时间,也让我对内核处理 HID 数据流的方式有了远比单纯看源码更深的理解。如果你也被类似问题卡住,不妨先把你设备的报告描述符打出来,很多“疑难杂症”其实只是固件里写错了几个字节。