1 Punkte von GN⁺ 2024-07-16 | 1 Kommentare | Auf WhatsApp teilen
  • Ausgehend von einem 512-Byte-Bootsektor wird der minimale Ablauf eines Bootloaders Schritt für Schritt aufgebaut, der eine x86_64-CPU vom 16-Bit-Real-Mode in den 64-Bit-Long-Mode bringt
  • Nach einer ersten Ausgabekontrolle per BIOS-Interrupt wird mit nasm, ld, objcopy und QEMU überprüft, ob das Boot-Image tatsächlich ausgeführt wird
  • Wegen der Größenbeschränkung des Bootsektors wird in Stage 1/Stage 2 aufgeteilt; vor dem Wechsel in den Protected Mode wird der nächste Code per BIOS-int 0x13 von der Disk geladen
  • Ab dem 32-Bit-Protected-Mode können BIOS-Routinen nicht mehr verwendet werden, daher sind Hardware-Initialisierungen wie GDT, Flat Segmentation und direkte Ausgabe in den VGA-Puffer nötig
  • Der Wechsel in den 64-Bit-Long-Mode erfordert korrekt eingerichtete Seitentabellen, PAE, EFER.LME, cr0.PG und eine GDT für 64 Bit; anschließend kann freestanding-C-Code wie ein Kernel aufgerufen werden

Die 16-Bit-Umgebung beim Start über BIOS

  • Eine x86-CPU befindet sich nach dem Reset im Real Mode, die Standard-Operandgröße beträgt 16 Bit
  • Der Real Mode erzeugt per Segmentierung einen 20-Bit-Adressraum und kann maximal 1 MB Speicher adressieren
  • Der erste von BIOS ausgeführte Code befindet sich im Bootsektor der Disk
    • BIOS sucht nach einer Disk, deren erster Sektor mit der Magic Number 0xaa55 endet
    • Dieser Sektor wird an die Speicheradresse 0x7c00 geladen
  • Da BIOS nur 512 Byte zur Verfügung stellt, konzentriert sich dieser Code auf die Bootstrap-Rolle, den restlichen Bootloader nachzuladen
  • BIOS-Routinen können nur verwendet werden, solange man im Real Mode bleibt

Voraussetzungen und Build-Umgebung

Einen Bootsektor erstellen und die Ausführung prüfen

  • Der erste Bootsektor gibt über BIOS-Routinen "Hello, world!" aus und hält dann mit hlt und einer Schleife an
  • Für die Ausgabe des Strings wird der BIOS-Video-Service int 0x10 mit ah = 0x0e verwendet
  • Das Makefile erzeugt mit nasm ein Objekt, linkt es per Linker-Script und erstellt mit objcopy -O binary ein rohes Boot-Image
  • make boot startet das Image in QEMU
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • Das Linker-Script ist so konfiguriert, dass der Bootsektor relativ zu 0x7c00 platziert wird
    • Der MEMORY-Eintrag boot_sector hat origin 0x7c00 und length 512
    • Der Abschnitt .bootsign fügt an Position 0x7c00 + 510 die Werte 0x55, 0xaa ein
  • Man könnte Offset und Magic Number auch direkt im Bootsektor-Assembly behandeln, hier übernimmt diese Aufgabe jedoch das Linker-Script

