OST – Ostschweizer Fachhochschule

Betriebssysteme 2
Lernzusammenfassung

📅 Frühlingssemester 2026 🧩 erweitert: C-Wiederholung · API/ABI · fork · Scheduling · IPC · ext2/ext4 👨‍🏫 Felix Morgner 📍 St. Gallen / Rapperswil
1

Einführung & Überblick

Was ist ein Betriebssystem?

Der Begriff „Betriebssystem" wird in zwei verschiedenen Bedeutungen verwendet, die sorgfältig unterschieden werden müssen.

Das Betriebssystem im engeren Sinne stellt den Rahmen bereit, in dem alle Software-Komponenten mit der Hardware und untereinander interagieren können. Es sorgt dafür, dass Programme auf der Hardware laufen können, ohne sich gegenseitig unkontrolliert zu stören, und kapselt Hardwaredetails. Wenn von „Linux-Kernel" oder „Windows-Kernel" gesprochen wird, ist das Betriebssystem im engeren Sinne gemeint.

Das Betriebssystem im weiteren Sinne umfasst zusätzlich alle Werkzeuge, die man im Alltag als Teil des Systems wahrnimmt: Shell, Konfigurationsprogramme, Desktop-Oberfläche, Browser, Dateimanager. Eine Linux-Distribution ist ein typisches Beispiel. Der Trend geht dahin, immer mehr Funktionalität mit dem Betriebssystem auszuliefern.

📌 Abgrenzung dieses Moduls: BSYS 2 behandelt ausschließlich das Betriebssystem im engeren Sinne. Nicht behandelt werden Treiberarchitektur, Datenbanksysteme und Middleware – obwohl diese durchaus Teil eines Betriebssystems sein können.

Semesterübersicht der Themen

#Thema
1Einführung & Wiederholung aus BSYS 1
2Betriebssystem-API (Systemaufrufe)
3Dateisystem-API (POSIX & C-Stream)
4Prozesse (fork, exec, wait)
5Programme & Bibliotheken (ELF, statisch, dynamisch)
6Threads (POSIX pthreads)
7Scheduling (FCFS, SJF, RR, Priorität, MLFQ)
8Mutexe & Semaphore
9Signale, Pipes & Sockets
10Message Passing & Shared Memory
11Unicode & ext2-Dateisystem
12ext4-Dateisystem (Extents, Journaling)
13X Window System / Wayland
1A

C-Wiederholung: Datentypen, Pointer, struct und Übung 1

ℹ️ Warum dieser Abschnitt wichtig ist: BSYS 2 arbeitet sehr nah an POSIX. POSIX ist im Kern eine C-nahe Schnittstelle. Deshalb müssen C-Datentypen, Pointer, Speicherlayout, structs, Funktionspointer und Bitoperationen sicher verstanden werden, bevor Systemaufrufe, Dateideskriptoren, Prozesse und IPC sinnvoll programmiert werden können.

Einfache Datentypen und feste Integer-Typen

In C ist ein Typ nicht nur eine „Kategorie“, sondern bestimmt, wie viele Bytes gelesen oder geschrieben werden und wie diese Bytes interpretiert werden. Besonders bei Betriebssystem-APIs ist das wichtig, weil dort Dateigrößen, Offsets, Pufferlängen, Adressen und Rückgabewerte exakt passen müssen.

TypBedeutung im BSYS-KontextMerksatz
charMaschinen-Byte; wird oft für rohe Bytes oder Zeichenketten verwendet.Ein Byte Speicher, Interpretation abhängig vom Kontext.
int„Natürliche“ Integergröße der Plattform, üblicherweise signed.Nicht verwenden, wenn eine exakte Bitbreite gefordert ist.
void *Ungetypter Pointer auf eine Adresse.Adresse ohne Information, wie viele Bytes dort als Wert dazugehören.
T *Pointer auf ein Objekt vom Typ T.Beim Dereferenzieren werden sizeof(T) Bytes als ein T interpretiert.
int8_t, int16_t, int32_t, int64_tInteger mit exakt definierter Bitbreite aus <stdint.h>.Nutzen, wenn Format, Datei oder Protokoll eine feste Größe verlangt.
uint8_t, uint32_t, uintptr_tUnsigned-Varianten; uintptr_t kann eine Adresse als Integer aufnehmen.Für Bits, Größenfelder und binäre Daten oft passender als signed Typen.
size_tErgebnistyp von sizeof; groß genug für Objektgrößen.Standardtyp für Array-Indizes, Pufferlängen und Größen im Speicher.
✅ Prüfungsregel: Wenn eine Aufgabe „4 Bytes“, „8 Bit“ oder „Bildformat/Dateiformat“ sagt, sind uint8_t, uint16_t, uint32_t usw. meist besser als bloß int.

Typ-Aliase mit typedef

typedef erzeugt keinen neuen inkompatiblen Typ, sondern einen weiteren Namen für denselben Typ. Das ist nützlich, wenn lange Deklarationen lesbarer werden sollen, zum Beispiel bei structs oder Funktionspointern.

/* Alias für int: int_t und int sind derselbe Typ */
typedef int int_t;

int   a = 7;
int_t b = a;       // OK

Pointer-Alias

typedef int *pint_t;

int value = 25;
pint_t p = &value;
*p = 30;

Der Alias pint_t steht für int *. Das kann kompakt sein, macht Deklarationen aber manchmal weniger offensichtlich.

Funktionspointer-Alias

typedef int (*function_t)(pint_t);

int calculate(pint_t p);

function_t callback = calculate;
int y = callback(&value);

Bei Funktionspointern ist typedef besonders hilfreich, weil die rohe C-Syntax sonst schwer zu lesen ist.

⚠️ Achtung bei Pointer-Aliassen: typedef int *pint_t; versteckt das Sternchen im Alias. In echtem Code kann das zu Missverständnissen führen, besonders wenn mehrere Variablen in einer Zeile deklariert werden. Für Lernzwecke ist es trotzdem wichtig, diese Syntax lesen zu können.

Adressen, Dereferenzierung und Pointer-Arithmetik

Eine Adresse allein sagt nur, wo im Speicher etwas liegt. Der Pointer-Typ sagt zusätzlich, wie der Speicher an dieser Adresse interpretiert wird. Bei T *p bedeutet *p: Lies oder schreibe ab Adresse p genau sizeof(T) Bytes als Wert vom Typ T.

Beispiel: uint64_t *p = 0xA00; A00 A08 A10 A18 A20 A28 A30 ++p: +8 Byte p += 3: +3 × sizeof(uint64_t) = +24 Byte Pointer-Arithmetik rechnet in Elementen des Zieltyps, nicht in einzelnen Bytes.
Abbildung 1A.1 Bei uint64_t * springt ein Schritt um 8 Bytes, weil sizeof(uint64_t) == 8.
AusdruckBedeutungTypischer Denkfehler
pAdresse des ersten Bytes eines Objekts vom Zieltyp.Die Adresse ist nicht der Wert selbst.
*pWert an dieser Adresse, gelesen/geschrieben mit Größe des Zieltyps.Es wird nicht nur ein Byte gelesen, sondern sizeof(*p) Bytes.
p + 1Adresse des nächsten Elements vom Typ *p.Bei uint64_t * ist das +8 Bytes, nicht +1 Byte.
q - pAnzahl Elemente zwischen zwei Pointern desselben Arrays.Das Ergebnis ist keine Byte-Differenz.

sizeof, Arrays und Strings

sizeof liefert die Anzahl Maschinenbytes eines Objekts oder Typs. Im normalen Vorlesungskontext wird das als Compilezeit-Eigenschaft betrachtet. Für Puffer, Arrays und Dateiformate ist sizeof zentral, weil damit Größen robust berechnet werden können.

#include <stdint.h>
#include <stddef.h>

uint64_t u = 9;
size_t a = sizeof u;           // 8
size_t b = sizeof(uint64_t);   // 8

uint32_t values[10];
size_t bytes = sizeof values;                  // 10 × 4
size_t count = sizeof values / sizeof values[0];
⚠️ Array vs. Pointer: In vielen Ausdrücken „zerfällt“ ein Array zu einem Pointer auf sein erstes Element. Dann kennt der Pointer die Arraylänge nicht mehr. Deshalb funktioniert sizeof array / sizeof array[0] nur dort, wo wirklich noch das Array sichtbar ist, nicht nach Übergabe als Funktionsparameter.

Strings richtig vergleichen

C-Strings sind Arrays aus char, die mit '\0' enden. Ein Vergleich mit == vergleicht bei char * nur die Adressen, nicht den Inhalt der Zeichenketten.

char const *a = "OST";
char const *b = "OST";

if (a == b) {              // vergleicht Pointer-Adressen
    /* nicht als inhaltlicher Stringvergleich benutzen */
}

if (strcmp(a, b) == 0) {   // vergleicht Inhalte
    /* Strings sind gleich */
}
FunktionVerwendungRückgabewert
strcmp(a, b)Vergleicht zwei nullterminierte Strings vollständig.0 gleich, < 0 a kleiner, > 0 a größer.
strncmp(a, b, n)Vergleicht maximal n Zeichen.Sicherer, wenn die Länge begrenzt werden muss, z. B. bei Pufferdaten.

const bei Pointern lesen

Bei Pointer-Deklarationen ist entscheidend, ob das Zielobjekt konstant ist oder der Pointer selbst. Eine einfache Leseregel ist: von rechts nach links lesen.

DeklarationBedeutungWas ist verboten?
char const *pPointer auf konstanten char.*p = 'x'; das Ziel darf über diesen Pointer nicht verändert werden.
const char *pGleichbedeutend mit char const *p.Das Ziel ist konstant.
char * const pKonstanter Pointer auf veränderbaren char.p = other; der Pointer darf nicht auf eine andere Adresse zeigen.
char const * const pKonstanter Pointer auf konstanten char.Weder Pointer noch Ziel dürfen über diesen Namen verändert werden.
✅ Bezug zur Übung: Ein Dateiname wird sinnvoll als char const *filename übergeben: Die Funktion darf den Namen lesen, aber nicht verändern.

struct, Padding und Alignment

Mit struct werden mehrere Werte zu einem zusammenhängenden Objekt gebündelt. Der Punktoperator . greift auf Member eines Objekts zu; der Pfeiloperator -> greift über einen Pointer auf Member zu.

typedef struct {
    int x;
    int y;
} point_t;

point_t p = { .x = 10, .y = 20 };
point_t *pp = &p;

p.x = 11;        // direkter Zugriff
pp->y = 21;      // gleichbedeutend mit (*pp).y = 21

Ein struct ist nicht immer genau so groß wie die Summe seiner sichtbaren Member. Compiler dürfen Padding-Bytes einfügen, damit Member an passenden Adressen liegen. Das nennt man Alignment.

Padding-Beispiel struct S { char a; int32_t b; char c; }; a pad b c pad 0 4 8 12 Das int32_t wird auf eine 4-Byte-Grenze gelegt. Dafür können unsichtbare Padding-Bytes entstehen. Die Reihenfolge der Member kann Speicher sparen, ändert aber auch das binäre Layout.
Abbildung 1A.2 sizeof(struct S) kann größer sein als sizeof(char)+sizeof(int32_t)+sizeof(char).
⚠️ Wichtig für Dateiformate: Ein struct einfach byteweise in eine Datei zu schreiben ist riskant, wenn Padding, Endianness oder Plattformunterschiede eine Rolle spielen. Für echte Dateiformate müssen Felder kontrolliert serialisiert werden.

Übung 1: Bilddaten in C

Die erste Übung verbindet die C-Wiederholung direkt mit einer Fallstudie: Bilder werden programmatisch erzeugt. Das BMP-Format wird bereitgestellt; im Fokus stehen gemeinsame Datenstrukturen, Pointer-Übergabe, Funktionspointer und Bitoperationen.

Aufgabe 1: gemeinsame Bildtypen in image.h

#include <stdint.h>

/* Ein Pixel: je 1 Byte für Rot, Grün, Blau */
typedef struct {
    uint8_t red;
    uint8_t green;
    uint8_t blue;
} rgb_t;

/* Ausschnitt eines späteren Fraktals */
typedef struct {
    double center_x;
    double center_y;
    double size;
} window_t;

/* Vorwärtsdeklaration, falls image_t erst später vollständig definiert wird */
typedef struct image image_t;

/* Dateiformat-spezifische open-Funktion, z. B. open_bmp_image */
typedef image_t *(*open_image_func_t)(char const *filename, uint32_t size);

rgb_t

Übt struct, feste Integergröße und semantische Benennung von Daten.

window_t

Bereitet die spätere Fraktal-Fallstudie vor: Mittelpunkt und Größe eines Ausschnitts.

open_image_func_t

Übt Funktionspointer: Verschiedene Dateiformate können dieselbe abstrakte Schnittstelle anbieten.

Aufgabe 2: Farbverlauf erzeugen

Die Funktion paint_background läuft über alle Pixel des Bildes und setzt die Farbe über einen Funktionspointer in der image_t-Struktur. Dadurch werden struct-Pointer und der Operator -> praktisch verwendet.

#define SIZE 256

void paint_background(image_t *image) {
    for (uint32_t y = 0; y < SIZE; ++y) {
        for (uint32_t x = 0; x < SIZE; ++x) {
            uint8_t r = 255 - y;
            uint8_t g = x;
            uint8_t b = (x + y) / 2;

            rgb_t color = { r, g, b };
            image->set_pixel(image, x, y, color);
        }
    }
}
image.h rgb_t · image_t bmp.h / bmp.c open · close main.c paint_background BMP gradient.bmp Die Übung trennt abstrakte Bilddaten von der konkreten BMP-Implementierung.
Abbildung 1A.3 Übungsarchitektur: gemeinsame Bildtypen, BMP-Backend und eigenes Malprogramm.

Aufgabe 3: Bilddaten mit Bitoperationen interpretieren

Die vorgegebenen Pixel sind platzsparend in 16-Bit-Werten codiert. Der oberste Bitbereich enthält die x-Koordinate, danach folgt die y-Koordinate, und die letzten zwei Bits bilden den Index in der Farbtabelle. Zusätzlich wird der Bildpunkt an der x-Achse gespiegelt, also auch bei SIZE - y - 1 gesetzt.

16-Bit-Pixeldaten 0 x-Koordinate · 7 Bit y-Koordinate · 6 Bit Farbe 15 8 2 0
Abbildung 1A.4 Bitfelder werden mit Shift und AND-Maske aus einem uint16_t extrahiert.
uint16_t value = pixel_data[i];

uint8_t x     = (value >> 8) & 0x7F;  // Bits 14..8, MSB ignoriert
uint8_t y     = (value >> 2) & 0x3F;  // Bits 7..2
uint8_t index =  value       & 0x03;  // Bits 1..0

rgb_t color = color_table[index];
image->set_pixel(image, x, y, color);
image->set_pixel(image, x, SIZE - y - 1, color);  // Spiegelung an der x-Achse
✅ Zentrale Verbindung zur Vorlesung: Aufgabe 3 übt genau die C-Konzepte, die später bei Betriebssystemdatenstrukturen wiederkommen: feste Integergrößen, Bitmasken, Speicherrepräsentation, Pointer auf Daten und sauber definierte Schnittstellen.

Empfohlene Bearbeitung der Übungen

Die Übungen sind bewusst praktisch. Der größte Lerneffekt entsteht nicht durch bloßes Lesen der Lösung, sondern durch eigenes Debugging und schrittweises Verstehen.

  1. Zuerst selbst versuchen und Compiler-/Laufzeitfehler ernst nehmen.
  2. Danach Hinweise oder Lösungsansätze mit Kolleginnen und Kollegen besprechen.
  3. Erst dann die Musterlösung anschauen.
  4. Die Lösung anschließend noch einmal selbstständig implementieren, bis sie ohne Copy-Paste verstanden ist.
2

Die Betriebssystem-API

API vs. ABI – die zwei Ebenen einer Schnittstelle

Bei Betriebssystemen werden häufig zwei Begriffe vermischt: API und ABI. Beide beschreiben Schnittstellen, aber auf unterschiedlichen Ebenen.

BegriffEbeneWas wird festgelegt?Beispiel
API
Application Programming Interface
Quellcode-EbeneFunktionsnamen, Parameter, Rückgabewerte, Semantikread(fd, buf, n), fork(), pthread_create()
ABI
Application Binary Interface
Binär-/MaschinenebeneRegisterbelegung, Calling Convention, Syscall-Nummern, Objektformat, Alignment, DatentypgrößenLinux/x86-64: Syscall-Nummer in rax, Argumente u.a. in rdi, rsi, rdx
Anwendungsprogramm in C POSIX-/C-API read(), write(), fork() libc-Wrapper validiert, setzt errno, ruft syscall Syscall-ABI: Nummern, Register, Rücksprung, Fehlercodes Kernel API ABI
Abbildung 2.1 Die API ist bequem für Programmierer; die ABI ist die konkrete binäre Vereinbarung zwischen Programm/Bibliothek und Kernel.
✅ Prüfungsformulierung: Eine API beantwortet: „Welche Funktion darf ich im Code aufrufen?“ Eine ABI beantwortet: „Wie sieht dieser Aufruf als Maschinen-/Binärschnittstelle exakt aus?“ Deshalb kann derselbe C-Code auf zwei Architekturen dieselbe API verwenden, aber unterschiedliche ABIs benötigen.

Was ist ein Systemaufruf?

Anwendungsprogramme laufen im User Space und haben keinen direkten Zugriff auf Hardware oder privilegierte Kernel-Strukturen. Um Dienste des Betriebssystems zu nutzen, muss eine Anwendung einen Systemaufruf (System Call) ausführen.

Technisch gibt es auf x86-64 einen einzigen syscall-Befehl. Da es viele verschiedene Kerneldienste gibt, muss jeder Dienst mit einem Code versehen werden, der in einem Register übergeben wird. Weitere Parameter folgen in anderen Registern.

Beispiel: Unter Linux/x86-64 hat der System Call exit die Nummer 60. Die Syscall-Nummer wird in rax/eax übergeben; der erste Parameter, also der Exit-Status, steht in rdi. Der Befehl syscall löst dann den kontrollierten Übergang in den Kernel aus. Diese Registerkonvention ist ABI- und architekturabhängig; portable Programme verwenden deshalb normalerweise C-/POSIX-Funktionen wie exit(), read() oder write() statt direkter Assembler-Systemaufrufe.

⚠️ ABI statt „beliebiger Kernel-Laune“: Nicht jede Architektur ist binärkompatibel zu jeder anderen: ARM, x86 und x86-64 haben unterschiedliche Maschinenbefehle und unterschiedliche Syscall-ABIs. Innerhalb einer stabilen Linux-ABI, z.B. Linux/x86-64, werden Syscall-Nummern und Registerkonventionen jedoch nicht beliebig pro Kernel-Version geändert. Entscheidend ist also die Kombination aus Betriebssystem, Architektur und ABI.

Privilege Levels, User-Mode und Kernel-Mode

Moderne Betriebssysteme stützen sich auf einen Hardwaremechanismus: Code läuft nicht immer mit denselben Rechten. Normale Anwendungen laufen im User-Mode; der Betriebssystemkern läuft im Kernel-Mode. Nur im Kernel-Mode dürfen privilegierte Instruktionen ausgeführt und geschützte Ressourcen direkt verwaltet werden.

\[ \text{Anwendung im User-Mode}\;\xrightarrow{\texttt{syscall}}\;\text{Kernel-Mode}\;\xrightarrow{\text{Return}}\;\text{User-Mode} \]
User-Mode / User Space Anwendungsprogramme, Shell, libc, systemd usw. laufen eingeschränkt. Kernel-Mode / Kernel Space Kernel, System-Call-Handler, Speicherverwaltung, Scheduler, Treiber. syscall return Eine Anwendung kann nicht selbst „in den Kernel-Mode wechseln“; der Übergang ist kontrolliert und läuft über definierte Schnittstellen.
Abbildung 2.2 System Calls sind der kontrollierte Übergang von einer Anwendung in den Kernel und zurück.

Warum reichen Benutzerrechte wie root nicht?

Auch ein Prozess, der als root läuft, läuft zunächst im User-Mode. root beschreibt eine Berechtigung im Sicherheitsmodell des Betriebssystems; Kernel-Mode beschreibt dagegen einen CPU-Privilege-Level. Ein Root-Prozess darf mehr System Calls erfolgreich ausführen, darf aber trotzdem nicht beliebige Kernel-Instruktionen direkt ausführen.

Kernel-Architekturen: Microkernel, monolithischer Kernel, Unikernel

Die Vorlesung kontrastiert mehrere Architekturideen. Entscheidend ist jeweils, wie viel Funktionalität im privilegierten Kernel-Mode liegt und wie oft zwischen User-Mode und Kernel-Mode gewechselt werden muss.

