Ausführbare Beispiele
Lassen Sie uns einige winzige Bare-Metal-Hallo-Welt-Programme erstellen und ausführen, die ohne Betriebssystem ausgeführt werden:
Wir werden sie auch so oft wie möglich auf dem QEMU-Emulator ausprobieren, da dies sicherer und bequemer für die Entwicklung ist. Die QEMU-Tests wurden auf einem Ubuntu 18.04-Host mit der vorgefertigten QEMU 2.11.1 durchgeführt.
Der Code aller folgenden x86-Beispiele und mehr ist in diesem GitHub-Repo enthalten .
So führen Sie die Beispiele auf realer x86-Hardware aus
Denken Sie daran, dass das Ausführen von Beispielen auf realer Hardware gefährlich sein kann, z. B. wenn Sie versehentlich Ihre Festplatte löschen oder die Hardware blockieren: Tun Sie dies nur auf alten Computern, die keine kritischen Daten enthalten! Oder noch besser, verwenden Sie billige Einweg-Devboards wie den Raspberry Pi (siehe ARM-Beispiel unten).
Für einen typischen x86-Laptop müssen Sie Folgendes tun:
Brennen Sie das Bild auf einen USB-Stick (zerstört Ihre Daten!):
sudo dd if=main.img of=/dev/sdX
Schließen Sie den USB an einen Computer an
Mach es an
Sagen Sie ihm, er soll vom USB booten.
Dies bedeutet, dass die Firmware vor der Festplatte USB auswählt.
Wenn dies nicht das Standardverhalten Ihres Computers ist, drücken Sie nach dem Einschalten die Eingabetaste, F12, ESC oder andere seltsame Tasten, bis Sie ein Startmenü erhalten, in dem Sie auswählen können, ob Sie vom USB-Gerät booten möchten.
In diesen Menüs ist es häufig möglich, die Suchreihenfolge zu konfigurieren.
Auf meinem T430 sehe ich beispielsweise Folgendes.
Nach dem Einschalten muss ich die Eingabetaste drücken, um das Startmenü aufzurufen:
Dann muss ich hier F12 drücken, um den USB als Startgerät auszuwählen:
Von dort aus kann ich den USB wie folgt als Startgerät auswählen:
Um alternativ die Startreihenfolge zu ändern und den USB-Anschluss mit höherer Priorität auszuwählen, damit ich ihn nicht jedes Mal manuell auswählen muss, drücke ich im Bildschirm "Startup Interrupt Menu" auf F1 und navigiere dann zu:
Bootsektor
Unter x86 können Sie am einfachsten und niedrigsten Ebene einen Master Boot Sector (MBR) erstellen , bei dem es sich um eine Art Bootsektor handelt , und ihn dann auf einer Festplatte installieren.
Hier erstellen wir eine mit einem einzigen printf
Aufruf:
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img
Ergebnis:
Beachten Sie, dass auch ohne etwas zu tun bereits einige Zeichen auf dem Bildschirm gedruckt sind. Diese werden von der Firmware gedruckt und dienen zur Identifizierung des Systems.
Und auf dem T430 erhalten wir nur einen leeren Bildschirm mit einem blinkenden Cursor:
main.img
enthält Folgendes:
\364
in octal == 0xf4
in hex: die Codierung für eine hlt
Anweisung, die die CPU anweist, nicht mehr zu arbeiten.
Daher wird unser Programm nichts tun: nur starten und stoppen.
Wir verwenden Oktal, da \x
Hex-Zahlen von POSIX nicht angegeben werden.
Wir könnten diese Codierung leicht erhalten mit:
echo hlt > a.S
as -o a.o a.S
objdump -S a.o
welche Ausgänge:
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
0: f4 hlt
Aber es ist natürlich auch im Intel-Handbuch dokumentiert.
%509s
509 Räume produzieren. Muss die Datei bis Byte 510 ausfüllen.
\125\252
in oktal == 0x55
gefolgt von 0xaa
.
Dies sind 2 erforderliche magische Bytes, die die Bytes 511 und 512 sein müssen.
Das BIOS durchsucht alle unsere Datenträger nach bootfähigen Datenträgern und berücksichtigt nur bootfähige Datenträger mit diesen beiden magischen Bytes.
Wenn nicht vorhanden, behandelt die Hardware dies nicht als bootfähige Festplatte.
Wenn Sie kein printf
Meister sind, können Sie den Inhalt von main.img
mit bestätigen:
hd main.img
welches das erwartete zeigt:
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
Wo 20
ist ein Leerzeichen in ASCII.
Die BIOS-Firmware liest diese 512 Bytes von der Festplatte, speichert sie im Speicher und setzt den PC auf das erste Byte, um sie auszuführen.
Hallo Welt Boot Sektor
Nachdem wir ein minimales Programm erstellt haben, gehen wir zu einer Hallo-Welt.
Die offensichtliche Frage ist: Wie mache ich IO? Einige Optionen:
Bitten Sie die Firmware, zB BIOS oder UEFI, dies für uns zu tun
VGA: Spezieller Speicherbereich, der beim Schreiben auf den Bildschirm gedruckt wird. Kann im geschützten Modus verwendet werden.
Schreiben Sie einen Treiber und sprechen Sie direkt mit der Display-Hardware. Dies ist der "richtige" Weg, dies zu tun: leistungsfähiger, aber komplexer.
serielle Schnittstelle . Dies ist ein sehr einfaches standardisiertes Protokoll, das Zeichen von einem Host-Terminal sendet und empfängt.
Auf Desktops sieht es so aus:
Quelle .
Es ist leider auf den meisten modernen Laptops nicht verfügbar, aber es ist der übliche Weg für Entwicklungsboards, siehe die ARM-Beispiele unten.
Dies ist wirklich eine Schande, da solche Schnittstellen zum Debuggen des Linux-Kernels zum Beispiel sehr nützlich sind .
Verwenden Sie die Debug-Funktionen von Chips. ARM nennt zum Beispiel ihr Semihosting . Auf echter Hardware ist zusätzliche Hardware- und Softwareunterstützung erforderlich, auf Emulatoren kann dies jedoch eine kostenlose und bequeme Option sein. Beispiel .
Hier machen wir ein BIOS-Beispiel, da es auf x86 einfacher ist. Beachten Sie jedoch, dass dies nicht die robusteste Methode ist.
Netz
.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 stromaufwärts .
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)
}
}
Zusammenbauen und verknüpfen mit:
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
Ergebnis:
Und auf dem T430:
Getestet auf: Lenovo Thinkpad T430, UEFI BIOS 1.16. Auf einem Ubuntu 18.04-Host generierte Festplatte.
Neben den Standard-Montageanleitungen für Userland haben wir:
.code16
: Weist GAS an, 16-Bit-Code auszugeben
cli
: Software-Interrupts deaktivieren. Dadurch könnte der Prozessor nach dem erneut gestartet werdenhlt
int $0x10
: führt einen BIOS-Aufruf durch. Dies ist es, was die Zeichen einzeln druckt.
Die wichtigen Link-Flags sind:
--oformat binary
: Geben Sie rohen binären Assembly-Code aus und wickeln Sie ihn nicht in eine ELF-Datei ein, wie dies bei regulären ausführbaren Benutzerlanddateien der Fall ist.
Machen Sie sich mit dem Verschiebungsschritt des Verlinkens vertraut, um den Linker-Skript-Teil besser zu verstehen: Was machen Linker?
Cooler x86 Bare-Metal-Programme
Hier sind einige komplexere Bare-Metal-Setups, die ich erreicht habe:
Verwenden Sie C anstelle von Assembly
Zusammenfassung: Verwenden Sie GRUB Multiboot, um viele lästige Probleme zu lösen, an die Sie nie gedacht haben. Siehe den folgenden Abschnitt.
Die Hauptschwierigkeit bei x86 besteht darin, dass das BIOS nur 512 Bytes von der Festplatte in den Speicher lädt und Sie diese 512 Bytes wahrscheinlich in die Luft jagen, wenn Sie C verwenden!
Um dies zu lösen, können wir einen zweistufigen Bootloader verwenden . Dadurch werden weitere BIOS-Aufrufe ausgeführt, die mehr Bytes von der Festplatte in den Speicher laden. Hier ist ein minimales Beispiel für eine Assembly der Stufe 2 von Grund auf mit den BIOS-Aufrufen von int 0x13 :
Alternative:
- Wenn Sie es nur benötigen, um in QEMU zu arbeiten, aber keine echte Hardware, verwenden Sie die
-kernel
Option, mit der eine gesamte ELF-Datei in den Speicher geladen wird. Hier ist ein ARM-Beispiel, das ich mit dieser Methode erstellt habe .
- Für den Raspberry Pi übernimmt die Standard-Firmware das Laden von Bildern aus einer ELF-Datei mit dem Namen
kernel7.img
, ähnlich wie bei QEMU -kernel
.
Hier ist nur zu Bildungszwecken ein einstufiges minimales C-Beispiel :
Haupt 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");
};
}
Eintrag.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 = .;
}
Lauf
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
C Standardbibliothek
Es macht mehr Spaß, wenn Sie jedoch auch die C-Standardbibliothek verwenden möchten, da wir nicht über den Linux-Kernel verfügen, der einen Großteil der Funktionen der C-Standardbibliothek über POSIX implementiert .
Einige Möglichkeiten, ohne auf ein vollwertiges Betriebssystem wie Linux zuzugreifen, sind:
Schreibe dein Eigenes. Am Ende sind es nur ein paar Header und C-Dateien, oder? Richtig??
Newlib
Detailliertes Beispiel unter: /electronics/223929/c-standard-libraries-on-bare-metal/223931
Newlib implementiert alle langweiligen Nicht-OS - spezifische Dinge für Sie, zum Beispiel memcmp
, memcpy
usw.
Anschließend erhalten Sie einige Stubs, mit denen Sie die Syscalls implementieren können, die Sie selbst benötigen.
Zum Beispiel können wir exit()
ARM durch Semihosting implementieren mit:
void _exit(int status) {
__asm__ __volatile__ ("mov r0, #0x18; ldr r1, =#0x20026; svc 0x00123456");
}
wie in diesem Beispiel gezeigt .
Sie können beispielsweise printf
zu den UART- oder ARM-Systemen umleiten oder exit()
mit Semihosting implementieren .
eingebettete Betriebssysteme wie FreeRTOS und Zephyr .
Mit solchen Betriebssystemen können Sie in der Regel die vorbeugende Planung deaktivieren, sodass Sie die volle Kontrolle über die Laufzeit des Programms haben.
Sie können als eine Art vorimplementierte Newlib angesehen werden.
GNU GRUB Multiboot
Bootsektoren sind einfach, aber nicht sehr praktisch:
- Sie können nur ein Betriebssystem pro Festplatte haben
- Der Ladecode muss sehr klein sein und in 512 Bytes passen
- Sie müssen viel selbst starten, z. B. in den geschützten Modus wechseln
Aus diesen Gründen hat GNU GRUB ein bequemeres Dateiformat namens Multiboot erstellt.
Minimales Arbeitsbeispiel: https://github.com/cirosantilli/x86-bare-metal-examples/tree/d217b180be4220a0b4a453f31275d38e697a99e0/multiboot/hello-world
Ich verwende es auch in meinem GitHub-Beispiel-Repo , um alle Beispiele problemlos auf realer Hardware ausführen zu können, ohne den USB millionenfach zu brennen.
QEMU-Ergebnis:
T430:
Wenn Sie Ihr Betriebssystem als Multiboot-Datei vorbereiten, kann GRUB es in einem regulären Dateisystem finden.
Dies ist, was die meisten Distributionen tun, indem sie OS-Images unterlegen /boot
.
Multiboot-Dateien sind im Grunde eine ELF-Datei mit einem speziellen Header. Sie werden von GRUB unter folgender Adresse angegeben: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html
Sie können eine Multiboot-Datei mit in eine bootfähige Festplatte verwandeln grub-mkrescue
.
Firmware
In Wahrheit ist Ihr Bootsektor nicht die erste Software, die auf der CPU des Systems ausgeführt wird.
Was tatsächlich zuerst läuft, ist die sogenannte Firmware , bei der es sich um eine Software handelt:
- hergestellt von den Hardwareherstellern
- normalerweise geschlossene Quelle, aber wahrscheinlich C-basiert
- im Nur-Lese-Speicher gespeichert und daher ohne Zustimmung des Anbieters schwieriger / unmöglich zu ändern.
Bekannte Firmwares sind:
- BIOS : alte allgegenwärtige x86-Firmware. SeaBIOS ist die Standard-Open-Source-Implementierung, die von QEMU verwendet wird.
- UEFI : BIOS-Nachfolger, besser standardisiert, aber leistungsfähiger und unglaublich aufgebläht.
- Coreboot : Der edle Cross-Source-Open-Source-Versuch
Die Firmware macht Dinge wie:
Durchlaufen Sie jede Festplatte, jeden USB-Stick, jedes Netzwerk usw., bis Sie etwas Bootfähiges finden.
Wenn wir QEMU ausführen, -hda
heißt es, dass main.img
es sich um eine an die Hardware angeschlossene Festplatte hda
handelt, die als erste ausprobiert wird und verwendet wird.
Laden Sie die ersten 512 Bytes in die RAM-Speicheradresse 0x7c00
, legen Sie den RIP der CPU dort ab und lassen Sie ihn laufen
Zeigen Sie Dinge wie das Startmenü oder BIOS-Druckaufrufe auf dem Display an
Die Firmware bietet betriebssystemähnliche Funktionen, von denen die meisten Betriebssysteme abhängen. Beispielsweise wurde eine Python-Teilmenge für die Ausführung unter BIOS / UEFI portiert: https://www.youtube.com/watch?v=bYQ_lq5dcvM
Es kann argumentiert werden, dass Firmwares nicht von Betriebssystemen zu unterscheiden sind und dass Firmware die einzige "echte" Bare-Metal-Programmierung ist, die man machen kann.
Wie dieser CoreOS-Entwickler es ausdrückt :
Der schwierige Teil
Wenn Sie einen PC einschalten, werden die Chips, aus denen der Chipsatz besteht (Northbridge, Southbridge und SuperIO), noch nicht ordnungsgemäß initialisiert. Obwohl das BIOS-ROM so weit wie möglich von der CPU entfernt ist, kann die CPU darauf zugreifen, da dies erforderlich ist, da die CPU sonst keine Anweisungen zum Ausführen hätte. Dies bedeutet nicht, dass das BIOS-ROM vollständig zugeordnet ist, normalerweise nicht. Es wird jedoch gerade genug zugeordnet, um den Startvorgang in Gang zu setzen. Alle anderen Geräte, vergiss es einfach.
Wenn Sie Coreboot unter QEMU ausführen, können Sie mit den höheren Schichten von Coreboot und mit Nutzdaten experimentieren, aber QEMU bietet wenig Gelegenheit, mit dem Startcode auf niedriger Ebene zu experimentieren. Zum einen funktioniert RAM von Anfang an.
Post BIOS-Anfangszustand
Wie viele Dinge in der Hardware ist die Standardisierung schwach, und eines der Dinge, auf die Sie sich nicht verlassen sollten, ist der Anfangszustand der Register, wenn Ihr Code nach dem BIOS ausgeführt wird.
Tun Sie sich selbst einen Gefallen und verwenden Sie einen Initialisierungscode wie den folgenden: https://stackoverflow.com/a/32509555/895245
Register mögen %ds
und %es
haben wichtige Nebenwirkungen, daher sollten Sie sie auf Null setzen, auch wenn Sie sie nicht explizit verwenden.
Beachten Sie, dass einige Emulatoren besser als echte Hardware sind und Ihnen einen guten Ausgangszustand bieten. Wenn Sie dann auf echter Hardware laufen, bricht alles zusammen.
El Torito
Format, das auf CDs gebrannt werden kann: https://en.wikipedia.org/wiki/El_Torito_%28CD-ROM_standard%29
Es ist auch möglich, ein Hybrid-Image zu erstellen, das entweder auf ISO oder USB funktioniert. Dies kann mit grub-mkrescue
( Beispiel ) erfolgen und wird auch vom Linux-Kernel bei make isoimage
Verwendung durchgeführt isohybrid
.
ARM
In ARM sind die allgemeinen Ideen dieselben.
Es gibt keine allgemein verfügbare halbstandardisierte vorinstallierte Firmware wie das BIOS, die wir für die E / A verwenden können. Die zwei einfachsten Arten von E / A, die wir ausführen können, sind:
- Seriennummer, die auf Devboards weit verbreitet ist
- Blink die LED
Ich habe hochgeladen:
Einige Unterschiede zu x86 sind:
IO erfolgt durch direktes Schreiben an magische Adressen, es gibt keine in
und out
Anweisungen.
Dies wird als speicherabgebildete E / A bezeichnet .
Für echte Hardware wie den Raspberry Pi können Sie die Firmware (BIOS) selbst zum Disk-Image hinzufügen.
Das ist gut so, denn dadurch wird die Aktualisierung dieser Firmware transparenter.
Ressourcen