Ring 1: микроядро для самых маленьких

Зачем мы завели Ring 1 для драйверов в нашем unikernel — и почему это получилось дешевле, чем казалось в 90-х. Прямая доставка прерываний, IOMMU per-driver, lock-free IPC, watchdog-супервизор. С конкретными файлами и тремя шишками, на которые натирали по два дня.

В этой статье: что такое кольца защиты x86, и почему индустрия за сорок лет так и не научилась пользоваться третьим. Зачем мы в нашем unikernel завели Ring 1 для драйверов — и почему именно для драйверов, а не для приложений. Что такое прямая доставка прерываний с DPL=1, IOMMU per-driver, lock-free IPC очереди и watchdog-супервизор. Как это устроено у MINIX 3, у seL4 и у Linux (никак). Как это устроено у нас — с конкретными файлами, номерами строк и историей граблей. В конце — три шишки, которые мы натирали по два дня каждую.

Кому читать: инженеру, который слышал слово «микроядро» и думал, что это про MINIX в учебнике Танненбаума. Архитектору, который хочет понять, почему падение драйвера сетевой карты в unikernel не уносит запущенное на нём приложение. CISO, который читает отчёт о сертификации и хочет понять, что значит фраза «изоляция драйверов на уровне аппаратной защиты CPU». Старому сисадмину, который видел MINIX в учебнике, microdrivers в академических статьях и Linux в проде, и хочет наконец увидеть Ring 1 в живом коде.


1. Холодная сцена

Четверг, 16:40. Переговорка «Сосна» в офисе Kernix. На доске — архитектура нашего ядра, нарисованная маркером, который заканчивается: наверху Ring 0 с ядром, ниже список драйверов, рядом — приложение поверх runtime. Под схемой крупными буквами: «Что будет, если у нас упадёт virtio-net?»

За столом — двое. С одной стороны — наш ядерный разработчик. С другой — инженер-сосед, который пилит на нашем unikernel приложение и смотрит на это всё со стороны рантайма. Перед ним ноутбук с открытым серийным логом со стенда, где позавчера ушёл в triple fault весь хост — virtio-net поймал page fault на битом DMA-буфере, и выгребло вообще всё. Он листает трейс и говорит:

— Слушай, я простую вещь хочу понять. У нас же unikernel. Один процесс, одно адресное пространство, всё в Ring 0. Я правильно понимаю, что если драйвер сетевой карты ловит page fault — падает вся виртуалка целиком?

Ядерный разработчик молчит секунду. Потом отвечает:

— Полгода назад ты был бы прав. С декабря — нет. Драйверы вынесены в Ring 1, у них отдельный exception handler, отдельный IST-стек, отдельный домен IOMMU. Падает драйвер — супервизор его рестартует, приложение работает.

Сосед моргает.

— Постой. Ring 1. Это вот то самое Ring 1 из учебников Intel, которое никто никогда не использовал? Серьёзно?

— Серьёзно. Хочешь, покажу?


2. Исторический контекст: четыре кольца, которые никому не пригодились

Когда в 1985 году Intel выпустила процессор 80386, инженеры заложили в архитектуру защиты четыре кольца привилегий: Ring 0, Ring 1, Ring 2, Ring 3. Идея была элегантной. Ring 0 — самый привилегированный, там ядро. Ring 3 — самый ограниченный, там приложения. А промежуточные Ring 1 и Ring 2 — для кода ядра, которому не нужны абсолютные права: драйверов, файловых систем, сетевого стека. Микроядро как аппаратная фича.

Прошло сорок лет. Откройте сейчас исходники любого современного ядра — Linux, FreeBSD, Windows NT. Сколько колец они используют? Два. Ring 0 для ядра. Ring 3 для пользовательских процессов. Кольца 1 и 2 — пустуют. Сорок лет аппаратной фичи, которой никто не пользуется.

Почему так случилось? Несколько причин.

