为测试目的伪造输入设备

Faking an input device for testing purpose

我想做什么

我正在编写一个守护进程,它监听输入设备的按键并通过 D-Bus 发送信号。主要目标是通过请求更改或通知更改来管理音量和屏幕背光级别。 我使用 libevdev 来处理输入设备事件。

我编写了一个用于打开位于指定路径的输入设备的函数:

Device device_open(const char *path);

该函数运行良好,但在我为其编写单元测试时,我想创建具有不同属性(文件的存在性、读取权限等)的文件固定装置以检查我的函数的错误处理和内存管理(因为我将数据存储在结构中)。

我已经做了什么

但使用真实输入设备(位于 /dev/input/event*)进行测试需要 root 访问权限。为每个人设置 /dev/input/event* 文件的读取权限可行,但对我来说似乎有风险。以 root 身份执行我的测试更糟糕!

使用 mknod 创建设备可行,但需要以 root 身份完成。

我还尝试使用字符特殊文件(因为输入设备是其中之一)允许所有人阅读(如 /dev/random、/dev/zero、/dev/null 甚至终端设备我目前正在使用:/dev/tty2).

但这些设备无法处理 libevdev 所需的 ioctl 请求:EVIOCGBIT 是第一个返回错误的请求 "Inappropriate ioctl for device"。

我在找什么

我希望能够以普通用户(执行单元测试的用户)身份创建设备文件。然后,通过设置访问权限,我应该能够针对不同类型的文件(只读、不允许读取、错误的设备类型等)测试我的功能行为。 如果看起来不可能,我肯定会使用私人助手重构我的功能。但是怎么做呢。有什么例子吗?

谢谢。

编辑:我试图更好地表达我的需求。

为允许访问设备的用户创建一个组,并创建一个 udev 规则以将该输入事件设备的所有权设置为该组。


我用teensy(系统)组:

sudo groupadd -r teensy

并使用例如

将每个用户添加到其中
sudo usermod -a -g teensy my-user-name

或任何可用的图形用户界面。

通过管理哪些用户和服务守护进程属于 teensy 组,您可以轻松管理对设备的访问。


对于我的 Teensy 微控制器(具有原生 USB,我用于 HID 测试),我有以下 /lib/udev/rules.d/49-teensy.rules

ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789A]?", ENV{MTP_NO_PROBE}="1"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789ABCD]?", GROUP:="teensy", MODE:="0660"
KERNEL=="ttyACM*", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", GROUP:="teensy", MODE:="0660"

不过,对于 HID 设备,您只需要第三行(SUBSYSTEMS=="usb", 一行)。确保 idVendoridProduct 与您的 USB HID 设备匹配。您可以使用 lsusb 列出当前连接的 USB 设备供应商和产品编号。匹配使用 glob 模式,就像文件名一样。

添加以上后,别忘了运行宁sudo udevadm control --reload-rules && sudo udevadm trigger重新加载规则。下次插入 USB HID 设备时,您组中的所有成员(上面的teensy)都可以直接访问它。


请注意,默认情况下,在大多数发行版中,udev 还会使用 USB 设备类型和序列号在 /dev/input/by-id/ 中创建永久符号链接。在我的例子中,我的一个 Teensy LC(序列号 4298820)具有组合的键盘鼠标操纵杆设备,为键盘事件设备提供 /dev/input/by-id/usb-Teensyduino_Keyboard_Mouse_Joystick_4298820-event-kbd,为鼠标事件设备提供 /dev/input/by-id/usb-Teensyduino_Keyboard_Mouse_Joystick_4298820-if01-event-mouse,以及 /dev/input/by-id/usb-Teensyduino_Keyboard_Mouse_Joystick_4298820-if03-event-joystick/dev/input/by-id/usb-Teensyduino_Keyboard_Mouse_Joystick_4298820-if04-event-joystick 用于两个操纵杆接口。

("persistent",我的意思并不是说这些符号链接总是存在;我的意思是只要插入那个特定的设备,那个名字的符号链接就存在,并指向实际的 Linux 输入事件字符设备。)


Linux uinput 设备可用于使用简单的特权守护程序实现虚拟输入事件设备。

