QEMU позволяет эмулировать работу не только одной отдельной машины, но и связывать несколько независимых машин между собой. Для организации такой связи их обычно объединяют в одну сеть, например с использованием virio-net-pci. Но виртуальный ethernet — не единственный способ, связь может быть и более близкой и плотной: общая память и mailbox, линии gpio и даже NTB.

Быстрая работа связанных QEMU машин приятна при разработке/отладке и очень важна при массовом прогоне автотестов в CI: нужна как высокая пропускная способность, так и низкая задержка передачи сообщения. Для одной из задач с коллегами из отдела системного программирования YADRO я оптимизировал часть такой связки, а именно — обмен прерываниями. В статье расскажу о дизайне нескольких подходов организации IQI, разберу внутреннее устройство QEMU и поделюсь, как оправдались наши ожидания. 

Моя команда занимается задачами программного связывания CPU с CPU, CPU с устройствами, а также устройств с устройствами. Используются как медленные, но надежные шины, так и быстрые, но капризные. Больше всего нам нравятся задачи в стиле «Здесь водятся драконы», когда информацию приходится собирать буквально по крупицам, а иногда и быть первопроходцами.

Содержание

Введение

Есть группа задач, которые хорошо решаются с применением QEMU:

  • Разработка ОС, в том числе драйверов, загрузчиков и специфичного архитектурного кода (например, текущая разработка, связанная с RISC-V, где многие функциональные модели QEMU появились даже раньше, чем их прототипы на ПЛИС).

  • Тестирование, изучение и анализ.

  • Разработка и отладка любого уровня коммуникаций (кроме, пожалуй, физического).

Конечно, на этом список не заканчивается, но мы поговорим о значительной части последнего пункта. В качестве простого примера коммуникаций хорошо подойдет фреймворк OpenAMP. Для нормального функционирования ему достаточно небольшого участка общей между двумя процессорами памяти и двух прерываний, идущих со стороны каждого процессора друг к другу. Обычно эта схема применяется для общения двух разных процессоров в рамках одного SoC (например, общение между OS Zephyr на Cortex-M4 с OS Linux на Cortex-A7 в чипе NXP imx7d). Часто, по ряду причин, мы не можем запустить единую машину QEMU сразу со всем многообразием процессоров в SoC — например из-за разной битности.

Другим примером может служить ivshmem — виртуальное устройство, предоставляемое QEMU, с готовыми драйверами в OS Zephyr, OS Linux и др. Изначально оно создавалось для коммуникаций между машинами QEMU, состоит из разделяемой между машинами памяти и некоторого количества прерываний между ними. 

Цель статьи — описать методы обмена примитивными событиями (гость может заказать возникновение прерывания в другом госте) из первой машины, которую мы далее называем QEMU0, во вторую машину, соответственно называемую QEMU1, и наоборот.

Организовать такой обмен (IQI) можно тремя способами. Каждый быстрее предыдущего, но сложнее в реализации:

  • Запрос события машиной QEMU0 после записи ее гостем в соответствующий регистр, прием машиной QEMU1 и выставление прерывания в ее госте.

    • Плюсы: универсальность — архитектуры гостей и хоста могут быть разными, нет требований к аппаратной поддержке виртуализации, относительная простота реализации.

    • Минусы: самый медленный из способов.

  • Посылка события машиной QEMU0 после записи ее гостем в соответствующий регистр, выставление прерывания напрямую в гостевом ядре, без участия машины QEMU1 (средствами KVM).

    • Плюсы: оптимизированная половина тракта позволяет сократить задержку между отправкой события и началом его обработки гостем-приемником.

    • Минусы: требуется поддержка KVM, и, как следствие, архитектуры гостей и хоста должны совпадать.

  • Посылка события гостевым ядром напрямую (без участия машины QEMU0), прием прерывания в гостевом ядре (без участия машины QEMU1).

    • Плюсы: самый быстрый из методов.

    • Минусы: те же, что в предыдущем способе, к тому же мы теряем контроль над маскированием и статусом прерываний.

Схемы обмена прерываниями
Схемы обмена прерываниями

В самом первом случае мы полностью передаем механизм обмена моделям QEMU. Соответственно, в данном контексте скорее правильнее говорить об обмене событиями, а не прерываниями, так как прерывания здесь вторичны и являются реакцией на события. 

Последовательность отправки прерывания:

  1. Гостевое ядро QEMU1 производит запись в регистр.

  2. Осуществляется выход ядра из вызова ioctl(KVM_RUN) в QEMU1 с причиной KVM_EXIT_MMIO.

  3. Производится отправка события в QEMU2.

  4. Вызывается прерывание для гостевого ядра QEMU2 функцией msix_notify().

Упрощенно это можно представить в следующем виде:

