nos_
ENRU

src/cpp/{block,net,fs} больше нет: как слои nos переехали на Rust

За два дня блочный слой, сеть, файловые системы и virtio в nos переехали на Rust, C++ удалили, а unsafe в слоях сократился с 924 мест до 113.

9 мин чтения
RustБезопасностьДрайверыСетьХранилищеМодули
Read in English

17 сентября 2026 года блочный слой, сетевой стек и файловые системы ядра были написаны на C++. В src/cpp/net лежало 8909 строк, в src/cpp/fs — 6849, в src/cpp/block — 877, и ещё 4839 приходилось на шину virtio с четырьмя драйверами. Rust жил в дереве с марта, но только на окраинах: четыре драйвера дисков и сетевых карт, подключённые к таблицам на C++, крейты протоколов TLS и SSH, несколько загружаемых модулей.

К вечеру 18-го все три каталога были удалены — меньше чем за девять часов, если судить по времени коммитов. В ту же ночь и на следующий день новый Rust избавился от большей части своего unsafe. Ниже — как это было и как мы показали, что ничего не сломалось.

Правило и шов, который всё сделал возможным

Правило, которое родилось за эти два дня, теперь записано в CLAUDE.md: новый код пишется на Rust, если только он не обязан быть на C++. За C++ остаётся фундамент: загрузка, MMU и аллокаторы, планировщик, блокировки, прерывания, таймеры, HAL, загрузчик модулей. Всё, что стоит сверху, — Rust.

Переносить слои по одному, без «дня X», позволил способ, которым загружаемые модули связываются с ядром. Таблицу экспорта для них Makefile строит так:

# The functions a module may bind to: those the ffi crate declares in its
# extern blocks that the kernel defines. Generated from pass1.elf like the
# symbol table, and linked into the final kernel only -- kernel/module.cpp has
# the empty weak table pass 1 gets instead.

Таблица — это пересечение имён, объявленных в крейте ffi, и имён, определённых в слинкованном ядре. Ей всё равно, на каком языке имя определено. Поэтому kernel_netdev_*, kernel_net_* и kernel_blockdev_* смогли переехать из C++ в Rust под теми же именами, а каждый драйвер и каждый модуль продолжили связываться с ними как ни в чём не бывало. Коммит, завершивший перенос сети, так и пишет: именно это свойство таблицы экспорта «сделало возможным весь перенос, слой за слоем» (c87dca6).

Сначала фундамент, и гейт — до переезда

Порядок был такой: block, virtio, fs, затем net, и каждый слой начинался со своего фундамента — сетевой стек с форматов кадров, блочный слой с таблиц разделов. Самый первый коммит (9ce9519) перенёс разбор MBR и GPT — код, который читал «байты, которые здесь никто не писал». Его гейт, parttest.py, сам записывает таблицы обоих видов, так что ядро читает ровно те байты, о которых говорит тест.

Лучше всего окупилась привычка писать гейт заранее и добиваться, чтобы он проходил на старом C++. ext2-test.py, который отдаёт на суд e2fsck -fn всё, что драйвер оставил на диске, появился раньше переноса ext2 (ea1c7bd). Так же было с тестами TCP и netconsole, и тест netconsole оправдал себя немедленно. Первая версия порта выделяла память под свой кольцевой буфер и блокировку, и ядро поднималось с молча выключенным netconsole. Дело в том, что netconsole взводится из командной строки ещё до появления страничного аллокатора, а удержать лог загрузки с этого раннего момента — весь его смысл. На сервере Hetzner, где единственная консоль — сеть, симптомом была бы машина, которая не говорит вообще ничего (9c95e67).

У nanofs нет своего e2fsck, поэтому её гейт разбирает образ сам, после того как ядро его записало. Драйвер, который читает назад собственные записи, «согласен сам с собой, какое бы смещение ни использовал», и контрольный эксперимент это подтвердил: если сдвинуть смещение одного поля (IN_BLOCKS) на четыре байта, все остальные проверки в файле проходят, а падает только эта (29221c9).

