Programmieren in C/C++: Zeiger


Grundlagen

Bearbeiten

Die Nutzung von Zeiger bedeutet, mit Speicheradressen zu hantieren und basierend auf diesen Adressen den dazugehörigen Speicherinhalt zu lesen/schreiben. Für ein besseres Verständnis, wie ein Prozessor funktioniert und was alles im Speicher 'abgebildet' ist, wird auf das Einführungskapitel Einführung/Literatur:Alles ist Speicher verwiesen.

Speicherauszug

Bearbeiten

Über einen Speicherauszug (engl. Dump) (siehe   dump) kann man sich den Speicherinhalt eines Adressbereiches ausgeben lassen. Hier ist für aufeinanderfolgende Speicheradressen der Speicherinhalt dargestellt:

 
Beispiel eines Speicherauszuges (Dump)

Der Dump beginnt mit der in hexadezimaler Schreibweise angegebenen Speicheradresse (ggf. gefolgt durch einen Doppelpunkt). Anschließend folgt der Speicherinhalt. Aufgrund der Tatsache, dass der Speicher byteweise organisiert ist, wird der Speicherinhalt als 8-Bit Wert in hexadezimaler Schreibweise ausgegeben. Optional gefolgt von der Interpretation des Speicherinhaltes als ASCII-Zeichen.

 
Beispiel eines Speicherauszuges (Dump)

Zur Platzeinsparung wird oftmals nicht nur der Inhalt einer Speicheradresse, sondern mehrerer Speicheradressen angegeben. Der linke Speicherinhalt entspricht dann dem Inhalt der angegebenen Speicheradresse. Der nächste Speicherinhalt dem Inhalt der Speicheradresse + 1, usw. .
Wenn die Variablen vom Datentyp integer sind, bietet es sich an, anstatt den Speicherinhalt von einer Speicherstelle den Inhalt von 4 Speicherstellen auszulesen und als eine 32-Bit Zahl auszugeben. Die Interpretation der 4 Speicherstellen hängt dabei von der Endianness der Rechnerarchitektur ab (hier Little Endian, bei welcher 7B den niederwertigen Teil der 32-Bit Zahl darstellt).

Hinweis:

  • Bei Speicheradressen werden zumeist die führenden Nullen nicht angegeben. Egal mit oder ohne führenden Nullen, die Speicheradresse besteht aus so viel Bitstellen, wie die Rechnerarchitektur adressieren kann
  • Bei grafischer Darstellung von Speicherinhalten wird ggf. der Speicher beginnend von der größten Speicheradresse hinabsteigend zur kleinsten Speicheradresse dargestellt

Speicherorganisation von Variablen

Bearbeiten

Für jede Variable (und für jede Funktion) wird Speicher reserviert. Der Compiler ordnet im Source-Code hintereinander definierten Variablen (des identischen Gültigkeitsbereiches) aufeinanderfolgenden Speicheradressen zu:

//Globale Variablen
int arr[3]={123,456,789};       //Alle 3 Arrayelemente liegen im Speicher
                                //direkt hintereinander
int var1=1,var2=2;              //Alle 3 Variablen liegen im Speicher direkt
int var3=3;                     //hintereinander.
int *ptr=&var2;                 //Auch für die Zeigervariable wird Speicher
                                //reserviert.
printf("Adresse von arr[0]=%p\n"
       "            arr[1]=%p\n"
       "            arr[2]=%p\n",&arr[0],&arr[1],&arr[2]);
printf("Adresse von var1  =%p\n"
       "            var2  =%p\n"
       "            var3  =%p\n",&var1,&var2,&var3);
printf("Adresse von ptr   =%p\n",&ptr);

Öffnen im Compiler Explorer

Ausgabe:

Adresse von arr[0]=0x00000000404040
            arr[1]=0x00000000404044
            arr[2]=0x00000000404048
Adresse von var1  =0x0000000040404c
            var2  =0x00000000404050
            var3  =0x00000000404054

Schaut man sich über den Speicherdump den Inhalt der Speicheradressen der obigen Variablen an, so erkennt man, dass der Speicher der globalen Variablen mit deren Initialisierungswerten vorbelegt ist. Integervariablen belegen 4-Byte Speicher, so dass die Endianness beim Interpretieren des Speicherinhaltes zu berücksichtigen ist (bei Little Endian steht in der kleineren Speicheradresse das niederwertige und in der höchsten Speicheradresse der Variable das hochwertige Byte):

 
Erklärung anhand eines Speicherauszuges (je Adresse ein Inhalt), wie Variablen im Speicher angeordnet werden

Werden nicht nur 1 Byte je Speicherstelle angezeigt, so kann der Speicherinhalt mehrerer Variablen übersichtlich dargestellt werden:

 
Erklärung anhand eines Speicherauszuges, wie Variablen im Speicher angeordnet werden

Hinweis:

  • Bei Rechnerarchitekturen mit ausgerichteter Speicherausrichtung (Alignment) können zwischen den Variablen noch Füllbytes vorhanden sein

Speicherorganisation unterschiedlicher Gültigkeitsbereiche

Bearbeiten

Für die einzelnen Gültigkeitsbereiche werden im Speicher (und auch in der Objekt-Datei) unterschiedliche Speicherbereiche (Segmente) vorgehalten, d.h. der Compiler teilt den Speicher in unterschiedliche Bereiche auf und weist den Variablen/Funktionen in Abhängigkeit der Gültigkeit/Sichtbarkeit unterschiedliche Speicherbereiche zu (siehe Grundlagen:Speicherzuweisung)! Diese unterschiedlichen Speicherbereiche sind gut an den Startadressen der Variablen erkennbar:

//Globale initialisierte Variablen
int var1=1,var2=2;
int var3=3;
//Globale nicht initialisierte Variablen
int bss1,bss2; int bss3;
//Globale Konstanten
const int con1=4,con2=5;
const int con3=6;
	
//Lokale Variablen
void foo(int par1,int par2) {
  int lok1=7,lok2=8;
  int lok3=9;
}

Öffnen im Compiler Explorer

Die Speicherverwaltung erfolgt unter bspw. Windows, Linux, macOS über die Memory Management Unit (MMU) (siehe   Memory Management Unit). Diese blendet für jeden Prozess einzelne Speicherbereiche aus dem physikalischen Speicher als virtuellen Speicher ein. Jedem Prozess kann auf dieser Basis der gesamte vom Prozessor adressierbare (virtuelle) Bereich zur Verfügung gestellt werden. Diese Zuordnung zwischen physikalischen- und virtuellen Speicher erfolgt jedoch nicht Byteweise, sondern auf Basis von Pages mit einer Größe von typischerweise 4-KiByte und z.T. 16-KiByte.
Ergänzend zu dieser Zuordnung können für jede virtuelle Page Zugriffsrechte gesetzt werden. 'Normale' Variablen erhalten die Zugriffsrechte 'Read' und 'Write'. Der ausführbare Code erhält die Zugriffsrechte 'Executeable' und ggf. 'Lesend'. Bei Konstanten wird nur das Zugriffsrecht 'Read' gesetzt.
Zur Speichereinsparung werden jedem Prozess nur so viele Pages zugeteilt, wie dieser tatsächlich benötigt. Bei bspw. einem Programm mit 9-KiByte Maschinencode, 5-KiByte Variablen und 1-KiByte Konstanten werden dem Prozess 3 Pages für den Maschinencode, 2 Pages für globale Variablen und eine Page für Konstanten zugeteilt. Mit dem Start des Programm fordert das Programm weitere Pages für den Stack und den Heap an.

Greift nun das Programm auf dem den Prozess nicht zugeteilte Pages zu, so erkennt dies die MMU und veranlasst über das Betriebssystem das Beenden des Prozesses. Dazu wird dem Prozess das Signal 'SIGSEGV:Segmentation violation' (siehe   Signal (Unix)) gesendet, welches zum Beenden des Prozesses führt. Identisches gilt, wenn die Zugriffsrechte der Pages missachtet werden.

Adress-Operator

Bearbeiten

Mit der Definition einer Variablen wird in Abhängigkeit des Datentyps Speicher reserviert. Der Variablenname zeigt auf die erste reservierte Speicherstelle dieser Variable. Die Variablenname als solches entspricht folglich einer Adresse, ab der sich der Inhalt der Variablen befindet. Die Speicheradresse kann mit dem Adress-Operator (Syntax: &Variablenname/&Funktionsname) ermittelt werden:

int var1=7;
printf("Adresse von var1=0x%zx/%p Inhalt von var1=%d\n",&var1,&var1,var1);
	
#define WERT 7        //Wert ist keine Variable, so dass hiervon 
                      //keine Adresse ermittelt werden kann  
printf("Adresse von WERT=----/---- Inhalt von WERT=%d\n",WERT);

struct {int x,y,z;} var2={47,11,12};
printf("Adresse von var2=0x%zx/%p Inhalt von var2=%d/%d/%d\n",&var2,&var2,var2.x,var2.y,var2.z);

union  {char a;short b;int c;} var3={47};
printf("Adresse von var3=0x%zx/%p Inhalt von var3=%d\n",&var3,&var3,var3.a);

enum {ROT,GRUEN,BLAU} var4=GRUEN;
printf("Adresse von var4=0x%zx/%p Inhalt von var4=%d\n",&var4,&var4,var4);

printf("Adresse von func=0x%zx/%p Inhalt von func=%#x/...\n",&func,&func,*(uint32_t *)&func);

Öffnen im Compiler Explorer

Der Wertebereich einer Adresse (und damit die Speichergröße der Speicheradresse) ist unabhängig vom zugrundeliegenden Datentyp, jedoch abhängig von der Adressierungsbereite der Rechnerarchitektur: 2-Byte (bei 16-Bit Rechnerarchitekturen), 4-Byte (bei 32-Bit Rechnerarchitekturen) und 8-Byte (bei 64-Bit Rechnerarchitekturen).
Der Adress-Operator gibt die Startadresse und den Datentype, auf den die Adresse zeigt, zurück (der Datentyp eines Zeigers ist durch ein an den Grunddatentyp angehängtes '*' gekennzeichnet):

int   var1;                 //Startadresse 0x404028
short var2;                 //Startadresse 0x40402c 
short var3;                 //Startadresse 0x40402e 
struct xyz {int x,y;} var4; //Startadresse 0x404030  
int arr[2];                 //Startadresse 0x404038
&var1    //entspricht:   (int *)0x404028 
&var2    //entspricht:   (short *)0x40402c 
&var3    //entspricht:   (short *)0x40402e 
&var4    //entspricht:   (struct xyz *)0x404030 
&var4.x  //entspricht:   (int *)0x404030 
&var4.y  //entspricht:   (int *)0x404034 
arr      //entspricht:   (int *)0x404038  //Ausnahme Array!

Öffnen im Compiler Explorer

Programmtechnisch wird einzig die Adresse gespeichert. Der Datentyp wird nur vom Compiler beim Dereferenzieren und zur Darstellung der 'Typsicherheit' verwendet:

  • Eine Adresse kann nur in einer zu ihrem Datentyp gehörenden Zeigervariablen gespeichert werden
  • Bei einer Dereferenzierung wird über den Datentyp bestimmt, wieviel Bytes gelesen/geschrieben werden sollen und wie diese Daten zu interpretieren sind
  • Ein Vergleich mit einer anderen Adresse kann nur erfolgen, wenn beide Adressen auf den identischen Datentyp verweisen
  • ...

Zeigervariable/Zeiger

Bearbeiten

Eine Zeigervariable (Pointer) (Syntax: Datentype * Zeigervariablenname) dient dazu, eine Adresse aufzunehmen/zu speichern. Sie entspricht einer normalen Variablen, wobei deren Variableninhalt eine Adresse darstellt. Der Datentype der Zeigervariablen sagt aus, wie der Inhalt des Speichers ab der in der Zeigervariablen enthaltenen Adresse zu interpretieren ist. D.h. bei Zeigervariablen auf einen Char (char *) ist der Inhalt der Speicherstelle (auf den der Zeiger zeigt) als Char zu interpretieren:

int var1,var2;
int *ptr1;              //Zeigervariable ptr1 vom Datentype (int *)
int *ptr1a,var1a,*ptr1b,*(ptr1c);  //Integervariable var1a
                        //Zeigervariable ptr1a,ptr1b,ptr1c vom Datentype (int *)
                        //'*' wirkt nur auf Variable rechts vom *

