GPIO 也是一个接口,还有 QEMU GPIODEV 和 GUSE
大家好!我是大聪明-PLUS!
曾经有人问我:“我该如何与它交互?” 这个问题主要涉及 QEMU 的 I2C 接口,而不是 GPIO。有一段时间,我痴迷于在 QEMU 中与设备进行“透明”交互的想法——使用与真实设备相同的库和工具。还有什么更好的选择呢?不是一些通过 QEMU 发送命令的脚本,而是 libgpiod 库中熟悉且一致的 gpioset/gpioget 或内核提供的 tools/gpio 工具。
我成功了吗?是的,但代价是什么呢……
QEMU GPIODEV:简介
少量的熵并不是什么坏事。我们认为,QMP 总比没有好。但我们想要更多——一个库或程序(最好是已经构建好的),能够通过 poll() / pselect() / select() / epoll() / read() 进行读写和检测状态变化。
在这种情况下,每个 GPIO 模型都需要一个类似于 chardev 使用的“胶水”——我们将其直接包含在修改后的 QEMU 中。这个“胶水”的明显名称是 gpiodev。以下是它的主要功能,现在几乎完全对应于 Linux 中的 GPIO UAPI:
报告线路数量、配置、每条线路的名称和消费者,
读取并设置线路状态,
跟踪线路状态和配置的变化(输入/输出、请求/释放)。
“粘合剂”由两组组成,第一组是 gpiodev 用于请求特定信息的 GPIO 模块特定函数:
LineInfoHandler() — 有关线路的信息:名称、标志和消费者,
LineGetValueHandler() — 线路状态:条件 0 或 1,
LineSetValueHandler() — 设置线路状态:0 或 1。
与 GPIO UAPI 类似,用于查询和设置线路的 LineGetMultiValueHandler() 和 LineSetMultiValueHandler() 函数也浮现在脑海中,但我决定将自己限制在最小设置内。
这些请求被分组到一个结构中,每个芯片使用该结构与 gpiodev 进行通信:
/* qemu/include/gpiodev/gpio-fe.h */
struct GpioBackend {Gpiodev *gpio;LineInfoHandler *line_info;LineGetValueHandler *get_value;LineSetValueHandler *set_value;void *opaque;
};
第二组由 GPIO 模块用来通知 gpiodev 状态变化的函数组成。通常,状态可以由模拟处理器或其他 QEMU 模块(例如“外部” I2C 传感器)更改:
qemu_gpio_fe_line_event() — 线路状态发生改变,
qemu_gpio_fe_config_event() — 该线路改变了其配置。
第一个 gpiodev 原型直接使用了来自uapi/linux/gpio.h的结构,但当我意识到 gpiodev 的使用可以扩展到本地机器之外时,我后来放弃了它们。
要使用 gpiodev,每个芯片必须初始化其接口:
bool qemu_gpio_fe_init(GpioBackend *b, Gpiodev *s, uint32_t nlines,const char *name, const char *label,Error **errp);
芯片报告其名称、行数,并建立其自身与特定-gpiodev之间的对应关系。
接下来,您需要注册我们之前提到的请求处理程序函数:
void qemu_gpio_fe_set_handlers(GpioBackend *b,LineInfoHandler *line_info,LineGetValueHandler *get_value,LineSetValueHandler *set_value,void *opaque);
GPIODEV 图
这对于我们的目的来说已经足够了。我们将在文章末尾介绍 ASPEED 的一个用例,因此我们接下来继续介绍几个外部接口选项。
项目组成部分:
qemu v10.0.0:
MMIO/PCI GPIO 模型,
带有 MMIO GPIO 和附加 dtb 生成的 RISC-V 虚拟机,
gpiodev 带有后端 CHARDEV、CUSE、GUSE,
buildroot 2025.02.2 vanilla(对应于上一次迭代),
qemu-gpio-tools — chardev 所需,
libgpiod — 针对 CUSE 和 GUSE 的修改,
libfuse - 修改以支持 GUSE,
Linux v6.12:
dtb注射贴剂,
QEMU MMIO/PCIE GPIO 驱动程序,
GUSE模块。
最少的组装
以 ASPEED ast2600-evb 为例,它是一款单板计算机 (SBC),暂时是 QEMU 中仿真最全面、外设最丰富的机器(我向你保证,请继续关注我们的博客)。ASPEED ast2600-evb 还实现了 I2C 从属模式仿真。
我附加了ASPEED 的工件,因为示例只需要编译 QEMU:
$ git clone -b nshubin/qemu-gpiodev https://gitflic.ru/project/maquefel/qemu-gpio-playground
$ git submodule update --init --depth 1 -- qemu
$ make .build-qemu
$ wget https://gitflic.ru/project/maquefel/qemu-gpio-playground/release/f34da376-f208-4b0b-943c-0d183f038da8/68d857b3-f61b-482e-b9e4-70e7cb551ea4/download -O initramfs.cpio.xz
$ wget https://gitflic.ru/project/maquefel/qemu-gpio-playground/release/f34da376-f208-4b0b-943c-0d183f038da8/acd48054-e894-40c4-a351-bafb447353bb/download -O zImage
$ wget https://gitflic.ru/project/maquefel/qemu-gpio-playground/release/f34da376-f208-4b0b-943c-0d183f038da8/8c4d2cec-12c9-4cdb-ba41-cf564abced95/download -O aspeed-ast2600-evb.dtb
通用发射线路:
host $ build-qemu/qemu-system-arm -M ast2600-evb,bmc-console=uart5 \-kernel buildroot_aspeed/build/linux-6.12.17/arch/arm/boot/zImage \-dtb ./buildroot_aspeed/images/aspeed-ast2600-evb.dtb \-initrd buildroot_aspeed/images/rootfs.cpio.xz \-nographic -serial mon:stdio
用于连接到 gpiodev 的附加命令集取决于您使用的外部接口。
我先从示例开始,然后再进行解释。让我们启动 ASPEED ast2600-evb 仿真:
列出 aspeed-gpio0 的输入/输出:
host $ qemu-gpio-tools/lsgpio -n /tmp/gpio0
sending 0x8044b401
GPIO chip: aspeed-gpio0, "ASPEED GPIO", 208 GPIO linesline 0: "gpioA0" unused [input]
[...]line 207: "gpioZ7" unused [input]
我们检查主机触发时中断和输入状态变化是否得到处理:
host $ ./gpio-hammer -n /tmp/gpio0 -o 8qemu # gpiomon -c 0 8
gpio_reg_direction: 0x0 0x036.791326811 rising gpiochip0 8
37.679910405 falling gpiochip0 8
38.568927503 rising gpiochip0 8
39.457922388 falling gpiochip0 8
40.346842481 rising gpiochip0 8
[...]
现在我们检查在客户系统中发起的线路状态变化是否在主机上被跟踪:
qemu # gpioset -c 0 8=0
^C
qemu # gpioset -c 0 8=1
^Chost $ ./gpio-event-mon -n /tmp/gpio0 -o 8
No flags specified, listening on both rising and falling edges
Monitoring line 8 on /tmp/gpio0
Initial line value: 0
GPIO EVENT at 572196930648 on line 8 (0|0) falling edge
GPIO EVENT at 574302333416 on line 8 (0|0) rising edge
Gpiodev-chardev 是 -chardev 的包装器,这意味着理论上它可以在 -chardev 支持的任何传输协议上工作:stdio、serial、pipe、pty、socket 等等。理论上,这是因为 -chardev 套接字需要 qemu-gpio-tools,而这个工具是我在 UNIX 套接字上实现的(它们只是 Linux 中 tools/gpio 套件中重写的实用程序)。当然,这并不是 socat 无法克服的限制,但它仍然是一个限制。
CHARDEV 图
这是这种方法的优点也是缺点:一方面,它需要专门的实用程序才能工作,但另一方面,我们不仅限于本地使用。
保险丝
“透明”交互的问题在于所有 i2c-dev、gpiochip、spidev 等都需要 ioctl() 才能运行,因此所有库和工具都依赖于它的使用。
虽然读写操作相当简单,但我不知道如何调用 ioctl()——只有设备或特殊的内核文件才有这个功能。所以,无论哪种情况,我们都需要内核的帮助。
此时 FUSE 就派上用场了,或者更确切地说,它的部分称为 CUSE(用户空间字符设备库)。
像往常一样,我先从例子开始。让我们在 QEMU 中运行 ASPEED ast2600-evb 开发板的仿真,并通过 CUSE 连接 gpiodev:
列出 aspeed-gpio0 的输入/输出:
host $ sudo libgpiod/tools/gpioinfo -c 10line 0: "gpioA0" input[...]line 207: "gpioZ7" input
检查来宾的中断:
host $ sudo libgpiod/tools/gpioset -t 0 -c 10 8=1 9=1 10=1
host $ sudo libgpiod/tools/gpioset -t 0 -c 10 8=0 9=0 10=0qemu # gpiomon -c 0 8 9 10
69.422108579 rising gpiochip0 9
69.422015591 rising gpiochip0 8
69.422173796 rising gpiochip0 10
124.508747142 falling gpiochip0 9
124.508841782 falling gpiochip0 10
124.508572457 falling gpiochip0 8
检查主机的中断:
qemu # gpioset -t 0 -c 0 8=1 9=1 10=1
qemu # gpioset -t 0 -c 0 8=0 9=0 10=0host $ sudo libgpiod/tools/gpiomon -c 10 8 9 10
1749204303.043403870 rising aspeed-gpio0 8
1749204303.043546291 rising aspeed-gpio0 9
1749204303.043650331 rising aspeed-gpio0 10
1749204308.437501487 falling aspeed-gpio0 8
1749204308.437650077 falling aspeed-gpio0 9
1749204308.437757098 falling aspeed-gpio0 10
注意sudo的使用:我们需要访问 /dev/cuse,然后访问 /dev/gpiochip10。访问可以通过 udev-rules 或容器化来实现。代码可以在 qemu/gpiodev/gpio-cuse.c 中找到——在我看来,除了使用 UAPI GPIO 之外,它非常简单。此外,我们没有使用 CUSE_UNRESTRICTED_IOCTL,这会使 ioctl() 变得非常复杂,使其变成一个两阶段甚至三阶段的过程。总的来说,它看起来像这样:
CUSE 图
让我们继续进行总结,并从 libgpiod 所需的编辑开始:
libgpiod 是一个相当偏执的库,所以我不得不“说服”它在 /sys/class/cuse 中的非标准路径中看到 gpiochip,以及标准 /sys/bus/gpio,
强制它重用相同的文件描述符来请求行,因为 CUSE 没有创建新文件描述符的机制。
最有趣的是,这是迄今为止在我们公司扎根的版本。这可能是因为单芯片无需支持多个客户端,Python 随时可用,而且 CUSE 支持已经存在很长时间,并且存在于所有发行版中。
我们需要修改 libgpiod 中的分支,构建 libfuse,并构建主机内核模块。我不建议这样做,因为内核模块目前处于 PoC 级别——除非你真的想这么做,并且只能在单独的 QEMU 机器上进行。
我在一个单独的分支上构建了 GUSE。libfuse和 guse 需要在本地构建,因为前者用于 QEMU,而后者加载到主机内核中:
# QEMU нужно пересобрать
host $ rm -rf build-qemu && make .build-qemu
# libgpiod нужно пересобрать
host $ make -C libgpiod clean && make .build-libgpiod
# Модуль собираем под текущее ядро
host $ make -C guse
# Или под конкретный линукс, который будем запускать внутри QEMU
host $ make guse/guse.ko
# В любом случае загружаем модуль в ядро (или текущее, а еще лучше внутри QEMU)
host/guest $ sudo insmod guse.ko
host/guest $ sudo build-qemu/qemu-system-arm -M ast2600-evb,bmc-console=uart5 \
-kernel buildroot_aspeed/build/linux-6.12.17/arch/arm/boot/zImage \
-dtb ./buildroot_aspeed/images/aspeed-ast2600-evb.dtb \
-initrd buildroot_aspeed/images/rootfs.cpio.xz \
-nographic -serial mon:stdio \
-gpiodev guse,id=aspeed-gpio0,devname=gpiochip10 \
-d guest_errors
我不会提供使用示例,因为它们与 CUSE 类似。
GUSE 图
一如往常,魔鬼藏在细节中。
我先说个好消息:libgpiod 只需稍作修改就能“说服”它查看 /sys/class/guse。我已经提到过,libgpiod 是一个偏执的库。问题是,真实设备和 libgpiod 模拟的设备之间几乎没有区别。
坏消息是我必须:
创建一个单独的 GUSE 内核模块,该模块可以正确处理GPIO_V2_GET_LINE_IOCTL并返回与请求的线路关联的新文件描述符,
为 libfuse 添加 guse 支持。
由于某些 FUSE 功能不适用于内核模块,所以结果很奇怪——我们无法在 FUSE 中创建新文件,因此我不得不向 inode 添加一个标志来区分 gpiochip 的文件描述符和为 GPIO_V2_GET_LINE_IOCTL 创建的文件描述符。
然而,这对我来说几乎从一开始就很明显,但我仍然决定看看 GUSE 的或多或少可运行的原型。
针对虚拟 MMIO/PCI GPIO 的修改
让我们从注册和初始化开始。由于 MMIO 和 PCI 模型都有一个通用组件,因此它们的初始化也相同:
static void gpio_realize(DeviceState *dev, Error **errp)
{GpioState *s = GPIO(dev);Object *backend;Gpiodev *gpio;if (dev->id) {backend = object_resolve_path_type(dev->id, TYPE_GPIODEV, NULL);if (backend) {gpio = GPIODEV(backend);qemu_gpio_fe_init(&s->gpiodev, gpio, s->nr_lines, dev->id,"VIRTUAL MMIO GPIO", NULL);qemu_gpio_fe_set_handlers(&s->gpiodev, mmio_gpio_line_info,mmio_gpio_get_line,mmio_gpio_set_line, s);}}
}
我们只是传达行数、名称以及与模型交互的方式。
一个相当简单的线路状态查询(不逐一命名),其中,根据 DIR_OUT 寄存器的状态,我们报告该线路是输入还是输出:
static void mmio_gpio_line_info(void *opaque, gpio_line_info *info)
{uint32_t offset = info->offset;GpioState *s = GPIO(opaque);if (test_bit32(offset, &s->regs[R_GPIO_QEMU_DIR_OUT])) {info->flags |= GPIO_LINE_FLAG_OUTPUT;} else {info->flags |= GPIO_LINE_FLAG_INPUT;}
}
查询线路状态的函数:
static int mmio_gpio_get_line(void *opaque, uint32_t offset)
{GpioState *s = GPIO(opaque);return test_bit32(offset, &s->regs[R_GPIO_QEMU_DATA]);
}
此函数设置状态 - 我们只需重用之前为 QMP 创建的方法:
static int mmio_gpio_set_line(void *opaque, uint32_t offset, uint8_t value)
{GpioState *s = GPIO(opaque);mmio_gpio_set_pin(s, offset, value);return 0;
}
我们不能只在 DATA 寄存器中设置一个位 - 我们可能还需要通过中断来发出信号。
如果上述函数是在 QEMU 外部启动的,则负责更改模型的状态,但我们还需要将状态更改从客户机发送到 QEMU。mmio_gpio_line_event() 和 mmio_gpio_config_event() 方法负责此操作。
如果输入状态从低->高或从高->低发生变化,我们总是将此报告给 gpiodev:
static uint64_t mmio_gpio_set(RegisterInfo *reg, uint64_t val)
{[...]unsigned bit = test_and_set_bit32(idx, &s->regs[R_GPIO_QEMU_DATA]);if (!bit) {
+ mmio_gpio_line_event(s, idx, GPIO_EVENT_RISING_EDGE);qemu_irq_raise(s->output[idx]);}[...]
}static uint64_t mmio_gpio_clear(RegisterInfo *reg, uint64_t val)
{[...]unsigned bit = test_and_clear_bit32(idx, &s->regs[R_GPIO_QEMU_DATA]);if (bit) {
+ mmio_gpio_line_event(s, idx, GPIO_EVENT_FALLING_EDGE);qemu_irq_lower(s->output[idx]);}[...]
}
由于有关外部客户端的信息包含在 gpiodev 中,而不是模型本身,因此应始终在 mmio_gpio_out() 中报告配置更改:
static uint64_t mmio_gpio_out(RegisterInfo *reg, uint64_t val)
{GpioState *s = GPIO(reg->opaque);uint32_t val32 = val;uint32_t changed = val ^ s->regs[R_GPIO_QEMU_DIR_OUT];unsigned idx;/* for each bit in val32 changed */idx = find_first_bit((unsigned long *)&changed, s->nr_lines);while (idx < s->nr_lines) {mmio_gpio_config_event(s, idx);idx = find_next_bit((unsigned long *)&val32, s->nr_lines, idx + 1);}/* simply apply what was set */return val;
}
我们专门为 gpiodev 添加了 mmio_gpio_out(),因为现在,与以前的版本不同,它需要区分哪一行更改了配置。
ASPEED GPIO 修改
ASPEED ast2600-evb 在每个端口的输入/输出数量方面有一个独特的特性:208 个,并带有一个共享中断。这些端口显示为单个 gpio 芯片,QEMU 中的单板仿真器的工作方式类似。因此,该模型有些复杂,提供每条线路信息的 aspeed_gpio_line_info() 函数也同样复杂:
static void aspeed_gpio_line_info(void *opaque, gpio_line_info *info)
{AspeedGPIOState *s = ASPEED_GPIO(opaque);AspeedGPIOClass *agc = ASPEED_GPIO_GET_CLASS(s);uint32_t group_idx = 0, pin_idx = 0, idx = 0;uint32_t offset = info->offset;const GPIOSetProperties *props;bool direction;const char *group;int i, set_idx, grp_idx, pin;for (i = 0; i < ASPEED_GPIO_MAX_NR_SETS; i++) {props = &agc->props[i];uint32_t skip = ~(props->input | props->output);for (int j = 0; j < ASPEED_GPIOS_PER_SET; j++) {if (skip >> j & 1) {continue;}group_idx = j / GPIOS_PER_GROUP;pin_idx = j % GPIOS_PER_GROUP;if (idx == offset) {goto found;}idx++;}}return;found:group = &props->group_label[group_idx][0];set_idx = get_set_idx(s, group, &grp_idx);snprintf(info->name, sizeof(info->name), "gpio%s%d", group, pin_idx);pin = pin_idx + group_idx * GPIOS_PER_GROUP;direction = !!(s->sets[set_idx].direction & BIT_ULL(pin));if (direction) {info->flags |= GPIO_LINE_FLAG_OUTPUT;} else {info->flags |= GPIO_LINE_FLAG_INPUT;}
}
复杂性的产生是因为该模型被设计为通用模型,考虑到了线阵列中可能存在的间隙。例如,在 ast2500 中,Y 组中的某些线无法配置为输入或输出。这正是表达式 uint32_t skip = ~(props->input | props->output); 中检查的内容。
这清楚地提示 gpiodev 添加一个单独的函数来查询一系列线路 - aspeed_gpio_lines_info() - 并在初始化期间传递有关所有线路的信息,而不是在每次请求时对它们进行迭代。
接下来,为了报告线路上的配置变化和事件,我们定义函数aspeed_gpio_line_event()和aspeed_gpio_config_event():
static void aspeed_gpio_line_event(AspeedGPIOState *s, uint32_t set_idx, uint32_t pin_idx)
{uint32_t offset = set_idx * ASPEED_GPIOS_PER_SET + pin_idx;QEMUGpioLineEvent event = GPIO_EVENT_FALLING_EDGE;if (aspeed_gpio_get_pin_level(s, set_idx, pin_idx)) {event = GPIO_EVENT_RISING_EDGE;}qemu_gpio_fe_line_event(&s->gpiodev, offset, event);
}static void aspeed_gpio_config_event(AspeedGPIOState *s, uint32_t set_idx, uint32_t pin_idx)
{uint32_t offset = set_idx * ASPEED_GPIOS_PER_SET + pin_idx;qemu_gpio_fe_config_event(&s->gpiodev, offset, GPIO_LINE_CHANGED_CONFIG);
}
这是一个简单的过程:我们在 aspeed_gpio_update() 中调用 aspeed_gpio_line_event()——但仅在状态发生变化时。当输入/输出状态发生变化时,即向 *_DIRECTION 寄存器写入数据时,我们调用 aspeed_gpio_config_event()。
对于 aspeed_gpio_get/set_line(),使用与通过 QMP 进行操作相同的函数:
static int aspeed_gpio_get_line(void *opaque, uint32_t offset)
{AspeedGPIOState *s = ASPEED_GPIO(opaque);int set_idx, pin_idx;set_idx = offset / ASPEED_GPIOS_PER_SET;pin_idx = offset % ASPEED_GPIOS_PER_SET;return aspeed_gpio_get_pin_level(s, set_idx, pin_idx);
}static int aspeed_gpio_set_line(void *opaque, uint32_t offset, uint8_t value)
{AspeedGPIOState *s = ASPEED_GPIO(opaque);int set_idx, pin_idx;set_idx = offset / ASPEED_GPIOS_PER_SET;pin_idx = offset % ASPEED_GPIOS_PER_SET;aspeed_gpio_set_pin_level(s, set_idx, pin_idx, value);return 0;
}
最后,注册和初始化,后者与虚拟 gpio 的初始化相同:
static void aspeed_gpio_realize(DeviceState *dev, Error **errp)
[...]if (d->id) {backend = object_resolve_path_type(d->id, TYPE_GPIODEV, NULL);if (backend) {gpio = GPIODEV(backend);qemu_gpio_fe_init(&s->gpiodev, gpio, agc->nr_gpio_pins, d->id,"ASPEED GPIO", NULL);qemu_gpio_fe_set_handlers(&s->gpiodev, aspeed_gpio_line_info,aspeed_gpio_get_line,aspeed_gpio_set_line, s);}}
[...]
}
结论
与 CUSE/GUSE 并行,我考虑使用 gpiosim/gpiomockup,它们用于测试 GPIO 和 libgpiod。不幸的是,它们功能相当有限:
当芯片状态改变时,configfs 没有反馈,
无法从 configfs 更改输入/输出配置,
configfs 中有很多文件,讽刺的是,它让人想起旧的 GPIO sysfs,而 GPIO UAPI 正在呼吁我们放弃它。
然而,gpiosim/gpiomockup 给了我一个想法:如果内核模块绝对必要,我至少可以创建一对“虚拟”的、处于链接状态的 gpio 芯片——一个可以在 QEMU 中使用,另一个可以在主机端使用。然而,这个解决方案也有一个明显的缺点。当所需的功能仅通过修改 QEMU 来实现时,情况是一回事;而当需要进行额外的更改时,情况则完全不同。
最终,CUSE 的表现非常出色,但并非针对 GPIO,而是针对 I2C——这才是我们真正以相对较低的成本实现标准通信的地方。它的主要应用是嵌入式软件开发部门进行的广泛的微控制器固件测试,这使我们能够在实际硬件测试之前就识别出一些错误。我们稍后会讨论完整的微控制器仿真和透明 I2C——敬请关注我们博客上的新文章。
最终,我决定,如果必须在 QEMU 之外做出一些改变,为什么不彻底地、大规模地进行呢?如果符合以下条件,chardev 选项就很有吸引力:
不要局限于 QEMU,而要创建一个适合与 GPIO 一起工作的协议 - 例如,对于 MOXA ioLogik,
使其不仅适用于大型系统,也适用于嵌入式系统,
以描述、库以及 Python 和其他语言的绑定的形式呈现它,以便将其集成到更大的系统中。
我还没有找到现成的解决方案,所以如果您在评论中分享您的想法,我会很高兴。