用eBPF修复HID键盘映射问题:无需内核补丁的现代方案 去年年底帮朋友折腾一把客制化键盘问题看着不大USB HID 键盘能识别但 Fn 组合键映射全乱几个功能键死活没反应。按老思路走就是查驱动源码、翻 hid-quirks 表、准备重新编译内核可真到了那一步才发现为了一个键位差异去维护一整份内核补丁成本高得离谱。那阵子我正好在研究 eBPF就抱着试试看的心态把思路换成“运行时注入 BPF 程序”问题解决得意外干净不用打补丁、不用重启加载即生效卸载即还原。这篇文章就把这套完整思路写出来既适合被 HID 设备兼容性问题折磨的 Linux 用户也适合想了解 eBPF 在内核驱动领域怎么落地的人。1. 为什么一个坏键鼠会让你想重新编译内核1.1 数据从硬件到应用的完整链路HIDHuman Interface Device是键盘、鼠标、触摸板这类人机交互设备的通用协议。设备并不是直接告诉系统“我按下了 A 键”而是通过报告描述符Report Descriptor声明自己的数据格式再通过 Input Report 把实际键值、位移、状态变化发给主机。以 USB 键盘为例一次按键的完整路径是键盘扫描到按键固件组装一份 Input Report报告通过 USB 中断端点发送usbhid 驱动接收usbhid 调用hid_input_report()把原始报告交给 hid-corehid-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-BPFeBPF 能接管 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-RECompile 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是 typearg2是 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 被固件错误声明为0xFF00Vendor-defined而不是0x000CConsumer 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除了修描述符另一个常见需求是重映射按键。比如说鼠标侧键默认上报的是“前进/后退”你希望它在某个环境里变成“CtrlW”。这里其实有两条路线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 数据流的方式有了远比单纯看源码更深的理解。如果你也被类似问题卡住不妨先把你设备的报告描述符打出来很多“疑难杂症”其实只是固件里写错了几个字节。