ptr1=&var1;             //Zuweisung eines Pointers mit einer Adresse
int *ptr2=&var2;        //Zuweisung eines Pointers als Initialisierungswert
int var2a,*ptr2a=&var2a;//OK: var2a ist zum Zeitpunkt der Adressabfrage 
                        //    bereits definiert (Single Pass Compiler)
int *ptr2b=&var2b,var2b;//KO: var2b ist zum Zeitpunkt der Adressabfrage
                        //    nocht nicht definiert --> Fehler
int *ptr3a=(int *)100;  //OK: Konstante 100 wird über Explit Cast zu einer
                        //    Adresse gewandelt
int *ptr3b=100;         //KO: Datentypkonflikt: (int *)=(int)
                        //    d.h. einer Zeigervariablen kann nur
                        //    ein Wert eines identischen Datentyps
                        //    zugewiesen werden!
                        //    zugewiesen werden
int *ptr4=&7;           //KO: Eine Konstante hat keine Adresse
                        //    Eine Adresse kann nur von einem LVALUE 
                        //    bestimmt werden

char* ptr5,var5;         
ptr1=ptr2;              //OK: (int *) = (int *)
                        //    ptr1 und ptr2 sind vom identischen Datentyp
ptr2=ptr5;              //KO: (int *) = (char *)
                        //    ptr2 und ptr5 sind vom unterschiedl. Datentypen

char str[]="Hallo";
char *start1=str;       //OK: (char*) = (char[]) = (char *)
                        //    start1 und str sind vom identischen Datentyp
char *ende=&str[strlen(str)-1]; //OK: (char *) = (char *)

Öffnen im Compiler Explorer

Der Datentyp wird wie beim Adress-Operator nur vom Compiler genutzt, jedoch nicht in der Variable gespeichert!

Ein Zeiger ist ähnlich einer Java-Referenz, nur dass:

  • eine Zeigervariable explizit mit dem zum Variablennamen vorangestellten * definiert werden muss
  • Operationen mit den Zeigervariablen erlaubt sind (siehe Zeigerarithmetik)
  • Bei der Dereferenzierung nicht geprüft wird, ob die Speicheradresse gültig ist (damit einhergehend die große Fehlergefahr beim Umgang mit Zeigern)
  • Der Speicher nicht durch die Laufzeitumgebung defragmentiert (verschoben) werden kann, da andernfalls alle Zeigervariablen, die auf diesen Speicherbereich zeigen ebenfalls angepasst werden müssten (Referenzen werden in diesem Fall angepasst)

Dereferenzierung

Bearbeiten

Über die Dereferenzierung (Syntax: * Zeigervariable oder * Adresse) wird auf den Inhalt der Speicheradresse zugegriffen, auf welche der Zeiger/die Zeigervariable zeigt. Hier wird zunächst der Inhalt der Zeigervariablen gelesen und dieser Inhalt als Startadresse zum Lesen/Schreiben des eigentlichen Inhaltes aus dem Speicher genutzt (= Indirekte Adressierung). Der zugrundeliegende Datentyp der Zeigervariablen gibt an, wie viel Bytes ab dieser Startadresse gelesen werden sollen und wie der Inhalt zu interpretieren ist.

 
Erklärung der Funktionsweise der Dereferenzierung

Mit *ptr=1; wird der Zuweisungswert 1 in den Speicherbereich geschrieben, auf welcher die Zeigervariable ptr zeigt. Die Zeigervariable ist vom Datentyp (int *), so dass aufgrund des zugrundeliegenden Datentyps (int) 4 Bytes geschrieben werden. Die Konstante selbst ist ebenfalls vom Datentyp (int).
Beim umgedrehten Fall var2=*ptr; (in der Grafik nicht dargestellt), würde als Zuweisungswert für var2 zunächst der Inhalt des Speicherbereiches gelesen, auf den die Zeigervariable ptr zeigt. Aufgrund des zugrundeliegenden Datentyps (int) der Zeigervariablen würden 4 Byte gelesen werden und diese als Integer interpretiert werden.
In ptr=&var2 wird keine Dereferenzierung verwendet (kein * auf eine Zeigervariable oder Adresse angewendet). Hier erfolgt eine normale Zuweisung der Zeigervariablen ptr mit der Adresse der Variablen var2. Die Zeigervariable ist vom Datentyp (int *) und der Adress-Operator erzeugt aus dem Integerdatentyp var2 einen Zeiger auf Integer (int *), so dass L-Value und R-Value vom identischen Datentyps sind.

Dereferenzierung aus Programmierersicht:

int  var1;            //(mögliche) Startadresse 0x100
int *ptr1=&var1;      //Startadresse 0x104
                      //Initialisierung dieser Variable mit
                      //  der Adresse von var1 (hier 0x100)
var1=7;               //Direktes Schreiben des Wertes 7 in var, d.h.
                      //  Schreiben von 7 in die Adresse ab 0x100
*ptr1=7;              //Indirektes Schreiben des Wertes 7 in var
                      //  d.h. Schreiben des Wertes 7 in die
                      //  Speicheradresse, auf die Variable ptr1
                      //  zeigt
//Bei Strukturen
struct xy{int x,y;};
struct xy var2;       //(mögliche) Startadresse 0x10C
struct xy var3;       //Startadresse 0x114
struct xy *ptr2;      //Startadresse 0x11C
ptr2=&var2;           //Startadresse der Struktur zuweisen
var3=var2;            //Direktes Lesen/Schreiben (8-Bytes)
var3=*ptr2;           //Indirektes Lesen (8-Bytes)
*ptr2=var3;           //Indirektes Schreiben (8-Bytes)
	
//Bei Strukturelementen
var2.x=1; var2.y=1;   //Direktes schreiben auf Strukturelemente
(*ptr2).x=2;          //Indirektes Schreiben über Dereferenzierung
(*ptr2).y=2;             
ptr2->x=3;            //Indirektes Schreiben über Dereferenzierung
ptr2->y=3;            //  durch Nutzung des '->' Operators
	
//Sonstiges
int *ptr3=&var2.y;    //Adresse eines Strukturelementes speichern
*ptr3= 4711;          //Inhalt des Strukturelementes ändern

Öffnen im Compiler Explorer

Hinweis:

  • Der -> Operator, anwendet auf einen Zeiger auf eine Struktur/Union und der Beschreibung eines Struktur-/Unionelementes entspricht der Dereferenzierung des Zeigers an den gegebenen Offset (des Struktur-/Unionelemntes).

Sichtweise von Zeiger auf Basis des Datentyps

Bearbeiten

Mittels des Adress-Operators wird aus dem Datentyp ein Zeiger. Aus Sichtweise des Datentyps wird dem Datentyp ein * zugefügt:

int var1;      //aus Datentypsicht  (int)
    &var1      //aus Datentypsicht &(int) → (int *)
int *ptr1;     //aus Datentypsicht  (int *)
    &ptr1      //aus Datentypsicht &(int *) → (int **)
int **ptr2;    //aus Datentypsicht  (int **)
     &ptr2     //aus Datentypsicht &(int **) → (int ***)
//Doppelte Anwendung des Adress-Operators
     &&ptr1    //aus Datentypsicht &(&(int *)) → &(Konstanten) → KO
               //da von einer Konstanten keine Adresse gebildet werden kann

Mit des Dereferenzierung wird der Inhalt des Datentyps gelesen. Aus Sichtweise des Datentyps wird ein * vom Datentyp weggenommen:

int var;           //aus Datentypsicht   (int)
int *ptr1=&var;    //aus Datentypsicht   (int *)
    *ptr1          //aus Datentypsicht  *(int *) → (int)

int **ptr2=&ptr1;  //aus Datentypsicht    (int **)
     *ptr2         //aus Datentypsicht   *(int **) → (int *)
    **ptr2         //aus Datentypsicht  **(int **) → (int)
//Sonstiges
   *var            //aus Datentypsicht   *(int) → KO
    //da Dereferenzierung angewendet auf Ganzzahl oder Gleitkommazahl dem
    //Multiplikatoroperator entspricht!

Der resultierende Datentyp ist anschließend die Grundlage für den weiteren Umgang mit diesem Ausdruck.

Genauso, wie sich eine Division durch eine Multiplikation 'aufheben' lässt, hebt auch eine Dereferenzierung den Adress-Operator auf:

int var;
*&var=7;      //OK 
//aus Datentypsicht *(&(int)) → *((int *)) → (int)

&*var=9;      //KO
//aus Datentypsicht &(*(int)) 
//Das * wird in diesem Fall als Multiplikationsoperator angesehen

Weitere Beispiele:

struct xyz {int x,y,z;};
struct xyz  var;   //aus Datentypsicht  (struct xyz)
struct xyz *ptr;   //aus Datentypsicht  (sruct xyz *)

  *ptr             //aus Datentypsicht *(struct xyz *) → (struct xyz)
  &ptr             //aus Datentypsicht &(struct xyz *) → (struct xyz **)
   var.x           //aus Datentypsicht  (int)
  &var.x           //aus Datentypsicht &(int) → (int *)

Zuweisung und explizites Cast

Bearbeiten

Bei der Zuweisung von Zeigern müssen beide Zeiger vom identischen Datentyp sein. Der Compiler führt keine implizite Typumwandlung durch, wenn die Zeiger auf unterschiedliche Datentypen zeigen (Ausnahme siehe Void-Zeiger). Bei Inkompatibilität gibt der Compiler entweder einen Error oder eine Warning unter Angabe der inkompatiblen Datentypen aus (Warning in diesem Fall bitte als Fehler ansehen).

char         varc,*ptrc;
short        vars,*ptrs;
int          vari,*ptri;
unsigned int varu,*ptru;
ptrc=&varc;         //OK (char *)=(char *)
ptrc=&vars;         //KO (char *)=(short *) *1)
ptrc=&vari;         //KO (char *)=(int *)   *1)
ptrc=ptrs;          //KO (char *)=(short *)
ptrc=ptri;          //KO (char *)=(int *)
 
ptru=&vari;         //KO (unsigned int *)=(  signed int *)
ptri=&varu;         //KO (  signed int *)=(unsigned int *)

Öffnen im Compiler Explorer

Über explizite Typkonvertierung (Cast Operator) kann ein Zeiger in einen anderen Zeiger gecastet werden. Es erfolgt dabei keine Konvertierung des Inhaltes, auf den der Zeiger zeigt, sondern nur eine andere Interpretation, wie der Inhalt der Speicheradresse zu interpretieren ist:

int    vari=0x01234567;
short  vars=0x89AB;
char   varc1=0xCD,varc2=0xEF;
int   *ptri=&vari;
short *ptrs=&vars;
char  *ptrc=&varc1;
dump(&vari,32,8,DUMP_8A);

ptri=&vari;           //OK (int   *)= (int *)
ptri=(int *)&vari;    //OK (int   *)=((int *))(int *)=(int *)
ptrs=&vari;           //KO (short *)= (int *)
ptrs=(short *)&vari;  //OK (short *)=((short *))(int *)=(short *)
ptrc=(char  *)&vari;  //OK (char  *)=((char  *))(int *)=(char  *)
printf("%x"  ,*ptri); //0x01234567
printf("%hx" ,*ptrs); //0x4567 *1)
printf("%hhx",*ptrc); //0x67   *2)
	
ptri=&vars;           //KO (int *)= (short *)
ptri=(int *)&vars;    //OK (int *)=((int  *))(short *)
printf("%x",*ptri);   //0xefcd89ab;  *3)

ptri=ptrc;            //KO (int  *)= (char *)
ptri=(int  *)ptrc;    //OK (int  *)=((int *)) (char *)
ptrc=(char *)ptri;    //OK (char *)=((char *))(int  *)
	
//Ausgabe
//Speicherdump: 0x404040 .. 0x40405f Mode=8-Bit  
//0x00000000404040: 67 45 23 01 ab 89 cd ef - gE#
//0x00000000404048: 40 40 40 00 00 00 00 00 - @@@
//0x00000000404050: 44 40 40 00 00 00 00 00 - D@@
//0x00000000404058: 46 40 40 00 00 00 00 00 - F@@

Öffnen im Compiler Explorer