Wie Stage 1 Stage 2 von der Disk lädt

  • Stage 1 ist der von BIOS geladene Bootsektor-Code; das Ziel ist, Stage 2 in den Speicher zu laden
  • Stage 2 enthält den Code für den Übergang vom 16-Bit-Real-Mode in den 32-Bit-Protected-Mode
  • Nach dem Eintritt in den Protected Mode können BIOS-Routinen nicht mehr verwendet werden, daher muss das Lesen der Disk-Sektoren vor dem Wechsel abgeschlossen sein
  • Für den Disk-Zugriff wird der BIOS-Disk-Service int 0x13 verwendet
    • ah = 0x42 steht für die BIOS-Funktion Extended Read
    • dl = 0x80 ist die Laufwerksnummer
    • Das Disk Address Packet enthält Anzahl der zu lesenden Sektoren, Zieladresse und Startsektor
  • Im Beispiel werden mit READ_SECTORS_NUM equ 64 insgesamt 64 Sektoren gelesen
    • Da der Bootsektor Sektor 0 ist, beginnt Stage 2 ab Sektor 1
    • Die Zieladresse ist BOOT_LOAD_ADDR + SECTOR_SIZE, also 0x7c00 + 512
  • Im Code bleibt eine provisorische Behandlung dafür erhalten, dass eventuell weniger Sektoren als angefordert gelesen werden
  • Stage 2 kopiert anfangs print_string für den Real Mode, gibt "Hello from stage 2" aus und prüft so, ob der Sprung aus Stage 1 korrekt erfolgt

In den 32-Bit-Protected-Mode wechseln

  • Um in den Protected Mode zu wechseln, muss zuerst eine Global Descriptor Table (GDT) definiert werden
  • Im Protected Mode wird Segmentierung grundsätzlich für den Speicherschutz verwendet
  • Im 64-Bit-Long-Mode ist Paging erforderlich, doch für den vorherigen Eintritt in den Protected Mode muss zunächst die Segmentierung eingerichtet werden
  • Die Beispiel-GDT folgt dem Flat Model aus dem Intel-Handbuch
    • Es gibt ein Code-Segment und ein Daten-Segment
    • Beide Segmente werden auf den gesamten linearen Adressraum abgebildet
    • Da dies nur ein Zwischenschritt zum Long Mode ist, wird das einfachste Modell verwendet
  • Die GDT ist eine zusammenhängende Struktur im Speicher
    • Am Anfang steht ein Null-Deskriptor, um ungültige Übersetzungen abzufangen
    • Danach folgen die Deskriptoren für Code-Segment und Daten-Segment
  • Der Wechsel erfolgt in folgender Reihenfolge
    • cli deaktiviert Interrupts
    • lgdt [gdt32_pseudo_descriptor] lädt Adresse und Länge der GDT in den GDTR
    • cr0.PE, also Bit 0 von cr0, aktiviert den Protected Mode
    • Ein Far Jump leert die Instruction Pipeline und aktualisiert cs auf das neue Code-Segment
  • Nach dem Eintritt in den Protected Mode haben die bisherigen Segmentwerte keine Bedeutung mehr; daher werden ds, ss, es, fs, gs auf den neuen Daten-Segment-Selektor gesetzt
  • Um Interrupts anschließend wieder zu aktivieren, sind weitere Schritte erforderlich

Ausgabe auf den Bildschirm ohne BIOS

  • Im Protected Mode können BIOS-Routinen nicht mehr aufgerufen werden
  • Die String-Ausgabe erfolgt nun durch direktes Schreiben in den VGA-Puffer
  • print_string32 schreibt Zeichen und Farb-Bytes an die Adresse 0xb8000
    • Der Farbwert ist 0xf
    • Jede Zeichenzelle verwendet 2 Byte
  • Diese Ausgabefunktion ist sehr einfach gehalten, daher erscheinen Meldungen immer links oben auf dem Bildschirm

Seitentabellen für den Eintritt in den Long Mode

  • Der IA-32e mode in den Intel-Dokumenten entspricht dem Long Mode im AMD64-Handbuch
  • Für den Wechsel in den Long Mode muss sich die CPU im Protected Mode befinden, außerdem muss Paging aktiviert sein
  • Für die Paging-Konzepte wird auf Introduction to Paging und OSTEP verwiesen
  • Im Long Mode mit aktiviertem PAE werden vierstufige Seitentabellen verwendet
  • build_page_table erzeugt an der angegebenen Adresse eine vierstufige Seitentabelle
    • Die Seitengröße ist 0x1000
    • Jede Seitentabelle ist 0x1000 groß
    • Die Anzahl der Einträge beträgt 512
    • Zunächst werden vier Tabellen mit 0 initialisiert, sodass alle Einträge auf not present stehen
    • Danach werden die ersten Einträge von PML4 → PDP → PD → Page Table miteinander verknüpft
    • Auf der untersten Ebene der Seitentabellen werden 512 Einträge gesetzt

