Збираємо U-Boot та запускаємо свій C++ код на Orange Pi Zero 2W
Привіт! Мене звати Володимир. Зазвичай я займаюся розробкою під Android, але сьогодні виступаю в ролі ембеддед-хобіста. У цій статті я хочу поділитися власним досвідом запуску програм на SoC у режимі bare-metal — тобто напряму на залізі, без жодної операційної системи. Сподіваюся, цей матеріал буде цікавим та корисним для таких самих ентузіастів, як і я.
Для одного з експериментів мені знадобилося перевірити, як саме операційна система Linux впливає на швидкість роботи із залізом. Для цього один і той самий алгоритм необхідно протестувати у двох середовищах: під керуванням Linux та в режимі bare-metal (без ОС). Крім того, мною рухав суто спортивний інтерес — було цікаво порівняти програмування мікроконтролерів і «дорослих»
Для проведення тестів я обрав процесор Allwinner H618:
- По-перше, він надзвичайно доступний. У моїй домашній embedded-лабораторії знайдеться з десяток пристроїв на цьому чипі: від ТВ-боксів до різноманітних SBC (Orange Pi, Walnut Pi тощо). Ще влітку 2025 року плати на базі H618 можна було придбати за
600–800 грн, але звісно зараз це змінилось і ціни помітно зросли. - По-друге, це баланс потужності та периферії. Маючи на борту чотири ядра Cortex-A53, цей процесор не назвеш «гальмівним». Він достатньо спритний для серйозних завдань. Робота з UART, SPI, I2C або таймерами тут зрозуміла і добре задокументована (якщо не в офіційних мануалах, то в спільноті linux-sunxi).
Щоб запустити свій код на такому залізі, спочатку потрібно зрозуміти, як воно прокидається. На відміну від мікроконтролерів, де виконання коду зазвичай починається одразу з Flash-пам’яті, процес завантаження складних SoC — це багатоступеневий квест.
Після подачі живлення на процесор він починає виконувати програму, яка вшита в BootROM (або MaskROM). Цей код закладено безпосередньо в маску при виготовленні кристала чіпа, тому він не може бути змінений. Головна задача BootROM — знайти SPL (Secondary Program Loader) на зовнішньому носії (SD-карта, eMMC або NAND), завантажити його в SRAM-кеш та передати йому керування.