创建新的虚拟 USB 输入事件设备的过程如下。

  1. 打开/dev/uinput写入(或读写):

    fd = open("/dev/uinput", O_RDWR);
    if (fd == -1) {
        fprintf(stderr, "Cannot open /dev/uinput: %s.\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    

    以上需要超级用户权限。但是,在打开设备后,您可以立即放弃所有权限,并将您的 daemon/service 运行 作为专用用户。

  2. 对每个允许的事件类型使用 UI_SET_EVBIT ioctl。

    您至少要允许 EV_SYNEV_KEY 用于键盘和鼠标按钮,EV_REL 用于鼠标移动,等等。

    if (ioctl(fd, UI_SET_EVBIT, EV_SYN) == -1 ||
        ioctl(fd, UI_SET_EVBIT, EV_KEY) == -1 ||
        ioctl(fd, UI_SET_EVBIT, EV_REL) == -1) {
        fprintf(stderr, "Uinput event types not allowed: %s.\n", strerror(errno));
        close(fd);
        exit(EXIT_FAILURE);
    }
    

    我个人在代码中使用静态常量数组,以便于管理。

  3. 对设备可能发出的每个键代码使用 UI_SET_KEYBIT ioctl,对每个相对移动代码(鼠标代码)使用 UI_SET_RELBIT ioctl。例如,要允许 space、鼠标左键、水平和垂直鼠标移动以及鼠标滚轮:

    if (ioctl(fd, UI_SET_KEYBIT, KEY_SPACE) == -1 ||
        ioctl(fd, UI_SET_KEYBIT, BTN_LEFT) == -1 ||
        ioctl(fd, UI_SET_RELBIT, REL_X) == -1 ||
        ioctl(fd, UI_SET_RELBIT, REL_Y) == -1 ||
        ioctl(fd, UI_SET_RELBIT, REL_WHEEL) == -1) {
        fprintf(stderr, "Uinput event types not allowed: %s.\n", strerror(errno));
        close(fd);
        exit(EXIT_FAILURE);
    }
    

    同样,静态常量数组(一个用于 UI_SET_KEYBIT,一个用于 UI_SET_RELBIT 代码)更易于维护。

  4. 定义一个struct uinput_user_dev,写入设备

    如果 name 包含设备名称字符串,vendorproduct 包含 USB 供应商和产品 ID 号,version 包含版本号 (0很好),使用

    struct uinput_user_dev  dev;
    
    memset(&dev, 0, sizeof dev);
    strncpy(dev.name, name, UINPUT_MAX_NAME_SIZE);
    dev.id.bustype = BUS_USB;
    dev.id.vendor = vendor;
    dev.id.product = product;
    dev.id.version = version;
    
    if (write(fd, &dev, sizeof dev) != sizeof dev) {
        fprintf(stderr, "Cannot write an uinput device description: %s.\n", strerror(errno));
        close(fd);
        exit(EXIT_FAILURE);
    }
    

    后来的内核有一个 ioctl 来做同样的事情(显然参与 systemd 开发会导致这种消耗);

    struct uinput_setup  dev;
    
    memset(&dev, 0, sizeof dev);
    strncpy(dev.name, name, UINPUT_MAX_NAME_SIZE);
    dev.id.bustype = BUS_USB;
    dev.id.vendor = vendor;
    dev.id.product = product;
    dev.id.version = version;
    
    if (ioctl(fd, UI_DEV_SETUP, &dev) == -1) {
        fprintf(stderr, "Cannot write an uinput device description: %s.\n", strerror(errno));
        close(fd);
        exit(EXIT_FAILURE);
    }
    

    思路好像是可以不用前者,先试试后者,如果不行就改用前者。您知道,因为某一天单个界面可能还不够。 (无论如何,documentation and commit 就是这么说的。)

    我在这里听起来可能有点古怪,但这只是因为我确实赞成这两种 Unix philosophy and the KISS principle (or minimalist 方法),并且认为这样的缺点完全没有必要。而且往往来自同一个松散相关的开发人员组。咳咳。无意侮辱人身;我只是觉得他们做得不好。

  5. 创建虚拟设备,通过发出 UI_DEV_CREATE ioctl:

    if (ioctl(fd, UI_DEV_CREATE) == -1) {
        fprintf(stderr, "Cannot create the virtual uinput device: %s.\n", strerror(errno));
        close(fd);
        exit(EXIT_FAILURE);
    }
    

    此时,内核将构造设备,提供相应的事件给udev守护进程,udev守护进程将根据其配置构造设备节点和符号链接。所有这些都需要一点时间——在现实世界中只需几分之一秒,但足以立即发出事件可能会导致其中一些事件丢失。

  6. 通过写入 uinput 设备发出输入事件 (struct input_event)。

    您可以一次写入一个或多个 struct input_event,并且永远不会看到短写入(除非您尝试编写部分事件结构)。部分事件结构被完全忽略。 (有关内核如何处理此类写入,请参阅 drivers/input/misc/uinput.c:uinput_write() uinput_inject_events()。)

    许多动作由不止一个 struct input_event 组成。例如,您可以沿对角线移动鼠标(为该单个移动发出 { .type == EV_REL, .code == REL_X, .value = xdelta }{ .type == EV_REL, .code == REL_Y, .value = ydelta })。同步事件({ .type == EV_SYN, .code == 0, .value == 0 })用作标记或分隔符,表示相关事件的结束。

    因此,您需要在每个单独的操作(鼠标移动、按键按下、按键释放等)后附加一个 { .type == EV_SYN, .code == 0, .value == 0 } 输入事件。将其视为换行符,用于行缓冲输入。

    例如,以下代码将鼠标沿对角线向右向下移动一个像素。

    struct input_event  event[3];
    memset(event, 0, sizeof event);
    
    event[0].type  = EV_REL;
    event[0].code  = REL_X;
    event[0].value = +1; /* Right */
    
    event[1].type  = EV_REL;
    event[1].code  = REL_Y;
    event[1].value = +1; /* Down */
    
    event[2].type  = EV_SYN;
    event[2].code  = 0;
    event[2].value = 0;
    
    if (write(fd, event, sizeof event) != sizeof event)
        fprintf(stderr, "Failed to inject mouse movement event.\n");
    

    失败案例不是致命的;这仅意味着事件未被注入(尽管我看不出在当前内核中会发生这种情况;最好采取防御措施,以防万一)。您可以简单地再次重试相同的操作,或忽略失败(但让用户知道,以便他们可以调查,如果它曾经发生过)。所以记录它或输出警告,但不需要它导致 daemon/service 退出。

  7. 销毁设备:

    ioctl(fd, UI_DEV_DESTROY);
    close(fd);
    

    当原始打开的描述符的最后一个副本关闭时,设备确实会自动销毁,但我建议像上面那样明确地这样做。

将步骤 1-5 放入一个函数中,您会得到类似于

#define  _POSIX_C_SOURCE 200809L
#define  _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <linux/uinput.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>

static const unsigned int  allow_event_type[] = {
    EV_KEY,
    EV_SYN,
    EV_REL,
};
#define  ALLOWED_EVENT_TYPES  (sizeof allow_event_type / sizeof allow_event_type[0])

static const unsigned int  allow_key_code[] = {
    KEY_SPACE,
    BTN_LEFT,
    BTN_MIDDLE,
    BTN_RIGHT,
};
#define  ALLOWED_KEY_CODES  (sizeof allow_key_code / sizeof allow_key_code[0])

static const unsigned int  allow_rel_code[] = {
    REL_X,
    REL_Y,
    REL_WHEEL,
};
#define  ALLOWED_REL_CODES  (sizeof allow_rel_code / sizeof allow_rel_code[0])

static int uinput_open(const char *name, const unsigned int vendor, const unsigned int product, const unsigned int version)
{
    struct uinput_user_dev  dev;
    int                     fd;
    size_t                  i;

    if (!name || strlen(name) < 1 || strlen(name) >= UINPUT_MAX_NAME_SIZE) {
        errno = EINVAL;
        return -1;
    }

    fd = open("/dev/uinput", O_RDWR);
    if (fd == -1)
        return -1;

    memset(&dev, 0, sizeof dev);
    strncpy(dev.name, name, UINPUT_MAX_NAME_SIZE);
    dev.id.bustype = BUS_USB;
    dev.id.vendor  = vendor;
    dev.id.product = product;
    dev.id.version = version;

    do {

        for (i = 0; i < ALLOWED_EVENT_TYPES; i++)
            if (ioctl(fd, UI_SET_EVBIT, allow_event_type[i]) == -1)
                break;
        if (i < ALLOWED_EVENT_TYPES)
            break;

        for (i = 0; i < ALLOWED_KEY_CODES; i++)
            if (ioctl(fd, UI_SET_KEYBIT, allow_key_code[i]) == -1)
                break;
        if (i < ALLOWED_KEY_CODES)
            break;

        for (i = 0; i < ALLOWED_REL_CODES; i++)
            if (ioctl(fd, UI_SET_RELBIT, allow_rel_code[i]) == -1)
                break;
        if (i < ALLOWED_REL_CODES)
            break;

        if (write(fd, &dev, sizeof dev) != sizeof dev)
            break;

        if (ioctl(fd, UI_DEV_CREATE) == -1)
            break;

        /* Success. */
        return fd;

    } while (0);

    /* FAILED: */
    {
        const int saved_errno = errno;
        close(fd);
        errno = saved_errno;
        return -1;
    }
}

static void uinput_close(const int fd)
{
    ioctl(fd, UI_DEV_DESTROY);
    close(fd);
}

这似乎工作正常,并且不需要任何库(标准 C 库除外)。

重要的是要意识到 Linux 输入子系统,包括 uinput 和 struct input_event,是 二进制接口 到 Linux 内核,因此将保持向后兼容(除了紧迫的技术原因,如安全问题或与内核其他部分的严重冲突)。 (将所有内容都包裹在 freedesktop.org 或 systemd 保护伞下的愿望不是一个。)