Unser bisheriger Assembler-Code lief im sogenannten Real Mode, einem alten Betriebsmodus, in dem wir nach dem Starten des BIOS landen. Soweit ich gelesen habe, wechselt das BIOS selbst manchmal schon in den Protected Mode, springt dann aber wieder zurück in den Real Mode.

Als nächsten Schritt in der Betriebssystementwicklung wollen wir daher in den sogenannten Protected Mode wechseln und zwar in der 32-bit-Variante. Dieser erlaubt uns Zugriff auf mehr als nur ein MB Speicher und bietet außerdem Zugriffsberechtigungen für Speicherbereiche.

Um den Wechsel vom Real in den Protected Mode verstehen zu können, sollten wir uns zunächst die Adressbereiche beider Modi ansehen. Beide Modi verwenden eine Segment-Offset-Adressierung. Diese werden mit einem Doppelpunkt notiert, also z.B. 08h:1234h (segment:offset). Im Real Mode sind die Segmente überlappend und jedes Segment umfasst 64kB. Die vollständige Adresse berechnet sich durch die Formel Adresse = Segment * 16 + Offset. Für obiges Beispiel ergäbe sich also die Adresse 12c4h.

Im Protected Mode arbeiten wir ebenfalls mit Segmenten, allerdings ergibt sich die Adresse hier nicht direkt aus einer einfachen Formel. Um die Zugriffsberechtigungen umzusetzen, arbeitet der Protected Mode mit einer Tabelle, in der steht, welcher Selektor (der vordere Teil in unserer Doppelpunktnotation) welchen Speicherbereich umfasst und welche Berechtigungen es auf diesem Segment gibt. Hierbei können auch mehrere Segmente auf denselben Speicherbereich zeigen, die Berechtigungen gelten dann jeweils für die verwendete Selektorennummer.

Wenn wir im Protected Mode die obige Adresse 08h:1234h angeben, dann nennt uns der Selektor 08h den Offset in der sogenannten Global Descriptor Table (GDT). Dazu muss natürlich auch die Basisadresse der GDT bekannt sein. Um diese zu setzen, gibt es einen eigenen Assembler-Befehl lgdt. Mit dem Selektor kommen wir dann zum richtigen Eintrag in der Tabelle, welcher Informationen zur Startadresse des Speicherbereichs, dessen Länge sowie Berechtigungen enthält.

Global Descriptor Table

Der genaue Aufbau eines Eintrags in der GDT sieht wie folgt aus:

  • Bit 0 bis 15: Limit Bit 0 bis 15
  • Bit 16 bis 39: Basis Bit 0 bis 23
  • Bit 40 bis 47: Access Byte
  • Bit 40 Access Bit: Wird von der CPU auf 1 gesetzt, wenn auf Segment zugegriffen wird
  • Bit 41 RW: Schreib/Lese-Berechtigungen je nach Code/Data-Segment-Einstellung
  • Bit 42 Direction bit/conforming bit: Direction für Datensegmente, conforming/execute privileges für Codesegmente
  • Bit 43 Executable: 1 für Codesegmente, 0 für Datensegmente
  • Bit 44: immer 1
  • Bit 45 und 46 Privilege level (2 Bits)
  • Bit 47 Present bit: Segment ist nur zugreifbar, wenn Present bit auf 1 gesetzt ist
  • Bit 48 bis 51: Limit Bit 16 bis 19
  • Bit 52 bis 55: Flags
  • Bit 52 und 53: Reserviert für CPU
  • Bit 54 Size Bit: Wahl zwischen 16bit (0) und 32bit (1) Segmenten
  • Bit 55 Granularity Bit: Wahl zwischen Limit Auflösung in 1-Byte-Schritten (0) oder 4kb-Schritten (1)
  • Bit 56 bis 63: Basis Bit 24 bis 31

