nos_
ENRU
Гипервизор

Гипервизор

Гипервизор первого типа на Rust: модуль, который загружается в работающий nos и запускает немодифицированный Linux, Alpine и Debian на AMD-V и Intel VT-x.

У nos есть собственный гипервизор. Это два крейта на Rust — hvarch, отвечающий за расширение виртуализации процессора, и hv, всё, что выше, — собранные в загружаемый модуль hv.ko: его подгружают в работающее ядро через insmod и выгружают через rmmod. После загрузки он включает AMD-V или Intel VT-x и запускает гостей: крошечных встроенных, каждый из которых проверяет один механизм, немодифицированный Linux bzImage, а также ISO Alpine и облачный образ Debian в том виде, в каком их выпускают, — с несколькими CPU, дисками virtio, сетью с NAT, ACPI и кнопкой питания. На голом железе под ним нет никакого Linux: машиной владеет nos.

Это третий этап дорожной карты, в конце которой — облачный узел на голом железе без Linux-хоста, управляемый по HTTP и обновляемый на лету под работающими гостями. Конечная цель — чужие Linux-гости на этой машине, поэтому изоляция идёт раньше скорости. Полный справочник — «The hypervisor», около 3800 строк; здесь — версия на пять минут.

Почему модуль

Причин три (подробно — в разделе «Why a module»):

  • Машина без гостей не несёт ничего из этого. hv и hvarch не входят в default members воркспейса, поэтому в ядре, собранном без модуля, нет ни одного символа гипервизора — и никакого его состояния в процессоре: EFER.SVME и MSR_VM_HSAVE_PA на AMD, CR4.VMXE и VMX root operation на Intel. Модуль загружен — всё это включено, выгружен — выключено.
  • Перезагрузка — худший способ отлаживать настройку VMCB и VMCS. insmod, попробовать, rmmod, поменять одно поле, снова insmod — цикл в несколько секунд.
  • Выгрузка — это гейт. hv-test.py включает расширение на всех CPU, выгружает модуль без выключения, загружает снова и спрашивает у каждого CPU — через IPI, который читает регистр, — что осталось включённым. rmmod, забывший про какой-нибудь CPU, оставил бы его сохранять состояние хоста в освобождённую страницу. Живому обновлению на пятом этапе нужна та же проверка — здесь она появилась на годы раньше.

Цена тоже названа: модуль не может зарегистрировать ни блочное устройство, ни сетевую карту («Loadable modules»), поэтому диски и сетевые карты гостя модуль эмулирует поверх устройств самого ядра (kcore::block::Disk, kcore::net::Nic) — а гипервизору именно это и нужно.

Где живёт unsafe

Граница между кодом, который трогает расширение процессора, и всем остальным проведена один раз — в графе крейтов, где её может посчитать скрипт («The crates»): в hvarch — инструкции, VMCB и VMCS и переключение между гостем и хостом; в hv — память гостя, политика, устройства, загрузчик Linux и цикл исполнения. Вот как их считает scripts/unsafe-count.py на master в октябре 2026 года:

Крейт Строк Мест с unsafe
hvarch — расширение 6593 201
hv — всё, что выше 18 929 4
modules/hv — модуль 5506 0

Четыре места в hv — это два обработчика IPI, которые включают и выключают расширение, и два входа в гостя, по одному на бэкенд. Входы видны только внутри крейта и принимают &GuestMemory, по таблице которой входят, так что их предусловия — инварианты типов, а не обещания вызывающего кода:

  • Память гостя — это страницы, которые не отображены нигде, кроме вложенной таблицы самого гостя, и обнуляются раньше, чем гость их увидит; для хоста отображены ещё только кольца очередей virtio — пока очередь поднята. Rust-ссылку внутрь неё не выдаёт никто: гость может писать туда в любой момент с любого CPU, а ссылка на память, которая меняется под компилятором, — это неопределённое поведение. Поэтому любой доступ — копия с проверкой границ, а в этих кольцах — одна volatile-загрузка или запись по проверенному выровненному смещению («Its memory»).
  • Вложенная таблица страниц живёт внутри неё, отображает только страницы, которыми владеет это же значение, и только растёт. Гость, добравшийся до памяти хоста, которую ему не давали, — не та ошибка, которую может совершить вызывающий код.

То, что процессор спрятан за одним крейтом, заодно позволяет hv-fuzz.py собирать большую часть hv для хоста поверх подставного CPU и фаззить её в CI (о фаззинге ядра на хосте).

Сначала AMD-V, потом Intel VT-x