Первая — мода на монолиты в 90-х. Линус Торвальдс публично поссорился с Танненбаумом ещё в 1992 году в comp.os.minix. Линус утверждал, что микроядра — академическая чушь, а монолитное ядро проще, быстрее и реалистичнее. Танненбаум возражал. Linux выиграл рынок, MINIX остался в учебниках, а с ним — и идея использовать Ring 1 для драйверов.

Вторая — переносимость. В 1996 году Microsoft выпустила Windows NT 4.0 на четырёх архитектурах: x86, MIPS, Alpha, PowerPC. У RISC-процессоров никаких четырёх колец не было — обычно только два режима, supervisor и user. Поэтому Microsoft решила: используем только Ring 0 и Ring 3, чтобы код ядра был одинаковым. Linux пошёл тем же путём.

Третья — виртуализация. Когда виртуализация на x86 стала массовой (VMware Workstation — 1999, Xen — 2003, KVM в мейнлайне Linux — 2007), Ring 1 неожиданно понадобился — гипервизор стал жить в Ring 0, а гостевое ядро уехало в Ring 1. Это называлось «ring deprivileging» в paravirt-режиме Xen. Но схема прожила лет пять и умерла, как только Intel и AMD добавили VT-x (2005) и AMD-V (2006) — отдельный режим VMX root, который сделал Ring 1 для гипервизоров ненужным. Кольцо снова осталось пустым.

И так получилось, что в 2026 году единственное место, где Ring 1 используется по назначению — это специализированные ОС вроде нашего unikernel. Не Linux. Не Windows. Не FreeBSD. Никто.

Параллельно в академии всё это время жила альтернативная линия — микроядерная. MINIX 3 (Танненбаум, начиная с 2005-го) вынес драйверы в отдельные процессы в Ring 3, общающиеся через IPC. seL4 (NICTA, формально верифицированное ядро, 2009) сделал то же самое, но математически доказал отсутствие багов в самом ядре. Singularity от Microsoft Research (2003) пошла ещё дальше — изоляция через типобезопасный язык, без аппаратных колец вообще. Все эти системы доказывали одну простую идею: драйвер устройства не должен ронять ядро.

Мы пришли к той же идее со стороны unikernel. И решили, что если уж мы платим за изоляцию, то платим её аппаратно — через ring-защиту, IOMMU и прямую доставку прерываний.

Главное историческое наблюдение, которое мы вынесли: аппаратная изоляция драйверов оказалась дешевле, чем казалось в 90-х, и сильно дешевле, чем IPC-через-сообщения в стиле MINIX 3. Об этом — дальше.


3. Техническое ядро

3.1. Что такое драйвер в монолитном unikernel

Наше ядро по своей природе — unikernel. Один процесс, одно адресное пространство, всё в Ring 0. Когда мы запускаем поверх него типичное приложение, в этом адресном пространстве живут одновременно: прикладной бинарь, рантайм языка, virtio-net (драйвер сетевой карты), virtio-blk (драйвер диска), virtio-scsi, virtio-rng.

Если у virtio-net случается page fault на битом буфере DMA — традиционный ответ был abort(), halt, конец. Виртуалка падает, приложение не работает. Именно так и упал тот стенд из холодной сцены.

Решение, которое мы приняли: драйверы — это отдельная категория кода, и их падение не должно ронять приложение. У них:

  • свой класс изоляции (Ring 1, DPL=1 в дескрипторах),
  • свой стек обработчиков исключений (IST[3], 8 КБ на CPU),
  • свой домен IOMMU (если железо поддерживает Intel VT-d или AMD-Vi),
  • свой обработчик отказов, известный супервизору,
  • свой watchdog, который перезапускает упавший компонент.

При этом прикладной код остаётся в Ring 0. Это сознательное решение: горячий путь запроса не пересекает кольцо. Платим за изоляцию мы только там, где она реально нужна — на границе с железом.

3.2. Кольца x86 как аппаратная защита

Каждый сегмент кода и каждый дескриптор в IDT (таблице прерываний) имеет два поля привилегий: CPL (Current Privilege Level — у текущего кода) и DPL (Descriptor Privilege Level — у дескриптора). Правило простое: перейти можно только в кольцо равного или меньшего номера. Из Ring 1 нельзя руками войти в Ring 0 — нужно прерывание или вызов через call gate.

