Доброг овремени суток, это Artisan Developer на связи. Сегодня представляю OS Platinum One, с разными билдерами и bootloader, на базе Ubuntu.

Полгода назад я решил, что вместо очередной кастомзации Armbian под конкретную плату хочу собрать полноценную операционную систему: с единым build-движком уровня Cargo/Buildroot, собственной оболочкой и претензией на то, чтобы одна и та же ОС одинаково хорошо стояла на одноплатнике (Orange Pi Zero 3W, Raspberry 5 PI, идр), планшете, смартфоне, ПК и, в перспективе, роботе. Первая плата — Orange Pi Zero 3W. Ниже — архитектура, работа на реальном устройстве.

Стартовый экран Platinum One OS
Стартовый экран Platinum One OS

Это технический разбор, а не анонс продукта. Если вы делали свой образ для одноплатника через Buildroot, Yocto или руками через debootstrap, часть боли будет знакомой — но детали конкретно нашего решения, думаю, интересны сами по себе.

Зачем ещё одна ОС

Готовых вариантов для одноплатников хватает: ванильный Armbian с рабочим столом, postmarketOS, manjaro-arm, DietPi. Все они решают задачу «дать Linux с рабочим столом на конкретное железо». Мы решаем другую: одна ОС на разных форм-факторах, где различия между устройствами — это данные, а не код. Пользовательский интерфейс рассчитан на сенсорный экран и жесты, а не на переклеенный поверх X11 рабочий стол из 2005 года с уменьшенными иконками.

Отсюда два решения, которые определили всю остальную архитектуру:

  1. Userspace — ванильная Ubuntu Base 26.04, поверх которой ставятся наши пакеты. Не форк Armbian, не собственный дистрибутив с нуля — Debian-инфраструктура apt, но с собственным набором пакетов и собственной оболочкой поверх.

  2. Armbian используется только как источник железа — pinned checkout ради compile.sh kernel и compile.sh uboot под конкретный SoC. Armbian даёт нам ядро и загрузчик; всё остальное — rootfs, пакеты, конфигурация, образ — наше.

    Platinum Builder ├── Ubuntu Base 26.04 + пакеты Platinum ├── platinum-armbian-bsp │ ├── pinned Armbian checkout │ ├── kernel / DTB (compile.sh kernel) │ └── U-Boot (compile.sh uboot) └── финальный Platinum OS image

Первая плата и первая ловушка

Orange Pi Zero 3W — не то же самое, что Orange Pi Zero 3. Похожие названия, разное железо: Zero 3 — Allwinner H618, Zero 3W — Allwinner A733 (sun60iw2). DTB, ядро, U-Boot и вообще весь BSP не взаимозаменяемы. Спутать их — гарантированный кирпич. Поэтому первое, что попало в правила проекта: pin Armbian только по 40-символьному SHA-коммиту, никогда по ветке main, и явный запрет на подмену одной платы другой даже «похожей».

Архитектура: движок не знает про железо

CLI → BuildEngine → Pipeline → Stage

Каждая стадия (Stage) реализует два метода — name() и execute(&self, ctx: &mut BuildContext)Pipeline гоняет их по порядку, логирует длительность через tracing и останавливается на первой ошибке. Стадий тринадцать:

prepare
→ download-rootfs → unpack-rootfs
→ [install-packages] → [install-firmware]
→ [bsp-sync → bsp-kernel → bsp-uboot → bsp-inventory
   → install-kernel → install-uboot]
→ [configure-system] → [configure-boot]
→ [build-image]

