Сегодня я хотел бы поделиться с вами результатами своего эксперимента по написанию ядра мобильной ОС с нуля.
Для чего? Главным образом — просто ради развлечения. Мне попадалось много статей по разработке десктопных ОС для x86. Да и сам я когда-то этим увлекался. Попытка разобраться с мобильной операционкой может стать довольно интересным опытом.
Что понадобится
Смартфон с разблокированным загрузчиком. Желательно, чтобы была техническая возможность подпаять UART-адаптер. В зависимости от модели, возможно, можно будет подпаяться к определенным тестовым площадкам, либо подключиться к USB-порту (такое есть на некоторых смартфонах Pixel). Через UART интерфейс мы сможем выводить отладочные сообщения. Аналога VGA-буфера, который доступен в x86, у нас не будет. Чтобы подключиться к ПК, нужен будет UART-TTL адаптер, например на базе CH340.
У меня как раз завалялся смартфон Xiaomi Redmi Note 7 (lavender). Архитектура процессора — ARMv8, дополнительный плюс в том, что в сети есть исходники ядра для этой модели. Туда я буду подсматривать, чтобы разобраться в устройстве некоторых драйверов.

Процесс загрузки устройства
Поскольку мой телефон работает на SoC Qualcomm, я буду описывать процесс загрузки именно для этой платформы.
После нажатия кнопки питания, процессор (SoC) начинает выполнение кода, записанного в его встроенной ПЗУ (ROM).
Этот код называется Primary Bootloader (PBL) — он зашит на заводе Qualcomm и изменить его нельзя. Primary Bootloader выполнив инициализацию DRAM, кэша, питания и прочих низкоуровневых вещей, передает выполнение вторичному загрузчику — Secondary Bootloader (SBL), который находится во флеш-памяти и продолжает инициализацию оборудования. Затем SBL загружает основной загрузчик - Application Bootloader (ABL). Это уже загрузчик “пользовательского уровня”: он уже показывает логотип, может заходить в fastboot, загружает и распаковывает ядро (boot.img). Обычно это ядро — упакованное и сжатое ядро Android, но не в нашем случае.
При загрузке ядра подтягивается также Device Tree Blob (DTB). DTB — это бинарный файл, используемый в ARM устройствах. В нем описывается «железо» устройства: какие у него контроллеры, адреса, IRQ, клоки, GPIO и т.д. Файл .dtb — это скомпилированная версия текстового файла .dts (Device Tree Source).
DTS выглядит примерно вот так
/dts-v1/;
/ {
model = "Xiaomi Redmi Note 7";
compatible = "qcom,sdm660", "qcom,msm8998";
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a73";
reg = <0>;
};
cpu@1 { ... };
};
memory@80000000 {
device_type = "memory";
reg = <0x0 0x80000000 0x0 0x40000000>;
};
soc {
uart@0c170000 {
compatible = "qcom,blsp1-uart2";
reg = <0x0 0x0c170000 0x0 0x1000>;
clocks = <&gcc GCC_BLSP1_UART2_APPS_CLK>;
status = "okay";
};
};
};
Настройка окружения
Для написания ОС был выбран язык Rust. Сам я не являюсь специалистом по этому языку, но это хороший современный язык программирования для системной разработки. Сборку итогового проекта проверял на Ubuntu и macOS. Ниже будут инструкции по настройке окружения в среде Ubuntu.
Сперва установим и настроим Rust:
curl https://sh.rustup.rs -sSf | sh
rustup update
rustup target add aarch64-unknown-none
rustup component add llvm-tools-preview
cargo install cargo-binutils
cargo install cargo-make
Понадобится fastboot — для заливки прошивки
sudo apt install fastboot
Также заранее можно скачать утилиту для упаковки ядра в образ, понятный штатному загрузчику. Утилиту mkbootimg можно скачать здесь: https://android.googlesource.com/platform/system/tools/mkbootimg.
Собираем минимальный образ
Мы планируем собрать свое ядро написанное полностью с нуля, без Android или Linux части, однако, загрузчику смартфона до нас дел нет — он ждет ядро в формате Android Boot Image. Можно, конечно, поставить сторонний загрузчик (например, U-boot), тогда мы сможем избавиться от этого требования, но интереснее будет остаться со штатным загрузчиком.
Наш Android Boot Image будет состоять из Gzip-сжатого ядра и файла DTB.
Получить DTB для вашей модели устройства можно разными путями. Например: сделать дамп раздела dtbo, скопировать через терминал TWRP из раздела /dev/block/bootdevice/by-name/dtb, распаковать какой-либо образ boot.img для вашего устройства, собрать из исходных DTS файлов. Раздел dtbo обычно содержит оверлеи Device Tree, а не сам основной DTB. Поэтому для большинства устройств базовый DTB находится в разделе dtb, а dtbo хранит дополнительные фрагменты, подмешиваемые загрузчиком. Исходники DTS можно поискать в mainline-ядре Linux или в исходниках какой-либо кастомной прошивки. В моем случае компиляция из исходников усложнит весь процесс. Поэтому я взял готовый DTB файл и положил его в arch/aarch64/devices/xiaomi/sdm660-lavender.dtb
Наконец, напишем нашу точку входа arch/aarch64/src/start.rs:
#![cfg(target_arch = "aarch64")]
#![no_std]
#![no_main]
use core::arch::asm;
use core::arch:global_asm;
use core::hint::spin_loop;
use core::ptr::addr_of;
// Заголовок формата Linux ARM64, для совместимости со стоковыми Android-загрузчиками
global_asm!(
r#"
.section .head, "ax"
.balign 8
.global _header_start
_header_start:
b _start // code0: branch to _start
.word 0 // code1
.quad 0 // text_offset
.quad _kernel_size // image_size
.quad 0 // flags
.quad 0 // res2
.quad 0 // res3
.quad 0 // res4
.word 0x644D5241 // magic "ARM\x64"
.word 0 // res5
"#
);
// Стек — 16 KiB
const STACK_SIZE: usize = 16 * 1024;
#[unsafe(link_section = ".bss.stack")]
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
#[unsafe(no_mangle)]
#[unsafe(link_section = ".text._start")]
pub extern "C" fn _start() -> ! {
unsafe {
// вершина стека = адрес сразу за массивом
let top = (addr_of!(STACK).wrapping_add(1) as usize) & !0xF;
asm!(
// Используем SP_EL1
"msr spsel, #1",
// Инициализация SP_EL1 (загрузчик должен передать управление в EL1)
"mov sp, {sp_top}",
// Включаем FP/SIMD
"mrs x0, cpacr_el1",
"orr x0, x0, #(0x3 << 20)",
"msr cpacr_el1, x0",
"isb",
"b {early_main}",
early_main = sym early_main,
sp_top = in(reg) top,
options(noreturn)
)
}
}
fn early_main() -> ! {
// Здесь будет код ранней инициализации
loop {
spin_loop();
}
}
// Поскольку мы находимся в no_std окружении, то нам нужен свой panic handler
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe {
loop {
asm!("wfi", options(nomem, nostack));
}
}
}
Здесь мы настроили минимум для перехода в высокоуровневый Rust-код. Для того, чтобы проект правильно слинковался, нужно будет добавить разметку линковки arch/aarch64/aarch64.ld.
OUTPUT_ARCH(aarch64);
ENTRY(_header_start);
# Будем передавать KERNEL_OFFSET через флаги линкера, чтобы менять его динамически
PROVIDE(KERNEL_OFFSET = 0);
SECTIONS {
. = KERNEL_OFFSET;
.head ALIGN(8) : {
KEEP(*(.head))
}
_kernel_start = .;
.text : ALIGN(4K) {
*(.text*)
}
.rodata : ALIGN(4K) {
*(.rodata*)
}
.data : ALIGN(4K) {
*(.data*)
}
.bss : ALIGN(4K) {
*(.bss*)
*(COMMON)
. = ALIGN(16);
*(.bss.stack)
} =0
_kernel_end = .;
# Провайдим символ для использования в заголовке Linux ARM64
PROVIDE(_kernel_size = _kernel_end - _kernel_start);
}
Осталось описать файлы конфигурации.
Cargo.toml:
[workspace]
resolver = "3"
members = [
"arch/aarch64",
]
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
Стратегия panic = "abort" говорит компилятору не раскручивать стек при panic!, а немедленно завершать выполнение (abort). Так в данном случае будет безопаснее.
arch/aarch64/Cargo.toml:
[package]
name = "arch-aarch64"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "kernel-aarch64"
path = "src/start.rs"
Makefile.toml:
[tasks.build]
command = "cargo"
args = ["build", "--release"]
[tasks.clean]
command = "cargo"
args = ["clean"]
[tasks.image]
dependencies = ["clean", "build"]
script = [
"mkdir -p ../../target/build/",
"cargo objcopy --release -O binary ../../target/build/kernel.bin",
]
[tasks.compress]
dependencies = [ "image"]
script = [
"gzip -9c ../../target/build/kernel.bin > ../../target/build/kernel.gz"
]
[tasks.concat]
dependencies = ["compress"]
script = [
"cat ../../target/build/kernel.gz devices/xiaomi/sdm660-lavender.dtb > ../../target/build/kernel.gz+dtb"
]
[tasks.bootimg]
dependencies = ["concat"]
script = [
"""
mkbootimg \
--kernel ../../target/build/kernel.gz+dtb \
--ramdisk /dev/null \
--base 0x0 \
--kernel_offset 0x0 \
--ramdisk_offset 0x01000000 \
--pagesize 4096 \
--header_version 1 \
--output ../../target/build/boot.img
"""
]
[tasks.boot]
dependencies = ["bootimg"]
script = [
"""
fastboot boot ../../target/build/boot.img
"""
]
Здесь мы описываем набор задач для сборки проекта. Я использую утилиту cargo-make — она позволяет описывать сценарии сборки в файле Makefile.toml и запускать их командами cargo make. Проект собирается по следующим шагам:
Сборка ядра (
cargo build --release)Извлекаем плоский бинарник из получившейся сборки (
cargo objcopy --release -O binary kernel.bin)Сжимаем бинарник в Gzip (
gzip -9c kernel.bin > kernel.gz).Добавляем DTB в конец ядра. (
cat kernel.gz sdm660-lavender.dtb > kernel.gz+dtb)Упаковываем получившийся файл в Android Boot Image (mkbootimg). Я использую заголовок версии 1, так как он поддерживается загрузчиком моего телефона. В формате boot image v1 DTB обычно просто конкатенируется к ядру, потому что отдельного поля --dtb в заголовке ещё нет. Начиная с v2, mkbootimg поддерживает явный параметр --dtb, и загрузчики, понимающие v2, читают DTB по смещению из заголовка.
Ну и последняя вспомогательная задача используется, чтобы сразу задеплоить на устройство, заранее переведенное в режим fastboot (
fastboot boot ../../target/build/boot.img). Командаfastboot bootзагружает образ прямо в RAM, не перезаписывая разделы. Так мы дополнительно убедимся, что ничего не сломали.
Теперь собираем проект командой cargo make bootimg, либо сразу запускаем cargo make boot. Будем готовы получать логи слушая UART командой screen /dev/ttyUSB0 115200. На некоторых моделях вывод через UART по умолчанию отключён, и его нужно включить специальной fastboot-командой.
После включения телефона и запуска ядра в терминале, слушающем UART, побежали символы:
Логи первого запуска
Device is unlocked, Skipping boot verification
VB2: BootState = 1
ReadKeyInternal: gEfiSimpleTextInputExProtocolGuid handles = 3
ReadKeyInternal: Read KEY = 0x0
bootstate = 4
var = UNLOCK splash update
Cannot find GraphicsHandles.
ImageWidth=1080,ImageHeight=2160,LogoPosX=0,LogoPosY=0
Avoid flicking for 2160x1080 images
Cannot find GraphicsHandles.
Render Splash [ 9096]
VB2: RenderSplashScreen end , BootState = 4
No Ffbm cookie found, ignore: Not Found
Memory Base Address: 0x40000000
Decompressing kernel image start: 9099 ms
Decompressing kernel image done: 9103 ms
Dtbo hdr magic mismatch 0, with D7B7AB1E
DTB offset is incorrect, kernel image does not have appended DTB
smem protocol = 9C3030A0
board_id1 is 1
board_id2 is 1
board_id3 is 0
board_id is 1
get_boardid_from_smem,SUCCESS ,board_id=0x102,cpu_id=0x6415640D,hwdefined=0
PON Reason is 145 cold_boot:1 charger path: 1
GetCheckPanelPressGuid:checkPanel = 0x1;Status:0x0
panel is Exist
VBRwDevice failed with: 00000050
Error Enabling charger screen: 00000050
Error Locate MiToken Protocol:
Error Reading the GetDebugPolicyFlagbuqshuai1
VB: readDebugPolicy: ScmSipSysCall Status: (0x7)
Error Reading the GetDebugPolicyFlagbuqshuai2
Error Get DebugPolicy: Device Error
DebugPolicyFlag: 0x0
Error Get DebugPolicy:
CMDLINE: AsciiStrLen AsciiStrLen (DebugPolicyFlag);
get_hwconfig_cmdline, board_id=0x102
get_hwconfig_cmdline, hwlevel=0x1
get_hwconfig_cmdline, maxcount=0x32
aaaaaa
get_hwconfig_cmdline, i=0x1D
Cmdline: androidboot.verifiedbootstate=orange androidboot.keymaster=1 dm="1 vroot none ro 1,0 7224056 verity 1 PARTUUID=50d69e1d-8239-78b7-6a82-098762fd8b7f PARTUUID=50d69e1d-8239-78b7-6a82-098762fd8b7f 4096 4096 903007 903007 sha1 625efce307828f1df4dd9b
RAM Partitions
Add Base: 0x0000000040000000 Available Length: 0x0000000060000000
Add Base: 0x00000000A0000000 Available Length: 0x000000005EAC0000
WARNING: Could not find mem-offline node.
ERROR: Could not get splash memory region node
kaslr-Seed is added to chosen node
pureason = 40091
Shutting Down UEFI Boot Services: 9147 ms
BDS: LogFs sync skipped, Unsupported
App Log Flush : 110 ms
Exit BS [ 9412] UEFI End
Судя по логам, загрузчик смог распаковать ядро и завершить свою работу. Дальше ядро, вероятно, запустилось… но пока без видимого результата.
Стоит обратить внимание на некоторые детали. Во-первых, сообщениеDtbo hdr magic mismatch 0, with D7B7AB1E. DTB offset is incorrect, kernel image does not have appended DTB указывает на то, что я ранее, в ходе экспериментов, отформатировал раздел dtbo.Подтверждение этому я нашёл на странице устройства в wiki postmarketOS.
Во-вторых, в логах есть важная строка: Memory Base Address: 0x40000000.Загрузчик скопирует ядро по этому физическому адресу. Аналогичную строку можно увидеть и в логах U-boot. Несмотря на то, что большинство инструкций используют относительную адресацию, использование глобальных (static) переменных или констант может потребовать корректной абсолютной адресации. Добиться этого можно несколькими способами:
релокацией ядра в заранее определённое место;
включением MMU с 1:1-маппингом ядра;
либо статическим указанием правильного адреса линковки.
Я пока выбрал последний вариант. Пусть это выглядит немного костыльно, зато просто и надёжно. Изучая исходники загрузчика, я подобрал адрес загрузки: memory_base + 0x8000. По итогу нужно передать этот адрес линкеру через .cargo/сonfig.toml: "-C", "link-arg=--defsym=KERNEL_OFFSET=0x40008000".
Вывод логов через UART
Сейчас ядро попадает в «чёрную дыру» — мы не видим, что оно делает. Чтобы начать отладку, стоит реализовать простой вывод в UART (через в SoC Qualcomm используется BLSP UART (UARTDM v1.4)). Флаги и пример реализации можно подсмотреть в исходниках ядра Linux.
сore/Cargo.toml
[package]
name = "kernel-core"
version = "0.1.0"
edition = "2024"core/src/byte_sink.rs
/// Сигнал «пока занято, попробуй позже»
#[derive(Copy, Clone, Debug)]
pub struct WouldBlock;
pub trait ByteSink {
/// Записать 1 байт без блокировок.
fn try_write(&self, b: u8) -> Result<(), WouldBlock>;
/// Попытаться записать сразу несколько байт.
/// Возвращает: Ok(n) — фактически записано n (может быть < buf.len()),
/// Err(WouldBlock) — не удалось записать ни одного байта.
#[inline(always)]
fn try_write_slice(&self, buf: &[u8]) -> Result<usize, WouldBlock> {
let mut n = 0;
for &b in buf {
match self.try_write(b) {
Ok(()) => n += 1,
Err(WouldBlock) => break,
}
}
if n == 0 { Err(WouldBlock) } else { Ok(n) }
}
}
core/src/writer.rs
use crate::byte_sink::ByteSink;
pub trait Writer {
fn write_all(&self, bytes: &[u8]);
fn flush(&self) {}
}
pub struct BlockingWriter<'a, S: ByteSink + ?Sized> {
sink: &'a S,
}
impl<'a, S: ByteSink + ?Sized> BlockingWriter<'a, S> {
pub const fn new(sink: &'a S) -> Self {
Self { sink }
}
pub fn print(&self, s: &str) {
self.write_all(s.as_bytes());
self.flush();
}
}
impl<'a, S: ByteSink + ?Sized> Writer for BlockingWriter<'a, S> {
fn write_all(&self, mut s: &[u8]) {
while !s.is_empty() {
match self.sink.try_write_slice(s) {
Ok(n) if n > 0 => s = &s[n..],
_ => core::hint::spin_loop(),
}
}
}
fn flush(&self) {
self.sink.flush()
}
}core/lib.rs
#![no_std]
pub mod byte_sink;Дальше добавим зависимость от этого модуля в aarch/aarch64/Cargo.toml:
[dependencies]
kernel-core = { path = "../../core" }
Наконец, непосредственно arch/aarch64/src/uart_mmio.rs:
use kernel_core::byte_sink::{ByteSink, WouldBlock};
// UARTDM v1.4 offsets (байтовые) и маски:
const NCF_TX: usize = 0x040; // "number of chars for TX"
const SR: usize = 0x0A4; // status
const CR: usize = 0x0A8; // command / enable
const TF: usize = 0x100; // TX FIFO (32-битные записи)
// Биты/команды (см. msm_serial_hs_hwreg.h):
const SR_TXRDY: u32 = 1 << 2;
const SR_TXEMT: u32 = 1 << 3;
const CMD_CLEAR_TX_READY: u32 = 0x300; // запускает передачу NCF_TX символов
pub struct UartMmio {
base: usize,
}
impl UartMmio {
pub(crate) fn new(base: usize) -> Self {
Self { base }
}
#[inline(always)]
fn r32(&self, off: usize) -> u32 {
unsafe { core::ptr::read_volatile((self.base + off) as *const u32) }
}
#[inline(always)]
fn w32(&self, off: usize, v: u32) {
unsafe { core::ptr::write_volatile((self.base + off) as *mut u32, v) }
}
/// Преобразует входной буфер, заменяя \n на \r\n
/// Возвращает (out_buf, out_len, in_consumed)
#[inline]
fn convert_newlines(buf: &[u8]) -> ([u8; 4], usize, usize) {
let mut out_buf = [0u8; 4];
let mut out_len = 0;
let mut in_pos = 0;
while in_pos < buf.len() && out_len < 4 {
let b = buf[in_pos];
if b == b'\n' {
// Добавляем \r перед \n
if out_len < 3 {
out_buf[out_len] = b'\r';
out_len += 1;
out_buf[out_len] = b'\n';
out_len += 1;
in_pos += 1;
} else if out_len < 4 {
// Влезет только \r, \n в следующий раз
out_buf[out_len] = b'\r';
out_len += 1;
break;
} else {
break;
}
} else {
out_buf[out_len] = b;
out_len += 1;
in_pos += 1;
}
}
(out_buf, out_len, in_pos)
}
}
impl ByteSink for UartMmio {
#[inline(always)]
fn try_write(&self, b: u8) -> Result<(), WouldBlock> {
match self.try_write_slice(core::slice::from_ref(&b)) {
Ok(1) => Ok(()),
_ => Err(WouldBlock),
}
}
#[inline(always)]
fn try_write_slice(&self, buf: &[u8]) -> Result<usize, WouldBlock> {
if buf.is_empty() {
return Ok(0);
}
// FIFO должен быть пуст
if (self.r32(SR) & SR_TXEMT) == 0 {
return Err(WouldBlock);
}
// Формируем буфер с заменой \n на \r\n
let (out_buf, out_len, in_consumed) = Self::convert_newlines(buf);
if out_len == 0 {
return Ok(0);
}
let mut word = 0u32;
for i in 0..out_len {
word |= (out_buf[i] as u32) << (i * 8);
}
// Задаём размер выпуска и сбрасываем TX_READY (старт передачи после записи в TF)
self.w32(NCF_TX, out_len as u32);
self.w32(CR, CMD_CLEAR_TX_READY);
// Ждём место в FIFO и кладём слово
if (self.r32(SR) & SR_TXRDY) == 0 {
return Err(WouldBlock);
}
self.w32(TF, word);
Ok(in_consumed)
}
#[inline(always)]
fn flush(&self) {
// Ждём полного опустошения передатчика
while (self.r32(SR) & SR_TXEMT) == 0 {
core::hint::spin_loop();
}
}
}
Осталось добавить в ealry_main:
fn early_main() -> ! {
// Захардкодим адрес. Нужный адрес для вашего устройства можно найти в DTS файлах
let uart_base = 0x0C170000;
let uart = UartMmio::new(uart_base);
let console = BlockingWriter::new(&uart);
console.print("Hello, world\n");
loop {
spin_loop();
}
}
Смотрим логи, и…
Те самые логи
Shutting Down UEFI Boot Services: 12689 ms
BDS: LogFs sync skipped, Unsupported
App Log Flush : 110 ms
Exit BS [12954] UEFI End
Hello, worldТеперь мы точно видим, что ядро загрузилось!
Бонус — вывод изображения на экран
Воодушевившись успехом вывода текста через UART, я начал искать способ вывода чего-нибудь на экран. Изучив, как реализован дисплейный стек вывода на дисплей на Android-устройствах, я понял, что реализовать такое на данном этапе будет слишком сложно, но в DTB есть упоминания фреймбуфера (framebuffer). В DTB обычно присутствует нода framebuffer@... или qcom,mdss_mdp, где описаны физический адрес буфера и параметры цветового формата. Значит, нужно попробовать узнать размер экрана, формат пикселей и адрес буфера. Поскольку параметров много, то и хардкода тоже будет немало. Поэтому логично попробовать реализовать парсинг DTB. Формат DTB представляет собой сериализованное дерево (Flattened Device Tree, big-endian), поэтому для чтения потребуется собственный парсер или готовая библиотека
arch/aarch64/src/fdt.rs
use core::mem::size_of;
pub const FDT_MAGIC: u32 = 0xD00D_FEED;
#[inline(always)]
pub fn be32(x: u32) -> u32 {
u32::from_be(x)
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct FdtHeader {
pub magic: u32,
pub totalsize: u32,
pub off_dt_struct: u32,
pub off_dt_strings: u32,
pub off_mem_rsvmap: u32,
pub version: u32,
pub last_comp_version: u32,
pub boot_cpuid_phys: u32,
pub size_dt_strings: u32,
pub size_dt_struct: u32,
}
#[repr(u32)]
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
pub enum Token {
FdtBeginNode = 1,
FdtEndNode = 2,
FdtProp = 3,
FdtNop = 4,
FdtEnd = 9,
}
#[inline(always)]
pub fn align4(v: usize) -> usize {
// FDT требует выравнивания разделов struct/strings по 4 байта
(v + 3) & !3
}
// ---------------- Высокоуровневый zero-copy API парсера ----------------
/// Корневой объект «живого» представления FDT.
/// Держит смещения на секции `structure` и `strings`.
pub struct DeviceTree {
struct_base: usize,
strings_base: usize,
}
/// Узел дерева: имя + границы тела (для итераторов).
#[derive(Clone, Copy)]
pub struct Node<'a> {
dt: &'a DeviceTree,
name: &'a [u8],
body_off: usize,
end_off: usize,
}
/// Свойство (имя + байтовое значение) без копирования.
#[derive(Clone, Copy)]
pub struct Prop<'a> {
pub name: &'a [u8],
pub value: &'a [u8],
}
impl DeviceTree {
/// Создаёт `DeviceTree` из адреса DTB.
/// `dtb_ptr` должен указывать на валидный, доступный для чтения blob FDT.
pub unsafe fn from_ptr(dtb_ptr: usize) -> Option<Self> {
if dtb_ptr == 0 {
return None;
}
let hdr = unsafe { &*(dtb_ptr as *const FdtHeader) };
if be32(hdr.magic) != FDT_MAGIC {
return None;
}
let struct_off = be32(hdr.off_dt_struct) as usize;
let strings_off = be32(hdr.off_dt_strings) as usize;
Some(DeviceTree {
struct_base: dtb_ptr + struct_off,
strings_base: dtb_ptr + strings_off,
})
}
/// Возвращает корневой узел `/` (если blob корректен).
pub fn root(&'_ self) -> Option<Node<'_>> {
let mut p = self.struct_base;
let token = be32(unsafe { *(p as *const u32) });
if token != Token::FdtBeginNode as u32 {
return None;
}
p += size_of::<u32>();
// Имя узла — C-строка, затем выравнивание на 4.
let mut q = p;
while unsafe { *(q as *const u8) } != 0 {
q += 1;
}
let name = unsafe { core::slice::from_raw_parts(p as *const u8, q - p) };
let body = align4(q + 1);
// Найти границу текущего узла (сканируя вложенность по токенам).
let end = self.scan_node_end(body);
Some(Node {
dt: self,
name,
body_off: body,
end_off: end,
})
}
/// Пролистывает поток токенов начиная с `p`, чтобы найти конец текущего узла.
/// Поддерживает вложенность через счётчик `depth`.
fn scan_node_end(&self, mut p: usize) -> usize {
let mut depth = 1i32;
loop {
let tok = be32(unsafe { *(p as *const u32) });
p += size_of::<u32>();
match tok {
t if t == Token::FdtBeginNode as u32 => {
// пропустить имя узла (C-строка + align)
let mut q = p;
while unsafe { *(q as *const u8) } != 0 {
q += 1;
}
p = align4(q + 1);
depth += 1;
}
t if t == Token::FdtEndNode as u32 => {
depth -= 1;
if depth == 0 {
return p;
}
}
t if t == Token::FdtProp as u32 => {
// len + nameoff + data[len] + align
let len = be32(unsafe { *(p as *const u32) }) as usize;
p += size_of::<u32>();
let _nameoff = be32(unsafe { *(p as *const u32) }) as usize;
p += size_of::<u32>();
p = align4(p + len);
}
t if t == Token::FdtNop as u32 => {}
t if t == Token::FdtEnd as u32 => {
// Защита от битых blob'ов: выходим по End.
return p;
}
_ => {
// Неизвестный токен — считаем конец во избежание зацикливания.
return p;
}
}
}
}
/// Глубокий поиск: обходит дерево в глубину и возвращает первый узел,
/// для которого `pred(node)` истина.
pub fn find_first<'a, F>(&'a self, mut pred: F) -> Option<Node<'a>>
where
F: FnMut(&Node<'a>) -> bool,
{
fn dfs<'a, F>(node: &Node<'a>, pred: &mut F) -> Option<Node<'a>>
where
F: FnMut(&Node<'a>) -> bool,
{
if pred(node) {
return Some(*node);
}
let mut it = node.children();
while let Some(child) = it.next() {
if let Some(found) = dfs(&child, pred) {
return Some(found);
}
}
None
}
self.root().and_then(|root| dfs(&root, &mut pred))
}
/// Возвращает C-строку из секции `strings` по смещению `off`.
#[inline(always)]
fn cstr_at(&self, off: usize) -> &[u8] {
let base = self.strings_base as *const u8;
let mut p = (base as usize) + off;
let start = p as *const u8;
unsafe {
while *(p as *const u8) != 0 {
p += 1;
}
core::slice::from_raw_parts(start, p - start as usize)
}
}
}
impl<'a> Node<'a> {
/// Имя узла (без завершающего `\0`).
pub fn name(&self) -> &'a [u8] {
self.name
}
/// Итератор по свойствам **только этого** узла (вложенные узлы пропускаются).
pub fn props(&self) -> PropsIter<'a> {
PropsIter {
node: *self,
cur: self.body_off,
depth: 1,
}
}
/// Итератор по **прямым дочерним** узлам.
pub fn children(&self) -> ChildrenIter<'a> {
ChildrenIter {
node: *self,
cur: self.body_off,
depth: 1,
}
}
/// Возвращает значение свойства по имени (байтовое сравнение).
pub fn get_prop(&self, want: &[u8]) -> Option<&'a [u8]> {
let mut it = self.props();
while let Some(p) = it.next() {
if p.name == want {
return Some(p.value);
}
}
None
}
}
/// Итератор по свойствам узла с пропуском вложенных узлов.
/// Держит «курсор» внутри секции `structure` и счётчик глубины.
pub struct PropsIter<'a> {
node: Node<'a>,
cur: usize,
depth: i32,
}
impl<'a> PropsIter<'a> {
fn bump_until_next(&mut self) -> Option<Prop<'a>> {
while self.cur < self.node.end_off {
let tok = be32(unsafe { *(self.cur as *const u32) });
self.cur += size_of::<u32>();
match tok {
t if t == Token::FdtBeginNode as u32 => {
// пропускаем имя узла, углубляемся
let mut q = self.cur;
while unsafe { *(q as *const u8) } != 0 {
q += 1;
}
self.cur = align4(q + 1);
self.depth += 1;
}
t if t == Token::FdtEndNode as u32 => {
self.depth -= 1;
if self.depth == 0 {
return None;
}
}
t if t == Token::FdtProp as u32 => {
// читаем len/nameoff/data и, если на нужной глубине, отдаём Prop
let len = be32(unsafe { *(self.cur as *const u32) }) as usize;
self.cur += size_of::<u32>();
let nameoff = be32(unsafe { *(self.cur as *const u32) }) as usize;
self.cur += size_of::<u32>();
let data = self.cur as *const u8;
self.cur = align4(self.cur + len);
if self.depth == 1 {
let name = self.node.dt.cstr_at(nameoff);
let value = unsafe { core::slice::from_raw_parts(data, len) };
return Some(Prop { name, value });
}
}
t if t == Token::FdtNop as u32 => {}
t if t == Token::FdtEnd as u32 => {
return None;
}
_ => {
return None;
}
}
}
None
}
}
impl<'a> Iterator for PropsIter<'a> {
type Item = Prop<'a>;
fn next(&mut self) -> Option<Self::Item> {
self.bump_until_next()
}
}
/// Итератор по дочерним узлам.
/// На глубине 1 вычисляет границу узла через `scan_node_end` и «перепрыгивает» его целиком.
pub struct ChildrenIter<'a> {
node: Node<'a>,
cur: usize,
depth: i32,
}
impl<'a> ChildrenIter<'a> {
fn bump_until_next(&mut self) -> Option<Node<'a>> {
while self.cur < self.node.end_off {
let tok = be32(unsafe { *(self.cur as *const u32) });
self.cur += size_of::<u32>();
match tok {
t if t == Token::FdtBeginNode as u32 => {
// имя узла до '\0', затем тело с 4-байтовым выравниванием
let mut q = self.cur;
while unsafe { *(q as *const u8) } != 0 {
q += 1;
}
let name =
unsafe { core::slice::from_raw_parts(self.cur as *const u8, q - self.cur) };
let body = align4(q + 1);
if self.depth == 1 {
// На нужной глубине создаём дочерний Node и сразу перескакиваем на его конец,
// чтобы следующая итерация вернула «соседа».
let end = self.node.dt.scan_node_end(body);
let child = Node {
dt: self.node.dt,
name,
body_off: body,
end_off: end,
};
self.cur = end;
return Some(child);
} else {
self.cur = body;
}
self.depth += 1;
}
t if t == Token::FdtEndNode as u32 => {
self.depth -= 1;
if self.depth == 0 {
return None;
}
}
t if t == Token::FdtProp as u32 => {
// пропускаем свойство (len + nameoff + data + align)
let len = be32(unsafe { *(self.cur as *const u32) }) as usize;
self.cur += size_of::<u32>();
let _nameoff = be32(unsafe { *(self.cur as *const u32) }) as usize;
self.cur += size_of::<u32>();
self.cur = align4(self.cur + len);
}
t if t == Token::FdtNop as u32 => {}
t if t == Token::FdtEnd as u32 => {
return None;
}
_ => {
return None;
}
}
}
None
}
}
impl<'a> Iterator for ChildrenIter<'a> {
type Item = Node<'a>;
fn next(&mut self) -> Option<Self::Item> {
self.bump_until_next()
}
}
arch/aarch64/src/framebuffer.rs
use crate::fdt::DeviceTree;
use core::arch::asm;
use core::ptr::write_volatile;
/// Форматы пикселей, встречающиеся в simple-framebuffer/qualcomm fb.
#[derive(Copy, Clone, Debug)]
pub enum PixelFormat {
Argb8888,
Xrgb8888,
Rgb565,
Unknown,
}
/// Информация о фреймбуфере из DTB (физ. адрес и геометрия).
#[derive(Clone, Copy, Debug)]
pub struct FramebufferInfo {
pub paddr: usize,
pub width: u32,
pub height: u32,
pub stride: u32,
pub bpp: u32,
pub format: PixelFormat,
}
/// Описатель уже отмапленного/готового к записи буфера.
#[derive(Copy, Clone, Debug)]
pub struct Framebuffer {
pub ptr: *mut u8, // виртуальный адрес начала буфера
pub width: usize,
pub height: usize,
pub stride_bytes: usize, // длина строки в байтах
pub bpp: usize, // бит на пиксель
pub format: PixelFormat,
}
impl Framebuffer {
/// Записывает один пиксель с учётом bpp/stride и формата (LE).
#[inline]
pub fn put_pixel(&mut self, x: usize, y: usize, color: u32) {
if x >= self.width || y >= self.height {
return;
}
unsafe {
match self.bpp {
32 => {
let off = y * self.stride_bytes + x * 4;
let word = pack32_le(self.format, color);
// volatile, чтобы компилятор не выкинул запись в MMIO/FB
write_volatile(self.ptr.add(off) as *mut u32, word);
}
16 => {
let off = y * self.stride_bytes + x * 2;
let c565 = rgb888_to_rgb565(color);
write_volatile(self.ptr.add(off) as *mut u16, c565);
}
_ => {}
}
}
}
/// Заливает весь буфер цветом (простая, но не самая быстрая реализация).
pub fn clear(&mut self, color: u32) {
for y in 0..self.height {
for x in 0..self.width {
self.put_pixel(x, y, color);
}
}
}
/// Заливает прямоугольник `[x..x+w) × [y..y+h)` без выхода за границы.
pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, color: u32) {
let x2 = (x + w).min(self.width);
let y2 = (y + h).min(self.height);
let bpp_bytes = (self.bpp / 8).max(1);
for row in y..y2 {
let row_base = row * self.stride_bytes + x * bpp_bytes;
for col in x..x2 {
unsafe {
match self.bpp {
32 => {
let word = pack32_le(self.format, color);
write_volatile(
self.ptr.add(row_base + (col - x) * 4) as *mut u32,
word,
);
}
16 => write_volatile(
self.ptr.add(row_base + (col - x) * 2) as *mut u16,
rgb888_to_rgb565(color),
),
_ => {}
}
}
}
}
}
}
#[inline]
fn rgb888_to_rgb565(c: u32) -> u16 {
let r = ((c >> 16) & 0xFF) as u16;
let g = ((c >> 8) & 0xFF) as u16;
let b = (c & 0xFF) as u16;
((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
}
/// Упаковка ARGB в порядок байт памяти для 32-битных форматов (LE).
#[inline]
fn pack32_le(fmt: PixelFormat, argb: u32) -> u32 {
let a = (argb >> 24) as u8;
let r = (argb >> 16) as u8;
let g = (argb >> 8) as u8;
let b = (argb >> 0) as u8;
match fmt {
// a8r8g8b8: в памяти на LE будет B, G, R, A
PixelFormat::Argb8888 => u32::from_le_bytes([b, g, r, a]),
PixelFormat::Xrgb8888 => u32::from_le_bytes([b, g, r, 0xFF]),
PixelFormat::Unknown => u32::from_le_bytes([b, g, r, a]),
_ => u32::from_le_bytes([b, g, r, a]),
}
}
/// Принудительно очищает кэш данных для области фреймбуфера до PoC.
#[inline(always)]
pub fn flush_framebuffer(ptr: *const u8, len: usize) {
let line_size: usize = 64; // типичное значение
let mut p = (ptr as usize) & !(line_size - 1);
let end = (ptr as usize) + len;
unsafe {
while p < end {
// cvac = Clean by VA to PoC
asm!("dc cvac, {p}", p = in(reg) p);
p += line_size;
}
// Барьер, чтобы предыдущие dc-операции завершились и стали видимы.
asm!("dsb ish");
}
}
fn parse_pixel_format(fmt: &[u8]) -> (PixelFormat, u32) {
if fmt.starts_with(b"a8r8g8b8") {
(PixelFormat::Argb8888, 32)
} else if fmt.starts_with(b"x8r8g8b8") {
(PixelFormat::Xrgb8888, 32)
} else if fmt.starts_with(b"r8g8b8a8") {
// В некоторых DT встречается как r8g8b8a8 для XRGB.
(PixelFormat::Xrgb8888, 32)
} else if fmt.starts_with(b"r5g6b5") {
(PixelFormat::Rgb565, 16)
} else {
(PixelFormat::Unknown, 0)
}
}
/// Ищет simple-framebuffer в DTB и возвращает параметры фреймбуфера.
pub unsafe fn find_in_dtb(dtb_ptr: usize) -> Option<FramebufferInfo> {
let dt = match unsafe { DeviceTree::from_ptr(dtb_ptr) } {
Some(d) => d,
None => return None,
};
let node = dt.find_first(|n| {
let name = n.name();
if name == b"framebuffer"
|| name.starts_with(b"framebuffer@")
|| name.starts_with(b"simple-framebuffer")
{
return true;
}
if let Some(compat) = n.get_prop(b"compatible") {
return compat.starts_with(b"simple-framebuffer");
}
false
})?;
let mut reg_base: usize = 0;
let mut width: u32 = 0;
let mut height: u32 = 0;
let mut stride: u32 = 0;
let mut bpp: u32 = 0;
let mut fmt = PixelFormat::Unknown;
let mut it = node.props();
while let Some(p) = it.next() {
if p.name == b"reg" {
if p.value.len() >= 16 {
let addr_hi = read_be_u32(p.value, 0) as u64;
let addr_lo = read_be_u32(p.value, 4) as u64;
reg_base = ((addr_hi << 32) | addr_lo) as usize;
} else if p.value.len() >= 8 {
reg_base = read_be_u32(p.value, 0) as usize;
}
} else if p.name == b"width" && p.value.len() >= 4 {
width = read_be_u32(p.value, 0);
} else if p.name == b"height" && p.value.len() >= 4 {
height = read_be_u32(p.value, 0);
} else if (p.name == b"stride" || p.name == b"line_length") && p.value.len() >= 4 {
stride = read_be_u32(p.value, 0);
} else if (p.name == b"format" || p.name == b"pixel_format") && !p.value.is_empty() {
// Строка формата может быть с NUL в конце — отрежем его.
let nul = p
.value
.iter()
.position(|&b| b == 0)
.unwrap_or(p.value.len());
let (pf, bits) = parse_pixel_format(&p.value[..nul]);
fmt = pf;
if bits != 0 {
bpp = bits;
}
} else if p.name == b"bits-per-pixel" && p.value.len() >= 4 {
bpp = read_be_u32(p.value, 0);
}
}
// Если stride не указан — вычисляем от width*bpp.
if reg_base != 0 && width != 0 && height != 0 && (stride != 0 || bpp != 0) {
let final_stride = if stride != 0 { stride } else { width * (bpp / 8) };
let final_bpp = if bpp != 0 { bpp } else { 32 };
return Some(FramebufferInfo {
paddr: reg_base,
width,
height,
stride: final_stride,
bpp: final_bpp,
format: fmt,
});
}
None
}
#[inline(always)]
fn read_be_u32(buf: &[u8], off: usize) -> u32 {
let b0 = *buf.get(off).unwrap_or(&0) as u32;
let b1 = *buf.get(off + 1).unwrap_or(&0) as u32;
let b2 = *buf.get(off + 2).unwrap_or(&0) as u32;
let b3 = *buf.get(off + 3).unwrap_or(&0) as u32;
(b0 << 24) | (b1 << 16) | (b2 << 8) | b3
}Итоговый start.rs:
#![cfg(target_arch = "aarch64")]
#![no_std]
#![no_main]
use crate::uart_mmio::UartMmio;
use core::arch::asm;
use core::arch::global_asm;
use core::hint::spin_loop;
use core::ptr::addr_of;
use kernel_core::writer::BlockingWriter;
mod fdt;
mod framebuffer;
pub mod uart_mmio;
// Заголовок формата Linux ARM64, для совместимости со стоковыми Android-загрузчиками
global_asm!(
r#"
.section .head, "ax"
.balign 8
.global _header_start
_header_start:
b _start // code0: branch to _start
.word 0 // code1
.quad 0 // text_offset
.quad _kernel_size // image_size
.quad 0 // flags
.quad 0 // res2
.quad 0 // res3
.quad 0 // res4
.word 0x644D5241 // magic "ARM\x64"
.word 0 // res5
"#
);
// Стек — 16 KiB
const STACK_SIZE: usize = 16 * 1024;
#[unsafe(link_section = ".bss.stack")]
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
unsafe {
// верх стека = адрес сразу за массивом
let top = (addr_of!(STACK).wrapping_add(1) as usize) & !0xF;
asm!(
// Cохранить DTB из x0
"mov x19, x0",
// Используем SP_EL1
"msr spsel, #1",
// Инициализация SP_EL1 (загрузчик должен передать управление в EL1)
"mov sp, {sp_top}",
// Включаем FP/SIMD
"mrs x0, cpacr_el1",
"orr x0, x0, #(0x3 << 20)",
"msr cpacr_el1, x0",
"isb",
"mov x0, x19",
"b {early_main}",
early_main = sym early_main,
sp_top = in(reg) top,
options(noreturn)
)
}
}
fn early_main(dtb: usize) -> ! {
// Захардкодим адрес. Нужный адрес для вашего устройства можно найти в DTS файлах
let uart_base = 0x0C170000;
let uart = UartMmio::new(uart_base);
let console = BlockingWriter::new(&uart);
console.print("Hello, world\n");
unsafe {
if let Some(info) = framebuffer::find_in_dtb(dtb) {
use crate::framebuffer::{Framebuffer, flush_framebuffer};
let mut fb = Framebuffer {
ptr: info.paddr as *mut u8,
width: info.width as usize,
height: info.height as usize,
stride_bytes: info.stride as usize,
bpp: info.bpp as usize,
format: info.format,
};
// Заливка фона и прямоугольника
fb.clear(0xFF1E1E1E);
let rect_w = (fb.width / 4).max(50);
let rect_h = (fb.height / 6).max(30);
fb.fill_rect(20, 20, rect_w, rect_h, 0xFFFF5500);
// Сброс кэша, если включен
flush_framebuffer(fb.ptr, fb.stride_bytes * fb.height);
} else {
console.print("No framebuffer node found in DTB\n");
}
}
loop {
spin_loop();
}
}
// Поскольку мы находимся в no_std окружении, то нам нужен свой panic handler
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe {
loop {
asm!("wfi", options(nomem, nostack));
}
}
}