Порядок определило то, где идёт разработка («AMD-V first, Intel VT-x second»). А идёт она на Mac, где ядро x86-64 работает под TCG из QEMU, и TCG эмулирует SVM вместе с вложенной трансляцией адресов, а VMX не эмулирует вовсе. К тому же AMD-V проще: VMCB — обычная структура в памяти, а VMX требует vmread/vmwrite для каждого поля и сообщает об отказе во входе одним кодом ошибки, за которым скрывается дюжина разных причин. Запускать нужно с -cpu max: модель по умолчанию qemu64 даёт SVM без вложенной трансляции. Есть у TCG и свои тёмные углы — о двух из них рассказывает статья «Когда баг в эмуляторе».

Intel VT-x написан для машин, на которых nos работает на самом деле, — Hetzner EX44 и ноутбук Dell оба на Intel, — а поднимали его под вложенным KVM на i5-13500. Выше hvarch у бэкендов одна форма: hv::vm::Backend — это Svm или Vmx, и оба хранят состояние гостя в формате save area от AMD (для VMX — теневую копию, которая лениво синхронизируется с VMCS), так что политика и загрузчик написаны один раз («The VMX backend»). Последнее препятствие на пути к vmxon нашлось вообще вне гипервизора: VMX требует CR0.NE, а nos этот бит никогда не ставил. Поэтому hv on отказывался (а hv info называл бит), пока загрузочный код не начал ставить его на каждом CPU.

Что он запускает

  • Встроенные гости. Их девятнадцать: каждый — горстка инструкций, хранящаяся байтами рядом со своим исходником в guests.rs, и каждого сверяют с тем, что ему велели сделать: память выше 4 ГиБ и все регистры через гипервызов, запись, остановленная вложенной таблицей, triple fault, который останавливает гостя, а не CPU, cli; jmp $, который хост всё равно останавливает, второй CPU, запущенный через INIT и start-up IPI, тысяча IPI крутящемуся в цикле CPU, которые он принимает прямо в госте, если у процессора есть posted interrupts («The built-in guests»).
  • Немодифицированный Linux. hv boot загружает 64-битный bzImage по протоколу загрузки поверх эмулированных 8250, PIT 8254, RTC MC146818 и пары 8259; CPUID и MSR устроены как allow-list («A Linux guest»; как до этого дошли — в статье о Linux под гипервизором nos).
  • Дистрибутивы как есть. ISO Alpine virt, где сам ISO служит гостю диском только для чтения, — вплоть до входа под root; облачный образ Debian 13 под systemd — в состоянии running без единого упавшего юнита («A distribution»).
  • Несколько CPU. cpus=N, до 16, у каждого свой локальный APIC — x2APIC, если не сказано xapic; все, кроме первого, стартуют как на обычном PC — через INIT и start-up IPI в реальном режиме; прерывания virtio приходят по MSI-X, а IO-APIC даётся по запросу («More than one CPU»). Под VT-x, если у процессора есть posted interrupts, прерывания гостя в режиме x2APIC — в том числе IPI от других CPU и MSI устройств — он доставляет сам, без выхода («Interrupts the CPU delivers itself»).
  • Диски и сеть. disk= — это legacy virtio-blk поверх файла nos, кольцам которого хост не доверяет ни в чём. net — порт virtio-net на коммутаторе модуля: nos по адресу 10.0.100.1 за своей виртуальной сетевой картой hv0, NAT наружу, DHCP, на который отвечает сам коммутатор, и hv forward для входящих соединений; контрольные суммы и TCP-отправки до 64 КиБ отдаются устройству в обе стороны, от гостя к гостю отправка идёт целиком, а прерывания приёма приходят не чаще раза в 100 мкс («A disk», «A network», о сети гостей).
  • ACPI и MMIO. Каждый гость получает таблицы ACPI и описанное в них фиксированное железо, так что hv stop нажимает кнопку питания, на которую отвечает systemd-logind или acpid из BusyBox («ACPI»). Устройства с регистрами в памяти — пока это страница xAPIC и IO-APIC — работают так: инструкция, которая споткнулась о неотображённую страницу, выбирается через таблицы страниц гостя и декодируется — без единого unsafe («MMIO»).

Сеанс

Жизненный цикл гостя в виде команд оболочки — на четвёртом этапе их спрячут за HTTP. Это прогон гейта под TCG, где гость добирается до оболочки через секунду после старта («Guests that stay up»):

