Den kompletten Linux-Kernel lesen und verstehen – das wäre ein anspruchsvolles Projekt. In einem Hobby-Kernel implementieren Sie Standard-OS-Features selbst und beschränken sich dabei auf wenige Hundert Zeilen Code.
Alle, die professionell mit Linux arbeiten, sind es gewohnt, Software aus den Quellen zu übersetzen, vielleicht kleinere Änderungen einzubauen, Routinearbeiten mit Shell-Skripten zu automatisieren oder gleich eigene Software in einer der zahlreichen aktuellen Programmiersprachen zu entwickeln. An einem eigenen, völlig neuen Betriebssystem zu basteln, ist hingegen eine eher ungewöhnliche Aktivität: Fängt man bei null an, braucht man lange, bis sich das System für irgendetwas nutzen lässt.
Wer ein anspruchsvolles Hobbyprojekt sucht oder (zum Beispiel im Rahmen eines Informatikstudiums) die theoretischen Grundlagen von Interrupts, Speicherverwaltung, Scheduling und so weiter besser verstehen möchte, für den ist die Arbeit an einem eigenen Kernel interessant. Auch Linus Torvalds hat einmal so angefangen [1]: “I’m doing a (free) operating system (just a hobby, won’t be big and professional like gnu) for 386(486) AT clones.”
Eine Entwicklungsumgebung für Betriebssysteme ist komplexer als eine für Anwendungen, denn man kann den Quellcode nicht einfach übersetzen und probeweise laufen lassen. Stattdessen gilt es, einen bootfähigen Datenträger zu erstellen, mit dem man dann eine VM oder einen emulierten PC booten kann. Es erleichtert die Arbeit, wenn es dabei Debugging-Möglichkeiten gibt. Als hilfreiche Tools haben sich Qemu [2] und Bochs [3] erwiesen.
Ein Weg, mit möglichst geringem Aufwand einen bootfähigen Datenträger zu erstellen und ihn regelmäßig mit einer neuen Version des eigenen Kernels zu aktualisieren, setzt auf ein FAT-formatiertes Disketten-Image mit dem Bootmanager Grub 1 [4] (Abbildung 1).
In diesem Artikel geht es in erster Linie um die Werkzeuge, die unter Linux bei der Entwicklung helfen. Beispieldateien zum Ausprobieren und Links zu ausführlicheren Erklärungen der theoretischen Grundlagen hat der Autor online gesammelt [5].
Basics
Die klassische Programmiersprache für die Betriebssystem-Entwicklung ist C – nicht C++ oder C#, sondern das alte prozedurale C. Daneben gilt es, Teile in Assembler zu schreiben, wahlweise in Intel- oder AT&T-Syntax [6].
Parallel zum Betriebssystem muss eine Sammlung von Test- und Hilfsprogrammen entstehen, mit denen man neu entwickelte OS-Features prüft; auch eine (zumindest rudimentäre) Shell muss her. Eine grafische Oberfläche zu programmieren, bevor die wichtigsten Grundfunktionen rund um Prozessverwaltung und Dateisystem stabil arbeiten, ist eher eine schlechte Idee.
“Prozessverwaltung” setzt dabei schon implizit voraus, dass das Betriebssystem Multitasking unterstützen soll – das ist aber nicht zwingend, denn auch die Entwicklung eines MS-DOS-Klons kann reizvoll sein.
Zwar kann man bei der Konzeption und Implementierung des eigenen Betriebssystems seiner Kreativität freien Lauf lassen, doch es gibt einige Stellen, an denen der Entwickler pedantisch die Beschreibungen aus Standarddokumenten umsetzen muss. Zwei Beispiele dafür sind Speicherverwaltung und Dateisysteme.
Segmente und Seiten
Um das moderne Speicherverwaltungsverfahren Paging [7] auf einem 32-Bit-Intel-Prozessor zu implementieren, muss man zwingend zunächst das ältere Verfahren der Segmentierung [8] aktivieren. Die Intel-Dokumentation beschreibt, wie man Segmente in der Global Descriptor Table (GDT) definiert und wie man dann den Prozessor in den Protected Mode schaltet und ihn dazu überredet, diese Tabelle zu verwenden. Erst danach lässt sich eine zweistufige Seitentabelle aufbauen und aktivieren.
Abbildung 2 zeigt den Aufbau eines acht Byte (64 Bit) breiten Eintrags in der GDT. Neben einigen speziellen Optionen sind hier vor allem zwei Werte gespeichert: die 32-bittige base address als Startadresse eines Segments und das 20-bittige limit, aus dem die Größe des Segments berechnet wird. Sie liegen aber in einem gewöhnungsbedürftigen Format vor: limit und base address sind durch andere Attribute unterbrochen. Das hat historische Gründe, weil die GDT aus einer kleineren Datenstruktur älterer Intel-CPUs entstand.