В нашем случае это означает: код драйвера, помеченный CS с CPL=1, не может выполнить инструкции, требующие CPL=0 — lgdt, lidt, mov to cr3, wbinvd. Если попробует — получит #GP (general protection fault), и мы его обработаем сами, без падения ядра.

3.3. Прямая доставка прерываний (DPL=1)

Стандартный путь прерывания в монолитной системе: железо генерирует IRQ → CPU прыгает в IDT-обработчик в Ring 0 → обработчик решает, кому это адресовано → диспетчеризует драйверу. Между «железо подняло прерывание» и «драйвер начал его обрабатывать» проходит цикл через Ring 0. Это стоит миллисекундных долей, но главное — это дополнительная точка отказа в Ring 0.

Мы сделали иначе. В IDT для векторов 34-36 (наши Ring 1 драйверные вектора) лежит interrupt gate, у которого селектор кодового сегмента указывает на сегмент с DPL=1. Для аппаратного IRQ DPL самих ворот CPU игнорирует — направление перехода задаёт селектор в воротах, и CPU прыгает прямо в Ring 1, минуя Ring 0. (Поле DPL у ворот проверяется только для софт-прерывания INT n, чтобы код Ring 1 не мог самопроизвольно вызвать чужой обработчик.) Полный цикл прерывания получается ~500 нс на современном Skylake — то же, что и у монолитного unikernel. Никакого штрафа.

Соответствующий код — в arch/x64/ring1_interrupt_stub.S (181 строка ассемблера) и arch/x64/ring1_dispatcher.cc.

3.4. IPC: как Ring 1 говорит с Ring 0

Когда драйверу всё-таки нужно что-то от ядра — например, разбудить поток-консьюмер пакетов, — он не может просто вызвать функцию. Между Ring 1 и Ring 0 — аппаратный барьер. Нужен IPC.

В MINIX 3 IPC построен на синхронных сообщениях через sendrec. Драйвер блокируется, ждёт ответа, Ring 0 отвечает, драйвер просыпается. Это просто, но дорого: 2-3 микросекунды на сообщение.

Мы пошли по другому пути — lock-free per-CPU очереди. Один SPSC (single-producer, single-consumer) кольцевой буфер на каждое CPU. Драйвер на CPU N пишет туда сообщение через relaxed memory order, поднимает doorbell-флаг, и продолжает работу не блокируясь. Поток ядра на том же CPU читает очередь в основном цикле. Стоимость одного сообщения — около 100 нс. Без атомиков-арбитража, без mutex’ов.

Файл: core/ring1_ipc.cc (370 строк), интерфейс — в include/ring1_ipc.hh.

3.5. IOMMU: per-driver защита DMA

Самый коварный класс ошибок в драйвере — это битый DMA-адрес. Драйвер сетевой карты говорит NIC’у: «положи пакет по адресу X». Если X указывает на приватные структуры приложения или на буферы другого драйвера — NIC честно туда положит. Никакая защита Ring/CPL тут не поможет: DMA идёт мимо CPU, мимо MMU, прямо в физическую память.

Защита от этого — IOMMU. Intel называет её VT-d, AMD — IOMMU. Это отдельный MMU для DMA-операций: для каждого устройства ведётся своя таблица переводов «physical → host physical». Можно настроить так, что virtio-net видит только свои буферы, а память приложения — физически недоступна для его DMA.

Мы написали iommu_controller (файл arch/x64/iommu.cc, 567 строк, заголовок — include/iommu_controller.hh), который поднимает per-driver домены: каждому драйверу — свою страницу таблиц. Если железо IOMMU не поддерживает (старый Xeon, дешёвый KVM-хост) — работаем в identity-mapping режиме, изоляция Ring 1 + supervisor всё ещё работает, но DMA-protection пропадает. Об этом мы честно пишем в логах загрузки.

3.6. Supervisor: watchdog, который восстанавливает

