Pointer

Video zum Kapitel

Link zu YouTube

Speicherlayout eines Prozesses

Programme werden vom Betriebssystem als Prozesse ausgeführt. Prozesse sind aus Sicherheitsgründen vollständig voneinander isoliert und haben eigene Ressourcen. Vor allem ist der Speicher von Prozessen vollständig getrennt, d. h. ein Prozess kann nicht auf den Speicher eines anderen Prozesses zugreifen. Prozesse können daher nur über den Umweg des Betriebssystems miteinander in Kontakt treten. Die Technik hierzu nennt man Interprozess-Kommunikation (interprocess communication) IPC. Beispiele für IPC sind Sockets, Pipes und Shared Memory. Wegen der vollständigen Isolation sind Prozesse relativ schwergewichtig, d. h. das Starten und die Verwaltung von Prozessen benötigt vergleichsweise viele Ressourcen.

Innerhalb eines Prozesses gibt es einen oder mehrere Threads, die man sich als Ausführungspfade vorstellen kann. Im Gegensatz zu Prozessen sind Threads leichtgewichtiger, d. h. sie lassen sich mit deutlich weniger Aufwand starten und verwalten. Da Threads innerhalb eines Prozesses laufen, haben sie keinen getrennten Heap, sondern teilen sich den Heap und kommunizieren über diesen miteinander. Der Stack ist aber bei Threads getrennt: Jeder Thread hat seinen eigenen Stack.

Der Speicher eines Prozesses besteht aus sechs Teilen.

Prozess-Speicher besteht aus

  • Text-Segment: Programmcode
  • Daten-Segment: Vorinitialisierte Daten
  • BSS-Segment: Nicht initialisierte Daten
  • Heap-Segment: Dynamisch allozierter Speicher
  • Stack-Segment: Lokale Variablen
  • Programm-Counter: Stelle im Text-Segment, die aktuell ausgeführt wird

Die Aufgaben der Bereiche sind:

  • Text-Segment (feste Größe): Enthält den Programmcode und kann zur Laufzeit nicht beschrieben werden. Dafür ist es als Executable gekennzeichnet, d. h. der Program-Counter darf sich bei Adressen befinden, die im Text-Segment liegen.
  • Daten-Segment (feste Größe): Enthält die globalen, initialisierten Variablen des Programms. Alle globalen Variablen, die einen Wert ungleich 0 haben, liegen im Daten-Segment.
  • BSS-Segment (feste Größe): Enthält den Speicher für die globalen Variablen, die nicht initialisiert wurden.
  • Heap-Segment (dynamische Größe): Dynamisch allozierter Speicher, der von der Programmiererin manuell verwaltet wird (siehe unten).
  • Stack-Segment (dynamische Größe): Dynamisch allozierter Speicher, der für die lokalen Variablen verwendet wird. Das Belegen und Freigeben des Speichers wird automatisch vom Compiler durchgeführt, der Entwickler muss sich nicht darum kümmern. Deswegen werden lokale Variablen in C auch als automatische Variablen bezeichnet.
  • Programm-Counter: Prozessor-Register, das den aktuell auszuführenden Befehl speichert.

Der Trennung in Daten- und BSS-Segment liegt eine Optimierung zugrunde: Damit die ausführbare Datei (Executable) nicht zu groß wird, enthält sie nur das Text- und Daten-Segment sowie eine Information, wie groß das BSS-Segment sein muss. Das BSS-Segment wird dann vom Betriebssystem beim Laden des Programms anhand dieser Information alloziert, das Executable bläht sich somit nicht unnötig auf.

Aus der Zeit des begrenzten Adressraums (32-Bit-Systeme können nur 4 GB an Speicher adressieren), hat sich die Konvention entwickelt, dass der Heap nach oben und der Stack nach unten wächst. So konnte man den Speicher immer maximal ausnutzen. Beim Erzeugen neuer Variablen auf dem Stack, bekommen diese kleinere Adressen, als die vorhergehenden, beim Heap hingegen, wachsen die Adressen an. Obwohl man dies heute bei 64-Bit-Adressen nicht mehr bräuchte, hat sich die Konvention auch auf den 64-Bit-Plattformen gehalten.

