This article is part of a series: Jump to series overview

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.

dd if=/dev/zero of=sos.img bs=512 count=131072

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
sfdisk sos.img < sos.img.layout

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.

sudo losetup /dev/loop0 sos.img
sudo losetup /dev/loop1 sos.img -o 1048576

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.

sudo mke2fs /dev/loop1
sudo mount /dev/loop1 /mnt
sudo grub-install --root-directory=/mnt --no-floppy --modules="normal ext2 part_msdos multiboot" /dev/loop0

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.

sudo losetup -d /dev/loop0
sudo umount /mnt
sudo losetup -d /dev/loop1

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:

nasm boot.asm -o boot.o -f elf

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:

void kernel_main()                                                              
{                                                                               
    char *vidmem = (char*) 0xb8000;                                             
    vidmem[0] = 'H';                                                            
    vidmem[2] = 'e';                                                            
    vidmem[4] = 'l';                                                            
    vidmem[6] = 'l';                                                            
    vidmem[8] = 'o';                                                            
    vidmem[10] = ' ';                                                           
    vidmem[12] = 'W';                                                           
    vidmem[14] = 'o';                                                           
    vidmem[16] = 'r';                                                           
    vidmem[18] = 'l';                                                           
    vidmem[20] = 'd';                                                           
                                                                                
    while (1) {                                                                 
    }                                                                           
}

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:

i586-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra

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.

i586-elf-gcc -T kernel.ld -o sos-kernel.img -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

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.

i586-elf-objdump -s sos-kernel.img
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:

ASM = nasm
ASMFLAGS =
GCC = $(HOME)/opt/cross/bin/i586-elf-gcc
GCCFLAGS = -std=gnu99 -ffreestanding -O2 -Wall -Wextra

all: image kernel kernel-install

kernel:
  $(ASM) boot.asm -o boot.o -f elf
  $(GCC) -c kernel.c -o kernel.o $(GCCFLAGS)
  $(GCC) -T kernel.ld -o sos-kernel.img -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

kernel-install:
  sudo losetup /dev/loop1 sos.img -o 1048576
  sudo mount /dev/loop1 /mnt
  sudo cp sos-kernel.img /mnt/boot/
  sudo umount /mnt
  sudo losetup -d /dev/loop1
  
image:
  dd if=/dev/zero of=sos.img bs=512 count=131072
  sfdisk sos.img < sos.img.layout
  
  sudo losetup /dev/loop0 sos.img
  sudo losetup /dev/loop1 sos.img -o 1048576
  sudo mke2fs /dev/loop1
  sudo mount /dev/loop1 /mnt
  sudo grub-install --root-directory=/mnt --no-floppy --modules="normal ext2 part_msdos multiboot" /dev/loop0
  sudo cp sos-kernel.img /mnt/boot/
  sudo cp grub/* /mnt/boot/grub/
  sudo losetup -d /dev/loop0
  sudo umount /mnt
  sudo losetup -d /dev/loop1
  
clean:
  rm *.o
  rm sos-kernel.img
  rm sos.img

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.

Unser Kernel wird geladen, aber wir entfernen die alten Zeichen noch nicht

I do not maintain a comments section. If you have any questions or comments regarding my posts, please do not hesitate to send me an e-mail to blog@stefan-koch.name.