Zwei Welten: Benutzermodus und Kernelmodus
Damit der Kernel Programme voneinander und vor sich selbst schützen kann, braucht er Hilfe von der Hardware. Jede moderne CPU kennt mindestens zwei Betriebsarten:
- Im KernelmodusKernelmodusPrivilegierter Betriebsmodus der CPU, in dem alle Befehle und der gesamte Speicher zugänglich sind. Auf x86 Ring 0. sind alle Befehle erlaubt und der gesamte Speicher ist zugänglich.
- Im BenutzermodusBenutzermodusEingeschränkter Betriebsmodus der CPU für Anwendungen. Privilegierte Befehle lösen eine Ausnahme aus. Auf x86 Ring 3. sind privilegierte Befehle gesperrt, und nur die für den Benutzermodus freigegebenen Speicherseiten sind erreichbar.
Auf ARM64 heißen die Privilegstufen Exception Levels: Anwendungen laufen in EL0, der Kernel in EL1, ein Hypervisor in EL2 und Firmware in EL3. RISC-V unterscheidet entsprechend User, Supervisor und Machine Mode.
Privilegiert sind alle Befehle, mit denen sich die Schutzmechanismen aushebeln ließen, zum Beispiel:
cli/sti: Interrupts sperren und freigeben, sonst könnte ein Programm den Scheduler blockieren,movnachCR3: auf andere Seitentabellen umschalten, also in fremde Adressräume wechseln,wrmsr: modellspezifische Register beschreiben, etwa die Einsprungadresse für Systemaufrufe,hlt: den Prozessor anhalten.
Versucht ein Programm im Benutzermodus einen solchen Befehl, löst die CPU eine Ausnahme aus (auf x86 eine General Protection Fault). Der Kernel übernimmt und beendet das Programm in der Regel mit dem Signal SIGSEGV.
Speicherschutz über Seitentabellen
Der zweite Baustein ist die MMUMMU (Memory Management Unit)Hardware-Baustein der CPU, der virtuelle in physische Adressen übersetzt und dabei Zugriffsrechte prüft.. Jeder Eintrag in den Seitentabellen enthält neben der physischen Adresse einige Bits mit Zugriffsrechten, darunter das User/Supervisor-Bit. Seiten des Kernels sind als „nur Supervisor“ markiert. Greift Code im Benutzermodus darauf zu, meldet die MMU einen Seitenfehler, und der Zugriff scheitert, obwohl die Seite im Adressraum eingeblendet ist.
Der virtuelle Adressraum
Jeder Prozess sieht einen eigenen virtuellen Adressraum. Die MMU übersetzt jede Adresse anhand der Seitentabellen des Prozesses in eine physische Adresse. Wie das genau funktioniert, behandelt Kapitel 7. Hier interessiert zunächst die grobe Aufteilung:
- Die untere Hälfte (User Space) ist für jeden Prozess anders. Dort liegen Programmcode, Daten, Heap, eingeblendete Bibliotheken und der Stack.
- Die obere Hälfte (Kernel Space) ist in allen Prozessen gleich eingeblendet. Deshalb muss der Kernel bei einem Systemaufruf nicht den Adressraum wechseln und kann direkt auf den Puffer des aufrufenden Prozesses zugreifen, solange PTI nicht aktiv ist.
- Ein Teil des Kernel-Adressraums bildet den gesamten physischen Arbeitsspeicher linear ab (direct map). So erreicht der Kernel jede physische Seite über eine einfache Adressrechnung.
Tiefer eintauchenKanonische Adressen und 5-stufige Seitentabellen
Obwohl Register auf x86-64 64 Bit breit sind, nutzen CPUs mit 4-stufigen Seitentabellen nur 48 Bit für virtuelle Adressen. Die Bits 63 bis 48 müssen eine Kopie von Bit 47 sein. Adressen, die diese Regel erfüllen, heißen kanonisch. Damit entstehen zwei gültige Bereiche, die untere und die obere Hälfte, und dazwischen eine riesige Lücke. Ein Zugriff auf eine nicht-kanonische Adresse löst sofort eine Ausnahme aus.
Neuere Prozessoren unterstützen 5-stufige Seitentabellen (LA57) mit 57 Bit virtuellen Adressen. Der User Space wächst dann von 128 TiB auf 64 PiB. Linux aktiviert diesen Modus nur, wenn die CPU ihn unterstützt und der Kernel entsprechend konfiguriert ist.
Drei Wege in den Kernel
Der Kernel ist kein Prozess, der ständig nebenher läuft. Er wird aktiv, wenn etwas passiert: Die CPU unterbricht den laufenden Code und springt an eine vom Kernel festgelegte Adresse. Dafür gibt es genau drei Anlässe:
| Systemaufruf | Ausnahme (Exception) | Interrupt | |
|---|---|---|---|
| Auslöser | Programm bittet bewusst um einen Dienst | der gerade ausgeführte Befehl | ein Gerät oder ein Timer |
| Zeitpunkt | synchron, gewollt | synchron, meist ungewollt | asynchron, jederzeit |
| Beispiele | read(), fork(), mmap() | Seitenfehler, Division durch null, ungültiger Befehl | Netzwerkpaket angekommen, Timer abgelaufen, Taste gedrückt |
| Mehr dazu | Kapitel 4 | Kapitel 7 und Kapitel 8 | Kapitel 8 |
Nicht jede AusnahmeAusnahme (Exception)Von der CPU selbst ausgelöste Unterbrechung als Folge des gerade ausgeführten Befehls, etwa ein Seitenfehler oder eine Division durch null. ist ein Fehler. Ein Seitenfehler ist meist völlig normal: Er zeigt dem Kernel, dass eine Seite erst jetzt tatsächlich gebraucht wird, etwa weil sie noch von der Festplatte geladen oder erstmals mit Speicher hinterlegt werden muss. Dieses „verzögerte“ Bereitstellen von Speicher (demand paging) ist ein zentrales Prinzip der Speicherverwaltung.
Prozesskontext und Interruptkontext
Für die Kernel-Programmierung ist entscheidend, in wessen Auftrag Kernel-Code gerade läuft:
- Im Prozesskontext arbeitet der Kernel für einen bestimmten Task, etwa während eines Systemaufrufs. Das Makro
currentzeigt auf dessentask_struct. Der Code darf schlafen, also blockieren und warten, zum Beispiel auf Daten von der Festplatte. - Im Interruptkontext reagiert der Kernel auf ein Hardware-Ereignis. Der unterbrochene Prozess hat damit nichts zu tun. Hier darf der Code niemals schlafen, denn es gibt keinen Task, den der Scheduler stattdessen schlafen legen könnte.
Die Subsysteme des Kernels
Intern ist der Kernel in Subsysteme gegliedert, die jeweils einen klar umrissenen Aufgabenbereich haben und meist einem eigenen Verzeichnis im Quellcode entsprechen. Jedes Subsystem hat eigene Maintainer, die Änderungen prüfen und an Linus Torvalds weiterreichen.
Klicke auf einen Baustein, um mehr zu erfahren.
Systemaufruf-Schnittstelle
Der einzige reguläre Weg aus dem User Space in den Kernel. Einige hundert Systemaufrufe bilden die stabile Schnittstelle zu allen Anwendungen – Linus Torvalds’ oberste Regel lautet: „We don’t break user space.“
Prozessverwaltung & Scheduler
Erzeugt und beendet Prozesse und Threads (beide sind für den Kernel „Tasks“) und entscheidet, welcher Task wann auf welcher CPU läuft. Zentrale Datenstruktur ist struct task_struct.
Speicherverwaltung
Verwaltet den physischen Arbeitsspeicher in Seiten (meist 4 KiB), richtet für jeden Prozess einen virtuellen Adressraum ein, behandelt Seitenfehler und hält den Page Cache, der Dateiinhalte im RAM zwischenspeichert.
Virtuelles Dateisystem (VFS)
Eine einheitliche Abstraktionsschicht über allen Dateisystemen. open(), read() und write() funktionieren dadurch gleich – egal ob die Datei auf ext4, Btrfs, NFS oder in einem virtuellen Dateisystem wie /proc liegt.
Netzwerk-Stack
Implementiert Sockets und die Protokolle von Ethernet über IP bis TCP/UDP, dazu Routing, Paketfilterung (Netfilter) und schnelle Paketverarbeitung mit XDP.
Interprozesskommunikation
Mechanismen, mit denen Prozesse Daten austauschen oder sich synchronisieren: Pipes, Signale, System-V- und POSIX-IPC, Shared Memory und Futexe.
Block-Schicht
Vermittelt zwischen Dateisystemen und Blockgeräten wie SSDs oder Festplatten. Sie sammelt, sortiert und verteilt I/O-Anfragen (blk-mq) auf die Hardware-Warteschlangen.
Sicherheit (LSM)
Das Linux Security Module Framework setzt an vielen Stellen im Kernel Prüfpunkte (Hooks). Module wie SELinux, AppArmor oder Landlock entscheiden dort zusätzlich zu den klassischen Dateirechten über Zugriffe.
Gerätetreiber
Der mit Abstand größte Teil des Quellcodes. Treiber steuern konkrete Hardware an und melden sich über das Gerätemodell bei den passenden Subsystemen (Block, Netzwerk, Eingabe, Grafik …) an. Viele lassen sich als Module zur Laufzeit laden.
Architekturspezifischer Code
Alles, was von der CPU-Architektur abhängt: Einsprungpunkte für Systemaufrufe und Interrupts, Kontextwechsel, Aufbau der Seitentabellen, früher Bootcode. Linux unterstützt rund 20 Architekturen, darunter x86, ARM64 und RISC-V.
Zusammenspiel am Beispiel: cat notizen.txt
Ein einfacher Befehl in der Shell zeigt, wie eng die Subsysteme zusammenarbeiten:
- Prozessverwaltung: Die Shell erzeugt mit
fork()(genauerclone()) einen neuen Prozess, der mitexecve()das Programm/usr/bin/catlädt (Kapitel 5). - Speicherverwaltung:
execve()baut einen neuen Adressraum auf. Programmcode und Bibliotheken werden nur eingeblendet. Erst beim ersten Zugriff lösen Seitenfehler das tatsächliche Laden aus. - VFS:
catruftopenat()auf. Das VFS zerlegt den Pfad, findet die Datei im zuständigen Dateisystem (etwa ext4) und prüft die Zugriffsrechte, zusätzlich auch über die LSM-Hooks. - Page Cache: Bei
read()schaut der Kernel zuerst im Page CachePage CacheTeil des Arbeitsspeichers, in dem der Kernel Dateiinhalte zwischenspeichert, um Zugriffe auf Datenträger zu vermeiden. nach. Liegt die Datei schon im RAM, ist der Aufruf in Mikrosekunden erledigt. - Block-Schicht und Treiber: Andernfalls erzeugt das Dateisystem eine I/O-Anfrage. Die Block-Schicht reicht sie an den NVMe-Treiber weiter, und der Prozess schläft.
- Interrupt und Scheduler: Sind die Daten da, meldet die SSD dies per Interrupt. Der Kernel markiert den Prozess als lauffähig, und der Scheduler lässt ihn wieder rechnen.
- Ausgabe:
catschreibt mitwrite()auf die Standardausgabe. Das geht über das Terminal-Subsystem (TTY) an das Terminalprogramm.
Dieser Ablauf berührt fast jedes Subsystem. Die folgenden Kapitel nehmen sie einzeln unter die Lupe.
Monolithisch, aber modular: Kernel-Module
Viele Teile des Kernels lassen sich wahlweise fest einbauen oder als ladbares ModulKernel-ModulZur Laufzeit ladbarer Teil des Kernels (Datei mit Endung .ko), meist ein Gerätetreiber oder Dateisystem. (.ko-Datei) übersetzen. Das legt die Kernel-Konfiguration fest: y für fest eingebaut, m für Modul, n für gar nicht. Distributionen bauen die meisten Treiber als Module, damit derselbe Kernel auf sehr unterschiedlicher Hardware läuft, ohne alle Treiber im Speicher zu halten.
Ein minimales Modul sieht so aus:
#include <linux/init.h>
#include <linux/module.h>
static int __init hello_init(void)
{
pr_info("Hallo, Kernel!\n");
return 0; /* 0 = erfolgreich geladen */
}
static void __exit hello_exit(void)
{
pr_info("Tschüss, Kernel!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Minimales Beispielmodul");
Geladen wird ein Modul mit modprobe, das auch Abhängigkeiten zu anderen Modulen auflöst. pr_info() schreibt in den Kernel-Log, den dmesg anzeigt. Wie man ein solches Modul übersetzt, zeigt Kapitel 17.
Tiefer eintauchenWarum es keine stabile interne Kernel-API gibt
Die Schnittstelle zum User Space ist bei Linux heilig: Einmal eingeführte Systemaufrufe bleiben stabil. Die internen Schnittstellen zwischen Kernel und Modulen ändern sich dagegen ständig. Funktionen werden umbenannt, Strukturen umgebaut, Parameter ergänzt.
Das ist Absicht und in der Kernel-Dokumentation (Documentation/process/stable-api-nonsense.rst) ausführlich begründet. Eine eingefrorene interne API würde Verbesserungen und Sicherheitskorrekturen bremsen. Wer einen Treiber in den offiziellen Kernel aufnehmen lässt, bekommt solche Anpassungen automatisch: Wer eine interne Schnittstelle ändert, muss alle Nutzer im Quellbaum mit anpassen. Treiber außerhalb des Kernels müssen dagegen ständig nachziehen.
Proprietäre Module ohne GPL-kompatible Lizenz sind technisch möglich, „verunreinigen“ aber den Kernel. Er wird als tainted markiert (/proc/sys/kernel/tainted), und viele interne Funktionen (EXPORT_SYMBOL_GPL) stehen ihnen nicht zur Verfügung.
Kernel-Threads
Nicht alle Arbeit im Kernel geschieht im Auftrag eines Benutzerprogramms. Für Hintergrundaufgaben startet der Kernel eigene Kernel-ThreadsKernel-ThreadTask, der ausschließlich im Kernelmodus läuft und keinen eigenen User-Space-Adressraum hat. In ps erscheint er in eckigen Klammern, z. B. [kworker/0:1].. Sie laufen ausschließlich im Kernelmodus und haben keinen eigenen User-Space-Adressraum. Einige Beispiele:
| Thread | Aufgabe |
|---|---|
kthreadd (PID 2) | erzeugt alle anderen Kernel-Threads |
ksoftirqd/N | erledigt aufgestaute Softirq-Arbeit auf CPU N (Kapitel 8) |
kworker/… | arbeitet Aufträge aus Workqueues ab |
migration/N | verschiebt Tasks zwischen CPUs |
kswapd0 | gibt bei Speicherknappheit Seiten frei (Kapitel 7) |
rcu_… | Hilfsthreads für den RCU-Mechanismus (Kapitel 9) |
Ein Blick in den Quellbaum
Die Gliederung in Subsysteme spiegelt sich direkt in der Verzeichnisstruktur des Quellcodes wider:
| Verzeichnis | Inhalt |
|---|---|
arch/ | architekturspezifischer Code (x86, arm64, riscv …) |
kernel/ | Kernfunktionen: Prozesse, Scheduler (kernel/sched/), Signale, Timer |
mm/ | Speicherverwaltung |
fs/ | VFS und alle Dateisysteme |
block/ | Block-Schicht |
net/ | Netzwerk-Stack |
drivers/ | Gerätetreiber, der mit Abstand größte Teil |
ipc/ | System-V- und POSIX-IPC |
security/ | LSM-Framework, SELinux, AppArmor, Landlock … |
init/ | Start des Kernels (start_kernel() in init/main.c) |
include/ | gemeinsame Header-Dateien |
lib/ | Hilfsfunktionen (Strings, Listen, Kompression …) |
rust/ | Rust-Unterstützung und -Abstraktionen |
Documentation/ | die offizielle Dokumentation |
Besonderheiten der Kernel-Programmierung
Kernel-Code ist C (seit Linux 5.18 im Standard GNU C11) und zunehmend auch Rust. Er unterscheidet sich aber deutlich von gewöhnlicher Anwendungsprogrammierung:
- Keine C-Standardbibliothek. Es gibt kein
printf()und keinmalloc(). Der Kernel bringt eigene Gegenstücke mit, etwaprintk()/pr_info()undkmalloc(). - Kleiner, fester Stack. Jeder Task hat im Kernel nur einen kleinen Stack (auf x86-64 16 KiB). Große lokale Arrays und tiefe Rekursion verbieten sich.
- Keine Gleitkommazahlen. Der Kernel sichert die FPU-Register bei Systemaufrufen nicht automatisch. Gleitkomma-Arithmetik ist deshalb nur in speziell geschützten Abschnitten erlaubt.
- Kein Speicherschutz vor sich selbst. Ein ungültiger Zeigerzugriff führt nicht zu einem sauberen Programmabbruch, sondern zu einem Oops oder einer Kernel Panic.
- Nebenläufigkeit überall. Auf mehreren CPUs gleichzeitig, unterbrochen von Interrupts und verdrängt vom Scheduler: Fast jede gemeinsam genutzte Datenstruktur braucht Synchronisation (Kapitel 9).
Selbst ausprobieren: Den Kernel bei der Arbeit beobachten
# Adressraum der eigenen Shell: Programmcode, Heap, Bibliotheken, Stack, vDSO
cat /proc/$$/maps
# Kernel-Threads: alle Kinder von kthreadd (PID 2)
ps -o pid,ppid,comm --ppid 2 | head -n 20
# Wie oft wurde welcher Interrupt auf welcher CPU ausgelöst?
head -n 15 /proc/interrupts
# Informationen zu einem Modul
modinfo ext4 | head -n 10In der Ausgabe von /proc/$$/maps liegen alle Bereiche in der unteren Hälfte des Adressraums (Adressen wie 55… oder 7f…). Die einzige Ausnahme ist die historische Seite [vsyscall] ganz oben; den eigentlichen Kernel-Teil zeigt die Datei nicht an.