Каждый Ring 1 драйвер регистрируется в ring1_supervisor (файл core/ring1_supervisor.cc, 1342 строки — это самый большой компонент в Phase 2). Супервизор хранит:

  • состояние компонента (uninitialized → initializing → running → stopping → stopped/failed),
  • счётчик рестартов,
  • список потоков и областей памяти, принадлежащих компоненту,
  • per-driver exception handler (что делать на page fault, что — на GPF),
  • backoff-таймер для рестарта.

Когда драйвер падает (ловит page fault или GPF), управление через IST[3] стек попадает в ring1_dispatch_*_handler. Тот вызывает зарегистрированный handler драйвера. Если драйвер сам не справился — помечает себя mark_failed(), и супервизор видит это в основном цикле. Дальше — рестарт с экспоненциальным бэкоффом: 500 мс, 1 с, 1.5 с, 2 с, 2.5 с, после пятой попытки — окончательный отказ и алерт в логи.

driver Ring 1 dispatcher Ring 0, IST[3] restart + backoff crash mark supervisor Ring 0

Прокси — то есть всё, что не драйвер — продолжает работать. Если в момент падения virtio-net в очереди отправки висели пакеты — они доедут после рестарта, потому что HTTP-сервер живёт в Ring 0 и ждёт на сокете, а не на драйвере напрямую.

3.7. Когда Ring 1 не нужен

Это нужно сказать честно, чтобы не выглядело как маркетинг.

  • Если у вас один-единственный драйвер и он стабилен (например, только virtio-net в продакшене) — выгода от Ring 1 нулевая.
  • Если у вас нет IOMMU (старое железо, дешёвый VPS) — DMA-защиты не будет, останется только software-isolation. Это всё равно лучше, чем ничего, но не дотягивает до полноценной модели.
  • Если вы запускаете нас на ARM64 — у ARM нет хардверного Ring 1 в смысле x86 (есть EL0/EL1/EL2/EL3, и они устроены иначе). В нашей ARM-сборке (NanoPi R3S) Ring 1 — только supervisor watchdog с software-restart. Прямой доставки прерываний нет.

4. Как у других

4.1. Linux — никак

Linux Kernel Driver Framework живёт целиком в Ring 0. Когда у вас падает драйвер графики — у вас падает ядро (в лучшем случае oops, в худшем — kernel panic). Есть kdump, есть live patching через kpatch, но архитектурно изоляции драйверов в Linux нет. Попытки были — проект Linux Kernel Driver Verifier, microdrivers research, проект Nooks от Майкла Свифта в Washington Univ. (2003) — все остались в академии.

Единственное близкое — userspace-драйверы через uio/vfio. Но это только для устройств, которые могут жить полностью в user space (DPDK, SPDK, мониторящие утилиты). Драйвер блочного устройства в user space вы не запустите — его контракты с VFS и блочным слоем идут изнутри ядра.

4.2. MINIX 3 — драйверы в Ring 3 как процессы

Идеологически — наш ближайший родственник. Танненбаум вынес все драйверы (сеть, диск, файловая система, таймер) в отдельные пользовательские процессы. Если драйвер падает — reincarnation server (тот самый supervisor у нас) перезапускает его.

Цена — очень дорогая IPC. Каждое чтение блока с диска проходит: file system server → disk driver → ядро → disk driver → file system server. Четыре context switch на одно чтение. На бенчмарках файловой системы MINIX 3 в 5-10 раз медленнее Linux на одинаковом железе.

Мы взяли идею MINIX 3 (драйвер падает — рестарт), но сменили изоляцию с Ring 0 ↔ Ring 3 на Ring 0 ↔ Ring 1, и заменили синхронный IPC на lock-free очереди. Получилось дешевле.

4.3. seL4 — формальная верификация

seL4 — микроядро на 10 000 строк кода, формально верифицированное в теореме-доказателе Isabelle/HOL. Это значит: для каждой функции ядра математически доказано, что она делает ровно то, что написано в спецификации, и ничего больше. Никаких багов в самом ядре — доказано.

Архитектурно seL4 ближе к L4-семейству: всё, кроме планировщика и IPC — в user space. Драйверы — в user space процессах, как в MINIX 3. IPC — синхронный по сообщениям. Используется в авионике (Boeing, DARPA HACMS), в автомобильной электронике, в военном оборудовании.