Всё, что относится к конкретной плате — SoC, DTB, набор пакетов, разметка разделов, pin Armbian — лежит в platinum/boards/<id>/*.toml и парсится с #[serde(deny_unknown_fields)]. Ни одного if board.id == "orangepi-zero3w" в движке нет и быть не должно: если плате нужно особое поведение, значит, сборщику не хватает данных, а не веток кода. Ниже — реальный board.toml для Raspberry Pi 5, чтобы показать, насколько это буквально данные, а не конфигурация с сюрпризами:

id = "raspberrypi-5"
name = "Raspberry Pi 5"
architecture = "arm64"
soc = "Broadcom BCM2712"
bsp_family = "bcm2712"
memory_mib = 8192
dtb = "bcm2712-rpi-5-b.dtb"

# Armbian поддерживает только bcm2711 (rpi4b), Pi 5 — это bcm2712.
# Секции [armbian] здесь нет: ядро приходит из архива Ubuntu обычным apt.

[bootloader]
method = "raspberry-pi"
firmware_mount_point = "/boot/firmware"
config = [
    "arm_64bit=1",
    "dtoverlay=vc4-kms-v3d",
    "max_framebuffers=2",
    "enable_uart=1",
]

У Orange Pi Zero 3W вместо этого — секция [armbian] с pinned коммитом и method = "boot-script": у него нет Armbian для нужного SoC на bcm2711, зато Pi 5 получает ядро прямо из архива Ubuntu без всякой компиляции. Две разные стратегии загрузки, ноль общих веток в коде движка.

Конвейер сборки: реальные цифры

Мы гоняем сборку в приватном Docker-контейнере arm64v8/ubuntu: на Apple Silicon это нативный arm64, chroot целевой системы работает без qemu, и установка userspace занимает секунды вместо получаса. Ниже — полный прогон под Orange Pi Zero 3W с прогретым кэшем (Ubuntu Base, checkout Armbian, cargo), записанный сегодня для этой статьи:

Стадия

Время

Что происходило

prepare

0 мс

Каталоги сборки

download-rootfs

61 мс

Ubuntu Base 26.04 — из кэша

unpack-rootfs

1.5 с

Распаковка tar

install-packages

91.8 с

apt-get update + ~520 пакетов (Qt6/QML, cage, sddm, NetworkManager, mpv…)

install-firmware

43.9 с

Прошивка Wi-Fi AIC8800 из репозитория Armbian

bsp-sync

3.0 с

pinned checkout Armbian — из кэша

bsp-kernel

34.9 с

не компилировалось — взято готовым из удалённого кэша Armbian

bsp-uboot

3.1 с

тоже из удалённого кэша

bsp-inventory

0 мс

Найдены три .deb: kernel-image, kernel-dtb, u-boot

install-kernel

3.6 с

dpkg внутрь rootfs

install-uboot

91 мс

dpkg внутрь rootfs

configure-system

4.3 с

hostname, locale, fstab, netplan, пользователь

configure-boot

118 мс

boot.scr + armbianEnv.txt + uInitrd

build-image

37.1 с

MBR в Rust, mkfs.ext4 -d без loop-устройств, запись U-Boot

Итого — 3 минуты 44 секунды от чистого platinum/tools/build-image.sh orangepi-zero3w до готового .img размером 6.03 ГиБ. Ключевая строка в логе, которая всё объясняет:

[💖] artifact [ obtained from remote cache: kernel-sun60iw2-vendor 6.6.98-… ]
[💖] artifact [ obtained from remote cache: uboot-orangepizero3w-vendor 2018.07-… ]

Armbian кладёт собранные артефакты под конкретный pin в собственный кэш артефактов, и если наш commit совпадает — ядро с U-Boot скачиваются готовыми. Если бы pin не совпал (обновили pin, а кэш ещё не прогрелся) — вместо 35 и 3 секунд пошла бы полноценная компиляция ядра: часы вместо минут, десятки гигабайт вместо восьми.

mkfs.ext4 -d заслуживает отдельного слова: мы не монтируем loop-устройство и не работаем с образом как с примонтированной файловой системой — mkfs.ext4 умеет собрать файловую систему из директории-источника напрямую (-d), а MBR и запись загрузчика в сырые сектора образа мы делаем сами на Rust. На macOS это единственный вариант без прав на loop-devices внутри контейнера; заодно сборка получилась воспроизводимой без привилегий монтирования выше тех, что уже даёт --privileged самого контейнера.

Оболочка: вложенный композитор внутри киоска

Тут решение специфичнее, чем «поставили рабочий стол». cage — внешний kiosk-композитор на wlroots, он даёт DRM, ввод и seat. Внутри него на весь экран крутится единственный клиент — qml6, исполняющий Shell.qml. Но Shell.qml — это не просто QML-приложение, это сам вложенный Wayland-композитор: он держит собственный сокет (wayland-1) и принимает на него нативные приложения — Firefox, LibreOffice, qterminal — как окна внутри собственной сцены.

cage (kiosk, wlroots)
 └─ qml6 :: Shell.qml   ← единственный клиент cage, сам вложенный компоситор
     ├─ сокет wayland-1 ← сюда подключаются нативные приложения
     ├─ WindowHost.qml  ← показывает активное окно на весь экран
     └─ Carousel.qml    ← переключатель: те же живые поверхности мельче

Зачем так сложно вместо обычного Wayland-композитора вроде sway или обычного WM: нам нужен домашний экран, свайпы, переключатель приложений и жесты — то есть мобильная модель UI, а не оконный менеджер. Гораздо проще держать всё это состояние (какое окно активно, что показывать при свайпе, как анимировать переключение) в одном QML-дереве, чем городить протокол между внешним композитором и отдельным UI-процессом.

Ключевая деталь: карточки в переключателе приложений — живые, не снимки экрана.

ShellSurfaceItem {
    id: surfaceView
    shellSurface: modelData.surface
    inputEventsEnabled: false
    transformOrigin: Item.TopLeft
    scale: (width > 0 && height > 0)
           ? Math.min(preview.width / width, preview.height / height)
           : 1
}

Одну и ту же Wayland-поверхность можно рисовать несколькими ShellSurfaceItem одновременно — большим на весь экран и маленьким в карусели, — и оба будут показывать актуальное содержимое без пересъёмки по таймеру. Снимок пришлось бы обновлять вручную и он всегда немного врал бы о текущем состоянии.

«Заявки»: как QML без привилегий делает привилегированные вещи

Оболочка на чистом QML не может писать в /etc, не может лезть в PAM, не может запускать произвольные процессы с нужным окружением. Всё привилегированное действие в системе идёт через один и тот же паттерн: атомарная запись файла → systemd path-юнит ловит изменение → короткоживущий сервис делает работу → пишет файл состояния обратно, который QML читает синхронным XMLHttpRequest по file://.

Показательный пример — разблокировка экрана. platinum-lockd не сидит в памяти постоянно (в отличие от демона уведомлений), а поднимается на одно срабатывание:

// platinum-lockd/src/main.rs
let contents = fs::read_to_string(&request_path)?;

// Заявка убирается с диска сразу после чтения, ещё до самой проверки:
// пароль не должен лежать в tmpfs дольше, чем необходимо для одного
// прохода через PAM.
let _ = fs::remove_file(&request_path);

let Some(request) = parse_request(&contents) else {
    tracing::debug!("platinum-lockd: неполная заявка проигнорирована");
    return Ok(());
};

let unlocked = check_password(&username, &request.password);

Пароль от экрана блокировки — тот же, что у учётной записи: решение осознанное, второй секрет означал бы второе место, которое нужно защищать той же политикой. Проверяется он не прямым вызовом unix_chkpwd — современный unix_chkpwd из пакета login отклоняет прямой вызов сообщением «This binary is not designed for running in this way», потому что это внутренний помощник pam_unix.so, а не публичный интерфейс. Мы идём через настоящий PAM-клиент (crate pam), который обращается к тому же unix_chkpwd правильным протоколом:

fn check_password(username: &str, password: &str) -> bool {
    let mut client = match Client::with_password(PAM_SERVICE) {
        Ok(client) => client,
        Err(error) => { tracing::warn!(%error, "не удалось создать клиент PAM"); return false; }
    };
    client.conversation_mut().set_credentials(username, password);
    match client.authenticate() {
        Ok(()) => true,
        Err(error) => { tracing::info!(%error, "PAM отклонил пароль"); false }
    }
}

Обратите внимание на обработку ошибок: любая ошибка PAM — «не разблокировано», а не повод остановить процесс. Экран обязан остаться заблокированным при любом сбое проверки, включая отсутствие модуля PAM или битую конфигурацию — fail closed, не fail open.

Тот же паттерн, тот же файловый протокол используется для запуска приложений (platinum-launcher.path → platinum-launcher.service → systemd-run --user), применения системных настроек (Wi-Fi, авиарежим — через rfkill), консоли и уведомлений. Единообразие того стоит: один способ отладки для всех привилегированных путей оболочки — journalctl --user -u <service> плюс чтение файла заявки/состояния руками.

Живая эксплуатация: где всё ломалось на самом деле

Вот часть, которую обычно не показывают. Три инцидента, каждый со своей неочевидной причиной.

mpv рушился при запуске

mpv в композиторе без GPU-прохода падал с ассертом при попытке автоопределить видеовывод — auto-probing GL/zink уходил в assert внутри GPU-less вложенного композитора. Диагностировали через journal, лечится одной строкой в команде запуска:

exec: "mpv --vo=wlshm %f"

wlshm — вывод через wl_shm, программные буферы напрямую в Wayland без всякого GL. Дороже по CPU, зато не падает там, где нет честного GPU-прохода до композитора.

LibreOffice ронял весь композитор, а не только себя

Это интереснее. Открытие LibreOffice Writer иногда укладывало всю оболочку целиком — не само приложение, а хост-процесс qml6coredumpctl dump + gdb -batch -ex "bt full" привели к QV4::VariantAssociationObject::virtualGet:

// было — падало
onSurfaceDestroyed: Windows.remove(modelData.surface)

onSurfaceDestroyed — это C++-колбэк, и modelData в этот момент — уже частично уничтоженный QVariantMap. Чтение поля синхронно внутри такого колбэка иногда попадало на объект в процессе уничтожения — отсюда SIGSEGV в движке QML прямо во время работы приложения, никак не связанного с LibreOffice по коду. Исправление — не трогать modelData синхронно внутри колбэка вообще, взять типизированное свойство самого элемента (переживает уничтожение поверхности) и отложить работу:

property int windowId: modelData.id

onSurfaceDestroyed: {
    const id = surfaceItem.windowId;
    Qt.callLater(function () { Windows.removeById(id); });
}

Урок отсюда обобщается за пределы этого конкретного бага: в C++-колбэках QML не читать составные поля var/QVariantMap синхронно, если объект может быть частично уничтожен к моменту вызова — только примитивы, скопированные заранее.

AppArmor снапов и имя Wayland-сокета

Firefox в архиве Ubuntu на arm64 — транзитный snap-пакет. Snap-приложения подпадают под AppArmor, и его профиль ограничивает, к каким Wayland-сокетам разрешено подключаться:

owner /run/user/[0-9]*/wayland-[0-9]* rw,

