nos_
ENRU

От флага в CPU до гостей с Debian: месяц гипервизора nos

Третий этап по git log: за месяц hv.ko прошёл путь от включения AMD-V до Alpine, Debian и многопроцессорных Linux-гостей на железе AMD и Intel.

13 мин чтения
ГипервизорRustx86-64ЖелезоQEMUМодули
Read in English

22 сентября 2026 года гипервизор nos был загружаемым модулем, который умел включать и выключать расширение виртуализации процессора. 21 октября тот же модуль, hv.ko, загружает Alpine и Debian в том виде, в каком их выпускают, и даёт Linux-гостям несколько CPU, диски virtio, коммутируемую сеть с NAT, ACPI и IO-APIC — под AMD-V на Ryzen 5 3600 и под Intel VT-x на i5-13500, причём оба раза на голом железе. Это третий этап дорожной карты, рассказанный по git log: чего стоил каждый шаг и что показывала каждая новая машина такого, чего не могла показать предыдущая.

Форма, выбранная в первый день

Первый же коммит (eec0894) разложил всё на три части. hvarch — расширение процессора: VMCB, VMCS, стабы входа в гостя, управляющие MSR — и весь unsafe гипервизора (тогда 48 мест, к слиянию «интеловского» раунда месяц спустя — 195). hv — всё, что выше, на safe Rust: память гостя и её вложенная таблица страниц, устройства, загрузчик Linux, цикл обработки выходов (тогда два места с unsafe, сейчас четыре). И modules/hv — команда оболочки и задачи, на которых работают CPU гостя. hv и hvarch не входят в default members воркспейса, так что ядро, собранное без модуля, не содержит гипервизора вовсе.

Модуль — потому что состояние, которое он трогает, принадлежит процессору: EFER.SVME и VM_HSAVE_PA на AMD, CR4.VMXE и VMX root operation на Intel, — и машина, на которой не работают гости, не должна его касаться. И потому что выгрузка сама по себе проверка: hv-test.py включает расширение на всех CPU, выгружает модуль, не выключив его, загружает снова и спрашивает у каждого CPU, что у него включено. Контрольный прогон с испорченным выключателем провалил шесть проверок — так мы убедились, что этот гейт вообще умеет падать.

AMD-V пошёл первым, потому что разработка идёт на Mac, где x86-ядро работает под TCG в QEMU, а TCG эмулирует SVM с вложенной трансляцией и не эмулирует VMX вовсе. Дыры у него есть и свои — до версии 9.2 он не пропускает гостя с выключенной страничной адресацией через вложенную таблицу, — поэтому первые встроенные гости стартовали сразу в long mode (подробнее о TCG).

Месяц в одной таблице

Дата Что вошло
2026-09-22 расширение включается и выключается на каждом CPU; VM и шесть встроенных гостей по несколько байт
2026-09-23 hv boot: ранняя консоль Linux, затем шелл BusyBox, выполняющий набранный id; первый настоящий AMD-V; hv start и hv attach; диск и сеть virtio
2026-09-24 Alpine и Debian как есть; NAT, DHCP и DNS; бэкенд VT-x
2026-09-25 что прятал вложенный KVM в бэкенде VT-x; выход там — с 17,9 до 5,8 мкс
2026-09-27 гости с несколькими CPU; MSI-X
2026-09-29 ACPI; MMIO и xAPIC; IO-APIC; фаззер гипервизора
2026-10-11/12 исправления по итогам ревью, затем его раздел об изоляции
2026-10-20 первый запуск на кремнии Intel
2026-10-21 половина ревью, относящаяся к VT-x; «интеловский» раунд

Сутки до шелла

Первая VM (78fd761) запускала гостей из горстки инструкций: ввод-вывод в порты, гиперколл, запись, которую отвергает вложенная таблица, triple fault, cli; jmp $. То, что оставляет хост хозяином, не отдано на откуп политике: hvarch выставляет это при каждом входе, что бы ни лежало в VMCB, — перехват прерываний хоста, всех портов и MSR, #DB и #AC (гость может зациклить их доставку внутри процессора навсегда — CVE-2015-8104 и CVE-2015-5307) и #MC, который заново поднимается в собственный обработчик хоста.

