# Zeiger und Speicher: Vom ersten Byte bis zum letzten Cache-Miss

Ein vollständiger Leitfaden zur Funktionsweise von Computerspeicher, von Bytes über virtuelle Adressräume und Heap-Fehler bis hin zu CPU-Cache-Lines, False Sharing und Memory Ordering.

Published: 25. Mai 2026
Canonical HTML: https://de.glazastov.com/pointers-and-memory
Markdown: https://de.glazastov.com/pointers-and-memory.md

Speicher ist das Grundlegendste, was ein Computer tut, und das, was die meisten Programmierer am wenigsten verstehen. Dieser Artikel beginnt am absoluten Anfang (was ein Byte ist, was eine Adresse ist) und endet bei den Problemen, die erst in der Produktion, unter Last, auf Mehrkern-Maschinen auftauchen. Es wird keine bestimmte Programmiersprache vorausgesetzt. C-Code erscheint gelegentlich als dünnstmögliche Schicht über dem Hardware-Modell, nicht als Thema selbst.

## Teil 1. Speicher ist nur nummerierte Kästchen

Stellen Sie sich eine sehr lange Reihe kleiner Kästchen vor. Jedes Kästchen hält genau acht Lichtschalter, acht **Bits**. Zusammen können diese acht Schalter jeden Wert von 0 bis 255 kodieren (2⁸ = 256 Möglichkeiten). Diese Acht-Bit-Einheit ist ein **Byte**, die kleinste individuell adressierbare Einheit im RAM jedes modernen Computers.

Nun versehen Sie jedes Kästchen mit einer fortlaufenden Nummer, beginnend bei null. Diese Zahl ist seine **Adresse**. Auf einem 64-Bit-Computer sind Adressen 64-Bit-Zahlen, was bedeutet, dass die Maschine theoretisch bis zu 2⁶⁴ verschiedene Bytes adressieren kann, etwa 18 Exabyte. Echte Hardware adressiert weit weniger, aber der Adressraum ist aus gutem Grund riesig.

Ein **Zeiger** ist nichts anderes als eine Variable, deren Wert eine dieser Adressen ist.

Das ist die vollständige Definition. Ein Zeiger ist eine Zahl. Sie wird als „geh an diese Position im Kästchen-Array" interpretiert. Alles andere (Syntax, Typsysteme, Eigentumsregeln) baut auf dieser einen Idee auf.

### Warum eine Adresse statt des Werts speichern?

Drei Gründe:

**1. Große Daten.** Eine 100-MB-Struktur bei jedem Funktionsaufruf zu kopieren wäre katastrophal. Übergeben Sie einen Zeiger (8 Bytes) und der Empfänger arbeitet mit dem Original.

**2. Geteilte Mutation.** Zwei Teile eines Programms, die gegenseitig die Änderungen des anderen sehen müssen, brauchen eine gemeinsame Position. Ein Zeiger ist diese Vereinbarung.

**3. Dynamische Strukturen.** Eine verkettete Liste oder ein Baum hat zur Compile-Zeit keine feste Größe. Die Struktur wird zur Laufzeit durch Allokation von Knoten und deren Verbindung durch Zeiger aufgebaut.

### Die Größe eines Zeigers

Auf einem 64-Bit-System ist ein Zeiger immer 8 Bytes, unabhängig davon, worauf er zeigt. Ob er auf ein einzelnes Zeichen oder auf einen 10-GB-Puffer zeigt, der Zeiger selbst hält immer eine 64-Bit-Adresse. Auf einem 32-Bit-System sind Zeiger 4 Bytes, was den adressierbaren Raum auf 4 GB beschränkt.

## Teil 2. Zeiger-Arithmetik

Der **Typ** eines Zeigers beeinflusst nicht die darin gespeicherte Adresse. Aber er beeinflusst die Arithmetik auf dem Zeiger.

Wenn `p` ein Zeiger auf einen 4-Byte-Integer ist und Sie `p + 1` berechnen, wollen Sie die Adresse des _nächsten_ Integers, also 4 Bytes weiter, nicht 1. Der Compiler kennt die Größe des Zieltyps und skaliert den Offset automatisch.