ArchitekturIdeeVorteilNachteil / Trade-off
MicrokernelNur sehr kleine Kernfunktionalität im Kernel-Mode; viele Dienste, teilweise auch Treiber, im User-Mode.Stabilität und bessere Isolierbarkeit: ein abstürzender User-Mode-Treiber reißt nicht automatisch den ganzen Kernel mit.Mehr Kommunikation und Kontextwechsel können Performance kosten.
Monolithischer KernelViele Betriebssystemdienste und Treiber laufen im Kernel-Mode.Hohe Performance, weniger teure Grenzübergänge.Fehler im Kernel-Mode haben größere Auswirkungen.
UnikernelAnwendung und benötigte Betriebssystemkomponenten werden sehr spezialisiert zusammengebaut.Klein, spezialisiert, potenziell effizient.Weniger allgemeine Isolation und Flexibilität als bei klassischen Mehrzwecksystemen.
📌 Merksatz: Je mehr Code im Kernel-Mode läuft, desto weniger Übergänge sind nötig; desto größer ist aber auch der Schaden, wenn dieser Code fehlerhaft ist.

Die Shell als Betriebssystem-Interface

Die Shell ist ein normales Programm, das es erlaubt, über Texteingabe Betriebssystemfunktionen aufzurufen. Sie benötigt keine besonderen Rechte, sondern nur einen Eingabe-Stream und einen Ausgabe-Stream. Es gibt viele Shell-Varianten mit jeweils eigener Syntax, zum Beispiel bash, zsh oder fish.

Beim Start eines Programms zerlegt die Shell die Eingabe in einzelne Argumente. Leerzeichen trennen Argumente; Quotes sorgen dafür, dass Leerzeichen Teil eines Arguments bleiben.

$ gcc -c abc.c
$ echo "ein Argument mit Leerzeichen"

POSIX

POSIX (Portable Operating System Interface) ist ein Standard, der viele Betriebssystemfunktionen spezifiziert. Die POSIX-API liegt sehr nah am Kernel und wird in C über standardisierte Bibliotheksfunktionen genutzt. Ein Großteil von BSYS 2 bewegt sich an dieser Schnittstelle. Für diesen Vorlesungsteil sind besonders wichtig: Shell-Verhalten, Programmargumente, Umgebungsvariablen, Man Pages und C-nahe API-Funktionen.


Programmargumente

Programmargumente sind explizite Informationen, die beim einzelnen Programmaufruf übergeben werden. Sie eignen sich für Dinge, die sich von Aufruf zu Aufruf ändern: Quellcodedateien, Optionen, Ausgabedateien, Modi oder Passwörter in Übungsaufgaben.

argc argv argv[0] argv[argc] == NULL Shell-Splitting

Shell-Zerlegung

Die Shell erzeugt aus einer Eingabe eine Folge von Strings. Für

$ gcc -c abc.c

entsteht konzeptionell:

\[ (\texttt{"gcc"},\;\texttt{"-c"},\;\texttt{"abc.c"}) \]

Quotes verändern nicht den Inhalt des späteren Strings durch „magische Markierungen“, sondern nur die Zerlegung durch die Shell. Das Argument "ein Argument" landet im Programm als ein einziger nullterminierter String.

argc und argv in C

Die übliche Signatur der main-Funktion lautet:

int main(int argc, char *argv[])
{
    /* ... */
}
\[ \texttt{argc} = \#\{\text{Einträge in }\texttt{argv}\text{ ohne abschließenden NULL-Pointer}\} \] \[ \texttt{argv} : \{0,\dots,\texttt{argc}-1\} \to \texttt{char*}, \qquad \texttt{argv[argc]} = \texttt{NULL} \]
  • argv[0] enthält normalerweise den Programmnamen bzw. den Namen, unter dem das Programm aufgerufen wurde.
  • Die eigentlichen Argumente beginnen bei argv[1].
  • Das letzte echte Argument liegt bei argv[argc - 1].
  • argv[argc] ist ein Nullpointer und markiert das Ende des Arrays.
Speicherlayout: gcc -c abc.c Das Betriebssystem legt Strings und ein Pointer-Array bereit; die C-Laufzeit ruft dann main(argc, argv) auf. argv[0] argv[1] argv[2] argv[3] = NULL g c c \0 - c \0 a b c . c \0 argc = 3 Jeder String endet mit einem Nullbyte.
Abbildung 2.3 argv ist ein Array aus Adressen; jede Adresse zeigt auf den Anfang eines nullterminierten Argument-Strings.

Argumente sicher durchsuchen

In Übung 2 wird ein Passwort-Argument gesucht. Zentral ist die Indexgrenze:

\[ 1 \le i < \texttt{argc} \qquad\text{und}\qquad \text{Folgeargument existiert} \Longleftrightarrow i+1 < \texttt{argc} \]
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    for (int i = 1; i < argc; ++i) {
        if (strcmp(argv[i], "-Bsys2") == 0) {
            puts("Richtig");

            if (i + 1 < argc) {
                printf("Folgeargument: %s\n", argv[i + 1]);
            } else {
                puts("Fehler: Nach -Bsys2 folgt kein Argument.");
            }
            return 0;
        }
    }

    puts("Falsch");
    return 1;
}

Umgebungsvariablen

Umgebungsvariablen sind implizite Informationen, die ein Prozess beim Start erhält. Sie werden typischerweise vom Elternprozess, zum Beispiel der Shell, an den Kindprozess weitergegeben. Sie eignen sich für Einstellungen, die über viele Programmaufrufe hinweg gleich bleiben, etwa PATH, HOME oder projektspezifische Variablen.

KEY=VALUE getenv setenv unsetenv putenv extern char **environ

Form eines Environment-Eintrags

Ein Environment-Eintrag ist ein nullterminierter String mit mindestens einem Gleichheitszeichen. Entscheidend ist das erste Gleichheitszeichen.

\[ s = \text{key} \;\Vert\; \texttt{"="} \;\Vert\; \text{value} \] \[ j = \min\{i \mid s_i = \texttt{'='}\}, \qquad \text{key}=s[0..j-1], \qquad \text{value}=s[j+1..] \]
TeilBeispiel bei PATH=/binBemerkung
KeyPATHInnerhalb einer Umgebung eindeutig; PATH und path sind verschieden.
Separator=Trennt Key und Value; weitere Gleichheitszeichen dürfen im Value vorkommen.
Value/binImmer String, auch wenn der Inhalt numerisch aussieht.

Speicherlayout mit environ

Das Environment liegt konzeptionell ähnlich wie argv im Speicher: ein Array aus Pointern auf nullterminierte Strings. Das Array endet mit einem Nullpointer.

\[ \texttt{environ} : \{0,\dots,m-1\} \to \texttt{char*}, \qquad \texttt{environ[m]} = \texttt{NULL} \]
Environment-Speicher: PATH=/bin environ[0] environ[1]=NULL P A T H = / b i n \0 getenv("PATH") liefert die Adresse des Values, nicht die Adresse des Keys. Key Value
Abbildung 2.4 getenv("PATH") gibt einen Pointer auf das erste Zeichen des Value-Teils zurück, also hier auf /.

getenv: Wert abfragen

#include <stdio.h>
#include <stdlib.h>

void print_value_of(char const *key)
{
    char *value = getenv(key);

    if (value == NULL) {
        printf("Environment variable '%s' does not exist.\n", key);
    } else {
        printf("Value of variable '%s' is: %s\n", key, value);
    }
}
\[ \texttt{getenv(key)} = \begin{cases} \&s[j+1], & \text{falls ein Eintrag } s = key\Vert\texttt{"="}\Vert value \text{ existiert} \\ \texttt{NULL}, & \text{sonst} \end{cases} \]

setenv und unsetenv

#include <stdlib.h>

int setenv(char const *key, char const *value, int overwrite);
int unsetenv(char const *key);

setenv erhält Key und Value getrennt. Existiert der Key bereits, entscheidet overwrite, ob der alte Wert ersetzt wird.

\[ \texttt{overwrite}=0 \land key\in E \Longrightarrow E' = E \] \[ \texttt{overwrite}\ne 0 \lor key\notin E \Longrightarrow E'[key] = value \]

Wichtig: Änderungen gelten nur für den aktuellen Prozess und für danach gestartete Kindprozesse. Bereits laufende Prozesse, insbesondere die Shell, werden dadurch nicht rückwirkend geändert.

\[ E_{\text{child}}(t_0) = \operatorname{copy}(E_{\text{parent}}(t_0)) \qquad\text{danach: unabhängige Environments} \]

putenv: vollständigen String einsetzen

#include <stdlib.h>

int putenv(char *string);  /* string hat Form KEY=VALUE */

Der subtile Unterschied: setenv kopiert Key und Value; putenv übernimmt den übergebenen String direkt in das Environment. Wird dieser String später verändert, ändert sich damit auch die Umgebungsvariable.

Unterschied: setenv vs. putenv setenv("X", "abc", 1) kopiert Key und Value Environment-Kopie spätere lokale Änderungen egal putenv(buffer) übernimmt Pointer auf buffer environ[k] = &buffer[0] spätere Änderungen sichtbar Für normale Programme ist setenv meist sicherer; putenv verlangt stabile, veränderbare Speicherbereiche.
Abbildung 2.5 setenv arbeitet mit Kopien; putenv kann Aliasing zwischen Programmvariable und Environment erzeugen.
#include <stdio.h>
#include <stdlib.h>

char bsys_var[] = "BSYS_VAR=myvalue";

int main(void)
{
    putenv(bsys_var);
    puts(getenv("BSYS_VAR"));   /* myvalue */

    bsys_var[9] = '_';          /* Value-Teil wird verändert */
    bsys_var[10] = '_';

    puts(getenv("BSYS_VAR"));   /* __value */
}

environ direkt ausgeben

Für produktiven Code verwendet man normalerweise getenv, setenv und unsetenv. Für die Übung ist environ aber nützlich, um das Speicherlayout sichtbar zu machen.

#include <stdio.h>

extern char **environ;

void print_env(void)
{
    printf("&environ = %p\n", (void *)&environ);
    printf("environ  = %p\n", (void *)environ);

    for (char **p = environ; *p != NULL; ++p) {
        printf("&entry=%p entry=%p string=%s\n",
               (void *)p, (void *)*p, *p);
    }
}

Übung 2: Was muss die HTML-Zusammenfassung abdecken?

Die Übung zu dieser Vorlesung prüft genau die Verbindung aus Speicherlayout, POSIX-API und C-Programmierung. Für die Prüfungsvorbereitung sollten diese Punkte sicher sitzen:

ÜbungsteilKonzeptWas man können muss
1a–1dUmgebungsvariable als KEY=VALUEKey und Value trennen, Case-Sensitivity erklären, Speicherlayout zeichnen, Pointer von getenv("PATH") korrekt auf den Value setzen.
1egetenvExistenz prüfen: Rückgabe ist entweder Pointer auf Value oder NULL.
1fsetenvoverwrite=0 vs. overwrite=1 erklären und Resultat ausgeben.
1gputenvErklären, warum Änderungen am übergebenen Array später im Environment sichtbar werden.
1henvironPointer-Array iterieren, Adressen unterscheiden: Adresse des Array-Elements, Inhalt des Elements, String-Adresse.
2a–2cargc/argvArgumentanzahl, Pointer-Array, argv[0], Nullterminierung und Quotes verstehen.
2dArgument-ParsingMit strcmp suchen, Grenzen prüfen, Folgeargument nur lesen, falls i+1 < argc.
✅ Prüfungscheck: Wenn du argv und environ als „Array von Pointern auf nullterminierte Strings mit abschließendem NULL“ zeichnen kannst, ist der zentrale Speicherlayout-Teil verstanden.
\[ \underbrace{\texttt{argv[i]}}_{\text{Pointer im Array}} \longrightarrow \underbrace{\texttt{'a'}\;\texttt{'b'}\;\texttt{'c'}\;\texttt{'\\0'}}_{\text{nullterminierter String}} \] \[ \underbrace{\texttt{environ[k]}}_{\text{Pointer im Environment-Array}} \longrightarrow \underbrace{\texttt{'K'}\;\texttt{'E'}\;\texttt{'Y'}\;\texttt{'='}\;\texttt{'V'}\;\texttt{'A'}\;\texttt{'L'}\;\texttt{'\\0'}}_{\texttt{KEY=VALUE}} \]
3

Dateisystem-API

Das Modul unterscheidet zwei Aspekte von Dateisystemen: die API (wie Anwendungen Dateien benutzen) und die Implementierung auf dem Datenträger (ext2, ext4 – siehe Kapitel 11 & 12). Anwendungen müssen in der Regel nichts über die interne Struktur wissen.

Grundbegriffe

Eine Datei besteht aus Nutzdaten und Metadaten. Metadaten umfassen Name, Größe, Zugriffsrechte, Zeitstempel u.a. Dateiendungen sind für das Betriebssystem meist nicht entscheidend; entscheidend sind Magic Numbers – spezifische Bytekombinationen am Anfang der Nutzdaten, die den Typ kennzeichnen (z.B. 7F 45 4C 46 = ELF).

Verzeichnisse bilden eine Hierarchie mit einem einzigen Wurzelverzeichnis (/ unter POSIX, C:\ unter Windows). Pfade können absolut (beginnen am Root), relativ (relativ zum Arbeitsverzeichnis) oder kanonisch (absolut, bereinigt von .. und Symlinks) sein.

Zugriffsrechte werden für drei Kategorien vergeben: Owner, Group und Other – jeweils mit read (r), write (w) und execute/search (x).

Application C-API (fopen, fread, fwrite, …) POSIX-API (open, read, write, close, …) direkt
Abbildung 3.1 Schichtenmodell der Dateizugriffs-APIs: Anwendungen können über die C-API oder direkt über die POSIX-API auf Dateien zugreifen.

POSIX File API

Die POSIX-API arbeitet mit File-Deskriptoren – ganzzahligen Indices in die File-Descriptor-Tabelle des Prozesses. Drei Standard-Deskriptoren existieren von Beginn an: 0 (stdin), 1 (stdout), 2 (stderr).

Datei öffnen und schließen

/* Öffnen – gibt File-Deskriptor (≥ 0) oder -1 bei Fehler zurück */
int open(char const *path, int flags, ...);

/* Wichtige Flags: */
O_RDONLY    // nur lesen
O_WRONLY    // nur schreiben
O_RDWR      // lesen und schreiben
O_CREAT     // Datei anlegen falls nicht vorhanden (dann: mode-Arg nötig)
O_APPEND    // vor jedem Schreiben ans Ende springen
O_TRUNC     // Datei beim Öffnen auf Länge 0 setzen

/* Beispiel: */
int fd = open("myfile.dat", O_WRONLY | O_CREAT, S_IRWXU);

/* Schließen */
int close(int fd);

Lesen und Schreiben

ssize_t read(int fd, void *buffer, size_t n);
ssize_t write(int fd, void const *buffer, size_t n);
🔴 Wichtig: Partielle Reads/Writes! read und write können weniger Bytes verarbeiten als angefordert. Der Rückgabewert gibt die tatsächlich verarbeitete Anzahl an. read gibt 0 zurück bei Dateiende (EOF) und -1 bei Fehler. Robuste Programme prüfen immer den Rückgabewert und lesen/schreiben in einer Schleife weiter.

Positionierung im File

/* Offset setzen */
off_t lseek(int fd, off_t offset, int whence);
// whence: SEEK_SET (absolut), SEEK_CUR (relativ), SEEK_END (vom Ende)

/* Lesen/Schreiben mit explizitem Offset – Offset des FD bleibt unverändert */
ssize_t pread(int fd, void *buf, size_t n, off_t offset);
ssize_t pwrite(int fd, void const *buf, size_t n, off_t offset);

Fehlerbehandlung

Bei Fehler setzen POSIX-Funktionen die globale Variable errno. Zur Ausgabe einer Fehlermeldung:

#include <errno.h>
#include <string.h>
#include <stdio.h>

char *msg = strerror(errno);      // Fehlermeldung als String
perror("open");                    // "open: No such file or directory"

Vollständiges Beispiel: Datei kopieren

#define N 32
char buf[N];

int src = open(spath, O_RDONLY);
int dst = open(dpath, O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU);

ssize_t n;
while ((n = read(src, buf, N)) > 0) {
    ssize_t written = 0;
    while (written < n) {                          // Schleife für partielle writes!
        ssize_t w = write(dst, buf + written, n - written);
        if (w < 0) { /* Fehlerbehandlung */ break; }
        written += w;
    }
}
if (n < 0) { /* Lesefehler behandeln */ }

close(src);
close(dst);
✅ Warum O_TRUNC hier wichtig ist: Existiert die Zieldatei bereits und ist länger als die neue Kopie, würden ohne O_TRUNC alte Restbytes am Dateiende stehen bleiben. Für ein Kopierprogramm gehört daher typischerweise O_TRUNC in die Flags. Außerdem bleibt die innere write-Schleife nötig, weil write weniger Bytes schreiben kann als angefordert.

Windows-Vergleich

POSIXWindows
openCreateFile
readReadFile
writeWriteFile
lseekSetFilePointer
closeCloseHandle
Trennzeichen: /Trennzeichen: \
Ein Wurzelverzeichnis: /Je Laufwerk: C:\, D:\, …

C-Stream-API

Die C-API (fopen, fread, fwrite, fprintf, …) liegt über der jeweiligen Betriebssystem-API und bietet eine weitgehend plattformunabhängige, gepufferte, zeichenorientierte Abstraktion. Sie ist portabler als direkte POSIX- oder Windows-Aufrufe, funktioniert aber nicht in allen Details identisch: Pfadsyntax, Text-/Binärmodus, Zeilenenden und Fehlerdetails können sich je nach Betriebssystem unterscheiden.

Vorteile C-API

  • Plattformunabhängig
  • Automatisch gepuffert (effizienter)
  • Formatierte Ein-/Ausgabe (fprintf, fscanf)
  • Übersetzt Zeilenenden (\n\r\n)

Vorteile POSIX-API

  • Direkter Zugriff, keine Pufferschicht
  • Volle Kontrolle über Dateioffset
  • Notwendig für Pipes, Sockets, spezielle Dateien
  • Atomar (z.B. pread/pwrite)
📌 Zeilenenden je Betriebssystem:
Windows: \r\n = 13d 10d = 0Dh 0Ah
Linux: \n = 10d = 0Ah
Mac OS (vor OS X): \r = 13d = 0Dh
Die C-API übernimmt diese Übersetzung automatisch im Textmodus.

Prüfungsnahe Ergänzung: Dateisystem-API aus Folien, Transkript und Übung 3

Dieser Abschnitt ergänzt die bisherige Zusammenfassung um die praktischen Teile aus der Übung: Dateien vergleichen, kopieren und mit der C-Stream-API zeichenweise auswerten. Der Schwerpunkt liegt auf dem Zusammenhang zwischen den Vorlesungskonzepten File-Descriptor, Offset, Stream, Pufferung und Fehlerbehandlung.

C-Stream-API FILE *, fopen, fgetc POSIX-API int fd, read, write, lseek Open File Entry Offset + Statusflags zeigt auf Datei/Inode Datei Nutzdaten + Metadaten Merke: Die C-API ist bequemer und gepuffert; die POSIX-API arbeitet näher am Kernel mit rohen Bytes und File-Deskriptoren. Ein Stream kann mit fileno auf einen fd zurückgeführt werden; fdopen baut einen Stream auf einem vorhandenen fd.
Abbildung 3.E1 Zusammenhang von C-Stream-API, POSIX-API, Open-File-Entry, Offset und Datei.
📌 Repräsentation des Transkripts: Die Vorlesung betont, dass Anwendungen normalerweise nur die abstrakte API sehen. Die konkrete Datenträgerstruktur wird erst später bei ext2/ext4 relevant. Übung 3 setzt genau diese API-Sicht praktisch um: erst POSIX mit open/read/write/close, danach C-Streams mit fopen/fgetc/fputc.

Übung 3, Aufgabe 1: zwei Dateien mit POSIX vergleichen

Zwei Dateien sind genau dann gleich, wenn sie gleich lang sind und jedes Byte übereinstimmt. Die Übung arbeitet blockweise mit zwei Heap-Puffern von 4 KiB. Entscheidend ist nicht ein Dateiname oder eine Endung, sondern der tatsächliche Byteinhalt.

SchrittWas passiert?Warum prüfungsrelevant?
1argc prüfen und beide Dateien mit open öffnenWiederholung von Programmargumenten und Fehlerbehandlung
2jeweils bis zu 4 KiB mit read lesenread kann weniger Bytes liefern als angefordert
3gelesene Byteanzahl vergleichenunterschiedliche Länge ⇒ Dateien ungleich
4Inhalte mit memcmp vergleichenBytevergleich, keine Textinterpretation
5wiederholen bis beide Dateien gleichzeitig EOF erreichenEOF ist hier der erfolgreiche Abbruchfall
/* Kernidee von read_and_compare_one_block */
ssize_t n1 = read(fd1, buffer1, size);
ssize_t n2 = read(fd2, buffer2, size);

if (n1 < 0 || n2 < 0) {
    return -1;              /* echter Lesefehler */
}
if (n1 != n2) {
    return -1;              /* eine Datei ist länger/kürzer */
}
if (memcmp(buffer1, buffer2, (size_t)n1) != 0) {
    return -1;              /* gleicher Umfang, aber andere Bytes */
}
return n1;                  /* 0 bedeutet: beide gleichzeitig EOF */
⚠️ Typischer Fehler: Nicht den ganzen Puffer mit memcmp(buffer1, buffer2, size) vergleichen, wenn der letzte Block kleiner als size ist. Korrekt ist der Vergleich nur über die tatsächlich gelesenen Bytes n1.

