Pointer
Video zum Kapitel

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. ZeigertypT*
- der Wert von
T*
ist die Adresse eines Objektes mit dem TypT
- sei
xp
eine Variable vom TypT*
undx
eine Variable vom TypT
xp = &x
weistxp
die Adresse vonx
zu*xp
dereferenziert den Pointer und bezieht sich aufx
*xp
hat den TypT
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 um1
- bei einem
int*
-Pointer erhöht++
den Wert um4
- bei einem
long*
-Pointer erhöht++
den Wert um8
- …
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++;
}
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*
undunsigned char*
dürfen auf andere Typen zeigen
int i = 99;
float *fp = (float*) &i; /* Fehler!!! */
*fp += 0.7f;
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
ISO C specification §6.3.2.3
/* 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.
/* 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)
odersizeof(x)
bestimmt werden - hat den Datentyp
size_t
(nichtint
!) - wird in Bytes gemessen
Pointer auf Pointer werden in C hauptsächlich für zwei Anwendungsfälle eingesetzt:
- Mehrdimensionale Arrays, also z. B.
char**
für ein Array von Zeichenketten, wie wir es ausargv
kennen. - Ü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ößeelsize
- initialisiert den Speicher mit
0
void*
realloc
(void *ptr, size_t size)
- vergrößert den Speicherblock, auf den
ptr
zeigt auf die Größesize
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 x
alloc
-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 aufNULL
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 vonmalloc()
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 gibtmalloc()
den WertNULL
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 WertNULL
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);
}
0x7ffe463aaa78
0x7ffe463aaa7a
pPsst!