Дальше — Linux. Загрузчик пишет zero page, карту e820, таблицы страниц и GDT через копирующие аксессоры: Rust-ссылки на память гостя нет нигде, потому что гость меняет её за спиной компилятора. Первые загрузки висли в циклах: 100 004 чтения порта 0x42 таймера PIT и 20 000 — порта 0x71 часов RTC в ожидании счётчика, который не считал, и бита обновления, который не сбрасывался (6688f58). С PIT, RTC, парой 8259 и тиком таймера tinyconfig-ядро 6.18 добралось до BusyBox — и через секунду его таймер умер. Простаивающий Linux выполняет sti; hlt, HLT оказывается в «тени прерываний» после STI, и в одном диагностическом прогоне инъекция была отклонена 3,7 миллиона раз (ccbad38). Потом редактор командной строки BusyBox спросил, где курсор (ESC[6n), и закрутился, опрашивая порт в ожидании ответа, который подавался только на простаивающем HLT (e0a2305). 23 сентября, на следующий день после первого коммита:

$ hv boot /bzImage initrd=/initrd cmdline=console=ttyS0 nolapic rdinit=/init
  --- ttyS0 ---
[    0.000000] Linux version 6.18.53 ...
...
[    0.540000] Run /init as init process
nos-guest: init is up, / on ramfs, busybox v1.36.1
...
BusyBox v1.36.1 built-in shell (ash)
~ # id
uid=0 gid=0

Все четыре демонстрации из плана третьего этапа были выполнены. Следующий коммит научил HLT простаивающего гостя останавливать его vCPU, а не выходить из гостя около 46 000 раз в секунду: за те же 120 секунд загрузки 5 633 131 выход превратился в 76 334 (aa8e263).

Что сказало настоящее железо

В тот же день гость запустился на Zen 2 в AX41 — Run /init через 18 мс по часам гостя против полусекунды под TCG — и дважды остановился там, куда TCG никогда не заходил (532219c). Лист CPUID 0x80000001 пропускался целиком, кроме бита SVM, а у Zen 2 там есть MWAITX: udelay гостя превратился в MONITORX — перехват, на который не было ответа. А Linux на любом Zen читает регистр статуса сброса FCH по фиксированному адресу 0xFED803C0, есть там FCH или нет. Лист стал белым списком, инструкция, которую CPUID не предлагал, — тем #UD, который выдал бы процессор без неё, а чтение из окна MMIO, на которое никто не отвечает, теперь находит доступную только на чтение страницу из одних единиц. Встроенный гость absent, читающий её, держит это поведение и под TCG.

AX41 продолжал в том же духе. Гость, ушедший в перезагрузку, крутил свой CPU на 100%: 1,3 миллиона чтений статуса несуществующего контроллера 8042 за 48 секунд (f58af03). Часы Debian насчитали 25 секунд из 62: при 250 Гц его vCPU, которого будил тик хоста на 100 Гц, получал один фронт таймера на два-три периода. Теперь каждый прошедший период считается долгом и отдаётся по одному, как это делает PIT в KVM, а hv-distro-test.py сверяет часы простаивающего гостя с часами nos: 1,000 с исправлением и 0,514 без него (768a56b).

Гости, которые живут долго

hv start оставляет гостя работать; до него дотягиваются hv list, exec, send, wait и stop, а hv attach выводит его консоль вживую через ssh -t (98100a6, 3aab12c). Диски — legacy virtio-blk поверх PCI, доступный через порты ввода-вывода, так что декодер инструкций всё ещё не нужен; кольца принадлежат гостю, и каждый индекс, адрес и длина проверяются перед использованием (cf32d7c). Сеть — virtio-net на коммутаторе внутри модуля, а со стороны nos — виртуальная сетевая карта hv0 в его собственном стеке (1f67325); следом пришли NAT, DHCP-сервер на коммутаторе и DNS-сервер nos для гостей (45df223).

Затем загрузился ISO virt от Alpine в том виде, в каком его выпускают (47568b5), и потребовал того, без чего обходилось специально собранное ядро: режима однократного срабатывания PIT для таймеров высокого разрешения; RTC со временем хоста — раньше он отвечал фиксированной датой 2026-01-01, и OpenRC считал каждый файл на ISO пришедшим из будущего; и топологии CPUID на один процессор, потому что гость принимал APIC ID процессора хоста за свой. Облачный образ Debian 13 подтянулся той же ночью, настроенный через systemd.set_credential= в командной строке ядра (8928493). На AX41 холодный apt-get update занял 39 секунд и потерял около 1400 кадров: кадр для занятого гостя ждал следующего тика хоста. Теперь коммутатор выбивает работающий vCPU из гостя, как это делает KVM (3c8bd36).

VT-x и то, что прятал вложенный KVM

Бэкенд Intel появился 24 сентября (232a1d8); его поднимали под вложенным KVM на машине с Intel. На пути стоял один бит за пределами гипервизора: VMX требует CR0.NE, nos его никогда не выставлял, и vmxon закончился бы исключением и паникой. Теперь boot64.asm выставляет его на каждом CPU, а hv on заранее проверяет фиксированные биты и называет процессор, который отказывается (dc059db). Дальше каждое исправление было ответом на падение настоящего ядра:

  • Linux загрузился целиком и упал с oops на RIP: 0x0, едва запустив /init: первый SYSCALL из userspace прыгнул по LSTAR хоста. У VMX нет vmsave, поэтому MSR системных вызовов теперь переключаются через списки загрузки MSR (0eb1531).
  • Alpine словил double fault при первом же возврате прерывания в пользовательский режим: swapgs меняет KERNEL_GS_BASE без всякого wrmsr, который можно было бы перехватить, поэтому значение гостя теперь сохраняется на каждом выходе (1d293b7).
  • Alpine шёл до приглашения входа десятки минут: шестьдесят с лишним vmread/vmwrite на выход, и каждый — ловушка во внешний KVM. С ленивой синхронизацией вышло около 19 секунд (495b58b), а после замеров и чистки выход подешевел с 17,9 мкс до 5,8 (0478d8b).

Потом ревью по мануалу нашло то, что прикрывает вложенный KVM, — и всё найденное проходило гейты в их тогдашнем виде (e433276). Худшее: CR8 не перехватывался, и mov cr8, 15 в госте записывал приоритет задачи в самом процессоре хоста, отсекая от него все прерывания, кроме NMI, — включая IPI для TLB shootdown, а это паника через десять секунд. Теперь встроенный гость tpr проверяет, что CR8 хоста по-прежнему равен 0 после того, как гость записал туда 15.

Машина, которую ждёт Linux

cpus=N (14a03e0) даёт гостю до 16 CPU, у каждого свой x2APIC, а остальные процессоры запускаются так, как их запускает PC: INIT, затем start-up IPI в реальный режим. Linux тут же намерил шину APIC в 10 005 МГц и отказался от таймера: крутясь в calibrate_APIC_clock, он не делал выходов, а потом получал накопившиеся тики PIT одной пачкой. Помогли выходы по PAUSE. На AX41 два CPU гостя на двух CCX видели свои TSC в 2052 тактах друг от друга: на этих семействах AMD инструкция LFENCE сериализует только при выставленном бите в DE_CFG, который прошивка оставила сброшенным, — теперь nos выставляет его при загрузке (c94e584). MSI-X сократил прерывание приёма с шести выходов через 8259 до одного: Debian с двумя CPU трижды скачал гигабайт на 117 МБ/с без единого потерянного кадра (0f9480b).

29 сентября пришёл ACPI, и в его DSDT обязательно должен быть PCI host bridge: Linux с ACPI без него шину не сканирует (bc9e15c). А hv stop стал начинать с нажатия кнопки питания:

$ hv stop 0 secs=300
hv: vm 0 stopped -- the guest powered itself off, S5 by its ACPI PM1 control register, at 0xffffffff8f1d5ae3

В тот же день появился MMIO (57c9826): инструкция, вызвавшая отказ, выбирается через собственные таблицы страниц гостя, декодируется — только те формы mov, которыми пользуются MMIO-аксессоры Linux; декодер сверен с кодировками clang для 1054 инструкций и с двумя миллионами случайных байтовых строк — и исполняется. Страница xAPIC стала первым устройством на этом пути, IO-APIC — вторым (86a1721).

Фаззинг, ревью, изоляция

Тем же вечером hv-fuzz собрал исходники гипервизора под хост и прогнал их — каждое устройство, путь MMIO, загрузчик и всю машину целиком под настоящим циклом выходов, где вместо процессора действует скрипт, — и отчитался о пяти находках, среди них запись статуса диска, адрес которой переполнялся за 2^64 (4ce4e77). Днём позже его цель guestnet на первом же входе обнаружила, что гость может слать кадры от имени другого и уводить его NAT-потоки; теперь коммутатор проверяет каждый кадр там, где тот входит (d1aeecf; подробнее — в посте о фаззинге).

10 октября ревью всего гипервизора, которое вели пятеро читателей одновременно, оставило список; его закрыли по коммиту на пункт (57347b1), а затем и раздел об изоляции (19d0d14): гости могли достучаться до собственного sshd nos и занять все его слоты для входа, NAT пускал их к адресу метаданных облака, а политика CPUID сообщала им о средствах управления спекуляцией, которых у них не было. Спекуляциям посвящён отдельный пост.

Кремний Intel и «интеловский» раунд

20 октября nos загрузился на EX44 — i5-13500, шесть P-ядер по два потока и восемь E-ядер — из своего системного образа, рядом с Ubuntu. Все встроенные гости прошли и на P-ядре, и на E-ядре; выход стоил 1,67 и 2,07 мкс против 5,8 под вложенным KVM. Linux работал с одним, двумя и четырьмя CPU, гость скачал гигабайт через NAT за 9,1 секунды, а rmmod при пяти запущенных VM остановил их все и оставил VMX выключенным на всех двадцати CPU. Кремний подтвердил и пробелы VT-x из списка ревью; исправления влились на следующий день (48bfaf5).

В тот же день влился «интеловский» раунд (3912397). Все ядра работали на базовой частоте 2,5 ГГц; с аппаратными P-состояниями (HWP) один и тот же цикл, нагружающий процессор, выполнялся в госте 3,06 секунды вместо 5,55 (Linux на той же машине — 2,81). Первая версия HWP повесила загрузку EX44 так, что ни одна консоль не показала причину, поэтому теперь panic=N перезагружает машину через N секунд после отчёта о панике. Гостям показываются лист 7 и ARCH_CAPABILITIES — и Alpine перестал считать свой процессор уязвимым к Meltdown, а pause-loop exiting сократил 20-секундную загрузку гостя с двумя CPU с 299 508 выходов до 99 297. Виртуальный APIC тоже вошёл, но только по запросу (apicv): в тесте TCP между гостями он сократил число выходов на 25–50%, а в среднем всё равно вышло 5,88 секунды против 5,70 без него — прерыванию для другого vCPU по-прежнему нужен «пинок», который уберут posted interrupts.

Что дальше

Чего не хватает, перечислено в документации гипервизора: вынести работу устройств из задачи первого vCPU, posted interrupts, XSAVE и AVX для гостей (дистрибутив, собранный под x86-64-v3, не стартует), современный virtio, HPET и ECAM для PCIe; бэкенд для arm64, который сегодня только отчитывается и отказывается работать, потому что nos сбрасывает EL2 при загрузке; и плоскость управления четвёртого этапа, которая спрячет hv start за API. Попробовать то, что уже есть, поможет инструкция по запуску Linux-гостя, а общая картина — на странице гипервизора.

Обновление от 22 октября 2026: posted interrupts влились на следующий день после этого поста, и вместе с ними доставка прерываний гостя самим процессором стала поведением по умолчанию везде, где у процессора они есть. Следом пришли one-shot-таймер хоста, модерация прерываний приёма, offload сетевой карты и отображённые кольца virtqueue — и тот же тест TCP между гостями теперь занимает 2,72–2,83 с. Вынос работы сетевой карты в отдельную задачу измерили и отложили: для гостя с одним CPU быстрее не стало. Подробности — в посте о сети гостей.