Übung 3, Aufgabe 2: Datei mit POSIX kopieren

Das Kopieren ist der Vergleichsaufgabe ähnlich, verwendet aber nur einen Puffer: lesen aus der Quelle, schreiben in das Ziel. Prüfungsrelevant ist hier besonders, dass write weniger Bytes schreiben kann als angefordert. Robust ist deshalb eine kleine Hilfsfunktion, die weiterschreibt, bis der ganze Block geschrieben wurde.

ssize_t write_all(int fd, char const *buf, size_t n) {
    size_t written = 0;

    while (written < n) {
        ssize_t r = write(fd, buf + written, n - written);
        if (r < 0) {
            return -1;
        }
        written += (size_t)r;
    }

    return (ssize_t)written;
}

Damit wird die Kopierschleife konzeptionell sehr einfach:

while ((n = read(src_fd, buffer, buffer_size)) > 0) {
    if (write_all(dst_fd, buffer, (size_t)n) < 0) {
        /* Fehler behandeln */
        break;
    }
}

Übung 3, Aufgabe 3 und 4: C-API, Laufzeitvergleich und wc-ähnlicher Zähler

Mit der C-API ist das Kopierprogramm kürzer, weil die Bibliothek selbst puffert. Die Übung verwendet dafür die einfache Zeichen-Schleife mit fgetc und fputc. Bei sehr großen Dateien kann die POSIX-Variante mit großen Blöcken schneller wirken; auf modernen Systemen können beide Varianten durch Betriebssystem-Cache und libc-Pufferung aber ähnlich schnell sein.

int c;
while ((c = fgetc(in)) != EOF) {
    if (fputc(c, out) == EOF) {
        /* Schreibfehler behandeln */
        break;
    }
}

if (ferror(in)) {
    /* EOF war nicht die Ursache, sondern ein Lesefehler */
}
C-Stream-FunktionBedeutungPrüfungsnotiz
fgetcliest ein Zeichen als intint, damit EOF unterscheidbar bleibt
fputcschreibt ein ZeichenRückgabewert prüfen
feofprüft EOF-Zustanderst nach fehlgeschlagenem Lesen sinnvoll
ferrorprüft FehlerzustandEOF und Fehler sauber trennen
ftell/fseek/rewindStream-Position lesen/setzenanalog zu lseek, aber auf FILE *
fdopen/filenoÜbergang zwischen fd und StreamVerbindet POSIX- und C-Ebene

Der Wortzähler aus Aufgabe 4 lässt sich als einfacher Zustandautomat formulieren. Gezählt wird ein neues Wort genau dann, wenn ein Nicht-Whitespace-Zeichen gelesen wird, während man vorher „außerhalb eines Wortes“ war.

int in_word = 0;
size_t lines = 0;
size_t words = 0;

while ((c = fgetc(file)) != EOF) {
    if (c == '\n') {
        ++lines;
    }

    if (isspace((unsigned char)c)) {
        in_word = 0;
    } else if (!in_word) {
        ++words;
        in_word = 1;
    }
}
4

Prozesse

Ein Programm ist passiv – es ist eine Datei, die Instruktionen beschreibt. Ein Prozess ist aktiv – er ist eine laufende Instanz eines Programms mit eigenem Zustand, Adressraum und Betriebssystem-Ressourcen. Mehrere Prozesse können dasselbe Programm gleichzeitig ausführen, sind aber voneinander vollständig isoliert.

Process Control Block (PCB) & Kontextwechsel

Das Betriebssystem speichert alle Informationen eines Prozesses im Process Control Block (PCB). Dazu gehören:

  • Registerinhalte (beim Unterbrechen gespeichert)
  • Instruction Pointer
  • Flags
  • MMU-Konfiguration / Page-Table-Pointer
  • Liste offener File-Deskriptoren
  • Prozess-ID (PID) und Parent-Prozess-ID (PPID)

Bei einem Kontextwechsel speichert das Betriebssystem den Kontext von Prozess A im PCB von Prozess A und stellt den Kontext von Prozess B aus dem PCB von Prozess B wieder her. Für beide Prozesse wirkt es, als liefen sie ununterbrochen weiter.

Prozess-Hierarchie

Unter POSIX hat jeder Prozess außer Prozess 1 genau einen Parent-Prozess. Prozess 1 ist der erste Prozess des Systems (früher init, heute oft systemd). Daraus ergibt sich eine Baumstruktur – die Prozesshierarchie, sichtbar über pstree.

PID 1 (init) PID 100 PID 200 PID 300 PID 201 (child) PID 301 (child)
Abbildung 4.1 Prozesshierarchie: Jeder Prozess hat genau einen Parent. Prozess 1 ist die Wurzel der gesamten Hierarchie.

Prozess-APIs: fork, exec, exit, wait

fork – Prozess duplizieren

#include <unistd.h>
pid_t fork(void);

fork erzeugt eine Kopie des aktuellen Prozesses. Nach dem Aufruf laufen beide Prozesse ab der nächsten Instruktion weiter. Der Rückgabewert unterscheidet:

  • Im Parent: PID des Child-Prozesses (> 0)
  • Im Child: 0
  • Bei Fehler: -1 (nur im Parent)
pid_t new_pid = fork();

if (new_pid > 0) {
    /* Parent-Code: new_pid ist die PID des Childs */
} else if (new_pid == 0) {
    /* Child-Code */
} else {
    /* Fehlerbehandlung: errno enthält den Grund */
}
📌 Copy-on-Write: Konzeptionell ist der Child eine vollständige Kopie des Parents (Heap, Stack, globale Daten). Moderne Betriebssysteme kopieren den Speicher aber nicht sofort physisch – sie verwenden Copy-on-Write (CoW): Parent und Child teilen zunächst dieselben physischen Pages. Erst wenn einer in eine Page schreibt, wird sie tatsächlich kopiert.

Ist fork eine „exakte Kopie“?

Konzeptionell ja, identisch aber nein. Nach fork() existieren zwei Prozesse, die ab derselben Stelle weiterlaufen. Der Child erhält jedoch eine neue PID. Die PID ist also gerade nicht gleich. Auch der Rückgabewert von fork() ist unterschiedlich, damit Parent und Child verschiedene Codepfade ausführen können.

AspektParent nach forkChild nach fork
PIDbleibt gleichneu, eindeutig im System
PPIDwie vorherPID des Parents
RückgabewertPID des Childs0
Virtueller Speichergleicher Inhalt, zunächst CoW geteiltgleicher Inhalt, zunächst CoW geteilt
Offene File-Deskriptorenbleiben offenwerden dupliziert und zeigen auf dieselben Kernel-Objekte
Program CounterInstruktion nach fork()Instruktion nach fork()
Parent vor fork() PID 1200, fd 3 → Datei Parent PID 1200, fork() → 1201 Child PID 1201, fork() → 0 gleiche Open-File-Description
Abbildung 4.2 Nach fork gibt es zwei Prozesse mit unterschiedlichen PIDs; kopierte File-Deskriptoren referenzieren aber dieselben Kernel-Objekte.

Die exec-Familie – Programmimage ersetzen

Mit exec wird das laufende Programmimage durch ein anderes ersetzt. Der Prozess bleibt derselbe (gleiche PID), führt aber danach ein neues Programm aus. Bei Erfolg kehrt exec nicht zurück.

FunktionArgumentePfad/Env
execlListePfad, altes Environment
execleListePfad, neues Environment
execlpListeSuche über PATH
execvArrayPfad, altes Environment
execveArrayPfad, neues Environment
execvpArraySuche über PATH

exit – Prozess beenden

void exit(int code);   // kehrt nicht zurück

wait / waitpid – auf Child warten

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

wait blockiert bis irgendein Child endet; waitpid wartet auf ein bestimmtes Child. Der Status wird über Makros ausgewertet:

if (WIFEXITED(status)) {
    int code = WEXITSTATUS(status);  // eigentlicher Exit-Code
}

Parameter von waitpid: pid > 0 wartet auf genau diesen Child; pid == -1 entspricht wait (irgendein Child).

Typischer fork-exec-wait Ablauf

pid_t pid = fork();
if (pid == 0) {
    /* Child: anderes Programm laden */
    execvp("ls", argv);
    perror("exec failed");   // nur erreicht bei Fehler
    exit(1);
}
/* Parent wartet auf Child */
int status;
waitpid(pid, &status, 0);

Wie erzeugt man wirklich einen neuen Subprozess?

Ein neuer Subprozess entsteht typischerweise in zwei Schritten: Zuerst erzeugt fork() einen Child-Prozess. Danach ersetzt der Child mit exec... sein Programmimage durch das gewünschte Programm. Der Parent behält seine PID und kann mit waitpid() auf genau diesen Child warten.

char *args[] = { "ls", "-l", NULL };
pid_t pid = fork();

if (pid == 0) {
    /* Child: neues Programm starten */
    execvp(args[0], args);
    perror("execvp");  // nur bei Fehler erreicht
    _exit(127);       // im Child nach exec-Fehler lieber _exit
}

if (pid > 0) {
    int status;
    waitpid(pid, &status, 0);
}
📌 Wichtig: exec erzeugt keinen neuen Prozess. Es ersetzt nur das Programm im bereits existierenden Prozess. Deshalb bleibt die PID beim erfolgreichen exec gleich.

Was passiert, wenn man close vergisst?

close(fd) entfernt genau einen File-Deskriptor aus der Deskriptor-Tabelle eines Prozesses. Das zugrunde liegende Kernel-Objekt verschwindet erst, wenn keine Deskriptor-Kopie mehr darauf zeigt. Nach fork() existieren diese Kopien oft in Parent und Child.

Vergessenes closeFolgeTypischer Fehler
Write-End einer Pipe bleibt irgendwo offenLeser sieht kein EOFread() blockiert scheinbar „für immer“
Read-End einer Pipe bleibt nicht offen, aber Schreiber schreibt weiterKernel sendet SIGPIPE oder write() liefert EPIPEProgramm beendet sich unerwartet
Socket-FD wird dupliziert und nicht geschlossenVerbindung bleibt offenGegenseite wartet auf Verbindungsende
Viele Dateien/Sockets werden nie geschlossenFD-Limit wird erreichtopen()/socket() schlägt mit EMFILE fehl
Fremde FDs werden über exec vererbtSubprozess besitzt unbeabsichtigt RessourcenSicherheits-/Debugging-Problem; Lösung: FD_CLOEXEC
⚠️ Faustregel nach pipe() + fork(): Jeder Prozess schließt sofort alle Pipe-Enden, die er nicht verwendet. Der Leser braucht nur fd[0]; der Schreiber braucht nur fd[1].

Zombie-Prozesse & Orphan-Prozesse

Zombie-Prozess

  • Child beendet sich bevor Parent wait aufruft
  • Prozess ist „tot, aber noch nicht entfernt"
  • Betriebssystem muss Status aufbewahren, bis Parent abholt
  • Kein weiterer Speicher außer dem PCB belegt

Orphan-Prozess

  • Parent beendet sich bevor Child endet
  • Child wird zu einem Orphan (Waisenkind)
  • Wird von Prozess 1 adoptiert
  • Prozess 1 ruft wait für alle adoptierten Prozesse

Was ist daran schlimm?

FallProblemEinordnung
Ein einzelner ZombieBelegt fast keinen Speicher und keine CPU, aber einen Eintrag in der Prozess-/PID-Tabelle.Meist harmlos, aber ein Hinweis auf fehlendes wait.
Viele ZombiesPID-/Prozesstabelle kann voll werden.Neue Prozesse können fehlschlagen; System wirkt instabil.
OrphanWird von PID 1/systemd adoptiert und später geerntet.Nicht automatisch schlimm; Daemons nutzen das teilweise absichtlich.
Orphan hält FDs offenPipe/Sockets/Dateien bleiben offen, obwohl Parent weg ist.Kann EOF verhindern oder Ressourcen blockieren.
✅ Saubere Lösung: Parent-Prozesse, die Children starten, sollten deren Ende mit wait()/waitpid() abholen. Bei Servern verwendet man oft einen SIGCHLD-Handler oder eine Warteschleife, die beendete Children regelmäßig „reapt“.
5

Programme & Bibliotheken

Der Loader

Der Loader ist die Komponente des Betriebssystems, die ein Executable und seine dynamischen Bibliotheken in den Hauptspeicher lädt. Er analysiert die Binärdatei, mappt Code- und Datensegmente in den Adressraum und stellt sicher, dass alle benötigten Symbole aufgelöst sind.

Der Loader lädt keine statischen Bibliotheken – diese wurden bereits zur Linkzeit eingebunden und sind unsichtbar. Unter Linux läuft der Ladevorgang über den Systemaufruf sys_execve, der eine Liste von Binary Handlers prüft. Der wichtigste Handler ist der ELF-Loader.

📌 Binary Handler: Linux erlaubt die Registrierung zusätzlicher Handler. Dadurch können fremde Binärformate über Emulatoren (z.B. QEMU für andere Architekturen, Wine für Windows-Programme) transparent gestartet werden.

Das ELF-Format

ELF (Executable and Linking Format) ist das Standard-Binärformat unter Linux und anderen UNIX-artigen Systemen. Es beschreibt, wie ausführbarer Code, Daten und Metainformationen in einer Datei abgelegt sind.

Eine ELF-Datei beginnt mit einem ELF-Header, der Informationen über die Architektur, den Einstiegspunkt und die Lage der anderen Tabellen enthält. Es gibt zwei Sichten:

Linking View (Sections)

  • Für den Linker relevant
  • .text – Maschinencode
  • .data – initialisierte Daten
  • .bss – uninit. Daten (nullgefüllt)
  • .rodata – Konstanten
  • .symtab – Symboltabelle

Execution View (Segments)

  • Für den Loader relevant
  • LOAD-Segmente: werden in Adressraum gemappt
  • Berechtigungen: read/write/execute
  • INTERP: Name des dynamischen Linkers
  • DYNAMIC: Infos für dynamisches Linken

Bibliotheken: statisch vs. dynamisch

Statische Bibliotheken (.a)

Statische Bibliotheken sind Archive von Objektdateien (.o). Der Linker kopiert nur die benötigten Teile direkt in das erzeugte Executable. Das erzeugte Binary ist vollständig eigenständig, aber größer.

# Erzeugen einer statischen Bibliothek:
ar rcs libmylib.a module1.o module2.o

# Einbinden beim Linken:
gcc main.o -L. -lmylib -o programm

Dynamische Bibliotheken (.so)

Dynamische Bibliotheken werden zur Laufzeit vom Loader geladen. Mehrere Prozesse können denselben Code-Teil einer Bibliothek im Speicher teilen. Updates der Bibliothek wirken für alle Programme, ohne sie neu kompilieren zu müssen.

# Erzeugen einer dynamischen Bibliothek (PIC erforderlich):
gcc -shared -fPIC -o libmylib.so module1.o module2.o

# Einbinden beim Linken:
gcc main.o -L. -lmylib -o programm
📌 Position Independent Code (PIC): Dynamische Bibliotheken müssen als PIC kompiliert werden, weil sie an beliebige Adressen im Adressraum geladen werden können (die Adresse ist zur Kompilierzeit unbekannt). PIC verwendet relative Adressierung und eine Global Offset Table (GOT) für externe Referenzen.

Was passiert beim Bauen und Starten?

1) Kompilieren main.c main.o gcc -c 2a) Statisch: .a wird kopiert libx.a programm enthält Code 2b) Dynamisch: .so bleibt Datei libx.so programm enthält NEEDED-Eintrag 3) Start: Kernel + dynamischer Loader execve(programm) ld-linux lädt benötigte .so Text-Segmente können geteilt werden
Abbildung 5.1 Statische Bibliotheken werden ins Programm gelinkt; dynamische Bibliotheken werden erst beim Start/Zur Laufzeit gemappt.

Warum .a?

.a bedeutet Archiv: eine Sammlung von Objektdateien. Der statische Linker nimmt daraus nur die benötigten Objektmodule und kopiert sie in das Executable.

Warum .so?

.so steht für Shared Object. Der Code bleibt in einer separaten Datei und kann von mehreren Prozessen als gemeinsame, read-only Text-Pages genutzt werden.

Wann statisch?

Gut für einfache Verteilung ohne Laufzeitabhängigkeiten. Nachteil: größere Binaries, Updates erfordern neu linken.

Wann dynamisch?

Gut für Speicherteilung und zentrale Updates. Nachteil: richtige Version der Bibliothek muss zur Laufzeit gefunden werden.

EigenschaftStatischDynamisch
LinkzeitpunktCompilezeitLaufzeit
Größe Executablegrößerkleiner
Speicherteilungneinja (Code)
Updatesneu kompilierenBibliothek ersetzen
Abhängigkeitenkeine zur LaufzeitBibliothek muss vorhanden sein
PIC nötigneinja (-fPIC)

Prüfungsnahe Ergänzung: Toolchain, Linker und Loader

Dieser Abschnitt ergänzt die Folien- und Transkriptteile, die in der ursprünglichen Zusammenfassung nur knapp enthalten waren. Wichtig ist die Trennung zwischen Übersetzen, Linken und Laden: Der Compiler erzeugt aus einer Translation Unit Objektcode, der Linker verbindet Objektdateien und Bibliotheken, und der Loader bringt das fertige Programm beim Start in den virtuellen Adressraum.

Vom C-Quelltext zum laufenden Prozess main.c Präprozessor #include/#define Compiler Assembler-Code main.o ELF libx.a wird kopiert libx.so NEEDED Linker Symbole execve Loader Merke: Sections helfen dem Linker; Segments helfen dem Loader. Dynamische Bibliotheken werden nicht ins Executable kopiert, sondern zur Laufzeit gemappt.
Abbildung 5.2 Zusammenhängende Sicht aus Folien und Transkript: Quelltext → Translation Unit → Objektdatei → ELF-Programm → Prozess im Speicher.
Schritt / WerkzeugWas passiert?Prüfungsrelevante Beobachtung
gcc -EPräprozessor expandiert Includes und Makros.Die entstehende Translation Unit ist der eigentliche Input für den Compiler.
gcc -SCompiler erzeugt Assemblertext.Hier sieht man bereits ABI-Konventionen, Funktionsnamen und Aufrufkonventionen.
gcc -cAssembler erzeugt eine Objektdatei .o.Symbole können noch undefiniert sein und werden erst beim Linken aufgelöst.
gcc ... -o progLinker verbindet Objektdateien und Bibliotheken.Statische Bibliotheken werden kopiert; dynamische Bibliotheken erzeugen Abhängigkeiten.
readelf -h/-S/-lELF-Header, Section Header und Program Header anzeigen.-S entspricht Linking View, -l entspricht Execution View.
ldd progDynamische Laufzeitabhängigkeiten anzeigen.Zeigt, welche .so-Dateien der dynamische Loader finden muss.
nm / objdumpSymbole und disassemblierte Instruktionen untersuchen.Nützlich, um undefinierte, exportierte und lokale Symbole zu unterscheiden.
✅ Repräsentation des Transkripts: Die Erklärung des Loaders ist jetzt mit der praktischen Toolchain verbunden. Damit ist sichtbar, warum execve nicht „C-Code ausführt“, sondern ein Binärformat interpretiert, Segmente mappt und gegebenenfalls den dynamischen Linker startet.

Dynamisches Linken, GOT/PLT und die dl-API

Dynamisches Linken hat zwei Varianten: Bibliotheken können beim Programmstart automatisch geladen werden, oder ein Programm lädt Module explizit zur Laufzeit. Für die zweite Variante verwendet man unter POSIX/Linux die dl-API.

Automatisch beim Start

  • Executable enthält NEEDED-Einträge.
  • ld-linux sucht passende .so-Dateien.
  • GOT/PLT helfen, externe Adressen positionsunabhängig und ggf. verzögert aufzulösen.

Explizit zur Laufzeit

  • dlopen lädt eine Bibliothek oder ein Plugin.
  • dlsym sucht eine Funktion oder globale Variable per Symbolname.
  • dlclose gibt die geladene Bibliothek wieder frei, wenn sie nicht mehr benötigt wird.
#include <dlfcn.h>

void *handle = dlopen("./plugin.so", RTLD_NOW);
if (!handle) { /* dlerror() ausgeben */ }

int (*plugin_fn)(int) = (int (*)(int)) dlsym(handle, "run");
if (!plugin_fn) { /* dlerror() ausgeben */ }

int result = plugin_fn(42);
dlclose(handle);
KonzeptBedeutungWarum wichtig?
SONAMELogischer Bibliotheksname, gegen den Programme gelinkt werden.Erlaubt Versionierung, ohne jedes Programm neu zu linken.
Real NameTatsächliche Datei, z.B. mit vollständiger Versionsnummer.Mehrere Versionen können parallel installiert sein.
Linker NameName, den der Linker über -l... findet.Wird meist als Symlink auf die passende Version realisiert.
GOTGlobal Offset Table mit Adressen externer Daten/Funktionen.Ermöglicht Position Independent Code.
PLTProcedure Linkage Table als Sprungtabelle für externe Funktionen.Erlaubt Lazy Binding: Funktionsadresse wird erst beim ersten Aufruf aufgelöst.
LD_LIBRARY_PATHZusätzlicher Suchpfad für dynamische Bibliotheken.Praktisch für Übungen, aber gefährlich/fragil für produktive Software.
⚠️ Typischer Denkfehler: Eine dynamische Bibliothek wird nicht „in jedes Programm hineinkopiert“. Das Executable enthält vor allem Metadaten und Relokationsinformationen. Der gemeinsam nutzbare Code der Bibliothek kann später als read-only Mapping in mehrere Prozesse eingeblendet werden.