```c
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;

// Diese beiden sind identisch:
int x = arr[3];        // Standard-Indexierung
int y = *(p + 3);      // Zeiger-Arithmetik — gleiches Ergebnis: 40
```

Array-Indexierung ist verkleidete Zeiger-Arithmetik. Die Notation `arr[i]` ist buchstäblich als `*(arr + i)` definiert. Deshalb wird ein Zugriff außerhalb der Grenzen nicht automatisch erkannt: die Arithmetik erzeugt einfach eine Adresse hinter dem Array-Ende, und die Hardware liest oder schreibt dort ohne Beschwerde.

## Teil 3. Die Sicht des Prozesses auf den Speicher

Ihr Programm sieht nicht den physischen RAM. Was es sieht, ist ein **virtueller Adressraum**, eine private Abbildung, die das Betriebssystem speziell für diesen Prozess aufbaut. Jeder Prozess bekommt seinen eigenen, von allen anderen Prozessen isolierten virtuellen Adressraum.

Der virtuelle Adressraum ist in verschiedene **Regionen** unterteilt:

```
┌─────────────────────────┐  Hohe Adressen
│        Kernel           │  (reserviert — für Programme nicht zugänglich)
├─────────────────────────┤
│         Stack           │  wächst abwärts ↓
│           ↓             │
│                         │
│           ↑             │
│    Memory-Mapped-Bereich│  gemeinsame Bibliotheken, mmap-Dateien
│           ↑             │
│          Heap           │  wächst aufwärts ↑
├─────────────────────────┤
│    BSS-Segment          │  nicht initialisierte globale Variablen (null-gefüllt)
├─────────────────────────┤
│    Datensegment         │  initialisierte globale Variablen
├─────────────────────────┤
│    Textsegment          │  Maschinencode (nur lesbar)
└─────────────────────────┘  Niedrige Adressen (Adresse 0)
```

Das **Textsegment** enthält die kompilierten Maschinenanweisungen und ist schreibgeschützt. Das **BSS-Segment** wird beim Start auf null gesetzt. Der **Heap** wächst von unten nach oben; der **Stack** wächst von oben nach unten. Wenn sie sich treffen würden, ist der Speicher des Prozesses erschöpft.

## Teil 4. Der Stack

Jedes Mal, wenn eine Funktion aufgerufen wird, reserviert die CPU auf dem Stack einen Block namens **Stack-Frame**. Dieser Frame enthält die lokalen Variablen der Funktion, die Rücksprungadresse und gespeicherte Registerwerte.

Wenn die Funktion zurückkehrt, wird ihr Stack-Frame sofort verworfen; der Stack-Pointer-Register wird einfach angepasst. Keine Allokation, kein Systemaufruf.

Der Stack hat eine feste maximale Größe, typischerweise ca. 8 MB unter Linux. Wird diese überschritten (meistens durch sehr tiefe Rekursion), stürzt das Programm mit einem **Stack-Overflow** ab.

### Hängende Stack-Zeiger

Da ein Stack-Frame beim Rückgabe der Funktion vernichtet wird, ist die Rückgabe eines Zeigers auf eine lokale Variable undefiniertes Verhalten:

```c
int *get_number(void) {
    int x = 42;
    return &x;   // FEHLER: x wird beim Rückgabe der Funktion vernichtet
}
```

Der Aufrufer erhält einen Zeiger, der gültig _aussieht_. Aber der nächste Funktionsaufruf wird diesen Speicher wiederverwenden und ihn stillschweigend überschreiben.

## Teil 5. Der Heap

Der Heap ist für Speicher, der länger leben muss als die Funktion, die ihn erstellt. Ein **Heap-Allokator** verwaltet den rohen Speicher: er teilt ihn in Blöcke auf, verfolgt, welche frei sind, und recycelt sie bei Freigabe.

Heap-Speicher wird nicht automatisch aufgeräumt (in Sprachen ohne Garbage Collector). Der Programmierer muss ihn explizit freigeben.

