Autor Thema: Ein Spiel im Boot-Sektor  (Gelesen 10499 mal)

Survari

  • Beiträge: 11
    • Profil anzeigen
Gespeichert
« am: 11. May 2021, 16:18 »
Spiele in den 512 Bytes des Boot-Sektors unterzubringen ist keine Neuheit, daher wollte ich auch mal etwas kleines probieren. Wie mit meinem letzten Projekt, bei dem ich hier um Rat gebeten habe, versuche ich etwas mehr über die Funktionsweise von Rechnern und x86-Assembly zu erfahren. Aktuell ist es erstmal mein Ziel, ein Rechteck mit Pfeiltasten über den Bildschirm zu bewegen.

Gesagt, getan. Das Programm läuft in QEMU super, Eingabe und Anzeige funktionieren wie gewollt. Jetzt das Problem: Es läuft nur auf QEMU (und Bochs). Sobald ich das Programm von einem USB-Stick auf meinem Rechner starte, macht es gar nichts mehr (Bildschirm bleibt leer, Cursor blinkt). Dabei ist mir ein möglicher Zusammenhang aufgefallen (bitte schlagt mich nicht, es folgen gefährliches Halbwissen und wahrscheinlich haufenweise falsche Annahmen): Es gibt Ausnahmen. Ich hatte viel Zeit das Programm an vielen Computern meiner Schule zu testen. Dabei ist mir aufgefallen, dass besonders Computer, die neuer waren, das Programm nicht richtig ausgeführt haben. Ich hatte die Überlegung, dass es möglicherweise am vom UEFI simulierten BIOS liegen könnte. Auf Rechnern ohne UEFI funktioniert es tatsächlich (zumindest gehe ich davon aus, dass die jeweiligen Rechner kein UEFI hatten, ich bin mir unsicher, wie genau ich das herausfinde). Soweit ich das beurteilen kann, würde ich außerdem eine Inkompatibilität aufgrund der Prozessorarchitektur ausschließen, da die meisten Computer auf x86/x64 laufen und sonst wahrscheinlich gar nichts passiert wäre (es gab Phasen, in denen Teile des Programmes auch auf den UEFI-Rechnern funktioniert haben, daher schließe ich das Problem mit den Architekturen aus).

Aktuell hat das Programm nette 118 Zeilen (NASM):
K_LEFT          equ 75
K_RIGHT         equ 77
K_UP            equ 72
K_DOWN          equ 80
MEM_VIDEO       equ 0xb800
SCREEN_WIDTH    equ 80
SCREEN_HEIGHT   equ 25
TICK_LENGTH     equ 4

org 0x7C00
bits 16

init:
    mov     ax, 0x0002      ; 16 farben, 80x25 Zeichen
    int     0x10

    ; -- Stack initialisieren
    xor     ax, ax
    mov     ds, ax
    mov     ss, ax
    mov     sp, 0x9c00

    mov     ax, MEM_VIDEO   ; zeiger auf bildschirmspeicher
    mov     es, ax          ; ES=MEM_VIDEO
    mov     al, 0x03
    int     0x10

    mov     ah, 1
    mov     ch, 0x26
    int     0x10

main:
    ; -- Bildschirm löschen und neu zeichnen
    call    clear_screen
    call    draw

    ; -- Eingabe und zurück zum Anfang springen
    call    input
    jmp     main

input:     ; steuerung
    xor     ah, ah
    int     0x16            ; tasteneingabe

    cmp     ah, K_RIGHT     ; Pfeiltaste nach Rechts
    je     .right_arrow

    cmp     ah, K_LEFT      ; Pfeiltaste nach Links
    je     .left_arrow

    cmp     ah, K_DOWN      ; Pfeiltaste nach Unten
    je     .down_arrow

    cmp     ah, K_UP        ; Pfeiltaste nach Oben
    je      .up_arrow

    jmp     .end

    .right_arrow:
        cmp     word [x_pos], word SCREEN_WIDTH-2
        jg      .end                    ; x_pos > SCREEN_WIDTH-2, keine veränderänderung
        add word [x_pos], 1
        jmp     .end

    .left_arrow:
        cmp     word [x_pos], word 1
        jl      .end                    ; x_pos < 1, keine veränderung
        sub     word [x_pos], 1
        jmp .end

    .down_arrow:
        cmp     word [y_pos], word SCREEN_HEIGHT-2
        jg      .end                    ; y_pos > SCREEN_HEIGHT-2, keine veränderung
        add     word [y_pos], 1
        jmp .end

    .up_arrow:
        cmp     word [y_pos], word 1
        jl      .end                    ; y_pos < 1, keine veränderung
        sub     word [y_pos], 1
        jmp .end

    .end:
        ret

