Тема этой статьи преследует меня, как статуя командора из известной сказки. Почти десять лет назад я сделал возможность чтения и записи GPIO для виртуальной машины QEMU. GPIO был нужен для тестирования алгоритмов контроллера взвешивания в движении (Weigh In Motion, WIM). С тех пор проект получил некоторое количество упоминаний, а я — несколько писем. И вот к десятилетнему юбилею я решил поставить точку в этой работе.
Меня зовут Никита Шубин, я ведущий инженер по разработке СнК в YADRO. Моя первая реализация GPIO основывалась на QEMU ivshmem и представляла собой просто область памяти, разделяемой между машиной и пользовательским пространством. Эту работу я подробно описал в статье «Драйвер виртуальных GPIO с контроллером прерываний на базе QEMU ivshmem для Linux». Ее недостатки были очевидны с самого начала:
-
использование ivshmem в качестве базы накладывало дополнительные требования,
-
инструментарий для взаимодействия с GPIO внутри QEMU носил скорее демонстрационный, чем практический характер,
-
возможность использования была только на машинах с PCI-шиной.
Несмотря на недостатки, я периодически обновлял проект для работы с последними версиями ядра Linux и QEMU. Наконец, я решил завершить эту долгую историю, о чем расскажу в серии статей.
Новое устройство в QEMU
Для начала я решил отказаться от ivshmem, ведь он изначально не предназначен для этой цели. В свое время я пошел по пути наименьшего сопротивления и не стал модифицировать QEMU — десять лет назад я просто не так хорошо его знал. ivshmem требует наличия:
-
«сервера», запускаемого отдельно от QEMU,
-
шины PCI в запускаемой машине.
Кроме того, поскольку ivshmem — это всего лишь разделяемая между QEMU и хостом память, то для симуляции GPIO мы фактически перекладываем всю структуру на клиентское приложение и не можем контролировать корректность доступа внутри.
Поэтому я пришел к выводу, что потребуется специализированное устройство, причем в двух исполнениях: MMIO и PCI. Чтобы максимально минимизировать код и упростить работу как драйвера, так и модели, у устройства есть следующие регистры:
|
Имя регистра |
Смещение |
Свойства |
Описание |
|
DATA |
0x00 |
RO |
Текущее состояние входов/выходов |
|
SET |
0x04 |
W1S |
Задать выход как 1 |
|
CLEAR |
0x08 |
W1C |
Сбросить выход к 0 |
|
DIR_OUT |
0x0c |
RW |
Назначить линию как выход |
|
ISTATUS |
0x10 |
RW |
Состояние прерываний |
|
EOI |
0x14 |
W1C |
Сбросить прерывание |
|
IEN |
0x18 |
RW |
Включить/выключить прерывание |
|
RISEN |
0x1c |
RW |
Включить/выключить прерывание по переднему фронту |
|
FALLEN |
0x20 |
RW |
Включить/выключить прерывание по заднему фронту |
Код драйвера Linux можно было бы упростить еще больше, сделав тип прерывания как значение в битовом поле для каждого входа, а не отдельными регистрами на каждый тип. Но это привело бы к усложнению самой модели QEMU.
Компоненты проекта:
qemu v10.0.0 c моделью MMIO GPIO и модифицированной машиной RISC-V virt,
linux v6.12 c патчами для dtb-инъекций и драйвером для QEMU MMIO GPIO,
ванильный buildroot 2025.02.2.
Пример использования
Как я уже упоминал, внешнее управление входами в QEMU использовалось для алгоритмов WIM. На реальном железе через блок сопряжения NAMUR к входам подключены датчики колеса. Возможность управления входом/выходом и считывания состояния линии входа может пригодиться при тестировании и разработке прошивок для:
-
датчиков,
-
кнопок и лампочек,
-
и даже такой экзотики, как шина SGPIO.
Готовые артефакты для ядра и rootfs я дам, а для QEMU — нет. Чтобы просто поиграться, достаточно собрать QEMU и скачать артефакты:
$ git clone 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/037789b9-f059-409b-9087-df5fe92d6c5a/be46609c-7755-4086-a92d-6d20a4fa3889/download -O initramfs.cpio.xz $ wget https://gitflic.ru/project/maquefel/qemu-gpio-playground/release/037789b9-f059-409b-9087-df5fe92d6c5a/ad6d8ae3-6301-404d-9681-c263658d9da2/download -O Image
Пример запуска модифицированной машиной RISC-V virt с GPIO SYSBUS:
$ build-qemu/qemu-system-riscv64 -machine virt -m 1G -kernel Image -initrd initramfs.cpio.xz -append "root=/dev/ram" -qmp unix:./qmp-sock,server,wait=off -nographic -serial mon:stdio qemu-system-riscv64 # modprobe gpio-qemu qemu-system-riscv64 # gpioinfo gpiochip0 - 32 lines: line 0: unnamed input line 1: unnamed input line 2: unnamed input line 3: unnamed input line 4: unnamed input line 5: unnamed input line 6: unnamed input line 7: unnamed input line 8: unnamed input line 9: unnamed input line 10: unnamed input line 11: unnamed input line 12: unnamed input line 13: unnamed input line 14: unnamed input line 15: unnamed input line 16: unnamed input line 17: unnamed input line 18: unnamed input line 19: unnamed input line 20: unnamed input line 21: unnamed input line 22: unnamed input line 23: unnamed input line 24: unnamed input line 25: unnamed input line 26: unnamed input line 27: unnamed input line 28: unnamed input line 29: unnamed input line 30: unnamed input line 31: unnamed input
Пока остается единственный вариант — задать или опросить состояние через QMP. Так мы сможем указать состояние входа/выхода следующим образом:
host $ tools/gpio-mmio-toggle.sh ./qmp-sock 0 1 {"QMP": {"version": {"qemu": {"micro": 91, "minor": 2, "major": 9}, "package": "v10.0.0-rc1-9-gc25d917239-dirty"}, "capabilities": ["oob"]}} {"return": {}} {"return": {}}
И получить ожидаемую реакцию внутри «гостя»:
qemu-system-riscv64 # gpiomon -c 0 0 5112.382098300 rising gpiochip0 0 5115.131181200 falling gpiochip0 0
Считать состояние входа/выхода:
qemu-system-riscv64 # gpioset -c 0 0=1 host $ tools/gpio-mmio-toggle.sh ./qmp-sock 0 {"QMP": {"version": {"qemu": {"micro": 91, "minor": 2, "major": 9}, "package": "v10.0.0-rc1-9-gc25d917239-dirty"}, "capabilities": ["oob"]}} {"return": {}} {"return": 1}
Модель GPIO для QEMU
Рассмотрим, как создать простейшую модель QEMU в двух вариантах, которые я условно решил назвать MMIO и PCI. Последний — тоже MMIO, но в QEMU они добавляются разными путями.
Мы начнем с сердца любой MMIO-модели — апертуры.
Апертура и адресное пространство
Как я упоминал в одной из своих статей, любое MMIO-устройство — это MemoryRegion с заданными шириной доступа и размером. Для того, чтобы он был виден CPU или другому устройству, такому как DMA, его нужно разместить в соответствующем адресном пространстве — например, пространстве, назначенном для cpu0:
0x0 0xffffffffffffffff |------|------|------|------|------|------|------|------| 0: [ address-space: cpu-memory-0 ] 0: [ address-space: memory ] 0x102000 0x1023ff 0: [ gpio ]
Рекомендую прочитать официальную документацию QEMU, эта тема там хорошо описана.
В любое время можно посмотреть существующие адресные пространства и регионы памяти в мониторе QEMU:
(qemu) info mtree [...] address-space: cpu-memory-0 address-space: memory 0000000000000000-ffffffffffffffff (prio 0, i/o): system 0000000000102000-00000000001023ff (prio 0, i/o): gpio [...]
Тогда в модели устройства нам нужно всего лишь создать такой регион и назначить ему соответствующие функции записи и чтения:
static const MemoryRegionOps mmio_mmio_ops = { .read = mmio_gpio_register_read_memory, .write = mmio_gpio_register_write_memory, .endianness = DEVICE_NATIVE_ENDIAN, .valid = { .min_access_size = 4, .max_access_size = 4, }, }; [...] memory_region_init_io(iomem, obj, &mmio_mmio_ops, s, "gpio", APERTURE_SIZE); [...]
Фактически это означает, что все семейство инструкций Load/Store будет вызывать mmio_gpio_register_read_memory()/mmio_gpio_register_write_memory() при совпадении адреса чтения/записи с адресом региона в адресном пространстве.
static uint64_t mmio_gpio_register_read_memory(void *opaque, hwaddr addr, unsigned size); static void mmio_gpio_register_write_memory(void *opaque, hwaddr addr, uint64_t value, unsigned size);
Передаваемые аргументы и возвращаемое значения интуитивно понятны. Отмечу, что hwaddr addr — это адрес относительно начала нашего региона, а не абсолютный адрес.
Нам остается лишь создать устройство и добавить его регион в файле машины:
gpio = qdev_new(TYPE_MMIO_GPIO); sysbus_mmio_map(SYS_BUS_DEVICE(gpio), 0, ADDRESS);
Здесь ADDRESS— это абсолютный адрес устройства, а TYPE_MMIO_GPIO — просто строка, определенная в заголовочном файле:
#define TYPE_MMIO_GPIO "mmio-gpio"
mmio_gpio_register_read_memory()/mmio_gpio_register_write_memory()
Вернемся к нашим функциям чтения/записи апертуры. Здесь нам необходимо смоделировать реакцию устройства на чтение/запись его регистров. В простейшем случае мы можем просто хранить значение записанного значения для входа/выхода и возвращать его при чтении. Тогда псевдокод для записи можно представить таким образом:
static void mmio_gpio_register_write_memory(void *opaque, hwaddr addr, uint64_t value, unsigned size) { uint32_t val32 = value; switch(addr) { case 0x04: // SET data |= val32; break; case 0x08: // CLEAR data &= ~val32; break; case 0x00: // DATA /* только для чтения */ default: /* можно сообщить об ошибке */ } [...] }
А таким — для чтения:
static uint64_t mmio_gpio_register_read_memory(void *opaque, hwaddr addr, unsigned size) { uint32_t val32 = 0; switch(addr) { case 0x00: // DATA val32 = data; break; case 0x04: // SET case 0x08: // CLEAR /* только для записи */ break; default: /* можно сообщить об ошибке */ } [...] return val32; }
Это не значит, что в каждой модели обязательно должны быть регистры — например, мы можем моделировать отображенную в адресное пространство FLASH-память. Тогда наша модель будет позволять писать и читать произвольный адрес условно произвольного размера — то есть любого из 1/2/4/8, если есть инструкции Load/Store соответствующей ширины. Для регистровых моделей нам доступен Register API.
Register API и прочий сахар
Для работы с регистрами в QEMU была сделана специальная прослойка. Применять ее необязательно, но она облегчает и формализует работу с регистрами.
Во-первых, это макросы REG8/16/32/64(reg, addr). Их цель — из названия и относительного адреса сделать соответствующие значения вида:
enum { A_ ## reg = (addr) }; enum { R_ ## reg = (addr) / (1, 2, 4, 8) };
Во-вторых — макросы FIELD_EX/SEX(storage, reg, field) и FIELD_DP/SDP(storage, reg, field, val) для каждой длины. Они представляют собой обертки для функций extract/deposit, которые предназначены для работы с битовыми полями.
В-третьих — структура struct RegisterAccessInfo и семейство функций register_init_block() для описания и регистрации блока регистров, соответственно. Они достаточно хорошо документированы, поэтому просто выделю основные моменты:
-
Чтение и запись регистра берет на себя Register API с учетом масок и сообщает об ошибке — например, при записи в биты, помеченные только для чтения (
-d guest_errors,unimp):-
ro— read-only, -
w1c— write one to clear, -
rsvd— reserved bits, -
unimp— unimplemented bits.
-
-
Мы можем добавить реакцию на чтение/запись с помощью назначенных функций:
-
pre_write— позволяет поменять значение до записи в соответствующий регистр и предпринять действия, связанные с записью в этот регистр, -
post_write— получить значение после применения всех масок и предпринять какие-либо действия, -
post_read— обязательные после чтения регистра действия, например сбросить прерывание.
-
В нашей модели регистры «монолитные», то есть без полей:
/* common gpio regs */ REG32(GPIO_QEMU_DATA, 0x00) REG32(GPIO_QEMU_SET, 0x04) REG32(GPIO_QEMU_CLEAR, 0x08) REG32(GPIO_QEMU_DIR_OUT, 0x0c) /* intr gpio regs */ REG32(GPIO_QEMU_ISTATUS, 0x10) REG32(GPIO_QEMU_EOI, 0x14) REG32(GPIO_QEMU_IEN, 0x18) REG32(GPIO_QEMU_RISEN, 0x1c) REG32(GPIO_QEMU_FALLEN, 0x20)
Хотя можно было бы принять и такую форму записи:
REG32(GPIO_QEMU_DATA, 0x00) FIELD(GPIO_QEMU_DATA, PIN0, 0, 1) FIELD(GPIO_QEMU_DATA, PIN1, 1, 1) [...] FIELD(GPIO_QEMU_DATA, PIN31, 31, 1)
Но я счел это нецелесообразным.
К описанию регистров в REG32 добавляем описание RegisterAccessInfo. Эта форма записи мне нравится своей формализованностью и наглядностью:
static const RegisterAccessInfo mmio_gpio_regs_info[] = { { .name = "DATA", .addr = A_GPIO_QEMU_DATA, .reset = 0x00000000, .ro = 0xffffffff, }, { .name = "SET", .addr = A_GPIO_QEMU_SET, .reset = 0x00000000, .pre_write = mmio_gpio_set, }, { .name = "CLEAR", .addr = A_GPIO_QEMU_CLEAR, .reset = 0x00000000, .pre_write = mmio_gpio_clear, }, { .name = "DIROUT", .addr = A_GPIO_QEMU_DIR_OUT, .reset = 0x00000000, }, { .name = "ISTATUS", .addr = A_GPIO_QEMU_ISTATUS, .ro = 0xffffffff, .reset = 0x00000000, }, { .name = "EOI", .addr = A_GPIO_QEMU_EOI, .reset = 0x00000000, .pre_write = mmio_gpio_intr_eoi, }, { .name = "IEN", .addr = A_GPIO_QEMU_IEN, .reset = 0x00000000, }, { .name = "RISEN", .addr = A_GPIO_QEMU_RISEN, .reset = 0x00000000, }, { .name = "FALLEN", .addr = A_GPIO_QEMU_FALLEN, .reset = 0x00000000, }, };
После этого достаточно зарегистрировать и добавить блок:
static void gpio_instance_init(Object *obj) { [...] memory_region_init(&s->mmio, obj, "container."TYPE_GPIO, MMIO_GPIO_APERTURE_SIZE); reg_array = register_init_block32(DEVICE(obj), mmio_gpio_regs_info, ARRAY_SIZE(mmio_gpio_regs_info), s->regs_info, s->regs, &mmio_mmio_ops, 0, MMIO_GPIO_APERTURE_SIZE); memory_region_add_subregion(&s->mmio, 0x0, ®_array->mem); [...] }
Мы добавляем блок регистров как еще один регион памяти внутри основной апертуры. Вместо этого можно использовать ®_array->mem как основной регион или создать несколько таких блоков, если регистры не являются непрерывными или у нас блоки с разным размером регистров.
Помимо стандартной записи и хранения, реализовано всего три функции:
-
mmio_gpio_set(), -
mmio_gpio_clear(), -
mmio_gpio_intr_eoi().
Все они — pre_write(). Как я уже отмечал выше, для регистра DATA в модели использованы семантики Write To Clear (w1c) и Write To Set (w1s). За каждую отвечает отдельный регистр с отдельной функцией обработки. Поэтому нам нужно просто выставить или очистить биты согласно переданному аргументу и вернуть 0, так как ни SET, ни CLEAR состояние не хранят — оно хранится в DATA.
Например, для mmio_gpio_set() код выглядит следующим образом:
static uint64_t mmio_gpio_set(RegisterInfo *reg, uint64_t val) { GpioState *s = GPIO(reg->opaque); uint32_t val32 = val; unsigned idx; /* for each bit in val32 set DATA */ idx = find_first_bit((unsigned long *)&val32, s->nr_lines); while (idx < s->nr_lines) { unsigned bit = test_and_set_bit32(idx, &s->regs[R_GPIO_QEMU_DATA]); if (!bit) { qemu_irq_raise(s->output[idx]); } idx = find_next_bit((unsigned long *)&val32, s->nr_lines, idx + 1); } return 0; }
Очевидно, что mmio_gpio_clear() будет отличаться лишь в очень небольшой части:
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) { qemu_irq_lower(s->output[idx]); } [...] }
Функции qemu_irq_raise/lower() нужны, чтобы передать состояние выхода в другую модель: reset для I2C-датчика, LED, I2C Bitbang, SPGIO и так далее.
Если нам не нужно различать, какой именно вход/выход изменил свое состояние, то все еще проще, как это сделано в mmio_gpio_intr_eoi():
static uint64_t mmio_gpio_intr_eoi(RegisterInfo *reg, uint64_t val) { GpioState *s = GPIO(reg->opaque); uint32_t val32 = val; uint32_t changed = val32 & s->regs[R_GPIO_QEMU_ISTATUS]; if (!changed) { return 0; } s->regs[R_GPIO_QEMU_ISTATUS] &= ~val32; if (!s->regs[R_GPIO_QEMU_ISTATUS]) { gpio_lower_irq(s); } return 0; }
Если значение регистра GPIO_QEMU_ISTATUS равно 0, тогда мы сбрасываем прерывание. В качестве альтернативы можно было бы убрать регистр EOI и назначить обработчик post_write() на ISTATUS, сделав его .w1c = 0xffffffff. В этом случае все свелось бы к:
static void mmio_gpio_intr_eoi(RegisterInfo *reg, uint64_t val) { GpioState *s = GPIO(reg->opaque); if (!val) { gpio_lower_irq(s); } }
Я не пользуюсь этим способом из-за непредсказуемости поведения qemu_irq_lower() в разных архитектурах. Функции qemu_irq_lower/raise() лучше использовать, только если нужно гарантированно вызвать или сбросить прерывание.
Для функционирования GPIO-модели хватает всего трех функций обработчика. Во многих случаях в модели достаточно простого хранения записываемых значений — например, для моделей PINMUX, поведение которых мы можем моделировать достаточно условно.
На этом общие для MMIO и PCI моменты заканчиваются и начинаются различия.
MMIO GPIO
Сразу обозначу проблему: в QEMU нет механизмов добавления MMIO-устройств через командную строку или конфигурационный файл. Это нужно делать прямо в коде машины:
// memmap[VIRT_MMIO_GPIO].base с адресом 0x102000 и апертурой 0x1000 // GPIO_IRQ c номером 12 в RISC-V PLIC sysbus_create_simple("mmio-gpio", memmap[VIRT_MMIO_GPIO].base, qdev_get_gpio_in(mmio_irqchip, GPIO_IRQ));
Для добавления моделей через командную строку есть старый патч, но Xilinx пошел еще дальше и генерирует машину из описания в виде DTB (на основе заранее известных блоков, сопоставляя compatible и TYPE). В этот проект патч и Xilinx я не включал, но надеюсь, что очередь дойдет и до них.
Чтобы добавить устройство, нам также нужно найти свободное место в адресном пространстве и свободное прерывание. Поскольку не у всех машин есть встроенная генерация DTB, то можно добавить запись в DTB:
// mmio_gpio_add_fdt() находится в коде самой модели // 32 - количество входов выходов mmio_gpio_add_fdt(&virt_memmap[VIRT_MMIO_GPIO], GPIO_IRQ, irq_mmio_phandle, 32);
Тогда на выходе мы получим следующую запись:
gpio@102000 { gpio-controller; ngpios = <0x20>; interrupts = <0x0c>; interrupt-parent = <0x03>; compatible = "qemu,mmio-gpio"; reg = <0x00 0x102000 0x00 0x400>; };
Впрочем, запись можно добавить и в исходный dts-файл, либо как overlay в уже стартовавшее ядро. Последний вариант работает только с модифицированным ядром (патч OF: DT-Overlay configfs interface (v8)):
# /bin/mount -t configfs none /sys/kernel/config/ # mkdir /sys/kernel/config/device-tree/overlays/gpio # cat gpio-mmio.dtb > /sys/kernel/config/device-tree/overlays/gpio/dtbo # gpiodetect gpiochip0 [102000.gpio] (32 lines)
Также можно использовать ACPI + ASL: встроенный в QEMU или инъектированный в работающее ядро. Но это уже совсем другая история.
PCI/PCIe GPIO
Эту модель, как и в случае с ivshmem, можно добавить только если есть PCI-шина. Зато это можно сделать с помощью аргумента:
$ build-qemu/qemu-system-riscv64 -machine virt,aia=aplic-imsic [...] -device pcie-gpio [...]
Или из командной строки QEMU (использован pcie-root-port, чтобы сработал hotplug):
$ build-qemu/qemu-system-riscv64 -machine virt,aia=aplic-imsic [...] -device pcie-root-port,id=pcie.1 [...] (qemu) device_add pcie-gpio,bus=pcie.1
Драйвер для Linux
Нам более интересна модель QEMU, а не очередной драйвер для GPIO, но я предлагаю обратить внимание на динамику сокращения кода в самом драйвере. С появлением таких конструкций, как devm_regmap_add_irq_chip() и devm_gpio_regmap_register(), наша задача сводится к конфигурации этих функций, причем мы даже можем обойтись без хранения внутреннего состояния в драйвере.
После запроса ресурсов в qgpio_pci_probe()/qgpio_mmio_probe() мы просто создаем regmap:
struct regmap *map; map = devm_regmap_init_mmio(dev, regs, &qgpio_regmap_config);
Его мы используем как для прерываний:
struct regmap_irq_chip_data *chip_data; struct regmap_irq_chip *chip; chip->status_base = GPIO_QEMU_ISTATUS; chip->ack_base = GPIO_QEMU_EOI; chip->unmask_base = GPIO_QEMU_IEN; chip->num_regs = 1; chip->irqs = qgpio_regmap_irqs; chip->num_irqs = ARRAY_SIZE(qgpio_regmap_irqs); chip->set_type_config = qgpio_mmio_set_type_config; chip->irq_drv_data = map; err = devm_regmap_add_irq_chip(dev, map, irq, 0, 0, chip, &chip_data);
Здесь мы указываем смещения регистров для управления прерываниями и задаем функцию управления типом прерывания qgpio_mmio_set_type_config(). Как я говорил выше, если в модели выделить два регистра по 32 бита, где каждый тип будет определен двумя битами (0 — выключено, 1 — по переднему фронту, 2 — по заднему фронту), то можно обойтись исключительно средствами regmap_irq_chip по умолчанию.
Так и для обычного управления входами/выходами:
struct gpio_regmap_config gpio_config = { 0 }; gpio_config.regmap = map; gpio_config.ngpio = 32; gpio_config.reg_dat_base = GPIO_REGMAP_ADDR(GPIO_QEMU_DATA); gpio_config.reg_set_base = GPIO_REGMAP_ADDR(GPIO_QEMU_SET); gpio_config.reg_clr_base = GPIO_REGMAP_ADDR(GPIO_QEMU_CLEAR); gpio_config.reg_dir_out_base = GPIO_REGMAP_ADDR(GPIO_QEMU_DIR_OUT); gpio_config.irq_domain = regmap_irq_get_domain(chip_data); return PTR_ERR_OR_ZERO(devm_gpio_regmap_register(dev, &gpio_config));
Так мы сократили код драйвера почти в два раза по сравнению с версией 5.14.
Заключение
В итоге мы с вами пришли к возможности использовать GPIO с любой машиной QEMU, на которой есть PCI-шина. А если ее нет, то встроить GPIO в существующее адресное пространство.
Кажется, мы выработали неплохое решение, но на поверку это не так:
-
в большинстве случаев пользователи не хотят GPIO в виде PCI или чужеродной для SoC модели MMIO — им нужно управлять тем, что уже есть в машинах по тем же адресам, что и в реальном железе: aspeed, rpi, stm32, gd32 и так далее,
-
пользователям нужна обратная связь от GPIO внутри QEMU, а также уведомления об изменении состояния и конфигурации входов/выходов,
-
пользователям нужны нормальные инструменты и библиотеки.
Об этом мы поговорим в следующей статье.
ссылка на оригинал статьи https://habr.com/ru/articles/925860/
Добавить комментарий