Прошёл год с момента выхода первой части. Тогда нам удалось вывести изображение на экран настоящего телефона. Это была проверка того, что идея в принципе реализуема.
Дальше началась системная работа, о которой я писал в предыдущей части. Теперь ОС наращивает userspace‑слой. На экран вернулась картинка — уже с окнами, кнопками и приложениями для телефона, Raspberry Pi и QEMU. На телефоне заработал и тачскрин.

Эта часть будет обзорной: посмотрим, как теперь устроены связи между программами, файловая система и поддержка C и C++, запустим SQLite и DOOM. А в конце напишем своё GUI‑приложение — калькулятор. В третьей части он выводил результат в UART. В этой сделаем калькулятор с окном и кнопками.
Весь код доступен на GitHub.
Содержание
Что уже можно открыть
Лаунчер показывает плитки приложений. Выбрать нужную можно касанием на телефоне или мышью в QEMU. На телефоне работают и физические кнопки: громкость переключает выбор, короткое нажатие питания открывает приложение, а удержание возвращает к лаунчеру.
Files показывает доступные файловые системы, каталоги и содержимое файлов. Можно открыть /boot с файлами образа или /tmp с данными ramfs.
Physics — демо с анимацией. Рядом выводятся частота кадров и время расчёта: можно сравнить, как приложение работает на телефоне, Raspberry Pi и в QEMU.
Journal выводит системный журнал прямо на экран. Наконец‑то логи ядра и программ можно читать на телефоне без проводов UART.

Чтобы всё это заработало, приложениям понадобился доступ к файлам, экрану и событиям ввода. Начнём с того, как они получают эти сервисы.
Как программы подключаются к сервисам
В третьей части мы вынесли вычисления в отдельный процесс: одна программа отправляла запрос на умножение, другая возвращала результат. Соединение между ними мы создавали сами. Теперь таких связей гораздо больше. Приложениям нужны файлы, экран и события ввода, а драйверу тачскрина — I²C и управление выводами чипа.
Каждая программа объявляет в манифесте, какие сервисы ей нужны и какие она предоставляет. Первые называются требованиями, вторые — экспортами. Например, лаунчеру нужен сервис дисплея, который экспортирует драйвер экрана.
Для требования и экспорта указывается тип IPC‑протокола. В третьей части мы уже использовали такие протоколы: они описывают методы сервиса и типы параметров. По описанию генерируется код клиента и сервера. Клиент вызывает метод как обычную Rust‑функцию, а обмен данными между процессами берёт на себя библиотека IPC. По идее это похоже на AIDL в Android и FIDL в Fuchsia.
Подключениями управляет rootkeeper — первый userspace‑процесс. Когда программа запрашивает сервис, он проверяет её объявленные требования и находит экспорт с подходящим протоколом. Если поставщик ещё не запущен, запускает его. Поставщик принимает заявку и выдаёт сессию, по которой клиент дальше обращается к нему напрямую.

Так лаунчер получает подключение к сервису Display, а какой драйвер его предоставит, зависит от доступного железа. Приложениям не приходится самим запускать поставщиков и выстраивать порядок загрузки. Сами программы и их ресурсы входят в загрузочный образ ОС.
Откуда берутся файлы
Теперь к содержимому userland.img можно обращаться через файловую систему imagefs, смонтированную в /boot. Программы могут читать файлы и перечислять каталоги образа. Imagefs работает только для чтения.
Для новых файлов есть ramfs, смонтированная в /tmp. Здесь программы могут создавать каталоги, записывать данные, переименовывать и удалять файлы. Всё это хранится в оперативной памяти и существует до перезагрузки. Дисковых файловых систем (например, ext4) пока нет.

Серверы imagefs и ramfs работают в отдельных процессах. За выбор нужной файловой системы отвечает маршрутизатор имён. Например, при открытии /tmp/example.txt он находит монтирование /tmp и запрашивает файловую сессию у ramfs. Дальше чтение и запись идут напрямую между клиентом и этим сервером.
Для программы это остаётся обычной работой с путями и файлами. Клиентская библиотека выполняет обмен с серверами, а поверх неё работает и привычный std::fs.
Поддержка C и C++
Писать свою ОС на Rust интересно, но хочется запускать на ней и существующий софт. Для начала удалось запустить SQLite и DOOM. Они написаны на C и используют привычные функции стандартной библиотеки — выделяют память, открывают файлы, читают время. Чтобы собрать их для нашей системы, понадобилась своя поддержка libc.
Писать всю библиотеку C с нуля я не стал — взял picolibc. Она уже содержит функции работы со строками, форматирования, математические функции и многое другое. Но библиотека сама по себе не знает, как в нашей ОС открыть файл или создать поток. Эту часть нужно реализовать под систему.
Для этого появился крейт c-library. Он предоставляет C‑коду функции, за которыми стоит наш Rust‑рантайм. Например, файловые операции используют ту же клиентскую библиотеку, что и std::fs, а стандартный вывод попадает в системный журнал. Выделение памяти, часы и потоки тоже опираются на существующие механизмы ОС. При этом исходники программы остаются на C. Вот, например, наша hello-c:
user/examples/hello‑c/src/hello.c:
#include <stdio.h> int main(int argc, char **argv) { printf("hello from %s\n", argc > 0 ? argv[0] : "C"); return 0; }