Abbildung 2: Viele Datenstrukturen im Kernel sind durch die Hardware exakt vorgegeben, hier die Intel-x86-Segmenttabelle (GDT).
Entscheidend ist, dass das Betriebssystem diese Datenstrukturen exakt in der von Intel vorgeschriebenen Form erzeugt und mit Inhalten füllt: Hier gibt es keinen Raum für kreative Abweichungen oder Verbesserungen, weil der Prozessor nur exakt das vorgegebene Format akzeptiert.
Dateisystem
Mehr Freiheit hat der Entwickler bei der Konzeption eines (logischen) Dateisystems, also bei der Frage, welche Blöcke auf einem Datenträger welche Verwaltungs- und Nutzdaten speichern und welche Features das Dateisystem bietet. Für ein Unix-ähnliches System sind mindestens die Features des Minix-Dateisystems [9] nötig; es sollte also Inodes mit Unix-Zugriffsrechten sowie User- und Gruppen-IDs, Verzeichnisse, symbolische und Hard Links sowie Spezialdateien (Block- und Zeichengeräte, Pipes, Sockets) geben.
Strebt man ansonsten keine Kompatibilität zu anderen Dateisystemen an, bleibt dabei reichlich Spielraum für neuartige Ansätze. Soll das Betriebssystem hingegen Datenträger eines anderen OS lesen und schreiben können, steht wieder die Lektüre von Standards an. Die legen aufs Byte genau fest, was es wo in welchen Block zu schreiben gilt.
Kernel-Entwicklung
Betriebssysteme bilden eine Schnittstelle zwischen Anwendungen und Hardware. Entsprechend steht zu erwarten, dass im Kernel häufig direkte Hardware-Zugriffe stattfinden. Das läuft über privilegierte Maschinensprachebefehle wie »in« und »out«. Außerdem gilt es, spezielle Prozessorregister mit Inhalten zu füllen, die zum Beispiel festlegen, in welcher Betriebsart die CPU läuft.
Kernel-Binaries haben nicht die üblichen Programm-Header (wie Linux-ELF- oder Windows-PE-Dateien) und können auch nicht beim Laden on the fly mit Bibliotheken gelinkt werden: Es gibt beim Start noch keinen Linker, der das leisten könnte.
Wer einen Debugger nutzt, kann durch das Setzen von Breakpoints und das Inspizieren von Register- und Speicherinhalten im laufenden System prüfen, ob neu hinzugefügter Code wie gewünscht arbeitet. Die CPU ändert einige Speicherbereiche autonom, also ohne dass ein Speicherschreibbefehl im Programm vorkommt. Das gilt zum Beispiel für Einträge in den Seitentabellen, bei denen der Prozessor selbstständig Access- und Dirty-Bits für einzelne Speicherseiten setzt.
Aktuelle Linux-Distributionen sind meist 64-bittig, und damit arbeiten auch Linker, Compiler und weitere Tools standardmäßig im 64-Bit-Modus. Um einen 32-Bit-Betriebssystem-Kernel zu erzeugen, muss man den Tools darum Optionen mit auf den Weg geben, die auf den entsprechenden Betrieb umschalten. Die Vorgaben für Gcc, Nasm und Ld zeigt Listing 1.
Listing 1
32-Bit-Betrieb
CFLAGS=-O0 -m32 ASMFLAGS=-f elf LDFLAGS=-m elf_i386
Einen gut verständlichen und kompakten Einstieg in die x86-Kernel-Entwicklung gibt “Bran’s Kernel Development Tutorial”, das sich aktuell nur via Archive.org auffinden lässt [10]. Es führt in mehreren Sitzungen zu einem im Emulator bootenden Mini-Kernel, der den Protected Mode des Intel-Prozessors aktiviert, eine Segmenttabelle für die Speicherzugriffe anlegt, zwei Interrupt-Handler (für Tastatur und Timer-Baustein) enthält sowie die Interrupt-Handler-Liste erzeugt und Interrupts einschaltet.
Wer weitere Betriebssystem-Features ergänzen möchte, kann zum Beispiel die Unterlagen zum Kurs “Betriebssystem-Entwicklung” der TH Nürnberg [11] durcharbeiten. Einige der nötigen Dateien, weitere Links und eine vorkonfigurierte VM mit allen Entwicklungstools finden sich im Netz [5].
Tools einrichten
Neben den üblichen Werkzeugen einer Entwicklungsumgebung benötigt man auch einen Assembler, weil die Startroutine des Betriebssystems keine gewöhnliche »main()«-Funktion ist. Zum Testen des Kernels bietet sich ein Emulator wie Qemu oder Bochs an.
Als Testsystem diente dem Autor ein Rechner mit Ubuntu 20.04, auf dem die nötigen Pakete mit den Kommandos aus Listing 2 schnell installiert waren. Die grafische Oberfläche des in Bochs integrierten Debuggers arbeitete hier allerdings nicht fehlerfrei, was sich durch ein Downgrade auf eine ältere Bochs-Version beheben ließ (siehe Kasten “Probleme mit Bochs 2.6.11”).
Listing 2
Installation
$ apt install build-essential nasm qemu-system-x86 bochs bochs-x $ sudo ln -s $(which qemu-system-i386) /usr/bin/qemu
Probleme mit Bochs 2.6.11
Alle Versuche, mit der aktuellen Bochs-Version 2.6.11 unter Ubuntu 20.04 Systemtabellen wie die GDT oder die IDT anzuzeigen, führten dazu, dass das Emulator- und das Debug-Fenster einfroren. Auch im Netz finden sich vereinzelte Hinweise auf Probleme in dieser Version. Für Ubuntu 20.04 ließ sich das Problem durch Entfernen (»apt remove« …) der drei Pakete bochs, bochs-x und bochsbios sowie deren Neuinstallation in Version 2.6.9 lösen. Daneben muss man libtinfo5 und libreadline7 einspielen (Listing 3). Statt Pfade abzutippen, kann man die Pakete auch via im Launchpad [12] oder über eine Websuche nach “bochs 2.6.9 ubuntu” finden.
Listing 3
Bochs-2.6.11-Problem lösen
$ sudo apt install libtinfo5 $ wget http://de.archive.ubuntu.com/ubuntu/pool/main/r/readline/libreadline7_7.0-3_amd64.deb $ for pkg in 396601834/bochsbios_2.6.9+dfsg-2_all.deb 396601839/bochs-x_2.6.9+dfsg-2_amd64.deb 396601840/bochs_2.6.9+dfsg-2_amd64.deb; do wget https://launchpadlibrarian.net/$pkg; done $ sudo dpkg -i bochs*.deb libreadline*.deb
Bootmanager
Als Ergebnis der Kernel-Entwicklung entsteht nach dem Kompilieren, Assemblieren und Linken ein Kernel-Image. Das muss der Rechner beim Einschalten an eine geeignete Speicherstelle laden und aktivieren. Aus BIOS oder UEFI heraus lädt er dazu auf dem ausgewählten Datenträger einen Bootsektor, der weitere Instruktionen fürs Nachladen des Kernels enthält.
Linux setzte dafür in frühen Versionen auf Lilo (Linux Loader); dessen Nachfolger Grub [3] ist flexibler und eignet sich auch ausgezeichnet, um einen eigenen Kernel zu starten. Ein vorbereitetes Diskettenabbild im FAT-Format mit Grub-Installation gibt es auf der Github-Seite von Christian Steinrücken [13]. Die Datei »bootgrub.gz« muss man nur noch per Gunzip entpacken und kann sie dann mounten oder mit den Mtools manipulieren.
Das Floppy-Image im FAT-Format erlaubt es, ohne Mounten Dateien im Image zu verändern: Über das Kopierprogramm Mcopy aus den Mtools [14] gelingt das Update der Kernel-Datei ohne Root-Rechte mit folgendem Befehl (inklusive der beiden Doppelpunkte am Ende):
$ mcopy -o -i kernel.img MeinKernel.bin ::
Alternativ kann man ein Minix-formatiertes Grub-Floppy-Image verwenden, muss dann aber Änderungen am Image mit einem Dreischritt aus »mount«, »cp« und »umount« erledigen.
Debugging
Die Fehlersuche in einem Betriebssystem ist schwieriger als bei Anwendungen, die als Prozesse laufen. Für die einfachste Form des Debuggings fügt man informative Ausgaben in den Code ein, die auf dem Bildschirm Speicheradressen, Variableninhalte und andere Zustandsinformationen ausgeben.
Um dabei nicht ständig die reguläre Bildschirmausgabe mit Debug-Informationen zu vermischen, kann man im Emulator auf einen virtuellen seriellen oder parallelen Port schreiben und diesen in eine Datei (oder ein Terminalfenster) umleiten.
Für Qemu sorgt die Zusatzoption »-serial mon:stdio« dafür, dass Ausgaben auf dem seriellen Port im Terminal erscheinen, aus dem der Emulator gestartet wurde. Bochs erlaubt das Umleiten serieller Ausgaben in eine Datei, sofern man in der Bochs-Konfiguration (».bochsrc«) eine passende Zeile einfügt (Listing 4). Statt einer Datei kann das Ziel via »mode=term, dev=/dev/pts/5« auch ein Terminalfenster sein, dessen Gerätedatei der Entwickler vorab mit »tty« abfragt.
Listing 4
Umleitung serieller Ausgaben
com1: enabled=1, mode=file, dev=serial.out
Listing 5 zeigt eine einfache Implementierung der Funktion »uartputc«, die ein einzelnes Zeichen an die erste serielle Schnittstelle (COM1) schickt: Sie verwendet dafür den Port 0x3f8. Die benutzten Funktionen »outportb« und »inportb« führen via Inline-Assembler die x86-Instruktionen »out« und »in« aus.
Listing 5
Debug-Ausgabe auf COM1
#define COM1_BASE 0x3f8 // erster serieller Port
void uartputc (char c) {
while (inportb (COM1_BASE+5) & 0x20) == 0) ;
outportb (COM1_BASE+0, c);
}
char inportb (short port) {
char wert;
asm ("inb %1, %0" : "=a" (rv) : "dN" (port));
return wert;
}
void outportb (short port, char wert) {
asm ("outb %1, %0" : : "dN" (port), "a" (wert));
}
Bochs verfügt über einen integrierten Textmodus-Debugger, den ein grafisches Frontend (im Paket bochs-x) etwas komfortabler macht. Am unteren Rand (oberhalb der Statuszeile) findet sich eine Eingabezeile, in der man zum Beispiel mit »lb 0xc0101496« einen Breakpoint setzt. Springt der emulierte Rechner an diese Adresse, unterbricht Bochs die Ausführung.
Mit [F11] kann der Entwickler nun schrittweise einen Maschinensprachebefehl nach dem anderen ausführen; den disassemblierten Code zeigt Bochs in der mittleren Spalte an (Abbildung 3). Die relevante Adresse lässt sich vorab über einen Blick in die Symboltabelle herausfinden, die zum Beispiel der Befehl aus Listing 6 aus dem Kernel-Binary erzeugt

