Загружаемый модуль ядра на Rust
Напишите модуль ядра nos на Rust с kmod и kcore, добавьте в него команду шелла, соберите .ko и загрузите его в работающее ядро.
В итоге у вас будет собственный модуль: крейт в 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/moduleswget 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, который проходит эти шаги и отвергает модуль на первом же неудачном:
- Файл. Он должен быть ELF64, little-endian, разделяемым объектом для этого CPU, с заголовками внутри файла и без сегментов thread-local и интерпретатора. Каждый загружаемый сегмент должен быть выровнен по странице, лежать внутри файла, не пересекаться с другими и никогда не быть одновременно доступным на запись и исполнение.
- Импорты — ещё до отображения в память. Каждый неопределённый символ ищется в таблице экспорта ядра. Модуль, которому ядро не может дать нужное, отвергается со списком всего, чего не хватает.
- Образ. Он отображается на собственные страницы, не больше 16 МиБ вместе с именами функций.
- Перемещения. Применяются только три вида: «ничего», «база загрузки плюс addend» и «символ плюс addend». Всё остальное отвергается.
- Заголовок, теперь уже с настоящими указателями: магическое число, версия формата, имя, init и exit, указывающие в собственный код модуля, и хеш интерфейса ядра.
- Права страниц. Каждый сегмент получает то, что просит его заголовок программы: только чтение, чтение и исполнение или чтение и запись. TLB сбрасывается на всех CPU, а на arm64 синхронизируются кэши инструкций. Ядро с модулем внутри остаётся W^X.
- 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].
Что дальше
- Loadable kernel modules — справочник с описанием больших модулей
blkload,netload,netblk,sshd,heapbenchиhv. - Rust in the kernel — слои, место для
unsafeи что есть вkcore. - Исходники настоящих модулей:
src/rust/modules. - Linux-гость под гипервизором nos — самый большой модуль из всех, гипервизор.