Übung 5: ELF-Dateien lesen und Section Header verstehen

Die Übung zu Programme & Bibliotheken prüft nicht nur Begriffe, sondern den Umgang mit echten Binärdaten. Man soll ELF-Header und Section Header dekodieren und daraus ableiten, wo Tabellen liegen, wie groß ein Eintrag ist und welche Sektionen ausführbaren Code enthalten.

ELF-Header parsen

Wichtige Felder sind e_ident, e_type, e_machine, e_entry, e_shoff, e_shentsize, e_shnum und e_shstrndx.

Section Header Table

Jeder Eintrag beschreibt eine Section: Name-Offset, Typ, Flags, Dateioffset und Größe. Die Namen selbst liegen in der Section-String-Table.

Hexdump dekodieren

Bytefolgen müssen gemäß ELF-Klasse und Endianness interpretiert werden. In der Übung ist die Eintragsgröße entscheidend, um zu erkennen, wie viele Header im Ausschnitt liegen.

Mit readelf prüfen

Das eigene Ergebnis wird mit readelf -h und readelf -S validiert. So wird aus der Theorie ein überprüfbares Werkzeug.

Frage aus der ÜbungLösungsstrategieMerksatz
Wie viele Section-Header sind im Hexdump?Anzahl Bytes des Ausschnitts durch e_shentsize teilen.Nicht nach Zeilen zählen, sondern nach Strukturgröße.
Welche Section-Typen liegen vor?Feld sh_type pro Eintrag dekodieren.SHT_NULL, SHT_PROGBITS, SHT_NOTE usw. sind numerische Codes.
Enthält eine Section ausführbaren Code?Flag SHF_EXECINSTR prüfen.Code erkennt man nicht am Namen allein, sondern an den Flags.
Wo liegen die Sections in der Datei?sh_offset und sh_size auswerten.Header beschreiben Dateiabschnitte; sie sind nicht selbst der Inhalt.
typedef struct {
    uint32_t name_offset;
    uint32_t type;
    uint64_t flags;
    uint64_t offset;
    uint64_t size;
} section_header_t;

/* Schema der Übung:
   1. ELF-Header lesen.
   2. e_shoff/e_shentsize/e_shnum verwenden.
   3. Array für Section Header auf dem Heap anlegen.
   4. Section-String-Table über e_shstrndx finden.
   5. Namen und Typen ausgeben und mit readelf vergleichen. */
✅ Abdeckung verbessert: Kapitel 5 enthält nun neben der Grundidee von ELF und Bibliotheken auch die prüfungsnahen Konzepte aus Übung 5: Toolchain, dynamisches Laden, dlopen/dlsym, GOT/PLT, ELF-Header-Felder und Section-Header-Dekodierung.
6

Threads

Motivation: Warum Threads?

Das Prozessmodell isoliert Prozesse vollständig. Das ist gut für Sicherheit und Stabilität, aber macht die Realisierung paralleler Abläufe innerhalb einer Anwendung aufwändig: Der Overhead für Prozesskopieren ist groß, gemeinsame Ressourcen sind schwer zu teilen.

Ein Thread ist ein eigenständiger Ausführungsstrang innerhalb eines Prozesses. Alle Threads eines Prozesses teilen:

  • Denselben Adressraum (Heap, globale Variablen)
  • Dieselben File-Deskriptoren
  • Dieselben Signal-Handler

Jeder Thread hat jedoch einen eigenen: Stack, Instruction Pointer, Register-Zustand und Thread-ID.

Worker-Modell: Aufgabenqueue + mehrere Threads

Ein sehr häufiges Muster ist ein Worker Pool: Ein Hauptthread legt Arbeitspakete in eine Queue. Mehrere Worker-Threads holen sich jeweils das nächste Paket, bearbeiten es und schreiben das Ergebnis zurück. Wichtig ist, dass die Queue synchronisiert wird, z.B. mit Mutex und Condition Variable.

Main Thread erzeugt Jobs Job Queue Mutex + Condition Variable [A][B][C][D]... Worker 1 Worker 2 Worker 3 gemeinsames Ergebnis ebenfalls schützen!
Abbildung 6.1 Worker Pool: Die Queue ist die zentrale Synchronisationsstelle. Ohne Mutex entstehen Race Conditions.

Konzeptionelles Zeitdiagramm

Zeit: 0 1 2 3 4 5 6
Worker 1: [Job A===========] [Job D====]
Worker 2: [Job B=====] [Job E=========]
Worker 3: [Job C=================]
Main: enqueue A,B,C,D,E wait/join

Die Ausführung überlappt sich: Drei Worker können drei unabhängige Jobs parallel bearbeiten. Trotzdem ist nicht alles automatisch schneller: Queue-Zugriffe, Ergebnislisten und gemeinsam genutzte Daten müssen geschützt werden, und zu viele Threads erzeugen Scheduling-Overhead.

while (running) {
    pthread_mutex_lock(&queue_mutex);
    while (queue_empty && running)
        pthread_cond_wait(&queue_not_empty, &queue_mutex);

    job = pop_job();
    pthread_mutex_unlock(&queue_mutex);

    process(job);  // lange Arbeit ohne Queue-Mutex
}

POSIX Threads (pthreads)

Thread erstellen

#include <pthread.h>

int pthread_create(
    pthread_t *thread_id,        // Out-Parameter: ID des neuen Threads
    pthread_attr_t const *attr,  // Attribute (NULL = Standard)
    void *(*start_function)(void *),  // Startfunktion
    void *argument               // Argument für Startfunktion
);
// Gibt 0 bei Erfolg zurück, sonst Fehlercode

/* Typische Startfunktion: */
void *thread_func(void *arg) {
    struct MyData *data = (struct MyData *)arg;
    /* ... */
    return NULL;
}

Thread-Lebenszyklus und wichtige Funktionen

/* Warten bis Thread beendet ist, Rückgabewert abholen: */
int pthread_join(pthread_t tid, void **return_value);

/* Thread selbst beenden: */
void pthread_exit(void *return_value);

/* Anderen Thread beenden anfordern (nicht für normalen Gebrauch): */
int pthread_cancel(pthread_t tid);

/* Ressourcen automatisch freigeben wenn Thread endet: */
int pthread_detach(pthread_t tid);

/* Eigene Thread-ID abfragen: */
pthread_t pthread_self(void);
⚠️ pthread_cancel: Threads sollten sich besser über ein gemeinsames Flag selbst beenden (kooperativ), anstatt pthread_cancel zu verwenden. Bei Cancel müssen trotzdem Ressourcen korrekt aufgeräumt werden.

Thread-Attribute

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 1 << 16);   // 64 KB Stack
pthread_create(&tid, &attr, start_fn, arg);
pthread_attr_destroy(&attr);

Thread-Local Storage (TLS)

Mit dem Schlüsselwort _Thread_local (C11) oder __thread (GCC-Extension) kann eine globale Variable als thread-lokal deklariert werden. Jeder Thread bekommt dann eine eigene Kopie dieser Variable.

_Thread_local int per_thread_counter = 0;

Amdahls Regel: Warum Threads nicht beliebig skalieren

Threads erzeugen Parallelität innerhalb eines Prozesses, aber sie beschleunigen nur die Anteile eines Programms, die tatsächlich parallel ausgeführt werden können. Amdahls Regel beschreibt diese Grenze: Ein serieller Anteil bleibt seriell, egal wie viele Prozessoren vorhanden sind.

\[ S(s,n) = \frac{1}{s + \frac{1-s}{n}} \]

In der Übung besteht der Algorithmus aus fünf Teilen mit insgesamt 100 ms. Die nicht parallelisierbaren Teile dauern zusammen 50 ms. Damit ist der serielle Anteil \(s = \frac{50}{100} = \frac{1}{2}\).

Anzahl ProzessorenSpeedup nach AmdahlInterpretation
2\(\frac{4}{3} \approx 1{,}33\)Nur der parallele Anteil profitiert von zwei Prozessoren.
4\(\frac{8}{5} = 1{,}6\)Mehr Kerne bringen zusätzlichen, aber abnehmenden Nutzen.
8\(\frac{16}{9} \approx 1{,}78\)Der Speedup nähert sich der Grenze.
2Mehr als Faktor 2 ist unmöglich, weil 50 % seriell bleiben.
📌 Prüfungsintuition: Wenn ein Programm zur Hälfte seriell ist, kann es niemals schneller als Faktor 2 werden. Threads lösen also nicht automatisch jedes Performanceproblem; sie helfen nur dort, wo Arbeit wirklich unabhängig parallelisiert werden kann.

Speicherlayout: Was Threads teilen und was nicht?

Das Transkript betont den Unterschied zu Prozessen: Threads sind keine isolierten Programme, sondern Ausführungsstränge im selben virtuellen Adressraum. Deshalb sehen sie denselben Code, dieselben globalen Variablen und denselben Heap, besitzen aber jeweils eigenen Stack und Registerzustand.

Ein Prozess, mehrere Threads gemeinsam im Prozess .text: f1(), f2(), run_thread() .data/.bss: global_var Heap: Parameter-Strukturen File-Deskriptoren Thread 1 Stack 1 locals Register TID Thread 2 Stack 2 locals Register TID Gemeinsame Adressen: Funktionen und globale Variablen. Unterschiedliche Adressen: lokale Variablen auf den jeweiligen Thread-Stacks.
Abbildung 6.2 Speicherbild für die Thread-Übung: Code, globale Variablen und Heap sind geteilt; Stack und Register sind thread-spezifisch.
Adresse / ObjektBei Threads gleich?Begründung
&f1, &f2JaFunktionen liegen im gemeinsamen Textsegment des Prozesses.
&global_varJaGlobale Variable liegt im gemeinsamen Datenbereich.
&local_in_run_threadNeinJeder Thread besitzt einen eigenen Stack.
&local_in_f1, &local_in_f2Zwischen Threads verschieden; innerhalb eines Threads oft gleich wiederverwendet.Die Stackframes werden nacheinander angelegt und können dieselben Stackpositionen wiederverwenden.
⚠️ Warum Parameter nicht auf den Stack von spawn_worker? Ein Thread erhält nur einen void *. Wenn dieser Pointer auf eine lokale Variable zeigt, ist sie nach Rückkehr der Funktion ungültig oder kann beim nächsten Aufruf überschrieben werden. Deshalb werden Thread-Parameter in der Übung auf dem Heap angelegt und später wieder freigegeben.

Thread Local Storage mit pthread_key_t

Die einfache Variante übergibt dem Thread direkt einen Pointer auf eine Parameterstruktur. Die Übung zeigt zusätzlich Thread Local Storage über die pthread-API. Das ist dann nützlich, wenn eine tief verschachtelte Funktionskette auf thread-spezifische Daten zugreifen muss, ohne dass jeder Funktionsaufruf die Parameter weiterreichen muss.

static pthread_key_t thread_local_data_key;

/* Im Master-Thread: Key anlegen, free als Destructor registrieren */
pthread_key_create(&thread_local_data_key, free);

void *run_calculation_thread(void *arg) {
    pthread_setspecific(thread_local_data_key, arg);
    unpack_and_calculate_julia();
    return NULL;
}

void unpack_and_calculate_julia(void) {
    thread_parameters_t *p = pthread_getspecific(thread_local_data_key);
    calculate_julia(p->image, p->window, p->exponent,
                    p->real, p->imaginary, p->iterations);
}

/* Am Ende des Programms */
pthread_key_delete(thread_local_data_key);

Direkter Pointer

  • Einfach und explizit.
  • Jede Funktion sieht an der Signatur, welche Daten gebraucht werden.
  • Für die meisten Fälle die bessere Lösung.

TLS über Key

  • Daten sind pro Thread global erreichbar.
  • Hilfreich bei Legacy-APIs oder Konzepten wie errno.
  • Kann Designprobleme verdecken, wenn es zu oft verwendet wird.

Übung 6: Prozesse, Argumente, Umgebungsvariablen und Threads verbinden

Die Übung verbindet die vorherigen Kapitel mit Threads. Zuerst wird der Fraktalgenerator mit getrennten Programmen umgesetzt: Der Master startet Worker-Prozesse und übergibt Parameter entweder als Programmargumente oder als Umgebungsvariablen. Danach wird dieselbe Fallstudie mit Threads umgesetzt, wodurch die Parameter direkt über Speicherpointer oder TLS übergeben werden können.

ÜbungsteilMechanismusWas man daraus lernen soll
Amdahls RegelSerieller vs. paralleler AnteilMaximaler Speedup ist durch nicht parallelisierbaren Code begrenzt.
Thread-IDs ausgebenpthread_create, pthread_exit, pthread_joinEin Thread muss gejoint oder detached werden, sonst endet main ggf. zu früh.
Speicheradressen vergleichenGlobale und lokale VariablenThreads teilen Adressraum, aber nicht Stack und Register.
Fraktalgenerator mit Prozessenfork, execv, setenv, execveProzesse brauchen textbasierte Übergabe über Argumente oder Environment.
Fraktalgenerator mit ThreadsPointer auf thread_parameters_tThreads können direkt auf gemeinsame Datenstrukturen zugreifen.
Thread Local Storagepthread_key_create, setspecific, getspecificJeder Thread erhält eigene Daten unter demselben globalen Key.
✅ Abdeckung verbessert: Kapitel 6 enthält nun die im Transkript und in Übung 6 wichtigen Ergänzungen: Amdahls Regel mit Zahlenbeispiel, Speicherlayout-Vergleich, Heap-Parameter für Threads, POSIX-TLS und die Verbindung zwischen Prozess- und Thread-Varianten der Fallstudie.
7

Scheduling

Scheduling löst das Problem, wie der Prozessor auf mehrere ausführungsbereite Threads aufgeteilt wird. Es kommt immer dann zum Einsatz, wenn mehr Threads laufen wollen als CPUs zur Verfügung stehen.

Thread-Zustände und Ready-Queue

ready (wartend auf CPU) running (auf CPU) waiting scheduler dispatch suspend (Preemption) wait for event event completion
Abbildung 7.1 Grundmodell der Thread-Zustände. Der Scheduler wählt aus der Ready-Queue den nächsten Thread aus (dispatch). Preemption schiebt einen laufenden Thread zurück in die Ready-Queue.
⚠️ Ready-Queue ≠ FIFO: Der Begriff „Ready-Queue" ist abstrakt. Die interne Datenstruktur muss keine FIFO-Queue sein – je nach Algorithmus kann es ein Heap, Baum oder eine prioritätssortierte Struktur sein.

Begriffe, die in Scheduling-Aufgaben oft vorausgesetzt werden

BegriffBedeutungWarum wichtig?
präemptivDer Scheduler darf einen laufenden Thread unterbrechen, z.B. nach Timer-Interrupt.Nötig für Round Robin, SRTF und viele interaktive Systeme.
kooperativEin Thread gibt die CPU freiwillig ab, z.B. durch Blockieren oder Yield.Ein schlecht programmierter Thread kann andere blockieren.
atomarEine Operation wirkt unteilbar: Andere Threads sehen keinen Zwischenzustand.Wichtig bei Locks, Semaphoren und Updates gemeinsamer Variablen.
DispatcherTeil des Kernels, der die vom Scheduler gewählte Ausführung wirklich aktiviert.Führt Kontextwechsel aus.
KontextwechselRegister/PC/Stack-Zustand eines Threads speichern und einen anderen laden.Erzeugt Overhead; zu kleine Zeitscheiben können ineffizient sein.
ZeitscheibeMaximale Laufzeit bis zur präemptiven Unterbrechung bei RR.Bestimmt Balance zwischen Reaktionszeit und Overhead.
WartezeitZeit in der Ready-Queue, ohne zu laufen.Wichtige Kennzahl in Rechenaufgaben.
DurchlaufzeitFertigstellungszeit minus Ankunftszeit.Auch Turnaround Time genannt.
AntwortzeitErste CPU-Zuteilung minus Ankunftszeit.Besonders wichtig für interaktive Programme.

Durchgerechnetes Scheduling-Beispiel mit Diagrammen

Gegeben sind vier Jobs auf einer CPU. Alle Zeiten sind abstrakte Zeiteinheiten.

JobAnkunftCPU-BurstPriorität
A083
B141
C222
D311

Für diese Tabelle gilt: kleinere Prioritätszahl = höhere Priorität.

FCFS: nicht präemptiv

0 | AAAAAAAAA | 8 | BBBB | 12 | CC | 14 | D | 15
Fertigstellung: A=8, B=12, C=14, D=15
Wartezeit = Fertigstellung − Ankunft − Burst
A: 8−0−8=0, B: 12−1−4=7, C: 14−2−2=10, D: 15−3−1=11
Ø Wartezeit = (0+7+10+11)/4 = 7

SJF: nicht präemptiv

Bei Zeit 0 ist nur A vorhanden, deshalb läuft A zuerst vollständig. Danach wird jeweils der kürzeste verfügbare Job gewählt.

0 | AAAAAAAAA | 8 | D | 9 | CC | 11 | BBBB | 15
Fertigstellung: A=8, D=9, C=11, B=15
Wartezeiten: A=0, B=15−1−4=10, C=11−2−2=7, D=9−3−1=5
Ø Wartezeit = 5.5

SRTF: präemptives SJF

SRTF (Shortest Remaining Time First) unterbricht den laufenden Job, wenn ein Job mit kürzerer Restlaufzeit ankommt.

0 | A | 1 | B | 2 | CC | 4 | D | 5 | BBB | 8 | AAAAAAA | 15
Fertigstellung: A=15, B=8, C=4, D=5
Wartezeiten: A=15−0−8=7, B=8−1−4=3, C=4−2−2=0, D=5−3−1=1
Ø Wartezeit = 2.75

Round Robin mit Zeitscheibe q = 3

0 | AAA | 3 | BBB | 6 | CC | 8 | D | 9 | AAA | 12 | B | 13 | AA | 15
Fertigstellung: A=15, B=13, C=8, D=9
Wartezeiten: A=15−0−8=7, B=13−1−4=8, C=8−2−2=4, D=9−3−1=5
Ø Wartezeit = 6.0
Antwortzeiten: A=0, B=3−1=2, C=6−2=4, D=8−3=5

Präemptives Priority Scheduling

Bei gleicher Priorität wird hier FCFS innerhalb derselben Priorität angenommen.

0 | A | 1 | BBBB | 5 | D | 6 | CC | 8 | AAAAAAA | 15
Prioritätsqueues P1 hoch: B D P2 mittel: C P3 tief: A Scheduler wählt immer höchste nicht-leere Queue
Abbildung 7.2 Prioritätsdiagramm: Bei präemptiver Priorität kann ein neu ankommender höher priorisierter Job den laufenden verdrängen.

MLFQ-Idee als Diagramm

Queue 0, höchste Priorität, q=2: interaktive/kurze Jobs
Queue 1, mittlere Priorität, q=4: Jobs, die bereits eine Zeitscheibe verbraucht haben
Queue 2, tiefe Priorität, q=8: CPU-lastige Jobs
Regel: Zeitscheibe vollständig verbraucht → eine Queue tiefer; lange Wartezeit → Aging/Boost nach oben.

Wichtige Begriffe

Ein Prozessor-Burst ist der Zeitraum, in dem ein Thread Instruktionen ausführt (ohne Unterbrechung). I/O-lastige Threads haben viele kurze Bursts; CPU-lastige Threads haben wenige lange Bursts.

Präemptives Scheduling kann einem laufenden Thread den Prozessor entziehen. Kooperatives Scheduling wartet, bis der Thread den Prozessor freiwillig abgibt. Busy-Wait soll vermieden werden (verschwendet CPU-Zeit, verhindert Energiesparzustände).

First Come, First Served (FCFS)

Threads werden in der Reihenfolge ihres Eintreffens in der Ready-Queue abgearbeitet. FCFS ist nicht präemptiv: Ein Thread läuft, bis er blockiert oder endet.

Problem: Convoy-Effekt. Kurze Threads können sehr lange hinter einem langen Thread warten. FCFS ist für interaktive Systeme ungünstig.

Shortest Job First (SJF)

Wählt den Thread mit dem kürzesten nächsten Prozessor-Burst. SJF ergibt unter idealen Bedingungen optimale durchschnittliche Wartezeit. Kann kooperativ oder präemptiv (= Shortest Remaining Time First, SRTF) eingesetzt werden.

🔴 Problem: Die Burst-Länge ist in allgemeinen Systemen unbekannt. SJF kann nur näherungsweise implementiert werden (historische Schätzung). Außerdem kann es zu Starvation kommen: Lange Jobs warten eventuell unbegrenzt, wenn ständig kurze Jobs eintreffen.

Round-Robin Scheduling (RR)