Представления уходят

После каждого переноса в C++ оставалось представление (view) — класс, оборачивающий хэндл и вызовы над ним. Каталоги опустели благодаря правилу: C++-код объявляет нужные ему вызовы kernel_* или rust_* прямо там, где их делает, без общего заголовка. Тринадцать сетевых команд шелла и семнадцать файловых переехали в крейты, которым принадлежат их данные. Дальше коммиты читаются как некрологи: 3a57641 «fs: src/cpp/fs is gone», 2192392 «block: src/cpp/block is gone too», d2c0ef2 «net: src/cpp/net is gone». В тот же вечер на Rust переехал и драйвер xHCI. По дороге закрылись и «круговые» маршруты: вызовы TCP, которыми пользуется SSH-сервер, были определены на C++ поверх C++-представления, которое тут же звало обратно в Rust, — «три прыжка ради вызова из Rust в Rust, на каждом байте, который отправлял sshd».

Код переехал, а не разросся. По подсчёту scripts/sloc.py, который считает только строки кода, кода на C++ стало меньше — с 52 366 строк до 33 858, на Rust больше — с 10 964 до 29 310, а общий объём сдвинулся с 64 776 до 64 605.

Сбой, который выглядел как успех

Одна регрессия два часа проходила через все гейты. При загрузке размер пула сетевых кадров передаётся как 0, что означает «значение слоя по умолчанию». Удалённое C++-представление превращало этот 0 в настоящее число, а Rust-инициализация такой размер отвергала — и пул не создавался вообще. Каждый кадр брался из общего аллокатора, освобождение в котором делает TLB shootdown на всех CPU, — ровно то, от чего пул и должен был избавить горячий путь. Ничего не падало: откатиться на аллокатор — штатное поведение при промахе пула.

Поймал это netload-test.py, написанный совсем для другого (7a004db). Теперь CI гоняет его на arm64 с комментарием, что нагрузочная мишень — «единственное здесь, что заметит пул кадров, который так и не был создан».

Теперь unsafe

В свежем Rust в net, block и fs набралось 924 места с unsafe, и почти ни одно из них не касалось железа. Коммит, с которого началась чистка (01b42d6), назвал три причины. Ни одна блокировка в kcore ничего не охраняла. Швы между двумя кусками Rust сохранили форму C++, который они заменили, — extern "C" fn(ctx: *mut u8, ..) с приведением типа на другой стороне, — и у 66 экспортов вообще не было вызывающих. А графы строились на сырых указателях. Кэш ARP до:

pub struct ArpTable {
    lock: SpinLock,
    cache: core::cell::UnsafeCell<[Entry; CACHE_SIZE]>,
}

/* Everything inside is touched with the lock held */
unsafe impl Sync for ArpTable {}
unsafe impl Send for ArpTable {}

// ...
        let _guard = self.lock.lock();
        let cache = unsafe { &mut *self.cache.get() };

и после, в net/src/arp.rs:

pub struct ArpTable {
    cache: SpinLock<[Entry; CACHE_SIZE]>,
    // ...
}

// ...
        let cache = self.cache.lock();

Теперь unsafe живёт по одному разу на тип блокировки — в Deref её guard'а в kcore/src/sync.rs. И это была не косметика: TCP читал состояние чужих соединений под блокировкой пула, не беря их собственных блокировок, а старая форма прятала это за тем же unsafe, что и всё остальное. Теперь порядок такой: сначала пул, потом соединение.