Только сокеты вида wayland-N. Наша оболочка изначально называла свой вложенный сокет иначе (произвольным именем) — и AppArmor его молча резал: Firefox стартовал и тут же падал без внятной ошибки в собственном логе, причина была видна только в journalctl по AppArmor-denials. Переименование сокета в wayland-1 и явная передача --setenv=WAYLAND_DISPLAY=wayland-1 в обёртке запуска приложений решило проблему разом для всех snap- и Flatpak-пакетов, не только Firefox.

macOS tar не для этого

Отдельная категория граблей — не про Linux, а про перенос файлов между Mac и устройством. Обычный tar на macOS без COPYFILE_DISABLE=1 пишет в архив AppleDouble-метаданные (._filename), и в одном случае это не просто намусорило рядом файлами — испортило бинарным мусором сам qmldir, из-за чего import Platinum переставал резолвиться и оболочка падала в crash-loop на чёрном экране при загрузке. Диагностируется не сразу: ошибка Qt («module "Platinum" is not installed») ничего не говорит про macOS-специфику происхождения. Фикс тривиален — COPYFILE_DISABLE=1 tar, но сам факт, что инструмент упаковки файлов может незаметно повредить текстовый файл конфигурации модуля, стоит держать в голове при любом переносе с Mac на Linux-цель.