Präemptive Variante von FCFS mit einer festen Zeitscheibe (Time Slice), typischerweise 10 bis 100 ms. Ein Thread darf laufen, bis:

  • er fertig ist,
  • er blockiert (wartet), oder
  • seine Zeitscheibe erschöpft ist → er wird hinten in die Ready-Queue eingereiht.
⚠️ Einfluss der Zeitscheibe:
  • Sehr große Zeitscheibe → ähnliches Verhalten wie FCFS
  • Sehr kleine Zeitscheibe → hoher Kontextwechsel-Overhead; kann Gesamtausführungszeit verlängern
  • Eine kleinere Zeitscheibe verbessert nicht automatisch jede Durchlaufzeit

Prioritätsbasiertes Scheduling

Jeder Thread erhält eine Priorität. Threads mit höherer Priorität laufen vor Threads mit niedrigerer Priorität. Threads gleicher Priorität werden nach FCFS behandelt. SJF ist ein Spezialfall: kurzer Burst = hohe Priorität.

🔴 Starvation und Aging: Niedrig priorisierte Threads können dauerhaft übersehen werden (Starvation). Gegenmaßnahme: Aging – die Priorität eines wartenden Threads wird periodisch erhöht, um sicherzustellen, dass er irgendwann ausgeführt wird.
📌 Prioritätenbereiche: Je nach OS können Prioritäten z.B. von 0–7 oder 0–4096 reichen. Ob 0 die höchste oder niedrigste Priorität ist, ist systemspezifisch – immer die Dokumentation prüfen!

Multi-Level Feedback Queue (MLFQ)

MLFQ verwendet mehrere Ready-Queues mit unterschiedlichen Prioritäten und Zeitscheiben. Höhere Queues haben kleinere Zeitscheiben. Erschöpft ein Thread seine Zeitscheibe, sinkt seine Priorität (er wandert in eine niedrigere Queue mit größerer Zeitscheibe). Threads, die freiwillig warten, bleiben in ihrer Queue.

Effekt: Threads mit kurzen Bursts (interaktive Threads) bleiben automatisch in hohen Queues mit schneller Reaktionszeit; CPU-intensive Threads wandern in niedrige Queues.

Scheduling-Vergleich

AlgorithmusPräemptivStärkeProblem
FCFSNeinEinfachConvoy-Effekt
SJFOptionalOptimale WartezeitBurst-Länge unbekannt, Starvation
Round RobinJaFair, gute AntwortzeitOverhead bei kleiner Zeitscheibe
PrioritätOptionalKritische Tasks bevorzugenStarvation
MLFQJaAdaptiv, kein Vorwissen nötigKomplex, parameterabhängig
✅ Merksatz: Es gibt keinen Scheduling-Algorithmus, der für alle Anwendungen optimal ist. Scheduling ist ein Balanceakt zwischen den Anforderungen der Applikationen (Antwortzeit) und den Anforderungen des Systems (Durchsatz, CPU-Auslastung).

POSIX Scheduling-APIs

/* Nice-Wert (Linux): beeinflusst statische Priorität */
int nice(int increment);
int getpriority(int which, id_t who);
int setpriority(int which, id_t who, int prio);
8

Mutexe & Semaphore

Race Conditions und Critical Sections

Sobald mehrere Threads denselben Speicher lesen und schreiben, können Race Conditions entstehen: Das Ergebnis hängt von der konkreten Ausführungsreihenfolge der Instruktionen ab und ist nicht reproduzierbar.

Warum ++counter nicht atomar ist: Auf Maschinenebene besteht die Operation typischerweise aus drei Schritten. Ein Kontextwechsel zwischen diesen Schritten erzeugt einen Race:

t0: Thread A liest counter in rax   → rax_A = 5
t1: Thread A erhöht rax             → rax_A = 6
t2: Thread B liest counter in rax   → rax_B = 5  ← liest alten Wert!
t3: Thread B erniedrigt rax         → rax_B = 4
t4: Thread A schreibt zurück        → counter = 6
t5: Thread B schreibt zurück        → counter = 4  ← Update von A verloren!
🔴 Nichtdeterminismus: Race Conditions sind besonders heimtückisch, weil sie meist nicht reproduzierbar sind. Sie können in Testläufen unsichtbar bleiben und durch Debugger oder Logging verschwinden (weil sich das Timing ändert). Ein Programm kann korrekt erscheinen und trotzdem einen Fehler enthalten.

Eine Critical Section ist ein Codebereich, in dem auf gemeinsamen Zustand zugegriffen wird. Alle Threads, die auf dieselben geteilten Daten zugreifen, müssen ihre Critical Sections koordinieren.

Anforderungen an Synchronisationsmechanismen

Jeder Synchronisationsmechanismus muss drei Eigenschaften erfüllen:

  1. Gegenseitiger Ausschluss (Mutual Exclusion): Immer nur ein Thread in der Critical Section
  2. Fortschritt: Wenn keine Critical Section belegt ist, muss in endlicher Zeit entschieden werden, wer eintreten darf
  3. Begrenztes Warten: Kein Thread darf beliebig oft übergangen werden
📌 Hardwareunterstützung: Synchronisationsmechanismen können auf modernen Computern nur mit Hardwareunterstützung korrekt implementiert werden – z.B. über atomare Operationen wie Test-and-Set oder Compare-and-Swap (CAS). Ohne diese Primitiven ist es nicht möglich, Race Conditions zuverlässig zu verhindern.

Semaphore

Ein Semaphor ist ein nichtnegativer Zähler mit zwei atomaren Operationen:

  • wait (P, down): Wenn Zähler > 0, verringern und fortfahren; sonst blockieren
  • post (V, up): Zähler erhöhen; wartende Threads wecken

Semaphore eignen sich besonders für Producer-Consumer-Probleme, um freie und belegte Pufferplätze zu zählen:

#include <semaphore.h>

sem_t free_slots, filled_slots;
sem_init(&free_slots, 0, BUFFER_SIZE);  // BUFFER_SIZE freie Plätze
sem_init(&filled_slots, 0, 0);           // 0 befüllte Plätze

/* Producer: */
sem_wait(&free_slots);   // freien Platz belegen
buffer[w] = item;
sem_post(&filled_slots); // befüllten Platz signalisieren

/* Consumer: */
sem_wait(&filled_slots); // auf befüllten Platz warten
item = buffer[r];
sem_post(&free_slots);   // Platz freigeben

Producer-Consumer-Problem ausführlich

Das Producer-Consumer-Problem ist eines der wichtigsten Standardprobleme der Nebenläufigkeit. Ein oder mehrere Producer erzeugen Datenpakete, ein oder mehrere Consumer verarbeiten sie. Weil beide Seiten unterschiedlich schnell sein können, liegt dazwischen ein begrenzter Puffer, häufig als Ringpuffer.

Producer

Erzeugt Items und schreibt sie in den Puffer. Wenn der Puffer voll ist, muss er warten.

Consumer

Liest Items aus dem Puffer und verarbeitet sie. Wenn der Puffer leer ist, muss er warten.

Bounded Buffer

Der Puffer hat feste Größe. Er darf weder überschrieben noch leer gelesen werden.

Ringpuffer: Schreibindex w und Leseindex r

Ein Ringpuffer ist ein Array fester Größe. w zeigt auf die nächste Schreibposition, r auf die nächste Leseposition. Nach dem letzten Feld springt der Index mit Modulo-Rechnung wieder auf den Anfang:

next = (index + 1) % BUFFER_SIZE
Ringpuffer mit 8 Plätzen 0 1 2 3 4 5 6 7 Item Item Item Item r nächstes Lesen w nächstes Schreiben Modulo-Rücksprung belegt / used frei / free
Abbildung 8.1 Producer schreibt bei w, Consumer liest bei r. Die belegten Felder liegen logisch zwischen r und w; am Arrayende wird mit Modulo weitergezählt.

Naive Lösung: Busy-Waiting

Eine einfache Lösung prüft ständig, ob der Puffer voll oder leer ist. Das funktioniert konzeptionell, ist aber ineffizient, weil ein wartender Thread weiter CPU-Zeit verbraucht.

/* Producer: wartet aktiv, falls der Puffer voll ist */
while (next_w == r) { }       // Busy-Wait: CPU wird weiter belastet
buffer[w] = item;
w = (w + 1) % BUFFER_SIZE;

/* Consumer: wartet aktiv, falls der Puffer leer ist */
while (r == w) { }            // Busy-Wait
item = buffer[r];
r = (r + 1) % BUFFER_SIZE;
⚠️ Problem 1: nur BUFFER_SIZE - 1 nutzbar. Wenn man nur r und w verwendet, ist r == w mehrdeutig: Der Puffer könnte leer oder voll sein. Deshalb lässt diese Variante einen Platz frei, damit „voll“ und „leer“ unterscheidbar bleiben.
🔴 Problem 2: Busy-Waiting. Producer oder Consumer drehen sich in einer Schleife und belegen den Prozessor, obwohl sie eigentlich nur warten. Besser ist Blockieren: Der Thread schläft, bis sich der Zustand ändert.

Versuch mit counter: scheinbar besser, aber Race Condition

Ein zusätzlicher Zähler counter scheint das Problem zu lösen: counter == 0 bedeutet leer, counter == BUFFER_SIZE bedeutet voll. Der kritische Punkt ist aber: ++counter und --counter sind in C nicht automatisch atomar.

ZeitProducerConsumerFolge
t0liest counter = 5Producer-Register enthält 5
t1erhöht Register auf 6noch nicht zurückgeschrieben
t2liest ebenfalls counter = 5Consumer sieht alten Wert
t3verringert Register auf 4
t4schreibt 6 zurückcounter = 6
t5schreibt 4 zurückcounter = 4, obwohl wieder 5 korrekt wäre

Das verlorene Update zeigt: Ein gemeinsamer Zähler muss synchronisiert werden. Ohne Schutz entsteht ein Data Race, und das Ergebnis hängt vom zufälligen Timing der Kontextwechsel ab.

Korrekte Standardlösung: zwei Semaphore + ein Mutex

Die robuste Lösung trennt zwei Aufgaben:

  • free_slots zählt freie Pufferplätze. Startwert: BUFFER_SIZE.
  • used_slots zählt belegte Pufferplätze. Startwert: 0.
  • Ein mutex schützt den eigentlichen Ringpuffer und die Indizes w und r.
#include <pthread.h>
#include <semaphore.h>

#define BUFFER_SIZE 16
item buffer[BUFFER_SIZE];
int w = 0;  // nächste Schreibposition
int r = 0;  // nächste Leseposition

sem_t free_slots;   // freie Plätze
sem_t used_slots;   // belegte Plätze
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void init(void) {
    sem_init(&free_slots, 0, BUFFER_SIZE);
    sem_init(&used_slots, 0, 0);
}

void *producer(void *arg) {
    while (1) {
        item x = produce_item();

        sem_wait(&free_slots);          // blockiert, wenn Puffer voll
        pthread_mutex_lock(&mutex);     // exklusiver Zugriff auf Ringpuffer

        buffer[w] = x;
        w = (w + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&used_slots);          // ein belegter Platz mehr
    }
}

void *consumer(void *arg) {
    while (1) {
        sem_wait(&used_slots);          // blockiert, wenn Puffer leer
        pthread_mutex_lock(&mutex);     // exklusiver Zugriff auf Ringpuffer

        item x = buffer[r];
        r = (r + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&free_slots);          // ein freier Platz mehr

        consume_item(x);
    }
}
✅ Reihenfolge merken: Erst auf den passenden Semaphor warten, dann kurz den Mutex nehmen, Puffer/Index ändern, Mutex freigeben, danach den Gegen-Semaphor signalisieren. Der Mutex darf nicht während langer Arbeit wie produce_item() oder consume_item() gehalten werden.

Warum zwei Semaphore und nicht nur ein Mutex?

MechanismusWas er löstWas er nicht löst
mutexSchützt gemeinsame Daten vor gleichzeitigem Zugriff.Er sagt nicht, ob der Puffer leer oder voll ist.
free_slotsBlockiert Producer, wenn kein freier Platz existiert.Schützt nicht automatisch die Indexvariablen.
used_slotsBlockiert Consumer, wenn kein Item vorhanden ist.Schützt nicht automatisch die Pufferstruktur.

Kleines Rechenbeispiel mit BUFFER_SIZE = 4

SchrittAktionwrfree_slotsused_slots
StartPuffer leer0040
1Producer schreibt A nach Feld 01031
2Producer schreibt B nach Feld 12022
3Consumer liest A aus Feld 02131
4Producer schreibt C nach Feld 23122
5Producer schreibt D nach Feld 30113

Wichtig: Die Semaphorwerte beschreiben nicht direkt die Indexpositionen, sondern die Anzahl freier bzw. belegter Plätze. Die Modulo-Rechnung bewegt nur w und r im Kreis.

Zeitdiagramm: schneller Producer, langsamer Consumer

Wenn der Producer schneller ist, füllt er den Puffer. Sobald free_slots == 0 ist, blockiert er in sem_wait(&free_slots). Erst wenn der Consumer ein Element entnimmt und sem_post(&free_slots) ausführt, kann der Producer weiterlaufen.

Zeit: 0 1 2 3 4 5 6 7 Producer: produce write write write BLOCK BLOCK wake write Consumer: idle read work work read work work read free: 4 → 3 → 2 → 1 → 0 0 1 → 0
Blockieren statt Busy-Wait Producer Consumer schreibt schreibt schreibt blockiert: free_slots = 0 weiter liest verarbeitet liest sem_post(free) Zeit
Abbildung 8.2 Blockierende Semaphore lassen wartende Threads schlafen. Dadurch wird keine CPU-Zeit in leeren Warteschleifen verbrannt.

Typische Fehler in Producer-Consumer-Lösungen

FehlerWarum problematisch?Folge
pthread_mutex_lock vor sem_waitThread kann mit gehaltenem Mutex blockieren.Deadlock: andere Seite kommt nicht mehr an den Puffer.
sem_post vergessenGegenpartei wird nie geweckt.Producer oder Consumer bleiben dauerhaft blockiert.
pthread_mutex_unlock vergessenCritical Section bleibt gesperrt.Alle anderen Threads hängen am Mutex.
Zu lange Arbeit im MutexAndere Threads können währenddessen nicht auf den Puffer zugreifen.Schlechte Parallelität, unnötige Wartezeiten.
Index ohne Modulo erhöhenw oder r läuft aus dem Array heraus.Speicherfehler oder Datenkorruption.
📌 Verbindung zu Pipes und Message Passing: Eine Pipe oder Message Queue ist ebenfalls eine Producer-Consumer-Struktur: Der Schreiber produziert Bytes/Nachrichten, der Leser konsumiert sie. Der Unterschied ist, dass der Puffer und die Synchronisation dann im Betriebssystem liegen. Bei Shared Memory oder eigenem Ringpuffer muss das Programm die Synchronisation selbst korrekt implementieren.

Alternative mit Message Passing / Rendezvous

Bei blockierendem Senden und blockierendem Empfangen entsteht ein Rendezvous: Der Producer wartet beim Senden, bis ein Consumer empfangen kann, und der Consumer wartet, bis eine Nachricht verfügbar ist. Dadurch wird Producer-Consumer ohne gemeinsamen Speicher lösbar.

/* Producer über Message Queue Q */
while (1) {
    message msg = produce_next();
    send(Q, &msg);       // blockiert ggf., wenn Queue voll oder Rendezvous nötig
}

/* Consumer über Message Queue Q */
while (1) {
    message msg;
    receive(Q, &msg);    // blockiert, bis Nachricht vorhanden ist
    consume_next(&msg);
}

Merksatz für die Prüfung: Semaphoren zählen Zustände („freie Plätze“, „belegte Plätze“), Mutexe schützen gemeinsame Daten („Puffer, Indizes, Counter“). Beides zusammen ergibt die klassische Lösung für den begrenzten Producer-Consumer-Puffer.

Mutexe

Ein Mutex (Mutual Exclusion Lock) ist der einfachste Mechanismus zum Schutz einer Critical Section. Er hat genau zwei Zustände: frei und belegt. Ein Mutex kann als binärer Semaphor implementiert werden.

POSIX Mutex API

#include <pthread.h>

pthread_mutex_t mutex;

/* Initialisieren (attr=NULL für Standard-Attribute): */
pthread_mutex_init(&mutex, NULL);

/* Sperren (blockiert bis verfügbar): */
pthread_mutex_lock(&mutex);

/* Sperren nicht-blockierend (EBUSY wenn belegt): */
pthread_mutex_trylock(&mutex);

/* Entsperren: */
pthread_mutex_unlock(&mutex);

/* Aufräumen: */
pthread_mutex_destroy(&mutex);

Verwendungspattern

void *thread_function(void *args) {
    while (running) {
        /* Nichtkritische Arbeit */

        pthread_mutex_lock(&mutex);
        ++counter;                 /* Critical Section */
        pthread_mutex_unlock(&mutex);

        /* Weitere nichtkritische Arbeit */
    }
    return NULL;
}
🔴 Acquire/Release müssen gepaart sein: Gibt ein Thread einen Mutex nicht frei, blockieren alle anderen Threads dauerhaft (Deadlock). Wenn ein Thread einen Mutex freigibt, den er nicht besitzt, ist das undefiniertes Verhalten.

Mutex-Attribute (POSIX)

Über pthread_mutexattr_t können konfiguriert werden:

  • pshared: Prozessübergreifende Nutzung (Standard: nur innerhalb eines Prozesses)
  • Typ: z.B. rekursiv (erlaubt mehrfaches Lock durch denselben Thread)
  • Priority Inheritance: Aktiviert Priority-Inheritance (s.u.)
  • Priority Ceiling: Feste Mindestpriorität während Lock gehalten wird

Priority Inversion & Priority Inheritance

Priority Inversion

Szenario mit drei Threads (Low A, Mid B, High C), Mutex M:

  1. Thread A (low) hält Mutex M
  2. Thread C (high) benötigt Mutex M → muss warten
  3. Thread B (mid) wird ready → verdrängt Thread A
  4. Thread B läuft, obwohl Thread C (high) wartet – effektive Prioritäten sind invertiert!

Priority Inheritance (Lösung)

Wenn Thread C auf Mutex M wartet, erhält Thread A vorübergehend die Priorität von C. A kann den Mutex schnell freigeben, C kann weiterlaufen. Nach Freigabe verliert A die geerbte Priorität wieder.

📌 Verantwortung: Priority Inversion zu vermeiden liegt primär bei den Entwicklerinnen und Entwicklern, da nur sie die Struktur, Ressourcen und Prioritäten kennen. Das Betriebssystem kann mit Priority Inheritance helfen, aber nicht das Problem vollständig lösen.
9

Signale, Pipes & Sockets

Signale

Signale sind ein einfacher Mechanismus, um einen Prozess von außen zu unterbrechen. Ein Signal transportiert nur eine Nummer – keine Nutzdaten. Das Verhalten ähnelt einem Interrupt: Das Betriebssystem unterbricht den Prozess, führt den Signal-Handler aus und setzt den Prozess fort (sofern der Handler ihn nicht beendet).

Prozess A kill(pid, SIGTERM) Kernel merkt Signal vor unterbricht Zielprozess Prozess B Handler oder Default Ein Signal transportiert normalerweise nur die Signalnummer, keine Nutzdaten.
Abbildung 9.1 Signalfluss: Ein Prozess oder der Kernel löst ein Signal aus; der Zielprozess behandelt es später an einer sicheren Stelle.

Signal-Handler

Jeder Prozess hat für jedes Signal einen Handler. Beim Start setzt das Betriebssystem Standard-Handler in drei Kategorien:

  • Ignore: Signal wird ignoriert
  • Terminate: Programm wird normal beendet
  • Abnormal Terminate: Programm wird beendet + Core Dump erzeugt
🔴 SIGKILL und SIGSTOP sind absolut! Diese beiden Signale können nicht abgefangen, ignoriert oder durch eigene Handler ersetzt werden. Alle anderen Signale können prinzipiell angepasst werden.

Wichtige Signale

SignalQuelleBedeutungStandard-Handler
SIGTERMProzess/ShellHöfliche BeendigungsanfrageTerminate
SIGINTCtrl-CInteraktiver AbbruchTerminate
SIGQUITCtrl-\Abbruch + Core DumpAbnormal Terminate
SIGKILLProzess/ShellNicht abfangbar; sofortige BeendigungTerminate (unabänderbar)
SIGSTOPProzess/ShellAnhalten (nicht abfangbar)Stop (unabänderbar)
SIGTSTPCtrl-ZAnhalten (abfangbar)Stop
SIGCONTShell (fg/bg)Gestoppten Prozess fortsetzenContinue
SIGSEGVBetriebssystemUngültiger SpeicherzugriffAbnormal Terminate
SIGFPEBetriebssystemArithmetischer Fehler (z.B. Div/0)Abnormal Terminate
SIGPIPEBetriebssystemSchreiben in Pipe ohne LeserTerminate

Signal-Handler ändern mit sigaction

struct sigaction sa = {0};
sa.sa_handler = my_handler;          // Handler-Funktion
sigemptyset(&sa.sa_mask);           // Keine weiteren Signale blockieren

sigaction(SIGTERM, &sa, NULL);

