nos_
ENRU

Загружаемый модуль ядра на Rust

Напишите модуль ядра nos на Rust с kmod и kcore, добавьте в него команду шелла, соберите .ko и загрузите его в работающее ядро.

12 мин чтенияОбновлено

В итоге у вас будет собственный модуль: крейт в src/rust/modules/, собранный в файл .ko, который регистрирует команду шелла. Вы загрузите его в работающий nos командой insmod и выгрузите командой rmmod — без пересборки ядра и без перезагрузки.

Понадобится:

  • дерево nos и рабочая сборка, нативная или в Docker (Сборка nos);
  • nos, в который модуль будет загружаться, например в QEMU (Запуск nos в QEMU);
  • немного Rust: модули — это крейты #![no_std].

Что такое модуль

  • Только Rust. Модуль — это крейт типа staticlib, слинкованный в позиционно-независимый разделяемый объект ELF, то есть .ko.
  • С ядром он общается через единственный API. Это функции extern "C", которые объявляет крейт ffi и безопасно оборачивает kcore, — тот же API, которым пользуются встроенные в ядро драйверы. Загрузчик связывает модуль с таблицей экспорта, сгенерированной из этих объявлений, и отвергает всё остальное.
  • insmod выполняет init модуля, rmmod уничтожает возвращённое им состояние. Drop модуля и есть его выход: отдельную функцию выгрузки писать не нужно.
  • Модуль — для того, без чего машина может работать. Сервер, генератор нагрузки или диагностика — это модуль: sshd, netblk, netload, blkload, heapbench и гипервизор hv — все модули.
  • Модуль не может быть драйвером диска или сетевой карты. Такие драйверы регистрируются в своём слое как trait-объекты Rust изнутри образа ядра, и у модуля нет C-имени, к которому можно было бы привязаться. Модуль пользуется дисками и сетевыми картами, но не предоставляет их.

Самый маленький настоящий модуль: hello

В src/rust/modules/hello/ два файла. Cargo.toml:

[package]
name = "mod-hello"
version = "0.1.0"
edition = "2021"

[lib]
name = "mod_hello"
crate-type = ["staticlib"]

[dependencies]
kmod = { path = "../../kmod" }
kcore = { path = "../../kcore" }

И src/lib.rs, из которого убран только doc-комментарий:

#![no_std]

extern crate alloc;

use alloc::boxed::Box;
use core::fmt::Write;
use core::sync::atomic::{AtomicU64, Ordering};
use kcore::cmd::Command;

static CALLS: AtomicU64 = AtomicU64::new(0);

struct Hello {
    _cmd: Command,
}

impl kmod::Module for Hello {}

impl Drop for Hello {
    fn drop(&mut self) {
        kcore::trace!(0, "hello: unloaded after {} calls", CALLS.load(Ordering::Relaxed));
    }
}

fn init() -> kcore::error::Result<Box<dyn kmod::Module>> {
    let cmd = Command::register("hello", "hello [name] - a command from a loadable module", |args, out| {
        let calls = CALLS.fetch_add(1, Ordering::Relaxed) + 1;
        let who = if args.is_empty() { "world" } else { args };
        let _ = writeln!(out, "hello, {} -- call {} since the module was loaded", who, calls);
    })?;

    kcore::trace!(0, "hello: loaded");
    Ok(Box::new(Hello { _cmd: cmd }))
}

kmod::module!(name: "hello", init: init);

Что делает каждая часть:

  • kmod::module!(name: "hello", init: init) даёт модулю имя и указывает функцию, которую выполнит insmod. Это имя показывает lsmod и принимает rmmod; в нём не больше 31 печатного символа, а слишком длинное имя ломает сборку. Макрос порождает nos_module_info — заголовок, который ищет загрузчик.
  • init создаёт состояние модуля и возвращает его как Box<dyn kmod::Module>. Если вместо этого вернуть ошибку, insmod не удастся, а в логе ядра появится module init failed: ....
  • Hello, состояние модуля, владеет всем, что модуль зарегистрировал. Пока Hello держит Command, команда зарегистрирована; rmmod уничтожает Hello, и drop у Command снимает регистрацию. Каждая отмена регистрации в kcore дожидается обратного вызова, ещё идущего на другом CPU, поэтому код под командой никогда не освобождается, пока она работает. Drop здесь лишь добавляет строку в лог.
  • impl kmod::Module for Hello {} требует Send: задача, которая выполняет rmmod, уничтожает то, что построила задача, выполнившая insmod.
  • Command::register(name, help, handler) делает команду доступной в шелле — в консоли, в UDP-шелле или в сеансе SSH. Обработчик имеет тип Fn(&str, &mut Output) + Send + Sync + 'static. Он получает остаток командной строки и Output, реализующий core::fmt::Write. От текста справки ядро хранит 95 символов. Если имя уже занято, register завершается ошибкой, а ? превращает её в неудачный insmod.
  • static CALLS принадлежит самому модулю. У каждого модуля своя копия kcore, поэтому общих статических переменных у модулей нет, а вызывать друг друга они не могут.