### Use-after-free

```c
int *p = malloc(4);
*p = 42;
free(p);          // Speicher zurück an Allokator
*p = 99;          // FEHLER: Allokator hat diesen Speicher möglicherweise weitergegeben
```

Nach `free` enthält der Zeiger noch die alte Adresse. Die Bytes dort sind oft noch unverändert; aber der Allokator kann sie sofort an eine andere Allokation weitergeben. Das Schreiben dorthin überschreibt ein anderes Objekt.

### Double-Free

```c
free(p);
free(p);  // FEHLER: korrumpiert die interne Buchhaltung des Allokators
```

Der Allokator nutzt freigegebenen Speicher, um Verwaltungsmetadaten zu speichern. Zweimaliges `free` überschreibt diese Metadaten; der Effekt ist oft erst viel später sichtbar.

### Pufferüberlauf

```c
char name[8];
strcpy(name, "Christopher");  // FEHLER: "Christopher" ist 12 Bytes mit Nullterminierung
```

Das Schreiben über das Ende eines Arrays hinaus überschreibt benachbarte Daten. Auf dem Stack kann das die Rücksprungadresse überschreiben, was das klassische Angriffsmuster für Stack-basierte Exploits ist.

### Speicherleck

```c
while (1) {
    char *buf = malloc(1024);
    process(buf);
    // FEHLER: free(buf) vergessen
    // 1 KB verschwindet pro Iteration aus dem verfügbaren Speicher
}
```

Ein Leck stürzt das Programm nicht sofort ab; es verbraucht langsam den verfügbaren Speicher. Lang laufende Server-Prozesse sind der natürliche Lebensraum von Lecks; sie wachsen und wachsen, bis das OS sie abbricht oder der Betreiber sie neu startet.

## Teil 6. Virtuelle Speicher, MMU und TLB

Wie werden virtuelle Adressen in physische RAM-Positionen umgewandelt?

Der zuständige Hardware-Bestandteil ist die **Memory Management Unit (MMU)**, die in jeden modernen CPU integriert ist. Die MMU arbeitet in Einheiten von **Seiten**, typischerweise 4 KB groß. Jede virtuelle Adresse wird in zwei Teile aufgeteilt:

- Eine **virtuelle Seitennummer** (VPN): welche Seite?
- Ein **Offset** innerhalb dieser Seite: wo in der Seite?

Der Offset bleibt unverändert. Die VPN wird mithilfe einer **Seitentabelle** (im RAM gespeichert) in eine **physische Rahmennummer** (PFN) übersetzt.

```
Virtuelle Adresse:  [ Virtuelle Seitennummer | Offset ]
                               ↓
                          Seitentabelle
                               ↓
Physische Adresse:  [ Physische Rahmennummer | Offset ]
```

### Das Problem: Seitentabellen liegen im RAM

Jede Speicherzugriff würde dadurch einen zusätzlichen Speicherzugriff (für die Tabelle) erfordern. Die Lösung ist der **Translation Lookaside Buffer (TLB)**, ein kleiner, extrem schneller Cache in der MMU, der die zuletzt verwendeten Übersetzungen speichert.

- **TLB-Treffer**: Die Übersetzung ist gecacht. Kosten: etwa ein CPU-Takt.
- **TLB-Fehlschlag**: Die Übersetzung ist nicht gecacht. Die CPU muss die Seitentabelle im RAM **durchwandern**, auf x86-64 vier separate Speicherzugriffe. Erst dann ist die Übersetzung bekannt.

Programme, die Speicher über viele Seiten verteilt zugreifen (etwa durch Zeigerverfolgung in einer verketteten Liste) verursachen ständige TLB-Fehlschläge. Das ist ein wesentlicher Grund, warum Zeigerstrukturen oft dramatisch langsamer sind als flache Arrays, selbst bei gleicher algorithmischer Komplexität.

### Seitenfehler und ASLR

