Esempi eseguibili
Creiamo ed eseguiamo alcuni minuscoli programmi di bare metal hello world che funzionano senza un sistema operativo su:
Li proveremo anche sull'emulatore QEMU il più possibile, poiché è più sicuro e più conveniente per lo sviluppo. I test QEMU sono stati su un host Ubuntu 18.04 con il QEMU preconfezionato 2.11.1.
Il codice di tutti gli esempi x86 di seguito e altro è presente in questo repository GitHub .
Come eseguire gli esempi su hardware reale x86
Ricorda che eseguire esempi su hardware reale può essere pericoloso, ad esempio potresti cancellare per errore il tuo disco o bloccare l'hardware: fallo solo su macchine vecchie che non contengono dati critici! O ancora meglio, usa devboard semi-usa e getta economici come il Raspberry Pi, vedi l'esempio ARM di seguito.
Per un tipico laptop x86, devi fare qualcosa del tipo:
Masterizza l'immagine su una chiavetta USB (distruggerà i tuoi dati!):
sudo dd if=main.img of=/dev/sdX
collegare l'USB su un computer
accendilo
digli di avviarsi dall'USB.
Ciò significa che il firmware deve selezionare USB prima del disco rigido.
Se questo non è il comportamento predefinito del tuo computer, continua a premere Invio, F12, ESC o altre chiavi così strane dopo l'accensione fino a quando non ottieni un menu di avvio in cui è possibile selezionare per l'avvio da USB.
Spesso è possibile configurare l'ordine di ricerca in quei menu.
Ad esempio, sul mio T430 vedo quanto segue.
Dopo l'accensione, questo è quando devo premere Invio per accedere al menu di avvio:
Quindi, qui devo premere F12 per selezionare l'USB come dispositivo di avvio:
Da lì, posso selezionare l'USB come dispositivo di avvio in questo modo:
In alternativa, per cambiare l'ordine di avvio e scegliere l'USB che abbia una precedenza più alta, quindi non devo selezionarlo manualmente ogni volta, vorrei premere F1 nella schermata "Startup Interrupt Menu", quindi navigare a:
Settore di avvio
Su x86, la cosa più semplice e di livello più basso che puoi fare è creare un Master Boot Sector (MBR) , che è un tipo di settore di avvio , e quindi installarlo su un disco.
Qui ne creiamo uno con una sola printf
chiamata:
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img
Risultato:
Nota che anche senza fare nulla, alcuni personaggi sono già stampati sullo schermo. Quelli sono stampati dal firmware e servono per identificare il sistema.
E sul T430 abbiamo solo uno schermo vuoto con un cursore lampeggiante:
main.img
contiene quanto segue:
\364
in ottale == 0xf4
in esadecimale: la codifica di hlt
un'istruzione, che indica alla CPU di smettere di funzionare.
Pertanto il nostro programma non farà nulla: solo avviare e arrestare.
Usiamo ottale perché i \x
numeri esadecimali non sono specificati da POSIX.
Potremmo ottenere facilmente questa codifica con:
echo hlt > a.S
as -o a.o a.S
objdump -S a.o
che produce:
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
0: f4 hlt
ma è anche documentato nel manuale Intel ovviamente.
%509s
produrre 509 spazi. Necessario per compilare il file fino al byte 510.
\125\252
in ottale == 0x55
seguito da 0xaa
.
Questi sono 2 byte magici richiesti che devono essere byte 511 e 512.
Il BIOS passa attraverso tutti i nostri dischi alla ricerca di quelli avviabili e considera avviabili solo quelli che hanno quei due byte magici.
Se non presente, l'hardware non lo tratterà come un disco di avvio.
Se non sei un printf
maestro, puoi confermare il contenuto di main.img
con:
hd main.img
che mostra l'atteso:
00000000 f4 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 |. |
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
*
000001f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 55 aa | U.|
00000200
dove si 20
trova uno spazio in ASCII.
Il firmware del BIOS legge quei 512 byte dal disco, li mette in memoria e imposta il PC sul primo byte per iniziare a eseguirli.
Ciao settore di avvio del mondo
Ora che abbiamo creato un programma minimo, spostiamoci in un mondo di ciao.
La domanda ovvia è: come fare IO? Alcune opzioni:
chiedere al firmware, ad esempio BIOS o UEFI, di farlo per noi
VGA: area di memoria speciale che viene stampata sullo schermo se scritta. Può essere utilizzato in modalità protetta.
scrivere un driver e parlare direttamente con l'hardware del display. Questo è il modo "corretto" per farlo: più potente, ma più complesso.
porta seriale . Questo è un protocollo standardizzato molto semplice che invia e riceve caratteri da un terminale host.
Sui desktop appare così:
Fonte .
Sfortunatamente non è esposto sulla maggior parte dei laptop moderni, ma è il modo comune di utilizzare schede di sviluppo, vedere gli esempi ARM di seguito.
Questo è davvero un peccato, dal momento che tali interfacce sono davvero utili per il debug del kernel Linux, ad esempio .
utilizzare le funzionalità di debug dei chip. ARM chiama ad esempio il loro semihosting . Sull'hardware reale, richiede un supporto hardware e software aggiuntivo, ma sugli emulatori può essere un'opzione conveniente gratuita. Esempio .
Qui faremo un esempio di BIOS in quanto è più semplice su x86. Ma nota che non è il metodo più robusto.
main.S
.code16
mov $msg, %si
mov $0x0e, %ah
loop:
lodsb
or %al, %al
jz halt
int $0x10
jmp loop
halt:
hlt
msg:
.asciz "hello world"
GitHub a monte .
link.ld
SECTIONS
{
/* The BIOS loads the code from the disk to this location.
* We must tell that to the linker so that it can properly
* calculate the addresses of symbols we might jump to.
*/
. = 0x7c00;
.text :
{
__start = .;
*(.text)
/* Place the magic boot bytes at the end of the first 512 sector. */
. = 0x1FE;
SHORT(0xAA55)
}
}
Assembla e collega con:
as -g -o main.o main.S
ld --oformat binary -o main.img -T link.ld main.o
qemu-system-x86_64 -hda main.img
Risultato:
E sul T430:
Testato su: Lenovo Thinkpad T430, UEFI BIOS 1.16. Disco generato su un host Ubuntu 18.04.
Oltre alle istruzioni standard di assemblaggio per l'utente, abbiamo:
.code16
: dice a GAS di emettere codice a 16 bit
cli
: disabilita gli interrupt software. Questi potrebbero far ricominciare il processore dopo ilhlt
int $0x10
: esegue una chiamata BIOS. Questo è ciò che stampa i personaggi uno per uno.
I flag di link importanti sono:
--oformat binary
: genera codice assembly binario non elaborato, non racchiuderlo in un file ELF come nel caso dei normali eseguibili userland.
Per comprendere meglio la parte dello script del linker, familiarizzare con la fase di trasferimento del collegamento: cosa fanno i linker?
Programmi bare metal x86 più freddi
Ecco alcune configurazioni bare metal più complesse che ho ottenuto:
Usa C invece di assemblare
Riepilogo: usa il multiboot di GRUB, che risolverà molti fastidiosi problemi a cui non hai mai pensato. Vedi la sezione sotto.
La principale difficoltà su x86 è che il BIOS carica solo 512 byte dal disco in memoria e che è probabile che tu faccia esplodere quei 512 byte quando usi C!
Per risolverlo, possiamo usare un bootloader a due stadi . Ciò effettua ulteriori chiamate BIOS, che caricano più byte dal disco in memoria. Ecco un esempio minimo di assemblaggio di fase 2 da zero utilizzando le chiamate BIOS int 0x13 :
In alternativa:
- se ne hai bisogno solo per funzionare in QEMU ma non in hardware reale, usa l'
-kernel
opzione, che carica un intero file ELF in memoria. Ecco un esempio ARM che ho creato con quel metodo .
- per Raspberry Pi, il firmware predefinito si occupa del caricamento delle immagini per noi da un file ELF chiamato
kernel7.img
, proprio come -kernel
fa QEMU .
Solo a scopo educativo, ecco un esempio C minimo di una fase :
main.c
void main(void) {
int i;
char s[] = {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'};
for (i = 0; i < sizeof(s); ++i) {
__asm__ (
"int $0x10" : : "a" ((0x0e << 8) | s[i])
);
}
while (1) {
__asm__ ("hlt");
};
}
entry.S
.code16
.text
.global mystart
mystart:
ljmp $0, $.setcs
.setcs:
xor %ax, %ax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
mov $__stack_top, %esp
cld
call main
linker.ld
ENTRY(mystart)
SECTIONS
{
. = 0x7c00;
.text : {
entry.o(.text)
*(.text)
*(.data)
*(.rodata)
__bss_start = .;
/* COMMON vs BSS: /programming/16835716/bss-vs-common-what-goes-where */
*(.bss)
*(COMMON)
__bss_end = .;
}
/* /programming/53584666/why-does-gnu-ld-include-a-section-that-does-not-appear-in-the-linker-script */
.sig : AT(ADDR(.text) + 512 - 2)
{
SHORT(0xaa55);
}
/DISCARD/ : {
*(.eh_frame)
}
__stack_bottom = .;
. = . + 0x1000;
__stack_top = .;
}
correre
set -eux
as -ggdb3 --32 -o entry.o entry.S
gcc -c -ggdb3 -m16 -ffreestanding -fno-PIE -nostartfiles -nostdlib -o main.o -std=c99 main.c
ld -m elf_i386 -o main.elf -T linker.ld entry.o main.o
objcopy -O binary main.elf main.img
qemu-system-x86_64 -drive file=main.img,format=raw
Libreria standard C.
Le cose diventano più divertenti se si desidera utilizzare anche la libreria C standard, poiché non abbiamo il kernel Linux, che implementa gran parte delle funzionalità della libreria C standard tramite POSIX .
Alcune possibilità, senza passare a un sistema operativo completo come Linux, includono:
Scrivi il tuo. È solo un mucchio di intestazioni e file C alla fine, giusto? Destra??
newlib
Esempio dettagliato su: /electronics/223929/c-standard-libraries-on-bare-metal/223931
Implementa newlib tutte le cose noiose non-OS specifici per voi, per esempio memcmp
, memcpy
, etc.
Quindi, fornisce alcuni stub per implementare le syscalls di cui hai bisogno.
Ad esempio, possiamo implementare exit()
su ARM tramite semihosting con:
void _exit(int status) {
__asm__ __volatile__ ("mov r0, #0x18; ldr r1, =#0x20026; svc 0x00123456");
}
come mostrato in questo esempio .
Ad esempio, è possibile reindirizzare printf
verso i sistemi UART o ARM o implementare exit()
con semihosting .
sistemi operativi integrati come FreeRTOS e Zephyr .
Tali sistemi operativi in genere consentono di disattivare la pianificazione preventiva, offrendo quindi il pieno controllo sul runtime del programma.
Possono essere visti come una sorta di Newlib pre-implementato.
GNU GRUB Multiboot
I settori di avvio sono semplici, ma non sono molto convenienti:
- puoi avere solo un sistema operativo per disco
- il codice di caricamento deve essere veramente piccolo e contenere 512 byte
- devi fare molto l'avvio da solo, come passare alla modalità protetta
È per questi motivi che GNU GRUB ha creato un formato file più conveniente chiamato multiboot.
Esempio di lavoro minimo: https://github.com/cirosantilli/x86-bare-metal-examples/tree/d217b180be4220a0b4a453f31275d38e697a99e0/multiboot/hello-world
Lo uso anche sul mio repository di esempi GitHub per essere in grado di eseguire facilmente tutti gli esempi su hardware reale senza masterizzare l'USB un milione di volte.
Risultato QEMU:
T430:
Se prepari il tuo sistema operativo come file multiboot, GRUB è quindi in grado di trovarlo all'interno di un normale filesystem.
Questo è ciò che fanno la maggior parte delle distro, mettendo sotto le immagini del sistema operativo /boot
.
I file multiboot sono fondamentalmente un file ELF con un'intestazione speciale. Sono specificati da GRUB su: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html
È possibile trasformare un file multiboot in un disco di avvio con grub-mkrescue
.
firmware
In verità, il tuo settore di avvio non è il primo software che gira sulla CPU del sistema.
Ciò che effettivamente viene eseguito per primo è il cosiddetto firmware , che è un software:
- prodotto dai produttori di hardware
- tipicamente sorgente chiusa ma probabilmente basato su C.
- archiviato nella memoria di sola lettura e quindi più difficile / impossibile da modificare senza il consenso del fornitore.
I firmware ben noti includono:
- BIOS : vecchio firmware x86 tutto presente. SeaBIOS è l'implementazione open source predefinita utilizzata da QEMU.
- UEFI : successore del BIOS, meglio standardizzato, ma più capace e incredibilmente gonfio.
- Coreboot : il nobile tentativo di open source ad arco incrociato
Il firmware fa cose come:
passa su ogni disco rigido, USB, rete, ecc. fino a quando non trovi qualcosa di avviabile.
Quando eseguiamo QEMU, -hda
dice che main.img
è un disco rigido collegato all'hardware, ed hda
è il primo da provare e viene utilizzato.
caricare i primi 512 byte nell'indirizzo di memoria RAM 0x7c00
, inserire lì il RIP della CPU e lasciarlo funzionare
mostra cose come il menu di avvio o le chiamate di stampa del BIOS sul display
Il firmware offre funzionalità simili al sistema operativo da cui dipende la maggior parte dei sistemi operativi. Ad esempio, un sottoinsieme Python è stato portato per essere eseguito su BIOS / UEFI: https://www.youtube.com/watch?v=bYQ_lq5dcvM
Si può sostenere che i firmware non sono distinguibili dai sistemi operativi e che il firmware è l'unica "vera" programmazione in metallo nudo che si possa fare.
Come dice questo sviluppatore CoreOS :
La parte difficile
Quando si accende un PC, i chip che compongono il chipset (northbridge, southbridge e SuperIO) non sono ancora inizializzati correttamente. Anche se la ROM del BIOS è la più lontana possibile dalla CPU, ciò è accessibile dalla CPU, perché deve essere, altrimenti la CPU non avrebbe istruzioni da eseguire. Ciò non significa che la ROM del BIOS sia completamente mappata, di solito no. Ma quel tanto che basta è mappato per avviare il processo di avvio. Qualsiasi altro dispositivo, dimenticalo e basta.
Quando esegui Coreboot in QEMU, puoi sperimentare con i livelli più alti di Coreboot e con i payload, ma QEMU offre poche opportunità di sperimentare con il codice di avvio di basso livello. Per prima cosa, la RAM funziona fin dall'inizio.
Stato iniziale post BIOS
Come molte cose nell'hardware, la standardizzazione è debole e una delle cose su cui non dovresti fare affidamento è lo stato iniziale dei registri quando il codice inizia a funzionare dopo il BIOS.
Quindi fatevi un favore e utilizzate un codice di inizializzazione come il seguente: https://stackoverflow.com/a/32509555/895245
I registri piacciono %ds
e %es
hanno effetti collaterali importanti, quindi dovresti azzerarli anche se non li stai usando esplicitamente.
Nota che alcuni emulatori sono più belli dell'hardware reale e ti danno un buono stato iniziale. Quindi quando corri su hardware reale, tutto si rompe.
El Torito
Formato che può essere masterizzato su CD: https://en.wikipedia.org/wiki/El_Torito_%28CD-ROM_standard%29
È anche possibile produrre un'immagine ibrida che funziona su ISO o USB. Questo può essere fatto con grub-mkrescue
( esempio ), ed è anche fatto dal kernel di Linux make isoimage
usando isohybrid
.
BRACCIO
In ARM, le idee generali sono le stesse.
Non esiste un firmware preinstallato semi-standardizzato ampiamente disponibile come BIOS da utilizzare per l'IO, quindi i due tipi di IO più semplici che possiamo fare sono:
- seriale, che è ampiamente disponibile su devboard
- lampeggia il LED
Ho caricato:
Alcune differenze rispetto a x86 includono:
L'IO viene fatto scrivendo direttamente agli indirizzi magici, non ci sono in
e out
istruzioni.
Questo si chiama IO mappato in memoria .
per alcuni hardware reali, come Raspberry Pi, puoi aggiungere tu stesso il firmware (BIOS) all'immagine del disco.
Questa è una buona cosa, poiché rende l'aggiornamento del firmware più trasparente.
risorse