clear_screen:
    ; -- Ganzen Bildschirm mit Schwarz füllen
    xor     di, di
    mov     cx, 0x07d0
    mov     ax, 0x0000
    rep     stosw
    ret

draw: ; Block an der Stelle aus [x_pos] und [y_pos] zeichnen
    ; -- Umwandeln der Koordinaten in Index für den Bildschirmspeicher, Ergebnis in dx
    mov     ax, word [y_pos]
    mov     bx, 160
    mul     bx
    mov     dx, ax

    mov     ax, word [x_pos]
    add     dx, ax
    add     dx, ax

    ; -- Datenindex setzen und zeichnen
    mov     di, dx
    mov     ax, 0xa020
    stosw
    ret

done:
    jmp     $           ; Endlosschleife am Ende (was eigentlich nie erreicht wird)

x_pos: times 10 dw 0
y_pos: times 10 dw 0

times 510-($-$$) db 0
dw 0xaa55            ; Signatur für's BIOS

Meine Frage dazu wäre dann wahrscheinlich klar: Warum läuft das Programm nicht? Gibt es Unterschiede am vom UEFI simulierten BIOS? Liegt es vielleicht an etwas völlig Anderem? Ich bin für jede Hilfe und Erklärung dankbar!

Svenska

  • Beiträge: 1 792
    • Profil anzeigen
Gespeichert
« Antwort #1 am: 21. May 2021, 20:22 »
Spontan würde ich mal den Stack initialisieren, bevor ich das BIOS aufrufe. Dein "int 10h" sollte zwar einen eigenen Stack benutzen, aber garantiert ist das m.W. nicht. Vor allem nicht bei modernen VBIOS-UEFI-Fallback-Implementationen, die garantiert nicht mehr im Detail auf sowas getestet werden.

Als nächstes fällt mir das hier auf:
    mov     ax, MEM_VIDEO   ; zeiger auf bildschirmspeicher
    mov     es, ax          ; ES=MEM_VIDEO
    mov     al, 0x03
    int     0x10


Damit rufst du int 10h / AH=B8h / AL=03h auf. RBIL kennt diese Funktion nicht; kann gut sein, dass dein Video-BIOS dir das übel nimmt.

Ich kann mir gut vorstellen, dass die Legacy-VBIOS-Emulation auf UEFI-Systemen von Intels Referenzimplementation stammt und sich auf so ziemlich allen UEFI-Systemen ähnlich verhält (ein generisches VBIOS für den UEFI-Grafiktreiber). Das würde so ein Verhalten erklären.

Gruß,
Svenska

Survari

  • Beiträge: 11
    • Profil anzeigen
Gespeichert
« Antwort #2 am: 27. May 2021, 08:06 »
Hi Svenska, danke für die Antwort!

Ich habe jetzt die Initialisierung des Stacks nach oben verschoben und den Interrupt mit 0xb8 entfernt. (Der war überflüssig, wahrscheinlich noch ein Überbleibsel von irgendetwas und ich habe vergessen es zu entfernen.) Läuft wieder in der Simulation, doch die PCs starten jetzt neu. (Aufgrund von Fehlern? Ich sehe sonst keinen Interrupt, der das rechtfertigen würde.)

So sieht der init-Abschnitt jetzt bei mir aus:

init:
    ; -- Stack initialisieren
    xor     ax, ax
    mov     ds, ax
    mov     ss, ax
    mov     sp, 0x9c00

    mov     ax, MEM_VIDEO   ; zeiger auf bildschirmspeicher
    mov     es, ax          ; ES=MEM_VIDEO

    mov     ax, 0x0003      ; 16 farben, 80x25 Zeichen
    int     0x10

    mov     ah, 1
    mov     ch, 0x26
    int     0x10

Svenska

  • Beiträge: 1 792
    • Profil anzeigen