$ insmod /hv.ko
$ hv on
$ hv start /bzImage initrd=/initrd cmdline=console=ttyS0 nolapic
hv: vm 0 started on cpu 3 -- /bzImage, 256 MiB, cmdline "console=ttyS0 nolapic"
$ hv exec 0 secs=60 id
... the rest of its boot ...
~ # id
uid=0 gid=0
~ #
$ hv send 0 echo nos$((6*7))nos\n
hv: vm 0: 20 bytes queued
$ hv wait 0 nos42nos
hv: vm 0 printed "nos42nos", 58 ms in
$ hv list
vm 0  running  cpu 3  256 MiB  1 s  exits 18237  irq 284  hlt 92  /bzImage
$ hv stop 0
hv: vm 0 stopped -- on request -- nothing in it listens to its power button
  stopped    on request, after 1925 ms
$ rmmod hv
module: hv unloaded

hv exec ждёт приглашения, напечатанного после того, как ушла его строка, поэтому строка, набранная гостю, который ещё грузится, возвращается с ответом; hv attach через ssh -t — это живая консоль. Пошагово всё это разобрано в how-to про Linux-гостя.

Как гости изолированы

То, что отделяет гостя от хоста и от других гостей, задаётся ниже политики — на каждом входе, что бы политика ни попросила («What entering a guest cannot be told to do»):

  • Перехваты, от которых зависит хост: его прерывания и NMI, все порты, все MSR, кроме трёх собственных сегментных баз гостя, инструкции виртуализации, #DB и #AC (их доставку гость иначе мог бы зациклить внутри CPU — CVE-2015-8104, CVE-2015-5307) и #MC. Бит CR4.MCE всегда установлен, так что machine check во время работы гостя доходит до хоста, а не прикидывается triple fault гостя.
  • TLB: поколения ASID на каждом CPU под AMD-V; под VT-x — VPID на всю жизнь гостя и INVEPT при его первом входе на каждом CPU («Address space identifiers»).
  • Спекулятивное исполнение при переключении: очистка регистров после каждого выхода, перезаполнение стека возвратов, IBPB между гостями, а там, где процессору это нужно, — автоматический IBRS на AMD и IBRS с BHI_DIS_S, VERW и сброс L1D на Intel (об изоляции спекуляций гостя).
  • Регистры, которые гость считает своими: DR0–DR3 хранятся для каждого гостя отдельно; CR4 читается таким, каким гость его записал, а в настоящем регистре MCE остаётся.
  • Ядра: по умолчанию гость получает целые ядра, на которых нет CPU других гостей, — два потока одного ядра делят то, на чём стоят MDS и L1TF.
  • Сеть и сервисы хоста: кадр обязан нести MAC и адрес своего порта, а отправка, которую гость оставил резать своей сетевой карте, даёт не больше 256 кадров; гость не дотягивается ни до sshd самого nos, ни до его соединений; NAT не пускает гостей к адресу метаданных облака, в частные сети и в подсеть аплинка, если nat allow не говорит иного; у общих ресурсов есть квоты на каждого гостя.
  • Память: резерв, который не может занять ни один гость, — шестнадцатая часть RAM, но не меньше 64 МиБ; а кольца устройства отображаются для хоста восемь раз, дальше — раз в секунду, потому что каждое снятое отображение прерывает все CPU хоста.