Запись в регистр DATA
Запись в регистр DATA
Примечание: к сожалению, из-за особенностей mermaid пришлось вынести связь первой и второй машины в отдельный блок
Примечание: к сожалению, из-за особенностей mermaid пришлось вынести связь первой и второй машины в отдельный блок

Во втором случае мы исключаем из цепочки QEMU2, т.к. отправка запроса прерывания идет непосредственно в гостевое ядро средствами KVM, а в третьем исключаем еще и QEMU1 — запрос прерывания идет непосредственно из гостевого ядра. Таким образом, в третьем случае обмен прерываниям происходит исключительно методами KVM.

При этом в качестве гостевой ОС мы тоже используем Linux, но, вообще говоря, это не обязательно. Обязательное условие — Linux с поддержкой KVM в качестве ОС хоста с определенными KVM Capabilities. Для простоты будем считать, что методы 2 и 3 демонстрационного проекта работают только под x86_64.

Результирующий код, как модели QEMU, так и модуля ядра Linux, способен использовать все три метода, переключаемые через аргументы запуска машины. Тем не менее мы разделили изменения на стадии для более простого восприятия материала. 

Короткая справка про IPC

Запущенная машина QEMU видна в системе как процесс, а значит, для обмена между машинами, а также между машинами и KVM, нужно воспользоваться какими-то IPC.

В QEMU уже можно использовать некоторые фундаментальные особенности POSIX IPC, в том числе уникальные для Linux, без которых взаимодействие между машиной и ядром было бы сильно затруднено.

UNIX Socket

Главная особенность сокета, способность передавать файловые дескрипторы другим процессам в системе, в основном применяется для передачи eventfd и memfd.

В QEMU API есть ряд ограничений:

  • за раз не может быть передано больше, чем TCP_MAX_FDS (16),

  • нельзя отправить только файловые дескрипторы, необходима ненулевая запись в socket.

Если такие ограничения не смущают, то можно использовать функции, предоставленные QEMU для работы с UNIX-сокетами:

  • qemu_chr_fe_set_msgfds(),

  • qemu_chr_fe_get_msgfds().

eventfd

Важная для нас особенность — возможность передавать дескрипторы ядру Linux в KVM-режиме, что обеспечивает доставку прерываний напрямую в гостевое ядро, минуя прослойку в виде QEMU. В остальном, если операционная система не поддерживает eventfd, QEMU использует pipe для эмуляции поведения eventfd. Поэтому для обмена прерываниями лучше всего использовать предоставленный API:

  • event_notifier_init(),

  • event_notifier_init_fd(),

  • event_notifier_set_handler(),

  • event_notifier_set(),

  • event_notifier_test_and_clear().

В общем, если использовать функции QEMU, то передача будет более универсальной под многие случаи.

Реализация модели и драйвера

Я постарался максимально разделить стадии для большей наглядности, чтобы по истории было видно — когда, что и в каком коммите было добавлено.

Модель для QEMU:

  • stage 0 — базовая модель для проверки загрузки модуля ядра,

  • stage 1 — добавление единственного прерывания MSI-X и вектора состояний для входов,

  • stage 2 — реализация обмена состояниями входов/выходов поверх eventfd,

  • stage 3 — отправка прерываний непосредственно в гостевое ядро, минуя QEMU,

  • stage 4 — отправка прерываний непосредственно из гостевого ядра, минуя QEMU.

В итоговой модели у нас есть возможность выбрать режим работы аргументами командной строки при запуске.

Модуль ядра Linux:

  • stage 0 — модуль MAILBOX с функционалом без прерываний,

  • stage 1 — добавление поддержки единственного прерывания MSI-X и вектора прерываний,

  • stage 2 — добавление поддержки множественных векторов прерываний MSI-X, где каждому событию появления данных или приема данных противоположной стороной соответствует отдельное прерывание.

Во второй стадии у нас происходит серьезная модификация модуля ядра драйвера — она обусловлено тем, что прекращается обмен состояниями между экземплярами QEMU, а модули общаются «напрямую», минуя прослойку. Это означает, что у нас нет информации о состоянии ISTATUSTXDONE и мы не можем контролировать прерывания с помощью IEN.

Подготовительная работа

Описание проекта-обертки

Компоненты:

  • qemu (v9.0.0 — c патчами pcie_mbox),

  • linux (v6.8-rc5 — с патчами qemu-mailbox, mailbox-pingpong),

  • buildroot (2024.02-31).

Ссылка на проект-обертку: qemu-playground.

Проект предусматривает генерацию initrd образа для использования с QEMU. Тем не менее основные работы велись через 9p filesystem, где в качестве директории для монтирования использовалась директория, собранная с помощью overlayfs отдельно для каждой машины.

Описание модели в QEMU

