В первой части мы загрузили своё ядро на настоящий телефон, вывели «Hello world» в UART и нарисовали квадратик на экране. Во второй — научили ядро управлять памятью и завели кучу.
С тех пор прошло более 4 месяцев, и проект тихо перешагнул важную черту: из bare-metal эксперимента получилось микроядро. В привилегированном режиме остались только процессы, память и передача сообщений, а всё остальное — драйверы, сервисы, будущая графика — обычные программы снаружи. Само ядро при этом выросло с неполной тысячи строк до сорока пяти тысяч.
В этой статье разберёмся, что такое capability и handle и при чём здесь seL4 и Zircon, запустим свой процесс-«калькулятор» на телефоне и поговорим с ним по IPC, а под конец напишем userspace-драйвер часов для устройства, про которое в ядре нет ни строчки.
Весь код доступен на GitHub.
Вот что появилось в ядре со времён второй части:
-
векторы исключений и драйвер контроллера прерываний — ядро реагирует на внешний мир;
-
таймер и планировщик с вытеснением — userspace-многозадачность в духе RTOS: фиксированные приоритеты, O(1) выбор потока, таймауты;
-
собственные адресные пространства и загрузчик образов — потоки превратились в изолированные процессы;
-
таблицы хендлов с правами и каскадным отзывом — система прав на основе capabilities;
-
syscall ABI и синхронный IPC с передачей хендлов — взаимодействие процессов с ядром и друг с другом;
-
userspace-рантайм с кодогенерацией IPC-контрактов — пока базовый, но уже прячет всю машинерию за обычным Rust-API.
Содержание
Современные концепции ядер ОС
Мобильный рынок сегодня поделён между двумя системами — Android и iOS. Android работает на ядре Linux. Это монолит: драйверы, файловые системы, сетевой стек, планировщик — всё исполняется в одном привилегированном адресном пространстве. У iOS ядро своё, XNU, и оно гибридное: в его основе лежит микроядро Mach, но компоненты BSD и драйверы работают всё в том же режиме ядра. На бумаге микроядерные корни, на практике — большое монолитное ядро. Получается, оба гиганта при всех различиях сделали один и тот же выбор: в ядре крутится огромный объём кода, которому приходится безоговорочно доверять. Это быстро и проверено годами. Но один кривой драйвер способен уронить или скомпрометировать всю систему.
Я решил в своём экспериментальном ядре попробовать пойти в другую сторону, ближе к настоящему микроядру. Идея обратная: в привилегированном режиме оставить самый минимум, всего лишь потоки, память и передачу сообщений. А драйверы, файловые системы и сервисы вынести в обычные процессы. Почти все: контроллер прерываний и системный таймер останутся при ядре. Планировщик живёт на его стороне, а прерывания и кванты времени — его рабочий инструмент. Да и устройствами их назвать сложно: generic timer — блок самой ARM-архитектуры, он есть в каждом ядре процессора и управляется системными регистрами, а не MMIO, как внешняя железка; у GICv3 процессорный интерфейс устроен так же. Это скорее часть процессора, чем периферия. UART тоже пока в ядре, но это временный костыль до появления userspace-логгера.
Сама идея десятилетиями считалась красивой, но непрактично медленной, сугубо академической. Ровно до тех пор, пока за неё не взялись всерьёз.
Микроядерный подход уже доехал до настоящих устройств — например, Fuchsia от Google, которая работает на их умных дисплеях. В её основе ядро Zircon: не монолит, процессы общаются сообщениями, а доступ к ресурсам идёт через объекты с правами.
А потянув за эту ниточку, добрался и до seL4 — микроядра, которое в этом мире считается эталоном строгости: крошечное и вдобавок формально верифицированное, то есть его корректность математически доказана. Тут и всплывает неудобный вопрос, который встаёт перед любым ядром, как только в нём заводятся процессы: кто и на каком основании решает, кому что можно — писать в эту память, слать сообщения вон тому сервису, завершать вот этот поток? Оба ядра отвечают одинаково: полномочия не проверяются по спискам, как права в Unix, а выдаются процессу на руки — их можно передать, скопировать послабее или отобрать. Мне эта идея зашла. Из связки — практичного Zircon и принципиального seL4 — я и пробовал собрать модель своей ОС. Разберём, что взял из каждого.
Что такое Zircon
Начнём с Zircon: он ближе к практике. Это ядро, на котором работает Fuchsia, и вся его модель крутится вокруг одного понятия — объекта ядра.
В Zircon всё, чем процесс может пользоваться, оформлено как объект ядра: сам процесс, поток, участок памяти, канал для сообщений, таймер, событие. Объект живёт внутри ядра, напрямую из userspace к нему нет доступа. Вместо этого процесс держит handle — ссылку на объект. По сути это обобщение знакомого по Unix файлового дескриптора: там небольшим числом адресуют открытый файл или сокет, здесь — вообще любой ресурс ядра.
Важно, что handle — не глобальное имя и не указатель, а запись в таблице хендлов конкретного процесса. Число 3 в одном процессе и число 3 в другом ведут к разным объектам или никуда. Чтобы что-то сделать с ресурсом, нужно иметь на него handle. Нету ручек — нет конфетки доступа. Совсем как электронный замок в отеле: ему неважно, кто ты, — важно, какая карта у тебя на руках.
У каждого хендла есть rights — битовая маска прав: читать, писать, дублировать, передавать. Один и тот же объект разные процессы могут держать с разными правами. Хендл можно продублировать, при желании сузив права, и, самое интересное, передать другому процессу прямо в сообщении по каналу. На этом держится вся модель: передать handle — значит передать полномочие. «Держи доступ к этому объекту, ровно с такими правами, дальше распоряжайся сам».
Внутри это одна операция над двумя таблицами. Сам объект — например, zx_channel, базовый IPC-примитив Zircon, — остаётся в ядре, а запись о нём пропадает из таблицы хендлов отправителя и появляется в таблице получателя. Номер у неё там свой: таблица у каждого процесса собственная, так что один и тот же объект у отправителя был, скажем, под 3, а к получателю приедет под 7.
handle плюс rights — это, собственно, и есть capability в инженерной упаковке. Именно отсюда мы взяли каркас своей модели: объекты ядра, права на них и плоскую таблицу хендлов. Но Zircon описывает, из чего система сложена, а не то, как её части разговаривают друг с другом в реальном времени. За стиль этого разговора у меня отвечает другой ориентир — seL4.
Что такое seL4
seL4 знаменит аскетизмом — он выносит из ядра почти всё, вплоть до управления памятью, и за счёт этого получается настолько маленьким, что его корректность доказали математически. Наше ядро, как и Zircon, держит внутри и менеджер памяти, и heap, и набор готовых объектов. Так что в минимализм мы не играем. У seL4 я подсмотрел другое — две вещи, которых в Zircon нет: как устроено общение между процессами и как отобрать назад однажды выданное полномочие.
Базовый IPC в seL4 — это синхронная встреча, рандеву. Отправитель и получатель сходятся на общей точке: кто пришёл первым, тот ждёт второго, и сообщение передаётся в момент встречи, а не складывается в очередь где-то в ядре. Выбор сознательный. Раз очереди нет, ядру нечего хранить: ни буферов сообщений, ни их объёма, ни политики, что делать при переполнении. Меньше состояния в ядре — меньше кода, который пришлось бы доказывать. Заодно бесплатно получается backpressure: быстрый отправитель не убежит вперёд медленного получателя, потому что физически блокируется на встрече, пока тот не готов принять.
Самый частый сценарий звучит как «позвонить и дождаться ответа»: процесс отправляет запрос и засыпает, пока на той стороне его не обработают. Чтобы ответить, заводится одноразовый reply-объект (в seL4 это reply-capability) — право ответить один раз и только тому, кто звал. Одноразовость тут по делу: ядро гасит это право сразу после ответа, так что сервер не ответит дважды и не переадресует ответ чужому запросу. Это заметно отличается от Zircon с его асинхронными каналами-очередями, где ядро как раз буферизует сообщения, а отправитель кидает своё и идёт дальше.
Наш IPC так и устроен. Базовый примитив — Port (точка встречи) и одноразовый Reply: PortCall отправляет запрос и блокирует вызвавшего до ответа, PortReply будит его результатом. Кадр сообщения лежит в IPC-буфере потока, и на него отведена ровно одна страница памяти — это и есть лимит размера сообщения. Вся вышестоящая логика, то есть то, как клиенты и сервисы разговаривают друг с другом, построена поверх этой синхронной пары send-recv.
У сервера обычно много клиентов. Можно было бы завести каждому свой Port, но это по объекту ядра на клиента — расточительно. Значок (badge), ещё один приём из seL4, позволяет обойтись одним портом на всех. Сервер раздаёт клиентам копии одного и того же порт-хендла, но каждую помечает своим значком; ставится он один раз и потом неизменен, переклеить нельзя. Когда приходит сообщение, ядро вместе с ним отдаёт серверу значок отправителя — и тот сразу знает, кто на проводе, хотя порт у всех общий.
Вторая вещь от seL4 — отзыв полномочий. Когда хендл дублируют, копия становится не самостоятельной, а потомком исходного: внутри ядра выстраивается дерево происхождения. Закрыть хендл — значит отозвать не только его, но и всё, что из него выросло: все производные копии, даже те, что уже уехали в другие процессы. Раздал право трём процессам, передумал, закрыл свой корневой хендл, и у всех троих оно разом протухает: на следующем же обращении они получат ошибку, а не молчаливый доступ к тому, что им больше не положено. Отзыв идёт строго вниз по дереву: закрытие потомка предка и соседей не трогает. Отсюда — два способа поделиться правом. Отдать насовсем: передать сам корневой хендл. Или одолжить: передать потомка-дубликат, а корень оставить себе — пока он у тебя, доступ у получателя работает, закрыл — забрал обратно. В Zircon так нельзя: там продублированный хендл живёт своей жизнью, и закрытие оригинала ему нипочём, а возможность одним движением забрать розданное назад — это как раз дисциплина seL4.
Получается честное разделение. Форму (объекты, права, таблицу хендлов) мы взяли у Zircon, а строгие дисциплины поверх неё — синхронный рандеву-IPC и отзыв полномочий по дереву — у seL4. Ни то, ни другое не копия: ни формальной верификации seL4, ни всей машинерии Zircon у нас нет, но обе идеи легли в основу в своей, заметно более простой форме.
Как устроена наша модель
Сложим всё вместе и посмотрим, во что эти заимствования превратились в нашем ядре.
В центре всего стоит объект ядра, у нас он называется capability target — то, на что ядро выдаёт права. Их немного, и список читается почти как оглавление всей системы. Process и Thread — единицы исполнения и изоляции. Memory — участок адресного пространства. Port и Reply — уже знакомые точки встречи и одноразовые ответы для IPC. Signal — примитив, на котором ждут и будят, причём ждать можно сразу на нескольких объектах разом, как в poll из Unix (в самом seL4 такого нет). Resource — право на физический ресурс, например кусок реального железа. Плюс пара объектов для прерываний, чтобы драйверы могли ловить IRQ прямо из userspace. Всё, что процесс способен потрогать, — один из них.
Доступ к объекту процесс получает не по имени, а через handle — индекс в своей, плоской таблице хендлов. У каждого хендла есть права из короткого набора: READ, WRITE, DUPLICATE, TRANSFER, EXECUTE. Права можно только сужать: дублируя хендл, ты вправе отдать копию с меньшим набором, но не дописать себе новых. В одну схему это укладывается цепочкой Process -> HandleTable -> Capability -> Target: процесс адресуется в свою таблицу, там лежит запись с правами, а она уже ведёт к объекту ядра.
Откуда берётся самый первый хендл? Из корня. При загрузке HAL поднимает железо и отдаёт управление ядру, а ядро создаёт первый userspace-процесс rootkeeper (решил покреативничать и не называть его банальным init) и вручает ему стартовый набор полномочий. Дальше rootkeeper раздаёт права остальным сервисам, сужая их по дороге. Это та самая «система из корня», которую мы подсмотрели у seL4: глобального реестра нет, любой доступ кто-то кому-то когда-то выдал явно.
Третий козырь пришёл уже не из мира микроядер. Синхронный Port/Reply хорош для управляющих запросов, но плох, когда надо гнать поток данных: каждое сообщение оборачивается системным вызовом и походом в ядро. Для таких случаев у нас есть второй транспорт — кольцевой буфер в памяти, разделяемой между двумя процессами. Идея старая и проверенная: так общается гость с гипервизором через virtio- и Xen-кольца, на ней же стоит io_uring в Linux.
Отправитель кладёт кадры в кольцо, получатель забирает. Пока в кольце что-то есть, ядро вообще не у дел: ни одного системного вызова. Будить его нужно лишь в одном случае: растолкать уснувшего получателя, когда кольцо было пустым и в него положили первый кадр. Под нагрузкой кольцо почти всегда непусто, так что и эти пробуждения исчезают, а обмен идёт на скорости памяти.
Так наш IPC получил два режима. Надёжный синхронный Port/Reply через ядро отвечает за команды и передачу прав, а быстрый кольцевой поток через общую память — за объёмные однонаправленные данные между двумя процессами.
Типизированные контракты
Транспорт (и Port, и кольцо) отдаёт и принимает голые байты: положил кадр, забрал кадр. Но клиенту и серверу мало сойтись на канале, им надо совпасть ещё и в том, что лежит внутри кадра: где какое поле, какой оно ширины, в каком порядке. Реализовывать такой протокол руками на обеих сторонах — занятие нервное: добавил серверу поле, а на клиенте поправить забыл, и вместо честной ошибки компиляции получаешь молча побитые данные где-то в рантайме.
Поэтому поверх транспорта лежат типизированные контракты. Контракт — это обычный Rust-трейт с атрибутом #[ipc::protocol], где каждый метод размечен по виду операции:
-
#[call]— вопрос с ответом: клиент блокируется, пока сервер не ответит (по умолчанию бессрочно, таймаут настраивается); -
#[cast]— односторонняя посылка: отправил и пошёл дальше; -
#[event]— то же, но в обратную сторону, от сервера к клиенту.
Из одного такого трейта proc-макрос генерирует всё, что нужно обеим сторонам. Стороне клиента — структуру Client с обычными методами: зовёшь метод, а упаковку кадра, отправку и разбор ответа она берёт на себя. Стороне сервера — трейт Service, чьи методы реализуешь как простые функции, и диспетчер dispatch_*, который принимает очередной кадр, находит по ordinal нужную операцию и зовёт твою реализацию. Весь серверный цикл — одна строка: while dispatch_<protocol_name>(&mut service, &transport).is_ok() {}.
Какой транспорт под этим лежит, контракту всё равно: порт или кольцо — только кольцу доступны лишь #[cast] и #[event], звонков с ответом в одностороннем потоке не бывает. Идея знакомая: так устроены FIDL в Fuchsia и AIDL у Binder в Android.
Userland-образ: откуда берутся программы
Файловой системы у нас нет, а программы откуда-то брать надо. Пока роль «диска» играет userland-образ: все userspace-программы на этапе сборки складываются в один бинарный контейнер userland.img, который ядро получает на старте. По сути это наш аналог initrd в Linux, только внутри не файловая система, а плоский список программ.
Формат нарочно простой: заголовок с магией USRLIMG и числом записей, дальше — записи-программы. У каждой имя, точка входа, размер стека и сегменты с правами: код — на чтение и исполнение, данные — на чтение и запись. Хост-инструмент нарезает всё это из обычных ELF-файлов после cargo build, а состав образа описывается декларативно:
Пример toml файла для описания userland образа
user/rtc-demo/image.toml:
version = 1[[programs]]package = "rtc-demo"bin = "rtc-init"name = "rtc-init"bootstrap = true[[programs]]package = "rtc-demo"bin = "rtc-driver"name = "rtc-driver"[[programs]]package = "rtc-demo"bin = "rtc-client"name = "rtc-client"
Запись с пометкой bootstrap — единственная, которую запускает само ядро. Остальные лежат в образе, пока кто-нибудь из userspace не попросит их запустить: образ приезжает процессу Memory-хендлом только на чтение, а load_entry из рантайма достаёт запись по имени и спавнит из неё потомка. Копировать при этом ничего не надо — парсер раздаёт ссылки прямо внутрь отображённого региона.
Когда-нибудь на этом месте будет файловая система, но для текущих задач контейнера хватает с запасом.
Демо 1: калькулятор в соседнем процессе
Хватит теории. Соберём минимальный пример, в котором модель отрабатывает целиком: один процесс запускает другой и разговаривает с ним. Второй — «калькулятор», сервис из одной операции: перемножить два числа. По дороге придётся достать образ программы, создать порт, выдать права и договориться о протоколе — тот же путь, который в большой системе проходит любой сервис.
Родитель начинает почти с пустыми руками: на старте у него один-единственный хендл — порт бутстрап-сервиса ядра. Всё остальное просим через него.
user/calculator-demo/src/main.rs:
#[unsafe(no_mangle)]pub extern "C" fn _start(root: Handle) -> ! { thread_exit(u64::from(run(root).is_err()))}fn run(root: Handle) -> Result<()> { let root_capability = OwnedHandle::from_handle(root); let bootstrap_client = BootstrapClient::new(PortTransport::client(root_capability.borrow())); // ...}
Сначала — код калькулятора. Просим у ядра хендл userland-образа, отображаем его к себе и достаём запись calculator:
// Получаем capability на доступ к userland-imagelet image_cap = bootstrap_client.acquire_userland_image()?.map_err(|_| Error::Vend)?;// Отображаем образ в своё адресное пространствоlet image_region = MemoryRegion::adopt(image_cap);let image_mapping = image_region.map_full(UserMemFlags::ReadOnly)?;// Парсим контейнер и достаём программуlet image = decode(image_mapping.as_bytes())?;let calculator_entry = image.entry("calculator").ok_or(Error::MissingEntry)?;
Обратите внимание: образ пришёл не путём к файлу, а хендлом Memory-объекта — таким же, как любой другой.
Теперь ключ для потомка. Создаём порт, на котором калькулятор будет принимать запросы, и дублируем хендл с урезанием прав:
let calculator_port = Port::create()?;// Калькулятору нужны READ (recv как сервер), WRITE (reply) и TRANSFER// (чтобы load_entry передал хендл потомку)let calculator_capability = calculator_port.handle();let duplicated_capability = calculator_capability.duplicate(Rights::READ | Rights::WRITE | Rights::TRANSFER, 0)?;
DUPLICATE в маске нет — скопировать ключ и раздать его кому-то ещё калькулятор не сможет. Полный хендл порта остаётся у родителя.
Спавн — одна строка, за которой прячутся три системных вызова: создать процесс, загрузить образ, запустить. Урезанный дубликат приедет потомку прямо в аргумент _start:
let calculator_process = load_entry(&calculator_entry, &resource, duplicated_capability)?;
Как весь этот маршрут выглядит глазами таблиц хендлов — по шагам на схеме:

Дело за языком общения. Вот протокол калькулятора, целиком:
user/calculator-demo/src/api.rs:
#[ipc::protocol(name = "Calculator")]pub trait CalculatorApi { #[call] fn multiply(&self, a: u64, b: u64) -> u64;}
Пять строк — и CalculatorApiClient, CalculatorApiService и dispatch_calculator_api сгенерированы. Кадр, который поедет в порт, устроен так:

Кстати, ordinal на схеме — не порядковый номер из какого-нибудь реестра, а хвост SHA-256 от имени операции. Кадр чужого протокола по нему просто не совпадёт и будет отвергнут — совместимость без центрального реестра номеров.
Сервер поэтому выглядит как скучная реализация трейта — про порты, кадры и ordinal он не знает ничего:
user/calculator-demo/src/calculator.rs:
struct Calculator;impl CalculatorApiService for Calculator { fn multiply(&mut self, a: u64, b: u64) -> u64 { a * b }}fn run(root: Handle) -> Result<()> { let transport = PortTransport::server(root); let mut calculator = Calculator; loop { let _ = dispatch_calculator_api(&mut calculator, &transport); }}
А клиент зовёт multiply как обычную функцию. Заставим сервис поработать: посчитаем факториалы, где каждое умножение уезжает в соседний процесс:
let calculator_api = CalculatorApiClient::new(PortTransport::client(calculator_capability));for n in 1..=6 { let result = factorial(&calculator_api, n); log(&bootstrap_client, &format!("Factorial of {n} is {result}"));}fn factorial(calculator: &CalculatorApiClient<PortTransport>, n: u64) -> u64 { if n <= 1 { 1 } else { calculator.multiply(n, factorial(calculator, n - 1)).unwrap() }}
Каждый такой вызов — синхронное рандеву из раздела про seL4: кадр уезжает в порт, клиент засыпает, сервер отвечает через одноразовый Reply:

Лог прогона — UART с реального Redmi Note 7, сборка загружена через fastboot boot, как в первой части:
[I] [boot_primary.rs] primary_main: post-MMU initialization complete[I] [kmain.rs] Starting kmain[I] [driver_init.rs] Running driver UART_DM_DRIVER. OK[I] [driver_init.rs] Running driver GIC_V3_DRIVER. OK[I] [driver_init.rs] Running driver ARM_GENERIC_TIMER_DRIVER. OK[I] [kmain.rs] Kernel drivers initialization completed[I] [init.rs] bootstrap process spawned[I] [calculator] Factorial of 1 is 1[I] [calculator] Factorial of 2 is 2[I] [calculator] Factorial of 3 is 6[I] [calculator] Factorial of 4 is 24[I] [calculator] Factorial of 5 is 120[I] [calculator] Factorial of 6 is 720[I] [bootstrap.rs] bootstrap: port closed, exiting[I] [init.rs] bootstrap process exited with code 0
Ядро поднимает три своих драйвера — GIC, таймер и UART, те исключения, о которых договаривались в начале, — и спавнит bootstrap-процесс, родителя из листингов выше. Строки с факториалами — klog от него, и за каждой из них прячется поход умножения по IPC в соседний процесс. В конце оба процесса штатно завершаются. Телефон, который в первой части печатал «Hello world», теперь исполняет изолированные процессы с передачей полномочий.
Дальше поднимем ставку: вместо умножения дадим процессу настоящее железо.
Демо 2: userspace-драйвер часов
Обещанное железо — часы PL031: простейшее RTC-устройство, счётчик секунд в MMIO-регистре. В Linux для него есть драйвер в ядре. Это демо, в отличие от калькулятора, живёт только в QEMU: PL031 — устройство виртуальной платы virt, в реальном телефоне его нет. В нашем ядре про PL031 нет ни строчки — и не будет: драйвером станет обычный userspace-процесс, такой же бесправный, как калькулятор из прошлого раздела.
Участников теперь трое: главный процесс, драйвер и клиент. Главный раздаёт роли, драйвер отдаёт время, клиент спрашивает. Договорённости — те же типизированные контракты:
user/rtc-demo/src/api.rs:
/// Сервис часов реального времени.#[ipc::protocol(name = "Clock")]pub trait Clock { /// Текущее время устройства в секундах. #[call] fn now(&self) -> u64;}/// Стартовые аргументы драйвера часов.#[ipc::protocol(name = "DriverArgs")]pub trait DriverArgs { /// Окно устройства и серверная часть Clock-порта. #[cast] fn provide(&self, device: ipc::wire::Cap, clock_port: ipc::wire::Cap);}/// Стартовые аргументы клиента часов.#[ipc::protocol(name = "ClientArgs")]pub trait ClientArgs { /// Клиентская часть Clock-порта и канал вывода в klog. #[cast] fn provide(&self, clock: ipc::wire::Cap, klog: ipc::wire::Cap);}
Стартовые хендлы уезжают потомку одним #[cast]-сообщением — ответа тут никто не ждёт. А тип Cap в сигнатуре — это передача capability прямо в кадре: пересадка хендла из таблицы отправителя в таблицу получателя.
Сначала железо надо найти. Адрес в код не зашит: главный процесс просит у ядра device tree (его ядро отдаёт таким же Memory-хендлом), находит узел PL031 и берёт базовый адрес оттуда:
user/rtc-demo/src/main.rs:
fn get_pl031_base(kernel: &BootstrapClient<PortTransport>) -> Result<u64> { let fdt_region = MemoryRegion::adopt(kernel.acquire_boot_fdt()?.map_err(|_| Error::Vend)?); let fdt_mapping = fdt_region.map_full(UserMemFlags::ReadOnly)?; let tree = DeviceTree::from_bytes(fdt_mapping.as_bytes()).map_err(|_| Error::Dtb)?; let root = tree.root().ok_or(Error::Dtb)?; let pl031 = root .children() .find(|node| node.is_compatible("arm,pl031")) .ok_or(Error::Dtb)?; pl031.first_reg_base(&root).ok_or(Error::Dtb)}
По найденной базе запрашиваем MMIO-окно самого устройства:
let base = get_pl031_base(&kernel)?;let device: MemoryRegion = kernel .acquire_device_memory_by_base(base)? .ok_or(Error::NoDevice)?;
Право на кусок физического железа выглядит так же, как всё остальное в системе, — это хендл.
Теперь раздача ролей. Драйвер спавнится с серверной частью своего args-порта и первым же сообщением получает окно устройства и серверную часть clock-порта:
let clock_port = Port::create()?;let driver_args_port = Port::create()?;// Драйвер стартует с серверной частью своего args-порта.let driver = spawn( &image, "rtc-driver", &resource, driver_args_port .handle() .duplicate(Rights::READ | Rights::WRITE | Rights::TRANSFER, 0)?,)?;let driver_args = DriverArgsClient::new(PortTransport::client(driver_args_port.handle()));driver_args.provide( device.into_handle(), clock_port .handle() .duplicate(Rights::READ | Rights::WRITE | Rights::TRANSFER, 0)?,)?;
Клиент получает своё симметрично, но взгляните на права:
client_args.provide( clock_port .handle() .duplicate(Rights::WRITE | Rights::TRANSFER, 0)?, root.duplicate(Rights::WRITE | Rights::TRANSFER, 0)?,)?;
Один и тот же clock-порт разъехался в два процесса с разными масками. У драйвера есть READ — он сервер и принимает запросы. У клиента только WRITE: звонить можно, слушать порт вместо сервера — нет. Раздел про Zircon это обещал — вот оно в работе.
Дальше весь драйвер — шесть десятков строк. Принял стартовые хендлы, отобразил окно устройства к себе и отдаёт время, читая регистр напрямую, — доступ к MMIO после отображения не стоит ни одного системного вызова:
user/rtc-demo/src/driver.rs:
/// Текущее время (секунды), RO.const RTC_DR: Reg<u32> = Reg::new(0x00);impl ClockService for ClockDriver { fn now(&mut self) -> u64 { u64::from(self.device.read_reg(RTC_DR)) }}fn run(server_args: Handle) -> Result<()> { let args_transport = PortTransport::server(server_args); let mut args: Option<ClockState> = None; dispatch_driver_args(&mut args, &args_transport)?; let ClockState { device, clock_port } = args.ok_or(Error::Vend)?; // Отображаем устройство в своё адресное пространство. let device = MemoryRegion::from_handle(device); let mapping = device.map_full(UserMemFlags::ReadWrite)?; let mut driver = ClockDriver::new(mapping.mmio()); let clock_transport = PortTransport::server(&clock_port); while dispatch_clock(&mut driver, &clock_transport).is_ok() {} Ok(())}
Клиент трижды спрашивает время с паузой, чтобы в логе появились разные секунды:
user/rtc-demo/src/client.rs:
/// Пауза между опросами, чтобы в логе были разные секунды.const POLL_PERIOD: Timeout = Timeout::from_ns(1_500_000_000);// Используем пустой сигнал как таймерlet timer = Signal::create()?;for _ in 0..3 { let seconds = clock.now()?; let _ = klog.log_str("rtc-client", &format!("clock: now() = {seconds}")); let _ = timer.wait(SIGNALED, POLL_PERIOD);}
Отдельного sleep-вызова в ядре, кстати, нет: пауза — это ожидание на пустом сигнале с таймаутом, ещё одна работа для всё того же Signal.
Лог прогона в QEMU:
[I] [boot_primary.rs] primary_main: post-MMU initialization complete[W] [boot_primary.rs] device window 0xa000200+0x200 skipped: misaligned base[I] [kmain.rs] Starting kmain[I] [driver_init.rs] Running driver UART_PL011_DRIVER. OK[I] [driver_init.rs] Running driver GIC_V3_DRIVER. OK[I] [driver_init.rs] Running driver ARM_GENERIC_TIMER_DRIVER. OK[I] [kmain.rs] Kernel drivers initialization completed[I] [init.rs] bootstrap process spawned[I] [rtc-client] clock: now() = 1783347961[I] [rtc-client] clock: now() = 1783347963[I] [rtc-client] clock: now() = 1783347964[I] [init.rs] bootstrap process exited with code 0
Загрузка та же, что на телефоне, только плата virt: UART здесь PL011, адреса GIC другие. Предупреждение про device window — вендинг MMIO-окон в работе: невыровненные окна virtio-устройств ядро пропускает, а окно PL031 доехало до драйвера. Дальше отчитывается клиент: три опроса с паузой в полторы секунды, и секунды в логе честно тикают — 61, 63, 64. Само значение — Unix-время: QEMU подкладывает в PL031 часы хоста, так что userspace-драйвер только что прочитал из «железа» настоящее время.
Часы идут, а в ядре по-прежнему нет ни слова «clock», ни адреса PL031. Механизм — выдать окно в железо — остался в ядре; политика — что это за железо и как с ним разговаривать — уехала в userspace. Упадёт драйвер — ядро и остальные процессы этого даже не заметят.
Что дальше
Подведём черту. Оба демо вместе — четыре сотни строк, и это обычные программы: без привилегий, без доступа в ядро, только с теми хендлами, которые им выдали. Всё остальное ядро сделало за кадром: изоляцию адресных пространств, планирование, доставку сообщений и проверку прав на каждом системном вызове.
Дальше в планах:
-
сервис файловой системы — для начала хотя бы ramfs;
-
реализация libc: стандартная библиотека C откроет дорогу настоящим приложениям — busybox, sqlite, а там, глядишь, и до DOOM недалеко;
-
стандартная библиотека Rust — чтобы собирать и запускать приложения, написанные на чистом Rust;
-
драйверы посерьёзнее часов: вывод на экран — уже не proof of concept, как квадратик из первой части, а полноценный userspace-драйвер дисплея.
Калькулятор запускается и на реальном телефоне, а вот драйверное демо пока живёт только в QEMU — так что путь к userspace-драйверам настоящего железа начнём с малого.
Если вы щупали seL4, Zircon или Genode руками — расскажите в комментариях про свой опыт с capability-моделью, интересно узнать. Код, как обычно, на GitHub.
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/1056164/