Збираємо U-Boot та запускаємо свій C++ код на Orange Pi Zero 2W

Для одного з експериментів мені знадобилося перевірити, як саме операційна система Linux впливає на швидкість роботи із залізом. Для цього один і той самий алгоритм необхідно протестувати у двох середовищах: під керуванням Linux та в режимі bare-metal (без ОС). Крім того, мною рухав суто спортивний інтерес - було цікаво порівняти програмування мікроконтролерів і "дорослих" 64-бітних багатоядерних процесорів.

Для проведення тестів я обрав процесор 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 Firmware) відповідає за налаштування безпеки процесора, 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:

https://github.com/TrustedFirmware-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 та UBoot

Клонуємо офіційний репозиторій з GitHub: https://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 секторі1в).

Обережно! Будьте надзвичайно уважними при виборі цільового диска. Помилка в назві пристрою (/dev/sdX або rdiskN) може призвести до повної втрати даних на вашому основному накопичувачі.

Спочатку визначимо шлях до нашої картки:

## MacOS
diskutil list

## Linux
lsblk 

Щоб уникнути конфліктів зі старими таблицями розділів або залишками даних, зануляємо перші 10 МБ картки:

## linux
sudo dd if=/dev/zero of=/dev/sdb bs=1M count=10

## macos
sudo dd if=/dev/zero of=/dev/rdiskN 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/rdiskN bs=1024 seek=8 status=progress
## linux
sudo dd if=u-boot-sunxi-with-spl.bin of=/dev/sdb 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 та асемблері, раніше нам було достатньо лише C-компілятора. Для збірки коду на C++ під архітектуру Arm64 необхідно встановити додатковий пакет:

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) 0x05000000 - 0x050003FF.

Для базового виводу тексту нам знадобляться лише два регістри:

  • Transmit Holding Register (THR) (зміщення 0x00): усе, що ми записуємо в цей регістр, буде відправлено в лінію UART.

  • Line Status Register (LSR) (зміщення 0x14): у ньому нас цікавить 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 початок оперативної пам’яті жорстко прив’язаний до фізичної адреси 0x40000000 (згідно з розділом 3.1 Memory Mapping технічного мануалу).

Будь-яка адреса, менша за цю (наприклад, 0x05000000 для UART), веде не до оперативної пам’яті, а до регістрів периферії або внутрішньої пам’яті чипа (SRAM). Ми не завантажуємо код безпосередньо в 0x40000000, оскільки перші мегабайти DRAM зазвичай уже зарезервовані під потреби U-Boot: там розміщуються таблиці сторінок MMU, глобальний стек та інші службові структури.

Для безпечної роботи необхідний відступ. У світі ARM64 прийнято завантажувати ядро саме зі зміщенням у 2 МБ — за адресою 0x40200000. Ми дотримуватимемося цього стандарту і для нашого 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 (0x0800) секторів по 512 байт.

sudo dd if=helloworld.bin of=/dev/... bs=512 seek=2048 conv=notrunc

Після подачі живлення зупиняємо автозавантаження натисканням будь-якої клавіші та вводимо наступні команди:

  • mmc dev 0 - вибираємо SD-картку як поточний пристрій.

  • mmc read 0x40200000 0x0800 0x1 - зчитуємо 1 сектор із відступу 1 МБ (0x800) в оперативну пам’ять за нашою базовою адресою 0x40200000.

  • go 0x40200000 - передаємо керування завантаженому коду.

Результат виконання в UART консолі:


Програму-мінімум виконано! Ми написали та запустили Helloworld на чистому залізі без операційної системи. Звісно завдяки U-Boot який взяв на себе всю складну роботу з ініціалізації контролера пам’яті DRAM та налаштування частот процесора, дозволивши нам зосередитися на логіці програми.

Коментарі

Популярні дописи з цього блогу

Огляд DC-DC Step-down Buck перетворювачів

ESP8266 модуль з OLED екраном (HW-364A)

Модуль PD тригер IP2721 на 15 та 20 вольт