SPL починає роботу, коли оперативна пам’ять ще не ініціалізована (контролер DRAM не налаштований). Його основними задачами є:
- Налаштування критичної периферії (контролер DRAM, тактування/PLL, UART-консоль).
- Завантаження в ініціалізовану DRAM наступних компонентів: TF-A (Trusted Firmware-A) та U-Boot.
- і далі передача керування на рівень TF-A.
TF-A (раніше це було ATF — ARM Trusted Firmwar) відповідає за налаштування безпеки процесора, TrustZone, керування живленням (PSCI) тощо. Це досить об’ємна та складна тема, яка потребує окремого дослідження, тому не будемо заглиблюватися в деталі в межах цього опису. Після завершення ініціалізації безпечного середовища TF-A передає керування до U-Boot.
Що таке U-Boot — це проєкт із відкритим вихідним кодом, який став стандартом для ембедед систем. Спочатку він розроблявся для архітектури PowerPC, але згодом став універсальним завантажувачем для платформ на базі Arm, RISC-V, MIPS та інших.
U-Boot значно розширює можливості взаємодії із залізом:
- Інтерактивний командний рядок (CLI) — дозволяє гнучко налаштовувати сценарії завантаження, перевіряти стан пам’яті та керувати периферією в реальному часі.
- Підтримка додаткової периферії — U-Boot може працювати з пристроями, які зазвичай не підтримуються на рівні BootROM. Наприклад, завантажувати код через USB, Ethernet (NFS/PXE) або з NVMe-накопичувача.
- Робота з файловими системами — він розуміє FAT, ext4 та інші системи, що дозволяє п росто скопіювати наш бінарник на картку, а не записувати його в сирі сектори.
Підготовка до збірки U-Boot
Щоб зібрати U-Boot, вам знадобиться Linux у будь-якому вигляді. Це може бути як основна ОС на вашому комп’ютері, так і віртуальна машина або віддалений VPS. Щодо WSL2 в Windows — теоретично це має працювати без проблем, проте я особисто цей варіант ще не перевіряв.
Вже зібраний образ можна записати на MicroSD-картку на будь-якій системі, де доступна утиліта dd. Наприклад, частину цього матеріалу я писав на десктопі з Arch Linux, а іншу частину — на MacBook Pro, використовуючи VPS з Ubuntu для самої компіляції та перекидаючи готовий бінарник для прошивки.
Для Debian/Ubuntu ставимо наступні пакети:
sudo apt update sudo apt install build-essential git bison flex libssl-dev \ gcc-aarch64-linux-gnu swig python3-dev bc device-tree-compiler \ libgnutls28-dev
Для ArchLinux:
sudo pacman -S base-devel git openssl aarch64-linux-gnu-gcc \ swig python-setuptools dtc bc gnutls
gcc-aarch64-linux-gnu — це компілятор саме для ARM64 платформи.
Збірка Trusted Firmware-A
Хоча код SPL та U-Boot знаходиться в одному репозиторії, TF-A — це окремий проєкт, який потрібно клонувати та збирати окремо. Офіційні вихідні коди доступні на GitHub:
github.com/...-A/trusted-firmware-a.git
Іноді виробники чипів (наприклад, Rockchip) підтримують власні форки TF-A чи U-Boot із патчами, яких ще немає в основній гілці. Проте для H618 нам цілком вистачить стандартного репозиторію TF-A.
Отже, клонуємо репозиторій та переходимо до збірки:
# Клонуємо та заходимо в директорію git clone https://github.com/TrustedFirmware-A/trusted-firmware-a.git cd trusted-firmware-a/ # Встановлюємо префікс крос-компілятора export CROSS_COMPILE=aarch64-linux-gnu- # Запускаємо збірку. Для H618 використовується платформа sun50i_h616 make PLAT=sun50i_h616 DEBUG=1 bl31
Компіляція пройде досить швидко, після чого ми отримаємо необхідний файл: build/sun50i_h616/debug/bl31.bin.
Важливо: Якось я помилився з налаштуванням PLAT — чи то взяв дані з невдалого мануалу, чи просто був неуважним — і вказав sun50i_a64. Все скомпілювалося без помилок, U-Boot теж зібрався, але під час завантаження система йшла в астрал одразу після SPL. Витратив чимало часу, поки помітив цю прикру помилку. Після перезбірки з правильним параметром нарешті отримав доступ до консолі U-Boot.
Збираємо SPL та U-Boot
Клонуємо офіційний репозиторій з GitHub: github.com/u-boot/u-boot
# Клонуємо та переходимо в директорію git clone https://github.com/u-boot/u-boot cd u-boot/ # Налаштовуємо змінні середовища для крос-компіляції export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu-
В U-Boot існують готові конфігурації для різних одноплатних комп’ютерів, переглянути які можна в папці configs/. Нас цікавлять конфігурації для плат Orange Pi:
$ ls -1 configs/orangepi* configs/orangepi-3b-rk3566_defconfig configs/orangepi-5-max-rk3588_defconfig ... configs/orangepi_zero2_defconfig configs/orangepi_zero2w_defconfig configs/orangepi_zero3_defconfig configs/orangepi_zero_defconfig configs/orangepi_zero_plus2_defconfig configs/orangepi_zero_plus2_h3_defconfig configs/orangepi_zero_plus_defconfig
Для прикладу я використовуватиму Orange Pi Zero 2W, тому запускаю конфігурацію наступною командою:
make orangepi_zero2w_defconfig
У результаті буде створено файл .config з параметрами майбутньої збірки. Тепер можна запускати процес компіляції. У параметрі BL31 необхідно вказати шлях до бінарного файлу TF-A (Trusted Firmware-A):
make BL31=../trusted-firmware-a/build/sun50i_h616/debug/bl31.bin -j$(nproc)
Після успішного завершення операції у кореневій директорії з’явиться файл u-boot-sunxi-with-spl.bin:
$ ls -l u-boot-sunxi-with-spl.bin -rw-r--r-- 1 mrco mrco 890309 Mar 18 19:22 u-boot-sunxi-with-spl.bin
Можливі проблеми:
- Image ’u-boot-sunxi-with-spl’ is missing external blobs and is non-functional: atf-bl31 — ви не вказали або вказали неправильний шлях до файлу
bl31.bin(TF-A). - Відсутність утиліт — якщо збірка переривається помилкою «command not found», перевірте, чи встановлені всі необхідні залежності (swig, python3-dev, bison, flex).
- Залежність від версії ОС — у неофіційних форках (я з цим зтикнувся у випадку з Luckfox) часто потрібне специфічне — застаріле оточення. Якщо компіляція не проходить на свіжій системі, найпростішим рішенням буде використання Docker або віртуальної машини зі старішою версією Ubuntu.
Прошивка завантажувача на SD-картку
Allwinner BROM шукає завантажувач (SPL) на картці по фіксованому зміщенню 8 КБ (16 секторів).
Обережно! Будьте надзвичайно уважними при виборі цільового диска. Помилка в назві пристрою (/dev/sdX або rdiskN) може призвести до повної втрати даних на якомусь важливому накопичувачі.
Спочатку визначимо шлях до нашої картки:
## MacOS diskutil list ## Linux lsblk
Щоб уникнути конфліктів зі старими таблицями розділів або залишками даних, зануляємо перші 10 МБ картки:
## linux sudo dd if=/dev/zero of=/dev/sdX bs=1M count=10 ## macos sudo dd if=/dev/zero of=/dev/rdiskX bs=1m count=10 status=progress
Записуємо зібраний файл u-boot-sunxi-with-spl.bin із відступом 8 КБ (seek=8 при блоці 1024 байти):
## macos sudo dd if=u-boot-sunxi-with-spl.bin of=/dev/rdiskX bs=1024 seek=8 status=progress ## linux sudo dd if=u-boot-sunxi-with-spl.bin of=/dev/sdX bs=1024 seek=8 conv=fsync
Після завершення запису картку можна витягувати. Оскільки ми не створювали та не монтували файлову систему, додаткове розмонтування (unmount) не потрібне.
Під’єднуємо UART-адаптер до пінів GND, TX та RX (UART0) Orange Pi Zero 2w та відкриваємо UART консоль через screen, швидкість 115200:
screen /dev/ttyXXX 115200
Якщо все зроблено правильно, після подачі живлення має зʼявитись лог успішного завантаження SPL, TF-A та U-Boot:
U-Boot SPL 2026.04-rc4-00006-geefb822fb574 (Mar 18 2026 - 16:21:53 +0200) DRAM: 2048 MiB Trying to boot from MMC1 NOTICE: BL31: v2.14.0(debug):sandbox/v2.14-755-g2adf0f434 NOTICE: BL31: Built : 17:13:38, Mar 15 2026 NOTICE: BL31: Detected Allwinner H616 SoC (1823) NOTICE: BL31: Found U-Boot DTB at 0x4a0b8d68, model: OrangePi Zero 2W INFO: ARM GICv2 driver initialized INFO: Configuring SPC Controller INFO: Probing for PMIC on I2C: INFO: PMIC: found AXP313 INFO: BL31: Platform setup done INFO: BL31: Initializing runtime services INFO: BL31: cortex_a53: CPU workaround for erratum 855873 was applied INFO: BL31: cortex_a53: CPU workaround for erratum 1530924 was applied INFO: PSCI: Suspend is unavailable INFO: BL31: Preparing for EL3 exit to normal world INFO: Entry point address = 0x4a000000 INFO: SPSR = 0x3c9 INFO: Changed devicetree. U-Boot 2026.04-rc4-00006-geefb822fb574 (Mar 18 2026 - 16:21:53 +0200) Allwinner Technology CPU: Allwinner H616 (SUN50I) Model: OrangePi Zero 2W DRAM: 2 GiB Core: 63 devices, 24 uclasses, devicetree: separate WDT: Not starting watchdog@30090a0 MMC: mmc@4020000: 0 Loading Environment from FAT... Unable to use mmc 0:0... In: serial@5000000 Out: serial@5000000 Err: serial@5000000 Allwinner mUSB OTG (Peripheral) Net: using musb-hdrc, OUT ep1out IN ep1in STATUS ep2in MAC de:ad:be:ef:00:01 HOST MAC de:ad:be:ef:00:00 RNDIS ready eth0: usb_ether starting USB... USB EHCI 1.00 USB OHCI 1.0 Bus usb@5200000: 1 USB Device(s) found Bus usb@5200400: 1 USB Device(s) found scanning usb for storage devices... 0 Storage Device(s) found Hit any key to stop autoboot: 0 =>
Helloworld на C++
Зробимо останній штрих — запустимо програму на C++ у режимі bare-metal, тобто безпосередньо на «залізі» без жодної операційної системи.
Оскільки U-Boot написаний переважно на C та асемблері, раніше нам було достатньо лише
sudo apt install g++-aarch64-linux-gnu
Процесор H618 використовує архітектуру Memory-Mapped Input/Output (MMIO). Тому, щоб вивести дані в UART, нам потрібно записати їх за відповідною фізичною адресою в пам’яті.
Щоб дізнатися цю адресу, необхідна офіційна документація. Для чипа H618 її у вільному доступі немає, проте можна використовувати документацію від практично ідентичного процесора H616: H616 User Manual V1.0.
Відповідно до мануалу, базова периферія UART0 має виділений блок пам’яті за адресою (9.2.5. Register List) 0×05000000 — 0×050003FF.
Для базового виводу тексту нам знадобляться лише два регістри:
- Transmit Holding Register (THR) (зміщення 0×00): усе, що ми записуємо в цей регістр, буде відправлено в лінію UART.
- Line Status Register (LSR) (зміщення 0×14): у ньому нас цікавить
5-й біт — TX Holding Register Empty (THRE). Оскільки процесор працює набагато швидше за UART, перед записом кожного наступного байта в THR необхідно в циклі перевіряти цей біт і чекати, поки буфер передавача не звільниться.
Код для нашого HelloWorld.cpp:
#include <stdint.h>
#define UART0_BASE 0x05000000
#define UART0_THR (*(volatile uint32_t*)(UART0_BASE + 0x00))
#define UART0_LSR (*(volatile uint32_t*)(UART0_BASE + 0x14))
#define LSR_THRE (1 << 5)
void uart_putc(char c) {
while ((UART0_LSR & LSR_THRE) == 0);
UART0_THR = c;
}
void uart_print(const char* str) {
while (*str) {
if (*str == '\n') uart_putc('\r');
uart_putc(*str++);
}
}
extern "C" __attribute__((section(".text.boot"))) void _start() {
uart_print("\n\n");
uart_print("================================\n");
uart_print(" Hello from C++ \n");
uart_print("================================\n");
// Let's wait a bit
for (volatile int i = 0; i < 10000000; i++) {}
uart_print("Returning control back to U-Boot...\n\n");
// return to the U-Boot
return;
}
У процесорах Allwinner H616/H618 початок оперативної пам’яті жорстко прив’язаний до фізичної адреси 0×40000000 (згідно з розділом 3.1 Memory Mapping технічного мануалу).
Будь-яка адреса, менша за цю (наприклад, 0×05000000 для UART), веде не до оперативної пам’яті, а до регістрів периферії або внутрішньої пам’яті чипа (SRAM). Ми не завантажуємо код безпосередньо в 0×40000000, оскільки перші мегабайти DRAM зазвичай уже зарезервовані під потреби U-Boot: там розміщуються таблиці сторінок MMU, глобальний стек та інші службові структури.
Для безпечної роботи необхідний відступ. У світі ARM64 прийнято завантажувати ядро саме зі зміщенням у 2 МБ — за адресою 0×40200000. Ми дотримуватимемося цього стандарту і для нашого Hello World, тож конфігурація скрипта лінкувальника (linker.ld) виглядатиме так:
ENTRY(_start)
SECTIONS
{
. = 0x40200000;
.text : {
*(.text.boot)
*(.text*)
}
.rodata : { *(.rodata*) }
.data : { *(.data*) }
.bss : {
__bss_start = .;
*(.bss*)
__bss_end = .;
}
}
Для збірки використовуємо крос-компілятор. Оскільки ми пишемо для «голого заліза» нам потрібно вимкнути стандартні бібліотеки, обробку винятків та RTTI. Точніше тут справа не в не тому, що процесор їх не тягне, а тому, що в нашому мікро-коді ще немає реалізації (керування пам’яттю та обробки помилок), на який ці функції спираються
# Компіляція об'єктного файлу aarch64-linux-gnu-g++ -ffreestanding -fno-exceptions -fno-rtti -c helloworld.cpp -o helloworld.o # Лінкування згідно з нашою картою пам'яті aarch64-linux-gnu-ld -T linker.ld helloworld.o -o helloworld.elf # Створення чистого бінарного образу (без заголовків ELF) aarch64-linux-gnu-objcopy -O binary helloworld.elf helloworld.bin
Оскільки наш U-Boot займає близько 800 КБ на початку диска, ми запишемо наш бінарник на картку із безпечним відступом в 1 МБ. Це рівно 2048 (0×0800) секторів по 512 байт.
sudo dd if=helloworld.bin of=/dev/XXX bs=512 seek=2048 conv=notrunc
Після подачі живлення зупиняємо автозавантаження натисканням будь-якої клавіші та вводимо наступні команди:
mmc dev 0— вибираємо SD-картку як поточний пристрій.mmc read 0x40200000 0x0800 0x1— зчитуємо 1 сектор із відступу 1 МБ (0×800) в оперативну пам’ять за нашою базовою адресою 0×40200000.go 0x40200000— передаємо керування завантаженому коду.
Результат виконання в UART консолі:

Програму-мінімум виконано! Ми написали та запустили Helloworld на чистому залізі без операційної системи. Звісно завдяки U-Boot який взяв на себе всю складну роботу з ініціалізації контролера пам’яті DRAM та налаштування частот процесора, дозволивши нам зосередитися на логіці програми.
16 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівДякую, дуже цікаво і пізнавально
Не зрозумів, навіщо тут С++, коли тут один С
...
Чудова статя, без води. З цікавості: це ви підʼєднали UART до телефону через Termux?
Дякую.
Ні, це планшет з Arch-ем. На Android USB UART теж працює, але мабуть в Termux не заведеться, бо там доступ треба отримати через Java/Koltin API. В новому Терміналі (який зʼявився з Android 14) теж не буде працювати, бо Гугл ще не доробив USB passthrough для віртуального Лінукса.
А що за планшет?
Якийсь OEMний, наче szbox n100 tablet. Не раджу, бо занадто сирий продукт.
Крутий експеримент. Цікаво, як ти на bare-metal вирішував проблему з когерентністю кешів (L1/L2)?
Бо без налаштованого MMU і правильної інвалідації кешу процесор буде читати старий мусор, якщо якась периферія запише нові дані прямо в DDR оперативку.
Писав руками асемблерні вставки для скидання кешу?
Насправді ніяк, DMA поки не був потрібний, бо все що треба через регістри робив.
Хотів ще спробувати запустити LVGL та HDMI і тут справді доведеться налаштувати MMU.
з мого досвіду, для BF533 це робилося з допомогою чекбокса в IDE (дописується перед заходом до main() блоку асемблерного коду), а що маєш проблеми з кешуванням?
хіба кешування так працює?
може коли дані в DDR змінилися і не співпадають з кешем, тоді блок кеша інвалідується, і копіюється з DDR в кеш, нє?
Це механізм I/O coherency. Як я розумію в бюджетних армах його немає і треба самому скидати кеш.
не знаю, що таке «бюджетний арм», але «самому скидати кеш» це
чи мова не про роботу з пам"яттю, а про периферією, яка мапиться на адресний простір?
але хіба вона кешується, чесно якось дивно звучить, але може в ARM свої заморочки, а в DSP свої, хз
На мою думку, якщо дані в блоці пам"яті для кешування не змінились (хештег блоку чи шо там), то нема змісту обновляти кеш
Я про периферію яка працює з DRAM через DMA, якщо щось змінює стан DRAM то в кеш це автоматично не попадає. Це в H618, на ньому є два варіанти:
— або вимкнути кеш для сторінки — тобто «блок асемблерного коду до main()»
— або власноруч керувати через виклики dc cvac/dc ivac
В лістінгу коду
відсутній шлях до файлу в інклуді.
В загальному, велике дякую за покроковий опис.
Так, дійсно. Десь загубилось при форматуванні. Виправив.