Mit diesem Wissen können wir uns bereits zwei GDT-Einträge für ein Code- und ein Datensegment anlegen. Beide sollen den gesamten Speicherbereich umfassen, also bei 0x0 beginnen und nach 4GB enden. Damit wir mit den 20 Bits der Limit-Einstellung diesen Bereich abdecken können, müssen wir zwingend das Granularity-Bit auf 1 setzen. Mit 20 Bits (gesetzt auf 0xFFFFF) und einer Auflösung von 4kB können wir exakt 4GB abdecken. Die 20 Bits auf einer Granularität von 1 Byte würden lediglich 1MB umfassen.

Beginnen wir zunächst mit dem Code-Segment: Die Basis soll also 0x0 sein, das heißt alle Bits der Basis werden auf Null gesetzt. Der Offset soll 0xFFFFF sein und das Granularitätsbit 1. Das Access-Bit setzen wir auf 0, da die CPU es selbst setzt. Das RW-Bit setzen wir auf 1, damit Lesezugriff erlaubt ist. Schreibzugriff ist bei Codesegmenten immer verboten. Das Executable-Bit muss für Code-Segmente natürlich auf 1 gesetzt werden. Den Privilege-Level setzen wir auf 0 für Kernelcode, da wir bisher nur dieses eine Code-Segment anlegen. Und das Present-Bit muss natürlich auf 1 gesetzt werden. Das Size-Bit habe ich auf 32bit gestellt, da ich in 32bit operieren will. Und das Granularitätsbit muss wie oben geschrieben auf 1 gestellt werden.

Das Conforming-Bit habe ich auf 0 gesetzt, wobei mir zugegebenermaßen die Implikation von 1 noch nicht ganz klar ist. Laut OSDev-Wiki bedeutet eine 1 hier, dass bspw. Ring-0-Programme in Code mit Privilege 1 springen dürften. Bei 0 dürfen sie dies nicht. Dies kommt mir momentan gerade verkehrt herum vor, da Ring-0 der Kernel-Ring ist und dieser doch eigentlich immer in User-Mode springen können müsste (nur umgekehrt nicht?). Einen sinnvollen Grund wird es aber sicherlich geben.

Das Daten-Segment sieht grundsätzlich sehr ähnlich aus. Hier müssen wir aber natürlich das Executable-Bit auf 0 stellen. Das Read-Write-Bit stellen wir hier wieder auf 1, allerdings bedeutet es diesmal, dass Schreibzugriff erlaubt ist (und Lesezugriff ist auf Datensegmenten sowieso immer erlaubt). DC bedeutet diesmal Direction-Bit und für den Einstieg habe ich dieses auf 0 gestellt, sodass das Datensegment nach oben wächst. Für Stacks scheint es hier Vorteile zu geben, wenn es nach unten wächst, jedoch muss man dann Basis und Limit anders interpretieren.

Wenn wir dies alles zu Assembler-Code zusammenbauen, erhalten wir folgenden Aufbau. Nicht vergessen werden sollte auch, dass der erste Eintrag immer der sogenannte Null-Deskriptor sein muss:

gdt_null: ; the first element is the so called null descriptor
    dd 0   ; it is not referenced by the processor
    dd 0

gdt_code: ; usually we want at least one segment descriptor for code
    dw 0ffffh ; segment length bits 0-15
    dw 0 ; segment base byte 0,1
    db 0 ; segment base byte 2
    db 10011010b ; access byte
    db 11001111b ; bit 7-4: 4 flag bits: granularity, default operation
                 ; size bit, 2 bits available for OS
                 ; bit 3-0: segment length bits 16-19
    db 0         ; segment base byte 3

gdt_data: ; usually we want at least one segment descriptor for data
    dw 0ffffh
    dw 0
    db 0
    db 10010010b
    db 11001111b
    db 0
gdt_end:

gdtr:
    dw gdt_end - gdt_null - 1 ; 2 bytes are the size of the GDT in bytes - 1
    dd gdt_null ; 4 bytes are the address of the GDT start

Diese GDT müssen wir der CPU bekanntmachen, bevor wir uns in den Protected Mode begeben können. Außerdem müssen wir vorher Interrupts deaktivieren, weil sonst ein BIOS-Interrupt getriggert werden könnte (meinem Test nach nicht zwangsläufig muss), der wegen der noch fehlenden Interrupt-Table zu einem Triple-Fault führen würde. Das würde den Absturz unseres Betriebssystems bedeuten.