Цена — экстремальная сложность разработки. Чтобы добавить новый сервис в seL4, нужно либо переверифицировать часть ядра, либо писать его как user-space сервер с собственными доказательствами безопасности. Это годы работы для команды из десятков людей.

Мы себе не ставим задачу формальной верификации. Наша цель — чтобы драйвер не уносил приложение, а не чтобы каждая функция ядра была доказательно безбажна. За это мы платим десятками тысяч строк кода вместо десятков тысяч строк формальных спецификаций.

4.4. Singularity / Midori — software isolation

Singularity (Microsoft Research, 2003-2008) и его продолжение Midori сделали ход конём: отказались от аппаратной изоляции вообще. Всё ядро написано на Sing# (типобезопасный диалект C#), компилятор гарантирует, что драйвер не выйдет за свои границы памяти. Никаких колец, никаких MMU — все процессы в одном адресном пространстве, изоляция статическая.

Это оказалось дешевле по runtime (нет MMU-переключений), но дороже по разработке: пришлось написать новый язык, новый компилятор, новую рантайм-систему. Microsoft закрыл проект.

В нашем случае — интересная аналогия. Если поверх ядра крутится приложение на Rust, типобезопасность языка гарантирует, что прикладной код не выйдет за границы своих структур. Но на уровне драйверов мы всё-таки опираемся на аппаратные кольца — потому что драйверы пишутся на C++, и UB в них объективно случается чаще.

4.5. Сводная табличка

СистемаИзоляция драйверовЦена IPCВосстановлениеГде работает
LinuxНетkdump (постфактум)Везде
WindowsUMDF (некоторые драйверы)RPC через ALPCПерезапуск UMDF-хостаWindows
MINIX 3Ring 3 процессы~3 мкс синхр.Reincarnation serverEmbedded, исследования
seL4User-space процессы~1 мксФормально доказанаАвионика, авто
SingularitySoftware (типы)~50 нсRestart процессаЗакрыта
Наш unikernel (Ring 0)НетТолько haltCloud, наша старая версия
Наш unikernel (Ring 1)Аппаратная Ring 1 + IOMMU~100 нс lock-freeSupervisor watchdogС ENABLE_RING1_DRIVERS=ON

5. Как у нас

5.1. Карта файлов

Архитектура Ring 1 появилась в декабре 2025 года и прошла шесть фаз (Phase 0–5). Итог — 32 657 строк в 40 файлах.

Что где лежит:

include/
├── ring1_driver.hh         # 546 строк — базовый класс драйвера
├── ring1_supervisor.hh     # 678 строк — supervisor API
├── ring1_ipc.hh            # 423 строки — IPC контракты
├── ring1_exceptions.hh     # 113 строк — exception registry
└── iommu_controller.hh     # 247 строк — DMA-protection abstraction

core/
├── ring1_driver.cc         # 50 строк — base class implementation
├── ring1_supervisor.cc     # 1342 строки — главный реактор
├── ring1_ipc.cc            # 370 строк — lock-free очереди
├── ring1_exceptions.cc     # 94 строки — registry, dispatch
└── ring1_test_runner.cc    # 292 строки — встроенный test harness

arch/x64/
├── ring1_dispatcher.cc     # 346 строк — диспетчер прерываний/исключений
├── ring1_interrupt_stub.S  # 181 строка — ассемблерные стабы
├── iommu.cc                # 567 строк — Intel VT-d driver
├── arch-cpu.hh             # +21 строка — IST[3], set_ring1_exception_stack()
├── exceptions.cc           # +87 строк — Ring 1 exception path
└── mmu.cc                  # +39 строк — page fault routing

drivers/
├── virtio-net.cc / .hh           # +384 строки — точка ветвления Ring 0 / Ring 1
├── virtio-net-ring0.cc           # 940 строк — старый код, без изменений по сути
├── virtio-net-ring1.cc           # 1022 строки — новый Ring 1 драйвер
├── virtio-blk.cc / .hh, virtio-scsi.cc / .hh, virtio-rng.cc / .hh
└── virtio-net-base.hh            # 69 строк — общая база