Pointer-Typen

Ein zentraler Datentyp in C, den man aus Java nicht kennt, ist der Pointer. Ohne Pointer kann man keine komplexeren C-Programme schreiben, weil Pointer die einzige Möglichkeit sind, an Funktionen übergebene Daten zu modifizieren oder dynamisch allozierten Speicher zu verwalten.

Jetzt gibt es allerdings nicht einen einzigen Typ, der Pointer repräsentiert, sondern es gibt genau so viele Pointer-Typen, wie es Datentypen in C gibt und noch einen mehr (void*), aber dazu später.

  • Jeder Datentyp T hat einen passenden Pointer-Typ bzw. Zeigertyp T*
  • der Wert von T* ist die Adresse eines Objektes mit dem Typ T
  • sei xp eine Variable vom Typ T* und x eine Variable vom Typ T
    • xp = &x weist xp die Adresse von x zu
    • *xp dereferenziert den Pointer und bezieht sich auf x
    • *xp hat den Typ T

Ein Pointer ist – wie der Namen schon sagt – ein Zeiger auf ein Datenobjekt. Pointer sind genauso Variablen, wie andere auch. Der Inhalt der Pointervariable ist die Speicher-Adresse des Datenobjektes, auf das der Pointer zeigt.

Die Adresse eines Datenobjektes erhält man mit dem &-Operator, Adress-Operator. Auf das Datenobjekt hinter dem Pointer greift man mit dem *-Operator zu (Indirektions-Operator). Man spricht hier auch von Dereferenzieren.

Das Beispiel zeigt, wie ein Pointer xp auf das Datenobjekt x benutzt wird, um die Daten in x zu ändern. Die Adresse des Datenobjektes ändert sich nicht, wohl aber sein Inhalt.

int x = 7;
int* xp = &x;

printf("xp=%p\n", xp);   /* xp=0x7fff5302f7ac */
printf("*xp=%d\n", *xp); /* *xp=7 */
printf("x=%d\n", x);     /* x=7 */

*xp = 42;
printf("xp=%p\n", xp);   /* xp=0x7fff5302f7ac */
printf("*xp=%d\n", *xp); /* *xp=42 */
printf("x=%d\n", x);     /* x=42 */

Der Format-Ausdruck %p im printf erlaubt es, die Adresse eines Pointers auszugeben. Der besseren Lesbarkeit wegen verwendet %p eine hexadezimale Darstellung.

Wichtig ist, dass die Typsicherheit von C auch für die Pointer gilt. Das heißt, dass ein Pointer vom Typ T* nur auf Objekte vom Typ T zeigen kann, der Typ des Objektes „färbt“ also auf den Typ des Pointers ab.

Im Prinzip sind C-Pointer nicht groß unterschiedlich zu Objekt-Referenzen in Java. Auch sie zeigen auf ein Objekt und tragen dessen Adresse in sich. Der Unterschied ist allerdings, dass Java-Referenzen die Information zur Speicher-Adresse des Objektes nicht preisgeben und automatisch dereferenziert werden, somit kein *-Operator benötigt wird.

class MyInt {
    // Wir müssen den int in eine Klasse verpacken, weil int
    // ein primitiver Datentyp ist und keine Referenzen erlaubt.
    // Integer scheidet auch aus, weil es immutable ist.
    public int x;

    public MyInt(int x) {
        this.x = x;
    }
}

class Main {
    public static void main(String[] args) {
        MyInt xp = new MyInt(7);
        MyInt xp2 = xp;

        System.out.println("x=" + xp.x);  // 7
        System.out.println("x=" + xp2.x); // 7

        xp.x = 42;
        System.out.println("x=" + xp.x);  // 42
        System.out.println("x=" + xp2.x); // 42
    }
}

Pointer-Arithmetik