Модель представляет из себя PCI-устройство, подключенное к основной шине PCI.

В BAR0 находятся все регистры управления для модели MAILBOX. BAR1 служит исключительно для таблиц MSI-X.

В модели специально были разделены регистры для каналов вместо объединения их в векторы по причинам, о которых вы узнаете позднее.

Общие регистры:

Имя

Адрес

Доступ

Описание

CFG

0x00

RO

Не используется

ISTATUS

0x04

RO

Вектор с информацией о состоянии прерываний DATA

IEN

0x08

RW

Включение/выключение прерывания для линии

TXDONE

0x10

RO

Вектор с информацией о состоянии прерываний TXDONE

Регистры для каждого канала:

Имя

Адрес

Доступ

Описание

DATA

0x00

RW

Запись или чтение сообщения

ACK

0x04

WO

Подтверждение приема сообщения

Немного о драйвере ядра для mailbox и модуле pingpong

Подсистема mailbox на данный момент используется в основном в следующих компонентах ядра:

  • remoteproc — фактически это физический уровень для virtio поверх shared memory,

  • firmware — простая отправка команд для сопроцессора, куда перенесен функционал, который считается «небезопасным» для исполнения на основном процессоре: включение/выключение, мультиплексирование ног, настройка тактирования и прочее.

В подсистеме mailbox нас интересуют два основных события:

  • Сообщение готово для чтения (в нашем случае это просто прием прерывания, так как само сообщение мы не передаем), далее оно будет упоминаться как DATA.

  • Сообщение было получено (в модели может быть инициировано двумя способами — чтение DATA или запись в ACK), далее оно будет упоминаться как TX_DONE.

Сам по себе драйвер mailbox нам ничего не дает, так как не позволяет принимать/отправлять сообщения, поэтому к нему необходим драйвер-клиент, в нашем случае это pingpong.

Задача драйвера pingpong очень простая:

  • запросить канал для связи с соседней машиной,

  • инициировать передачу сообщения,

  • посчитать задержку по достижению какого-либо критерия, в нашем случае по количеству итераций.

Код модулей содержится в отдельном репозитории (разумеется, репозиторий включен в суперпроект):

Подсистема mailbox на данный момент одна из наименее сложных в ядре. Тем не менее она рассчитана на использование практически исключительно совместно с DT (Device Tree) — то есть канал нельзя запросить через ACPI или функцией (как это сделано для DMA), а только этим способом. Поэтому в qemu-mailbox добавлена функция qemu_mbox_request_channel() для запроса каналов.

Запуск машин QEMU1 и QEMU2

Приведем пример для запуска машин, в качестве которого использовался третий метод как наиболее полный, причем для запуска первой машины использовалась следующая команда:

build-qemu/qemu-system-x86_64 -machine q35 --enable-kvm -smp 2 -m 512 \
-cpu host,kvm-poll-control=true,kvm-hint-dedicated=true,kvmclock-stable-bit=true \
-chardev socket,path=/tmp/mbox_socket0,id=mbox0,server=on \
-device pcie-mbox,chardev=mbox0,topo=simple \
-initrd build-image/initramfs.cpio.xz \
-kernel build-linux/arch/x86_64/boot/bzImage \
-append "console=ttyS0 earlycon nokaslr initrd=/init" \
-display none -serial mon:stdio "$@"

Для второй, соответственно:

build-qemu/qemu-system-x86_64 -machine q35 --enable-kvm -smp 2 -m 512 \
-cpu host,kvm-poll-control=true,kvm-hint-dedicated=true,kvmclock-stable-bit=true \
-chardev socket,path=/tmp/mbox_socket0,id=mbox0 \
-device pcie-mbox,chardev=mbox0,topo=simple \
-initrd build-image/initramfs.cpio.xz \
-kernel build-linux/arch/x86_64/boot/bzImage \
-append "console=ttyS0 earlycon nokaslr initrd=/init" \
-display none -serial mon:stdio "$@"

Как мы видим, запуск машин практически одинаков, за исключением запуска -chardev socket. Первая машина создает сокет и, прежде чем начать исполнение, ждет, когда произойдет подключение.

Все сценарии запуска можно посмотреть здесь.

Сами методы выбираются с помощью ключей к -device pcie-mbox:

Метод

-device pcie-mbox

1

topo=simple

2

topo=recieve

3

topo=both

При загрузке гостевого ядра следует обратить внимание на наличие строчки:

[    0.408755] cpuidle: using governor haltpoll

Это означает, что мы используем специальный драйвер для контроля перехода гостевого ядра в состояние HLT. С использованием этого драйвера наблюдается уменьшение задержек, в некоторых случаях почти в два раза (подробнее — в документации по guest halt polling). Даже большего эффекта можно достичь за счет отключения CONFIG_CPU_MITIGATIONS в ядре хоста.