Greift ein Programm auf eine virtuelle Adresse ohne gültige Seitentabellen-Eintrag zu, löst die CPU einen **Seitenfehler** aus. Das OS entscheidet: Wenn die Seite auf Disk ausgelagert ist, liest es sie zurück und setzt das Programm fort. Wenn die Adresse ungültig ist (NULL-Dereferenz), sendet das OS ein Signal, unter Linux **SIGSEGV**, und das Programm wird beendet.

**ASLR** (Address Space Layout Randomisation) nutzt virtuellen Speicher zur Sicherheit: Der Stack, der Heap und die Bibliotheken werden bei jedem Programmstart an zufälligen Adressen platziert, sodass ein Angreifer Adressen nicht vorhersagen kann.

## Teil 7. CPU-Caches und die wahren Kosten eines Speicherzugriffs

RAM ist schnell im Vergleich zu einer Festplatte, aber außerordentlich langsam im Vergleich zur CPU. Ein Speicherrundtrip kostet auf einem modernen Prozessor rund **200 CPU-Takte**.

Die Lösung ist eine Hierarchie von Caches:

| Ebene    | Typische Größe | Typische Latenz | Geteilt von  |
| -------- | -------------- | --------------- | ------------ |
| Register | ~1 KB          | 0 Takte         | Einem Kern   |
| L1-Cache | 32–64 KB       | ~4 Takte        | Einem Kern   |
| L2-Cache | 256 KB–1 MB    | ~12 Takte       | Einem Kern   |
| L3-Cache | 4–64 MB        | ~40 Takte       | Allen Kernen |
| RAM      | 8–512 GB       | ~200 Takte      | Allen Kernen |
| NVMe SSD | TBs            | ~50.000 Takte   | Maschine     |

### Cache-Lines

Der Cache arbeitet nicht byteweise, sondern in **Cache-Lines**, typischerweise 64 Bytes groß. Wenn Sie auf ein einziges Byte zugreifen, holt die CPU die gesamte 64-Byte-Cache-Line, die es enthält. Sequentielle Array-Iteration ist deshalb extrem cache-freundlich; verkettete Listen, deren Knoten über den Heap verteilt sind, sind es nicht.

### False Sharing

In einem Mehrkern-System hat jeder Kern seinen eigenen L1- und L2-Cache. Wenn zwei Kerne eine Kopie derselben Cache-Line halten und einer von ihnen darauf schreibt, muss die Hardware die Kopie des anderen Kerns für ungültig erklären, selbst wenn sie verschiedene Bytes in der Cache-Line schreiben.

Wenn Thread A wiederholt in `zähler_a` schreibt und Thread B in `zähler_b`, und beide Variablen zufällig auf derselben 64-Byte-Cache-Line liegen, verbringen die beiden Kerne die meiste Zeit damit, um die Cache-Line zu kämpfen. Der Leistungsverlust kann erheblich sein.

Die Lösung: thread-private Daten so auffüllen, dass jede Cache-Line nur von einem Thread verwendet wird:

```c
struct Zähler {
    long wert;
    char _pad[56];  // auffüllen auf 64 Bytes
};
```

## Teil 8. Memory Ordering, wenn CPUs lügen

Moderne CPUs führen Anweisungen nicht in der Reihenfolge aus, in der sie geschrieben wurden. Außerdem haben CPUs **Store-Buffer**: Ein Schreibvorgang landet zuerst im Store-Buffer und wird asynchron in den Cache übernommen. Andere Kerne sehen den Schreibvorgang möglicherweise erst deutlich später.

Für nebenläufige Programme hat das konkrete Auswirkungen:

```
Thread A:           Thread B:
x = 1;              while (bereit == 0) {}
bereit = 1;         print(x);
```

Ohne Synchronisierung könnte Thread B `0` ausgeben, da der Schreibvorgang auf `x` möglicherweise noch im Store-Buffer von Thread A liegt, wenn Thread B `bereit = 1` beobachtet.

Die Lösung ist eine **Speicherschranke** (Memory Barrier): eine Anweisung, die alle vorangehenden Schreibvorgänge erzwingt, bevor nachfolgende Lese- oder Schreibvorgänge fortfahren. Das ist der Hardware-Mechanismus hinter `volatile`, `atomic` und Mutex-Operationen in jeder Programmiersprache.