Man kann sich C-Pointer wie Java-Referenzen vorstellen. Allerdings erlaubt C, dass man mit Pointern direkt rechnen kann. Hierbei wird einfach die Adresse, die in dem Pointer gespeichert ist, für die Rechenoperation verwendet. Der Compiler muss also für die Pointer-Arithmetik kaum Magie betreiben und fasst den Pointer einfach als Zahlenwert auf.

Die einzige Besonderheit ist, dass der Compiler bei Addition und Subtraktion die Größe des Datentyps berücksichtigt, auf den der Pointer zeigt.

Unter Pointer-Arithmetik versteht man, dass man mit Pointern rechnen kann

  • Pointer können mit == und != verglichen werden
  • Pointer können mit ++ inkrementiert und mit -- dekrementiert werden
  • Integer können zu Pointern addiert (+) oder subtrahiert (-) werden
  • bei den arithmetischen Operationen wird die Größe des Datentyps berücksichtigt, auf den der Pointer zeigt
    • bei einem char*-Pointer erhöht ++ den Wert um 1
    • bei einem int*-Pointer erhöht ++ den Wert um 4
    • bei einem long*-Pointer erhöht ++ den Wert um 8

Das folgende Beispiel zeigt, wie man mithilfe der Pointer-Arithmetik die einzelnen Bytes einer Zahl auslesen kann. Dabei sieht man auch, dass die Zahlen auf Intel-Plattformen im Little-Endian-Format abgelegt sind.

typedef unsigned char byte;
int i = 0xcafebabe;
byte* bp = (byte*) &i;

while (bp < (byte*)&i + 4) {
  printf("%x ", *bp & 0xff);
  bp++;
}
Ausgabe
be ba fe ca