/* Handler-Funktion: */
void my_handler(int sig) {
    running = 0;   // z.B. sauberes Beenden signalisieren
}

Signale aus C senden

raise(SIGABRT);              // an eigenen Prozess
kill(pid, SIGTERM);          // an anderen Prozess
pthread_kill(tid, SIGUSR1);  // an bestimmten Thread
⚠️ kill ≠ töten: Das Shell-Kommando kill und die C-Funktion kill() senden nur ein Signal. Ob der Prozess beendet wird, hängt vom Signal und Handler ab. kill 1234 ohne Option sendet SIGTERM, kill -KILL 1234 sendet SIGKILL.

Pipes und Named Pipes (FIFOs)

Eine Pipe ist ein unidirektionaler Byte-Strom zwischen Prozessen. Man kann sie sich wie ein Förderband im Kernel vorstellen: Ein Prozess schreibt Bytes am write end hinein, ein anderer Prozess liest dieselben Bytes am read end wieder heraus. Die Reihenfolge ist FIFO: Was zuerst geschrieben wurde, wird zuerst gelesen.

📌 Grundidee: Eine Pipe ist kein normales geteiltes File. Bei einer anonymen Pipe gibt es keinen Dateinamen im Dateisystem. Die Daten liegen nur temporär in einem Kernel-Puffer und verschwinden nach dem Lesen oder wenn alle zugehörigen Deskriptoren geschlossen sind.

Das Konzept: read end, write end und Kernel-Puffer

pipe(fd) erzeugt zwei File-Deskriptoren im aktuellen Prozess:

DeskriptorBedeutungTypische Operation
fd[0]Leseende der Piperead(fd[0], buf, n)
fd[1]Schreibende der Pipewrite(fd[1], buf, n)
Pipe als Kernel-Puffer zwischen zwei Prozessen Writer-Prozess write(fd[1]) Kernel-Pipe-Puffer Byte-Strom: keine Datei, keine dauerhafte Speicherung, keine Nachrichten-Grenzen Reader-Prozess read(fd[0]) Bytes hinein Bytes heraus
Abbildung 9.2 Grundmodell einer Pipe: Der Kernel verwaltet den Puffer; Prozesse sehen nur File-Deskriptoren.

Unidirektional

Eine Pipe transportiert Daten nur in eine Richtung. Für echte Zweiweg-Kommunikation braucht man zwei Pipes oder einen Socket.

Byte-Strom

Eine Pipe speichert Bytes. Sie ist kein Paketformat. Ein read kann weniger Bytes liefern, als vorher mit einem write geschrieben wurden.

Kernel-Objekt

Die Deskriptoren sind nur lokale Nummern im Prozess. Das eigentliche Pipe-Objekt liegt im Kernel.

Was passiert bei pipe() und fork()?

Eine anonyme Pipe wird meistens vor einem fork() erzeugt. Danach besitzen Parent und Child zunächst beide Pipe-Enden, weil fork() die File-Deskriptor-Tabelle kopiert. Die Deskriptor-Nummern sind dann in beiden Prozessen gleich, zeigen aber auf dasselbe Pipe-Objekt im Kernel.

Nach pipe() und fork(): beide Prozesse haben zuerst beide Enden Parent fd-Tabelle fd[0] = read end fd[1] = write end ein gemeinsamer Kernel-Pipe-Puffer Child fd-Tabelle fd[0] = read end fd[1] = write end Nach korrekt gesetztem close(): jeder Prozess behält nur das benötigte Ende Parent liest nur fd[0] Pipe-Puffer Child schreibt nur fd[1] Merke: close entfernt nur diesen Deskriptor. Das Pipe-Objekt bleibt, solange irgendwo noch ein passendes Ende offen ist.
Abbildung 9.3 File-Deskriptoren werden durch fork() kopiert; danach müssen unnötige Enden geschlossen werden.

Warum close() bei Pipes so wichtig ist

Bei Pipes entscheidet der Kernel anhand der noch offenen Enden, ob ein Prozess blockieren soll, EOF sieht oder ein Fehler entsteht. Deshalb reicht es nicht, nur im richtigen Prozess zu lesen oder zu schreiben: Die nicht benötigten Enden müssen wirklich mit close() geschlossen werden.

SituationVerhaltenWarum das wichtig ist
Pipe leer, aber mindestens ein write end ist noch offenread() blockiertDer Kernel nimmt an, dass später noch Daten kommen könnten.
Pipe leer und alle write ends sind geschlossenread() liefert 0Das ist EOF: Der Leser erkennt, dass endgültig nichts mehr kommt.
Pipe voll, aber mindestens ein read end ist offenwrite() blockiertDer Writer wartet, bis der Leser Platz schafft.
Kein read end mehr offenwrite() erzeugt typischerweise SIGPIPE bzw. EPIPENiemand kann die Daten mehr lesen.
Falsches Ende bleibt versehentlich offenProgramm hängt scheinbar grundlosEOF oder SIGPIPE tritt nicht ein, weil der Kernel noch ein offenes Ende zählt.
close()-Regel: offene Enden bestimmen das Verhalten Leser wartet read(fd[0]) Pipe leer aber irgendwo ist noch write end offen Folge: blockiert EOF-Fall read(fd[0]) Pipe leer alle write ends geschlossen Folge: read liefert 0 Broken Pipe write(fd[1]) kein read end mehr offen niemand liest Folge: SIGPIPE / EPIPE
Abbildung 9.4 Drei typische Pipe-Zustände: blockierendes Lesen, EOF und Broken Pipe.
🔴 Häufigster Denkfehler: „Der Parent schreibt ja nicht, also ist sein write end egal.“ Falsch. Solange der Parent fd[1] offen lässt, zählt für den Kernel noch ein Writer. Ein Child/Parent, der liest, bekommt dann eventuell nie EOF und bleibt in read() hängen.

Minimaler Ablauf: Child schreibt, Parent liest

Das folgende Muster ist der Standardfall. Wichtig ist nicht nur read und write, sondern vor allem das frühe Schließen des jeweils falschen Endes.

int fd[2];
if (pipe(fd) == -1) { /* Fehler behandeln */ }
// fd[0] = read end, fd[1] = write end

pid_t pid = fork();
if (pid == 0) {                  /* Child: produziert Daten */
    close(fd[0]);                 // Child liest nicht
    write(fd[1], "Hallo
", 6);
    close(fd[1]);                 // EOF für Leser ermöglichen
    _exit(0);
} else {                            /* Parent: konsumiert Daten */
    close(fd[1]);                 // Parent schreibt nicht
    while ((n = read(fd[0], buf, sizeof buf)) > 0) {
        /* n gelesene Bytes verarbeiten */
    }
    close(fd[0]);
    waitpid(pid, NULL, 0);
}

Shell-Pipes: was bei cmdA | cmdB konzeptionell passiert

Eine Shell-Pipe ist keine Magie und auch keine temporäre Datei. Die Shell baut intern eine Pipe, startet zwei Prozesse und verdrahtet deren Standard-Streams mit dup2(). Danach ersetzt exec() die Child-Prozesse durch die eigentlichen Programme.

Beispiel: ls -l | grep txt Shell pipe() fd[0] read | fd[1] write Kernel-Pipe-Puffer Child A: ls -l dup2(fd[1], STDOUT) Child B: grep txt dup2(fd[0], STDIN) Nach dup2 schließen beide Childs die alten fd[0]/fd[1] und führen mit exec die Programme aus.
Abbildung 9.5 Shell-Pipe: stdout des linken Programms wird zum write end, stdin des rechten Programms zum read end.
/* stark vereinfacht: linkes Kommando */
dup2(fd[1], STDOUT_FILENO);  // stdout von cmdA → write end
close(fd[0]);
close(fd[1]);
execlp("cmdA", "cmdA", NULL);

/* stark vereinfacht: rechtes Kommando */
dup2(fd[0], STDIN_FILENO);   // stdin von cmdB ← read end
close(fd[0]);
close(fd[1]);
execlp("cmdB", "cmdB", NULL);

Named Pipes / FIFOs

Eine Named Pipe oder FIFO funktioniert konzeptionell wie eine Pipe, besitzt aber einen Namen im Dateisystem. Dieser Name ist nur ein Treffpunkt: Die Daten werden nicht dauerhaft in dieser Datei gespeichert, sondern weiterhin durch einen Pipe-Puffer im Kernel transportiert.

Named Pipe: Dateiname als Treffpunkt Prozess A open(..., O_WRONLY) /tmp/myfifo Kernel-Pipe-Puffer Prozess B open(..., O_RDONLY) Der Pfad verbindet unabhängige Prozesse. Die Nutzdaten liegen trotzdem nicht als normale Datei auf der Platte.
Abbildung 9.6 Named Pipes eignen sich für nicht verwandte Prozesse, weil beide denselben Pfad öffnen können.
mkfifo("/tmp/myfifo", S_IRUSR | S_IWUSR);

/* Prozess A: Schreiber */
int out = open("/tmp/myfifo", O_WRONLY);
write(out, msg, len);
close(out);

/* Prozess B: Leser */
int in = open("/tmp/myfifo", O_RDONLY);
read(in, buf, sizeof buf);
close(in);

/* Name im Dateisystem entfernen */
unlink("/tmp/myfifo");
⚠️ Blockierendes open bei FIFOs: Ein open(..., O_RDONLY) auf einer FIFO wartet normalerweise, bis ein Schreiber öffnet. Umgekehrt wartet open(..., O_WRONLY), bis ein Leser öffnet. Mit O_NONBLOCK kann man dieses Verhalten ändern, muss dann aber Fehlerfälle sauber behandeln.

Prüfungsmerksätze zu Pipes

  • Pipe = Kernel-Puffer + zwei Deskriptoren, nicht normale Datei.
  • fd[0] liest, fd[1] schreibt.
  • Nach fork() haben Parent und Child zunächst beide Enden.
  • Nicht benötigte Enden sofort schließen, sonst fehlen EOF/SIGPIPE und Programme können hängen.
  • Shell-Pipes entstehen durch pipe(), fork(), dup2(), close() und exec().
  • Named Pipes haben einen Pfad, speichern Daten aber nicht dauerhaft als Dateiinhalt.

Berkeley Sockets

Sockets sind File-Deskriptoren für Netzwerkkommunikation. Sie sind häufig bidirektional und unterstützen verschiedene Protokolle (TCP, UDP, Unix-Domain-Sockets).

Client-Socket connect(), send(), recv() TCP/IP oder AF_UNIX bidirektionaler Stream Server listen-fd bind(), listen() Client-fd accept() erzeugt neu Wichtig: Der Listening-Socket bleibt für neue Verbindungen offen; der von accept() zurückgegebene Socket ist für genau eine Verbindung.
Abbildung 9.3 Socket-Modell: Anders als eine Pipe ist ein TCP-Socket bidirektional; ein Server nutzt zusätzlich einen Listening-Socket.

Socket erzeugen

int socket(int domain, int type, int protocol);
// Typisch: */
socket(AF_INET, SOCK_STREAM, 0);   // IPv4 + TCP
socket(AF_INET, SOCK_DGRAM, 0);    // IPv4 + UDP
socket(AF_UNIX, SOCK_STREAM, 0);   // Unix-Domain-Socket

IPv4-Adresse angeben

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);         // Host→Network Byte Order (Short)
inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr.s_addr);
📌 Byte Order: Viele Intel-Systeme verwenden Little Endian (Host Byte Order). Netzwerkprotokolle verwenden Big Endian (Network Byte Order). Immer Konversionsfunktionen verwenden: htons/htonl (Host→Network) und ntohs/ntohl (Network→Host).

Server-Ablauf

int srv = socket(AF_INET, SOCK_STREAM, 0);
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 10);               // Warteschlange einrichten (nicht akzeptieren!)

while (running) {
    int client = accept(srv, NULL, NULL);  // neuer Socket pro Client
    /* lesen/schreiben auf client, dann close(client) */
}

Client-Ablauf

int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, (struct sockaddr*)&srv_addr, sizeof(srv_addr));
send(sock, buf, len, 0);    // entspricht write(sock, buf, len)
recv(sock, buf, len, 0);    // entspricht read(sock, buf, len)
close(sock);
⚠️ close vs. shutdown: close(fd) schließt nur diesen File-Deskriptor. Wenn durch fork oder dup2 weitere Kopien existieren, bleibt die Verbindung bestehen. shutdown(fd, SHUT_RDWR) beendet die Verbindung auf Protokollebene, unabhängig von weiteren Kopien.
10

Message Passing & Shared Memory

Moderne Betriebssysteme isolieren Prozesse vollständig. Für Interprozesskommunikation (IPC) gibt es zwei grundlegende Paradigmen: Message Passing (Daten werden kopiert) und Shared Memory (gemeinsamer Speicher).

POSIX Message Queues

Message Queues sind systemweite, benannte Warteschlangen. Nachrichten haben eine Priorität; höher priorisierte Nachrichten werden zuerst zugestellt.

#include <mqueue.h>

/* Queue öffnen/erzeugen: */
mqd_t mq = mq_open("/meine_queue", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR, NULL);

/* Senden (blockiert wenn voll, außer O_NONBLOCK): */
mq_send(mq, msg, strlen(msg), 0);  // Priorität 0

/* Empfangen (blockiert wenn leer): */
mq_receive(mq, buf, sizeof(buf), &priority);
// buf muss mind. mq_msgsize groß sein!

/* Schließen und entfernen: */
mq_close(mq);
mq_unlink("/meine_queue");   // Name entfernen; offene Handles bleiben!
📌 mq_unlink: Entfernt nur den Namen der Queue. Bestehende offene Deskriptoren bleiben gültig. Neue Prozesse können die Queue nicht mehr über den entfernten Namen öffnen.

mq_attr – Queue-Konfiguration

struct mq_attr {
    long mq_flags;    // 0 oder O_NONBLOCK
    long mq_maxmsg;   // max. Anzahl Nachrichten in der Queue
    long mq_msgsize;  // max. Nachrichtengröße in Bytes
    long mq_curmsgs;  // aktuelle Anzahl Nachrichten
};

POSIX Shared Memory

Bei Shared Memory werden virtuelle Pages verschiedener Prozesse auf denselben physischen Frame gemappt. Änderungen eines Prozesses sind sofort für alle anderen sichtbar.

Prozess P1 virt. Page V1 Prozess P2 virt. Page V2 Physischer Frame F (Shared Memory)
Abbildung 10.1 Shared Memory: Zwei virtuelle Pages (V1 und V2) aus verschiedenen Prozessen werden auf denselben physischen Frame F gemappt.
#include <sys/mman.h>

/* Shared Memory erzeugen/öffnen: */
int fd = shm_open("/myshm", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);

/* Größe festlegen: */
ftruncate(fd, sizeof(struct MyData));

/* In Adressraum einblenden: */
struct MyData *shm = mmap(NULL, sizeof(struct MyData),
    PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

close(fd);     // fd kann nach mmap geschlossen werden

/* Verwenden: */
shm->value = 42;

/* Aufräumen: */
munmap(shm, sizeof(struct MyData));
shm_unlink("/myshm");
🔴 Keine absoluten Pointer in Shared Memory! Die virtuelle Adresse des Shared-Memory-Bereichs kann in verschiedenen Prozessen unterschiedlich sein. Ein absoluter Pointer 0x4080 aus Prozess P1 zeigt in P2 möglicherweise auf völlig falsche Daten oder ist ungültig. Immer relative Offsets verwenden: offset = ptr - shm_base.
📌 Lebensdauer von Shared Memory: Ein POSIX-Shared-Memory-Objekt existiert unabhängig von Prozessen. Endet der erzeugende Prozess, bleibt das Objekt im System. Nur shm_unlink entfernt das Objekt dauerhaft.

Vergleich: Message Passing vs. Shared Memory

EigenschaftMessage PassingShared Memory
DatenaustauschKopieren der NachrichtenDirekter Speicherzugriff
SynchronisationEingebaut (Queue)Manuell (Mutex/Semaphor)
GeschwindigkeitLangsamer (Kopie)Sehr schnell
KomplexitätEinfacherKomplexer (Race Conditions!)
LebensdauerSystemweit persistentSystemweit persistent
11

Unicode & das ext2-Dateisystem

Zeichenkodierungen: ASCII, Codepages, Unicode

ASCII

ASCII (American Standard Code for Information Interchange) ist ein 7-Bit-Encoding mit 128 Zeichen (00h–7Fh). Es enthält 33 Kontrollzeichen, Ziffern, Interpunktion und lateinische Buchstaben. Die Tabelle ist so gestaltet, dass einfache Operationen möglich sind: Ziffer minus 30h = numerischer Wert; Groß/Kleinschreibung durch ein Bit umschaltbar.

Codepages

Da ASCII nur 128 Zeichen kennt, entstanden regionale 8-Bit-Erweiterungen. Das achte Bit erlaubt 128 zusätzliche Zeichen (80h–FFh). Das Problem: Die Erweiterungen sind inkompatibel. ISO 8859-1 (Westeuropa) und ISO 8859-5 (Kyrillisch) belegen E4h mit verschiedenen Zeichen. Ein Programm kann anhand der Bytes allein nicht erkennen, welche Codepage gemeint ist.

Unicode

Unicode weist jedem Zeichen eine eindeutige Nummer zu – den Code Point (CP). Der mögliche Bereich umfasst etwa 1'112'064 Code Points. Unicode 16.0 (Sept. 2024) definiert 154'998 Zeichen. Der Bereich D800h–DFFFh ist reserviert (nicht als Code Points nutzbar).

Wie ein Code Point als Bytes gespeichert wird, bestimmt das Encoding. Die Speichereinheit heißt Code Unit (CU).

Unicode-Encodings im Detail

UTF-32

32-Bit-Code-Units. Jeder Code Point entspricht genau einer Code Unit. Einfach, aber speicherintensiv. Endianness muss beachtet werden (UTF-32BE, UTF-32LE).

UTF-32 und Endianness genau gerechnet

UTF-32 ist konzeptionell einfach: Jeder Unicode-Code-Point wird als 32-Bit-Zahl gespeichert. Die Frage ist nur, in welcher Byte-Reihenfolge diese 32-Bit-Zahl im Speicher liegt.

ZeichenCode Point32-Bit-WertUTF-32BEUTF-32LE
AU+00410000004100 00 00 4141 00 00 00
äU+00E4000000E400 00 00 E4E4 00 00 00
😀U+1F6000001F60000 01 F6 0000 F6 01 00
Big Endian: höchstwertiges Byte zuerst → 00 01 F6 00
Little Endian: niedrigstwertiges Byte zuerst → 00 F6 01 00
/* 32-Bit-Codepoint x in Bytes zerlegen */
be[0] = (x >> 24) & 0xff;  be[1] = (x >> 16) & 0xff;
be[2] = (x >>  8) & 0xff;  be[3] =  x        & 0xff;

le[0] =  x        & 0xff;  le[1] = (x >>  8) & 0xff;
le[2] = (x >> 16) & 0xff;  le[3] = (x >> 24) & 0xff;
📌 BOM: UTF-32 kann mit einer Byte Order Mark beginnen. Der Code Point U+FEFF wird in UTF-32BE als 00 00 FE FF und in UTF-32LE als FF FE 00 00 gespeichert. Daran kann ein Leser die Byte-Reihenfolge erkennen.

UTF-8

8-Bit-Code-Units (= Bytes). Kein Endianness-Problem. Rückwärtskompatibel mit ASCII. De-facto-Standard im Web (~90% aller Webseiten).

Code-Point-BereichBytesBitmuster
0000h – 007Fh10xxxxxxx
0080h – 07FFh2110xxxxx 10xxxxxx
0800h – FFFFh31110xxxx 10xxxxxx 10xxxxxx
10000h – 10FFFFh411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Das erste Byte gibt die Anzahl der Bytes an; Fortsetzungsbytes beginnen immer mit 10.

Berechnungsbeispiele

ä (U+00E4) in UTF-8: E4h = 1110 0100. Passt in 11 Bits → 2 Bytes.
110 00011 | 10 100100 = C3 A4

ặ (U+1EB7) in UTF-8: 1EB7h = 0001 1110 1011 0111. Benötigt 3 Bytes.
Nutzbits gruppieren: 0001 | 111010 | 110111
UTF-8-Bytes: 1110 0001 | 10 111010 | 10 110111 = E1 BA B7

UTF-16

16-Bit-Code-Units. Für Code Points ≤ FFFFh (außer D800h–DFFFh): direkte Darstellung. Für Code Points > FFFFh: Surrogate Pairs aus zwei Code Units.

Berechnung eines Surrogate Pairs für Code Point P ≥ 10000h:

Q = P − 10000h
High Surrogate = D800h + obere 10 Bits von Q
Low Surrogate = DC00h + untere 10 Bits von Q

Beispiel: Gotisch 𐌰 (U+10330)

Q = 10330h − 10000h = 0330h
Q als 20-Bit-Wert: 0000000000 1100110000
Obere 10 Bits: 0000000000 = 000h
Untere 10 Bits: 1100110000 = 330h
High Surrogate = D800h + 000h = D800h
Low Surrogate = DC00h + 330h = DF30h
UTF-16BE: D8 00 DF 30

Das ext2-Dateisystem

Geschichte der ext-Familie

JahrVersionNeuerung
1992extErstes Linux-FS, Erweiterung von MINIX
1993ext2Erste kommerziell taugliche Qualität
2001ext3Journaling eingeführt; heute in ext4 aufgegangen
2008ext4Extents, größere FS, mehr Robustheit

Grundbegriffe

Ein Sektor ist die kleinste Übertragungseinheit der Hardware (traditionell 512 Byte, heute oft 4 KB). Ein Block besteht aus aufeinanderfolgenden Sektoren. In ext2 sind Blöcke typischerweise 1 KB, 2 KB oder 4 KB groß. Blöcke sind die zentrale Allokationseinheit.

Blockgruppen

Das gesamte Volume wird in Blockgruppen aufgeteilt. Jede Blockgruppe hat dieselbe Struktur:

Super- block Kopie Gruppen- deskriptortabelle Kopie Block-Usage- Bitmap Inode-Usage- Bitmap Inode- Tabelle Dateiinhalte (Datenblöcke)
Abbildung 11.1 Layout einer ext2-Blockgruppe. Nicht jede Blockgruppe muss Superblock- und Deskriptortabellen-Kopien enthalten (Sparse Superblocks).

Superblock

Der Superblock enthält alle zentralen Metadaten des Volumes und beginnt immer an Byte 1024 (wegen eventueller Bootdaten am Anfang). Seine Blocknummer hängt von der Blockgröße ab:

Block 1 bei 1-KB-Blöcken (Block 0 liegt vor Blockgruppe 0)
Block 0 bei 2-KB-Blöcken (Superblock in der zweiten Hälfte von Block 0)
Block 0 bei 4-KB-Blöcken (Superblock im zweiten Viertel von Block 0)

Inhalt des Superblocks u.a.: Anzahl freier/gesamter Inodes und Blöcke, Bytes pro Block und Inode, Blöcke und Inodes pro Gruppe, Zeitstempel, Statusbytes, Feature-Flags.

Sparse Superblocks

Ohne Sparse Superblocks enthält jede Blockgruppe eine Kopie von Superblock und Deskriptortabelle. Mit Sparse Superblocks nur in: Blockgruppe 0, Blockgruppe 1 und Gruppen, deren Nummer eine reine Potenz von 3, 5 oder 7 ist:

0, 1, 3, 5, 7, 9, 25, 27, 49, 81, 125, 243, 343, …

Gruppendeskriptor (32 Byte)

Beschreibt eine Blockgruppe: Blocknummer der Block-Bitmap, Blocknummer der Inode-Bitmap, Startnummer der Inode-Tabelle, Anzahl freier Blöcke und Inodes, Anzahl Verzeichnisse in der Gruppe.

Inodes

Jede Datei und jedes Verzeichnis wird durch einen Inode beschrieben. Der Inode enthält Metadaten (Dateityp, Größe, Rechte, Zeitstempel, Link-Zähler) und Verweise auf Datenblöcke. Der Name der Datei steht nicht im Inode, sondern im Verzeichniseintrag.

Inodes sind durchnummeriert (ab 1). Die Lokalisierung eines Inodes aus seiner Nummer:

Blockgruppe = (Inode − 1) / Inodes_pro_Gruppe
Index in der Gruppe = (Inode − 1) % Inodes_pro_Gruppe

Beispiel: Bei Inode 12345 und 8192 Inodes pro Gruppe gilt:

Blockgruppe = (12345 − 1) / 8192 = 1
Index in Gruppe 1 = (12345 − 1) % 8192 = 4152

Blockverweis-Schema im Inode

Ein Inode enthält 15 Blockverweise (32-Bit-Nummern). Bei Blockgröße 4 KB (1024 Einträge pro Block):

  • Verweise 0–11: direkt (12 Blöcke = 48 KB bei 4-KB-Blöcken)
  • Verweis 12: einfach indirekter Block (enthält 1024 Blocknummern → Blöcke 12–1035)
  • Verweis 13: doppelt indirekter Block (1024 × 1024 Blöcke)
  • Verweis 14: dreifach indirekter Block (1024 × 1024 × 1024 Blöcke)
Inode 0–11 direkt 12 einfach 13 doppelt 14 dreifach Datenblöcke indirekter Block Datenblöcke Block mit Zeigern weitere Zeigerblöcke Daten
Abbildung 11.2 ext2-Inode-Adressierung: direkte Verweise zeigen sofort auf Datenblöcke; indirekte Verweise zeigen zuerst auf Metadatenblöcke mit weiteren Blocknummern.
📌 Kapazität bei 4-KB-Blöcken:
Zeiger pro indirektem Block: 4096 Byte / 4 Byte = 1024 = 400h
Direkt: 12 × 4 KiB = 48 KiB
Einfach indirekt: 1024 × 4 KiB = 4 MiB
Doppelt indirekt: 1024² × 4 KiB = 4 GiB
Dreifach indirekt: 1024³ × 4 KiB = 4 TiB
Theoretisch über das Adressierungsschema: (12 + 1024 + 1024² + 1024³) × 4 KiB ≈ 4 TiB.
Praktisch begrenzen ext2-Metadatenfelder wie i_blocks klassische ext2-Dateien häufig auf ungefähr 2 TiB.

Verzeichnisse

Ein Verzeichnis ist ebenfalls ein Inode; seine Datenblöcke enthalten Dateieinträge variabler Länge (8–263 Byte):

  • 4 Byte: Inode-Nummer
  • 2 Byte: Länge des Eintrags (auf 4 Byte ausgerichtet)
  • 1 Byte: Länge des Dateinamens
  • 1 Byte: Dateityp (normal, Verzeichnis, Symlink, Device, …)
  • 0–255 Byte: Dateiname (kein Null-Terminator)

Jedes Verzeichnis enthält automatisch . (eigener Inode) und .. (Elternverzeichnis-Inode). Das Wurzelverzeichnis hat immer Inode-Nummer 2.

Hardlinks und Symlinks

Hardlink

  • Mehrere Verzeichniseinträge → gleicher Inode
  • Inode enthält Link-Zähler
  • Daten erst gelöscht wenn Link-Zähler = 0
  • Nicht über Dateisystemgrenzen möglich
  • Nicht für Verzeichnisse (Zyklen)

Symbolischer Link

  • Eigener Inode (Typ: Symlink)
  • Inhalt = Pfad des Ziels (als String)
  • Kann über Dateisystemgrenzen
  • Kann auf Verzeichnisse zeigen
  • Kann hängen (Ziel gelöscht)

ext2-Rechenaufgaben: Vorgehen und Beispiele

1) Blockgröße aus dem Superblock

