Mein neues längerfristiges Lernprojekt wird es sein, ein einfaches Betriebssystem zu programmieren.

Dazu muss ich zunächst einen Bootloader programmieren. Der Bootloader ist das erste echt austauschbare Programm, das beim Starten des PC ausgeführt wird.

Die Startreihenfolge ist in etwa so:

  1. BIOS
  2. Bootloader
  3. Betriebssystem-Kern

Ein ziemlich bekannter Bootloader ist GRUB. Diesen kann man als Einstiegspunkt für sein eigenes Betriebssystem verwenden, wenn man sich an seine Spezifikation hält, aber um wirklich alles zu verstehen, ist es sicherlich sinnvoll, als erstes einen eigenen kleinen Bootloader zu programmieren. So schwer ist es gar nicht, wenn man bereits Assembler beherrscht.

Ein absolut nutzloser Bootloader

Zunächst soll ein kleiner Bootloader programmiert werden, der erstmal gar nichts tut. Später wird er dann “Hello World” ausgeben, aber immer noch nichts tun, weil es noch kein anderes Programm zu laden gibt.

Was muss man im Bootloader alles tun? Wenn der Bootloader gar nichts tun soll, wie hier, eigentlich nicht viel. Ein paar wichtige Regeln will ich trotzdem gleich vorstellen. Ich verwende übrigens die Intel-Notation und als Assembler nasm.

Zunächst mal starten auch heutige PCs noch - wegen der allseits verhassten Abwärtskompabilität - im 16-Bit-Real-Modus. Dies müssen wir dem Assembler mitteilen, damit er darauf achtet, dass nur mit 16-Bit gearbeitet wird. In den 32- oder gar 64-Bit-Modus muss man manuell wechseln, aber das werde ich erst später erklären.

Außerdem muss man dem Assembler mitteilen, dass das Stück Code, das er gerade assembliert, später an Stelle 0x7C00 im RAM liegen wird. Warum gerade 0x7C00 und woher weiß man das? Das ist die fest vorgegebene Stelle im RAM, in die der Bootloader geschoben wird, sobald das BIOS ihn von der Diskette/CD/Festplatte liest.

Beides steht ganz oben:

[BITS 16]
[ORG 0x7C00]

Adressierung im Real Mode

Anschließend kann man sich um die Segment-Register kümmern. Der 16-Bit-Real-Mode arbeitet mit einer zweigeteilten Adresse. Mit 16 Bit könnte man an sich 2^16 Byte adressieren, also 32kB. Da das auch für damalige Verhältnisse zu wenig war, hat man sich etwas überlegt, wie man bis zu 1MB adressieren kann. Dazu wird die Adresse so aus zwei 16-Bit-Wörtern zusammengesetzt, dass man insgesamt 2^20 Byte adressieren kann. Dies schafft man, indem man dafür sorgt, dass exakt 4 Bit sich nicht überlappen. Man verwendet dazu ein 16-Bit-Segment und einen 16-Bit-Offset und berechnet: [latex]Segment * 16 + Offset = Adresse[/latex]. Ist beispielsweise das Segment 0x0010 gegeben und der Offset 0x0002, so ergibt das die 20-Bit-Adresse 0x00102. Hiermit kann man natürlich die gleichen Adressen auf verschiedene Art angeben. Wie ich gelesen habe, war das damals so gewollt, warum kann ich aber leider nicht erklären.

Damit können nun auf jeden Fall zu Beginn des Programms gleich die Segmentregister richtig initialisiert werden.

xor ax, ax ; setze ax auf 0 = erstes Segment
mov ss, ax ; Stack-Segment
mov ds, ax ; Data-Segment
mov es, ax ; Extra-Segment

Nachdem diese schöne Vorarbeit getan ist, kann auch schon die Endlosschleife installiert werden, damit die Codeausführung nicht in Bereichen ausgeführt wird, die man nicht mehr unter Kontrolle hat (d.h. nach dem Bootloader noch auf der Diskette stehende Daten).

jmp $

Hierbei steht $ für die aktuelle Speicherstelle. D.h. es wird immer wieder zum Jump-Befehl gesprungen.

Dieses Symbol wird auch gleich wieder benötigt, weil ein Bootloader exakt 512 Byte groß sein muss und mit den Bytes 0x55AA enden muss. Da Intel Little Endian verwendet, muss dies allerdings las 0xAA55 angegeben werden. Das Symbol $$ steht für die Speicherstelle des Beginns des Codesegments, also beim Bootloader 0x7C00. Den Bereich bis zum Ende des Bootloaders kann man bspw. mit Halt-Befehlen auffühlen, üblich sind aber auch Nullen.

times 510-($-$$) hlt
dw 0xAA55

Die gesamte Assembler-Datei sieht nun also so aus:

; boot.asm

[BITS 16]
[ORG 0x7C00]

xor ax, ax ; setze ax auf 0 = erstes Segment
mov ss, ax ; Stack-Segment
mov ds, ax ; Data-Segment
mov es, ax ; Extra-Segment

jmp $

times 510-($-$$) hlt
dw 0xAA55

Diesen Bootloader kann man nun mit nasm kompilieren und in QEMU ausführen:

nasm -f bin -o boot.bin boot.asm
qemu-system-i386 boot.bin

Von der Sinnlosigkeit zu etwas Text

Dieser Bootloader tut allerdings noch nichts. Er kann allerdings schon ziemlich schnell aufgebessert werden, indem man ihn etwas ausgeben lässt.