## Teil 9. Speicherabgebildete Dateien und Copy-on-Write

### Speicherabgebildete Dateien (mmap)

Das virtuelle Speichersystem kann nicht nur RAM, sondern auch **Dateiinhalte** direkt in den virtuellen Adressraum eines Prozesses abbilden. Der Zugriff auf die abgebildeten Adressen veranlasst das OS, die relevanten Datei-Blöcke bei Bedarf einzulesen, genau wie ein Seitenfehler bei gewöhnlichem Speicher.

Vorteile:

- Mehrere Prozesse, die dieselbe Datei abbilden, teilen dieselben physischen Seiten, ohne Duplikation.
- Nur die tatsächlich zugegriffenen Teile der Datei werden jemals in den RAM geladen.
- Datenbanken, Shared-Memory-IPC und Hochleistungs-Logging nutzen diesen Mechanismus.

### Copy-on-Write (CoW)

Wenn Unix-ähnliche Systeme einen neuen Prozess mit `fork()` erstellen, beginnt der Kindprozess als exakte Kopie des Elternprozesses. Den gesamten Speicher tatsächlich zu kopieren wäre zu teuer. Stattdessen teilen Eltern und Kind anfangs dieselben physischen Seiten; beim ersten Schreibzugriff eines Prozesses auf eine Seite löst die Hardware einen Seitenfehler aus, das OS kopiert genau diese Seite, und erst dann hat der Prozess seine eigene Kopie.

### Huge Pages

Die Standard-Seitengröße von 4 KB wurde in den 1980er Jahren festgelegt. Bei 128 GB RAM gibt es 32 Millionen Seiten. Der TLB hält typischerweise rund 1000 Einträge, was nur 4 MB abdeckt. **Huge Pages** (2 MB oder 1 GB auf x86-64) reduzieren den TLB-Druck drastisch: Jeder TLB-Eintrag deckt 2 MB statt 4 KB ab. Datenbanken und Hochleistungsanwendungen konfigurieren Huge Pages, um TLB-Fehlschläge als Engpass zu eliminieren.

## Fazit

Das lassen Einführungskurse aus. Das Speichermodell, das dort gelehrt wird (Variablen halten Werte, Funktionen besitzen ihren Geltungsbereich, Zuweisungen sind sequenziell), ist eine Vereinfachung, an deren Aufrechterhaltung Prozessor, Compiler und Betriebssystem still mitwirken.

Keiner von ihnen wurde darum gebeten.

Der TLB hat eine feste Anzahl von Einträgen und verdrängt ältere Übersetzungen, wenn er voll ist. Der Store-Buffer leert sich nach eigenem Zeitplan, nicht nach dem des Programmierers. Eine Cache-Line ist 64 Bytes, unabhängig davon, wie viele gebraucht wurden — und wenn zwei Kerne in Variablen schreiben, die zufällig dieselbe teilen, verbringt die Hardware mehr Zeit mit Eigentumsstreitigkeiten als mit sinnvoller Arbeit. Irgendwann in diesem Artikel wurde spürbar, wie weit Modell und Maschine auseinanderliegen. Dieses Unbehagen war beabsichtigt.

Nichts davon bewahrt vor dem Schreiben von Speicherfehlern. Es verändert, was man sieht, wenn sie auftreten. Use-after-free hört auf, mysteriöse Korruption zu sein, und wird zu einem Schreibvorgang auf Speicher, dessen Eigentümerschaft einige Zeilen früher abgetreten wurde. False Sharing bekommt eine Ursache statt eines Schulterzuckens. Memory-Ordering-Fehler fühlen sich nicht mehr wie Nichtdeterminismus an, sondern wie ein vorhersehbares Ergebnis auf einer Maschine, die nie zugesagt hatte, was man vorausgesetzt hatte.