In ext2 wird die Blockgröße nicht direkt als 1024/2048/4096 gespeichert, sondern über einen Logarithmuswert.

Blockgröße = 1024 × 2s_log_block_size
Beispiele: 0 → 1024 Byte, 1 → 2048 Byte, 2 → 4096 Byte

2) Maximale Blöcke pro Blockgruppe

Die Block-Usage-Bitmap einer Blockgruppe belegt einen Block. Jedes Bit beschreibt einen Block. Deshalb gilt:

max. Blöcke pro Gruppe = Blockgröße_in_Bytes × 8
Bei 4 KiB: 4096 × 8 = 32768 Blöcke pro Gruppe
Gruppengröße = 32768 × 4096 Byte = 128 MiB

3) Gruppendeskriptortabelle berechnen

Ein ext2-Gruppendeskriptor ist 32 Byte groß.

Anzahl Gruppen = ceil(Gesamtblöcke / Blöcke_pro_Gruppe)
Deskriptortabelle in Byte = Anzahl Gruppen × 32
Benötigte Tabellenblöcke = ceil(Deskriptortabelle_in_Byte / Blockgröße)

Beispiel: 1 GiB Dateisystem, Blockgröße 4 KiB.

Gesamtblöcke = 1 GiB / 4 KiB = 262144 Blöcke
Blöcke pro Gruppe = 32768 → Gruppen = 262144 / 32768 = 8
Deskriptortabelle = 8 × 32 = 256 Byte → passt in 1 Block

4) Inode-Position genau berechnen

Gegeben: Inode 12345, 8192 Inodes pro Gruppe, Inodegröße 256 Byte, Blockgröße 4096 Byte.

Blockgruppe = floor((12345 − 1) / 8192) = 1
Index in Gruppe = (12345 − 1) mod 8192 = 4152
Byte-Offset in der Inode-Tabelle = 4152 × 256 = 1'062'912 Byte
Tabellenblock-Offset = floor(1'062'912 / 4096) = 259
Offset innerhalb dieses Blocks = 1'062'912 mod 4096 = 2048 Byte

Wenn die Inode-Tabelle der Blockgruppe bei Block T beginnt, liegt der gesuchte Inode also in Block T + 259, 2048 Byte nach Blockanfang.

5) Maximale Dateigröße über direkte/indirekte Blockverweise

Bei ext2 enthält ein Inode 12 direkte Verweise sowie je einen einfach, doppelt und dreifach indirekten Verweis. Wenn ein Blockverweis 4 Byte groß ist, passen in einen indirekten Block:

p = Blockgröße / 4
Datenblöcke = 12 + p + p² + p³
max. adressierbare Nutzdaten = Datenblöcke × Blockgröße
BlockgrößepDatenblöcke nach Schematheoretische Nutzdaten
1 KiB25612 + 256 + 65'536 + 16'777'216ca. 16 GiB
4 KiB102412 + 1'024 + 1'048'576 + 1'073'741'824ca. 4 TiB
⚠️ Theorie vs. Praxis: Diese Rechnung beschreibt die Adressierbarkeit des Verweisschemas. Klassische ext2-Implementierungen können zusätzlich durch Felder wie i_blocks, Blocknummerbreite und Kernel-/Tool-Unterstützung begrenzt sein.

6) Verzeichniseintrag ausrechnen

Ein ext2-Verzeichniseintrag hat 8 Byte Fixanteil plus den Dateinamen und wird auf 4 Byte aufgerundet.

minimale Länge = 8 + name_len
rec_len = auf nächstes Vielfaches von 4 aufrunden
Beispiel Name "hello": 8 + 5 = 13 → rec_len = 16 Byte

File-Holes und Sparse Files

Ein File-Hole ist ein logisch existierender Bereich, der nur Nullbytes enthält. In ext2 kann ein Blockverweis-Eintrag auf 0 stehen – das bedeutet, der Block ist nicht alloziert. Beim Lesen liefert das Betriebssystem Nullbytes. Sparse Files nutzen dies: Ihre logische Größe kann die Größe des belegten Speichers übertreffen, in Extremfällen sogar die Größe des gesamten Volumes.

Ergänzung Übungen: Unicode-Codierungen und ext2-Reader

Dieser Abschnitt ergänzt Kapitel 11 um die Teile, die in der Übung besonders wichtig sind: konkrete Unicode-Umrechnungen und der praktische Zugriff auf ein ext2-Disk-Image. Damit werden die Konzepte aus Folien und Transkript nicht nur erklärt, sondern auch als Rechen- und Implementationsschema sichtbar.

Unicode: Code Point → Code Units → Bytes

Zeichen z. B. € Code Point U+20AC Code Units UTF-8/16/32 Bytes im Speicher / in der Datei UTF-8: E2 82 AC Wichtig: Unicode definiert abstrakte Code Points; UTF-8/UTF-16/UTF-32 definieren, wie daraus speicherbare Code Units und Bytes werden.
Abbildung 11.E1 Trennung zwischen Zeichen, Code Point, Encoding und tatsächlichen Bytes.
EncodingCode-Unit-GrößeEndianness relevant?Kernidee
UTF-3232 Bitjaein Code Point wird direkt in eine 32-Bit-Code-Unit geschrieben
UTF-1616 BitjaBMP-Zeichen in einer Code Unit, höhere Code Points als Surrogatpaar
UTF-88 Bitneinvariable Länge von 1 bis 4 Bytes, ASCII-kompatibel
✅ Prüfungsrezept UTF-8: Zuerst den Code Point binär schreiben, dann die Bits von rechts nach links in das passende Muster einsetzen: 0xxxxxxx, 110xxxxx 10xxxxxx, 1110xxxx 10xxxxxx 10xxxxxx oder 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx.
Beispiel: U+20AC EURO SIGN

U+20AC = 0010 0000 1010 1100
UTF-8-Muster für 3 Bytes:
1110xxxx 10xxxxxx 10xxxxxx

Gruppieren der Nutzbits:
0010 | 000010 | 101100

Einsetzen:
1110 0010   1000 0010   1010 1100
E2          82          AC

UTF-16-Surrogate knapp erklärt

Für Code Points oberhalb von U+FFFF reicht eine 16-Bit-Code-Unit nicht aus. Deshalb wird zuerst 0x10000 abgezogen. Die verbleibenden 20 Bits werden in zwei 10-Bit-Teile zerlegt: High Ten Bits und Low Ten Bits.

Code Point P > U+FFFF
X = P − 0x10000
High Surrogate = 0xD800 + obere 10 Bits von X
Low Surrogate = 0xDC00 + untere 10 Bits von X

Die Byte-Reihenfolge der beiden 16-Bit-Code-Units hängt anschließend von Little Endian oder Big Endian ab. Das ist genau der Punkt, an dem sich die Konzepte Encoding und Endianess berühren.

ext2-Reader: vom Pfad zum Datenblock

Die ext2-Übung verwendet ein Disk-Image als Rohdatei. Anstatt über fopen oder normale Dateipfade des Host-Systems zu gehen, liest das Programm die ext2-Strukturen selbst. Die POSIX-Funktion pread ist dabei nur der Ersatz für eine echte blockbasierte Gerätelesefunktion.

Superblock blocksize, counts Block Group Descriptor inode table start Inode Table Inode-Strukturen Datenblock Dateiinhalt ext2_read_inode(fs, inode_id, out) inode_id - 1 → block_group_id + local_inode_id → inode_table_block + offset pread(fs->fd, out, sizeof(*out), byte_offset) Die Abbildung macht sichtbar, warum ext2 im Vergleich zu modernen Dateisystemen gut als Übungsdateisystem geeignet ist: die Kernstruktur ist linear und gut berechenbar.
Abbildung 11.E2 ext2-Leseweg vom Superblock über Blockgruppen-Deskriptor und Inode-Tabelle bis zum Datenblock.
Funktion aus der ÜbungAufgabeWichtige Formel / Idee
ext2_read_blockeinen globalen Block lesenoffset = global_block_id * fs->blocksize
ext2_read_inodeInode-Struktur anhand der Inode-ID leseninode_id - 1, dann Blockgruppe und lokalen Index berechnen
ext2_map_local_to_global_block_idlokale Dateiblocknummer auf physischen Block abbildenfür reader.c reicht der direkte Lookup über i_block[local]
ext2_find_inode_by_pathPfadkomponenten über Verzeichniseinträge auflösenbei / mit Root-Inode 2 starten
/* ext2_read_block: ein globaler Block im Disk-Image */
ssize_t ext2_read_block(ext2_t *fs, uint32_t global_block_id, void *buffer) {
    off_t offset = (off_t)global_block_id * (off_t)fs->blocksize;
    return pread(fs->fd, buffer, fs->blocksize, offset);
}

/* direkter Teil der Blockabbildung */
uint32_t ext2_map_local_to_global_block_id(ext2_inode_t const *inode,
                                           uint32_t local_block_id) {
    if (local_block_id < 12) {
        return inode->i_block[local_block_id];
    }

    /* einfache, doppelte und dreifache Indirektion wären die Erweiterung */
    return 0;
}
⚠️ Off-by-one-Falle: ext2-Inode-IDs beginnen bei 1, Array-/Tabellenindizes im Code aber bei 0. Deshalb wird bei der Berechnung zuerst inode_id - 1 verwendet.
12

ext4 – Journaling und Extents

ext4 (2008) erweitert ext2/ext3 um wesentliche Funktionen für Robustheit und Leistung. Es ist rückwärtskompatibel mit ext2/ext3-Features.

Journaling

Ohne Journaling kann ein Systemabsturz während eines Schreibvorgangs das Dateisystem in einem inkonsistenten Zustand hinterlassen. Journaling schreibt Änderungen zuerst in ein spezielles Journal (Write-Ahead-Log). Nach einem Absturz kann das Dateisystem das Journal durchgehen und angefangene Transaktionen entweder vollständig ausführen oder rückgängig machen.

Journaling-ModusWas wird gejourntSicherheitGeschwindigkeit
journalDaten + MetadatenHöchsteLangsam
ordered (Standard)Nur Metadaten; Daten vor MetadatenHochMittel
writebackNur MetadatenGrundlegendSchnell

Extents (Extent Trees)

ext2 verwendet für jeden Block einen einzelnen 32-Bit-Verweis (direkt/indirekt). Bei großen Dateien mit zusammenhängenden Blöcken ist das ineffizient: 1000 aufeinanderfolgende Blöcke benötigen 1000 einzelne Einträge.

ext4 verwendet stattdessen Extents: Ein Extent beschreibt einen zusammenhängenden Bereich als Tripel (erster logischer Block, erster physischer Block, Länge). Der Inode enthält einen Extent Tree mit bis zu 4 Einträgen direkt im Inode; für größere Dateien wird der Baum über zusätzliche Blöcke erweitert.

Extent Tree: Notation

  • Blattknoten (Extent): (1. logischer Block, 1. physischer Block, Anzahl Blöcke)
  • Indexknoten: (Blocknummer der Kinder, kleinste log. Blocknummer aller Kinder)
  • Header: (Anzahl Einträge, Tiefe)

Beispiel: 4 MiB, alle Blöcke konsekutiv ab 1000h (4-KiB-Blöcke)

4 MiB = 2²² Byte = 1024 Blöcke = 400h Blöcke. Weil die Blöcke direkt hintereinander liegen, reicht ein einziger Extent:

Inode Extent Array:
  [0] = (1, 0)           ← Header: 1 Eintrag, Tiefe 0 (Blattknoten direkt)
  [1] = (0, 1000h, 400h) ← Extent: log. Block 0 → phys. Block 1000h, 400h Blöcke

Zum Vergleich benötigt die direkte/indirekte Adressierung von ext2 für dieselbe Datei: 12 direkte Datenblockverweise und zusätzlich einen einfach indirekten Block. Dieser indirekte Block könnte 400h Einträge speichern; tatsächlich genutzt werden hier aber nur 400h − 0Ch = 3F4h Einträge, weil die ersten 12 Datenblöcke bereits direkt im Inode adressiert werden.

ext2 für 4 MiB: 400h Datenblöcke insgesamt
Direkt im Inode: 0Ch Datenblöcke
Über einfach indirekten Block: 400h − 0Ch = 3F4h Datenblockverweise
ext4 mit Extent: 1 Eintrag beschreibt alle 400h konsekutiven Datenblöcke

ext4-Rechenbeispiele: Extents und Journal

1) Extent für konsekutive Blöcke

Gegeben: Datei mit 10 MiB, Blockgröße 4 KiB, physisch zusammenhängend ab Block 0x20000.

Dateiblöcke = 10 MiB / 4 KiB = 2560 Blöcke = 0xA00
Ein Extent reicht: (logischer Start 0, physischer Start 0x20000, Länge 0xA00)

Im Vergleich zu ext2 müssten 2560 einzelne Datenblocknummern adressiert werden. ext4 beschreibt den gesamten zusammenhängenden Bereich mit einem einzigen Eintrag.

2) Fragmentierte Datei in mehrere Extents zerlegen

Logischer DateibereichPhysische BlöckeExtent
0–991000–1099(0, 1000, 100)
100–1799000–9079(100, 9000, 80)
180–2553000–3075(180, 3000, 76)

Man erzeugt immer dann einen neuen Extent, wenn die physische Fortsetzung nicht mehr direkt an den vorherigen physischen Block anschließt.

3) Kapazität eines Extent-Blocks abschätzen

Ein Extent-Header ist 12 Byte groß; ein Extent- oder Indexeintrag ebenfalls 12 Byte. Bei 4-KiB-Blöcken passen daher ungefähr:

floor((4096 − 12) / 12) = 340 Einträge pro Extent-Block

Der Inode selbst hat im Feld i_block Platz für Header plus bis zu vier Extent-Einträge. Erst bei mehr Fragmenten oder größeren Bäumen werden zusätzliche Extent-Blöcke benötigt.

4) Journal-Ablauf bei Metadatenänderung

1 Journal-Start 2 Änderungen ins Journal 3 Commit Record 4 echte Blöcke aktualisieren Nach Crash: Mit Commit wird die Transaktion wiederholt; ohne Commit wird sie verworfen.
Abbildung 12.1 ext4-Journaling als Write-Ahead-Log für konsistente Metadatenupdates.

Weitere ext4-Features

  • 64-Bit-Blocknummern: Unterstützung für sehr große Volumes
  • Flexible Blockgruppen: Metadaten mehrerer Gruppen können zusammengefasst werden
  • Delayed Allocation: Speicherzuweisung wird verzögert, um Fragmentierung zu reduzieren
  • Checksummen: Für Metadaten und Journal
  • Größere Inodes: Platz für erweiterte Attribute
  • Multiblock-Allokation: Mehrere Blöcke auf einmal allozieren