Программа собирается вместе с остальным userland через Cargo. Небольшой build.rs компилирует C‑исходники, а Rust‑файл подключает c-library, которая подготавливает рантайм и передаёт управление в C‑функцию main. После сборки программу можно включить в образ и запустить обычным способом.
Больше всего возни было с потоками. Программы на C ждут POSIX threads: pthread_create, мьютексы, условные переменные, ключи потоков. Потоки в нашем рантайме есть, но pthreads — это не только запуск функции в отдельном потоке. У каждого потока свой errno, свои thread_local‑переменные и деструкторы, которые нужно вызвать при его завершении. Поэтому за каждым pthread_t стоит запись со всем этим состоянием, а на поддержку pthreads ушло больше тысячи строк.
Для C++ добавились библиотеки LLVM — libc++, libc++abi и libunwind. Первая предоставляет стандартные контейнеры, строки, потоки и другие средства C++. Остальные обеспечивают работу таких механизмов языка, как исключения и раскрутка стека. Например, при выходе из функции по исключению должны вызываться деструкторы локальных объектов.
В итоге C++‑код может использовать std::vector, std::thread, мьютексы и исключения. Системные операции при этом проходят через ту же поддержку C и Rust‑рантайм. Для проверки есть отдельные программы на C и C++, которые запускаются в нашей ОС.
Теперь попробуем собрать что‑нибудь поинтереснее Hello world — SQLite.
SQLite
Для первого примера я выбрал SQLite — самую настоящую СУБД. Она работает внутри процесса приложения и умеет создавать базы, записывать данные и выполнять SQL‑запросы.
Перенос оказался проще, чем я ожидал: у SQLite уже предусмотрены заменяемые части для работы с файлами и мьютексами. Наш VFS подключает её к файловым функциям библиотеки C, а мьютексы работают через pthread_mutex. Так пригодилась поддержка C из предыдущего раздела.
В тестовой программе создаём файл базы в /tmp и заполняем таблицу книг:
CREATE TABLE books(title TEXT, author TEXT, year INTEGER); INSERT INTO books VALUES ('Капитанская дочка', 'Александр Пушкин', 1836), ('Собака Баскервилей', 'Артур Конан Дойл', 1902), ('Маленький принц', 'Антуан де Сент-Экзюпери', 1943);
После этого закрываем базу и открываем тот же файл заново. Записи можно получить обычным запросом с сортировкой по году:
SELECT title, author, year FROM books ORDER BY year;

В той же программе проверяются транзакции: ROLLBACK отменяет изменения, а после COMMIT они остаются в файле и доступны при повторном открытии базы. Сам файл пока лежит в ramfs, поэтому данные сохраняются между запусками программы, но не переживают перезагрузку ОС.
Как приложение рисует на экране и получает ввод
В первой части код ядра знал адрес framebuffer и записывал туда пиксели. Для квадратика этого хватало. Приложению с кнопками и списками уже нужны отрисовка интерфейса, обработка нажатий и способ работать с разными экранами.
Доступ к экрану предоставляет протокол Display. Через него можно узнать разрешение, получить буфер для рисования и показать готовое изображение. На телефоне и Raspberry Pi за этим стоит драйвер framebuffer, в QEMU — virtio‑gpu.
Для интерфейса я выбрал Slint. Причина банальна: оказалось, что его тоже легко запустить, требования с ОС минимальны. Это библиотека для создания GUI с отдельным языком описания интерфейса и API для Rust и C++. В ней есть готовые элементы интерфейса, компоновки, анимации и предварительный просмотр в редакторе. Чем‑то похоже на Qt.
В Slint можно описать расположение элементов, их свойства и обработчики событий. Кнопки, текст и списки рисует сам Slint. Наш слой интеграции предоставляет ему поверхность для рисования, отправляет готовые кадры на экран и передаёт события ввода.
Окно приложению выдаёт лаунчер. Когда пользователь выбирает плитку, лаунчер подключается к экспорту Application нужной программы и передаёт ей доступ к экрану и вводу. Приложение открывается на весь экран. После его закрытия управление возвращается лаунчеру.
В обратную сторону идут события ввода. Драйверы сообщают о клавишах, движениях указателя и касаниях хабу ввода. Хаб направляет их в нужную сессию, а интеграция со Slint превращает их в события окна. Поэтому обработчик кнопки в интерфейсе не зависит от того, нажали её мышью в QEMU или пальцем на телефоне.