GDT für 64 Bit und Reihenfolge des Wechsels in den Long Mode

  • Obwohl Paging den virtuellen Adressraum und die Rechteverwaltung übernimmt, wird auch im Long Mode weiterhin eine GDT benötigt
  • Die GDT für 64 Bit folgt ebenfalls dem Flat Model und ist fast identisch mit der GDT für den Protected Mode
  • Der Unterschied liegt in den für den Long Mode relevanten Bit-Einstellungen
    • Beim Code-Segment wird das Flag für ein 64-Bit-Code-Segment gesetzt
    • Wenn dieses Flag gesetzt ist, muss das Bit für die Standard-Operationsgröße 0 sein
  • Der Wechsel in den Long Mode verläuft dann wie folgt
    • An Adresse 0x1000 wird eine vierstufige Seitentabelle erzeugt
    • Die Adresse der PML4-Tabelle wird in cr3 eingetragen
    • Bit 5 von cr4 aktiviert PAE
    • Das MSR 0xc0000080 wird gelesen und EFER.LME, also Bit 8, gesetzt
    • Durch Setzen des PG-Flags, also Bit 31 von cr0, wird Paging aktiviert
    • Die 64-Bit-GDT wird per lgdt geladen
    • Ein Far Jump in das 64-Bit-Code-Segment bringt die CPU in den 64-Bit-Modus
  • Unmittelbar nach dem Aktivieren von Paging befindet sich die CPU zunächst im IA-32e Compatibility Mode; erst der Sprung mit einer GDT, deren 64-Bit-Segment-Flag gesetzt ist, schaltet in den 64-Bit-Modus um
  • Die Erfolgsmeldung wird über den VGA-Puffer links oben auf dem Bildschirm angezeigt

Freestanding-C-Code aufrufen

  • Sobald der 64-Bit-Long-Mode erreicht ist, kann freestanding-C-Code aufgerufen werden
  • kernel.c löscht den VGA-Puffer bei 0xb8000 und gibt "Hello from C" aus
  • Auf der Assembly-Seite gibt start_long_mode nach der 64-Bit-String-Ausgabe extern _start_kernel an und ruft _start_kernel auf
  • Das Linker-Script teilt den Speicherbereich in drei Teile auf
    • boot_sector: 0x7c00, Länge 512
    • stage2: 0x7e00, Länge 512
    • kernel: 0x8000, Länge 0x10000
  • Die Abschnitte .text, .data, .rodata, .bss werden im Kernel-Bereich platziert
  • Das Makefile wird so erweitert, dass sowohl Assembly als auch C gebaut werden
    • Der C-Compiler ist gcc
    • Wichtige CFLAGS sind -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • Der vollständige Beispielcode wird über einen Download-Link bereitgestellt