Каждое исправление пришло со своим гейтом — встроенным гостем, проверкой в hv-linux-test.py, моделью в hv-fuzz.py, — и для большинства из них показано, что на заменённом коде они падают. Открытые вопросы тоже записаны, среди них SRSO и Retbleed на Zen 1–4 («Before other people's guests»).

Сколько стоит выход

hv bench запускает на одном CPU гостя, который только и делает, что выходит, и замеряет время («What an exit costs»):

Где Расширение Один выход
TCG из QEMU на Mac AMD-V, эмулированный 17 130 нс — о настоящем CPU это ничего не говорит
вложенный KVM на i5-13500 VT-x 17 900 нс, затем 5800 нс после того, как путь входа облегчили; 4,6 мкс из них приходятся на внешнее ядро
AX41, Ryzen 5 3600 (Zen 2), голое железо AMD-V 731 нс с сохранёнными ASID, 940 нс со сбросом TLB на каждом входе
EX44, i5-13500, голое железо, базовая частота VT-x 1,69 мкс на P-ядре, 2,07 мкс на E-ядре; 1,82 и 2,49 мкс с защитами от спекулятивных атак
EX44, ядра на турбочастоте VT-x 1018 нс на P-ядре, 1512 нс на E-ядре

Доступ к MMIO дороже, потому что хосту приходится дойти до инструкции, выбрать её и декодировать: на P-ядре EX44 выход по CPUID стоит 1,8 мкс, а загрузка из MMIO — 4,5. Простаивающий гость почти ничего не стоит: шестнадцать таких на двенадцати CPU AX41 загружают машину на 1,6%.

Где он уже работал

  • TCG из QEMU и вложенный KVM на хостах AMD и Intel — там гоняются гейты.
  • AX41 (Ryzen 5 3600, Zen 2), голое железо: Linux доходит до Run /init за 18 мс по своим часам — против полсекунды под TCG; Debian выходит к приглашению входа меньше чем за четыре секунды; гигабайт через NAT скачивается на 117 МБ/с; диск гостя пишется на 1,2 ГБ/с. Первый же прогон на нём дважды остановился там, где TCG не останавливался никогда: лист CPUID 0x80000001 пропускал MWAITX, а Linux на Zen читает FCH от AMD по фиксированному адресу, есть он там или нет («On real hardware»).
  • EX44 (i5-13500, шесть P-ядер и восемь E-ядер), голое железо, VT-x, с 2026-10-20: встроенные гости на P- и E-ядрах, Linux с одним, двумя и четырьмя CPU, диск virtio на 1,0–1,2 ГБ/с с fsync, Alpine у приглашения входа за 13 с и rmmod при пяти работающих ВМ, после которого VMX выключен на всех двадцати CPU («The EX44, under VT-x»). С 2026-10-22 один гость отправляет там другому 500 МБ по TCP за 2,72–2,83 с вместо 5,72, а пять скачиваний 100 МБ с веб-сервера занимают 0,29 с — 1,8 ГБ/с (о сети гостей).
  • Пока нет: Skylake в ноутбуке Dell; и arm64, где гипервизор — не расширение, которое ядро включает, а уровень исключений, на котором ядро работает. nos при загрузке опускается с EL2 на EL1, так что этот бэкенд только сообщает, что предложил бы EL2 («arm64»).

Команды

Команда Что делает
hv, hv info расширение и CPU, на которых оно включено, — со слов самих CPU
hv on, hv off включить или выключить, на одном CPU или на всех
hv run <guest|all> [cpu] запустить встроенных гостей
hv bench замерить выход или доступ к MMIO
hv boot <bzImage> ... Linux-гость на заданное время, потом его консоль
hv start <bzImage> ... то же, но гость остаётся работать: mem=, cpus=, initrd=, disk=, net, restart, xapic, ioapic, cmdline=
hv list, hv console, hv attach список гостей; консоль одного из них — что уже было или вживую
hv send, hv exec, hv wait напечатать гостю, выполнить строку, дождаться текста
hv restart, hv stop перезагрузить гостя; нажать кнопку питания, затем остановить
hv forward, hv cores пробросить порт к гостю; свои ли у гостя ядра

Полный синтаксис — в «Shell commands», а как гонять гейты — в how-to про гейты.

Что дальше

Все четыре демонстрации третьего этапа готовы — гость из нескольких байт, long mode под вложенной трансляцией, ранняя консоль bzImage, полная загрузка до оболочки, — а диски, сеть и SMP-гости, которые план оставлял на четвёртый этап, появились раньше. Дальше, по разделу «What comes next»:

  • Больше устройств на пути MMIO: HPET и ECAM от PCIe, которых сегодня нет в таблицах ACPI гостей.
  • Скорость — после posted interrupts, one-shot-таймера, offload и отображённых колец: работа устройств не в задаче первого CPU — для гостей с MSI-X и свободным CPU (для гостя с одним CPU отдельная задача сетевой карты оказалась не быстрее); дисковый путь без единственной блокировки VFS; меньше выходов на запрос; доставка прерываний самим процессором для гостей в режиме xAPIC и AVIC от AMD; XSAVE для гостей — без него нет AVX, и дистрибутив, собранный под x86-64-v3, не стартует; современный virtio.
  • Этап 4, control plane (план): HTTP API на собственном TCP-стеке nos — создать, перечислить, уничтожить, транслировать консоль — с типизированными описаниями ВМ, определениями, которые переживают перезагрузку, и квотами.
  • Этап 5, живое обновление (план): новое ядро хоста под работающими гостями, их RAM остаётся на месте, простой — меньше 100 мс. Для этого всё состояние ВМ должно быть простыми сериализуемыми данными, а модели устройств не должны держать указателей в память ядра — пока это выполняется не полностью. Первая веха обходится без kexec: rmmod hv, затем insmod hv — а гости тем временем продолжают работать.
  • arm64: бэкенд для EL2 — это изменение загрузочного пути, а не модуля.