Методика проведения измерений

Производятся замеры времени между событиями TX_DONE на одной из машин QEMU. Сам обмен производится в цикле — как только мы получили событие DATA, мы посылаем подтверждение TX_DONE.

Методика проведения измерений
Методика проведения измерений

Цикл посылки и получения находится в модуле mailbox-pingpong, загруженном на машинах. Инициирует цикл сторона, которая получила команду run, а вторая лишь подтверждает получение прерывания путем отправки прерывания со своей стороны.

По итогам измерений мы получаем информацию о задержках, количестве итераций и общем времени, затраченном на тест:

# dmesg
...
[    6.443918] mbox_pp: made 5000 iterations, lasted 217322 usecs
[    6.444867] mbox_pp: latency 43 us (43464 ns)

Также к каждому измерению был сделан вывод временной метки события TX_DONE в наносекундах с помощью trace_printk():

qemu-irqs # cat /sys/kernel/debug/tracing/trace
...
  mbox_pp_thread-169     [000] .....     6.243447: mbox_pp_func: TX_DONE=6078138811
  mbox_pp_thread-169     [000] .....     6.243534: mbox_pp_func: TX_DONE=6078226751
  mbox_pp_thread-169     [000] .....     6.243616: mbox_pp_func: TX_DONE=6078308151
  mbox_pp_thread-169     [000] .....     6.243681: mbox_pp_func: TX_DONE=6078373971
  mbox_pp_thread-169     [000] .....     6.243723: mbox_pp_func: TX_DONE=6078415721
...

Каждый экземпляр QEMU живет в собственном cpuset — по четыре CPU для каждого эксклюзивно, к тому же сами CPU ограничены по нижней планке возможной частоты.

Для всех измерений далее использован этот метод.

Базовая модель без прерываний

Простая модель, можем вызвать событие DATA посредством записи через QOM:

stage 0 hw/misc: Add PCI Mailbox model

Мы начинаем с того, что создаем регион памяти с заданными функциями для чтения и записи pcie_mbox_io_read/write():

static const MemoryRegionOps pcie_mbox_mmio_ops = {
    .read = pcie_mbox_io_read,
    .write = pcie_mbox_io_write,
    .endianness = DEVICE_NATIVE_ENDIAN,
    .valid = {
        .min_access_size = 4,
        .max_access_size = 4,
    }
};

При каждом обращении к региону для чтения и записи будут вызываться именно наши определенные функции, также мы жестко задаем размер доступа шириной в четыре байта. Это именно жесткие ограничения — при попытке чтения/записи с размером, отличным от указанного, мы получим ошибку шины.

В функции чтения/записи нам передается относительный адрес внутри самого региона памяти, ширина доступа и само значение в случае операции записи:

switch (addr) {
case A_CFG:
        ...
        break;
case A_ISTATUS:
        ...
        break;
case A_IEN:
        ...
        break;
case A_TXDONE:
        ...
        break;
default:
        /* report error here */
        break;
}

Помимо MemoryRegionOps .read()/.write(), также доступны версии .read/write_with_attrs(), которые позволяют генерировать ошибку на шине в каких-то случаях, например регистр с таким адресом отсутствует или доступен только для чтения/записи.

Далее мы регистрируем наш регион как BAR0:

pci_register_bar(dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &s->mmio);

Для того чтобы писать и читать состояние линий вне QEMU или гостя, добавим дополнительные свойства для QOM:

for (i = 0; i < PCIE_MBOX_CHAN_CNT; i++) {
    s->chans[i].idx = i;
    object_property_add(obj, "chan[*]", "uint32",
                        pcie_mbox_chan_qom_get,
                        pcie_mbox_chan_qom_set, NULL, &s->chans[i]);
}

Проверим появление устройства:

# lspci -vvv -s 00:03.0
00:03.0 Communication controller: Red Hat, Inc. Device 1111
        Subsystem: Red Hat, Inc. Device 1100
        Control: I/O+ Mem+ BusMaster- SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR+ FastB2B- DisINTx-
        Status: Cap- 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast >TAbort- <TAbort- <MAbort- >SERR- <PERR- INTx-
        Region 0: Memory at fe004000 (64-bit, prefetchable) [size=1K]

Проверим загрузку модуля (mailbox_pingpong — единственный клиент для qemu-mailbox, сам по себе модуль qemu-mailbox не обладает достаточным функционалом для приема сообщений):

# modprobe qemu-mailbox
# modprobe mailbox_pingpong

Добавление прерывания MSI-X

Добавление единственного прерывания MSI-X и вектора статуса прерываний:

stage 1  hw/misc: pcie_mbox: Add single MSIX intr

Добавляем выделенный только для MSI-X BAR1 c единственным прерыванием:

msix_init_exclusive_bar(dev, 1 /* количество линий */, 1 /* номер BAR */, errp);

Он отобразится в виде отдельного BAR, также можно увидеть количество линий прерывания:

00:03.0 Communication controller: Red Hat, Inc. Device 1111
        Subsystem: Red Hat, Inc. Device 1100
        Control: I/O+ Mem+ BusMaster+ SpecCycle- MemWINV- VGASnoop- ParErr- Stepping- SERR+ FastB2B- DisINTx+
        Status: Cap+ 66MHz- UDF- FastB2B- ParErr- DEVSEL=fast >TAbort- <TAbort- <MAbort- >SERR- <PERR- INTx-
        Latency: 0
        Region 0: Memory at febf1000 (32-bit, non-prefetchable) [size=1K]
        Region 1: Memory at febf2000 (32-bit, non-prefetchable) [size=4K]
        Capabilities: [40] MSI-X: Enable+ Count=1 Masked-
                Vector table: BAR=1 offset=00000000
                PBA: BAR=1 offset=00000800
        Kernel driver in use: qemu-mailbox

Тогда при вызове изменения состояния через QOM просто вызываем прерывание и выставляем бит линии в векторе прерываний ISTATUS, если соблюдены необходимые условия:

static void pcie_mbox_push_chan(PcieMboxState *s, uint8_t idx, uint32_t value)
{
    PcieMboxChanState *chan = &s->chans[idx];
    PCIDevice *pdev = PCI_DEVICE(s);
 
    trace_pcie_mbox_push(idx, value);
 
    set_bit(chan->idx, &s->intsts);
    if (s->ien & BIT_ULL(chan->idx)) {
        msix_notify(pdev, 0);
    }
}

Соответствующая часть драйвера mailbox заключается в простом назначении обработчика прерываний с чтением состояния из ISTATUS:

static irqreturn_t qemu_mbox_isr(int virq, void *data)
{
        struct qemu_mbox *mbox = data;
        struct mbox_chan *chan;
        unsigned int stat = 0;
        int offset;
        u32 msg;
 
        /* чтение вектора статусов прерываний */
        regmap_read(mbox->map, QEMU_MBOX_ISTATUS, &stat);
 
        dev_dbg(mbox->dev, "stat: 0x%x\n", stat);
 
        for_each_set_bit(offset, (unsigned long *)&stat, QEMU_MBOX_MAX_CHAN_CNT) {
                regmap_read(mbox->map, QEMU_MBOX_CHAN_ADDR(offset), &msg);
                chan = &mbox->mbox.chans[offset];
                if (chan->cl)
                        mbox_chan_received_data(chan, &msg);
        }
 
        return IRQ_HANDLED;
}

С помощью QMP отправим сообщение для первого канала и проверим изменения состояния из гостевой машины:

# tools/qemu1.sh -qmp unix:./qmp.sock,server,wait=off
$ socat UNIX:qmp.sock - < tools/qmp_push_mbox
{"QMP": {"version": {"qemu": {"micro": 90, "minor": 2, "major": 8}, "package": "v9.0.0-rc0-74-gc819c30a8e-dirty"}, "capabilities": ["oob"]}}
{"return": {}}
{"return": {}}

Путь к устройству можно узнать через info qom-tree в консоли монитора QEMU.

qemu-irqs # cat /proc/interrupts
...
 28:          1          0          0          0  PCI-MSIX-0000:00:03.0   0-edge      qemu_mbox_isr
...

Добавление обмена векторами прерываний

Добавим обмен файловыми дескрипторами для уведомления об изменении состояния:

stage 2  hw/misc: pcie_mbox: Add inter-vm events

На каждый канал назначим по два eventfd: один — для события DATA, другой — для TX_DONE.

Посылка события осуществляется по записи или чтению DATA регистра:

static void pcie_mbox_io_write(void *opaque, hwaddr addr,
                               uint64_t val, unsigned size)
{
    ...
    case PCIE_MBOX_CHAN_OFFSET ... PCIE_MBOX_CHAN_END:
        pcie_mbox_chan_write(s, addr, val, size);
        break;
    ...
}
 
static uint64_t pcie_mbox_io_read(void *opaque, hwaddr addr,
                                  unsigned size)
{
    ...
    case PCIE_MBOX_CHAN_OFFSET ... PCIE_MBOX_CHAN_END:
        val = pcie_mbox_chan_read(s, addr, size);
        break;
    ...
}

Обработчик назначен для каждого eventfd и по сути просто вызывает прерывания для канала так же, как это делает QOM:

static void pcie_mbox_vector_notify(void *opaque)
{
    MailboxVector *vec = opaque;
 
    if (!event_notifier_test_and_clear(&vec->push)) {
        return;
    }
 
    trace_pcie_mbox_vector_notify(vec->idx);
 
    pcie_mbox_push_chan(vec->parent, vec->idx, 1);
}
Передача прерываний посредством модели QEMU
Передача прерываний посредством модели QEMU