Остальное kmod один раз даёт каждому модулю: глобальный аллокатор (это куча ядра); обработчик паники, так что паника в модуле — это паника ядра; и заголовок с магическим числом, версией формата, именем, init, exit и хешем интерфейса ядра.

Свой модуль

Скопируйте hello и дайте копии своё имя. Здесь это greet:

cp -r src/rust/modules/hello src/rust/modules/greet

В src/rust/modules/greet/Cargo.toml назовите пакет mod-<name>, а библиотеку mod_<name>. Makefile собирает пакет mod-greet и линкует libmod_greet.a:

 [package]
-name = "mod-hello"
+name = "mod-greet"
 version = "0.1.0"
 edition = "2021"

 [lib]
-name = "mod_hello"
+name = "mod_greet"
 crate-type = ["staticlib"]

Добавьте крейт в members рабочего пространства в src/rust/Cargo.toml (строка здесь сокращена). default-members не трогайте: это то, что компилирует сборка самого ядра.

-members = [..., "modules/hv", "modules/heapbench"]
+members = [..., "modules/hv", "modules/heapbench", "modules/greet"]

Добавьте имя в MODULES в Makefile:

-MODULES = hello modtest slowexit blkload netblk netload sshd hv heapbench
+MODULES = hello modtest slowexit blkload netblk netload sshd hv heapbench greet

Наконец, в src/rust/modules/greet/src/lib.rs поменяйте имя и справку команды, а также имя модуля в kmod::module!(name: "greet", init: init).

Обращение к ядру через kcore

Модулю доступно то, что оборачивает kcore. Полная карта — в What kcore has. Чаще всего модулю нужны:

kcore:: Что даёт модулю
cmd Command::register, а также Output::read_input для команды, читающей то, что печатает пользователь SSH
trace trace!(level, ...) — в лог ядра; уровень 0 виден всегда
sync блокировки, которые владеют защищаемыми данными: Mutex<T>, SpinLock<T>, IrqSpinLock<T> и другие; Event, WaitGroup
task задачи: spawn_with, spawn_on_with для задачи, держащей Arc на состояние модуля; TaskHandle дожидается задачи при drop; sleep_ms
time, random, cpu часы, пул случайных чисел ядра, номера CPU и IPI
fs файлы: read, write, create и File для открытого файла
net, tcp, block существующая сетевая карта (UDP-слушатели, кадры), TCP-слушатели и потоки, существующий диск или раздел
timer, softirq периодический таймер; обработчик soft IRQ, у которого нет отмены регистрации и который делает модуль постоянным: rmmod откажется его выгружать

Если команде нужно состояние модуля, держите его в Arc и перемещайте клон в обработчик. Так модуль гипервизора регистрирует hv:

let for_cmd = state.clone();
let cmd = Command::register("hv", HELP, move |args, out| command(&for_cmd, args, out))?;

Правила, по которым живёт модуль, — из соглашений проекта:

  • Пишите без unsafe. Сначала ищите подходящий тип в kcore. Если для нужной формы его нет, добавьте тип в kcore — один раз, с объяснением, почему он корректен. scripts/unsafe-count.py modules/greet считает такие места.
  • Не выделяйте и не освобождайте память, держа спин-блокировку. Аллокатор страниц сбрасывает TLB на всех остальных CPU и ждёт ответа от каждого. CPU, крутящийся на вашей блокировке, работает с выключенными прерываниями и ответить не может, поэтому оба ждут друг друга, пока через десять секунд ядро не запаникует, назвав CPU, которые так и не ответили. Соберите то, что нужно освободить, под блокировкой, отпустите её и только потом освобождайте.
  • Помните о том, что ждёт при уничтожении. UDP-слушатель, TaskHandle или обработчик прерывания при drop дожидаются своих обратных вызовов. Держите каждый в собственном Lock<Option<..>>: забирайте его через take() под блокировкой, а уничтожайте после. Уничтоженный под той блокировкой, которая нужна его ожиданию, он даст взаимоблокировку.
  • Никогда не вызывайте rmmod из собственной команды модуля. Выгрузка ждала бы эту команду, а команда — выгрузку.
  • Модуль может вызывать только то, что объявлено в ffi. Новый сервис ядра добавляется в три шага: функция на C++ в src/cpp/kernel/rust_ffi.cpp, её объявление в src/rust/ffi и безопасная обёртка в src/rust/kcore (Adding a kernel service). Это меняет хеш интерфейса, поэтому ядро и все модули пересобираются вместе.