Um der CPU letztlich zu sagen, dass wir in den Protected Mode wechseln wollen, müssen wir das Bit 0 im cr0-Register setzen. Der Wechsel in den Protected Mode wird vollendet durch einen sogenannten Far-Jump zur nächsten Instruktion. Das bedeutet lediglich, dass wir mithilfe des neuen Adressierungschemas an eine Instruktion springen. Da wir nun ein Code- und ein Daten-Segment haben, springen wir natürlich zum Code-Segment, also wird 08h als Selektor verwendet.

enter_pmode:
    cli
    lgdt [gdtr]
    mov eax, cr0
    or eax, 1
    mov cr0, eax

    jmp 08h:far_jump

Nur benötigen wir natürlich auch noch das Sprungziel. Nach dem Sprung setzen wir gleich mal die Register für das Datensegment und das Stacksegment auf unseren neuen Selektor für Daten (10h) und den Stackpointer an eine beliebige Stelle (später sollten wir uns vielleicht genauere Gedanken machen). Um auch etwas zu sehen, schubsen wir außerdem noch ein paar Daten auf den Speicherbereich, in dem die Displayanzeige liegt. Hier wird jeweils ein Byte für den Buchstaben und ein Byte für dessen Farbschema verwendet. Da wir oben den Schreib-Lese-Zugriff besprochen haben, ist klar, dass wir für diese Operation das Datensegment verwenden müssen. Nur hier gibt es einen Schreibzugriff, im Codesegment haben wir nur Lesezugriff.

[BITS 32] 
far_jump:
    mov ax, 10h
    mov ds, ax
    mov ss, ax
    mov esp, 090000h
    mov byte [ds:0B8000h], 'P'
    mov byte [ds:0B8001h], 1Bh

hang:
    jmp hang

Und nochmal der ganze Code im Überblick:

[BITS 16]
[ORG 0x8000]
 
xor ax, ax ; set ax to 0 = first segment
mov ss, ax
mov ds, ax
mov es, ax

enter_pmode:
    cli
    lgdt [gdtr]
    mov eax, cr0
    or eax, 1
    mov cr0, eax

    jmp 08h:far_jump

[BITS 32]
far_jump:
    mov ax, 10h
    mov ds, ax
    mov ss, ax
    mov esp, 090000h
    mov byte [ds:0B8000h], 'P'
    mov byte [ds:0B8001h], 1Bh

hang:
    jmp hang
 
gdt_null: ; the first element is the so called null descriptor
    dd 0   ; it is not referenced by the processor
    dd 0

gdt_code: ; usually we want one segment descriptor for code
    dw 0ffffh ; segment length bits 0-15
    dw 0 ; segment base byte 0,1
    db 0 ; segment base byte 2
    db 10011010b ; access rights
    db 11001111b ; bit 7-4: 4 flag bits: granularity, default operation
                 ; size bit, 2 bits available for OS
                 ; bit 3-0: segment length bits 16-19
    db 0         ; segment base byte 3

gdt_data: ; usually we want one segment descriptor for data
    dw 0ffffh
    dw 0
    db 0
    db 10010010b
    db 11001111b
    db 0
gdt_end:

gdtr:
    dw gdt_end - gdt_null - 1 ; 2 bytes are the size of the GDT in bytes - 1
    dd gdt_null ; 4 bytes are the address of the GDT start

Dieser Code gehört in eine Datei kernel.asm und zum Ausführen wird weiterhin die Datei boot.asm benötigt aus einem älteren Beitrag zum Nachladen von Code, der zu groß für den Bootloader ist.

Allerdings hat dieser Code nun noch einen Fehler, den wir erst später beheben werden: Er aktiviert nicht die A20-Line. Das ist ein ziemlich hässliches Relikt aus antiker Zeit, das leider auch sehr fummelig zu beheben ist. Hier man muss häufig über den Keyboard-Controller Einfluss auf die Speicheradressierung nehmen.