✅ ext2 vs. ext4 – Kernunterschied: ext2 adressiert Blöcke einzeln (direkt/indirekt), was bei großen Dateien viele Einträge erzeugt und Fragmentierung begünstigt. ext4 beschreibt zusammenhängende Blöcke als Extents, braucht bei konsekutiven Dateien nur wenige Einträge und ist für moderne Workloads deutlich effizienter.

Ergänzung Übung 12: direkte/indirekte Adressierung vs. Extent Trees

Dieser Abschnitt ergänzt die ext4-Zusammenfassung um die konkreten Rechenmuster aus Übung 12. Ziel ist, dass man eine Datei sowohl im klassischen ext2-Schema mit direkten/indirekten Blocknummern als auch im ext4-Schema mit Extent Trees darstellen kann.

ext2: einzelne Blocknummern Inode i_block[] 0→1000h, 1→1001h, …, Bh→100Bh Ch→indirekter Block indirekter Block 1024 × 4-Byte-Einträge ext4: Extent Tree Header: (Anzahl Einträge, Tiefe) Extent: (log. Start, phys. Start, Länge) Index: (Kindblock, kleinster log. Start) Extent-Block Header + bis zu 340 Einträge Der entscheidende Unterschied: ext2 zählt einzelne Blöcke auf; ext4 fasst zusammenhängende physische Bereiche zu Extents zusammen.
Abbildung 12.E1 Gegenüberstellung der klassischen Blockadressierung und des Extent-Tree-Modells.
📌 Kapazitätsnotiz zur Übung: Ein 4-KiB-Block enthält roh 4096 / 4 = 1024 = 400h 4-Byte-Blocknummern. Bei 12-Byte-Elementen sind roh floor(4096 / 12) = 341 Elemente möglich. In einem echten Extent-Block steht vorne zusätzlich ein 12-Byte-Header; dadurch bleiben floor((4096 − 12) / 12) = 340 nutzbare Extent- oder Indexeinträge.

Notation aus der Übung

StrukturNotationBedeutung
Header(Anzahl, Tiefe)Wie viele Einträge folgen und ob es Blatt- oder Indexebene ist
Extent/Blatt(logischer Start, physischer Start, Anzahl)zusammenhängender physischer Blockbereich
Indexknoten(Blocknummer der Kinder, kleinster logischer Start)Verweis auf einen weiteren Extent-/Indexblock

Fall A: 4 KiB-Datei ab Block 1000h

ext2 direkt:
  0 → 1000h

ext4 Extent Tree:
  0 → (1, 0)              Header: 1 Eintrag, Tiefe 0
  1 → (0, 1000h, 1)       ein Extent für genau einen Block

Fall B: 4 MiB, vollständig zusammenhängend ab 1000h

4 MiB bei 4-KiB-Blöcken sind 400h Datenblöcke. Bei ext2 werden die ersten 0Ch Blöcke direkt im Inode adressiert, der Rest liegt in einem einfach indirekten Block. Bei ext4 reicht ein einziger Extent.

ext2:
  Datenblöcke: 1000h … 13FFh
  0  → 1000h
  1  → 1001h
  …
  Bh → 100Bh
  Ch → 1400h               indirekter Block
  1400h.0    → 100Ch
  1400h.1    → 100Dh
  …
  1400h.3F3h → 13FFh

ext4:
  0 → (1, 0)               Header
  1 → (0, 1000h, 400h)    ein Extent beschreibt alle 400h Blöcke

Fall C: 4 MiB in vier zusammenhängenden 1-MiB-Bereichen

Jeder Bereich umfasst 100h Blöcke. Für ext4 passen genau vier Extents direkt in den Inode, weil dort nach dem Header noch vier 12-Byte-Einträge Platz haben.

Physische Bereiche:
  [1000h, 10FFh], [2000h, 20FFh], [3000h, 30FFh], [4000h, 40FFh]

ext2:
  0  → 1000h
  …
  Bh → 100Bh
  Ch → 1100h               indirekter Block, kleinste freie Metablocknummer > 1000h
  1100h.000h → 100Ch
  …
  1100h.0F3h → 10FFh
  1100h.0F4h → 2000h
  …
  1100h.3F3h → 40FFh

ext4:
  0 → (4, 0)               Header: 4 Extents, Tiefe 0
  1 → (0,    1000h, 100h)
  2 → (100h, 2000h, 100h)
  3 → (200h, 3000h, 100h)
  4 → (300h, 4000h, 100h)

Fall D: 4 MiB in 128-KiB-Bereichen

128 KiB entsprechen 20h Blöcken. Für 4 MiB braucht man also 20h Extents. Diese passen nicht mehr direkt in den Inode, weil dort nur vier Extents neben dem Header Platz haben. Deshalb wird im Inode ein Indexeintrag auf einen separaten Extent-Block verwendet.

ext2:
  Daten in 20h Paketen à 20h Blöcken:
  [1000h, 101Fh], [2000h, 201Fh], …, [2'0000h, 2'001Fh]

  0  → 1000h
  …
  Bh → 100Bh
  Ch → 1020h               indirekter Block
  1020h.0    → 100Ch
  …
  1020h.13h  → 101Fh
  1020h.14h  → 2000h
  …
  1020h.3F3h → 2'001Fh

ext4:
  0        → (1, 1)        Header im Inode: 1 Eintrag, Tiefe 1
  1        → (1020h, 0)    Indexknoten: Kinderblock 1020h, kleinster log. Block 0
  1020h.0  → (20h, 0)      Header im Extent-Block: 20h Extents, Tiefe 0
  1020h.1  → (0,    1000h, 20h)
  1020h.2  → (20h, 2000h, 20h)
  …
  1020h.20h → (3E0h, 2'0000h, 20h)

Rezept zum Lösen solcher Aufgaben

  1. Dateigröße in Anzahl Datenblöcke umrechnen.
  2. Physische Blockbereiche als Intervalle notieren, statt alle Blöcke auszuschreiben.
  3. Für ext2: zuerst 12 direkte Einträge füllen, danach benötigte Metadatenblöcke an der kleinsten freien Blocknummer allozieren.
  4. Für ext4: jedes physisch zusammenhängende Intervall wird ein Extent.
  5. Wenn höchstens vier Extents entstehen, bleiben sie direkt im Inode. Bei mehr als vier Extents wird ein Indexeintrag und ein zusätzlicher Extent-Block benötigt.
✅ Lernziel-Verbindung: Die Übung zeigt den Grund für Extents sehr klar: Nicht die Dateigröße allein entscheidet über den Aufwand, sondern die Fragmentierung. Eine große, zusammenhängende Datei kann in ext4 durch einen einzigen Extent beschrieben werden, während viele kleine physische Bereiche mehrere Extents und eventuell Indexblöcke benötigen.
13

X Window System / Wayland

📌 Quellenlage: Die hochgeladenen Folien und die Übung behandeln inhaltlich vor allem X11 / X.org. Wayland wird dort als moderner Ersatz für das X Protocol erwähnt; deshalb bleibt Wayland hier als Kontext im Titel, der prüfungsrelevante Schwerpunkt dieses Abschnitts liegt aber auf dem X Window System.

Ziele und Grundidee

Das X Window System stellt unter Unix/Linux die klassische Basisinfrastruktur für grafische Oberflächen bereit. Es zeichnet nicht einfach „die ganze GUI“ selbst, sondern trennt mehrere Rollen: Anwendungen wollen Fenster und Inhalte darstellen, der X-Server steuert Display, Tastatur und Maus, und ein Window Manager verwaltet Platzierung, Dekoration und Verhalten der Fenster.

  • GUI-Grundbegriffe: Fenster, Events, Mauszeiger, Fokus, Window Manager und Desktop.
  • X-Architektur: Client/Server-Modell, X Protocol, Xlib, X-Ressourcen und Netzwerktransparenz.
  • Programmierung: Display öffnen, Fenster erzeugen, Events selektieren, Event Loop schreiben, zeichnen und Fenster sauber schließen.

GUI-Basiskonzepte: programm- vs. ereignisgesteuert

Textorientierte Programme folgen oft einem programmgesteuerten Ablauf: Das Programm gibt etwas aus, wartet auf Eingabe und entscheidet dann, was als Nächstes passiert. GUIs funktionieren dagegen ereignisgesteuert: Das Programm wartet auf Ereignisse wie Mausklicks, Tastendrücke, Größenänderungen oder Neuzeichnen-Anforderungen und reagiert darauf.

Programmgesteuert Programm println() Benutzer Eingabe Scanner.next() Ereignisgesteuert Benutzer Klick / Taste X-Server Event Programm Event Loop Bei GUIs entscheidet nicht das Programm allein den Ablauf: Benutzeraktionen erzeugen Events, die das Programm in einer Schleife abholt und verarbeitet.
Abbildung 13.1 Programmgesteuerter Ablauf im Vergleich zur ereignisgesteuerten GUI-Programmierung.

Fenster, Maus und Window Manager

Ein Fenster ist im X-Konzept ein rechteckiger Bereich des Bildschirms. Fenster bilden eine Baumstruktur: Das Root Window ist die Wurzel und bedeckt den ganzen Screen. Top-Level-Windows von Anwendungen sind Kinder des Root Windows; Menüs, Buttons oder andere GUI-Elemente können weitere Kindfenster sein. Kindfenster werden auf den Bereich ihres Elternfensters begrenzt (clipping to the parent).

Screen / Root Window Window 1 Window 2 Button überlappt, aber clipped Dekoration durch Window Manager × Client-Fensterinhalt Top-Level Der Window Manager ist selbst ein X-Client mit Sonderrolle: Er platziert Fenster, ergänzt Rahmen/Titelleisten und entscheidet über Stacking, Minimieren, Maximieren usw.
Abbildung 13.2 Fensterhierarchie und Rolle des Window Managers.
✅ Wichtig: Mausklicks gelten für das kleinste sichtbare Fenster unter dem Hotspot des Mauszeigers. Tastaturereignisse gehen dagegen an das Fenster mit Fokus beziehungsweise dessen Abkömmlinge.

X-Architektur: Client, Server, Protokoll und Xlib

Die Begriffe sind bei X zunächst ungewohnt: Der X-Server läuft auf dem Rechner, an dem Display, Tastatur und Maus hängen. Der X-Client ist die Anwendung, die etwas anzeigen oder Events empfangen will. Dadurch können Clients lokal oder auf einem entfernten Rechner laufen. Diese Trennung ermöglicht Netzwerktransparenz.

Display-Rechner X Server Screen Input steuert Pixel, Tastatur, Maus Lokaler X-Client Xlib App Remote X-Client Xlib App X Protocol / IPC X Protocol / Netzwerk Merksatz: Der X-Server sitzt beim Benutzer und verwaltet das Display; Clients sind Programme, die dieses Display nutzen.
Abbildung 13.3 X-Client/X-Server-Architektur mit lokaler und entfernter Anwendung.
BausteinFunktionPrüfungsmerkpunkt
DisplayRechner mit Tastatur, Zeigegerät und einem oder mehreren Screens.Wird in Xlib über Display * repräsentiert.
X ServerSteuert Display und Eingabegeräte, verwaltet serverseitige Ressourcen.Läuft dort, wo GUI-Ein-/Ausgabe stattfindet.
X ClientAnwendung, die Fenster erzeugt, zeichnet und Events verarbeitet.Kann lokal oder entfernt laufen.
XlibC-Schnittstelle zum X Protocol.Header <X11/Xlib.h>, Linker-Flag -lX11.
ToolkitKomfortschicht oberhalb von Xlib, z.B. GTK+ oder Qt.Direkte Xlib-Programmierung ist Low-Level.

Minimaler Xlib-Ablauf

#include <X11/Xlib.h>

Display *display = XOpenDisplay(NULL);      /* DISPLAY-Variable verwenden */
int screen = DefaultScreen(display);
Window root = RootWindow(display, screen);

Window window = XCreateSimpleWindow(
    display, root,
    50, 50,          /* x, y */
    400, 250,        /* width, height */
    1,               /* border width */
    BlackPixel(display, screen),
    WhitePixel(display, screen)
);

XSelectInput(display, window, ExposureMask | ButtonPressMask);
XMapWindow(display, window);                /* macht Fenster sichtbar */

X Protocol, Pufferung und Ressourcen

Das X Protocol definiert Nachrichten zwischen Client und Server. Es gibt vier wichtige Typen: Requests vom Client an den Server, Replies als Antworten, Events als spontane Meldungen vom Server an den Client und Errors als Fehlermeldungen. Requests werden clientseitig gepuffert, um Netzwerkverkehr zu reduzieren. Der Puffer wird zum Beispiel geleert, wenn ein Reply nötig ist, wenn der Client auf Events wartet oder wenn explizit XFlush() aufgerufen wird.

X Client Request Buffer Event Buffer X Server Ressourcen Event Buffer Requests Replies / Errors Events Serverseitige Ressourcen vermeiden, dass große Datenstrukturen ständig zwischen Client und Server kopiert werden müssen.
Abbildung 13.4 Nachrichtenarten, Pufferung und serverseitige Ressourcen im X-Protokoll.
X-RessourceBedeutungWarum sie serverseitig liegt
WindowFenstereigenschaften und Fensterhierarchie.Der Server muss Trefferprüfung, Sichtbarkeit und Events verwalten.
PixmapServerseitiger Grafikspeicher, z.B. für Icons oder schnelles Neuzeichnen.Komplexe Inhalte können einmal vorbereitet und später kopiert werden.
ColormapFarbtabelle: Farbindizes werden auf konkrete Farben abgebildet.Reduziert Speicherbedarf und vereinheitlicht Farbdarstellung.
FontBeschreibung einer Schriftart.Text kann serverseitig mit bekannten Font-Ressourcen gezeichnet werden.
Graphics Context (GC)Zeicheneigenschaften wie Farbe, Liniendicke, Füllmuster.Zeichenfunktionen müssen nicht jedes Mal alle Eigenschaften mitsenden.

Event Handling

Ein X-Client erhält nicht automatisch alle Events. Er muss pro Fenster mit XSelectInput() festlegen, welche Ereignistypen ihn interessieren. Ohne Auswahl ist die Event-Maske leer. Eine wichtige Ausnahme sind ClientMessage-Events, die immer zugestellt werden können.

XSelectInput(display, window, ExposureMask | ButtonPressMask | KeyPressMask);
XNextEvent(display, &event);
switch (event.type) { ... }
Benutzer Maus / Tastatur X-Server Event erzeugen Client Buffer Events warten XNextEvent kopiert Event Handler switch Events sind Werte vom Typ XEvent. XEvent ist eine Union; zuerst wird event.type geprüft, danach verwendet man den passenden Union-Member, z.B. event.xbutton oder event.xkey.
Abbildung 13.5 Weg eines GUI-Ereignisses bis zur Verarbeitung im Client.
XEvent event;

while (!application->done) {
    XNextEvent(application->display, &event);

    switch (event.type) {
    case Expose:
        handle_expose_event(application, &event.xexpose);
        break;
    case ButtonPress:
        handle_button_press_event(application, &event.xbutton);
        break;
    case ClientMessage:
        handle_client_message_event(application, &event.xclient);
        break;
    }
}
EventWann typisch?Was macht der Client?
ExposeFenster oder Bereich wird sichtbar und muss neu gezeichnet werden.Alle sichtbaren Inhalte erneut zeichnen.
ButtonPressMaustaste wird im Fenster gedrückt.Buttonnummer in event.xbutton.button auswerten.
KeyPressTaste wird gedrückt und Fenster hat Fokus.Mit XLookupKeysym() Taste interpretieren.
ClientMessageWindow Manager sendet Protokollnachricht, z.B. Fenster schließen.Atom im Datenteil prüfen.

Zeichnen: Pixmap, Colormap, Graphics Context

X zeichnet mit einfachen Grafikprimitiven in ein Ziel: entweder direkt in ein Fenster oder in eine Pixmap. Für fast alle Zeichenoperationen wird ein Graphics Context benötigt. Er enthält Eigenschaften wie Vordergrundfarbe, Hintergrundfarbe, Liniendicke und Füllmuster.

Direkt ins Fenster

  • Einfach und unmittelbar sichtbar
  • Bei Expose muss neu gezeichnet werden
  • Geeignet für einfache Übungen

Erst in Pixmap

  • Serverseitiger Zwischenspeicher
  • Komplexe Inhalte können wiederverwendet werden
  • Nützlich für schnelles Neuzeichnen
GC gc = XCreateGC(display, window, 0, NULL);

XDrawRectangle(display, window, gc, 20, 20, 260, 120);
XDrawString(display, window, gc, 40, 80,
            "Hallo X Welt", 12);

XFreeGC(display, gc);
⚠️ Neuzeichnen nicht vergessen: Wenn sich interne Daten ändern, wird das Fenster nicht automatisch neu gezeichnet. In der Übung wird der Klick-Zähler erst sichtbar, wenn später ein Expose-Event entsteht, z.B. durch Größenänderung. High-Level-Toolkits bieten dafür meist Funktionen wie repaint oder update; bei Xlib muss man den Zeichenzeitpunkt selbst sauber behandeln.

Fenster schließen: Window Manager, Atoms und WM_DELETE_WINDOW

Der Schließen-Knopf gehört nicht zur eigentlichen Anwendung, sondern wird vom Window Manager bereitgestellt. X selbst weiß nicht automatisch, dass dieser Knopf „Programm beenden“ bedeuten soll. Deshalb gibt es ein Protokoll zwischen Window Manager und Client: Der Client registriert sich für WM_DELETE_WINDOW; beim Klick auf den Schließen-Knopf erhält er ein ClientMessage-Event.

Anwendung XSetWMProtocols WM_DELETE_WINDOW Window Manager Close-Button ClientMessage data.l[0] == atom done = 1 registriert sendet Event Atoms sind IDs für wohlbekannte Strings. Statt ständig Stringnamen zu vergleichen, arbeiten X und Window Manager mit Atom-Werten.
Abbildung 13.6 Schließen eines Fensters über das Window-Manager-Protokoll WM_DELETE_WINDOW.
Atom delete_atom = XInternAtom(display, "WM_DELETE_WINDOW", False);
XSetWMProtocols(display, window, &delete_atom, 1);

/* im Event-Handler */
if (event->type == ClientMessage &&
    (Atom)event->xclient.data.l[0] == delete_atom) {
    application->done = 1;
}

Übung 13: Was du aus dem Code verstehen musst

Die Übung vertieft die Xlib-Konzepte an einem kleinen C-Programm. Das Programm erzeugt ein Fenster mit schwarzem Rahmen auf weißem Grund, verarbeitet Events und wird anschließend um Textausgabe, Window-ID und Mausklick-Zähler erweitert.

ÜbungsteilKernideePrüfungsrelevanz
KompilierenDas Programm muss mit -lX11 gelinkt werden.Xlib ist eine separate Library.
mainnew_Application()execute()delete_Application().Konstruktor-/Destruktor-Muster in C erkennen.
struct ApplicationDisplay * ist ein Pointer auf Verbindungsdaten; Window, GC und Atom sind IDs.Serverseitige X-Ressourcen werden über IDs referenziert.
KonstruktorDisplay öffnen, Fenster erzeugen, GC erzeugen, Atoms holen, Events auswählen, Fenster mappen.Typische Reihenfolge eines Xlib-Programms.
DestruktorGC freigeben, Fenster zerstören, Display schließen.Ressourcen sauber freigeben.
executeEvent Loop läuft, bis done != 0.GUI-Programme warten ereignisgesteuert.
dispatch_eventEntscheidet über event.type und ruft passende Handler auf.XEvent-Union korrekt verwenden.
ExposeRechteck, Text, Window-ID und Counter neu zeichnen.Zeichnen gehört in den Neuzeichnen-Handler.
ButtonPressCounter erhöhen; später nur rechte Maustaste zählen.ButtonPressMask selektieren und event.xbutton.button auswerten.

Prüfungsschema für das Übungsprogramm

struct Application {
    Display *display;       /* Verbindung zum X-Server */
    Window window;          /* ID der serverseitigen Window-Ressource */
    GC gc;                  /* ID des Graphics Context */
    Atom delete_atom;       /* Atom für WM_DELETE_WINDOW */
    int done;               /* Abbruchbedingung der Event Loop */
    int counter;            /* Übungserweiterung: Mausklicks zählen */
};

/* Reihenfolge */
new_Application();          /* alloziert + initialisiert X-Ressourcen */
execute();                  /* XNextEvent + dispatch_event */
delete_Application();       /* gibt Ressourcen frei */
✅ Merksatz: Xlib-Code besteht fast immer aus zwei Teilen: Erst werden serverseitige Ressourcen erzeugt und Events selektiert; danach läuft eine Event Loop, die Events abholt und anhand von event.type verteilt.

Kurzvergleich: X11 und Wayland-Kontext

In den Materialien wird Wayland nur kurz als Ersatz für das X Protocol genannt. Für diese Zusammenfassung genügt daher der Kontext: X11 trennt X-Server, X-Clients, Window Manager und Protokoll sehr stark und erlaubt Netzwerktransparenz. Moderne Systeme verwenden häufig Wayland-basierte Compositoren, aber die hochgeladenen Folien und Übungen prüfen vor allem das Verständnis der X11-Architektur und der Xlib-Programmierung.