Сборка

make modules
make modules ARCH=aarch64

Получатся out/x86_64/modules/greet.ko и out/aarch64/modules/greet.ko. make и make nocheck тоже собирают модули. На Mac собирайте в контейнере nos-builder:

docker run --platform linux/amd64 --rm -v "$PWD:/src" -w /src nos-builder make modules

Каждый модуль компилируется отдельно, со своей копией core, alloc, kcore и kmod, в src/rust/target/modules, потому что ему нужны флаги, которых Rust-часть ядра не использует:

  • позиционно-независимый код, ведь никто не знает, куда модуль загрузят;
  • на x86 — малая модель кода, тогда как само ядро использует большую;
  • собственные memcpy и родственные функции из compiler-builtins, чтобы модуль импортировал только API ядра.

Затем ld.lld линкует его как разделяемый объект с -Bsymbolic, каждый сегмент — на собственных страницах, а скрипт версий экспортирует nos_module_info и больше ничего. Для трассировок стека добавляется секция .nos_syms с деманглированными именами функций модуля. hello.ko получается около 25 КиБ.

Собирайте модуль из того же дерева, что и ядро, в которое будете его загружать. Сборка хеширует исходники крейта ffi в дайджест, который попадает в каждый модуль и в ядро, и загрузчик отвергает модуль, чей дайджест не совпадает с дайджестом ядра. Модуль, собранный в обход Makefile, несёт unset и тоже отвергается.

Как доставить модуль на машину

  • На корневую файловую систему. Положите .ko в каталог и сделайте из него корневой образ командой scripts/mkrootfs.sh root.img 64 <dir>. Подключите образ к QEMU как диск virtio: root=auto с ISO монтирует файловую систему с меткой nos как /. Полная команда QEMU есть в руководстве по Linux-гостю.

  • По HTTP со своей машины. Раздайте каталог сборки и скачайте файл из шелла nos. В user-сети QEMU ваша машина — это 10.0.2.2:

    python3 -m http.server 8000 --directory out/x86_64/modules
    wget http://10.0.2.2:8000/greet.ko /greet.ko

    Без корневого диска / — это ramfs, и после перезагрузки файла не будет.

  • Из релиза — для модулей из дерева. Каждый релиз содержит все модули как <name>-x86_64.ko и <name>-arm64.ko. Берите модуль из того релиза, из которого взято работающее ядро (его называет version):

    wget https://github.com/irqlevel/nos/releases/download/<tag>/hello-x86_64.ko /hello.ko

На сервере, установленном из системного образа, /lib/modules принадлежит образу и заменяется при каждом обновлении, поэтому собственные модули держите в другом месте (Установка nos на выделенный сервер).

Загрузка, работа, выгрузка

Сеанс с hello в том виде, в каком его записала документация проекта; ваш модуль ведёт себя так же:

$ insmod /hello.ko
module: hello loaded at 0xFFFF8000...
$ hello nos
hello, nos -- call 1 since the module was loaded
$ lsmod
hello  24 KiB at 0xFFFF8000..., 7 kernel imports
$ rmmod hello
module: hello unloaded
  • Строки лога модуля уходят в лог ядра, а не в шелл: посмотрите их командой dmesg 5 hello.
  • Пятисекундное ожидание. insmod и rmmod работают каждый в своей задаче и ждут её не дольше пяти секунд. Выгрузка, которая длится дольше, например потому что ещё работает команда модуля, продолжается в фоне. lsmod тем временем показывает модуль как выгружающийся, а лог ядра сообщает, чем всё закончилось.
  • Загрузка при старте. Добавьте строку в /etc/rc командой rc add insmod /greet.ko. Задача шелла выполняет /etc/rc, когда сеть уже поднята.
  • Выключение. poweroff и reboot выгружают все модули, которые можно выгрузить, начиная с самого нового, до размонтирования файловых систем.