Добавление KVM-обработчиков для приема прерываний MSI-X

stage 3  hw/misc: pcie_mbox: Add KVM rcv interrupts

До этого момента все вышеперечисленное будет работать как в softmmu-режиме, так и KVM-режиме. Но использование KVM_IRQFD уже ограниченно исключительно KVM-режимом. Вызов ioctl(KVM_IRQFD) ставит в соответствие переданный номер прерывания, переданному (в этом же вызове) файловому дескриптору. Иными словами, запись в файловый дескриптор будет вызывать соответствующее прерывание в гостевом ядре.

Как только драйвер устройства включает прерывания MSI-X, мы устанавливаем обработчики событий маскирования и демаскирования:

static void pcie_mbox_enable_irqfd(PcieMboxState *s)
{
    ...
    /* Удалим все старые обработчики out/in[].push/tx_done */
    for (i = 0; i < s->nr_chans; i++) {
        eventfd = event_notifier_get_fd(&s->out[i].push);
        qemu_set_fd_handler(eventfd, NULL, NULL, NULL);
 
        eventfd = event_notifier_get_fd(&s->in[i].tx_done);
        qemu_set_fd_handler(eventfd, NULL, NULL, NULL);
    }
 
    /* Установим обработчики событий для MSI-X прерываний */
    ret = msix_set_vector_notifiers(pdev,
                                    pcie_mbox_vector_use,
                                    pcie_mbox_vector_release,
                                    NULL);
    ...
}

Тогда при демаскировании прерывания MSI-X мы запрашиваем номер линии прерывания для передачи в KVM_IRQFD c помощью функции kvm_irqchip_add_msi_route() и назначаем ему файловый дескриптор eventfd, полученный нами ранее. С этого момента прерывание идет фактически «напрямую» в гостевое ядро из соседнего экземпляра QEMU, минуя собственный.

static int pcie_mbox_vector_use(PCIDevice *pdev, unsigned nr,
                                MSIMessage msg)
{
    EventNotifier *n;
    ...
    virq = kvm_irqchip_add_msi_route(&c, nr, pdev);
    ...
    ret = kvm_irqchip_add_irqfd_notifier_gsi(kvm_state, n, NULL, virq);
    ...
}

На этой стадии мы уже не можем полагаться на чтение регистров:

  • ISTATUS,

  • TXDONE.

Мы, в принципе, уже не можем полагаться на состояние модели QEMU, так как eventfd-события идут в обход нашей модели, напрямую в гостевое ядро.

Также добавим отдельные обработчики прерываний, рассчитанные на новую схему обмена:

Обработчик множественных прерываний MSI
Обработчик множественных прерываний MSI

Теперь мы не проверяем ISTATUS и TXDONE в обработчиках прерываний и записываем подтверждение о получении в регистр ACK в qemu_mbox_vec_isr():

static irqreturn_t qemu_mbox_vec_isr(int virq, void *data)
{
    ...
    regmap_write(mbox->map, QEMU_MBOX_CHAN_ADDR(vec->idx) + QEMU_MBOX_ACK, 1);
    mbox_chan_received_data(chan, &msg);
 
    return IRQ_HANDLED;
}
 
static irqreturn_t qemu_mbox_txdone_vec_isr(int virq, void *data)
{
    ...
    mbox_chan_txdone(chan, 0);
 
    return IRQ_HANDLED;
}
Прием прерываний непосредственно в гостевом ядре
Прием прерываний непосредственно в гостевом ядре

Добавление KVM-обработчиков для отправки прерываний MSI-X

Также следует учитывать, что если принято решение использовать DMA для записи в регистры, то путь прерывания пойдет по первому методу, так как именно модель DMA внутри QEMU будет писать в регистры, а не модуль ядра в режиме KVM.

Теперь в отправляющей события стороне необходимо проделать обратную операцию — писать в соответствующий eventfd при записи в регистры DATA, ACK. Именно с этой целью на каждый канал был сделан отдельный регистр ACK, поскольку KVM_IOEVENTFD поддерживает только запись:

static int pcie_mbox_setup_kvm_ioevents(PcieMboxState *s, bool enable)
{
    hwaddr addr = s->mmio.addr + PCIE_MBOX_CHAN_STRIDE;
    int i, ret;
 
    for (i = 0; i < s->nr_chans; i++) {
        hwaddr reg = addr + i * PCIE_MBOX_CHAN_STRIDE + A_DATA;
 
        ret = pcie_mbox_setup_kvm_ioevent(&s->out[i].push, reg, 0x01, enable);
        if (ret) {
            error_report("pcie_mbox: Failed to setup kvm_vm_ioctl(KVM_IOEVENTFD)");
            return ret;
        }
    }
 
    for (i = 0; i < s->nr_chans; i++) {
        hwaddr reg = addr + i * PCIE_MBOX_CHAN_STRIDE + A_ACK;
 
        ret = pcie_mbox_setup_kvm_ioevent(&s->in[i].tx_done, reg, 0x01, enable);
        if (ret) {
            error_report("pcie_mbox: Failed to setup kvm_vm_ioctl(KVM_IOEVENTFD)");
            return ret;
        }
    }
 
    return 0;
}

Стандартной инфраструктуры для QEMU в данном случае не предусмотрено, поэтому мы будем оперировать вызовом функции kvm_vm_ioctl():

static int pcie_mbox_setup_kvm_ioevent(EventNotifier *notifier,
                                       hwaddr addr, uint8_t data, bool assign)
{
    struct kvm_ioeventfd kick = {
        .flags = KVM_IOEVENTFD_FLAG_DATAMATCH,
        .fd = event_notifier_get_fd(notifier),
        /* Данные для сравнения, для нашего случая всегда 0x01 */
        .datamatch = data,
        /* Физический адрес регистра */
        .addr = addr,
        .len = 0x4,
    };
 
    if (!kvm_check_extension(kvm_state, KVM_CAP_IOEVENTFD)) {
        return -ENOSYS;
    }
 
    if (!assign) {
        kick.flags |= KVM_IOEVENTFD_FLAG_DEASSIGN;
    }
 
    trace_pcie_mbox_setup_kvm_ioevent(addr, data, assign);
 
    return kvm_vm_ioctl(kvm_state, KVM_IOEVENTFD, &kick);
}

Трассировка назначения файлового дескриптора в соответствие адресу и значению:

pcie_mbox_setup_kvm_ioevent addr: 0xfebf1020 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1040 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1060 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1080 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10a0 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10c0 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10e0 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1100 data: 1 assign: 1
 
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1024 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1044 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1064 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1084 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10a4 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10c4 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf10e4 data: 1 assign: 1
pcie_mbox_setup_kvm_ioevent addr: 0xfebf1104 data: 1 assign: 1

В итоге картина будет выглядеть следующим образом:

Прием и отправка прерываний в гостевых ядрах
Прием и отправка прерываний в гостевых ядрах

Вывод

Итоги замеров

Всего мы сняли по 5000 точек с каждого метода, машина работала в штатном режиме, без применения каких-либо специальных сценариев нагрузки.

Прикладываю характеристики машины, конфигурацию ядра хоста и конфигурацию ядра гостя.

Linux munin 6.9.8-gentoo-x86_64 #1 SMP PREEMPT_DYNAMIC Wed Jul 10 12:20:51 MSK 2024 x86_64 AMD Ryzen 9 7950X 16-Core Processor AuthenticAMD GNU/Linux
Architecture:            x86_64
  CPU op-mode(s):        32-bit, 64-bit
  Address sizes:         48 bits physical, 48 bits virtual
  Byte Order:            Little Endian
CPU(s):                  32
  On-line CPU(s) list:   0-31
Vendor ID:               AuthenticAMD
  Model name:            AMD Ryzen 9 7950X 16-Core Processor
    CPU family:          25
    Model:               97
    Thread(s) per core:  2
    Core(s) per socket:  16
    Socket(s):           1
    Stepping:            2
    Frequency boost:     enabled
    CPU(s) scaling MHz:  51%
    CPU max MHz:         5879.8818
    CPU min MHz:         3000.0000
    BogoMIPS:            9004.46
    Flags:               fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good amd_lbr_v2 nopl nonstop_tsc cpuid extd_apic
                         id aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce
                         topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba perfmon_v2 ibrs ibpb stibp ibrs_enhanced vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid cqm rdt_a avx512f avx512dq
                          rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local avx512_bf16 clzero irperf xsaveerptr rdpru wbnoinvd
                         cppc arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif x2avic v_spec_ctrl vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpc
                         lmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid overflow_recov succor smca fsrm flush_l1d
Virtualization features:
  Virtualization:        AMD-V
Caches (sum of all):    
  L1d:                   512 KiB (16 instances)
  L1i:                   512 KiB (16 instances)
  L2:                    16 MiB (16 instances)
  L3:                    64 MiB (2 instances)
NUMA:                   
  NUMA node(s):          1
  NUMA node0 CPU(s):     0-31