Im obigen Beispiel belegt die Integervariable den Speicherbereich 0x404040..0x404043. Aufgrund der Little Endian Kodierung des Prozessors wird das niederwertige Byte zuerst im Speicher abgelegt. Die nächste freie Speicheradresse ist 0x404044, so dass die Shortvariable diese und die nachfolgende Adresse 0x404045 zugewiesen bekommt. Nachfolgende zwei Speicheradresse werden den beiden Charvariablen varc1 und varc2 zugewiesen. ptri, ptrs und ptrc werden im Anschluss abgelegt und belegen jeweils 8 Byte Speicher. Diese sind mit der Startadresse der jeweiligen Variablen initialisiert.
1) Mit ptrs=(short *)&vari; wird zunächst die Adresse von vari (0x404040) vom Datentyp (int *) geladen. Da dieser inkompatibel zum Zieldatentyp (short *) ist, wieder dieser per explizitem Cast in den korrekten Zieldatentyp gewandelt. Die Adresse wird dabei nicht verändert. Beim Dereferenzieren des Pointers mittels *ptrs wird nun zunächst die Speicheradresse aus der Variablen ptrs geladen (0x404040). Da der Zeiger vom Datentyp (short *) ist, wird aufgrund des zugrundeliegenden Datentyps ab dieser Adresse 2 Bytes gelesen. Der Inhalt 0x67 der kleineren Adresse bildet den niederwertigen Teil des 16-Bit Datenwertes und der Inhalt 0x45 der höheren Adresse den höherwertigen Teil, so dass der ausgelesen Wert 0x4567 ist. Da bei variadischen Funktionen keine Parameter kleiner als integer übergeben werden können, wandelt der C-Compiler diesen short Wert in einen int Wert um (0x00004567). Mit der Formatanweisung %hx wird printf gesagt, dass von diesem Integerwert nur die unteren 16-Bit zu nutzen sind.
2) Auch hier wird mit ptrc=(char *)&vari; die Adresse von vari geladen und der Integer-Zeiger auf einen Char-Zeiger gecastet. Beim Dereferenzieren mit *ptrc wird ab der Startadresse aufgrund des zugrundeliegenden Datentyps char nur eine Speicherstelle gelesen, so dass sich 0x67 ergibt. Dieser Wert wird aufgrund der variadischen Funktion in einen Integer gewandelt (0x00000067) und mit %hhx nur die unteren 8-Bit des 32-Bit Wertes ausgegeben.
3) Aufgrund ptri=(int *)&vars wird die Startadresse der Short-Variablen vars (0x404044) in eine Integer-Zeiger gewandelt. Mit *ptri wird ab der Adresse in ptri aufgrund des zugrundeliegenden Datentyps 4 Byte gelesen. Der Wert in der Speicherstelle 0x404044 mit 0xab stellt dabei die niederwertigsten 8-Bit und der Wert an der Speicherstelle 0x404047 mit 0xef die höchstwertigsten 8-Bit dar. Die Dereferenzierung ergibt somit 0xefcd89ab. Vorhandene 'Variablengrenzen' spielen folglich beim Dereferenzieren keine Rolle mehr!

Für den Anwendungsfall, dass die Adressvergabe von Variablen/Funktionen nicht durch die Toolchain, sondern durch den Programmierer erfolgt (z.B. für den Peripheriezugriff oder der Interprozess-Kommunikation), können Ganzzahlvariablen/-konstanten zu Zeiger gecastet werden (und umgedreht). In diesem Fall gilt es, die Datenbreite des Zeigers und die Datenbreite des Ganzzahldatentyps zu beachten:

char         var1,*ptr1;
int          var2;
long long    var3;
ptr1=(char *)0x100;   //OK, wobei der Compiler die Integer-konstante 0x100
                      //bei einer 64-Bit Rechnerarchitektur zu einer 
                      //LongLong-Konstante erweitert.
ptr1=(char *)var1;    //Compilerwarning, da die Charvariable in einem
                      //64-Bit zu 'kurz' ist.
ptr1=(char *)var2;    //Compilerwarning, sie zuvor
ptr1=(char *)var3;    //OK, var3 ist eine 64-Bit Variable, welche
                      //unproblematisch eine Adresse beinhalten kann

float        varf;
ptr1=(char *)varf;    //KO Es können nur Ganzzahlkonstanten konvertiert
                      //   werden

Öffnen im Compiler Explorer

Für diesen Sonderfall stellt C/C++ den Datentyp uintptr_t (vorzeichenlos) und intptr_t (vorzeichenbehaftet) bereit, welcher die identische Datenbreite wie ein Pointer hat (Definition in der Header-Datei stdint.h)

#include <stdint.h> //Definition von intptr_t  uintptr_t
uintptr_t  var1;    //Ganzahlvariable zur Aufnahme einer Speicheradresse
int        var2;
char      *ptr1;
var1=(uintptr_t)&var2;  //Adresse einer Variablen in einer Ganzzahlvariablen
                        //speichern
ptr1=(char *)var1;      //Ganzzahlvariablen zu einer Adresse konvertieren

Öffnen im Compiler Explorer

Bei Nutzung von Pointercasts gilt es besondere Vorsicht walten zu lassen. Geübten Programmierer können hiermit die komplexesten Datenstrukturen schnell und effektiv realisieren. Ungeübte Programmierer richten hiermit eher Schaden an, als dass es ihnen Vorteile bringt.

Zeigerarithmetik

Bearbeiten

Zeigeraddition/-subtraktion

Bearbeiten

Arrays sind dadurch gekennzeichnet, dass nicht nur Speicherplatz für ein Element des zugrundeliegenden Datentyps, sondern Speicherplatz für mehrere Elemente reserviert wird. Alle Elemente liegen dabei im Speicher direkt hintereinander. Die Adressen der einzelnen Elemente haben folglich einen Abstand entsprechend der Größe des zugrundeliegenden Datentyps. Der Variablenname entspricht dabei der Startadresse des ersten Elementes. Die []-Klammer entsprechen der Dereferenzierung, wobei auf die Startadresse ein Offset gerechnet wird, der sich aus dem Index multipliziert um die Größe zugrundeliegenden Datentyps ergibt:

int var[7];      //Es werden 7*4 Byte Speicher reserviert
var              //var ohne [] und ohne & entspricht der Startadresse
                 //des reservierten Speichers
&var[0]          //Startadresse +   0 *sizeof(int)
&var[1]          //Startadresse +   1 *sizeof(int)
&var[2]          //Startadresse +   2 *sizeof(int)
&var[-1]         //Startadresse + (-1)*sizeof(int)

Die Addition/Subtraktion einer Ganzzahl zu einem Zeiger entspricht (entsprechend der Array Dereferenzierung) der Addition/Subtraktion der Ganzzahl multipliziert mit der Größe des zugrundeliegenden Datentyps des Zeigers zu diesem Zeiger:

int   arri[4]={0x80000001,0x40000002,
               0x20000004,0x01000008};
short arrs[8]={0x0011,0x2233,0x4455,0x6677,
              0x8899,0xAABB,0xCCDD,0xeeff};
int   var=-1;
dump(&arri,(uintptr_t)&var-(uintptr_t)&arri+sizeof(var),8,DUMP_8A);
short *ptrs=arrs;
	
printf("*(ptrs+1)=%#hx",*(ptrs+1)); //*(Startadresse+1*sizeof(short)) *1)
printf("*(ptrs+3)=%#hx",*(ptrs+3)); //*(Startadresse+3*sizeof(short)) *2)
printf("*(3+ptrs)=%#hx",*(3+ptrs)); //*(Startadresse+3*sizeof(short)) *3)
	
int offset=3;
printf("*(ptrs+offset)=%#hx",*(ptrs+offset));     //0x6677 *4)
printf("*(ptrs+2*offset)=%#hx",*(ptrs+2*offset)); //0xCCDD *4)
printf("*(offset+ptrs)=%#hx",*(offset+ptrs));     //0x6677 *4)
printf("*(2*offset+ptrs)=%#x",*(2*offset+ptrs)); //0xFFFFCCDD *5)
	
printf("*(ptrs+8)=%#hx",*(ptrs+8)); //0xFFFF *6)
printf("*(ptrs-1)=%#hx",*(ptrs-1)); //0x0100 *6)
	
printf("*ptrs+1  =%#hx",*ptrs+1);   //0x0012 *7)
	
//Mit Cast eines Pointers auf einen anderen Zeigertyp
int *ptri =(int *)&arrs[2];
printf("*(ptri+1)=%#x",*(ptri+1));  //0xaabb8899 *8)
	
//Ohne Umweg über eine Pointervariable
printf("*(int *)&arrs[3]=%#x",*(int *)&arrs[3]); //0x88996677 *9)

printf("*  (short *)(arri +2) =%#hx\n",*  (short *)(arri+2));  //*10)
printf("*(((short *) arri)+2))=%#hx\n",*(((short *) arri)+2)); //*11)
printf("*  (short *) arri +2  =%#hx\n",*  (short *) arri+2);   //*12)
	
//Mit Strukturen
struct xyz{int x,y,z;} var1[7],*ptr1;
ptr1=var1;
*(ptr1+3)=(struct xyz){1,2,3};   //*13)

//Ausgabe
//Speicherdump: 0x404040 .. 0x404063 Mode=8-Bit  
//0x00000000404040: 01 00 00 80 02 00 00 40
//0x00000000404048: 04 00 00 20 08 00 00 01
//0x00000000404050: 11 00 33 22 55 44 77 66
//0x00000000404058: 99 88 bb aa dd cc ff ee
//0x00000000404060: ff ff ff ff 00 00 00 00

Öffnen im Compiler Explorer

