Гипервизор
Гипервизор первого типа на 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 — это изменение загрузочного пути, а не модуля.