Gespeichert
« Antwort #3 am: 31. May 2021, 20:45 »
Du setzt DS und SS auf 0x0000. Das ist unklug, denn da befindet sich die IVT (Interruptvektortabelle).
In UEFI-Code habe ich noch nicht gewühlt, aber zumindest DOSBox und mein :YES schalten in ihren Interrupthandlern explizit Interrupts ein. Wenn du ungünstig an der IVT gespielt hast, dürfte der nächste Timerinterrupt das System neu starten.

Dein Code wird an die lineare Adresse 0x07C00 geladen, aber das BIOS kann daraus verschiedene Segment:Offset-Paare erzeugen. Du solltest das erstmal normalisieren. Im "tiny"-Speichermodell setzt du dann CS=DS=SS und hast dort erstmal garantiert 64 KB Speicher, in dem du arbeiten kannst.

Also ungefähr so:
  ; CS:IP ist unbekannt
  .org 0x0000
  mov ax, 0x07C0
  mov ds, ax
  mov ss, ax
  mov sp, 0xFFFE
  jmp WORD 0x07C0:main

main:
  ; CS=DS=SS=0x07C0
  ; hier geht's los

Survari

  • Beiträge: 11
    • Profil anzeigen
Gespeichert
« Antwort #4 am: 02. June 2021, 11:57 »
Zitat
Dein Code wird an die lineare Adresse 0x07C00 geladen, aber das BIOS kann daraus verschiedene Segment:Offset-Paare erzeugen. Du solltest das erstmal normalisieren.

Danke dir, das scheint das Problem gewesen zu sein! Aber kannst du das vielleicht etwas genauer erklären oder mich auf eine Seite verweisen, damit ich mich da mal näher zu belesen kann? Ich verstehe noch nicht genau, warum man das machen muss und was dahinter steckt.
« Letzte Änderung: 02. June 2021, 16:36 von Survari »

Svenska

  • Beiträge: 1 792
    • Profil anzeigen
Gespeichert
« Antwort #5 am: 07. June 2021, 15:30 »
Das hängt mit der Speicheradressierung im Real Mode zusammen, und die wiederum ist eine Eigenheit des 8086/8088 und seiner Nachfolger. Der 8086 hat einen 20 Bit-Adressbus (kann also 1 MB adressieren), aber nur 16 Bit-Register (kann also nur 64 KB am Stück adressieren).

Weil ein Adressregister - einschließlich des Instruction Pointers IP - nur 64 KB am Stück adressieren kann, wird der Speicher in "Segmente" aufgeteilt. Jedes dieser Segmente ist 64 KB groß und hat eine Nummer (die steht in den Segmentregistern). Jetzt haben die Segmentregister aber auch 16 Bit, und zusammen mit einem normalen Adressregister kommt man auf 32 Bit - aber der Chip hat nur 20 Adressbits.

Also hat Intel die Segmente nicht brav hintereinander gelegt, sondern mit einem Abstand von jeweils 16 Bytes übereinander:

- Segment 0 geht von 0x00000 bis 0x0FFFF (64 KB ab Adresse 0)
- Segment 1 geht von 0x00010 bis 0x1000F (64 KB ab Adresse 16)
- Segment 2 geht von 0x00020 bis 0x1001F (64 KB ab Adresse 32)
- Segment 3 geht von 0x00030 bis 0x1003F (64 KB ab Adresse 48)
- ... und so weiter ...
- Segment 61439 geht von 0xEFFF0 bis 0xFFFEF (64 KB ab Adresse 983024)
- Segment 61440 geht von 0xF0000 bis 0xFFFFF (64 KB ab Adresse 983040)
- Segment 61441 geht von 0xF0010 bis 0x10000F (64 KB ab Adresse 983056)
- ... und so weiter ...
- Segment 65535 geht von 0xFFFF0 bis 0x10FFEF (64 KB ab Adresse 1048560)

Eine Nebenwirkung dieses Systems ist, dass die Segmente ab 61441 (Segment-Nummer 0xF001) hinter den 20 Bit-Adressraum zeigen (beachte den Übertrag in der Hex-Darstellung). Da der Chip aber nur 20 Adresspins hat, fällt das obere Bit weg und diese Segmente zeigen wieder auf das erste Segment.

Das heißt, die zweiten 16 Byte von Segment 0 (0x0000:0x0010 bis 0x0000:0x001F) sind gleichzeitig die ersten 16 Byte von Segment 1 (0x0001:0x0000 bis 0x0001:0x000F) und die letzten 16 Byte von Segment 61442 (0xF002:0xFFF0 bis 0xF002:0xFFFF) und die vorletzten 16 Byte von Segment 61441 und so weiter und so fort.