Im Speicher wird zunächst der Speicher für das Integer-Array mit 4 Elementen a 4-Byte reserviert (Speicherbereich 0x404040..0x40404F) und dieser mit den Initialisierungswerten vorbelegt. Der Variablenname arri entspricht der Startadresse 0x404040. Nachfolgend wird der Speicher für das Short-Array reserviert, welches sich von 0x404050 bis 0x40405f erstreckt. arrs zeigt auf die Speicheradresse 0x404050. Die nächsten 4-Byte von 0x404060..0x404063 sind für die variable var reserviert (-1 wird über das 2er Komplement von +1 gebildet, so dass sich 0xFFFFFFFF ergibt).
1) Mit short *ptrs=arrs; wird eine Short-Zeigervariable definiert und diese mit der Startadresse des Short-Arrays (hier 0x404050) initialisiert. Der Datentyp von arrs entspricht ein Zeiger auf den zugrundeliegenden Datentyp des Arrays, so dass der Datentyp der Zeigervariablen und des Arraynamens identisch ist. Mit *(ptrs+1) wird zunächst zum Inhalt von ptrs einmal die Größe des zugrundeliegenden Datentyps von ptrs (short *) addiert (0x404050+1*sizeof(short)=0x404052). Diese Adresse wird im Anschluss dereferenziert, d.h. aufgrund des Short-Zeigers der Inhalt der Speicherstellen 0x404052..0x404053 gelesen. Der Zugriff über *(ptrs+1) entspricht folglich arrs[1].
2) Wie zuvor, nur dass hier nicht einmal sondern dreimal die Größe des zugrundeliegenden Datentyps auf die Startadresse addiert wird. Der ausgelesene Speicherbereich ist 0x404056..0x404057 und der Inhalt entsprechend 0x6677.
3) Bei der Zeigerarithmetik ist es unbedeutend, ob zum Pointer eine Ganzzahl oder zu einer Ganzzahl ein Pointer addiert wird.
4) Der Offset zu einem Pointer kann sich wahlweise aus einer Konstanten, einer Ganzzahlvariablen oder einem Ausdruck ergeben, der eine Ganzzahl zurückliefert.
5) Bei der Dereferenzierung eines Short-Zeigers werden 2-Byte gelesen. Diese 2-Byte werden aufgrund der variadischen Funktion in einen Integerwert gewandelt. Das Vorzeichenbit/das höchstwertige Bit der zu konvertierenden Zahl (0xCCDD=0b1100110011011101) ist '1', so dass aufgrund des vorzeichenbehafteten Datentyps short eine vorzeichenrichtige Erweiterung erfolgt. Die resultierende Zahl ist folglich 0xffffccdd, welche aufgrund des Formatstrings %x als 32-Bit Zahl ausgegeben wird.
6) Bei der Pointerarithmetik wird nicht darauf geachtet, ob die resultierende Speicheradresse auf eine gültige Adresse zeigt. Vielmehr wird die Arithmetik so ausgeführt, wie diese codiert ist. Im ersten Fall wird zur Startadresse 8*sizeof(short) addiert, so dass der Adressbereich 0x404060..0x404061 mit den Inhalt 0xFFFF dereferenziert wird. Im zweiten Fall ergibt sich als Adressbereich 0x40404E..40404F mit dem Inhalt 0x0100. Erster Speicherbereich gehört zur Variablen vari und zweiterer zum Array arri.
7) Die Dereferenzierung hat nach der Prioritätenliste der Operatoren eine höhere Priorität als die Addition. Folglich wird hier erst der Inhalt des Zeigers dereferenziert (0x0011) und zu diesem Wert 1 addiert.
8) ptri wird mit int *ptri =(int *)&arrs[2]; auf die Startadresse des 2 Array-Elementes gesetzt (0x404054) und diese Adresse zu einem Integer-Zeiger gecastet. Wird nun mit *(ptri+1) dieser Integer-Zeiger dereferenziert, so wird entsprechend des zugrundeliegenden Datentyps 1*sizeof(int) auf den Inhalt des Pointers addiert. Daraus ergibt sich der adressierte Speicherbereich zu 0x404058..0x40405B und dem Inhalt 0xaabb8899.
9) Hier wird mit *(int *)&arrs[3] zunächst die Adresse des dritten Array-Elementes (0x404056) geholt und diesen Short-Zeiger in einen Integer-Zeiger gecastet. Dieser Integer-Zeiger wird im Anschluss dereferenziert. Adressiert wird der Speicherbereich 0x404056..0x404059 mit dem Inhalt 0x88996677.
10) *(short *)(arri+2) Der Arrayname entspricht einem Zeiger auf den zugrundeliegenden Datentyp, so dass arri ein Integer-Zeiger entspricht. Aufgrund der Klammerung wird zu dieser Adresse zweimal der Offset des zugrundeliegenden Datentyps addiert, so dass die Klammerung einen Int-Zeiger mit der Startadresse 0x404048 ergibt. Diese Startadresse wird nun zu einem Short-Zeiger gecastet, welcher im Anschluss dereferenziert wird. Adressiert wird der Speicherbereich 0x404048..0x404049 mit dem Inhalt 0x0004.
11) *(((short *) arri)+2) Der Arrayname entspricht einem Zeiger auf den zugrundeliegenden Datentyp, so dass arri ein Integer-Zeiger entspricht. Dieser Zeiger wird aufgrund der Klammerung in einen Short-Zeiger gewandelt. Die Pointerarithmetik wird nun auf diesen Short-Zeiger angewendet, so dass auf die Startadresse '2*sizeof(short)' addiert wird. Im Anschluss wird dieser Short-Zeiger mit der Adresse 0x404044..0x404045 mit dem Inhalt 0x0002 dereferenziert.
12) *(short *) arri+2 Hier hat die Typumwandlung eine höhere Priorität als die Addition, so das zunächst der Integer-Zeiger in einen Short-Zeiger gewandelt wird. Auch die Dereferenzierung hat einen höhere Priorität als die Addition, so dass der Short-Zeiger mit der Adresse 0x404040..0x404041 und dem Inhalt 0x0001 dereferenziert wird. Zu diesem Ganzzahlwert wird nun die 2 addiert.
13) In diesem Anwendungsfall *(ptr1+3) ist ptr1 ein Zeiger auf (struct xyz *). Eine Addition von 3 bewirkt hier, dass zur Startadresse 3*sizeof(struct xyz) addiert wird.


Addition/Subtraktion eines Pointers mit einer Ganzzahl (Syntax: Adresse/Zeiger ± Ganzzahl resp. Ganzzahl ± Adresse/ Zeiger) bedeutet folglich Vielfaches des zugrundeliegenden Datentyps zu/von der Startadresse aufaddieren/abziehen.

Eine Addition zweier Pointer (Syntax: Adresse/ Zeiger + Adresse/ Zeiger) ist nicht möglich und führt zu einem Compilerfehler.

Eine Subtraktion zweier Pointer (Syntax: Adresse/ Zeiger - Adresse/ Zeiger) ist möglich (siehe Pointerdifferenz).

Weitere Operatoren wie Multiplikation/Division/logische Operationen sind mit Zeigern nicht möglich, unabhängig davon, ob der zweite Operator eine Ganzzahl oder ein Pointer ist.

Zeigerarithmetik kann zu einer ungültigen Speicheradresse führen. Die Speicherung der ungültigen Adresse in einer Zeigervariablen ist dabei nicht das Problem, sondern die Dereferenzierung. Der Fehler macht sich unterschiedlich bemerkbar:

  • Programmabsturz, da auf eine dem Prozess nicht zugewiesen Speicherstelle zugegriffen wurde -> Segmentation Fault.
  • Adresse zeigt auf eine gültige Speicheradresse, welche eine andere 'Bedeutung' hat, bspw. einer anderen Variablen zugehörig ist. Bei Dereferenzierung dieser Adresse würde sich der Inhalt einer anderen Variablen ändern/gelesen werden, welches erst bei der späteren Nutzung dieser Variablen auffällt. Das Programm verhält sich in diesem Falle zumeist 'merkwürdig'.

Solche Fehler sind nur schwer zu finden, da die Codezeilen, bei welchen der Fehler erkannt wird und die Codezeilen, welche den eigentlichen Fehler erzeugen, weit voneinander entfernt sind.

Pre/Post Inkrement/Dekrement

Bearbeiten

Der Pre/Post Inkrement/Dekrement Operator bedeutet, den Inhalt einer Variablen um 1 zu erhöhen oder zu erniedrigen. Angewendet auf Zeiger bedeutet der Operator, die Adresse nicht um 1 sondern um die Größe des zugrundlegenden Datentyps zu erhöhen/erniedrigen:

int *ptr1=(int *)0x100;
int *ptr2;
ptr2=  ptr1++;  //ptr2    =ptr1_old             ptr1_new=ptr1_old+sizeof(int)
                //ptr2    =0x100                ptr1_new=0x104
ptr2=++ptr1;    //ptr1_new=ptr1_old+sizeof(int) ptr2    =ptr1_new
                //ptr1_new=0x108                ptr2    =0x108
ptr2=  ptr1--;  //ptr2    =ptr1_old             ptr1_new=ptr1_old-sizeof(int)
                //ptr2    =0x108                ptr1_new=0x104
ptr2=--ptr1;    //ptr1_new=ptr1_old-sizeof(int) ptr2    =ptr1_new
                //ptr1_new=0x100                ptr2    =0x100

Öffnen im Compiler Explorer

Mit dem Inkrement/Dekrement kann ergänzend der Pointer Dereferenziert werden. Bei Anwendung von Post Inkrement/Dekrement *ptr++ oder *ptr-- bedeutet dies, dass die Dereferenzierung auf Basis der aktuellen Pointerinhaltes erfolgt und im Anschluss an die Dereferenzierung der Pointerinhalt erhöht/erniedrigt wird. Beim Pre Inkrement/Dekrement *++ptr oder *--ptr wird der Pointerinhalt vor der Dereferenzierung erhöht/erniedrigt:

int var[8]={0x00112233,0x44556677,0x8899AABB,0xCCDDEEFF,
            0xFFEEDDCC,0xBBAA9988,0x77665544,0x33221100};
                  //Startadresse von var ist 0x404040
int *ptr=&var[4]; //ptr=0x404050
int val=0;

val=*ptr++;    //val   =*ptr_old             ptr_new= ptr_old+sizeof(int)
               //val   =0xFFEEDDCC           ptr_new= 0x404054
val=*++ptr;    //ptr_new=ptr_old+sizeof(int) val   =*ptr_new
               //ptr_new=0x404058            val   =0x77665544
val=*ptr--;    //val   =*ptr_old             ptr_new= ptr_old-sizeof(int)
               //val   =0x77665544           ptr_new= 0x404054
val=*--ptr;    //ptr_new=ptr_old-sizeof(int) val   =*ptr_new
               //ptr_new=0x404050            val   =0xFFEEDDCC
}

Öffnen im Compiler Explorer

Ein Vorteil dieser 'Kombi' ist, dass sie nicht nur Tipparbeit erspart, sondern der Compiler passende Maschinensprachebefehle anwendet, welche die Programmausführung beschleunigen. Insbesondere bei Schleifen wird daher diese Kombination aus Performancegründen gerne angewendet:

void strcpy(char *dst, char *src) {
  for(  ; *src;  )
    *dst++=*src++;
  *dst++=*src++;  //Stringendezeichen noch mit kopieren
}

Öffnen im Compiler Explorer

Zeigerdifferenz

Bearbeiten

Die Differenz zweier Zeiger (Syntax: Adresse/Zeiger - Adresse/Zeiger) gibt den Abstand der beiden Zeiger in Vielfachen des zugrundeliegenden Datentyps an. Die beiden Zeiger müssen vom identischen Datentyp sein. Der resultierende Datentyp der Subtraktion ist kein Pointer, sondern eine vorzeichenbehaftete Ganzzahl vom Datentyp ptrdiff_t (entspricht ssize_t (vorzeichenbehaftet size_t) und ist in der Header-Datei stddef.h definiert). Der Length-Modifier im printf-Formatstring für ptrdiff_t 't' so dass '%td' zur Formatierung von Variablen dieses Datentyps zu nutzen ist:

#include <stddef.h>  //Definition des Datentyps ptrdiff_t
struct xyz {int x,y,z;} xyz[]={{1,2,3},{4,5,6},{0,0,0}};
struct xyz *ptr1= xyz;
struct xyz *ptr2=&xyz[2];
ptrdiff_t  diff=ptr2-ptr1;  //=2
           diff=xyz -ptr2;  //=-2
struct abc {int a,b,c;} abc[]={{1,2,3},{4,5,6},{0,0,0}};
           diff=xyz-abc;    //KO, aufgrund von unterschiedliche Datentypen

Öffnen im Compiler Explorer

Ein Anwendungsfall der Zeigerdifferenz ist die Bestimmung der Stringlänge:

size_t strlen(char *str) {
  char *lauf=str;
  for( ;*lauf++; );
  return (size_t)(lauf-str)-1;
  //Da die Länge des Strings stets positiv ist, kann 
  //der vorzeichenbehaftete Datentyp ptrdiff_t unproblematisch
  //in den vorzeichenlosen Datentyp size_t gewandelt werden!
}

Öffnen im Compiler Explorer

Zeigervergleich

Bearbeiten

Ein Zeigervergleich Syntax: Adresse/Zeiger < / <= / == / >= / > Adresse/Zeiger gibt an, ob die linke Adresse kleiner/gleich/größer als die rechte Adresse ist. Die zu vergleichenden Adressen müssen vom identischen Datentyp sein!

int arr[10]; 
int *ptr=&arr[2];
if(ptr == &arr[2])            //TRUE
if(ptr <  &arr[2])            //FALSE
if(ptr <= &arr[2])            //TRUE
if(ptr >   arr   )            //TRUE

Öffnen im Compiler Explorer

Hinweise:

  • Da der Arrayname immer ein Zeiger ist (siehe Kapitel Array) ergibt folgender Ausdruck einen gültigen Syntax, bewirkt jedoch etwas anderes als gegebenenfalls erwartet (siehe Array:Vergleichen von Arrays):
char arr1[3] = {4,5,6};    //0x100 0x101 0x102
char arr2[3] = {1,2,3};    //0x102 0x103 0x104
if(arr1 > arr2)   //Es werden die Speicheradresse der Arrays verglichen
                  //Nicht deren Inhalt

Öffnen im Compiler Explorer

  • Dies gilt auch für Objekte in C++, welche als Zeiger angelegt werden
#include <string>
std::string *str1 = new std::string("hallo1");
std::string *str2 = new std::string("hallo1");
if(str1 == str2)
  printf("Adressen sind identisch\n");
if(str1->compare(*str2)==0)
  printf("Inhalte sind identisch\n");
if(*str1 == *str2)
  printf("Inhalte sind identisch\n");

Öffnen im Compiler Explorer

Zeigerinitialisierung

Bearbeiten

Zeiger müssen vor der ersten Nutzung (Dereferenzierung) mit einer gültigen Speicheradressen initialisiert werden! Nicht initialisierte Zeiger entsprechen nicht initialisierte Variablen, d.h,

  • nicht initialisierte globale und statische lokale Zeiger werden mit 0 initialisiert, d.h. eine Dereferenzierung würde den Inhalt der Speicheradresse 0 beschreiben/lesen. Dieser ist bei Windows/Linux/macOS nicht dem Prozess zugeordnet (genau aus diesem Grund) so dass ein Zugriff auf diese Speicheradressen zu einem Programmabsturz führt!
  • nicht initialisierte lokale Zeiger sind mit einem Zufallswert belegt, d.h. eine Dereferenzierug wird höchstwahrscheinlich den Speicherinhalt einer dem Prozess nicht zugewiesenen Speicheradresse beschreiben/lesen.

