Пишем мобильную ОС на Rust. Часть 3. Путь от bare-metal эксперимента к capability-based микроядру

от автора

В первой части мы загрузили своё ядро на настоящий телефон, вывели «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/