Каждая форма, которой раньше требовался unsafe, получила тип в kcore, а обоснование его корректности записано в комментарии к самому типу: Once — для чего-то единственного, CpuLocal — для состояния отдельного CPU, RxOwned — для того, чего касается только путь приёма, kcore::pod — для структур на диске (суперблок ext2 один раз объявляет unsafe impl Pod, и каждое чтение после этого проверяет границы). Дерево файловой системы стало ареной идентификаторов — номер слота плюс поколение, — так что устаревший идентификатор просто ничего не находит. Хэндл снаружи ищется, а не разыменовывается:

    pub fn by_handle(&'static self, handle: usize) -> Option<&'static Device> {
        let base = self.devices.as_ptr() as usize;
        let offset = handle.checked_sub(base)?;
        if offset % core::mem::size_of::<Device>() != 0 {
            return None;
        }
        self.devices.get(offset / core::mem::size_of::<Device>())
    }

Следом пошли драйверы (8147157). Драйвер теперь — трейт над устройством, которое уходит в Box::leak и живёт вечно, а его кольцо дескрипторов — &'static-срез ячеек Volatile. Обработчик прерывания — функция-элемент (function item) над своей &'static-целью, и kcore на этапе компиляции проверяет, что у обработчика нет ни одного байта, поэтому слово контекста, которое носит ядро, и есть цель:

pub(crate) const fn assert_stateless<F>() {
    assert!(
        core::mem::size_of::<F>() == 0,
        "a handler must be a function item or a closure that captures nothing",
    );
}

Счёт по scripts/unsafe-count.py (блоки, unsafe fn и unsafe impl):

Крейт До, 7a004db После, 2f9133f master, октябрь 2026
net 486 61 68
block 70 14 14
fs 368 38 47
девять крейтов драйверов 216 15 15
kcore 274 241 268

kcore сначала вырос до 363, пока в него переезжали типы, а потом похудел: ушли никем не вызываемые обёртки, а 51 FFI-функция, корректная при любых аргументах, стала safe fn. Те немногие места, что net и fs набрали с тех пор, находятся на границе C ABI, через которую к ним обращается модуль гипервизора.

«Поведение не изменилось» — показано

Рефакторинг, который меняет только типы, не должен трогать машинный код, — так что мы проверили машинный код. После перехода на safe fn секции .text и .data обоих ядер совпали со старыми байт в байт, а все 10 220 и 10 904 функции сохранили свои размеры. .rodata различалась только дайджестом ABI модулей, который хэширует исходники FFI (cd94789). Если изменение не могло оставить бинарники нетронутыми, его коммит так и говорит, и доказательством служат гейты на обеих архитектурах (4c3f585).

На проводе мы сравнивали пакеты: старому и новому модулю netblk под QEMU с записью трафика отправили одни и те же восемь запросов с одного и того же порта, и восемь ответов совпали байт в байт (b55366b). В драйверах последовательности обращений к регистрам и DMA-барьеры сверили со старым кодом — они совпали. r8168 и r8125 ничем не эмулируются, и их коммит честно об этом говорит: «compiled, diffed, not run» — собраны, сверены, не запускались. С тех пор EX44, у которого единственная консоль — его RTL8125, загрузил nos, и на него заходили по ssh (Real hardware).

Модули

Нагрузочная UDP-мишень оставалась последним необязательным компонентом, вшитым в сетевой слой. Она стала модулем netload без единого собственного unsafe (cda33d1). Форматы кадров переехали в netwire — крейт, в котором нет ядра, потому что модуль линкуется сам по себе и не может слинковать слой; а слушатель модуля получил ту же типизированную форму, что и у сервисов самого слоя, — Nic::listen(port, Arc<H: UdpHandler>). У границы крейтов нашлась цена: без LTO обработчик эха вырос с 8 вызовов на пакет в образе ядра до 18 в модуле, а #[inline] на пакетных аксессорах вернул его к 10 (2f9133f). Одно ограничение осталось: драйвер диска или сетевой карты не может быть модулем, потому что регистрируется в своём слое как трейт-объект изнутри образа ядра.

Что осталось

Эти два дня оставили после себя не только код, но и правила: новый код пишется на Rust с тем минимумом unsafe, который допускает задача; недостающая форма добавляется в kcore один раз, вместе с обоснованием, а не блоком unsafe в каждом месте использования; а unsafe-count.py — то, чем изменение проверяет само себя. Подробности — в Rust в ядре, а начать лучше всего с написания модуля.