Vor dem Jump-Befehl kann man dazu ein paar neue Befehle zur Ausgabe eines Asterisk einbauen.

mov ah, 0eh  ; output on screen
mov al, 2ah  ; asterisk
int 10h      ; interrupt for video services

Hier wird nun ein Interrupt aufgerufen, genauer gesagt der BIOS-Interrupt 10h für alle Arten von Video-Diensten. Da dieser Interrupt sehr viele verschiedene Video-Dienste anbietet, muss man ihm im Register AH zusätzlich noch einen Code übergeben. Das h hinter den Zahlen steht für Hexadezimalzahlen - hat also genau dieselbe Bedeutung wie ein vorangestelltes 0x. Da man den Interrupt aber wohl als 10h bezeichnet und in der Intel-Syntax das h recht üblich scheint, habe ich es auch verwendet.

An dieser Stelle sollte ich vielleicht erklären, dass man die üblichen General-Purpose-Register mit 16 Bit AX, BX, CX und DX auch in obere und untere Hälfte mit je 8 Bit unterteilen kann. Diese nennt man dann AH (A high), AL (A low) und so weiter. Aneinandergehängt ergeben AH und AL wieder AX. D.h. wenn man einen Wert in AX schreibt, ist auch AH überschrieben.

Statt der beiden Einzelbefehle für AH und AL oben, könnte man auch etwas kürzer (aber meiner Anfängermeinung nach weniger übersichtlich) schreiben:

mov ax, 0e2ah  ; output asterisk on screen
int 10h        ; interrupt for video services

Im Gesamten ergibt sich damit also:

times 510-($-$$) hlt
dw 0xAA55

Die gesamte Assembler-Datei sieht nun also so aus:

; boot.asm

[BITS 16]
[ORG 0x7C00]

xor ax, ax ; setze ax auf 0 = erstes Segment
mov ss, ax ; Stack-Segment
mov ds, ax ; Data-Segment
mov es, ax ; Extra-Segment

mov ah, 0eh  ; output on screen
mov al, 2ah  ; asterisk
int 10h      ; interrupt for video services

jmp $

times 510-($-$$) hlt
dw 0xAA55

Assemblieren und ausführen: Voila, ein Asterisk erscheint.

Und jetzt ein bisschen mehr Text

Jetzt ist nur ein Asterisk natürlich langweilig, deshalb soll “Hello World” ausgegeben werden.

Es muss nun ein String für den Text definiert werden. Da im Bootloader alles in einem Block steht und es keine Trennung zwischen Text und Daten gibt, sollten die Daten so weit nach hinten, dass sie nicht als Code ausgeführt werden. Deshalb definiert man sich hinter dem Jump-Befehl den Text.

welcome_msg db 'Hello World!', 0

Vor dem Jump-Befehl wird diese Marke in das SI-Register geladen. SI steht für Source Index und wird beim Lesen von Daten verwendet. Der Befehl zum Lesen der Daten inkrementiert dieses Register auch automatisch.

mov si, welcome_msg
call print_string

Hier wird zudem noch die Routine print_string aufgerufen, welche man sich hinter dem Jump-Befehl anlegen sollte. In dieser Routine werden solange Daten von der Speicherstelle gelesen, auf die SI zeigt, bis man das Nullbyte liest.

print_string:
    mov ah, 0eh     ; Ausgabe bei Interrupt 10h
.next_char:
    lodsb           ; liest ein Byte aus und speichert es in AL
    cmp al, 0       ; wurde das Nullbyte gelesen?
    jne .done       ; falls ja: Fertig
    int 10h         ; ansonsten: gib aktuelles Zeichen in AL aus
    jmp .next_char   ; und mache beim nächsten Zeichen weiter
.done:
    ret

Die Wikipedia bietet eine kompakte Übersicht über den x86-Instruktionssatz. Wer etwas genauer wissen will, schaut direkt bei Intel ins Handbuch (Volume 7, die zweigeteilten Bücher von A-L und M-Z).

Mit diesen drei Ergänzungen gibt der Bootloader nun den schönen Text “Hello World” von sich.

Damit ergibt sich, wenn man das alte Asterisk herauslöscht, folgender Code:

[BITS 16]
[ORG 0x7C00]

xor ax, ax ; setze ax auf 0 = erstes Segment
mov ss, ax
mov ds, ax
mov es, ax

mov si, welcome_msg
call print_string

jmp $

print_string:
    mov ah, 0eh     ; Ausgabe bei Interrupt 10h
.next_char:
    lodsb           ; liest ein Byte aus und speichert es in AL
    cmp al, 0       ; wurde das Nullbyte gelesen?
    je .done       ; falls ja: Fertig
    int 10h         ; ansonsten: gib aktuelles Zeichen in AL aus
    jmp .next_char   ; und mache beim nächsten Zeichen weiter
.done:
    ret

welcome_msg db 'Hello World!', 0

times 510-($-$$) hlt
dw 0xAA55

Weitere Ideen

Einige Dinge, die man zur Übung (und weil es einfach Freude macht, sowas direkt auf BIOS-Ebene mal zu schaffen) jetzt noch implementieren könnte:

  • Anzeige von mehreren Texten in mehreren Zeilen
  • Auf Texteingaben des Benutzers warten (und ihm anzeigen, was er getippt hat, das geht nämlich nicht automatisch)
  • Den eingegebenen Text des Benutzers zwischenspeichern und wie ein Papagei nochmal nachsprechen
  • Den Cursor selbst bewegen können