Die Funktionsweise des Programms zu verstehen, kann als kleine Übung in Pointer-Arithmetik verstanden werden. Die Casts auf byte* sind nötig, da man Pointer unterschiedlichen Typs nicht vergleichen und zuweisen darf (siehe unten). Beachten Sie auch, dass es man die while-Schleife auch als while (bp < (byte*)(&i + 1)) { hätte schreiben können – es kommt also sehr auf die Klammern an.

Strict Aliasing Rule

Die Strict Aliasing Rule ist eine Regel, die festlegt, wie Zeiger auf unterschiedliche Datentypen verwendet werden können. Sie wurde eingeführt, um die Optimierungsmöglichkeiten des Compilers zu verbessern.

Sie besagt, dass auf ein Objekt (Variable oder Speicherbereich) nicht über Zeiger eines anderen Typs zugegriffen werden darf, es sei denn, es handelt sich um einen char oder unsigned char-Pointer. Das bedeutet, dass zwei Pointer unterschiedlicher Typen nicht auf dasselbe Objekt zeigen dürfen.

Durch diese Regel kann der Compiler effizienteren Maschinencode generieren, da er sicher sein kann, dass bestimmte Speicherzugriffe keine Seiteneffekte auf andere Objekte haben.

Strict Aliasing Rule

  • Zwei Pointer unterschiedlichen Typs dürfen nicht auf dasselbe Objekt zeigen
  • Ausnahme: char* und unsigned char* dürfen auf andere Typen zeigen
Verboten
int i = 99;
float *fp = (float*) &i; /* Fehler!!! */
*fp += 0.7f;
Erlaubt
int i = 99;
char *cp = (char*) &i; /* OK */
*cp = 5;

Ein Verstoß gegen diese Regel führt zu sogenanntem „undefined behavior“. Auf dieses Konzept und seine Konsequenzen wird in einem späteren Kapitel noch detailliert eingegangen.

Will man Daten zwischen zwei unterschiedlichen Typen austauschen, muss man über die memcpy-Funktion gehen und die Daten umkopieren.

  • Verwendung von memcpy für Konvertierung
int i;
float cp = 42.23;

/* Korrekte Umwandlung mit memcpy */
memcpy(&i, &cp, sizeof(float));

printf("%d\n", i); /* 1109977989 */

Die kopierten Daten dürfen sich bei memcpy nicht überlappen.

void-Pointer

Nicht immer kennt man den Typ des Objektes, auf das ein Pointer zeigt. Deswegen braucht man einen Pointer-Typ, der auf jedes beliebige Objekt zeigen kann, so wie in Java eine Referenz vom Typ Object jedes Objekt referenzieren kann.

Diese Aufgabe übernimmt in C der void*-Pointer.

Void-Pointer void* ist der generische Pointer

  • kann jedem anderen Pointer zugewiesen werden (mit Cast)
  • jeder andere Pointer kann dem void-Pointer zugewiesen werden
  • verhält sich bei der Pointer-Arithmetik wie ein char*-Pointer
int* ip;
void* vp = ip;
sizeof(*vp) == 1;

Mithilfe eines Casts kann man den void*-Pointer einem Pointer eines anderen Pointer-Typs zuweisen.

int i = 7;
void* vp = &i;
int* ip = (int*) vp; /* cast von void* auf int*
printf("%d\n", *ip); /* Ausgabe: 7 */

Eine Variable vom Typ void* kann man nicht dereferenzieren, weil der Compiler den dahinter liegenden Datentype nicht kennen kann. Im obigen Beispiel würde deswegen *vp = 5; zu einem Compilerfehler führen. Nur mit einem entsprechendem Cast kann man dem Compiler die nötige Information geben, z. B. *((int*)vp).

int i = 7;
void* vp = &i;
printf("%d\n", *((int*)vp) ); /* Ausgabe: 7 */

Kommt ein void*-Pointer in einem Ausdruck der Pointer-Arithmetik vor, dann wird er wie ein char*-Pointer betrachtet, sodass z. B. ein ++ die Adresse um 1 erhöht.

char s[] = "Hallo"; /* char[] ist identisch mit char* */
void* vp = s;
printf("%p\n", vp); /* 0x7ffd650d9126 */
vp++;
printf("%p\n", vp); /* 0x7ffd650d9127 */

NULL-Pointer

Welchen Wert hat ein Pointer, der auf nichts zeigt? Java verwendet hier für seine Referenzen den Wert null, C einfach den Zahlenwert 0, da 0 keine gültige Adresse ist. Da aber 0 keine Adresse ist, ist der technisch korrekte Wert des sogenannten Null-Pointers (void*) 0.

Will man ausdrücken, dass ein Pointer auf nichts zeigt, setzt man ihn auf 0 (NULL) → null pointer

An integer constant expression with the value 0, or such an expression cast to type void *, is called a null pointer constant. If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function.
ISO C specification §6.3.2.3
Beispiel: null-Pointer als Zahl
/* null Pointer */
int *px = (void*) 0;

Damit man nicht ständig (void*) 0 schreiben muss, liefert C ein Präprozessormakro NULL, das genau durch diesen Ausdruck ersetzt wird.

Beispiel: null-Pointer über NULL Macro
/* null Pointer mit NULL Macro */
int *px = NULL;

Adresse von Objekten

Wie kommt man an die Adresse eines Datenobjektes, um diese in einem Pointer zu speichern?

  • Adresse eines Datenobjekts mit dem Typ typ, z. B. int x
    • kann über &x geholt werden
    • hat den Datentyp type*, z. B. int*
    • ist 4 Byte (32 Bit-Plattform) oder 8 Byte (64 Bit-Plattform) groß
    • kann selbst wieder über einen Pointer referenziert werden type**, z. B. int**
  • Größe eines Datenobjekts mit dem Typ typ, z. B. int x
    • kann über sizeof(typ) oder sizeof(x) bestimmt werden
    • hat den Datentyp size_t (nicht int!)
    • wird in Bytes gemessen

Pointer auf Pointer werden in C hauptsächlich für zwei Anwendungsfälle eingesetzt:

  1. Mehrdimensionale Arrays, also z. B. char** für ein Array von Zeichenketten, wie wir es aus argvkennen.
  2. Übergabe von Pointern an Funktionen, die in der Funktion verändert werden sollen (pass-by-value).

Das folgende Beispiel zeigt die Verwendung von einem Pointer auf einen Pointer. Hier wird der Parameter mem in der Funktion my_malloc geschrieben und muss deshalb, wegen des Pass-By-Value in C, als int** übergeben werden. Die Fehlerbehandlung ist weggelassen.

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

void my_malloc(int** mem, size_t num) {
    *mem = malloc(num * sizeof(int));
}

int main(int argc, char** argv) {
    int* array;
    my_malloc(&array, 10); /* error handling fehlt */
    array[0] = 23;
    array[1] = 42;
    printf("%d %d\n", array[0], array[1]); /* -> 23 42 */
    free(array);
}

Die Funktion sizeof() liefert einen Wert vom Typ size_t, weil dieser Typ speziell dafür entworfen wurde, die Größe von Objekten in Byte zu repräsentieren und dabei die Anforderungen der Plattform zu berücksichtigen. Es ist ein vorzeichenloser Ganzzahltyp, der so dimensioniert ist, dass er groß genug ist, um die größte mögliche Größe eines Objekts auf der Plattform zu speichern. int wäre zum eine vorzeichenbehaftet und zum anderen zu klein für 64 Bit-Plattformen.

Dynamische Speicherverwaltung

In C muss sich die Entwicklerin selbst um die Verwaltung des Speichers kümmern. Dazu bietet die Sprache in der Standardbibliothek eine Reihe von Funktionen an, um Speicher zu allozieren und wieder freizugeben.

  • void* malloc (size_t size)
    • alloziert einen Speicherblock mit size Bytes
    • Speicher wird nicht initialisiert
  • void* calloc (size_t n, size_t elsize)
    • alloziert Speicher für ein Array mit n Elementen der Größe elsize
    • initialisiert den Speicher mit 0
  • void* realloc (void *ptr, size_t size)
    • vergrößert den Speicherblock, auf den ptr zeigt auf die Größe size Bytes
    • gibt einen Pointer zurück (kann andere Adresse sein als ptr)
    • kopiert den Inhalt des Blocks, auf den ptr zeigt um
  • void free (void *ptr);
    • gibt den Speicher frei, auf den ptr zeigt
    • Speicher muss mit einer der xalloc-Funktionen alloziert worden sein
    • darf nur genau ein mal pro ptr aufgerufen werden
    • darf mit NULL aufgerufen werden

Wenn man Speicher mit malloc anfordert, muss man den Speicher danach selbst initialisieren. Hierzu dient die Funktion memset. Bei calloc kann man sich das ersparen, da die Funktion den Speicher selbständig initialisiert. Hier ist aber zu beachten, dass man nicht die Größe des gewünschten Speichers in Byte angibt, sondern die Anzahl der Elemente mit einer bestimmten Größe. Wenn man die Größe auf 1 setzt, kann man natürlich auch wieder die Größe in Bytes angeben.

Bei realloc muss man immer daran denken, dass man einen komplett neuen Speicherbereich bekommen kann, und nicht der alte vergrößert wurde. D. h. man muss die zurückgegebene Adresse für weitere Zugriffe verwenden.

C ist eine Programmiersprache, die keine Fehler verzeiht und keinerlei Sicherheitsnetz bietet. Im Gegenzug läuft das Programm mit maximaler Performance, weil keine komplexen Laufzeitprüfungen stattfinden.

Todsünden im Umgang mit Pointern

  • Zuweisung an einen Pointer, der auf kein Objekt zeigt
    int* p;
    *p = 7; /* schreibt irgendwo in den Speicher */
  • Speicher mit malloc() allozieren und nicht wieder freigeben
    (⇒ Memory-Leak)
  • Rückgabewert von malloc() nicht auf NULL prüfen
  • vor oder hinter den allozierten Speicher greifen
  • Speicher mehr als einmal mit free() freigeben
    Double Free
  • Zugriff auf Speicher, nachdem er mit free() freigegeben wurde
    Use after Free
  • Pointer auf Stack-Speicher aus Funktion herausgeben
    int a[] = { 1, 2, 3 };
    return a;

Jedes dieser Probleme kann schwerwiegende Sicherheitslücken aufreißen oder das Programm sporadisch zum Absturz bringen. Fehler, die durch das Memory-Management entstehen sind schwer zu finden und können lange unbemerkt bleiben.

  • Memory-Leaks verbrauchen langsam den gesamten verfügbaren Speicher, bis das Programm abstürzt. Es gibt C-Programme, die lange Zeit laufen und dann plötzlich wegen Speichermangel crashen. Insbesondere für langlaufende Server-Prozesse ist dies ein großes Problem. Da sich Memory-Leaks nur im laufenden Betrieb und nach einiger Zeit zeigen, sind sie schwer zu finden.
  • malloc()-Rückgabewert nicht prüfen: Es gibt Entwickler, die glauben, dass ein Aufruf von malloc() immer erfolgreich ist (malloc never fails). Dies muss aber nicht so sein, da der Speicher ausgehen oder der angeforderte Speicherblock zu groß sein könnte. In diesem Fall gibt malloc() den Wert NULL zurück und das Programm sollte sich im Normalfall sauber beendet. Prüft es den Rückgabewert aber nicht, verwendet es einen Zeiger mit dem Wert NULL für seine weiteren Operationen, was zu unvorhersagbaren Problemen führen kann.
  • Vor- oder hinter den Speicher greifen: Über malloc() allozierter Speicher liegt irgendwo im RAM des Computers. Direkt davor oder dahinter können sich andere Daten befinden oder Verwaltungsstrukturen des Heaps. Es ist ebenso denkbar, dass der Speicher überhaupt nicht beim Betriebssystem angefordert wurde und ein Segmentation Fault ausgelöst wird. In jedem Fall kann man viele Probleme provozieren, wenn man auf Adressen zugreift, die einem nicht gehören. Von Abstürzen bis ernsthaften Sicherheitsmängeln ist alles denkbar.
  • Speicher mehrmals freigeben (double free): Wenn Speicher mit free() freigegeben wird, dann werden entsprechenden Verwaltungsinformationen im Heap aktualisiert. Gibt man den Speicher dann erneut frei, ist vollkommen undefiniert, was dann passiert. Absturz, Sicherheitsloch… lassen Sie sich überraschen.
  • Zugriff nach Freigabe (use after free): Nachdem der Speicher mit free() freigegeben wurde kann er an das Betriebssystem zurückgegeben worden sein, erneut vergeben worden sein oder immer noch frei. Benutzen darf man ihn auf jeden Fall nicht mehr.
  • Pointer auf Stack-Speicher aus Funktion herausgeben (pointer to stack memory): Daten auf dem Stack werden nach dem Zurückkehren der Funktion automatisch freigegeben. Tatsächlich werden die Daten aber aus Performance-Gründen nicht überschrieben, sondern stehen weiterhin im Hauptspeicher. Deswegen kann es sein, dass ein Pointer auf den Stack einer bereits beendeten Funktion noch eine Weile sinnvolle Daten liefert, und zwar so lange, bis dieser Stackspeicher von einem anderen Funktionsaufruf überschrieben wurde. Definiert ist das Verhalten hier aber nicht.

Für den letzten Fall hier ein Beispiel, mit clang 7 compiliert. Die Ausgabe hängt davon ab, wie der Compiler das Stack-Layout durchführt.

#include <stdio.h>

void secret(void) {
    char secret[6] = { 'P', 's', 's', 't', '!', '\0' };
    printf("%p\n", secret);
}

char* no_secret(void) {
    long l = 0;
    return (char*) &l;
}

int main(int argc, char** argv) {
    char* s = no_secret();
    printf("%p\n", s);
    secret();
    printf("%s\n", s);
}
Ausgabe
0x7ffe463aaa78
0x7ffe463aaa7a
pPsst!

Copyright © 2025 Thomas Smits