Willkommen zu Modul 4, liebe Anhalter! Wenn der Prozessor das rasende Herz eures Systems ist, dann ist der Arbeitsspeicher (RAM) der Schreibtisch, auf dem die eigentliche Arbeit stattfindet. Und hier offenbart sich eine der unverrückbaren Konstanten des Universums, die seit den ersten digitalen Rechenmaschinen bis heute gilt: Der Bedarf an Arbeitsspeicher ist immer größer als der verfügbare physische Platz[cite: 1]. Fast immer ist der RAM schlichtweg zu klein, um ein gewaltiges Programm oder eine Vielzahl von Programmen gleichzeitig vollständig in sich aufzunehmen[cite: 1].
Die Speicherverwaltung ist daher ein absoluter Grundbestandteil eines jeden Betriebssystems und fast immer vollständig im Kernel implementiert[cite: 1]. Moderne Systeme wie unsere RHEL- oder Debian-Server gehen pragmatisch vor: Sie nutzen die Tatsache aus, dass ein Prozess zu einem exakten Zeitpunkt immer nur auf winzige, spezifische Speicherplätze zugreift, während der Rest der Daten lediglich für einen späteren Zugriff bereitsteht[cite: 1].
Die Lösung für unseren chronischen Platzmangel ist die Zuhilfenahme externer Massenspeicher (Festplatten), von denen bei Bedarf Informationen in den Arbeitsspeicher geladen werden[cite: 1]. Das Betriebssystem entscheidet dann als allmächtiger Verwalter, welche Programmabschnitte im superschnellen RAM verbleiben und welche auf den vergleichsweise kriechend langsamen Hintergrundspeicher verbannt werden[cite: 1].
Um zu verstehen, warum moderne Linux-Systeme so genial sind, müssen wir uns ansehen, wie mühsam die Speicherverwaltung in der Vergangenheit war.
In dedizierten oder simplen Embedded Systems wird oft nur ein einziger Prozess zur selben Zeit ausgeführt[cite: 1]. Dieser Prozess genießt ein Privileg, das moderne Server-Prozesse nicht kennen: Er hat den völlig uneingeschränkten Zugriff auf den physischen Arbeitsspeicher und adressiert diesen direkt[cite: 1]. * Die Speicherverwaltung ist hier trivial: Die angeforderte Adresse wird schlichtweg über den Datenbus der Hardware zur Verfügung gestellt[cite: 1]. * Bei dedizierten Systemen werden die Programme exakt so designt und kompiliert, dass sie immer in einem vorher exakt festgelegten Speicherbereich operieren[cite: 1]. * In frühen universellen Systemen lud ein sogenannter Absolutlader die Benutzerprogramme und Bibliotheken direkt an feste Adressen im Speicher, da alle Adressen bereits im kompilierten Objektcode als absolute Adressen verankert waren[cite: 1]. * Der Nachteil: Wenn die geladenen Datenpakete flexibel in ihrer Größe waren (etwa wachsende Logdateien), musste immer der theoretische Maximalaufwand an Speicher reserviert werden[cite: 1]. Dies führte zu einer extrem ineffizienten Ausnutzung des teuren Arbeitsspeichers[cite: 1].
Um flexibler zu werden, schob man die Festlegung der benötigten Speicherplätze einfach bis zum Ladezeitpunkt auf[cite: 1]. * Ein Relocating Loader lädt das Programm in den Speicher[cite: 1]. Die Maschinenbefehle sind nicht mehr absolut, sondern als relative Adressen gekennzeichnet[cite: 1]. Der Loader addiert dann zu diesen relativen Adressangaben lediglich noch eine konstante Größe (den Offset), um die finale physische Adresse zu berechnen[cite: 1]. * Einmal in den Hauptspeicher geladen, konnte das Programm jedoch nicht mehr im laufenden Betrieb verschoben werden[cite: 1].
Den nächsten Schritt der Flexibilität brachte die Adressumsetzung, die nicht mehr beim Laden, sondern erst in Echtzeit bei der Ausführung des Befehls stattfand[cite: 1]. * Hierfür ist ein Prozessor mit einem eigenen Adressrechenwerk zwingend erforderlich[cite: 1]. Dieses addiert bei jedem Befehl den Inhalt eines Basisadressregisters oder des Program-Counters zur relativen Adresse[cite: 1]. * Man nennt dies Position Independent Code (relocatable)[cite: 1]. Der Ladevorgang wird dadurch drastisch beschleunigt, und das Programm kann sogar nach dem Laden noch munter im RAM verschoben werden[cite: 1].
Als die Programme in den 1970ern und 1980ern endgültig größer wurden als der verfügbare Speicher, mussten die Programmierer selbst kreativ werden. Sie spalteten ihre Software händisch in sogenannte Overlays (Teile) auf[cite: 1]. * Zunächst wurde Overlay 1 ausgeführt. Sobald dieses fertig war, rief es selbstständig Overlay 2 auf, welches dann Overlay 3 lud und so weiter[cite: 1]. * Die einzelnen Overlays wurden auf der Festplatte gespeichert und vom Betriebssystem dynamisch ein- und ausgelagert[cite: 1]. * Das Problem: Obwohl das Betriebssystem die Overlays verwaltete, lag die gesamte intellektuelle Arbeit der Aufteilung beim Programmierer[cite: 1]. Riesige Programme in kleine, logisch funktionierende modulare Teile aufzuspalten, war eine unfassbar zeitaufwändige, fehleranfällige und schlichtweg langweilige Arbeit[cite: 1].
Die Menschheit brauchte eine Lösung, die den Programmierer von dieser Bürde befreite. Die Lösung war der Virtuelle Speicher.
Warum sollte sich der Programmierer um Speichergrenzen kümmern, wenn das Betriebssystem diese Arbeit viel besser erledigen kann[cite: 1]?
Die Grundidee des Virtuellen Speichers ist ein Geniestreich: Man erlaubt dem System, dass der Programmcode, die Daten und der Stack in Summe wesentlich größer sein dürfen als der tatsächlich verfügbare physische Hauptspeicher[cite: 1]. Das Betriebssystem übernimmt die Rolle des Illusionisten: Es hält exakt jene Teile des Programms, die in diesem Bruchteil einer Sekunde gebraucht werden, im Hauptspeicher[cite: 1]. Der gesamte Rest verbleibt schlafend auf der Festplatte[cite: 1].
Damit das funktioniert, müssen wir den Adressraum in zwei Welten trennen[cite: 1]:
Programme können dank dieser Trennung nun völlig unabhängig vom physischen Adressraum geschrieben werden[cite: 1]. Doch wie übersetzen wir zwischen diesen Welten?
Die dominierende Technik in fast allen modernen virtuellen Speichersystemen (und damit auch in jedem Linux-Kernel) ist das Paging[cite: 1]. Beim Paging wird der riesige virtuelle Adressraum der Programme in gleich große Blöcke, die sogenannten Seiten (Pages), unterteilt[cite: 1]. Der physische Adressraum (der RAM) wird in korrespondierende Einheiten gleicher Größe zerschnitten, die wir Seitenrahmen (Page Frames) nennen[cite: 1].
In einer Standard-x86_64-Architektur beträgt die Größe einer Page exakt 4096 Bytes (4 KB).
Wenn ein Prozess nun auf eine Speicheradresse zugreifen möchte, muss die logische Adresse blitzschnell in die physische Adresse umgerechnet werden. Diese Übersetzung leistet eine dedizierte Hardwarekomponente: die Memory Management Unit (MMU)[cite: 1].
Der Ablauf in der Praxis: Die MMU pflegt im Hintergrund eine riesige Übersetzungstabelle (die Page Table). * Ein Programm fordert Daten von der virtuellen Adresse `0x0040A000` an. * Die CPU übergibt diese Adresse an die MMU. * Die MMU schlägt in der Page Table nach: „Aha, die virtuelle Seite 4 liegt aktuell im physischen Seitenrahmen 12.“ * Die MMU berechnet die physische Adresse und reicht sie an den RAM weiter.
Der Translation Look-aside Buffer (TLB): Da die Page Table selbst im RAM liegt, würde jede Speicheranforderung eines Programms in Wirklichkeit *zwei* Zugriffe auf den langsamen RAM erfordern (einmal in die Tabelle schauen, einmal die echten Daten holen). Das würde das System halbieren! Die Lösung ist der TLB, ein winziger, aber unfassbar schneller Hardware-Cache direkt in der MMU, der die zuletzt genutzten Umrechnungen puffert[cite: 1]. Ist die Umrechnung im TLB vorhanden (TLB Hit), entfällt der Tabellenzugriff. Ist sie nicht vorhanden (TLB Miss), muss die CPU mühsam in die Page Table im RAM schauen.
Neben dem Paging (das den Speicher in starre, gleich große Blöcke hackt) gibt es eine zweite Philosophie: das Segmentieren[cite: 1]. Beim Segmentieren wird der logische Adressraum nicht in fixe Pages, sondern in Abschnitte völlig variabler Größe unterteilt[cite: 1]. Diese Abschnitte orientieren sich an den logischen Einheiten des Programms (z. B. einem Code-Segment, einem Daten-Segment für Variablen und einem Segment für den Stack)[cite: 1].
Wie funktioniert die Adressierung hier? Die logische Adresse besteht beim Segmentieren immer aus zwei Teilen[cite: 1]:
1. **Die Segment-Nummer:** Der höchstwertige Teil der Adresse, der das gewünschte Segment identifiziert[cite: 1]. 2. **Die Wortadresse (der Offset):** Die Adresse relativ zum Startpunkt (Segmentanfang) des jeweiligen Segments[cite: 1].
Damit die Hardware weiß, wo diese Segmente im echten RAM liegen, pflegt das System eine Segmenttabelle[cite: 1]. * Für jedes in den Arbeitsspeicher geladene Segment ist in dieser Tabelle die reale physische Anfangsadresse (die Basisadresse) festgehalten[cite: 1]. * Im Multiprogramming-Betrieb (wenn Dutzende Jobs parallel laufen) werden für die verschiedenen Prozesse meist jeweils eigene, voneinander getrennte Tabellen bereitgestellt[cite: 1]. Dies ist essenziell, damit bei den Segment-Nummern der verschiedenen Jobs keine Verwechslung oder Überschneidung möglich ist[cite: 1]. * Um den Zugriff zu steuern, muss vor dem Segmenttabellenzugriff der Inhalt eines jobspezifischen Segmenttabellenregisters zur Segmentnummer addiert werden[cite: 1]. * Bei jedem Kontextwechsel (Umschalten zwischen Jobs durch den Scheduler) muss das Betriebssystem zwingend auch dieses Segmenttabellenregister umladen, damit der neue Prozess auf seine korrekte Tabelle zugreift[cite: 1].
Der geniale Nebeneffekt: Sicherheit! Die Segmenttabelle enthält nicht nur die Basisadresse, sondern auch exakte Angaben über die zugewiesene Größe der einzelnen Segmente[cite: 1]. Dadurch kann die Hardware fehlerhafte Speicherzugriffe, die aus dem erlaubten Segment herausführen würden, in Echtzeit erkennen und sofort verhindern[cite: 1]. (Wer in C schon einmal einen „Segmentation Fault / Segfault“ programmiert hat, weiß jetzt genau, welcher Hardware-Schutzschalter hier gerade Schlimmeres verhindert hat).
*(Anmerkung des Kursleiters: Hier verlassen wir das Basis-Skript und betrachten die rohe Linux-Realität)*
Was passiert eigentlich, wenn ein Programm auf eine virtuelle Adresse zugreift, die zu diesem Zeitpunkt gar nicht im physischen RAM liegt? Das Programm fordert eine Seite an, aber das „Present-Bit“ in der Page Table steht auf 0.
Dies löst einen Page Fault (Seitenfehler) aus. Dies ist kein Absturz, sondern ein gewollter Interrupt! 1. Die MMU schlägt Alarm und weckt das Betriebssystem. 2. Der Kernel hält den verursachenden Prozess sofort an (Zustand: Blockiert). 3. Der Kernel sucht im RAM nach einem freien Seitenrahmen (Page Frame). 4. Ist ein Rahmen frei, wird die fehlende Seite von der Festplatte geladen, die Page Table aktualisiert und der Prozess darf weiterrechnen.
Was, wenn der RAM komplett voll ist? Dann beginnt das sogenannte Swapping (genauer: Paging). Das Betriebssystem muss eine Seite, die aktuell im RAM liegt, opfern und auf die Festplatte (die Swap-Partition) schreiben, um Platz zu machen.
In Linux übernimmt diese Aufgabe der Kernel-Thread `kswapd`. Er scannt den Speicher und wählt Opfer-Seiten anhand raffinierter Algorithmen aus (häufig Varianten von „Least Recently Used“ - LRU, also Seiten, die lange nicht angefasst wurden). Auf euren RHEL- und Debian-Maschinen könnt ihr das Verhalten dieses Daemons über den Kernel-Parameter `vm.swappiness` stufenlos steuern (Werte von 0 bis 100).
Der Worst Case: Der OOM-Killer Wenn das System derart überlastet ist, dass das ständige Ein- und Auslagern von Pages die gesamte Festplatten-E/A und CPU-Zeit frisst, sprechen wir von Thrashing (Seitenflattern). Das System reagiert nicht mehr. Als Ultima Ratio schickt der Linux-Kernel dann den gefürchteten Out-Of-Memory (OOM) Killer los. Dieser bewertet alle laufenden Prozesse (anhand ihres Speicherverbrauchs und ihrer Priorität) und terminiert den größten Speicherfresser gnadenlos per SIGKILL-Signal, um das System vor dem totalen Crash zu retten. Wenn euer Checkmk-Agent also plötzlich meldet, dass ein MariaDB- oder Java-Prozess spurlos verschwunden ist – schaut ins `/var/log/syslog` oder `dmesg`. Der OOM-Killer hinterlässt dort immer seine blutige Unterschrift.