test/
├── ring1_driver_test.cc                # 304 строки
├── ring1_ipc_test.cc                   # 451 строка
├── ring1_supervisor_test.cc            # 454 строки
└── ring1_cross_driver_isolation_test.cc # 981 строка — 33 кросс-теста изоляции

5.2. Жизненный цикл драйвера

Все Ring 1 драйверы наследуются от ring1_driver и реализуют один и тот же интерфейс:

class my_driver : public ring1_driver {
public:
    int init() override;            // hardware probe, allocation
    void shutdown() override;       // graceful stop, release
    int reset_hardware() override;  // for restart
    void on_page_fault(exception_frame*) override;
    void on_gpf(exception_frame*) override;
};

Состояния:

uninitialized initializing stopping stopped running failed restart

Останов — кооперативный, не принудительный. Каждый рабочий цикл драйвера проверяет атомарный флаг _shutdown_requested:

while (!_shutdown_requested.load(std::memory_order_acquire)) {
    process_one_packet();
}

Это безопаснее, чем pthread_cancel(): успеваем освободить ресурсы, не оставляем висящих lock’ов. Следствие — если драйвер заблокирован в bus-контракте с устройством и не проверяет флаг, shutdown задержится. Мы это знаем; для virtio-net подобного не случается, потому что цикл poll’ит очереди.

5.3. Сборка

Всё включается одной CMake-опцией:

cmake -DENABLE_RING1_DRIVERS=ON ..

Когда выключено (default до сих пор) — собирается в точности старый монолитный билд. Никаких #ifdef в горячих путях нет, потому что мы пошли путём разделённых файлов: virtio-net-ring0.cc и virtio-net-ring1.cc собираются альтернативно через Makefile. Это даёт ноль регрессии для тех, кто Ring 1 не использует.

5.4. Что мы намерили

Метрики со стенда demo.kernix.ru после Phase 5:

МетрикаRing 0 (old)Ring 1 (new)Delta
Interrupt latency, virtio-net~480 нс~510 нс+6%
HTTP throughput приложения под нагрузкой9.7 Гбит/с9.6 Гбит/с-1%
Время восстановления после краша драйвераhalt0.5–2.5 с (backoff)
Память на supervisor + IPC0~2 МБ+2 МБ

То есть мы заплатили ~1% на горячем пути и 2 МБ памяти. За это получили: падение драйвера → автоматический рестарт за секунду без остановки приложения. На наш взгляд — выгодная сделка.


6. Что выбрать

Простая матрица решений для архитектора, который выбирает, включать ли Ring 1 в своей инсталляции:

СценарийВключать Ring 1?Почему
Cloud-инсталляция на KVM с современным CPU и VT-dДаПолный набор: ring isolation + IOMMU + supervisor
Bare metal с Intel VT-d, серверный сегментДаТо же самое
KVM на старом CPU без VT-dДаSoftware isolation остаётся, теряем DMA-protection
Embedded ARM64 (NanoPi R3S и подобное)Да, но выгода неполнаяТолько supervisor watchdog, нет аппаратного Ring 1
Stress-test инсталляция, где нужен абсолютный максимум throughputМожно отключить-1% throughput иногда критичен
Лаборатория, демо, разработкаДаВидно, как драйвер падает и восстанавливается

Для подавляющего большинства production-инсталляций ответ — включить. Цена в 1% throughput компенсируется тем, что вам больше не звонят в 3 часа ночи с воплем «всё легло».

Что мы пока не перенесли в Ring 1

Честно: не всё. Драйверы PCI-host bridge, ACPI, локальный APIC, ATA legacy — пока в Ring 0. Это либо потому, что они нужны для самой загрузки (Ring 1 ещё не инициализирован), либо потому, что они slowpath и риск падения низкий. Roadmap, что осталось, ведётся отдельно.


7. Шишки