Beispiele:

char *ptr1;     //Im Falle einer globalen Variablen wird diese mit 0
                //initialisiert
*ptr1=47;       //Schreiben der Konstanten 47 in die Speicheradresse 0
                //welche zumeist dem Programm vom Betriebssystem
                //nicht zugewiesen wird → Programmabsturz
void foo(void) {
  char *ptr2;   //Im Falle einer lokalen Variablen wird diese
                //nicht initialisiert, enthält somit eine 'Zufallsadresse'
  *ptr2=47;     //Schreiben der Konstanten 47 in diese Zufallsadresse
                //führt wahlweise zum Programmabsturz oder Änderung
}               //anderen Variablen

Öffnen im Compiler Explorer


Bei der Initialisierung von Zeigern mit Variablen muss die Gültigkeitsdauer der Adresse berücksichtigt werden:

  • Initialisierung mit Adresse von globalen/static lokalen Variablen
Globale Variablen und static lokale Variablen sind während der gesamten Programmlaufzeit gültig und Zeiger auf diese Adressen sind somit immer gültig. Solch initialisierte Zeiger können zu jedem Zeitpunkt dereferenziert werden:
int glob=4711;
int *ptr;
void func1(void) {
  ptr=&glob;   //Initialisierung mit Adresse einer globalen Variable
}
void func2(void) {
  *ptr=1147;   //Nach Initialisierung ist dieser Pointer bis zum 
               //Programmende gültig
}

Öffnen im Compiler Explorer

  • Initialisierung mit Adresse von lokalen Variablen
Lokale Variable / Parameterübergabevariablen sind nur während der Laufzeit der Funktion / des Blockes gültig. Wird ein Zeiger mit solch einer Adresse initialisiert, so darf eine Dereferenzierung des Zeigers nur erfolgen, solange die dazugehörige Variable 'existiert'. Nach Verlassen des Blockes / Beendigung der Funktion darf solch ein Zeiger nicht mehr dereferenziert werden:
Korrekt Inkorrekt Korrekt Inkorrekt
void func(int par)
{
 int lok;
 int *ptr1=&par;
 int *ptr2=&lok;
 
 *ptr1=0;  //OK
 *ptr2=0;  //OK
}

Öffnen im CE

int *func(void)
{
 int lok=7;
 return(&lok);
}
int main(void)
{
 int *ptr;
 ptr=func();
 *ptr=7; //KO
 return 0;
}

Öffnen im CE

void func1(int *ptr)
{
 *ptr=8; //OK
}
void func2(void)
{
 int lok=7;
 func1(&lok);
}

Öffnen im CE

int *ptr;
void func(void)
{  
 int lok=7;
 ptr=&lok;
 *ptr=8;  //OK
}
int main(void)
{
 func();
 *ptr=7; //KO
 return 0;
}

Öffnen im CE

  • Initialisierung mit Adresse vom Heap
Der vom Heap angeforderte Speicher bleibt so lange reserviert/erhalten, bis er explizit freigegeben wird. Nach Freigabe ist kein Zugriff mehr auf den Speicher erlaubt:
char *ptr=malloc(100);
strcpy(ptr,"hello world"); //OK
free(ptr);
printf("%s",ptr);          //KO

Öffnen im Compiler Explorer

Daher empfiehlt sich, mit der Freigabe des Speichers alle auf diesen Speicherbereich zeigenden Zeiger auf NULL zusetzen:
char *ptr=malloc(100);
strcpy(ptr,"hello world"); //OK
free(ptr);
ptr=NULL;
ptr[0]='a';                //KO, jedoch mit definierten Ende
                           //    d.h. Programmabsturz
//etwas Eleganter unter Nutzung des Preprozessors
#define SETNULL(ptr) ({typeof(ptr)dummy=ptr; ptr=NULL; dummy;})
char *ptr=malloc(100);
strcpy(ptr,"hello world");
free(SETNULL(ptr));

Öffnen im Compiler Explorer

Hinweise:

  • Im Falle von nicht initialisierten Zeigern oder Zeigern deren Adresse die Gültigkeit verloren haben, spricht man von Hängenden / wilden Zeigern (englisch dangling pointer). (Siehe   Hängender Zeiger)
  • C/C++ führt zur Laufzeit keine Kontrolle durch, ob der Zeiger auf eine gültige Adresse zeigt. Zur Compilezeit findet solch eine Überprüfung nur an wenigen Stellen statt. Da hängende Zeiger eine häufige Fehlerquelle sind, wäre hier Optimierungsbedarf beim Compiler sinnvoll.

Void-Zeiger

Bearbeiten

Bei der Zuweisung und dem Vergleich von Zeiger müssen beiden Operanden vom identischen Datentyp sein. Eine Ausnahme stellt der Void-Zeiger (Generische Zeiger, anonyme Zeiger, untypisierter Zeiger) dar. Dieser ist zu jedem anderen Zeiger kompatibel, so dass ein Vergleich / Zuweisung ohne explizites Cast möglich ist. C++ ist im Umgang mit void-Zeigern etwas restriktiver. Eine Zuweisung eines void-zeigers an einen typisierten Zeiger ist nicht möglich. Aufgrund des zugrundeliegenden void Datentyps kann der void-Zeiger nicht dazu genutzt werden, Inhalte zu dereferenzieren:

int   var;
int  *ptri;
void *ptrv;
ptri=&var;
ptrv=&var;

ptri=ptrv; //OK in C, KO in C++
ptrv=ptri;  

*ptri=12;
*ptrv=12;   //KO, ein Void-Zeiger kann nicht dereferenziert werden

if(ptri >= ptrv) ...
if(ptrv >= ptri)

Öffnen im Compiler Explorer (C)
Öffnen im Compiler Explorer (C++)

Anwendungen:

  • Die Malloc Funktion gibt einen void-Zeiger zurück, so dass der Rückgabewert der malloc Funktion in C jedem Zeiger ohne explizites Cast zugewiesen werden kann (in C++ ist hier ein explizites Cast notwendig!):
int *ptr1=malloc(10*sizeof(int));
struct {int x,y,z;} *ptr2=malloc(12);
  • Mittels den memxxx() Funktionen können Speicherbereiche:
  • Auf einen Wert gesetzt/initialisiert werden memset()
  • Miteinander verglichen werden memcmp()
  • Kopiert werden memcpy() (Speicherbereiche dürfen sich nicht überlappen)
  • Verschoben werden memmove() (Speicherbereiche dürfen sich überlappen)
Diese Funktionen sind unter anderem Hilfreich beim Umgang mit Arrays (sieh Array). Damit diese unabhängig vom Datentyp des Zeigers sind (=generische Funktion), nutzen diese Funktionen den void-Zeiger als Übergabeparameter:
#include <string.h> 
//enthält u.A. folgende Prototypen
//void *memcpy(void *dst,void *src,size_t n);
//int   memcmp(void *s1, void *s2 ,size_t n);
  • Soll eine Struktur vom Aufrufer 'versteckt' werden, so wird die Strukturdefinition nicht in die Header-Datei, sondern in der C-Datei beschrieben. Der Aufrufer speichert die Adresse dann in einem void-Zeiger:
class.h
void *class_init(void);
main.c class.c
#include "class.h"
int main(void) {
   void *obj;
   obj=class_init();
   //Aufrufer kennt struct class
   //nicht und kann somit nicht
   //auf die Strukturelemente
   //x,y,z zugreifen
   //Die Strukturelemente 
   //sind somit 'private'
    
   return 0;
}
#include "class.h"
struct class {
     int x,y,z;
};

void *class_init(void)
{
  struct class *me;
  me=malloc(sizeof(struct class));
   
  return me;  //Kein explizites Cast
              //notwendig
}

Öffnen im Compiler Explorer

Null-Zeiger

Bearbeiten

Ein Zeiger auf die (eigentlich gültige) Speicheradresse 0 (=Null-Zeiger) wird in C/C++ als nicht initialisierter Zeiger angesehen. Ergänzend wird mit dem Null-Zeiger als Rückgabewert von Funktionen gesagt, dass die Funktion die geforderte Funktionalität nicht ausgeführt hat.

Der Test, ob ein Zeiger eine gültige Speicheradresse hat (d.h. ungleich 0 ist) erfolgt über einen Zeigervergleich. Dieser bedingt, dass beide Operanden ein Zeiger sind. Zur Vermeidung einer Datentypanpassung empfiehlt sich ein void-Zeiger. In der Header-Datei stddef.h ist dazu folgendes Makro enthalten:

#define NULL ((void *)0)

Hier wird die Ganzzahl 0 zu einem void-Zeiger auf die Speicheradresse 0 gecastet.

Anwendungen:

  • Zeiger ist nicht intialisiert:
#include <stddef.h>
char *ptr=NULL;
printf("%s",ptr?:(char *)"(nil)");
	
ptr=(char *)malloc(100 * sizeof(char));
free((void *)ptr);
ptr=NULL;

Öffnen im Compiler Explorer

  • Funktion hat die geforderte Funktionalität nicht ausgeführt:
//Malloc() gibt im Fehlerfall NULL zurück
int *ptr=malloc(100);
if(ptr!=NULL)  //zum Überprüfen, ob Malloc dem Aufrufer die 
               //angeforderte Speichergröße bereitstellen kann
if(strstr("Test","st")==NULL)  //Zum überprüfen, ob der Substring
                               //im String vorhanden ist

Öffnen im Compiler Explorer

  • Der Nullzeiger wird oft bei Arrays auf Zeigern genutzt, um entsprechend dem Stringendezeichen zu signalisieren, dass dies der letzte Eintrag ist:
#include <stddef.h>
char const * const strarray[]={"hallo","Du",NULL};
//For-Schleife über Index-Variable
for(size_t lauf=0;strarray[lauf];lauf++)
  printf("%s\n",strarray[lauf]);

//Optimierte For-Schleife über Zeiger
for(char const*const *ptr=strarray;*ptr; ptr++)         
  printf("%s\n",*ptr);
//Wird auch bei argv angewendet
for(char **ptr=argv;*argv;argv++)            
  printf("%s\n",*ptr);

Öffnen im Compiler Explorer

Hinweise:

  • Entgegen Windows/Linux, bei welche die Speicheradresse 0 nicht dem Prozess zugewiesen wird und eine Dereferenzierung folglich zu einem Laufzeitfehler führt, ist die Speicheradresse 0 bei Embedded Systemen sehr wohl dem 'Prozess' zugewiesen. Hier befindet sich zumeist der Interrupt-Vektor-Tabelle, so dass ein Schreiben auf diese Adresse gravierende Auswirkung haben könnte
  • strcpy() und viele andere Funktionen gehen davon aus, dass eine gültige Adresse übergeben wurde. Wenn bspw. strcpy einen Nullzeiger übergeben wird, greift strcpy auf die Speicheradresse 0 zu, was zumeist zu einem Programmabsturz führt
  • Für die Ausgabe eines Strings mittels printf() wird die Startadresse des Strings übergeben. In einigen Anwendungsfällen überprüft printf(), ob es sich um einen Nullzeiger handelt und gibt "(null)" aus. In anderen Anwendungsfällen dereferenziert printf() den NULL Zeiger, was zu einem Programmabsturz führt
  • Die Header-Datei stddef.h wird durch viele Header-Dateien inkludiert (z.b. stdlib.h, stdio.h), so dass diese nicht gesondert inkludiert werden muss

Zeiger auf Zeiger

Bearbeiten

Nicht nur eine Variable, sondern auch eine Zeigervariable hat eine Speicheradresse. Nutzt man die Adresse einer Zeigervariablen, spricht man von Zeiger auf Zeiger:

int    var;
int   *ptr=&var;         //Datentyp: (int *)   → 'Einfacher' Zeiger
int  **ptrptr=&ptr;      //Datentyp: (int **)  → Zeiger auf Zeiger
int ***ptrptrptr=&ptrptr;//Datentyp: (int ***) → Zeiger auf Zeiger auf Zeiger

Öffnen im Compiler Explorer

