← Torna al progetto

Simple Operating System

Bootstrapping the OS C Step 1 di 24

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.

OpenSBI gira in M-mode e mette i suoi servizi a disposizione del kernel, che gira in S-mode e mette i propri a disposizione delle applicazioni in U-mode: in tutti e due i passaggi si sale di privilegio con una ecall, e solo il firmware tocca l'hardware. OpenSBI sta al kernel come il kernel sta alle applicazioni.
OpenSBI gira in M-mode e mette i suoi servizi a disposizione del kernel, che gira in S-mode e mette i propri a disposizione delle applicazioni in U-mode: in tutti e due i passaggi si sale di privilegio con una ecall, e solo il firmware tocca l'hardware. OpenSBI sta al kernel come il kernel sta alle applicazioni.

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:

  1. Da quale indirizzo iniziare
  2. Quali sezioni mettere nell'immagine e in che ordine
  3. 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 corrente

    assegnargli 0x80200000 all'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 .text di qualsiasi file di input, e ogni sezione il cui nome comincia per .text.". L'asterisco davanti alla parentesi è il file, quello dentro è il nome.

  • KEEP

    impedisce al linker di buttare via .text.boot. Da notare che l'ordine conta: .text.boot è nominata per prima, quindi boot finisce a 0x80200000, 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-elf dice a clang di generare un ELF per nessun sistema operativo con istruzioni per RISC-V.

  • -march=rv32imac_zicsr elenca le estensioni selezionate, e -mabi=ilp32 definisce la calling convention: interi, long e puntatori a 32 bit.

Il secondo gruppo serve a togliere tutto ciò di cui non abbiamo bisogno:

  • -ffreestanding dice al compilatore che non esiste una libreria standard che andrà a chiamare il main.

  • -nostdlib dice al linker di non collegare nessuna libreria, e di non aggiungere il codice che chiama main.

  • -fno-stack-protector toglie 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=lld sceglie il linker di LLVM, che sa già collegare per RISC-V senza bisogno di installare altro.

  • -Wl,-Tkernel.ld permette di utilizzare il nostro linker script al posto di quello di default.

  • -Wl,-Map=kernel.map specifica 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 default carica OpenSBI.
  • -nographic toglie la finestra e porta la seriale sul terminale.
  • -serial mon:stdio collega la seriale della macchina, cioè la sua console, allo standard input e output del terminale. Il prefisso mon: ci aggiunge il monitor di QEMU, una console di comandi per ispezionare la macchina, a cui si passa con Ctrl-A e poi c.
  • -no-reboot fa 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.

Congratulazioni, hai completato il progetto Simple Operating System

Tutti i 24 step sono superati.