Abbildung 3: Das Bochs-GUI zeigt Registerinhalte, disassemblierten OS-Code und ausgewählte Inhalte an (hier die GDT).
Listing 6
Symboltabelle erzeugen
$ objdump -M intel -D kernel.bin | grep -e '^[^ ]* <' | sed -e 's/<//' -e 's/>://'
Auch mit Qemu lässt sich der Kernel debuggen. Startet Qemu mit der Option »-s«, lauscht es lokal auf TCP-Port 1234 und lässt sich darüber mit Gdb fernsteuern. Nun ruft man den Debugger mit dem Kernel-Binary als Parameter auf – dann kennt er die symbolischen Namen, also Funktionen und Variablen – und baut dann aus Gdb heraus mit »target remote tcp::1234« eine Verbindung zum Kernel auf. Anschließend kann man in Gdb den Kernel anhalten und sich zum Beispiel mit »disassemble« den Assembler-Code einzelner Funktionen anzeigen lassen (Abbildung 4).
Ausblick
Durch Experimentieren mit den Beispieldateien zu diesem Artikel [5] lassen sich leicht weitere Features ergänzen. Wer daraus ein richtiges Unix-System mit Multitasking und einigen Standard-Shell-Tools machen möchte, kann einen Blick auf Ulix [15] werfen: Der Autor dieses Artikels hat das Unix-ähnliche 32-Bit-System für den Einsatz im Informatikstudium entwickelt und in einem 700-seitigen Buch [16] den kompletten Sourcecode dokumentiert. Das Buch führt parallel in die theoretischen Grundlagen für Betriebssysteme ein. Eine erstaunlich lange Übersicht anderer Hobby-Betriebssysteme gibt es bei OSDev.org [17]. Wer einen eigenen Kernel basteln möchte, ist also keineswegs allein. (uba/jlu)
Der Autor
Hans-Georg Eßer ist Professor für Betriebssysteme an der FH Südwestfalen. Vor seinem Wechsel in die akademische Laufbahn arbeitete er viele Jahre für Schwesterzeitschriften des Linux-Magazins, zuletzt als Chefredakteur von EasyLinux.
Infos
-
Posts von Linus Torvalds: https://en.wikiquote.org/wiki/Linus_Torvalds
-
Qemu: https://www.qemu.org
-
Bochs: http://bochs.sourceforge.net
-
Beispieldateien: http://swf.hgesser.de/d/kernel/
-
x86-Assembler-Sprache: https://en.wikipedia.org/wiki/X86_assembly_language
-
Segmentierung: https://de.wikipedia.org/wiki/Segmentierung_(Speicherverwaltung)
-
Minix: http://www.minix3.org/
-
Kernel-Tutorial: https://web.archive.org/web/20191123051335/http://www.osdever.net/tutorials/view/brans-kernel-development-tutorial
-
Betriebssystem-Entwicklung, TH Nürnberg: http://ohm.hgesser.de/be-ws2015/
-
Bochs 2.6.9 für Ubuntu: https://launchpad.net/ubuntu/+source/bochs/2.6.9+dfsg-2/+build/15625627
-
Grub-FAT-Bootdiskette: https://q4.github.io/bootgrub.html
-
Ulix: http://ulixos.org
-
“The Design and Implementation of the ULIX Operating System”: http://ulixos.org/doc/ulix-book-0.13.pdf
-
OSDev.org-Wiki: https://wiki.osdev.org/Projects