Что понадобилось для запуска тачскрина
В QEMU клавиатуру и мышь обслуживает virtio‑input. На телефоне нужно было запустить контроллер тачскрина — Novatek NT36672A. Он подключён по I²C, а для сообщения о касаниях использует отдельные выводы. Прежде чем получить от него координаты пальца, пришлось написать драйверы, от которых зависит его работа.
Сначала понадобился драйвер TLMM (Top Level Mode Multiplexer) — контроллера, который настраивает выводы процессора. Один и тот же вывод можно использовать как обычный цифровой вход или выход (GPIO) либо назначить ему функцию периферийного устройства, например линии I²C. Для I²C нужно выбрать назначение линий шины, а для тачскрина — настроить сброс и вход прерывания.
Затем понадобилось настроить тактирование. Контроллеру I²C нужны тактовые сигналы, которыми управляет GCC SDM660. Здесь GCC — Global Clock Controller, а не компилятор. Его драйвер включает нужные сигналы и устанавливает частоту.
После этого можно запускать сам контроллер шины QUP (Qualcomm Universal Peripheral). Его драйвер выполняет операции I²C: отправляет байты устройству и читает ответ. Через него драйвер Novatek получает параметры чипа и отчёты о касаниях. Спасибо, что хотя бы питание тачскрина на этом этапе уже включено загрузчиком.

Связи устройств описаны в Device Tree. Оттуда драйвер узнаёт адрес чипа на шине, линии сброса и прерывания, а также размеры сенсорной области. Эти параметры не приходится зашивать в код работы с касаниями.
Эти зависимости драйвер объявляет в своём манифесте:
user/system/driver‑novatek‑nvt‑ts/src/entry.rs:
component_manifest! { struct TouchscreenStartup { requirements: { i2c: all(i2c::Protocol), pins: all(pins::Protocol), platform: session(platform::Protocol), }, features: ["novatek,nt36672a-ts"], exports: [input_source::Protocol], } }
В requirements перечислены нужные драйверу сервисы: шина I²C, управление выводами и описание платформы. session запрашивает одно подключение, а all позволяет найти подходящего поставщика среди доступных — например, контроллер той шины, к которой подключён тачскрин.
В exports указан InputSource: через этот протокол драйвер подключается к хабу ввода и передаёт ему касания. Строка features делает экспорт доступным, когда система обнаружила устройство с совместимостью novatek,nt36672a-ts.
Когда палец касается экрана, контроллер сообщает об этом прерыванием. Драйвер читает по I²C отчёт, разбирает координаты и состояние контактов и передаёт их хабу ввода. Дальше событие проходит тот же путь до Slint, что и ввод от виртуального устройства в QEMU. Так за одним нажатием кнопки на экране оказались управление выводами, тактирование, шина I²C и драйвер самого сенсора.
Даже физические кнопки телефона подключены по‑разному. Power и Volume Down обслуживает блок кнопок контроллера питания PMIC (Power Management Integrated Circuit). А Volume Up подключена к GPIO другого PMIC — для неё понадобились драйвер управления выводами и отдельный драйвер GPIO‑кнопок.
DOOM
В конце третьей части я обещал, что «там, глядишь, и до DOOM недалеко». Добрались.

