Um das Betriebssystem tOSh herunterzuladen, kannst du dies über den Bereich Resources dieser Website tun.
Teile
Diese Artikelserie wird aus folgenden Teilen bestehen:
- Teil I - Bootloader
- Teil II - Stage 1
- Teil III - Sprung zu C <— Du bist hier
- Teil IV - Interrupts
- Teil V - Dateisystem und Diskettenlaufwerk-Treiber
- Teil VI - Booten auf verschiedenen Maschinentypen
Armando Máquina de 1997
Zu C springen
Manchmal, während ich diese Serie schreibe, frage ich mich, ob ich wirklich jeden Begriff hinter jedem Fachausdruck erklären muss, den ich benutze, etwa Kernel, und ich weiß nie so recht, wie tief ich in meinen Artikeln gehen sollte. Dann packe ich einfach Links auf die Begriffe und das Thema ist für mich erledigt.
Während ich genau diesen Satz schreibe, frage ich mich gerade: Muss ich erklären, was ein Kernel ist? Ich glaube nicht. Wenn du das hier liest, gehe ich davon aus, dass du verdammt viel weißt. Und wenn nicht, packe ich trotzdem Links zu jedem technischen Begriff rein, den ich benutze, damit du selbst weiterforschen kannst.
Gut. Wir müssen einen Kernel bauen, weil wir bereits in ihn springen, aber wir haben noch überhaupt nicht darüber gesprochen, wie man anfängt, aus C heraus Bytes für ein Betriebssystem zu schreiben.
Das Erste und vielleicht Wichtigste, was du über Kernel-Entwicklung wissen musst, ist, dass du nicht einfach so programmieren kannst, zack zack, gcc oder clang schnappen, paff! ein main.c hinschmeißen, kompilieren und das Binary irgendwo hinpacken kannst, wo es dir gerade passt.
Neeeeeein! Was du zuerst machen musst, ist einen crosscompiler aufzusetzen, also einen Compiler, mit dem du Binaries für andere Architekturen erzeugen kannst, ohne von den Bibliotheken abhängig zu sein, die mit dem Zielbetriebssystem oder dessen ABI kommen, denn es gibt keine ABI.
Das heißt: keine Bibliotheken, keine Interfaces, keine Syscalls, gar nichts, weil du keinen Kernel hast, weil du gerade einen baust!
Es klingt vielleicht selbstverständlich, das zu erwähnen, aber wenn wir kurz darüber nachdenken, sind wir es gewohnt, Hunderte von Abstraktionsschichten zu haben, die uns davon abhalten, direkt mit der CPU zu interagieren, und Hunderte von Problemen lösen, die fast magisch mit ein oder zwei Funktionsaufrufen erledigt werden. Ein Standard-C-printf zu schreiben ist überraschend kompliziert und ein __rabbit hole__, in das ich
nicht heute, aber irgendwann einmal, hinabsteigen werde.
Nachdem das gesagt ist, empfehle ich dringend, den Artikel GCC Cross-compiler bei osdev zu lesen.
Von KMain, Linkern und Bash
Das wird vorerst unser Kernel. Das Einzige, was er machen wird, ist das Zeichen "X" an die Speicheradresse VGA xb8000 zu schreiben. Das ist der Speicherbereich, auf dessen Inhalt die Grafikkarte schaut, um ihn auf dem entsprechenden Monitor darzustellen, und irgendwann werden wir die CPU crashen, weil wir linear weiter bis 2^32 - 1 schreiben oder, noch besser, bis zum Limit des Speichers, den wir in der Hardware installiert haben.
__attribute__((section(".text.kmain_entrypoint")))
int kmain(void) {
char * vga = (char *) 0xb8000;
*vga = 'X';
while(1){
*(vga++) ='X';
};
char * str = "end of kernel code.";
return 1;
}
Um diesen Code zu kompilieren, brauchen wir mindestens das folgende Makefile.
Zuerst definieren wir, wo sich die Installation unserer Cross-kompilierten Version von GCC befindet, die wir nach den Anweisungen im Artikel GCC Cross-compiler bei osdev kompiliert haben.
ASM=nasm
CROSS_DIR=~/opt/cross/bin
CC=$(CROSS_DIR)/i686-elf-gcc
AS=$(CROSS_DIR)/i686-elf-as
LD=$(CROSS_DIR)/i686-elf-ld
Dann setzen wir die folgenden Compiler-Flags:
-
-m32sagt GCC, dass es 32-Bit-Code erzeugen soll. Das bedeutet, 32-Bit-Allzweckregister (eax, ebx, ebp, esp, edi, esi, etc…), 32-Bit-Pointer, 32-Bit-ABI, mit dem 32-Bit-Protected-Mode kompatible Instruktionen. -
-std=gnu99ist die C99-Version des C-Standards mit GNU-Erweiterungen aus dem Jahr 1999. -
-Ttext 0x10000ist ein Linker-Flag und sagt dem Linker, dass die.text-Section bei der Adresse0x10000beginnt. -
-ffreestandingsagen wir GCC, dass es nicht davon ausgehen soll, dass wir Standard-C-Runtime-Funktionen wiemalloc(),printf(), etc… haben. -
-fno-picdeaktiviert Position Independent Code. Zu wissen, dass der gesamte Kernel ab0x10000beginnt, reicht uns. -
-fno-asynchronous-unwind-tablesund-fno-unwind-tablesdeaktivieren die von GCC erzeugten Metadaten, um im Fehlerfall Exceptions, Tracebacks und Stacktraces zu rekonstruieren. Dadurch wird das Binary kleiner, aber gleichzeitig schränken wir unsere Debugging-Möglichkeiten ein. -
-march=i486setzen wir den Instruction Set weit zurück auf 486, um fortgeschrittene Instruktionen zu vermeiden (also Instruktionen aus der Pentium-Familie und aufwärts), wodurch die Kompatibilität mit älteren CPUs eingeschränkt wird. Vorerst läuft tOSh abi386. -
-Map kernel.mapist eine Linker-Option und dient dazu, eine Memory Map zu erstellen und die Offsets der einzelnen Funktionen und Sections im Binary zu finden.
CCFLAGS= -m32 -std=gnu99 -Ttext 0x10000
CCFLAGS+= -ffreestanding -O0 -Wall -Wextra -fno-pic
CCFLAGS+= -fno-asynchronous-unwind-tables
CCFLAGS+= -fno-unwind-tables
CCFLAGS+= -march=i486 # avoid instructions like cmovna to be generated by gcc
LDFLAGS= -Map kernel.map
Zum Schluss definieren wir ein Standard-Makefile. Nichts Verrücktes hier, wir haben .o, .map und etwas Besonderes: unsere linker.ld-Datei.
KERNEL_SRC=$(wildcard kernel/*.c)
KERNEL_OBJS=$(KERNEL_SRC:.c=.o)
KERNEL_BIN=kernel.bin
all: kernel
clean:
rm -rf *.bin
rm -rf *.o
rm -rf *.map
rm -rf *.img
rm -rf kernel/*.o
rm -rf *.lst
%.o: %.c
$(CC) -o $@ -c $< $(CCFLAGS)
kernel: $(KERNEL_OBJS)
$(LD) -o $(KERNEL_BIN) $^ $(LDFLAGS) -Tkernel/linker.ld
Das Linker-Script
Beim Kompilieren erzeugt GCC einige .o-Dateien, die Code und Daten enthalten, aber jede davon ist als unabhängiger Brocken gedacht. Außerdem hat jede .o mehrere Sections wie .text, .rodata, .bss, etc…
Was der Linker macht: Er nimmt all diese .o-Dateien, die beim Kompilieren einer oder mehrerer .c-Dateien entstehen, und baut daraus ein Programm, indem er jeden dieser OBJekt-Codes positioniert und entscheidet, wo genau jeder davon im Speicher landet.
Zusätzlich löst er Referenzen auf. Wenn die Funktion pepito_delicioso() in mi_libreria_de_pepitos.c programmiert ist, befindet sich dieser Code in mi_libreria_de_pepitos.o, und wenn meine kmain.c-Funktion sie aufruft, kümmert sich der Linker darum, die Referenz auf diese Funktion aufzulösen.
Die Datei linker.ld ist ein linker script. Sie sagt dem Linker, dass er die OBJ-Dateien nicht einfach irgendwohin packen soll, wo es ihm gerade passt, sondern dass du ihm vorgibst, wo jedes Ding im Speicher landen soll. Indem du neue Sections definierst oder festlegst, wo .text beginnt, hast du mehr Kontrolle über das layout deines Programms im Speicher.
OUTPUT_FORMAT("binary")
/*ENTRY(kmain)*/
SECTIONS
{
/* the start address of the kernel's .text section */
. = 0x10000;
.text BLOCK(4K) : ALIGN(4K)
{
/* *(.text.prologue) */
/*
set up the program entry point
kmain.c should have only one function,
and it's going to be the first section of code executed
when jumping to the kernel.
in the generated kernel.map file, kernel/kmain.o should be
set at address 0x0000000000010000
and void kmain(void); should be the first function written on top
of the program
*/
*(.text.kmain_entrypoint)
/* and later on, all the kernel objects */
*(.text)
}
.rodata BLOCK(4K) : ALIGN(4K)
{
*(.rodata)
}
.data BLOCK(4K) : ALIGN(4K)
{
*(.data)
}
.bss BLOCK(4K) : ALIGN(4K)
{
*(.bss)
}
.idt BLOCK(4K) : ALIGN(4K)
{
*(.idt)
}
.dma BLOCK(4K) : ALIGN(4K)
{
*(.dma)
}
end = .;
}
Ganz am Anfang definieren wir OUTPUT_FORMAT(\"binary\"). Dadurch erzeugt der gcc-Linker direkt ein .bin statt eines ausführbaren .elf, ohne Header, Tabellen, Debugging-Symbole, gar nichts. Ein Flat Binary.
Unser Kernel weiß nichts über Binärformate oder so einen Kram, und wenn wir in ein .elf springen würden, würde die CPU die Bytes des ELF-Format-Headers ausführen und dabei crashen.
Mit . = 0x10000; innerhalb von SECTIONS sagen wir dem Linker, dass er ab dieser Adresse anfangen soll, die Binaries zu platzieren. Das zeigt auf .text. Das heißt, dass das, was wir dem Makefile mitgegeben haben, wo wir festlegen, wo die .text-Section beginnt, redundant ist.
Danach muss jede Section ein Block sein und außerdem auf "4-Kilobyte-Seiten" ausgerichtet sein.
Diese Größe, 4 KB, ist in x86 als Page definiert. Die Sections müssen also nicht nur aus 4-KB-Blöcken bestehen, sondern auch auf 4-KB-Grenzen ausgerichtet sein.
Die erste ausführbare Section heißt .text.kmain_entrypoint. Indem wir sie so definieren, sorgen wir dafür, dass die C-Funktion, die dieses Attribut besitzt, genau dort landet, also an der Speicheradresse 0x10000, die wir bereits definiert haben. Damit stellen wir sicher, dass diese Funktion als erste ausgeführt wird.
Wenn du weiter oben in den C-Code schaust, den ich zuerst gezeigt habe, siehst du über kmain() das Attribut, das der Linker aus dem Script übernimmt, damit es das Allererste ist, was ausgeführt wird:
/*
0x10000 <-- arrancando en esta dirección de memoria
*(.text.kmain_entrypoint) <-- primero mete esta función
*(.text) <-- despues el resto de funciones
+-------------------------------+
| kmain_entrypoint | <- PRIMERO
+-------------------------------+
| otras funciones .text |
| configurar_cosas() |
| contemplar_existencia() |
| handlear_interrupciones() |
| etc. |
+-------------------------------+
*/
__attribute__((section(".text.kmain_entrypoint")))
void kmain(void) { [...] }
Die folgenden Sections wie .bss, .rodata und .data sind Standard, aber die danach nicht; sie sind spezifisch für das Design meines Kernel.
Die .idt-Section werden wir später brauchen, wenn wir die Interrupt-Tabelle ungefähr so definieren:
.idt BLOCK(4K) : ALIGN(4K)
{
*(.idt)
}
__attribute__((section(".idt")))
struct idt_entry idt[256];
Dasselbe machen wir mit der .dma-Section, die wir später ebenfalls verwenden werden, um einige Buffer zu speichern:
.dma BLOCK(4K) : ALIGN(4K)
{
*(.dma)
}
__attribute__((section(".dma")))
uint8_t dma_buffer[...];
Also, grob gesagt, würde das Memory-Layout des Kernel ungefähr so aussehen:
0x10000
+--------------------------+
| .text |
| |
| kmain() | <- primera función de todas, el entrypoint
| fs_init() |
| fs_read() |
| idt_init() |
| ... |
+--------------------------+
| .rodata |
| strings / constantes |
+--------------------------+
| .data |
| variables inicializadas |
+--------------------------+
| .bss |
| variables sin init |
+--------------------------+
| .idt |
| IDT |
+--------------------------+
| .dma |
| DMA buffers |
+--------------------------+
| |
| end |
+--------------------------+
La Gotita
Zum Schluss müssen wir alle Puzzleteile mit "La Gotita" zusammenkleben und ein Image haben, das wir qemu, bochs oder einer echten Maschine hinklatschen können. Dafür müssen wir ein Image erzeugen, das bootloader, stage1 und den kernel enthält, wobei die einzelnen Binaries so positioniert sein müssen, dass bootloader und stage1 die Sachen in den Speicher kopieren können, ohne dass sich die Binaries beim erneuten Kompilieren und Programmieren gegenseitig überschreiben und mit genügend Abstand zueinander liegen.
Dafür mussten wir uns mit einem Blatt Papier hinsetzen und ausrechnen, wie viel ausreichend Platz wir brauchen, wobei "ausreichend" eine Variable ist, die sich während der Entwicklung dieses Kernel ziemlich oft geändert hat. Ungefähr so sieht das Layout der 3,5"-1,44-MB-Floppy aus, die ich benutze, um das Betriebssystem darauf unterzubringen (Update August 2026):
| Sektoren | Offset | Inhalt | Größe |
|---|---|---|---|
| 1 | 0x000000 |
Bootsektor (boot.bin) |
512 B |
| 2–8 | 0x000200–0x000FFF |
Reservierter Bereich / Stage1 | bis zu 7 Sektoren |
| 9–136 | 0x001000–0x10FFF |
Kernel (kernel.bin) |
bis zu 128 Sektoren |
| 137–2880 | 0x11000–0x167FFF |
Filesystem / verbleibender Platz | ~1.37 MB |
#!/bin/sh
echo "Cleaning up..."
make clean
echo "Compiling tOSh Kernel..."
make
kernel_size=$(stat -c %s kernel.bin)
kernel_sectors=$(( (kernel_size + 511) / 512 ))
MAX_KERNEL_SECTORS=128
KERNEL_FIRST_SECTOR=9
echo "kernel size: $kernel_size bytes, kernel sectors: $kernel_sectors"
if [ "$kernel_sectors" -gt "$MAX_KERNEL_SECTORS" ]; then
echo "ERROR: kernel too large!"
echo "Maximum: $((MAX_KERNEL_SECTORS * 512)) bytes"
exit 1
fi
echo "Compiling bootsector and stage1..."
echo "taking into account the kernel size of $kernel_size bytes, which is $kernel_sectors sectors..."
echo "bootsector.asm -> boot.bin"
nasm -f bin bootsector.asm -o boot.bin
echo "Kernel first sector: $KERNEL_FIRST_SECTOR"
echo "stage1.asm -> stage1.bin with KERNEL_FIRST_SECTOR=$KERNEL_FIRST_SECTOR and KERNEL_SECTORS=$kernel_sectors"
nasm -D KERNEL_FIRST_SECTOR=$KERNEL_FIRST_SECTOR -D KERNEL_SECTORS=$kernel_sectors -f bin -l stage1.lst stage1.asm -o stage1.bin
stage1_size=$(stat -c%s stage1.bin)
stage1_sectors=$(( (stage1_size + 511) / 512 ))
echo "Stage1 size: $stage1_size bytes, $stage1_sectors sectors"
#nasm -f bin kernel/kernel.asm -o kernel.bin
echo "Creating floppy image..."
dd if=/dev/zero of=tOSh.img bs=512 count=2880
echo "Copying Bootsector at position 0, block 512 bytes..."
dd if=boot.bin of=tOSh.img conv=notrunc # copy the bootsector
echo "Copying stage1 at position 1, block 512 bytes..."
dd if=stage1.bin of=tOSh.img conv=notrunc bs=512 seek=1 # copy stage1
echo "Copying kernel at position $((KERNEL_FIRST_SECTOR -1)) (sector $KERNEL_FIRST_SECTOR), block 512 bytes..."
dd if=kernel.bin of=tOSh.img conv=notrunc bs=512 seek=$(($KERNEL_FIRST_SECTOR -1)) # copy kernel
Wenn wir dieses Bash-Script ./build.sh ausführen, wird das Betriebssystem als Floppy-Image erzeugt, das bereit ist, auf einer Maschine oder in einem Emulator gebootet zu werden. Bis hierhin machen wir nichts weiter, als X in den Speicher zu schreiben (beginnend beim VGA-Mapping), bis die CPU crasht, weil wir außerhalb der Speichergrenzen schreiben.
Das ist das "grundlegende" Framework, um mit der Entwicklung eines Kernel in C beginnen zu können. Ab hier können wir die CPU weiter konfigurieren, Treiber dafür programmieren und alle Vorzüge einer Hochsprache wie C nutzen und damit Assembly hinter uns lassen.