Skip to content

Input 子系统按键 - 别重复造轮子了

说实话,写到这里我们已经实现过两种按键驱动了:第一种用字符设备接口,用户空间自己 read/poll;第二种用 poll 改进,支持阻塞等待。这两套方案都能用,代码跑起来也没问题。但每次写完我都会想一个问题:Linux 系统里这么多输入设备——键盘、鼠标、触摸屏、游戏手柄——难道每个驱动都自己搞一套接口?那应用层岂不是要为每个设备写专门的代码?

后来发现 Linux 早就为我们准备好了统一的解决方案——Input 子系统。这是处理输入设备的"正统"方式,所有输入设备都用同一套 API,应用层也用统一的方式访问。你写一个按键驱动,X11、Wayland、Qt、GTK 这些框架直接就能识别,不需要额外适配。

为什么最后才学 Input 子系统

你可能会问,既然 Input 子系统这么好,为什么不一开始就讲?说实话,这是一个循序渐进的考虑。Input 子系统抽象程度高,直接上手的话你很难理解它到底在干什么。先学字符设备,你会理解设备节点的概念、中断处理、工作队列这些基础;再学 poll,你知道应用层怎么等待事件;最后学 Input 子系统,你才能 appreciate 这种抽象带来的便利。

就像学编程,先学底层原理,再学上层框架。Input 子系统是框架,但如果你不懂底层原理,用框架就会变成"调包侠",遇到问题不知道怎么排查。

Input 子系统到底解决了什么问题

我们之前自己写的字符设备驱动,本质上是一个"私有协议"。设备节点 /dev/beep 是我们自定义的,read() 返回的数据格式也是我们自己定义的。这样的驱动有几个明显的问题:

第一个问题是应用层兼容性差。你想在 Qt 应用里用这个按键?对不起,Qt 不认识你自定义的设备节点。你想在浏览器里捕获按键事件?浏览器只支持标准输入设备。你得为每个应用写适配层,或者自己写一个中间层把自定义事件转换成标准事件。说实话,这种重复造轮子的事情真的很没意思。

第二个问题是设备节点管理麻烦。每次加载驱动都要记得创建设备节点,忘记这步的话用户空间根本访问不了。虽然有 udev 规则可以自动创建,但你得维护额外的配置文件。Input 子系统会自动在 /dev/input/eventX 创建设备节点,不需要任何额外配置。

第三个问题是事件格式不统一。我们的驱动返回的是简单的结构体,但标准输入设备支持的事件类型多得多——按键、相对坐标、绝对坐标、LED 状态、自动重复等等。如果以后要扩展功能,自己实现的协议就要不断改,而 Input 子系统早就定义好了标准格式。

Input 子系统的分层架构

Input 子系统采用了经典的分层设计,理解这个架构很重要,不然你写驱动的时候会不知道该调用哪个接口:

┌─────────────────────────────────────────┐
│  用户空间 (X11/Wayland/Qt/应用程序)     │
└───────────────────┬─────────────────────┘
                    │ /dev/input/eventX
┌───────────────────▼─────────────────────┐
│         Input Core (input.c)            │
│  - 事件分发                            │
│  - Handler 管理                        │
└───────────────────┬─────────────────────┘
         ┌───────────┴───────────┐
         │                       │
┌────────▼────────┐    ┌────────▼─────────┐
│  Evdev Handler  │    │  其他 Handler     │
│  (evdev.c)      │    │  (kbd/mouse/...)  │
└─────────────────┘    └──────────────────┘

┌─────────┴───────────┐
│  Input 驱动层        │
│  (我们的驱动)        │
└─────────────────────┘

最底层是驱动层,也就是我们要写的代码。驱动层的工作很简单:检测硬件变化(比如按键按下),然后调用 Input 子系统提供的事件报告函数。

中间是 Input Core,它负责事件分发。你调用 input_event() 报告一个事件,Input Core 会把这个事件分发给所有注册的 Handler。

最上层是各种 Handler,它们把事件传递给用户空间。最常见的 Handler 是 evdev,它创建 /dev/input/eventX 设备节点,用户空间通过 read() 就能读到事件。还有其他 Handler 比如 kbd(把键盘事件转发到控制台)、mouse(处理鼠标事件)、joystick(游戏手柄)等。

TIP

