Bare-metal program pro AArch64 v Rustu
V tomto článku se dozvíte, jak napsat jednoduchý bare-metal program v Rustu pro architekturu ARM AArch64, spustit jej pomocí QEMU na virtuálním zařízení, připojit debugger a provést disassemblování.
Úvod
Rust je moderní programovací jazyk navržený s důrazem na bezpečnost, produktivitu a vývoj systémového softwaru. Nabízí vlastní balíčkovací systém a různé nástroje pro správu, překlad, linkování, a dokonce i formátování kódu. Jazyk je velmi všestranný, takže jeho pochopení a osvojení může chvíli trvat.
Chtěl jsem jej vyzkoušet pro programování ARM AArch64, zjistil jsem však, že informací o použití Rustu pro tuto architekturu není mnoho a všechny nalezené příklady byly zastaralé a vyžadovaly další práci, aby fungovaly. Rozhodl jsem se proto popsat první kroky s Rustem a AArch64 v naději, že to pomůže i někomu dalšímu.
Příprava prostředí
Nainstalujte rustup, správce toolchainů
pro Rust. Postup závisí na operačním systému; v Linuxu doporučuji nejprve
vyhledat balíček rustup v nativním správci balíčků. Po instalaci spusťte:
rustup update
V systému byste nyní měli mít následující nástroje:
- rustup – instalátor toolchainů
- rustc – překladač
- rust-gdb – debugger GNU
- cargo – správce balíčků (balíčky se v ekosystému Rust nazývají crates)
Dále budete potřebovat:
- QEMU – emulátor procesoru
- toolchain GCC pro cílovou bare-metal platformu AArch64
- vícearchitekturní
debugger GNU
(volitelně lze místo něj použít
aarch64-none-elf-gdbz toolchainu)
Instalace toolchainu
K překladu kódu pro AArch64 potřebujeme nightly toolchain. Nainstalujete jej a nastavíte jako výchozí příkazem:
rustup default nightly
Instalace správce sysroot
Pro cross-build je nutný sysroot cílové platformy s binárními soubory
standardních crates. Ruční přípravu nedoporučuji, naštěstí ji automatizují dvě
řešení třetích stran: xargo a jeho
zjednodušený fork cargo-xbuild.
Zvolil jsem cargo-xbuild, který funguje jako obálka pro cargo. Později jej
uvidíte v praxi; nyní jej nainstalujte:
cargo install cargo-xbuild
Pro cross-compilation výchozích komponent jazyka je také nutné nainstalovat
zdrojové kódy Rustu. Komponentu rust-src přidáte pomocí rustup:
rustup component add rust-src
Založení projektu
Nyní máme všechny potřebné nástroje. Nový projekt vytvoříme pomocí cargo:
cargo new aarch64-bare-metal --bin --edition 2018
aarch64-bare-metal– název projektu- příznak
--binříká, že vytváříme aplikaci, nikoli knihovnu --edition 2018– struktura má odpovídat edici Cargo 2018
Struktura souborů by měla vypadat přibližně takto:
+ aarch64-bare-metal/
| + src/
| | - main.rs
| - Cargo.toml
Máte-li nainstalovaný git, najdete uvnitř také předem inicializovaný repozitář.
Píšeme program
V oblíbeném textovém editoru nahraďte obsah nově vytvořeného souboru
src/main.rs následujícím kódem:
#![no_std]
#![no_main]
#![feature(global_asm)]
use core::ptr;
mod panic;
global_asm!(include_str!("start.s"));
#[no_mangle]
pub extern "C" fn not_main() {
const UART0: *mut u8 = 0x0900_0000 as *mut u8;
let out_str = b"AArch64 Bare Metal";
for byte in out_str {
unsafe {
ptr::write_volatile(UART0, *byte);
}
}
}
Několik částí si zaslouží vysvětlení. Pomocí #![no_std] zakážeme linkování se
standardní knihovnou a #![no_main] nám umožní použít jiný vstupní bod aplikace
než funkci not_main. Skutečný vstupní bod bude v linker skriptu definován
jako funkce _start a implementován v souboru assembleru start.s. Je to
nutné, protože program se spustí při startu procesoru, kdy ještě neexistuje
operační systém ani nastavený ukazatel zásobníku. Musíme jej tedy nastavit ručně
v assembleru.
Pro vložený assembler povolíme funkci global_asm a poté můžeme pomocí
include_str vložit celý soubor start.s.
Atributem #[no_mangle] vypneme
komolení názvů, aby symbol funkce
not_main zůstal nezměněný. Současně pomocí extern "C" použijeme
konvenci volání jazyka C,
což umožní funkci volat mimo kód Rust.
Nejdůležitější část programu je uvnitř funkce not_main. Zapisuje řetězec
AArch64 Bare Metal znak po znaku na adresu 0x0900_0000, což je paměťově
mapovaná adresa periferie UART0 v QEMU.
Ve stejné složce vytvořte soubor panic.rs s tímto obsahem:
use core::panic::PanicInfo;
#[panic_handler]
fn on_panic(_info: &PanicInfo) -> ! {
loop {}
}
Musíme definovat funkci on_panic s atributem #[panic_handler], kterou
překladač použije při stavu panic. Rust implementuje vícefázový mechanismus
zpracování chyb
Unwinding, který při panic
volá destruktory, uvolňuje paměť a pokračuje v běhu aplikace. Jde o poměrně
složitou funkci závislou na knihovnách operačního systému, takže ji prozatím
musíme obejít.
Kvůli jednoduchosti bych tento handler raději ponechal v
main.rs, ale překladač pak z nějakého důvodu nikdy nepoužije linker skript, který brzy připravíme. Program by se bez něj přeložil pro chybnou vstupní adresu a nemusel by správně fungovat.
Nyní ve složce src/ vytvořte soubor start.s s následujícím obsahem:
.globl _start
.extern LD_STACK_PTR
.section ".text.boot"
_start:
ldr x30, =LD_STACK_PTR
mov sp, x30
bl not_main
.equ PSCI_SYSTEM_OFF, 0x84000008
.globl system_off
system_off:
ldr x0, =PSCI_SYSTEM_OFF
hvc #0
Je velmi krátký a jednoduchý. Nejprve definujeme symbol _start jako globální,
poté inicializujeme zásobník adresou 0x40004000 a zavoláme funkci not_main
z main.rs. Po jejím návratu pokračuje běh na návěští system_off, kde se
procesor vypne hypervisorovou instrukcí hvc.
Píšeme linker skript
Program potřebuje velmi jednoduchý linker skript. V kořenové složce projektu
vytvořte soubor aarch64-qemu.ld s tímto obsahem:
ENTRY(_start)
SECTIONS
{
. = 0x40080000;
.text.boot : { *(.text.boot) }
.text : { *(.text) }
.data : { *(.data) }
.rodata : { *(.rodata) }
.bss : { *(.bss) }
. = ALIGN(8);
. = . + 0x4000;
LD_STACK_PTR = .;
}
Důležité jsou dvě věci:
- Symbol
_startze souborustart.sje pomocíENTRY(_start)deklarován jako vstupní bod programu, od kterého musí začít jeho provádění. . = 0x40080000;je adresa paměti, na kterou QEMU načte spustitelný soubor. Jak tuto adresu zjistit a proč je důležitá, vysvětlím v příštím článku o programování MMU.
Konfigurace cílové platformy
Úplný seznam podporovaných cílových platforem získáte příkazem
rustc --print target-list. Běžným způsobem jejich pojmenování jsou
trojice
ve tvaru <architektura CPU>-<výrobce>-<OS>-<ABI>.
Pro bare-metal programy je vhodný aarch64-unknown-none. Pro cross-build musíme
dodat specifikaci cíle ve formátu JSON. Následující příkaz získá výchozí
specifikaci pro aarch64-unknown-none a uloží ji do souboru
aarch64-unknown-none.json:
rustc -Z unstable-options --print target-spec-json --target aarch64-unknown-none > aarch64-unknown-none.json
Otevřete soubor v textovém editoru a do kořenového objektu přidejte následující část, která překladači Rustu určí náš linker skript:
"pre-link-args": {
"ld.lld": ["-Taarch64-qemu.ld"]
},
Výsledný obsah souboru aarch64-unknown-none.json by měl vypadat takto:
{
"abi-blacklist": ["stdcall", "fastcall", "vectorcall", "thiscall", "win64", "sysv64"],
"arch": "aarch64",
"data-layout": "e-m:e-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128",
"disable-redzone": true,
"env": "",
"executables": true,
"features": "+strict-align,+neon,+fp-armv8",
"is-builtin": true,
"linker": "rust-lld",
"linker-flavor": "ld.lld",
"linker-is-gnu": true,
"pre-link-args": {
"ld.lld": ["-Taarch64-qemu.ld"]
},
"llvm-target": "aarch64-unknown-none",
"max-atomic-width": 128,
"os": "none",
"panic-strategy": "abort",
"relocation-model": "static",
"target-c-int-width": "32",
"target-endian": "little",
"target-pointer-width": "64",
"vendor": ""
}
Sestavení a spuštění
Nyní jsme připraveni program přeložit. Struktura projektu by v této fázi měla vypadat takto:
+ aarch64-bare-metal/
| + src/
| | - main.rs
| | - panic.rs
| | - start.s
| - Cargo.toml
| - aarch64-unknown-none.json
| - aarch64-qemu.ld
Sestavení profilu dev by mělo být snadné:
cargo xbuild --target=aarch64-unknown-none.json
Pro profil release použijte:
cargo xbuild --target=aarch64-unknown-none.json --release
Zajímá-li vás, jak xbuild spravuje sysroot, přidejte k těmto příkazům
příznak -v pro podrobný výstup.
Po sestavení se v kořenové složce objeví adresář target/ s přeloženými
spustitelnými soubory:
target/aarch64-unknown-none/debug/aarch64-bare-metal: ladicí sestavenítarget/aarch64-unknown-none/release/aarch64-bare-metal: produkční sestavení
Sestavení ve složce release je optimalizované a neobsahuje ladicí symboly,
zatímco sestavení debug je obsahuje. Oba soubory jsou ve formátu ELF,
připravené ke spuštění v QEMU s následujícími parametry:
qemu-system-aarch64 -machine virt \
-m 1024M \
-cpu cortex-a53 \
-nographic \
-kernel target/aarch64-unknown-none/debug/aarch64-bare-metal
V terminálu byste měli vidět následující výstup:
AArch64 Bare Metal
Disassemblování
Disassemblování je velmi důležitou součástí nízkoúrovňového vývoje. Hodí se při řešení problémů, analýze i optimalizaci. Vývojář musí znát cílovou architekturu i její jazyk symbolických instrukcí. Zkušenosti přicházejí časem; dobrou zprávou je, že assembler AArch64 je mnohem přívětivější a intuitivnější než například x86.
K disassemblování programu použijeme nástroj objdump z GNU toolchainu:
aarch64-none-elf-objdump --disassemble-all target/aarch64-unknown-none/debug/aarch64-bare-metal
Úplný výpis má přibližně 4 000 řádků, proto uvedu jen jednu zajímavou část:
00000000400011b4 <_ZN4core3ptr14write_volatile17hf16241fac42e4551E>:
// address: code: instruction:
400011b4: d10043ff sub sp, sp, #0x10 // decrement stack pointer by 16 bytes
400011b8: f90003e0 str x0, [sp] // store in stack value from register x0 (it has target address 0x0900_0000 in our case)
400011bc: 39003fe1 strb w1, [sp, #15] // store in stack one byte from register w1 (it has character to output)
400011c0: 39000001 strb w1, [x0] // store byte from w1 at address in x0 (actually output a character to UART)
400011c4: 910043ff add sp, sp, #0x10 // increment stack pointer by 16 bytes
400011c8: d65f03c0 ret // return
Jde o disassemblovanou funkci ptr::write_volatile, kterou jsme použili
k zápisu bajtů do UART. Vidíte zde dříve zmíněné komolení názvu:
_ZN4core3ptr14write_volatile17hf16241fac42e4551E. Pravděpodobně jste si ale
také všimli, jak neefektivní kód je: místo jediné potřebné instrukce
strb w1, [x0] obsahuje šest instrukcí navíc, které nic užitečného nedělají.
Když jsem to viděl poprvé, upřímně jsem chtěl s dalším studiem Rust skončit. Naštěstí jsem stejným způsobem disassembloval také sestavení
release.
aarch64-none-elf-objdump --disassemble-all target/aarch64-unknown-none/release/aarch64-bare-metal
Výpis má překvapivě jen 66 řádků, proto jej uvedu téměř celý:
1: Disassembly of section .text.boot:
2:
3: 0000000040001000 <_start>:
4: 40001000: 580000de ldr x30, 40001018 <system_off+0xc>
5: 40001004: 910003df mov sp, x30
6: 40001008: 94000008 bl 40001028 <not_main>
7:
8: 000000004000100c <system_off>:
9: 4000100c: 580000a0 ldr x0, 40001020 <system_off+0x14>
10: 40001010: d4000002 hvc #0x0
11: 40001014: d503201f nop
12: 40001018: 400050b0 .inst 0x400050b0 ; undefined
13: 4000101c: 00000000 .inst 0x00000000 ; undefined
14: 40001020: 84000008 ld1sb {z8.s}, p0/z, [x0, z0.s, uxtw]
15: 40001024: 00000000 .inst 0x00000000 ; undefined
Tato část se až na několik výjimek velmi podobá souboru start.s. Pro snazší
orientaci jsem ručně přidal čísla řádků. Na řádku 4 se načítá osmibajtová
adresa z 0x40001018. Na řádku 12 je adresa, na kterou překladač uložil
hodnotu LD_STACK_PTR, tedy 400050b0. Podobně byla uložena konstanta
PSCI_SYSTEM_OFF; viz řádky 9 a 14.
Nyní se podívejme na funkci not_main:
// listing of not_main function: all done inline!
0000000040001028 <not_main>:
40001028: 52a12008 mov w8, #0x9000000 // UART0 address
4000102c: 52800829 mov w9, #0x41 // A
40001030: 52800e4a mov w10, #0x72 // r
40001034: 52800c6b mov w11, #0x63 // c
40001038: 39000109 strb w9, [x8]
4000103c: 39000109 strb w9, [x8]
40001040: 52800d09 mov w9, #0x68 // h
40001044: 3900010a strb w10, [x8]
40001048: 3900010b strb w11, [x8]
4000104c: 528006cb mov w11, #0x36 // 6
40001050: 39000109 strb w9, [x8]
40001054: 52800689 mov w9, #0x34 // 4
40001058: 3900010b strb w11, [x8]
4000105c: 5280040b mov w11, #0x20 // _
40001060: 39000109 strb w9, [x8]
40001064: 52800849 mov w9, #0x42 // B
40001068: 3900010b strb w11, [x8]
4000106c: 39000109 strb w9, [x8]
40001070: 52800c29 mov w9, #0x61 // a
40001074: 39000109 strb w9, [x8]
40001078: 3900010a strb w10, [x8]
4000107c: 52800caa mov w10, #0x65 // e
40001080: 3900010a strb w10, [x8]
40001084: 3900010b strb w11, [x8]
40001088: 528009ab mov w11, #0x4d // M
4000108c: 3900010b strb w11, [x8]
40001090: 52800e8b mov w11, #0x74 // t
40001094: 3900010a strb w10, [x8]
40001098: 52800d8a mov w10, #0x6c // l
4000109c: 3900010b strb w11, [x8]
400010a0: 39000109 strb w9, [x8]
400010a4: 3900010a strb w10, [x8]
400010a8: d65f03c0 ret
Funkce ptr::write_volatile se vůbec nevolá a výsledný kód je velmi dobře
optimalizován na výkon:
- znaky ASCII se zapisují přímo do registrů instrukcemi
mov, - písmena
A,a,r,ese přesunou jen jednou a stejné registry se později znovu použijí, - nepoužívá se zásobník ani zbytečné přesuny registrů.
To je jednoduše úžasné!
Připojení debuggeru
Ladění je zásadní součástí nízkoúrovňového vývoje. Při programování MMU,
přerušení nebo ovladačů je někdy jediným způsobem, jak zjistit, co je v kódu
špatně. Pro skutečný hardware můžete potřebovat externí hardwarový debugger
(a možná i umět pájet), u virtuálního zařízení QEMU však stačí k dřívějšímu
příkazu přidat dva příznaky -S -s. QEMU pozastaví běh, otevře TCP socket na
výchozím portu 1234 a počká na připojení debuggeru GNU.
qemu-system-aarch64 -machine virt -m 1024M -cpu cortex-a53 -nographic -kernel target/aarch64-unknown-none/debug/aarch64-bare-metal -S -s
Nyní přichází na řadu gdb-multiarch. Nejprve pro něj v kořenové složce
projektu připravte konfigurační soubor release.gdb:
set disassemble-next-line on
set confirm off
add-symbol-file target/aarch64-unknown-none/release/aarch64-bare-metal
target remote tcp::1234
set arch aarch64
layout regs
V jiném okně terminálu spusťte gdb-multiarch:
gdb-multiarch -x debug.gdb

Tahák pro GDB
GDB má vlastní konzoli, což může být pro začátečníky náročné. Pro rychlý start však stačí zapamatovat si několik jednoduchých příkazů:
break <fn|addr>nebob <fn|addr>: nastaví breakpoint; napříkladb not_main,b *0x40001010break <fn> if <condition>: podmíněný breakpoint; napříkladbreak context_switch if next == init_taskclear: odstraní breakpoint; napříkladclear not_mainsinebostepi: provede jednu strojovou instrukci a při volání funkce do ní vstoupíninebonexti: provede jednu strojovou instrukci, ale volání funkce přeskočícnebocontinue: pokračuje v běhu do dalšího breakpointu
Závěrem
Nějakou dobu jsem pracoval na jádře LeOS v C. Po implementaci multitaskingu a přepínání mezi úrovněmi výjimek jsem začal programovat MMU. Když vše začalo fungovat, uvědomil jsem si, že je vhodná chvíle k refaktoringu a novému uspořádání všeho, co jsem vytvořil. Současně jsem se rozhodl prozkoumat Rust a zvážit jej jako hlavní jazyk jádra.
Několik dní jsem strávil aplikací z tohoto článku, poznáváním ekosystému Rust a dalším výzkumem. Když nyní článek dokončuji, už vím, že jeho výsledky vytvoří základ prvního commitu v repozitáři LeOS Kernel.
Komentáře