Heartbleed waren vier Bytes. Nicht vier Megabytes, kein katastrophischer Rechenfehler; vier Bytes hinter dem Ende eines Heap-Puffers im Heartbeat-Handler von OpenSSL. Der Morris-Wurm nutzte einen Stack-Puffer fester Größe in fingerd, einem Unix-Programm, an das die meisten Betreiber seit Jahren nicht mehr gedacht hatten. Cloudbleed war ein Use-after-free in Cloudflares HTTP-Parser, der von Ragel aus einer Zustandsmaschinenbeschreibung erzeugt worden war. In allen drei Fällen tat der Code genau das, was ihm die Programmierer gesagt hatten. Ihr Modell davon, was das zur Laufzeit bedeutete, war falsch.

## Quellen

1. Bryant, R. E.; O'Hallaron, D. R. _Computer Systems: A Programmer's Perspective_, 3. Aufl. Pearson, 2015. Standardlehrbuch für CMU 15-213.
2. Carnegie Mellon University. CS 15-213: _Introduction to Computer Systems — Virtual Memory_. [cs.cmu.edu/afs/cs/academic/class/15213-s09/www/lectures/17-virtual-memory.pdf](https://www.cs.cmu.edu/afs/cs/academic/class/15213-s09/www/lectures/17-virtual-memory.pdf)
3. Stanford University. CS 107: _Pointers, Arrays and the Heap_ (Lab 3). [web.stanford.edu/class/archive/cs/cs107/cs107.1216/lab3](https://web.stanford.edu/class/archive/cs/cs107/cs107.1216/lab3/)
4. Stanford University. CS 107: _More Pointers and Arrays_ (Lecture 6). [web.stanford.edu/class/archive/cs/cs107/cs107.1212/lectures/6/Lecture6.pdf](https://web.stanford.edu/class/archive/cs/cs107/cs107.1212/lectures/6/Lecture6.pdf)
5. MIT OpenCourseWare. 6.004 Computation Structures: _Caches and the Memory Hierarchy_. [ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c14](https://ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c14/)
6. MIT OpenCourseWare. 6.004 Computation Structures: _Virtual Memory_. [ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c16](https://ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c16/)
7. MIT OpenCourseWare. 6.172 Performance Engineering: _Memory Systems and Performance Engineering_. [ocw.mit.edu/courses/6-172-performance-engineering-of-software-systems-fall-2010](https://web.archive.org/web/20120202120920/http://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-172-performance-engineering-of-software-systems-fall-2010/video-lectures/lecture-7-memory-systems-and-performance-engineering/)
8. University of Illinois Urbana-Champaign. CS 225: _Stack and Heap Memory_. [courses.grainger.illinois.edu/cs225/sp2022/resources/stack-heap](https://courses.grainger.illinois.edu/cs225/sp2022/resources/stack-heap/)
9. Technische Universität Darmstadt. _Rechnerorganisation, Kapitel 6: Speicherhierarchie_. [esa.informatik.tu-darmstadt.de](https://www.esa.informatik.tu-darmstadt.de/archive/twiki/pub/Lectures/Rechnerorganisation16DE/RO_Kapitel06kommentiert.pdf)
10. Tanenbaum, A. S. _Modern Operating Systems_, 4. Aufl. Pearson, 2014. Kap. 3: „Memory Management."
11. Drepper, U. _What Every Programmer Should Know About Memory_. Red Hat, Inc., 2007. [akkadia.org/drepper/cpumemory.pdf](https://akkadia.org/drepper/cpumemory.pdf)
12. OWASP Foundation. _Buffer Overflow Attack_. [owasp.org/www-community/attacks/Buffer_overflow_attack](https://owasp.org/www-community/attacks/Buffer_overflow_attack)
13. Preshing, J. _Weak vs. Strong Memory Models_. preshing.com, 2012. [preshing.com/20120930/weak-vs-strong-memory-models](https://preshing.com/20120930/weak-vs-strong-memory-models/)
14. Intel Corporation. _Intel® 64 and IA-32 Architectures Software Developer's Manual_, Vol. 3A. Kap. 4: „Paging." [intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)