Bei der Dereferenzierung eines Zeigers auf einen Zeiger wird entsprechend der Datentypsicht jeweils ein * entfernt. Eine Mehrfachdereferenzierung ist möglich:

  *ptrptrptr=NULL;    //aus Datentypsicht:   *(int ***) → (int **)
 **ptrptrptr=NULL;    //aus Datentypsicht:  **(int ***) → (int *)
***ptrptrptr=NULL;    //aus Datentypsicht: ***(int ***) → (int)
 **ptrptr   =NULL;    //aus Datentypsicht:  **(int **)  → (int)
  *ptrptr   =NULL;    //aus Datentypsicht:   *(int **)  → (int *)

Ein Zeiger auf einen Zeiger entspricht einem 'normalen' Zeiger, d.h. er beinhaltet die Adresse einer anderen Variablen und belegt somit Speicherplatz wie ein 'normaler' Zeiger:

int   var1=0x01020304;   //*1)
int   var2=0x55667788;
int  *ptr1=&var1;        //*2)
int  *ptr2=&var2;
int **ptrptr=&ptr1;      //*3)
	
ptr2=*ptrptr;   //Gibt die Adresse der Variablen ptr1 zurück *4)
var2=**ptrptr;  //Gibt den Inhalt der Variablen var1 zurück *5)
*ptrptr=&var2;  //Ohne Worte, gerne im Dump nachvollziehen
ptrptr=&ptr2;   //Ohne Worte, gerne im Dump nachvollziehen
&&var;   //KO Der erste Adress-Operator gibt eine Konstante zurück
         //   von welcher keine Adresse abgeleitet werden kann
//Ausgabe
//Speicherdump: 0x404040 .. 0x40405f Mode=8-Bit
//0x00000000404040: 04 03 02 01 88 77 66 55
//0x00000000404048: 40 40 40 00 00 00 00 00
//0x00000000404050: 44 40 40 00 00 00 00 00
//0x00000000404058: 48 40 40 00 00 00 00 00

Öffnen im Compiler Explorer

  • 1) Die Variable var1 belegt den Speicherbereich von 0x404040..0x404043 und beinhaltet den Initialisierungswert
  • 2) Der Zeiger ptr1 belegt den Speicherbereich von 0x404048..0x40404F und beinhaltet die Adresse der Variablen var1 (0x404040)
  • 3) Der Zeiger auf Zeiger ptrptr belegt den Speicherbereich von 0x404058..0x40405F und beinhaltet die Adresse des Zeigers ptr1 (0x404048)
  • 4) Mit der 'einfachen' Dereferenzierung des Zeigers auf Zeiger wird zunächst der Inhalt der Variablen ptrptr (0x404058..0x40405F) gelesen. Dieser gelesene Wert (0x404048) wird als Startadresse für einen weiteren Lesezyklus genutzt. Da der zugrundeliegende Datentyp des Zeigers auf Zeiger ein Zeiger ist, werden 8-Byte gelesen. Der ermittelte Wert (0x404040) ist die Startadresse der Variablen var1
  • 5) Mit der 'doppelten' Dereferenzierung des Zeigers auf Zeigers wird zunächst der Inhalt der Variablen ptrptr(0x404058..0x40405F) gelesen. Dieser gelesen Wert (0x0404048) wird als Startadresse für einen weiteren Lesezyklus genutzt. Da der zugrundeliegende Datentyp des Zeigers auf Zeiger ein Zeiger ist, werden 8-Byte gelesen. Dieser gelesene Wert (0x404040) wird erneut als Startadresse für einen Lesezyklus genutzt. Da der zugrundeliegende Datentyp des Zeigers ein int ist, werden 4-Byte gelesen. Der ermittelte Wert (0x01020304) ist der Inhalt der Variablen var1

Anwendung:

  • Soll die aufgerufene Funktion Speicher reservieren und diese an dem Aufrufer zurückgeben, so gibt es zwei Möglichkeiten. Entweder über den Rückgabewert oder über einen Funktionsparameter. Im letzteren Fall muss der Parameter ein Zeiger auf einen Zeiger sein:
//Rückgabe der Speicheradresse über Rückgabewert der Funktion
int *func1(size_t size) {
  int *ptr=malloc(size);
  return ptr;	
}
//Rückgabe der Speicheradresse über Funktionsparameter
int func2(int **ret, size_t size) {
  int *ptr=malloc(size);
  if(ptr==NULL)
    return -1;
  *ret=ptr;
  return 0;
}

Öffnen im Compiler Explorer

Hinweis:

  • Zeiger auf Zeiger sind ähnlich wie eine komplizierte mathematische Formel nur schwer nachvollziehbar. Daher sollten solche Zeiger nur dort eingesetzt werden, wo sie unbedingt benötigt werden

Zeiger bei Funktionsaufrufen

Bearbeiten

Ein wichtiger Einsatzbereich von Zeigern ist die Verwendung von Zeigern als Übergabeparameter und Rückgabeparameter bei Funktionsaufrufen. Vorrangiges Ziel hier ist:

  • einerseits das (zeit)aufwendige Kopieren von Daten zu vermeiden und
  • ergänzend dem aufgerufenen (Callee) zu ermöglichen, direkt Werte des Aufrufers (Caller) zu verändern

Prinzipiell erfolgt bei einem Funktionsaufruf immer ein Kopieren/Zuweisen der Werte des Callers in die lokale Parametervariablen des Callees (Call by Value). Wird ein Zeiger übergeben, hat der Callee über die Dereferenzierung des Zeigers die Möglichkeit, die Variablen des Caller zu verändern (Call by Referenz).

Call by Value

Bearbeiten

Die Parameterübergabe bei Funktionen basiert darauf, dass der Inhalt des Caller-Parameters in die lokale Callee-Variable kopiert wird:

void func(int lok) {
  lok=lok*2;     //Änderung von lok bewirkt keine Änderung von 7/var
}
int main(void) {
  int var=8;
  func(7);       //lok=7   
  func(var);     //lok=var  
                 //var behält seinen Wert unabhängig vom Aufruf von func bei
  func(var+7);   //lok=var+7
  return 0;
}

Öffnen im Compiler Explorer

Die Konstante 7 / der Inhalt der Variablen var wird mit dem Funktionsaufruf in die lokale Variable lok der Funktion func kopiert. Eine Änderung der lokalen Variablen lok hat folglich keine Auswirkung auf den Inhalt der 'Ursprungsvariablen'.

Call By Reference

Bearbeiten

Wird anstatt dem Inhalt einer Variablen die Adresse der Variablen übergeben, so hat der Callee nun die Möglichkeit, über die Adresse den Inhalt der Ursprungsvariablen zu ändern:

void func(int *ptr) {
  *ptr=10;        //Änderung des Inhaltes von var
  ptr=ptr+7;      //Änderung der lokalen Variablen ptr
}
int main(void) {
  int var=8;   //bspw 0x404060
  func(&var);  //ptr=&var
               //var hat nach Aufruf von func einen anderen Wert
  return 0;
}

Öffnen im Compiler Explorer

Auch hier findet ein Kopiervorgang statt, wobei hier die Adresse der Variablen var in die lokale Variable vom Typ Zeiger kopiert wird. Über die Dereferenzierung kann auf den Inhalt und damit auf die Ursprungsvariable zugegriffen werden. Eine Änderung der lokalen Zeigervariablen ptr ist möglich, ändert dann einzig die Zeigervariable, aber nicht den ursprünglich referenzierten Inhalt.

Anwendungen:

  • Als alternative zum Rückgabewert einer Funktion, insbesondere dann, wenn mehrere Rückgabewerte benötigt werden:
void func(double *ret1, double *ret2, double value)  {
  *ret1=sin(value);
  *ret2=cos(value);
}
//Auch strcpy(), strcat(), snprintf(), .. 
//bekommen als ersten Parameter einen Zeiger übergeben, in welchen das
//Ergebnis (der Rückgabewert) geschrieben wird

Öffnen im Compiler Explorer

  • der eigentliche Funktionsrückgabewert als Funktionsstatus angesehen wird
enum status {OK,KO};
enum status func(int *retvalue, int para1, int para2) {
  if(!(para1!=0 && para2!=0))
    return KO;
  *retvalue=para1+para2;
  return OK;
}

Öffnen im Compiler Explorer

  • Für eine schnellere Programmausführung, damit nicht der gesamte Inhalt kopiert werden muss, sondern nur der Zeiger übergeben wird (insb. Bei komplexen Strukturen)
struct xyz {
  int arr1[1000];
  int arr2[100];
} var;
struct xyz func_slow(struct xyz lok)
{
  //Hier werden jeweils 1100*4 Bytes beim Aufruf
  //und beim Beenden der Funktion kopiert
  return lok;
}
func_slow(var);

void func_fast(struct xyz *ptr) {
  //Hier werden nur 8-Bytes kopiert.
}
func_fast(&var);

Öffnen im Compiler Explorer

Werterückgabe

Bearbeiten

Neben der Verwendung von Zeigern für die Parameterübergabe kann auch ein Zeiger zurückgegeben werden:

char *strstr(const char *haystack, const char *needle);
char *strdup(char *src);

Dies ist sinnvoll/notwendig, wenn entweder größere Datenmengen zurückgegeben werden sollen oder die Anzahl der zurückgegebenen Daten sich erst zur Laufzeit ergibt. Besondere Achtsamkeit gilt hier der 'Adressfindung'. Der Speicherbereich, auf welchen die Adresse zeigt, muss nach Beenden der Funktion noch gültig sein. Damit verbietet sich die Rückgabe der Adresse einer lokalen Variablen (siehe auch Zeigerinitialisierung).
Über die Werterückgabe wird oft der Status der Funktion zurückgeben. Ein Fehler bei der Funktionsausführung wird im Falle von Zeigern mit einem Zeiger auf die Adresse NULL dargestellt

Const-Zeiger

Bearbeiten

Nicht nur Variablen, sondern auch Zeiger können 'Konstant', also nicht änderbar/beschreibbar sein. Im Falle von Zeigern müssen zwei Fälle unterschieden werden. Einerseits kann die Zeigervariable als solches nicht änderbar sein und anderseits kann der Inhalt, auf welchen der Zeiger zeigt, nicht änderbar sein:

  • Zeigervariable ist Kontant
Syntax: Datentyp * const Variablenname;
Die Zeigervariable ist Kontant und kann nicht geändert werden.
Der Speicherinhalt, auf welchen der Zeiger zeigt, kann geändert werden
char var;
char * const ptr=&var;
*ptr='a';   //OK
ptr++;      //KO

Öffnen im Compiler Explorer

  • Dereferenzierung ist Kontant
Syntax: Datentyp const * Variablenname;
Syntax: const Datentyp * Variablenname;
Die Zeigervariable ist nicht konstant und kann geändert werden
Der Speicherinhalt, auf welchen der Zeiger zeigt, ist konstant und kann nicht geändert werden
char var;
char const * ptr=&var;
*ptr='a';   //KO
ptr++;      //OK

Öffnen im Compiler Explorer

  • Zeigervariable und Dereferenzierung sind Konstant
Syntax: Datentyp const * const Variablenname;
Syntax: const Datentyp * const Variablenname;
Sowohl die Zeigervariable, als auch der Speicherinhalt, auf welchen der Zeiger zeigt, sind Konstant
char var;
char const * const ptr=&var;
*ptr='a';   //KO
ptr++;      //KO

Öffnen im Compiler Explorer

Anwendungen:

  • Werden Konstanten als normale Variablen angelegt und diese im Folgenden über Zeiger 'verwaltet', so sollten auch die Zeiger als Zeiger vom Typ auf eine konstante Dereferenzierung sein:
int  const var=4711;
int * ptr1=&var;
*ptr1=13;                   //KO Über die Dereferenzierung von ptr1
                            //   kann var geändert werden
int  const * ptr2=&var;     //Korrekt

char *str1="hallo";         //KO Änderungen über Dereferenzierung
                            //   führen zum Programmabsturz, da
                            //   String-Konstante in Read-Only Speicher
                            //   gehalten wird!
char const * str="hallo";   //Korrekt

Öffnen im Compiler Explorer

  • Zur Darstellung bei der Parameterübergabe mit Zeigern, dass der 'Inhalt' des Zeigers durch den Callee nicht geändert wird (Read Only Parameter)
char *strcpy(char *dst, char const *src); //const hier als Zusicherung
                        //von strcpy, dass der Inhalt von src nicht
                        //geändert wird.