В этой статье я поделился результатами своего хобби. Буду рад конструктивным комментариям и обратной связи. В следующей части расскажу о реализации менеджера памяти для ядра. Спасибо за внимание!
Комментарии (5)

lgorSL
02.11.2025 19:42Очень круто! Я на такие низкоуровневые штуки смотрел как тёмную магию, а оказывается не так уж и сложно.

al_shayda
02.11.2025 19:42подождите. а bluetooth? там же есть отличный bluetooth. гальванически развязанный. это он чего может тогда делать. прошивку чтоли обновлять. с ардуиной общаться. но зачем. а он вам нафига? примерно. retrofit чтоли какой-то делать. (к станку присобачить) Educational ? "Забить Мике баки". была же RTLinux. и сплыла. но код то остался, пальчики-то работу помнят. VAX был. ну Educational в общем.

bodyawm
02.11.2025 19:42Блин, прикольно! Я не ковырял квалки на настолько низком уровне, но не ожидал что там всё относительно просто - по крайней мере для вывода картинок, общения через UART и возможно дерганья GPIO откуда-нибудь :)
Я тоже выкидывал Android, но оставлял Linux и писал прям так.
rPman
Шикарно!
Это для развлечения или есть какие то далеко идущие планы?
p.s. без драйверов, любая затея разработать операционную систему к сожалению обречена на провал
Даже если удастся получить доступ к сенсорному экрану, то остается радиомодуль (а это не только мобильные сети но и wifi), звуковая карта, видеоускоритель.. да тут вопрос, можно ли хотя бы усыпить железку корректно.
Ziens Автор
В первую очередь для развлечения, а там буду пробовать ковырять дальше, пока сил хватит. Конечно, я не претендую на создания "убийцы андроида"). В теории, этот проект переносим на какие-то одноплатные ПК, например, raspberry pi. Так что, вполне можно придумать какое-то прикладное применение.
Кстати, по поводу выключения железки. Под боком есть исходники загрузчика и ядра. Сам по себе загрузчик реализован не сложно. Значит это должна быть посильная задача.