Для порта взял doomgeneric. В нём движок отделён от платформенной части: нужно предоставить функции вывода кадра, чтения клавиш, получения времени и ожидания. Сам движок написан на C и собирается с нашей libc, а эти функции реализованы на Rust.
Например, когда движок вызывает DG_DrawFrame, готовое изображение из его буфера передаётся нашему коду вывода. Он масштабирует кадр под экран и показывает его через Display. А DG_GetKey забирает нажатия и отпускания клавиш, полученные через Input. Для времени и задержек используются часы рантайма.
В качестве игровых данных используется Freedoom — свободный набор уровней, графики и звуков для Doom‑совместимых движков. Файл freedoom1.wad встроен в образ программы.
На телефоне пришлось приделать сенсорное управление: область движения, повороты, стрельбу и выбор оружия. Касания переводятся в те же действия, что и клавиши. Игровой кадр и управление размещаются в альбомной ориентации, чтобы использовать экран телефона по ширине. В QEMU можно играть с клавиатуры.
Пишем GUI‑калькулятор
В третьей части калькулятор был отдельным процессом, который перемножал числа по IPC. Теперь сделаем обычное приложение: поле результата, кнопки с цифрами и четыре арифметические операции. Вычисления выполняются внутри самого приложения. Используем уже знакомые протокол, манифест и точку входа, а к ним добавим описание интерфейса и обработчик кнопок.
Описываем интерфейс
Интерфейс лежит в файле calculator.slint. В нём задаются элементы окна, их расположение и оформление. Rust‑код передаёт в интерфейс результат вычисления и получает нажатия через callback press.
Фрагмент user/apps/calculator/ui/calculator.slint:
export component Calculator inherits Window { in property <string> display; in property <string> expression; in property <string> lit-operator; in property <bool> failed; in property <length> screen-height; callback press(string) -> bool; // ... }
display содержит число на экране, expression — строку с текущей операцией. Остальные свойства нужны для подсветки выбранной операции, отображения ошибки и подстройки интерфейса под высоту экрана.
Клавиатура описана списком кнопок. У каждой есть подпись, положение и размер в сетке. При нажатии кнопка передаёт свою подпись в общий обработчик:
clicked => { root.press(key.label); }
Так и цифра 7, и операция +, и кнопка = попадают в одну функцию. Нажатия с физической клавиатуры передаются туда же. Оформление кнопок и полную раскладку оставим в репозитории.
Во время сборки slint-build превращает описание интерфейса в Rust‑код. Приложение подключает его через slint::include_modules!() и создаёт окно как ui::Calculator.
Получаем окно
Чтобы лаунчер мог открыть калькулятор, программа объявляет экспорт Application с именем calculator. Через этот протокол она получает от лаунчера окно и ввод.
Манифест и точка входа
user/apps/calculator/src/entry.rs:
component_manifest! { struct CalculatorStartup { exports: [application::Protocol => [{ name = "calculator" }]], } }
При запуске программа получает стартовую связь с родительским процессом и ждёт выдачи окна. После этого подключает его к Slint:
let CalculatorStartup { parent } = components::requirements::<CalculatorStartup>()?; let Shown { window, input } = receive_window(executor, parent).await?; let mut platform = ScreenPlatform::open(window).await?;
window даёт доступ к экрану, а input — события ввода. ScreenPlatform связывает окно со Slint. Подготовку executor оставим за пределами листинга; полная точка входа есть в репозитории.
Подключаем кнопки к вычислениям
Логика калькулятора хранится в обычной Rust‑структуре Calculator. Метод press принимает нажатую клавишу, а методы display и expression возвращают текст для интерфейса. Отдельного процесса или потока для арифметики здесь нет.
Создаём интерфейс и начальное состояние:
let view = ui::Calculator::new() .map_err(|error| format!("ui refused: {error:?}"))?; view.set_screen_height(platform.logical_size().height); let calculator = RefCell::new(Calculator::default()); show(&view, &calculator.borrow());
RefCell позволяет менять состояние внутри обработчика нажатий. Сам обработчик передаёт клавишу калькулятору и обновляет свойства окна:
let weak = view.as_weak(); view.on_press(move |key| { let mut calculator = calculator.borrow_mut(); let accepted = calculator.press(&key); if let Some(view) = weak.upgrade() { show(&view, &calculator); } accepted });
Функция show переносит результат вычисления в Slint. После изменения свойств интерфейс перерисовывается:
fn show(view: &ui::Calculator, calculator: &Calculator) { view.set_display(calculator.display().into()); view.set_expression(calculator.expression().into()); view.set_lit_operator(calculator.lit_operator().into()); view.set_failed(calculator.failed()); }
Остаётся показать окно, подключить ввод и запустить цикл обработки событий:
view.show() .map_err(|error| format!("window refused: {error:?}"))?; platform.deliver_input(executor, input, &view); let Err(error) = platform.run().await; Err(error.into())
Теперь нажатие проходит весь путь: Slint вызывает press, калькулятор обновляет состояние, а show передаёт новый результат в окно. На телефоне кнопки нажимаются пальцем, в QEMU — мышью или с клавиатуры.
Запускаем приложение
Калькулятор включён в основной образ userland:
user/system/rootkeeper/image.toml, фрагмент:
[[programs]] package = "calculator" path = "/calculator"
В лаунчере для него уже есть плитка Calculator. Собираем и запускаем образ в QEMU:
cargo xtask build devices/spec/qemu-aarch64.yaml --run

Обычный калькулятор. Теперь с GUI.
Что дальше
С каждым новым приложением растёт и ramdisk загрузочного образа, в котором лежит userland. Мы уже упираемся в ограничение загрузчика телефона на размер образа, передаваемого через Fastboot. Так что дальше нужен накопитель и драйвер ext4, с которого ОС сможет читать программы и на котором останутся созданные файлы. Заодно база SQLite перестанет исчезать после перезагрузки.
А на Raspberry Pi 5 пока вообще нет ввода. Для тестов я подключал драйвер, который выдаёт программные нажатия. Получается, калькулятор написали, DOOM запустили, но управлять ими на Raspberry Pi пока нечем. Для этого займёмся написанием USB стека и подключим к Raspberry Pi клавиатуру (и не только).