int printf(const char *fmt , );          //Dito
  • Ein mit malloc() reservierter Speicher muss mit free() freigegeben werden. Dazu ist die von malloc erhaltene Adresse zu nutzen. Dieser Zeiger darf nicht verändert werden:
char * const ptr = malloc(100);
ptr++;  //KO
free(ptr);

Öffnen im Compiler Explorer

  • Zur Nachbildung einer Referenz, welche mit dem Anlegen initialisiert werden muss und danach nicht mehr geändert werden kann:
int var1;
int * const ptr=&var1;
int var2;
ptr=&var2;   //KO, Referenzen können nicht geändert werden

Öffnen im Compiler Explorer

Leider findet man die Anwendung dieser Regeln nur selten in der praktischen Anwendung. Insbesondere unter Beachtung dieser Regeln können mit C/C++ einige Laufzeitfehler vermieden werden!

Typkonvertierung

Bearbeiten

Das Schlüsselwort const ist Bestandteil des Datentyps. Folglich gilt es dies bei der Zuweisung (incl. Parameterübergabe) von const zu nicht const Datentypen zu berücksichtigen.
C wendet hierbei folgende implizite Typkonvertierungen an:

Zieldatentyp  = Quelldatentyp
char const *  = char const *   <-- Keine Typkonvertierung notwendig
char const *  = char       *   <-- char * wird implizit zu char const* gewandelt
char *        = char const *   <-- char const * wird implizit zu char * gewandelt
                                   unter ggf. ergänzenden Ausgabe einer Warning
                                   Da nun über die Dereferenzierung des neuen
                                   Zeigers der eigentlich konstante Inhalt geändert
                                   werden kann, muss diese Warning als ERROR
                                   angesehen werden   
char * const  = char * const   <- Keine Typkonvertierung notwendig
char *        = char * const   <- char *const wird implizit zu char * gewandelt
char * const  = char *         <- char * wird implizit zu char * const gewandelt

Das obige negative Beispiel führt in C nur in selten Fällen zu einer Compiler-Meldung. Der Programmierer ist somit gefordert, auf Typverletzung zu achten. In C++ führt diese korrekterweise zu einer Compiler-Meldung:

char *str="hallo";  //"hallo" ist ein Const-Zeiger
*str='p';           //Dereferenzierung eines nicht 
                    //Const-Zeigers ist möglich
                    //--> Programmabsturz

strcpy("dst","src");//Const-Zeiger als Ziel für
                    //Kopierfunktion
                    //-> Programmabsturz
strtok("token=value","="); //strtok() verändert Inhalt des
                           //ersten Parameters
                           //--> Programmabsturz

Öffnen im Compiler Explorer (C)
Öffnen im Compiler Explorer (C++)

Sonstiges

Bearbeiten

Bei Zeigern auf Zeiger kann der Zeiger als solches, die erste Dereferenzierung und die zweite Dereferenzierung konstant sein:

int   var;
int  *ptr=&var;
int      *     *      ptrr1 = &ptr;
int      *     *const ptrr2 = &ptr;
int      *const*      ptrr3 = &ptr;
int      *const*const ptrr4 = &ptr;
int const*     *      ptrr5=(int const** )&ptr;
...

Zeiger auf Funktionen

Bearbeiten

Ähnlich wie bei Zeigervariablen, welche die Adresse einer Variablen beinhalten und auf dessen Inhalt über die Dereferenzierung zugegriffen werden, gibt es Zeiger auf Funktionen, die die Startadresse einer Funktion beinhaltet und welche bei der 'Dereferenzierung' aufgerufen werden.

Der Datentyp zum Anlegen eines Funktionszeigers entspricht dem Funktionsprototyp, mit dem Unterschied, dass der Funktionsname der Variablenname ist und dieser geklammert und mit einem Stern (für Zeiger) versehen wird. Die Namen der Übergabeparameter sind optional:

//Über Funktionszeiger aufzurufende Funktion
void func(int par) {
  printf("%d\n",par);
}
	
void (*fptr1)(int par); //Funktionseiger auf obige Funktion
void (*fptr2)(int);     //Dito

fptr1=&func;            //Zuweisen der Funktionsadresse an Funktionszeiger
fptr2=func;             //Dito

(*fptr1)(4);            //Dereferenzierung=Aufruf
fptr2(3);               //Dito

fptr1(4,5);             //KO, da Parameter nicht mit Datentyp übereinstimmt
fptr1("hallo Welt");    //KO, da Parameter nicht mit Datentyp übereinstimmt
if(fptr1(4)==7)         //KO, da Rückgabewert nicht mit Datentyp übereinst.

Öffnen im Compiler Explorer

Auch von einem Funktionszeiger kann über Typedef ein Alias erzeugt werden. Der Alias steht jedoch nicht am Ende des Typedefs, sondern anstelle des Funktionszeigers:

typedef void * MALLOC_PTR(size_t);   //MALLOC_PTR ist der ALIAS
                                     //Sternchen gehört zu void
typedef void (*FREE_PTR)(void *ptr); //FREE_PTR ist der ALIAS

MALLOC_PTR  *malloc_ptr;    //Anlegen des Funktionszeigers 	 
FREE_PTR     free_ptr;      

malloc_ptr=&malloc;         //Zuweisung des Funktionszeigers
free_ptr  =&free;           //mit einer Startadresse

void *ptr=malloc_ptr(400);  //Entspricht dem Aufruf der malloc Funktion
free_ptr(ptr);

Öffnen im Compiler Explorer

Typkonvertierung

Bearbeiten

Über explizite Cast auf einen anderen Funktionszeigerdatentyp kann ein Funktionszeiger eines Datentyps in einen anderen Funktionszeiger konvertiert werden. Dies ist jedoch eher ein theoretischer Aspekt und hat in der Praxis keine Anwendung, da dies zu einem fehlerhaften Funktionsaufruf und ggf. zum Programmabsturz führt:

void dummy(void) {
  printf("dummy() called");
}
void (*fptr)(int);
fptr=(void (*)(int))&dummy;  //fptr wird mit einer Funktion mit anderem
                             //Parameter initialisiert
fptr(10);                    //dummy() wird nun mit einem Parameter
                             //aufgerufen

Öffnen im Compiler Explorer

Anwendungen

Bearbeiten

Funktionszeiger finden an diversen Stellen Anwendung:

  • Angaben von Callback Funktionen, die bei bestimmten Ereignissen aufgerufen werden:
void callbackfcn(int ereignisnr) {
  //Reaktion auf Ereignis
}
 
//Globale Variablen zum Speichern des Funktionszeigers
void(*ActionListener_cb)(int)=NULL;
 
void AddActionListener( void(*fcn)(int) ) {
  ActionListener_cb =fcn;
}

int main(int argc, char *argv[]) {
  AddActionListener(callbackfcn);
  while(1) {
    int but=mouse_get();
    if((but!=0) && (ActionListener_cb!=NULL))
      ActionListener_cb(but);
  }
  return 0;
}

Öffnen im Compiler Explorer

Insbesondere GUI-Anwendung nutzen dieses Feature zur Trennung von Applikation und Betriebssystem. Zunächst reagiert das Betriebssystem auf die Tastendrücke einer Maus. In Abhängigkeit der Mausposition ruft das Betriebssystem die hinterlegte CallBack Funktion auf.
Grundprinzip hierbei ist die ereignisorientierte Programmierung (siehe    Ereignis (Programmierung))
  • Generische Such-/Sortieralgorithmus. Über Funktionszeiger kann einer generischen Such-/Sortieralgorithmus eine 'Funktion' übergeben werden, welche die Such-/Sortierbedingung beinhaltet:
//Zu sortierende Datenstruktur
struct xyz {int x,y,z;};

//Vergleichsfunktion für obigen Datentyp
int sort_x(const void *p1, const void *p2) {
  const struct xyz *ptr1=p1;
  const struct xyz *ptr2=p2;
  if(ptr1->x < ptr2->x)
    return -1; //Zur Darstellung dass das erste Argument kleiner
  else if(ptr1->x > ptr2->x)
    return +1;  //Zur Darstellung das das erste Argument größer
  else
    return 0;
}
//Weitere Vergleichsfunktion für obigen Datentyp 
int sort_y(const void *p1, const void *p2) {
  return ((const struct xyz*)p1)->y - ((const struct xyz *)p2)->y;
}
 
int main(int argc, char *argv[]) {
  struct xyz arr[]={{1,3,2},{7,1,9},{0,2,11}};
  qsort(arr,sizeof(arr)/sizeof(arr[0]),sizeof(struct xyz),sort_x);
  for(size_t lauf=0;lauf<3;lauf++) 
    printf("%d",arr[lauf].x);
 
  qsort(arr,sizeof(arr)/sizeof(arr[0]),sizeof(struct xyz),sort_y);
  for(size_t lauf=0;lauf<3;lauf++)
    printf("%d",arr[lauf].y);
  return 0;
}

Öffnen im Compiler Explorer

  • DLL/Shared Librarys entsprechen einer Funktionsbibliothek, welche entweder zum Start der Anwendung oder zur Laufzeit durch die Anwendung zur eigentlichen Anwendung dazu gebunden werden. Werden die Funktionen zum Programmstart (durch den Loader) dazu gebunden, so übernimmt der Loader der Adressauflösung, so dass diese Funktionen wie 'normale' Funktionen aufgerufen werden können (Early Binding). Die Standard-C-Library libc.so, die Mathematische Library libm.so sind typische Beispiele hierfür. Werden die Funktion während der Laufzeit eingebunden, so ist die Anwendung für die Adressauflösung zuständig (Late Binding). In Posix Konformen Betriebssystemen erfolgt dies über dlopen() dlsym() und dlclose():
Anwendung, welche eine Libray zur Laufzeit einbindet Library: Ansammlung von Funktionen
Entspricht einem C-Programm ohne main() Funktion
#include <dlfcn.h>   //dlopen()/..
 
int main(int argc,char *argv[])
{
//Late Binding
//Einbinden der Library erfolgt händisch
void *handle= dlopen("./bin/liblib.so",
                    RTLD_LAZY|RTLD_LOCAL);
 
//Abfrage der Adresse der setter-Funktion
void *ptr=dlsym(handle,"setter");
//Konvertierung in den korrekten Datentyp
void (*setter)(int var)=ptr;
 
//Abfrage der Adresse der getter-Funktion
int (*getter)(void)=dlsym(handle,"getter");
    
//Aufruf der Funktion
setter(4711);
printf("%d",getter());
 
dlclose(handle);
}
void __attribute__ ((constructor)) my_load(void);
void __attribute__ ((destructor))  my_unload(void);
 
// Called when the library is loaded 
// and before dlopen() returns
void my_load(void) {
  printf("LIB: " __FILE__ " attached\n");
}
 
// Called when the library is unloaded
// and before dlclose() returns
void my_unload(void) {
  printf("LIB: " __FILE__ " detached\n");
}
 
int glob_data=9;
int getter(void) {
  printf("Lib: Get %d\n",glob_data);
  return glob_data;
}
 
void setter(int var) {
  printf("Lib: Set %d=%d\n",glob_data,var);
  glob_data=var;
}
Dieser Anwendungsfall wird bspw. in Python genutzt, welche komplexe Rechenaufgaben über Librarys tätigt. Diese Librarys werden erst zur Laufzeit eingebunden.
  • Eine   virtuelle Methode ist in der objektorientierten Programmierung eine Methode einer Klasse, deren Einsprungadresse erst zur Laufzeit ermittelt wird. Dieses sogenannte dynamische Binden ermöglicht es, Klassen von einer Oberklasse abzuleiten und dabei Funktionen zu überschreiben bzw. zu überladen:
#include <iostream>

using namespace std;

struct Tier {
  // Rein virtuelle Methode
  virtual void essen() = 0;
};

struct Wolf: Tier {
  // Implementierung der virt. Methode
  void essen() {
    cout << "Essen Fleisch" << endl;
  }
};
struct Kuh: Tier {
  // Implementierung der virt. Methode
  void essen() {
    cout << "Sind Vegetarier!" << endl;
  }
};
int main(void) {
  //Tier obj0; kann aufgrund der
  //virtuellen Methode nicht
  //als Objekt angelegt werden
  Wolf obj1;
  Kuh  obj2;
    
  Tier *obj;
  obj=&obj1;
//Aufruf der virtuellen Mehoden
  obj->essen();
  obj=&obj2;
  obj->essen();
  return 0;
}