驱动只需要调用 input_event() 报告事件,剩下的分发、用户空间接口、设备管理都由子系统处理。这就是分层的好处,每层只做自己的事情。

Input 子系统驱动的核心结构

我们来看一下 Input 子系统驱动的核心数据结构。首先是 input_dev,它代表一个输入设备:

c
struct input_dev {
    const char *name;           // 设备名称
    const char *phys;           // 物理路径
    struct input_id id;         // 设备 ID

    unsigned long evbit[NBITS(EV_MAX)];    // 支持的事件类型
    unsigned long keybit[NBITS(KEY_MAX)];  // 支持的按键代码
    /* ... 更多能力位图 ... */
};

input_dev 包含设备的各种信息:名字、物理路径、设备 ID,最重要的就是能力位图(bit field)。通过这些位图,驱动声明这个设备支持哪些事件类型、哪些按键代码。比如我们的按键驱动就设置 EV_KEYKEY_ENTER 这两个位。

然后是驱动自己的设备结构体:

c
struct input_key_dev {
    struct gpio_desc *gpio;             // GPIO 描述符
    struct input_dev *input_dev;        // Input 设备
    struct delayed_work debounce_work;  // 消抖延时工作
    spinlock_t lock;                    // 保护并发访问
    int last_state;                     // 上次报告的状态
};

和之前的字符设备驱动相比,这里没有 cdev、没有 class、没有设备节点管理。Input 子系统把这些都接管了,我们只需要关注硬件交互和事件报告。

工作流程:从按键到事件

整个流程其实很直观。用户按下按键,GPIO 电平变化,触发中断。中断处理函数里我们不直接报告事件,而是调度一个延时工作来消抖。延时工作到期后,我们读取 GPIO 状态,如果状态确实改变了,就调用 Input 子系统的函数报告事件:

c
/* 报告按键按下 */
input_report_key(dev->input_dev, KEY_ENTER, 1);
input_sync(dev->input_dev);

/* 报告按键松开 */
input_report_key(dev->input_dev, KEY_ENTER, 0);
input_sync(dev->input_dev);

input_report_key() 是一个便利宏,它会调用底层的 input_event()input_sync() 告诉子系统"这一批事件结束",确保事件原子地传递给用户空间。

INFO

input_sync() 的重要性:如果你报告多个相关事件(比如按键的按下 + 自定义扫描码),必须用 input_sync() 标记同步点,确保用户空间收到完整的一批事件。

Input 子系统 vs 字符设备:直观对比

特性字符设备驱动Input 子系统
设备节点自定义 /dev/beep标准 /dev/input/eventX
用户空间支持需要自己写应用X11、Qt、GTK 直接支持
事件格式自定义协议标准 input_event 结构
设备节点创建手动 mknod 或 udev自动创建
多按键支持需要自己扩展天然支持
消抖处理自己实现可用子系统机制

设备树配置

Input 子系统驱动的设备树配置和之前的平台驱动类似,但有一些标准属性:

c
imxaes_key_input: key-input {
    compatible = "imxaes-input-key";
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_key>;
    gpios = <&gpio1 18 GPIO_ACTIVE_LOW>;
    label = "imxaes-key";
    linux,code = <28>;      /* KEY_ENTER */
    linux,input-type = <1>;  /* EV_KEY = 1 */
    status = "okay";
};

linux,codelinux,input-type 是通用的 GPIO 按键绑定的标准属性。虽然我们在驱动里硬编码了 KEY_ENTER,但设备树也可以指定按键代码,这样同一个驱动可以支持不同的按键映射。

接下来的内容

这个教程会按顺序讲解以下内容:

  1. Input 子系统架构 - 深入理解 Input Core 和 Handler 的工作机制
  2. 事件报告 - input_report_key()input_sync() 的使用
  3. 延时消抖 - 用 delayed_work 实现可重新调度的消抖
  4. 用户空间集成 - 应用程序开发
  5. 编译和测试 - 完整的构建流程和验证方法

学完这个教程,你就掌握了"正统"的 Linux 按键驱动写法。以后遇到任何输入设备需求,不管是键盘、触摸屏还是游戏手柄,你都知道该怎么做。


相关文档

下一步: 继续阅读 02_input_architecture.md 深入了解 Input 子系统的架构设计。

Built with VitePress