Alle diese Segmente überlagern sich. Das heißt, es gibt viele Adressen, die auf das gleiche Byte zeigen. Innerhalb eines Segments gibt es nur eine Adresse für jedes Byte (die Segment-Nummer ist ja festgelegt), aber es gibt 4096 Segmente, die auf dieses Byte zeigen können. Und in jedem dieser Segmente heißt das Byte anders.

Dein BIOS lädt den Bootloader an Adresse 0x07C00, aber du kennst den Wert von CS nicht. Das kann CS=0x0000 sein (dann ist IP=0x7C00), es kann aber auch CS=0x07C0 sein (dann ist IP=0x0000). Oder CS=0x03E0 (dann ist IP=0x3E00) oder über 4000 andere Möglichkeiten.

Du kannst den Bootloader theoretisch so schreiben, dass er an jeder möglichen Adresse funktioniert, aber das ist ineffizient (besonders auf dem 8086), daher macht man das nicht. Dein Code ist also normalerweise so gebaut, dass das erste Byte an Adresse 0x0000 liegt (das ist das ".org 0x0000"), aber davon weiß die CPU ja nichts. Also machst du noch einen "FAR JMP" hinterher, der gleichzeitig CS und IP setzt. Danach hat CS den Wert 0x07C0 und IP 0x000F (die Adresse von "main", wenn man ab 0 anfängt zu zählen).

Verständlich?
« Letzte Änderung: 07. June 2021, 15:32 von Svenska »

Jidder

  • Administrator
  • Beiträge: 1 625
    • Profil anzeigen
Gespeichert
« Antwort #6 am: 10. June 2021, 07:48 »
Was Svenska geschrieben hat, ist korrekt, aber ich will trotzdem meinen Senf dazu geben, der Spekulation meinerseits ist. In den 80ern war denen Ingenieuren vermutlich egal, was in CS oder IP steht. Hauptsache MS DOS läuft. Der Bootsektor muss an die Adresse 0x07C00 geladen werden und DOS macht den Rest. Die Entwickler diverser PC-Klone haben beim Klonen des BIOS von IBM vielleicht gemerkt, dass es egal ist, was in CS oder IP steht, solange die Rechnung, die Svenska beschrieben hat, aufgeht, weil DOS damit klar kommt. Vielleicht haben die auch eine Münze geworfen oder Option 2 gewählt, um behaupten zu können: "Wir haben andere Werte in CS oder IP als IBM. Verklagt uns nicht.". Oder auch nicht.

Meine Behauptung ist also, dass es keine Dokumentation gibt, weil es keinen Masterplan gibt.
Dieser Text wird unter jedem Beitrag angezeigt.

Survari

  • Beiträge: 11
    • Profil anzeigen
Gespeichert
« Antwort #7 am: 10. June 2021, 20:51 »
Okay. Diese Funktionsweise wirkt auf mich etwas schräg, aber ich denke ich habe es recht gut verstanden. Manchmal frage ich mich, warum man das nicht einfacher und damit verständlicher konzipiert hat, aber ich stecke nicht genug in der Materie drin, um das beurteilen zu können. Ich danke euch beiden für die Hilfe dabei, auf diese Lösung wäre ich nie gekommen!

Eine Frage hätte ich tatsächlich doch noch: Es ist etwas umständlich das Programm auf einem echten System zu testen, da die ganzen Emulatoren anders damit umgehen. Kann ich mit QEMU das UEFI-BIOS (=das VBIOS?) simulieren?
« Letzte Änderung: 10. June 2021, 21:12 von Survari »

Svenska

  • Beiträge: 1 792
    • Profil anzeigen
Gespeichert
« Antwort #8 am: 10. July 2021, 11:37 »
Qemu kann UEFI, aber ob er auch im UEFI-Modus das CSM unterstützt, weiß ich nicht.
Du kannst halt das normale BIOS benutzen.

Außerdem ist es sinnvoll, in verschiedenen Emulatoren zu testen, z.B. VirtualBox und VMware sind eher wie moderne Systeme gebaut (vllt funktioniert UEFI mit CSM dort), PCem eher wie ältere Systeme. DOSBox (mit Floppy-Image) ist auch interessant.

 

Einloggen