Simple Operating System
Setup a RISC-V Toolchain
Iniziamo mostrando come configurarsi una propria toolchain in grado di eseguire del codice RISC-V a 32 bit. La toolchain non richiede di possedere un hardware fisico. Tutto sarà emulato via software tramite QEMU.
Privilege levels in RISC-V
RISC-V definisce tre modalità di esecuzione, in ordine di privilegio crescente dal basso verso l'alto.
| modo | chi ci gira |
|---|---|
| U-mode (user) | le applicazioni |
| S-mode (supervisor) | il kernel |
| M-mode (machine) | il firmware |
Il nostro kernel vivrà in S-mode. Una buona parte del progetto
sarà dedicata ad implementare il layer di astrazione tra la modalità
supervisor e la modalità user. Un livello ancora più privilegiato del
kernel è il firmware, che gira in M-mode. Nel nostro caso il
firmware sarà OpenSBI, che implementa la Supervisor Binary
Interface, cioè l'insieme di servizi messi a disposizione dalla
macchina per il kernel. Scrivere un carattere sulla console, per
esempio, è uno di questi servizi.
Dependencies
Per far funzionare la toolchain abbiamo bisogno delle seguenti dipendenze:
-
clang con lld, per compilare e linkare il codice per RISC-V.
-
llvm-objcopy, ed altri strumenti LLVM, per guardare dentro ai binari prodotti.
-
qemu-system-riscv32, l'emulatore che girerà il compilato nell'architettura emulata.
Per installare queste cose possiamo scaricare i seguenti pacchetti, che variano a seconda della distribuzione:
-
Debian o Ubuntu:
sudo apt install clang lld llvm qemu-system-misc -
Arch
sudo pacman -S clang lld llvm qemu-system-riscv -
macOS, con Homebrew
brew install llvm lld qemu
Una volta installate le dipendenze possiamo controllare che tutto sia stato installato correttamente nel seguente modo. I numeri di versione specifici potrebbero essere diversi dai vostri, ma non importa. L'importante è aver installato il software.
$ clang --version | head -1
clang version 22.1.8
$ ld.lld --version
LLD 22.1.8 (compatible with GNU linkers)
$ qemu-system-riscv32 --version | head -1
QEMU emulator version 11.0.3
Per controllare che il compilatore clang supporta l'architettura
RISC-V possiamo utilizzare la flag -print-targets.
$ clang -print-targets | grep riscv32
riscv32 - 32-bit RISC-V
The first file
Andiamo adesso a scrivere il primo codice del kernel. Prima ancora di
introdurre il layer C, andiamo a scrivere delle semplici istruzioni
assembly per RISC-V. Iniziamo scrivendo un semplice ciclo infinito nel
file boot.S.
File: boot.S
.section .text.boot
.globl boot
boot:
wfi
j boot
L'istruzione wfi sta per wait for interrupt e viene utilizzata per
fermare il processore finché non arriva un interrupt. Se avessimo
solamente j boot da solo, il kernel continuerebbe a girare a pieno
regime senza fare nulla. Invece con l'istruzione wfi la macchina
aspetta.
The role of the linker
Il processo di compilazione produce delle sezioni. Il codice si trova in una sezione, le costanti in un'altra, le variabili in un'altra ancora. Chi decide dove ciascuna di esse finisce nell'immagine, e a quale indirizzo, è il linker.
Quando compiliamo un normale programma in Linux non ci interessiamo
molto del linker. Si utilizza uno script di default e il sistema
operativo va a caricare il programma in memoria dove preferisce. Dato
che qui il sistema operativo lo stiamo scrivendo noi, nessuno caricherà
i nostri programmi in memoria. L'unica cosa che abbiamo fin
dall'inizio è OpenSBI, che quando finisce di preparare la macchina
salta all'indirizzo 0x80200000.
Per specificare il modo in cui il linker produce il programma finale abbiamo bisogno di specificare uno script di linking. Questo file specifica tre cose:
- Da quale indirizzo iniziare
- Quali sezioni mettere nell'immagine e in che ordine
- Quale simbolo è il punto di ingresso
Scriviamo quindi il seguente linker script.
File: kernel.ld
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text : {
KEEP(*(.text.boot));
*(.text .text.*);
}
}
Segue la descrizione per i vari comandi presenti nel file:
-
ENTRY(boot)fissa il punto di ingresso. In questo caso il punto di ingresso è vincolato dal comportamento di OpenSBI.
-
SECTIONS { ... }contiene l'elenco delle sezioni di uscita, nell'ordine in cui vanno disposte.
-
Il punto
.specifica l'indirizzo correnteassegnargli
0x80200000all'inizio significa dire "da qui in poi disponi tutto a partire da questo indirizzo". -
.text : { ... }dichiara una sezione di uscita, e dentro le graffe si dice cosa ci finisce.
*(.text .text.*)significa "la sezione.textdi qualsiasi file di input, e ogni sezione il cui nome comincia per.text.". L'asterisco davanti alla parentesi è il file, quello dentro è il nome. -
KEEPimpedisce al linker di buttare via
.text.boot. Da notare che l'ordine conta:.text.bootè nominata per prima, quindibootfinisce a0x80200000, che è esattamente dove salterà il firmware.
In futuro lo script sarà modificato per introdurre altre sezioni.
Let's compile!
Il comando per compilare il binario è il seguente, e utilizza il
compilatore clang.
$ clang --target=riscv32-unknown-elf -march=rv32imac_zicsr -mabi=ilp32 \
-std=c11 -O2 -g3 -Wall -Wextra \
-ffreestanding -fno-stack-protector -nostdlib \
-fuse-ld=lld -Wl,-Tkernel.ld -Wl,-Map=kernel.map \
boot.S -o kernel.elf
Oltre le classiche opzioni come -Wall, -Wextra e -std, le altre
opzioni si dividono in tre gruppi. Il primo gruppo specifica per quale
macchina compilare e sfrutta le capacità di cross-compilation del
compilatore scelto:
-
--target=riscv32-unknown-elfdice a clang di generare un ELF per nessun sistema operativo con istruzioni per RISC-V. -
-march=rv32imac_zicsrelenca le estensioni selezionate, e-mabi=ilp32definisce la calling convention: interi, long e puntatori a 32 bit.
Il secondo gruppo serve a togliere tutto ciò di cui non abbiamo bisogno:
-
-ffreestandingdice al compilatore che non esiste una libreria standard che andrà a chiamare ilmain. -
-nostdlibdice al linker di non collegare nessuna libreria, e di non aggiungere il codice che chiamamain. -
-fno-stack-protectortoglie i controlli che il compilatore inserirebbe attorno allo stack.
Il terzo gruppo parla al linker, e -Wl, è il modo di passargli una
opzione attraverso il compilatore:
-
-fuse-ld=lldsceglie il linker di LLVM, che sa già collegare per RISC-V senza bisogno di installare altro. -
-Wl,-Tkernel.ldpermette di utilizzare il nostro linker script al posto di quello di default. -
-Wl,-Map=kernel.mapspecifica di scrivere una memory map, ovvero un file con l'indirizzo finale di ogni simbolo.
Una volta compilato il file kernel.elf possiamo avviare la macchina
tramite QEMU come segue.
$ qemu-system-riscv32 -machine virt -bios default -nographic \
-serial mon:stdio -no-reboot -kernel kernel.elf
-machine virtè una macchina generica pensata per la virtualizzazione: ha una console seriale, un controller di interrupt e un bus virtio.-bios defaultcarica OpenSBI.-nographictoglie la finestra e porta la seriale sul terminale.-serial mon:stdiocollega la seriale della macchina, cioè la sua console, allo standard input e output del terminale. Il prefissomon:ci aggiunge il monitor di QEMU, una console di comandi per ispezionare la macchina, a cui si passa conCtrl-Ae poic.-no-rebootfa fermare QEMU invece di riavviare, se il kernel muore.
Il risultato di questo è il seguente. Per uscire da QEMU possiamo
premere Ctrl-A seguito da x.
$ qemu-system-riscv32 -machine virt -bios default -nographic \
-serial mon:stdio -no-reboot -kernel kernel.elf
OpenSBI v1.7
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
...
Domain0 Next Address : 0x80200000
Domain0 Next Mode : S-mode
...
Dopo il banner di OpenSBI non compare nient'altro, ed è giusto così:
il nostro kernel gira nel ciclo di wfi senza stampare niente. Come
facciamo allora a sapere che è partito? Tra le righe del banner c'è
Domain0 Next Address, l'indirizzo a cui OpenSBI salta quando ha
finito di preparare la macchina, e Domain0 Next Mode, il livello di
privilegio con cui lo fa. 0x80200000 in S-mode è proprio dove il
linker script ha messo boot.
Possiamo analizzare il codice ELF con il comando llvm-readelf.
$ llvm-readelf -h kernel.elf | grep -E 'Class|Machine|Entry'
Class: ELF32
Machine: RISC-V
Entry point address: 0x80200000
Inizia il progetto per avere un repository e poter consegnare gli step.