Vulnerabilities:        
  Gather data sampling:  Not affected
  Itlb multihit:         Not affected
  L1tf:                  Not affected
  Mds:                   Not affected
  Meltdown:              Not affected
  Mmio stale data:       Not affected
  Retbleed:              Not affected
  Spec rstack overflow:  Mitigation; safe RET
  Spec store bypass:     Mitigation; Speculative Store Bypass disabled via prctl
  Spectre v1:            Mitigation; usercopy/swapgs barriers and __user pointer sanitization
  Spectre v2:            Mitigation; Enhanced / Automatic IBRS, IBPB conditional, STIBP always-on, RSB filling, PBRSB-eIBRS Not affected
  Srbds:                 Not affected
  Tsx async abort:       Not affected
RANGE                                 SIZE  STATE REMOVABLE BLOCK
0x0000000000000000-0x000000007fffffff   2G online       yes     0
0x0000000100000000-0x000000107fffffff  62G online       yes  2-32
 
Memory block size:         2G
Total online memory:      64G
Total offline memory:      0B

В таблице приведены усредненные значение в микросекундах по 10 измерениям на каждый метод передачи (без использования trace_printk()):

Метод

host haltpoll

guest haltpoll

guest haltpoll + host mitigations=off

1

28 µs

24 µs

13 µs

2

10 µs

7 µs

4 µs

3

7 µs

5 µs

3 µs

Ссылки на сырые данные измерений (guest haltpoll c использованием trace_printk()):

Метод

Файлы измерений

Сводные таблицы измерений

1

logs/0

logs/0_10_5000.csv

2

logs/1

logs/1_10_5000.csv

3

logs/2

logs/2_10_5000.csv

Интерпретация результатов

Мы наблюдаем существенное сокращение времени задержки в сравнении первого и второго методов передачи прерываний и относительно небольшое в сравнении второго и третьего. Существенная часть уменьшения задержек заключается в экономии вызовов и KVM_EXIT_MMIO. Возможно, в каких-то специальных сценариях применения и для другого железа картина будет существенно отличаться, но не в данном случае (я специально воздержался от применения каких-либо сценариев нагрузки).

Но не стоит забывать, что переключение на использование KVM irqs несет за собой следующие неудобства:

  • мы отказываемся от сопутствующих регистров в модели (статус и маскирование прерываний),

  • усложняем драйвер Linux, внося в него изменения,

  • отказываемся от «кросс»-модели (мы не можем применять KVM в режиме softmmu),

  • усложняем отладку и трассировку (так как трассирования методами QEMU уже недостаточно).

Сами по себе модули ядра могут быть использованы для добавления в подсистему mailbox поддержки ACPI, а совместно с какой-либо разделяемой памятью — и для реализации remoteproc/rpmsg для x86_64.

Также можно доработать pcie_mbox_setup_kvm_ioevent() и предложить изменения в upstream QEMU.

Библиография

От автора

Благодарю коллег за ценные комментарии и дополнения:

Комментарии (2)


  1. seyko2
    03.12.2024 20:06

    binder вроде как раз для моментального обмена сообщениями между задачами. В Qemu его использование ещё не встроили? Короче, преимущества конкретного решения перед binder?


  1. seyko2
    03.12.2024 20:06

    Вот мнение GigsChat: Да, ваша точка зрения вполне обоснованна. Одна из возможных причин отсутствия использования Binder IPC в данном конкретном проекте может заключаться в недостаточной осведомлённости разработчиков об этом механизме. Действительно, несмотря на широкую распространённость Binder IPC в экосистеме Android, его применение в других контекстах остаётся ограниченным.

    Вот несколько аспектов, которые могут объяснить такую ситуацию:

    1. Скрытая природа Binder IPC в Android: Как вы правильно заметили, в Android использование Binder IPC скрыто от большинства разработчиков приложений. Он встроен глубоко в систему и используется для взаимодействия между службами и приложениями, но для обычного разработчика Android-приложений это не столь очевидно. Соответственно, многие разработчики могут даже не знать о существовании этого механизма.

    2. Отсутствие активного обучения: В отличие от других механизмов IPC, таких как сокеты или каналы, Binder IPC редко рассматривается в учебных материалах и курсах по программированию. Это может объясняться тем, что Binder IPC тесно связан с Android и считается специализированной технологией, которая не часто обсуждается вне контекста этой платформы.

    3. Ограниченная документация: Документация по Binder IPC доступна, но она зачастую ориентирована на специалистов, работающих непосредственно с ядрами Linux и Android. Это делает её менее доступной для широкой аудитории разработчиков.

    4. Нехватка примеров использования вне Android: Хотя теоретически Binder IPC может быть использован в любых дистрибутивах Linux, примеров его применения вне Android относительно мало. Это затрудняет понимание возможностей и преимуществ данного механизма для разработчиков, работающих в других областях.

    Таким образом, отсутствие знаний о Binder IPC среди разработчиков может быть основной причиной того, что они предпочли создать собственный драйвер вместо использования уже существующей технологии.