Wir starten mit dem Kernel in C
Fast alles, was wir bisher gemacht haben, wird üblicherweise gar nicht zum Bereich des Kernels gezählt, sondern zum Bootloader. Der Bootloader setzt die A20-Line und wechselt in den Protected Mode. Einzig das Einstellen der GDT ist dann noch Sache des Kernels. Weil für den Protected Mode eine GDT benötigt wird, initialisiert der Bootloader aber auch noch eine temporäre GDT, die der Kernel aber nach eigenen Wünschen überschreiben muss.
Das alles gilt zumindest für multiboot, an welches wir uns bei unserem Projekt auch halten wollen.
In diesem Artikel werden wir endlich mit dem eigentlichen Kernel starten und als Bootloader zunächst einmal GRUB verwenden. Das hat den Vorteil, dass es leichter zu prüfen ist, ob wir multiboot richtig benutzen (solange wir nicht GRUB 2 verwenden, denn das scheint einen Bug in Bezug auf Multiboot zu haben):
But the Multiboot header as used by most versions of GRUB 2 (including GRUB 1.98-2) does not include the header_length field that is specified in the Multiboot 1.6 specification.
Wir werden also die ersten Codezeilen des Kernels programmieren, GRUB als Bootloader einsetzen und prüfen, ob wir korrekt in unseren Kernel booten können. Wenn das funktioniert, können wir später darauf zurückkommen und unseren eigenen Bootloader ebenfalls multiboot-fähig machen (je nach Aufwand und Interesse natürlich).
GRUB einrichten
Zunächst mal müssen wir also dafür sorgen, dass der externe Bootloader in unserem Image landet. Je nachdem, welchen Bootloader man wählt, hat man hier ganz unterschiedliche Wege. Viele verwenden GRUB Legacy, ich habe mich für GRUB 2 entschieden, weil dieser auch auf meinem eigenen PC installiert ist. Sauberer wäre es wahrscheinlich, sich seinen Bootloader-Installer auch ins lokale Arbeitsverzeichnis zu ziehen und nicht vom Hauptsystem zu nehmen. Aber alles zu seiner Zeit und erstmal müssen Erfolge her!
Zu GRUB 2 gibt es Anleitungen für alle möglichen Boot-Medien im OSDev-Wiki. Ich habe mich für die Methode Disk-Image entschieden, welches ich dann in Bochs als Festplatte angeben kann. Hierzu braucht man lediglich eine leere Datei zu erstellen, die groß genug für Bootloader, Kernel und alle anderen gewünschten Daten ist. Anschließend muss man hierin eine Partition mit Dateisystem erstellen, da GRUB bereits einige Dateien ins Datesystem schreiben will.
Die Partition kann man sich entweder mit fdisk
erstellen oder aber man nimmt die von mir bereits erstellte Layout-Datei und sfdisk
.
label: dos
label-id: 0x8e648a50
device: sos.img
unit: sectors
sos.img1 : start= 2048, size= 129024, type=83, bootable
Der Trick an dieser Methode ist, dass man das Image per losetup
einbindet und sich so die Umgebung schafft, die man sonst immer als /dev/sda
und /dev/sda1
kennt (also gesamte Festplatte und Partition). Auf die Partition wollen wir nämlich die Dateien schreiben, aber der Bootloader muss auch den MBR an den Anfang der Festplatte schreiben.
Der Offset für /dev/loop1
liegt genau bei 1MB, was auch dem Offset der Partition auf der Festplatte entsprechen müsste. Ich sehe in der Layout-Datei keine Spezifikation für die Sektor-Größe, sie ist aber fast immer 512. Ich glaube, dass die Sektorgröße sogar bei den WD-Festplatten auf 512 gesetzt war, die intern mit 4kB arbeiteten. Da gab es nämlich Geschwindigkeitsprobleme, wenn der erste Sektor unter fdisk
nicht an einem 4kB-Block aligned war. Das nur so als Randnotiz. Falls ihr euch unsicher seid, öffnet die Image-Datei ruhig nochmal mit fdisk und schaut nach, welche Sektorgröße angegeben ist.
Dann erstellen wir uns ein Dateisystem auf der Partition (also /dev/loop1
), mounten sie und schreiben unseren Bootloader auf unsere “Festplatte” (also die Image-Datei). Als Zielfestplatte nennen wir dem Installer /dev/loop0
(da dieser Bereich ja die gesamte Festplatte umfasst, also wie /dev/sda
es sonst tut) und als Zielbereich für das Dateisystem nennen wir die gemountete Partition.
Die Option part_msdos
ist notwendig um den MBR lesen zu können.
Jetzt müssen wir nur noch eine Datei grub.cfg
im Ordner /mnt/boot/grub/
anlegen. Dies ist die normale GRUB-Konfiguration und wird von GRUB gelesen, um die Liste an Menüeinträgen anzuzeigen.
set timeout=15
set default=0
menuentry "SOS" {
multiboot /boot/sos-kernel.img
boot
}
Ist alles erledigt, unmounten/detachen wir wieder alles und können unseren Bootloader starten. Den Kernel haben wir jetzt noch nicht kopiert, d.h. das Auswählen des Menüeintrags wird noch nicht funktionieren. GRUB selbst wird aber trotzdem korrekt starten.
Will man Bochs zum Starten benutzen, muss man die bochsrc
noch korrekt anpassen. Kommt man von einem Floppy-Disk-Setup, muss man eine neue Zeile für den ata0-master
einfügen und die boot-Einstellung auf disk
setzen. In meinem Fall sieht sie so aus:
romimage: file="$HOME/opt/bochs/share/bochs/BIOS-bochs-latest"
vgaromimage: file="$HOME/opt/bochs/share/bochs/VGABIOS-elpin-2.40"
boot: disk
ata0-master: type=disk, path="sos.img", mode=flat
cpu: count=1, ips=4000000, reset_on_triple_fault=0
magic_break: enabled=1
Den eigentlichen Kernel anfangen
Damit wir endlich mit unserem eigentlichen Kernel beginnen können, müssen wir noch einen kleinen Teil der Arbeit in Assembler erledigen und dann unseren C-Code aufrufen. Zunächst mal müssen wir dafür sorgen, dass unser Kernel multiboot
-fähig ist.
Aus Sicht des Kernels bedeutet das zum Glück nicht sehr viel, wir müssen lediglich dem Bootloader mitteilen, dass wir multiboot
-fähig sind und dazu eine spezielle Magic-Number und ein paar Einstellungen am Anfang des Binaries bereitstellen.
Um mich ein bisschen vom OSDev-Wiki abzuspalten und das Bootloader-Zeug selbst zu lernen, verwende ich weiterhin NASM für Assemblercode.
Wie gesagt, erfordert ein Multiboot-Kernel einen speziellen Header, der innerhalb der ersten 8192 Bytes der Binary stehen muss und auf 32-Bit aligned sein muss. Dieser Header besteht mindestens aus 32-bit Magic-Number (0x1BADB002
), 32-Bit Flags und 32-Bit Prüfsumme (Magic-Number + Flags + Prüfsumme = 0). Je nachdem, welche Flags gesetzt sind, kommen noch weitere Felder hinzu, wir werden diese jedoch erstmal alle weglassen.
Das heißt für uns, wir müssen im Assembler-Code eine Section anlegen, die diesen Header enthält. Diese können wir später beim Linken auf einen vorderen Bereich des entstehenden Kernels schieben.
section .multiboot
align 4
dd 1BADB002h
dd 0003h ; set align boot modules to page boundaries, supply memory map
dd -(1BADB002h + 0003h)
Da wir aktuell keinen Stack haben, aber eine Funktion aufrufen wollen, müssen wir für einen gültigen Stack sorgen. Denn ein Funktionsaufruf führt allein schon durch das Speichern der Rücksprungadresse zu einer Operation auf dem Stack. Auch hierfür legen wir eine neue Section an und reservieren mit dem NASM-Befehl resb
einige Bytes. Mit nobits
zeigen wir NASM, dass dieser Bereich uninitialisierte Werte beinhalten darf (welche durch resb verursacht werden).
section .bootstrap_stack nobits
stack_bottom:
resb 16384 ; 16 KiB
stack_top:
Zuletzt brauchen wir noch den Code, der in unser C-Programm springt. Dieser kommt in die .text
-Section. Zunächst mal müssen wir eine Marke anlegen, die auf diesen Codeteil zeigt, und sie gleich als global
markieren, damit unser Linker sie später als Eintrittspunkt in den Kernel finden kann. Außerdem müssen wir NASM mit external
mitteilen, dass die C-Funktion aus einer anderen Datei stammt und er sie deshalb nicht finden kann.
section .text
extern kernel_main
global _start
_start:
mov esp, stack_top
call kernel_main
cli
hlt
.lhang:
jmp .lhang
Damit haben wir schonmal ein schönes Gerüst, das wir zu einer Objektdatei kompilieren können:
Im Kernel wollen wir zunächst gar nicht viel machen, deshalb legen wir nur die Funktion kernel_main
an und schreiben einen Text auf den Monitor. Wir wissen bereits, dass der Speicherbereich für den Monitor auf 0xb8000
liegt und jeweils zwei Byte für einen Buchstaben stehen (ein Byte für einen Buchstaben und ein Byte für die Farbe). Deshalb können wir ohne viel Aufwand den Text “Hello World” auf dem Monitor ausgeben:
Weil der Kernel nicht mehr zurückkehren soll, haben wir am Ende eine Endlosschleife eingebaut.
Den Kernel können wir nun mit unserem Cross-Compiler bauen:
Damit haben wir zwei Objektdateien, die wir noch zu einer einzelnen ausführbaren Datei zusammenfügen müssen. Das geschieht über den Linker, dem wir gleich noch ein Linker-Skript übergeben, damit er weiß, wo welche Sections hinsollen. Wir haben schließlich einige eigene Sections erstellt. Außerdem müssen wir dem Linker-Skript über ENTRY
mitteilen, wo unser Eintrittspunkt ist.
Wir teilen dem Linker mit, in welcher Reihenfolge er die Sektionen hintereinanderreihen soll und insbesondere dass er den Multiboot-Header ganz vorne anordnen soll (wir erinnern uns, der Multiboot-Header muss in die ersten 8192 Bytes).
Das Linker-Skript stammt wieder mal aus dem OSDev-Wiki, da meine Linker-Skript-Kenntnisse bisher bei Null lagen.
ENTRY(_start)
OUTPUT_FORMAT(elf32-i386)
SECTIONS
{
/* Begin putting sections at 1 MiB, a conventional place for kernels to be
loaded at by the bootloader. */
. = 1M;
/* First put the multiboot header, as it is required to be put very early
early in the image or the bootloader won't recognize the file format.
Next we'll put the .text section. */
.text BLOCK(4K) : ALIGN(4K)
{
*(.multiboot)
*(.text)
}
.rodata BLOCK(4K) : ALIGN(4K)
{
*(.rodata)
}
.data BLOCK(4K) : ALIGN(4K)
{
*(.data)
}
.bss BLOCK(4K) : ALIGN(4K)
{
*(COMMON)
*(.bss)
*(.bootstrap_stack)
}
/* The compiler may produce other sections, by default it will put them in
a segment with the same name. Simply add stuff here as needed. */
}
Jetzt können wir aus den beiden Objektdateien eine einzelne ausführbare Datei erzeugen.
Interessant ist es auf jeden Fall, sich die generierte Datei einmal mit objdump anzusehen. Dann sieht man, was das Linkerskript denn nun genau wie zusammengefasst hat.
sos-kernel.img: file format elf32-i386
Contents of section .text:
100000 02b0ad1b 03000000 fb4f52e4 66906690 .........OR.f.f.
100010 bc005010 00e80600 0000faf4 ebfe6690 ..P...........f.
100020 c6050080 0b0048c6 0502800b 0065c605 ......H......e..
100030 04800b00 6cc60506 800b006c c6050880 ....l......l....
100040 0b006fc6 050a800b 0020c605 0c800b00 ..o...... ......
100050 57c6050e 800b006f c6051080 0b0072c6 W......o......r.
100060 0512800b 006cc605 14800b00 64ebfe .....l......d..
Contents of section .eh_frame:
100070 14000000 00000000 017a5200 017c0801 .........zR..|..
100080 1b0c0404 88010000 10000000 1c000000 ................
100090 90ffffff 4f000000 00000000 ....O.......
Contents of section .comment:
0000 4743433a 2028474e 55292034 2e382e30 GCC: (GNU) 4.8.0
0010 00 .
Schaut man sich nun die erste Stelle in der .text
-Sektion an, erkennt man die Magic-Number wieder (die Datei ist in Little Endian).
Außerdem sehen wir die einzelnen Zeichen aus unserem C-Code, die wir auf den Monitor schreiben wollen.
Den fertigen Kernel müssen wir jetzt nur noch an seinen designierten Ort in /boot/sos-kernel.img
kopieren und können das Betriebssystem booten.
Makefile
Weil diese ganzen Schritte ziemlich umfangreich werden, kann man auch noch eine Makefile anlegen, die alle notwendigen Schritte zum Bauen des Systems enthält:
Mit make all
kann man dann das Image und den Kernel bauen und kopieren, wenn man das Image einmal hat, reicht es auch aus den Kernel mit make kernel kernel-install
neu zu bauen und zu installieren. Wenn man mit QEMU testen will, reicht auch nur make kernel
und dann qemu-system-i386 -kernel sos-kernel.img
.