|
|
||
Orange Pi Zero 2 стоит около двух тысяч рублей. Внутри - 64-битный процессор ARM Cortex-A53, тот же кластер ядер, что стоит в Raspberry Pi 3, в серверах Amazon Graviton и в миллионах Android-смартфонов. На таком железе можно пройти весь путь от чистой ROM-загрузки до пользовательского процесса на Rust - и заодно увидеть, как именно устроена граница между "железом" и "программой". В этой статье - маршрут на шесть месяцев, который ведёт от "мигает светодиод" до собственного микроядра, осознанных атак на кэш и читаемого кода на Rust поверх C-библиотек вендора.
Дальше идёт технический разбор. Где заканчивается доверие к ROM и начинается доверие к коду, который вы написали. Как MMU превращает физическую память в виртуальную. Почему U-Boot проверяет ядро по RSA-подписи и почему это добавляет всего 4% к времени загрузки, но 36% - если использовать TPM-модуль. Какие именно уязвимости работают на Cortex-A53 (почти все из важных), и почему Rust не отменяет C, а лишь сдвигает границу между "unsafe" и "safe" примерно на два слоя вверх.
Статья не учит паять и не учит собирать Linux с нуля за выходные. Она описывает маршрут, в котором каждый шаг имеет свою цену во времени и в сложности. Вы сами решаете, насколько глубоко погружаться. Остановиться можно на любом уровне - и то, что вы успели, будет осмысленным, а не недоделанным.
Главная мысль простая: архитектура современного компьютера не магия. Это слои доверия, и каждый из них можно пощупать руками, если у вас есть плата за две тысячи рублей и полгода вечеров. Дальше - что именно трогать и в каком порядке.
Любая одноплатная плата загружается не из "операционки", а из гораздо более мелких шагов. На Orange Pi с процессором Allwinner H616 последовательность такая: Boot ROM (зашит на заводе), затем SPL (Secondary Program Loader), затем U-Boot, затем ядро Linux, затем rootfs. Boot ROM неизменен - он находится внутри чипа и читает первые 32 килобайта с SD-карты или из SPI-flash. SPL включает контроллер памяти, чтобы U-Boot влез в DDR. U-Boot знает про SD, USB, Ethernet и сеть (TFTP), умеет читать ext4, выполняет bootcmd и передаёт управление ядру с конкретной командной строкой. Ядро запускает init, init поднимает процессы. "Собрать прошивку" - значит осознанно пересобрать любой из этих шагов и понимать, на каком из них вы что меняете.
Каждый шаг - это слой доверия. Boot ROM нельзя проверить - вы доверяете китайскому вендору. SPL и U-Boot вы уже можете собрать сами из исходников. Ядро - тоже. Здесь и проходит граница: ниже уровня U-Boot вы ничему не можете доверять, выше - всё под вашим контролем. Профентас с соавторами в работе 2019 года в журнале DCOSS показали: проверка подписи ядра средствами U-Boot (RSA-2048, SHA-256) добавляет к загрузке 164 миллисекунды - это 4% от общего времени старта Linux на BeagleBone и Raspberry Pi 3. Если добавить аппаратный TPM, цифра взлетает до 1923 миллисекунд, или 36% времени загрузки. Разница между программной и аппаратной защитой - в десять раз по скорости. Цена "сильной" защиты - почти секунда молчания на старте.
Знание этих цифр не означает, что нужно сразу включать secure boot. Это означает другое: secure boot не "просто галочка в конфиге", а слоистая система, где каждый уровень платит своим временем. Когда вы позже встанете перед выбором - подписывать или нет, добавлять TPM или нет, - выбирать будете не из суеверия, а из знания цены.
Полезное упражнение для первого месяца: вытащить SD-карту, вставить пустую, подать питание. Boot ROM ничего не найдёт и упадёт в режим FEL - аварийный USB-режим Allwinner. Через USB и утилиту sunxi-fel можно прочитать регистры процессора, не записывая ничего на SD. Это ровно та точка, где вы видите, что процессор живой и без операционки. Он просто ждёт команд от вас.
Cortex-A53 - четырёхъядерный 64-битный ARMv8-A. Дешёвый, энергоэффективный, стоит во всей массовой электронике последних лет. У него четыре уровня привилегий - EL0 (пользовательский процесс), EL1 (ядро ОС), EL2 (гипервизор), EL3 (TrustZone и secure monitor). Между ними переходят по инструкциям eret, smc, hvc. У него отдельные MMU для инструкций и данных, кэш L1 по 32 КБ на ядро и общий L2 на 256-512 КБ, в зависимости от интеграции вендором.
Три ассемблерные инструкции открывают доступ к управлению процессором:
mrs x0, SCTLR_EL1 ; прочитать конфигурацию системы
mrs x1, CurrentEL ; узнать текущий уровень исключения
msr VBAR_EL1, x2 ; установить вектор таблицы исключений
mrs (Move Register from System) читает системный регистр. msr пишет. С их помощью ядро ОС включает MMU, настраивает кэш, разрешает прерывания, переводит процессор в 64-битный режим. Без этих инструкций вы не сможете писать ядро - любая операционная система начинается с того, что через эти регистры "объясняет" процессору, где она находится.
Эти инструкции нельзя написать на Rust напрямую. Rust не знает, что такое SCTLR_EL1. Это делает unsafe-блок с inline-ассемблером - единственный способ в Rust дотянуться до железа. Дальше вы оборачиваете их в безопасные абстракции: Mmu::enable(), Exceptions::set_vector_table(addr). На уровне C выглядит иначе - там volatile-указатели на memory-mapped регистры. И на C, и на Rust этот слой принципиально unsafe: проверка безопасности пришла бы слишком дорого, она должна была бы понимать модель процессора.
Полезно сразу понять размер "недоверенного" мира. На EL1 у вас полный доступ к памяти и регистрам. На EL0 - нет. Между ними - граница, которую проводит код на EL1 (ваше ядро ОС). Гипервизор на EL2 делает то же самое между EL1-инстансами. Secure monitor на EL3 - между "нормальным" миром и TrustZone. Если вы хотите понять, как изоляция процессов работает в Linux - нужно смотреть, как ядро настраивает SCTLR_EL1 и TTBR0_EL1 при переключении контекста. Это не теория. Это конкретные регистры, и на Orange Pi их видно в gdb-multiarch.
MMU - это отдельная железка внутри Cortex-A53, которая переводит виртуальные адреса в физические по таблицам страниц. На ARMv8-A базовая гранула 4 КБ, трёхуровневые таблицы (L0, L1, L2 для 4 КБ страниц или L1, L2, L3 в зависимости от режима). Каждая запись таблицы - 8 байт - описывает права доступа, кэшируемость, физический адрес. Если страницы нет - происходит page fault, ядро ОС решает, что делать.
// Пример записи таблицы страниц ARMv8-A, 4KB гранула
typedef uint64_t pte_t;
#define PTE_VALID (1UL << 0)
#define PTE_TABLE (1UL << 1)
#define PTE_AF (1UL << 10) // Access Flag
#define PTE_SH_INNER (3UL << 8) // Inner Shareable
#define PTE_ATTR_NORMAL (0UL << 2)
#define PTE_ATTR_DEVICE (1UL << 2)
pte_t make_pte(uint64_t pa, uint64_t attrs) {
return PTE_VALID | PTE_AF | PTE_SH_INNER | attrs | pa;
}
Каждый бит в этой 64-битной записи означает что-то конкретное. PTE_VALID - страница существует. PTE_AF - к ней обращались. PTE_SH_INNER - изменения в этой странице видны другим ядрам через внутренний кэш-когерентный домен. PTE_ATTR_DEVICE - это память устройства, кэшировать нельзя, переупорядочивать нельзя. Ошибка в атрибутах приводит к трудноуловимым багам: например, если пометить DMA-буфер как Normal Cacheable, данные в нём могут оказаться в кэше одного ядра и не дойти до устройства.
DMA - отдельная история. DMA-контроллер сам по себе ничего не знает про MMU - он читает и пишет физические адреса. Если ваша программа передаёт ему адрес буфера, который в виртуальной памяти выглядит непрерывным, но в физической раскидан по разным страницам, - DMA портит соседние страницы. На Orange Pi это не абстракция: у Allwinner H616 есть собственный DMA, и его драйвер в ядре Linux явно настраивает scatter-gather списки, чтобы обойти эту проблему.
Где помогает Rust? В типах. В C у вас void* для всего - указатель на DMA-буфер, на MMIO-регистр, на обычную память. В Rust можно сделать struct DmaBuffer<T> с методом as_physical(), который возвращает физический адрес, и as_volatile(), который возвращает VolAddr<T> для записи без оптимизации. Компилятор уже не даст вам передать MMIO-указатель туда, где ожидается DMA-буфер. Это не экономит такты процессора - это экономит часы отладки.
Когда у вас в руках плата и осциллограф, вы можете увидеть разницу между Device и Normal memory буквально глазами. Запись в Normal-память процессор может переставить - запись в Device-память обязана идти строго по программному порядку. Если вы настраиваете UART-передатчик и его регистр управления помечен как Normal, между записью данных и записью "отправить" может произойти что угодно - процессор оптимизирует. На Device-памяти такого не будет. Это наблюдаемая физически разница, и Orange Pi позволяет её поймать.
Прошивка приходит на плату пятью разными путями. Boot ROM у Allwinner умеет читать с SD-карты, из SPI-flash, через USB OTG в режиме FEL. FEL - это аварийный режим, в который Boot ROM падает, если не нашёл ничего загрузочного. Через USB можно загрузить SPL и U-Boot в память без записи на SD - режим для разработки.
После Boot ROM включается U-Boot. Он умеет загружать ядро по TFTP, по NFS, с SD, с eMMC, с USB-флешки. Окружение U-Boot (printenv без аргументов) хранится в нескольких килобайтах на SD-карте. Любая переменная bootcmd может быть заменена, и тогда на старте выполнится ваш скрипт вместо стандартного.
=> setenv bootcmd 'tftp 0x42000000 zImage; tftp 0x43000000 dtb; bootz 0x42000000 - 0x43000000'
=> saveenv
Четыре строчки - и плата начинает грузиться по сети. Это режим, в котором вы пишете ядро на своём компьютере, компилируете, кладёте в TFTP-сервер, перезагружаете плату. Цикл "правка кода - компиляция - тест" сокращается с минут до секунд. На шестимесячной дистанции это даст вам примерно в десять раз больше итераций, чем если бы вы каждый раз переписывали SD-карту.
Для обновления в проде этот режим не подходит. Современный подход - A/B слоты: две копии rootfs, при обновлении пишется в неактивную, в конце обновления U-Boot переключает флаг загрузки. Если новая версия не стартует, U-Boot переключает обратно. Android использует эту модель с самого начала, в embedded Linux её делает системный менеджер rauc или swupdate. Оба проверяют подпись обновления - это и есть secure boot в реальном смысле.
Здесь и возникает вопрос об уязвимостях доставки. Если Boot ROM не проверяет подпись SPL, любой, кто подменил SD-карту или откатил eMMC, загрузит свой код. У Allwinner H616, как у большинства бюджетных чипов, secure boot есть, но в открытых платах вроде Orange Pi его обычно не настраивают на заводе. Это значит, что доверие к прошивке - дело того, кто собирал плату. Вы как разработчик либо настраиваете подписывание сами (готовите ключи, вписываете их в eFUSE, подписываете SPL и U-Boot), либо принимаете, что у вас нет цепочки доверия.
На Cortex-A53, который стоит в Orange Pi Zero 2 и Prime, работают почти все атаки из тех, что публикуются на крупных конференциях по безопасности. Lipp и соавторы в работе "ARMageddon" (USENIX Security 2016) показали: на ARM-смартфонах с Cortex-A53 (Alcatel One Touch Pop 2) и Cortex-A57 (Samsung Galaxy S6) работают Flush+Reload, Evict+Reload, Prime+Probe и Flush+Flush. Это атаки на кэш - они позволяют процессу наблюдать, к каким строкам кэша обращается другой процесс, и по этому восстанавливать ввод пользователя.
Авторы показали на практике: приложение без root и без разрешений отслеживает тапы и свайпы по экрану, измеряя обращения к библиотеке libinput.so. Оно отличает короткое касание от длинного, определяет длину введённого слова по паттерну кэш-хитов. Это работает и на Orange Pi: тот же процессор, те же принципы кэш-когерентности, тот же Linux-стек. Только экран нужен другой - через UART или USB-HID. Принцип не меняется.
// Sketch: измерение времени доступа к общей shared-библиотеке
volatile uint8_t *probe = (uint8_t *)shared_lib_addr;
uint64_t t0 = read_cycle_counter();
uint8_t dummy = *probe;
uint64_t t1 = read_cycle_counter();
// t1 - t0 < ~40 циклов: кэш-хит - кто-то обращался
// t1 - t0 > ~500 циклов: кэш-мисс - обращения не было
Кохер и соавторы в "Spectre Attacks" (IEEE S&P 2019) показали, что спекулятивное выполнение на ARM позволяет обойти проверки границ массива и считать данные, которые процессу недоступны. Равичандран и соавторы в "PACMAN" (ISCA 2022) объединили PAC (Pointer Authentication Code, расширение ARMv8.3) с атакой на кэш - обошли защиту, которая считалась достаточной для ARM-серверов. На Orange Pi Zero 2 нет расширения PAC (Cortex-A53 - это ARMv8.0-A), но другие спекулятивные атаки из этого же ряда работают.
Если вы думаете, что это "далёкая теория", - нет. На Orange Pi можно воспроизвести кэш-атаку за пару дней. Достаточно двух процессов, общего libc (он всегда есть в памяти в одной физической странице) и способа измерять время. Атака на libinput.so требует устройства ввода, но атака на libc - не требует. Она работает между двумя любыми процессами. Это означает, что изоляция процессов на ARM - не абсолют. Это статистическая изоляция: разница во времени доступа - сотни циклов, и этой разницы достаточно, чтобы восстановить чужой поток инструкций.
С secure boot та же история. Если вы подписываете U-Boot, но не подписываете ядро, - вы ничего не защитили. Если подписываете ядро, но initramfs не подписан, - тоже. Цепочка доверия работает только целиком. Один непроверенный слой обнуляет все подписи выше. На Orange Pi у вас нет аппаратного TPM (есть на некоторых моделях, но на базовых нет), но есть программные механизмы: подписанные образы в U-Boot, проверка целостности rootfs через dm-verity. Их можно настроить и проверить на устойчивость.
Прошивку полностью на Rust сегодня написать нельзя. U-Boot написан на C, SPL от вендора - на C, доверенная загрузка от Allwinner - на C. Linux - на C. Rust в Linux добавился в версии 6.1 (2022 год), и сегодня это экспериментальные драйверы, а не основное ядро. Пантер и Эйсти в работе "Rusty Linux" (ESEM 2024) проанализировали 28 исследований на эту тему и сделали вывод: Rust в ядре - ранняя стадия, выигрыш в безопасности есть, проигрыш в производительности минимальный, но зрелости ещё нет. В отдельных проектах - Theseus, RedLeaf, Tock OS - Rust уже используется как основной язык, и в RustyHermit доля unsafe-кода составляет всего 3,27%.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
#[no_mangle]
pub extern "C" fn _start() -> ! {
// Здесь вы в EL1, MMU выключен, кэш выключен.
// Можно писать в MMIO-регистры напрямую.
let gpio: *mut u32 = 0x0300_B000 as *mut u32;
unsafe {
// Зажечь светодиод на Orange Pi Zero 2 (Port C, пин 5)
core::ptr::write_volatile(gpio.offset(0x10), 1 << 5);
}
loop {}
}
Это, грубо, минимальная прошивка на Rust. Она не делает ничего полезного, но она стартует. Компилятор не вставляет код инициализации libc, не вызывает main, не подключает аллокатор. Всё, что есть, - это ваш _start, panic_handler и unsafe-запись в память. Сборка такой прошивки делается через cargo с target-файлом для ARMv8-A bare-metal.
Хорошее место для Rust - уровень драйверов и middleware. Плохое - уровень загрузчика (U-Boot уже есть, переписывать его не нужно) и архитектурно-зависимая часть ядра (там ассемблер и глубокое знание ARM). Если вы пишете свой микроядро или RTOS поверх Rust, как RedLeaf, Theseus или Tock, - получаете две вещи. Первая: типобезопасные интерфейсы к железу - GpioPin<Output>, I2cBus, DmaBuffer<[u8; 512]>. Компилятор не даст передать пин ввода в функцию, которая ожидает пин вывода. Вторая: отсутствие классов ошибок памяти. В RedLeaf и RustyHermit доля unsafe-кода - 3-4%, и весь он локализован в низкоуровневых модулях.
Rust не отменяет C - он сдвигает границу. На C у вас почти всё unsafe по умолчанию, и "safe" - то, что вы сами проверили в коде-ревью. На Rust у вас почти всё safe, и unsafe - узкие мосты к железу. Это не ликвидирует уязвимости - Spectre работает на Rust так же, как на C, потому что он в железе, а не в языке. Но это убирает половину багов, которые появляются при программировании: use-after-free, переполнения буфера, гонки данных. По данным Microsoft, около 70% багов безопасности в их продуктах - ошибки памяти. Убрать 70% - не победа над всеми уязвимостями, но заметное улучшение.
По данным Пантера и Эйсти, инкапсулированный Rust-компонент в Linux ядре добавляет примерно 3% overhead по производительности, неинкапсулированный - 0,7%. Размер бинарника при переписывании модуля с C на Rust растёт примерно на 0,06%. Это не катастрофа, но это цифры, которые нужно знать, прежде чем переписывать целый драйвер. Реалистичный сценарий на Orange Pi: взять драйвер GPIO от вендора на C, написать безопасную обёртку на Rust, измерить разницу. Это упражнение учит большему, чем учебник.
Маршрут не предполагает, что вы бросаете всё и занимаетесь только Orange Pi. Это две-три встречи в неделю по два-три часа. К концу у вас не будет своего Linux, но будет понимание того, как Linux начинает работать.
Первый-второй месяц: загрузка, ассемблер, регистры. Вы ставите Armbian на Orange Pi, изучаете U-Boot printenv, переключаете загрузку на TFTP. Пишете на C мини-программу, которая запускается вместо ядра - мигает светодиодом, пишет в UART. Это не Linux, это "голое железо". Здесь вы учитесь читать datasheet Allwinner H616, находить адреса регистров GPIO и UART, писать через volatile. На Rust - то же самое, но через no_std и write_volatile.
Третий-четвёртый месяц: MMU, кэш, DMA. Вы добавляете в свою программу настройку MMU, переключаетесь из физической памяти в виртуальную. Запускаете два процесса, проверяете изоляцию. Пишете драйвер DMA, который перекладывает данные из памяти в UART. Здесь вы натыкаетесь на первые сюрпризы: кэш-когерентность, барьеры памяти, разница между Normal и Device memory. Это и есть точка, где учебник перестаёт быть теорией.
Пятый-шестой месяц: доставка, secure boot, уязвимости, Rust. Вы настраиваете подписанное обновление через A/B слоты. Включаете secure boot (если есть eFUSE на плате). Воспроизводите кэш-атаку между двумя своими процессами. Переписываете часть драйвера с C на Rust, измеряете разницу в производительности и в размере бинарника. К концу у вас в руках не "операционка", а глубокое понимание стека: что делает Boot ROM, как MMU переводит адреса, почему DMA не работает без барьеров, как подпись защищает загрузку и как её обойти, что даёт Rust, а чего он не даёт.
В каждый из этих месяцев есть конкретный артефакт, который вы получаете. К концу второго месяца - светодиод, мигающий вашей прошивкой. К концу четвёртого - два процесса с изоляцией памяти. К концу шестого - подписанная прошивка с A/B-обновлением и Rust-обёрткой над одним из драйверов. Это не степени и не сертификаты, это физические доказательства того, что вы понимаете систему.
Архитектура компьютера не магия. Это слои доверия, каждый из которых объясним. Boot ROM доверяет SPL, SPL доверяет U-Boot, U-Boot - ядру, ядро - init, init - вашим процессам. Каждый шаг можно собрать, разобрать, заменить. Orange Pi за две тысячи рублей даёт вам доступ к тому же стеку, который стоит в серверах Amazon Graviton и в большинстве Android-смартфонов. Не к упрощённой версии - к настоящей.
За полгода вы не научитесь писать Linux. Вы научитесь читать его исходники и понимать, что они делают. Это и есть цель: не "сделать свою ОС", а перестать смотреть на компьютер как на чёрный ящик. Когда вы видите в логе ядра строку про page fault, вы знаете, что произошло. Когда читаете про Spectre, вы понимаете, почему он работает. Когда выбираете между C и Rust для драйвера, вы выбираете осознанно - не потому что модно.
Эта работа не заканчивается через полгода. Вы выходите из неё не с сертификатом, а с инструментом мышления. И этот инструмент работает не только с Orange Pi, а с любой современной вычислительной системой - от умного чайника до сервера в дата-центре. Когда-нибудь вам встретится встроенное устройство, и вы поймёте: вы знаете, как оно устроено внутри. Не потому что читали - потому что сами собрали похожее.
|