Мы написали 32 тысячи строк кода. Ровно три бага из них стоили нам по полтора-два дня каждый. Это не «всё прошло гладко» — это «микроядро с прямой доставкой прерываний — упрямое». Делимся.

Шишка 1. IST[3] нужно ставить до загрузки IDT

Симптом: page fault во время раннего бута, когда впервые срабатывает прерывание из векторов 34-36. Стек ломается, ядро зависает в triple fault, виртуалка ребутится в цикле.

Причина: дескрипторы IDT для векторов 34-36 ссылаются на IST[3]. Мы загружали IDT в init_on_cpu(), а потом настраивали IST[3]. В промежутке между этими двумя действиями могло прилететь прерывание (особенно от virtio-net на быстром стенде), и оно попадало в невалидный стек.

Лечение — arch/x64/arch-cpu.hh, в init_on_cpu():

ltr(gdt_tss * 8);
#ifdef ENABLE_RING1_DRIVERS
    set_ring1_exception_stack();   // ← ДО idt.load_on_cpu()!
#endif
idt.load_on_cpu();

Урок: настраивай ресурсы, на которые ссылаются дескрипторы, строго до того, как дескрипторы становятся активными. Звучит очевидно, но мы это пропустили.

Шишка 2. APIC EOI забывать нельзя

Симптом: после первого прерывания на векторе 35 драйвер начинает получать его с частотой 50 кГц, вешая CPU на 100%. Прокси работает, но «как-то медленно» — потому что один из CPU полностью ушёл в interrupt storm.

Причина: написали ring1_dispatch_interrupt(), забыли вызвать processor::apic->eoi(). Уровневое прерывание (level-triggered) не снималось с линии, APIC честно его доставлял снова и снова.

Лечение в arch/x64/ring1_dispatcher.cc:

void ring1_dispatch_interrupt(unsigned vector, exception_frame* ef) {
    if (auto driver = lookup_driver(vector)) {
        driver->handle_interrupt(ef);
    }
    processor::apic->eoi();   // ← КРИТИЧНО: без этого storm
}

Урок: в любом обработчике прерывания первое и последнее, что нужно проверить — есть ли EOI. Уровневые vs edge-triggered — поведение разное, но EOI не помешает никому.

Шишка 3. Не вызывать abort() под локом

Самая дорогая. Симптом: рестарт приложения после краша драйвера зависает в pthread_create. Watchdog восстанавливает приложение, оно пытается стартовать новый поток, и зависает навечно. Никаких сообщений в логе, никакого taskstats — просто тишина.

Причина — классический deadlock с участием vma_list_mutex. Когда падал app-thread с page fault, путь был:

core/mmu.cc::vm_fault()
    WITH_LOCK(vma_list_mutex.for_read()) {
        ...
        if (needs_sigsegv) vm_sigsegv(...);  // → abort() → hlt
    }                                         // ← lock НЕ снят

abort() на упавшем потоке заходил в sti; hlt навсегда, держа read-lock на vma_list_mutex. Когда watchdog рестартил приложение, новый pthread пытался взять write-lock на тот же mutex — и блокировался до конца времён.

Лечение — снимать lock до потенциального hlt:

bool needs_sigsegv = false;
{
    SCOPE_LOCK(vma_list_mutex.for_read());
    if (vma == end || access_fault(vma, ef)) needs_sigsegv = true;
    else vma->fault(addr, ef);
}   // ← lock освобождён здесь
if (needs_sigsegv) vm_sigsegv(addr, ef);   // безопасно abort'ить

Урок: никогда не вызывайте функцию, которая может не вернуться, под захваченным локом. Звучит как тривиальное правило — но в обычном коде функция всегда возвращается, и об этом просто не думаешь. В микроядерной архитектуре, где обработчик исключения может уйти в бесконечный hlt, это становится абсолютным правилом.


Сосед закрывает ноутбук, допивает остывший чай и говорит:

— Ладно. Если оно правда так работает — собери мне fault-injection демо на следующей неделе. На моём билде, не на твоём.

Ядерный разработчик кивает:

— На твоём — так на твоём.

Стрелка часов на стене переговорки «Сосна» доходит до конца дня.