Первая настоящая загрузка на плате

Собрать образ, который проходит в CI/эмуляции — не то же самое, что образ, который грузится на реальном железе. Три находки живой сборки, каждая — пример того, как честный тест на бумаге не ловит реальный дефект.

Раздел внутри области загрузчика. partitions.toml резервировал под загрузчик 16 MiB, но boot_package.fex весит 1.33 МиБ и пишется по смещению 16400 KiB — то есть на 16 КиБ внутрь первого раздела. Образ собирался без единой ошибки на любой стадии. Проблему нашёл только e2fsckResize inode not validIllegal block(s) in inode 7. Загрузиться образ, вероятно, смог бы — но расширение rootfs при первом включении (та самая служба, растягивающая раздел под реальный размер карты) сломало бы файловую систему. Тест, который должен был это ловить, на деле проверял только вырожденный случай start_mib = 0. Исправление: reserved_mib стал обязательным полем partitions.toml — данные семейства BSP, а не константа в коде — и start_mib для этой платы вырос до 32.

Ложная ошибка bsp-build-uboot. Команда возвращала ошибку даже после успешной сборки U-Boot — потому что печатала inventory, который требовал наличия собранных пакетов ядра, хотя цель была только загрузчик. Symptom и root cause разошлись: команда «упала», хотя всё, что она должна была сделать, уже было сделано.