Öffnen im Compiler Explorer

Der Compiler setzt virtuelle Methoden über Funktionstabelle / VTABLE (= Arrays von Zeiger auf Funktionen) um (siehe   Tabelle virtueller Methoden). In der Hauptklasse wird für jede virtuelle Methode ein Funktionszeiger als verstecktes Attribut integriert. Mit Aufruf des Konstruktors der abgeleiteten Klasse wird dieser Funktionszeiger auf die passende Funktion gesetzt. Beim Aufruf der virtuellen Methode wird eine Wrapper-Methode aufgerufen, welche den Funktionszeiger dereferenziert.

Sonstiges

Bearbeiten

Die Schreibweise für Funktionszeiger ist für sich schon 'kryptisch'. Werden Funktionszeiger als Rückgabewert von Funktionen benutzt, so lässt sich der eigentliche Datentyp nicht auf den ersten Blick ermitteln:

int (*fpfi (int (*)(long),int)) (int, ...);
//Dies ist ein Prototyp für eine Funktion, welche 2 Übergabeparameter hat.
//Der erste Parameter ist ein Zeiger auf eine Funktion mit Rückgabewert int
//und Übergabewert long und der zweite Parameter ist vom Typ int. 	//Rückgabewert der Funktion ist ein Zeiger auf eine Funktion, welche int
//zurückgibt und als Parameter ein int und belieibig viele weitere bekommt
int par_func(long);  //Protoyp
int main(int argc, char *argv[]) {
  fpfi(&par_func,3)(2);  //Aufruf der obigen Funktion
  return 0;
}
int par_func(long par) {
  return printf("par_func(%ld) called\n",par);
}
int ret_func(int par,...) {
  return printf("ref_func called(%d)\n",par);
}
//Implementierung der obigen Funktion
int (*fpfi (int (*fptr)(long)     ,int par))    (int,...) {
  printf("fpfi called(%p,%d)\n",fptr,par);
  fptr((long)2);
  return &ret_func;
}

Öffnen im Compiler Explorer

Hier empfiehlt sich die Nutzung von Aliasen:

//Original Prototype
int (*fpfi (      int (*)(long)     ,int ))    (int,...);

//Besser lesbarer Prototype
typedef int (*PAR_FUNC)(long);
int (*fpfi(     PAR_FUNC,int)) (int, ...);
//Sehr gut lesbarer Prototype
typedef int (*PAR_FUNC)(long);
typedef int (*RET_FUNC)(int, ...);
RET_FUNC fpfi(PAR_FUNC,int);

Restrict-Zeiger Type Qualifier

Bearbeiten

Syntax: Datentyp * restrict Variablenname

Mit dem Type Qualifier restrict (seit C99 und nur in C, nicht in C++ enthalten) wird dem Compiler mitgeteilt, dass das Objekt, auf welches der restrict Zeiger zeigt, während der Lebenszeit des restrict Zeigers nur über diesen restrict Zeiger im Zugriff steht.
Ist das Objekt bspw. ein Array und dieses Array wird einer Funktion als restrict Zeiger übergeben, so wird dieses Objekt exklusiv dieser Funktion zugeordnet. In nebenläufigen Threads darf während der Laufzeit dieser Funktion nicht parallel auf das Objekt zugegriffen werden.

Diese Einschränkung ermöglicht es dem Compiler, einen effektiveren Code zu erzeugen (in dem er bspw. den Inhalt des über den Zeigers zugegriffen Speicherbereiches längere Zeit (auch über Sequence Points hinweg) cacht).

Beispiel:

void updatePtrs(size_t *restrict ptrA, 
                size_t *restrict ptrB, 
                size_t *restrict val) { 
  *ptrA += *val; 
  *ptrB += *val; //Compiler kann den dereferenzierten Wert von val cachen
	             //so dass hier ein Speicherzugriff gespart werden kann
}


Viele Standard-C-Library Funktionen nutzen diesen Qualifier:

void *memccpy(      void *restrict dest, 
              const void *restrict src, int c, size_t n);
//memcpy erwartet ergänzend, dass sich die Speicherbereich
//von src[0..n-1] und dst[0..n-1] nicht überlappen.

Sonstiges

  • Restrict kann auch auf Elemente einer Struktur / Union angewendet werden, wobei restrict dann nur für die Gültigkeit des dazugehörigen Objektes gilt

Referenzen

Bearbeiten

Zeiger sind ein mächtiges, aber auch fehleranfälliges Werkzeug. Zur Reduzierung der Fehlermöglichkeiten wurden in C++ und anderen Sprachen Referenzen eingeführt.
Referenzen sind 'interne Zeiger' auf Variablen. Sie werden genauso verwendet wie gewöhnliche Variablen, verweisen jedoch auf das Objekt, mit dessen Adresse sie initialisiert wurden. Beim Zugriff auf die Referenz ersetzt der Compiler den Zugriff durch die Dereferenzierung.

Dadurch, dass Referenzen mit dem Anlegen initialisiert werden und keine Zeigerarithmetik möglich ist, sind diese sicherer in der Nutzung als Zeiger. Da die Anwendung von Referenzen identisch zur Anwendung von Variablen ist, ist auf den ersten Blick nicht sichtbar, ob es sich um einen 'Call by Reference' oder einen 'Call by Value' Zugriff handelt. Bei Zugriff auf Referenzen wird das Objekt geändert, auf welches es zeigt. Bei Variablenzugriff wird nur die adressierte Variable geändert.

C++ Referenzen

Bearbeiten

C++ Referenzen entsprechen einem Zeiger, mit dem Unterschied:

  • das Referenzen über den & Operator (und nicht dem * wie bei Zeigern) angelegt werden
  • diese mit dem Anlegen initialisiert werden müssen, so dass die Referenz immer auf ein gültige Adresse zeigt
  • das zum Dereferenzieren kein Dereferenzierungs-Operator '*' notwendig ist
  • auf die Speicheradresse des internen Zeigers nicht zugegriffen werden kann
  • keine Zeigerarithmetik hiermit getätigt werden kann

Beispiel:

int var1=0;
int &ref=var1;  //Definition einer Referenz
ref=1;          //Änderung von var1

int var2=2;
ref=var2;   //Referenz kann nicht neu gesetzt werden
            //Referenz zeigt weiterhin auf var1
ref=3;      //Änderung von var1

void foo(int par,int &ref) {
  par++;
  ref++;    //Änderung von var2
}
var1=var2=0;
foo(var1,var2);

Öffnen im Compiler Explorer

Java Referenzen

Bearbeiten

Wird ein Objekt in Java angelegt, so wird Speicher im Heap reserviert und der Zeiger auf diesen Speicherbereich in einer Referenz gespeichert. In diesem Sinne entsprechen Java Referenzen den C++-Referenzen.
Wird ein Objekt nicht mehr benötigt, so kann dieses durch 'Löschen' der Referenz gelöscht werden. Der Garbage Collector erkennt, dass auf dem im Heap reservierten Speicherbereich keine Referenz mehr zeigt und gibt diesen Speicherbereich frei. Damit der Heap nicht zu sehr fragmentiert wird (und ggf. weitere Speicheranforderungen im Heap nicht mehr bedienen kann), führt der Garbage Collector ergänzend eine Defragmentierung des Heaps durch. Dabei werden Speicherbereiche verschoben, so dass die entstandenen Lücken geschlossen werden. Mit dem Verschieben müssen dann auch die Referenzen, welche die Startadresse auf diesen Speicherbereich beinhalten, angepasst werden.
Im Unterschied zu C++ können sich die Speicheradressen, auf denen die Java-Referenzen zeigen, durch die virtuelle Maschine geändert werden.

Umgang mit Zeigern

Bearbeiten

Zeiger sind ein mächtiges, aber auch fehleranfälliges Werkzeug. Bei sachgemäßer Verwendung bieten Zeiger die Möglichkeit zur Geschwindigkeitssteigerung und zur effektiven Nutzung des Speichers. Auch werden hiermit Features ermöglicht, die sonst nicht darstellbar sind (Zugriff auf Peripherie, dynamische Funktionalitäten). Eine falsche Nutzung von Zeigern führt im Best Case Fall direkt zu einem Programmabsturz. Im Worst Case Fall wird eine Speicherstelle verändert, die erst im späteren Programmablauf sich bemerkbar macht.

Typische Fehler

Bearbeiten

C/C++ geht immer davon aus, das alle Zeiger auf 'gültige' Speicheradressen zeigen. Dereferenzierungen werden somit ungeprüft ausgeführt.
Bei Zugriff auf ungültige Speicheradressen wird oftmals von einem Bufferoverflow/Stackoverflow/Dangling Zeiger gesprochen.

Bufferoverflow

Bearbeiten

Von einem Bufferoverflow wird gesprochen, wenn außerhalb des reservierten Speichers eines Arrays oder eines Heap-Bereiches zugegriffen wird.

int arr[10];
arr[10]=11;   //Bufferoverflow
int *ptr=(int *)malloc(10);
*(ptr+10)=11;  //Bufferoverflow

Stackoverflow

Bearbeiten

Der von einem Prozess adressierbare Speicher ist begrenzt. Dementsprechend ist auch die max. Größe des Stacks begrenzt. Da für jeden Funktionsaufruf Speicher auf dem Stack reserviert wird, kann insbesondere bei Rekursion der verfügbare Speicher ausgeschöpft werden. Im Anschluss an den Speicherbereich des Stacks befindet sich zumeist der Speicherbereich des Heaps. Ein Stackoverflow bedeutet somit, dass der Heap überschrieben wird.
Alternativ kann auch das Betriebssystem den Prozess beenden, wenn dieser mehr Stack vom OS anfordert, als das OS diesem Prozess/Thread bereitstellt.

Ergänzend wird mit Stackoverflow oftmals auch interpretiert, dass auf Bereiche außerhalb von lokalen Variablen zugegriffen wird. Im Gutfall wird auf den freien Bereich 'oberhalb' des Stacks zugegriffen. Im Schlechtfall wird auf den Stackbereich der aufrufenden Funktion zugegriffen.
Auf dem Stack wird nicht nur der Speicher für die lokalen Variablen gehalten, sondern auch die Rücksprungadresse zur aufrufenden Funktion. Wird diese aufgrund eines Bufferoverflows beschrieben, so würde mit Beenden der Funktion die Programmausführung an einer nicht definierten Stelle fortgesetzt werden. Virenhersteller nutzen diese Möglichkeit gerne aus, um Schadcode auf dem System zur Ausführung zu bringen.

Dangling Zeiger

Bearbeiten

Von einem Dangling Zeiger spricht man, wenn ein Zeiger auf eine Variable zeigt, die nicht mehr gültig ist:

int *ptr;
{
  int var;
  ptr=&var;
}
*ptr=4711; //Adresse des Zeigers nicht mehr gültig

ptr=(int *)malloc(100*sizeof(int));
free(ptr);
ptr[0]=4711;  //Adress des Zeigers nicht mehr gültig

Best Practice

Bearbeiten
  • Code Review durch eine andere Person
  • Zeiger müssen vor der Dereferenzierung mit einer gültigen Speicheradresse initialisiert werden
  • Gültigkeitsbereich des Speichers, auf den der Zeiger zeigt, kontrollieren
  • Funktionen, welche Zeiger zurückgeben, geben im Fehlerfall den NULL-Zeiger zurück. Der Rückgabewert von Funktionen ist zwingend zu kontrollieren
  • Compilerwarnungen hinsichtlich Zeiger sind als Fehler anzusehen. Ggf. Compiler auf max. Warning Level stellen (-Wall)
  • 'Googlen' hilft bei Problemen im Umgang mit Zeigern nicht weiter, da zumeist die 'anderen' ebenfalls Zeiger nicht verstanden haben und eher den Fehler mit einem Workaround umgehen
  • Zeiger auf Zeiger sind schwer verständlich. Diese sind daher zu vermeiden, resp. nur dort einzusetzen, wo sie unbedingt benötigt werden.
  • Entsprechend Referenzen Zeiger als const-Zeiger anlegen
  • Zeiger auf NULL setzen, wenn deren Speicheradresse, auf welche diese zeigt, nicht mehr gültig ist
#define SETNULL(ptr) ({typeof(ptr)dummy=ptr; ptr=NULL; dummy;})
char *ptr=malloc(100);
strcpy(ptr,"hello world");
free(SETNULL(ptr));