Что проверяет загрузчик

insmod передаёт файл загрузчику src/cpp/kernel/module.cpp, который проходит эти шаги и отвергает модуль на первом же неудачном:

  1. Файл. Он должен быть ELF64, little-endian, разделяемым объектом для этого CPU, с заголовками внутри файла и без сегментов thread-local и интерпретатора. Каждый загружаемый сегмент должен быть выровнен по странице, лежать внутри файла, не пересекаться с другими и никогда не быть одновременно доступным на запись и исполнение.
  2. Импорты — ещё до отображения в память. Каждый неопределённый символ ищется в таблице экспорта ядра. Модуль, которому ядро не может дать нужное, отвергается со списком всего, чего не хватает.
  3. Образ. Он отображается на собственные страницы, не больше 16 МиБ вместе с именами функций.
  4. Перемещения. Применяются только три вида: «ничего», «база загрузки плюс addend» и «символ плюс addend». Всё остальное отвергается.
  5. Заголовок, теперь уже с настоящими указателями: магическое число, версия формата, имя, init и exit, указывающие в собственный код модуля, и хеш интерфейса ядра.
  6. Права страниц. Каждый сегмент получает то, что просит его заголовок программы: только чтение, чтение и исполнение или чтение и запись. TLB сбрасывается на всех CPU, а на arm64 синхронизируются кэши инструкций. Ядро с модулем внутри остаётся W^X.
  7. init. Второй модуль с тем же именем отвергается ещё до этого шага, а неудачный init ничего после себя не оставляет.

Как выглядит отказ:

Сообщение Что делать
module: built against another kernel interface (ffi ..., this kernel ...) -- rebuild it from this tree Соберите модуль из дерева ядра или возьмите его из релиза ядра.
module: needs <symbol>, which this kernel does not export Модуль вызывает то, чего нет в ffi этого ядра. Пересоберите ядро и модуль вместе.
module: segment N is both writable and executable Соберите .ko через make modules: его линковка даёт каждому сегменту собственные страницы и права.
module: no nos_module_info -- not a module built with kmod::module! В крейте нет вызова kmod::module!.
module: greet is already loaded Сначала выполните rmmod greet.
module: greet: init failed init вернул ошибку. dmesg покажет строку module init failed: ... с причиной.
module: greet is permanent: it imports ..., which nothing undoes Модуль зарегистрировал обработчик soft IRQ и останется в ядре до следующей загрузки.

Кадр стека в коде модуля называется в трассировках так же, как кадры ядра, с именем модуля в конце, например mod_hello::init::{closure#0}+0x3c [hello]. Отчёт о панике заканчивается строкой Modules: с адресами всех модулей.

Проверка

  • Каждая загрузка проверяет загрузчик. Самотест при загрузке на обеих архитектурах загружает modtest, модуль, встроенный в образ ядра, а затем скармливает загрузчику его испорченные копии: обрезанную, от чужой архитектуры, с импортом, которого ядро не экспортирует, без заголовка, с неверным магическим числом, под другой интерфейс ядра. Поэтому smoke-загрузки покрывают и загрузчик (Гейты: как проверять изменения в nos).
  • После изменений в загрузчике или в объявлениях ffi запускайте scripts/sshd-test.py.
  • С проверками неопределённого поведения соберите make nocheck RUSTUB=1. Модули пересоберутся с проверками предусловий unsafe-операций core и с проверками переполнения, и первое нарушение станет паникой с указанием места.
  • Свой модуль проверяйте вручную. Если он может сломаться так, что это будет выглядеть как успех, напишите для него собственный гейт — как у netload есть scripts/netload-test.py.

Если что-то пошло не так

make modules не собирает ваш модуль. Его имени нет в MODULES в Makefile.

Cargo сообщает, что пакет mod-greet не найден. Крейта нет в members рабочего пространства, или пакет назван не mod-<name>.

insmod пишет init failed. Найдите в dmesg строку module init failed. Частая причина — имя команды: register не срабатывает, если такое имя уже есть у встроенной команды или у команды другого модуля.

rmmod вернулся, а lsmod всё ещё показывает модуль как выгружающийся. Ещё работает вызов одной из его команд, и выгрузка его ждёт. Команда, которая никогда не завершается, оставляет свой модуль выгружающимся навсегда.

Ядро запаниковало в вашем коде. Паника в модуле — это паника ядра. Трассировка помечает ваши кадры как [greet].

Что дальше