TOML тихо проглотил поле не в той таблице. Ключ modules был дописан в board.toml ниже секции [bootloader] — и TOML совершенно корректно (по своим правилам) отнёс его к этой таблице, а не к корню документа. До #[serde(deny_unknown_fields)] serde молча отбрасывал поле, которого не ожидала структура BootloaderConfig. Сборка проходила зелёной, /etc/modules оставался стоковым, и Wi-Fi на устройстве просто не поднялся бы — без единого предупреждения где бы то ни было в логе. После этого случая deny_unknown_fields появился на всех конфигурационных структурах проекта: опечатка в ключе или ключ не в той таблице теперь — ошибка парсинга, а не тихо потерянные данные.

Все три исправления объединяет одно: юнит-тесты были, но проверяли не тот случай, или проверяли поведение, которое хорошо работает в отрыве от реальных чисел (реальный размер .fex, реальное смещение, реальная вложенность TOML). Дешёвый вывод — тестировать граничные значения с настоящими числами артефактов, а не с круглыми условными.

Итог. 5 августа 2026 Orange Pi Zero 3W загрузилась с собранного образа. Vendor U-Boot 2018.05 умеет читать симлинки в ext4 напрямую — цепочка boot.scr → /boot/Image → /boot/dtb/${fdtfile} рабочая без замены симлинков на реальные файлы, что закрывало главный до того открытый архитектурный вопрос.

Что дальше

Список нерешённого честный, не приглаженный:

  • Обновление uInitrd при смене ядра на самом устройстве — хук /etc/initramfs/post-update.d/99-uboot пока не ставится.

  • Отдельный раздел /boot — сейчас нужен staging-каталог для mkfs -d, который ещё не реализован для вложенных точек монтирования.

  • Собственный apt-репозиторий Platinum — все пакеты пока приходят из архива Ubuntu плюс сборка «на месте» через Docker.

  • Экран первого запуска для Wi-Fi — ручная настройка через Control Center уже работает, отдельного onboarding-мастера нет.

Если резюмировать архитектурно: главная ставка, которая себя оправдывает — жёсткое разделение «данные платы» / «код движка». За полгода добавление Raspberry Pi 5 не потребовало ни одной ветки if board == ... в BuildEngine — только новый набор TOML-файлов и один новый вариант [bootloader] method, который до этого не встречался. Вторая ставка, которая пока стоит своей сложности — вложенный композитор вместо обычного WM: живые превью в переключателе и единая модель окон получаются почти бесплатно, когда весь UI и вся Wayland-логика живут в одном QML-дереве.

Видео работы:

https://youtu.be/tVzDGqDfJLI?si=56CtkjFO9N34A3ge

Репозиторий:

https://github.com/digkill/Platinum-OS

В планах: тест-девайс для смартфонного форм-фактора — OnePlus 15 (Snapdragon 8 Elite Gen 5). Загрузчик на глобальных версиях (OxygenOS) разблокируется штатно через fastboot oem unlock — не проблема (ограничение на «Deep Testing»-заявку с ColorOS 16 касается только материковых китайских устройств).

Барьер другой: у SoC этого класса нет готового BSP-пути вроде Armbian для Allwinner/Broadcom — свой kernel fork Qualcomm, свой набор проприетарных firmware blobs (Wi-Fi/BT/модем/GPU) и никакого прямого аналога U-Boot-конвейера boot.scr, которым загружается Orange Pi Zero 3W.

У SoC этого класса пока нет ни board.toml, ни BSP-пути в проекте. До этого пункта — данные платы и pin BSP по тем же правилам, что для Orange Pi Zero 3W: не гадать VID:PID и версии прошивки, а подтверждать их на живом устройстве.