1 Kommentare

 
GN⁺ 2024-07-16
Hacker-News-Kommentare
  • Man kann mit viel weniger Code direkt in den Long Mode wechseln, ohne erst durch den Protected Mode zu gehen: https://wiki.osdev.org/Entering_Long_Mode_Directly
    Es gab einen Bootloader für einen kleinen 64-Bit-Kernel, der auf diese Weise gebaut war, und selbst mit Code zum Einlesen des Kernels von der Festplatte und zum Setzen des VESA-Modus passte alles noch bequem in den Bootsektor. Ein Loader der zweiten Stufe war ebenfalls nicht nötig

    • Ich frage mich, wie man das alles in 512 Byte unterbringt. Ein echtes Dateisystem, in dem der Kernel wie eine normale Datei irgendwo auf der Platte liegen kann, dürfte da kaum möglich sein, und allein die Behandlung von Dateifragmentierung würde wohl schon deutlich mehr als 512 Byte brauchen
    • Wenn man einfach https://limine-bootloader.org/ verwendet, wird alles viel einfacher. Man muss Real Mode nicht anfassen, bei SMP ist es genauso, der Kernel wird automatisch mit Higher-Half-Mapping geladen, und es funktioniert auch auf aarch64 und riscv64
    • Stimmt zwar, aber wenn man auch noch die Partitionstabelle unterbringen und moderne AHCI-Controller und SATA unterstützen will, wird der Platz für den Bootloader noch knapper und Optimierung nötig. In diesem Fall kann man nicht alle 510 Byte für den Loader verwenden, sondern deutlich weniger, und wenn man auch noch gültige Partitionseinträge füllen muss, kann man nicht einmal die Bytes innerhalb der Tabelle benutzen, was es noch schwieriger macht
      Wenn man wirklich moderne Festplatten verwenden will, sollte man statt MBR besser GPT betrachten. Damit lassen sich große Platten über 2 TB nutzen, ohne an die Grenzen der Partitionstabelle zu stoßen. UEFI beseitigt diese Probleme und erlaubt ohne besondere Schwierigkeiten ein ordentliches Plattenlayout
      Um in den 64-Bit-Modus zu wechseln, braucht man den Protected Mode nicht. BIOS sollte man allerdings besser nicht verwenden. Es ist unordentlich und macht alles nur umständlicher
      Besser ist es, UEFI mit EDK2 oder GnuEFI zu verwenden; beides ist recht einfach und angenehm zu implementieren. Es dauert etwas, bis man sich an die Grundkonzepte von UEFI gewöhnt hat, aber mit Beispielprojekten auf GitHub lässt sich die Struktur leicht nachvollziehen. EDK hat unschöne Dinge wie .dec- und .inf-Dateien, und bei GnuEFI muss man in Header-Dateien nach Funktionen suchen, aber das ist immer noch viel besser als die unklar spezifizierten BIOS-Schnittstellen. Auf echter Hardware kann man nicht einmal davon ausgehen, dass int 0x10, int 0x15 usw. korrekt vorhanden sind
      Auf UEFI-Systemen kann man von einer stabilen Minimalbasis ausgehen, und Hardware- oder Plattformfunktionen lassen sich sinnvoll aufzählen. Außerdem hat UEFI die Plattform bereits weitgehend eingerichtet, sodass man im OS-Loader nicht mehr viel initialisieren muss, sondern direkt die Komponenten laden kann, die zum Design von OS, Treibern und Kernel passen. Man holt sich die Memory Map, greift auf das EFI-Dateisystem zu und liest die benötigten Dinge ein
    • Ich wusste nicht, dass so etwas möglich ist. Wenn das Ziel einfach nur ist, in den Long Mode zu gelangen, frage ich mich, warum man überhaupt erst durch den Protected Mode gehen sollte
  • Der 80286 hatte das 16-Bit-Register Machine Status Word (MSW), und der 80386 erweiterte es zum 32-Bit-Register CR0. Später kam für den 64-Bit-Long-Mode das EFER-MSR hinzu, und CR0 wurde auf 64 Bit erweitert, aber bis heute werden in CR0 nur 11 Bits genutzt, und auch in EFER sind nur 8 Bits aktiv
    Ich frage mich, warum Intel/AMD die freien Bits in bestehenden Registern nicht einfach verwendet haben und stattdessen zweimal neue Entscheidungen getroffen haben: https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • Wahrscheinlich wollte man die Abwärtskompatibilität robuster machen. Software könnte Werte reservierter Bits voraussetzen oder hineinschreiben. Die Bitvergabe in solchen Hardware-Registern ist ohnehin ziemlich willkürlich, und die Verwendung höherer Bits verursacht praktisch keine Kosten
    • Wenn ich es in einem Wort beantworten müsste, dann wohl Bürokratie. Große Organisationen treffen insgesamt nicht besonders oft gute Entscheidungen, und dadurch entstehen viele unsinnige Festlegungen
      Dass CR1 und CR5 bis CR7 weiterhin reserviert sind, während CR8 eingeführt wurde, wirkt ähnlich
  • Am unnötig kompliziertesten an diesem Artikel wirken Makefile und Linker-Skript. NASM unterstützt die Ausgabe flacher Binärdateien, aber offenbar hielt man das für zu „hacky“

    • Meiner Meinung nach ist ein Linker-Skript viel lesbarer und leichter nachvollziehbar als flaches NASM. Das gilt besonders dann, wenn es mehrere Quelldateien gibt
    • Absolut richtig. Später werden Makefile und Linker-Skript zu wichtigen Problemquellen, aber wenn man ein flaches Binärformat bauen will, dann sollte man eben einfach ein flaches Binärformat bauen. Man muss das nicht unnötig aufblasen
      Früher hatte mein OS aus Spaß eine Datei namens make.sh, um genau das zu verspotten. Heute verwende ich schicke Dinge wie „Dateiformate“, daher sind -fbin und --oformat=binary nur noch vergängliche Erinnerungen. Ich habe lange versucht, Daten-C-Dateien und Code-C-Dateien zu trennen, als Binärdaten auszugeben und daraus dann ein Monster zusammenzubauen, aber Linking und Laden wurden viel zu schwierig. Es ist besser, einfach ELF oder PE zu benutzen; offenbar sind diese Formate genau für so etwas da
  • Sieht cool aus und wie eine gute Übung, aber ich weiß nicht, ob es nützlich ist. Ich frage mich, ob es wenigstens so etwas wie eine Fisher-Price-artige UX gibt, mit der man Einstellungen zur Laufzeit prüfen oder ändern kann
    Booten ist der Weg vom Minimalmodus über den Einzelbenutzermodus und den Wiederherstellungsmodus hin zu einem Zustand im Flug
    Ich benutze seit den Tagen von Xenix/DOS zusammen mit Microsoft-Produkten auch Unix, also wohl seit ungefähr 40 Jahren. Ich frage mich, wie viel Fortschritt es in dieser Zeit wirklich gegeben hat
    Linux habe ich ebenfalls seit der schwedischen Version benutzt, also seit dem ersten Release, und GNU 0.1 habe ich auch ausprobiert
    Ich entschuldige mich dafür, Xenix als Unix bezeichnet zu haben. Xenix war schon kurz nach seiner Veröffentlichung bis zu seinem Niedergang ein chaotisches Me-too-Produkt, das am liebsten bereits Vergangenheit gewesen wäre
    Microsoft wirkt auf mich wie ein Unternehmen, das keine Produkte veröffentlicht, sondern die Katzentoilette über seinen Kunden ausleert. Jüngere Beispiele sind Copilot und 22H2
    Wenn man sich ansieht, wie sich F1-Autos, Bleistifte und Taschenrechner entwickelt haben, fragt man sich, wie nah wir einem brauchbaren Ideal eigentlich schon sind
    Ich frage mich auch, warum Bootloader nicht im statischen Kernel-Modus arbeiten. Früher war das so, und vor Kurzem hat jemand vorgeschlagen, wieder dorthin zurückzukehren, und ich habe zugestimmt

  • https://wiki.osdev.org/A20_Line

  • Es überrascht, wie viele Schritte nötig sind, um die CPU in den richtigen Modus zu versetzen, obwohl sie alle unnötig wirken. Die meisten Prozeduren scheinen nur wegen der Abwärtskompatibilität erforderlich zu sein
    Ich frage mich, ob Intel nicht von Anfang an ein Flag oder einen Befehl hätte anbieten können, um direkt im richtigen Modus zu starten, oder ob man die Abwärtskompatibilität nicht hätte abschaffen können
    Ich meine mich zu erinnern, dass es bei ARM64 teilweise ähnliche Probleme gab. Ich frage mich, ob es eine CPU gibt, die von vornherein als 64-Bit-System entworfen wurde, keine Abwärtskompatibilität braucht und standardmäßig im gewünschten Zustand startet. War das vielleicht Ziel oder Design von Itanium?

    • Das ist der Zweck von Intels vorgeschlagenem X86S

      X86S is a legacy-reduced-OS ISA that removes outdated execution modes and operating system ISA.
      The presence of the X86S ISA is enumerated by a single, main CPUID feature LEGACY_REDUCED_ISA in CPUID 7.1.ECX[2] which implies all the ISA removals described in this document. A new, 64-bit “start-up” interprocessor interrupt (SIPI) has a separate CPUID feature flag.
      [0] https://cdrdv2.intel.com/v1/dl/getContent/776648 [Achtung PDF]

    • Intel hat das mit dem 80376 versucht, aber es hat nicht funktioniert: https://en.wikipedia.org/wiki/Intel_80376
      Bei Itanium, also Itanic, war es genauso
      Abwärtskompatibilität ist genau der entscheidende Grund, warum man x86 statt ARM, MIPS oder RISC-V wählt. Leider scheinen manche Leute bei Intel und AMD das nicht zu verstehen
    • UEFI gibt es bereits. Man legt ein Windows-ähnliches Binärprogramm in einen Ordner auf einer Partition, und es wird in einer 64-Bit-Hosting-Umgebung ausgeführt. Natürlich gibt es auch unzählige Bootloader, die einem diese Arbeit abnehmen
    • Ich weiß nicht, worin bei arm64 das Problem bestehen soll
  • Großartiges Projekt. Wenn sich UEFI-Befürworter hier fragen, warum man überhaupt noch eine neue Bootloader-Methode bauen würde, dann entgeht ihnen wohl der eigentliche Grund, warum Leute so etwas machen
    Ganz wie der Autor am Ende schreibt: „Wenn du bis hierher mitgekommen bist, großartig“ — genau das ist es wirklich

  • Ich frage mich, wie lange es UEFI inzwischen schon gibt. Schade, dass man BIOS nicht zusammen mit dem Long Mode abgeschafft hat

    • BIOS ist bereits deprecated. Auf neuen Mainboards wird diese Funktion standardmäßig über UEFI emuliert und nicht weiter ausgebaut
      Deprecated bedeutet nicht, dass es entfernt wurde, sondern dass es nicht mehr aktualisiert oder weiterentwickelt wird, weil das Ziel seine Abschaffung ist
  • Ich frage mich, ob dieser Bootvorgang auch unter EFI/UEFI funktioniert. Falls ja, frage ich mich auch, ob der UEFI-Supervisor die Wechsel zwischen Real Mode, Protected Mode und Long Mode emuliert oder ob sie auf echter Hardware ausgeführt werden

    • Nein. UEFI-Firmware stellt dem UEFI-Bootloader eine völlig andere Umgebung bereit als die Legacy-BIOS-Umgebung, also den Real-Address-Mode. UEFI-Firmware auf modernen Systemen geht direkt in den 64-Bit-Long-Mode und richtet außerdem ein GDT mit flachem Speichermodell sowie Identity-Mapped Paging ein
      Hier ist beschrieben, wie man einen UEFI-Bootloader für ein Hobby-OS baut: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • Ich frage mich, ob das bei ARM einfacher ist

    • Einfacher in dem Sinn, dass jeder Board-Hersteller machen kann, was er will. Für die Board-Hersteller ist es einfach, für alle anderen wird es furchtbar kompliziert
    • Ja. Bootloader sind weiterhin komplex, aber es braucht weniger Legacy-Konfiguration. Wenn man statt auf BIOS auf UEFI zielt, wird es allerdings auch auf x86 deutlich einfacher
    • Sicher bin ich nicht, und ich würde es auch nicht erwarten. Ich beschäftige mich gerade intensiv mit RISC-V, und dort scheint es Anlass zur Hoffnung zu geben