Pointer-Arithmetik

Aus mborm wiki
Zur Navigation springen Zur Suche springen

Pointer sind Speicherzellen, deren Inhalt die Adresse einer anderen Speicherzelle ist. Der Inhalt solcher Speicherzellen wird manchmal als Variablen bezeichnet. Man spricht dabei auch von indirekter Adressierung, weil man die Adresse einer Variablen aus einer anderen Variablen holen muss. Beispiel dafür sind Arrays, die über einen Index (indirekt) adressiert werden.

Es gibt auf der CPU zwei Arten von Speicherzellen: Registervariablen und Speichervariablen.

Registervariablen

Registervariablen sind Speicherzellen innerhalb der CPU. Sie werden über ihren Namen angesprochen. Diese Register haben keine Adresse im Speicher und können nicht über Pointer angesprochen werden. Registervariablen werden zur Speicherung von Pointern und zur Berechnung von Pointer-Adressen verwendet.

Die Register auf den ersten Intel 8-Bit CPUs (8080) wurden einfach durchnummeriert als: A, B, C, D. Jede Zelle nimmt ein Byte auf. Dabei stehen die Buchstaben A...D für Akkumulator, Base-Register, Counter und Data-Register weil es dafür Spezialbefehle gibt.

Die zweite, dritte und vierte Generation von Intels CPU (8086, 80186, 80286) hat bereits 16 Bit Register, die als AX, BX, CX und DX bezeichnet werden. Jedes Byte kann auch einzeln angesprochen werden über AL, AH; BL, BH,; usw. Das X im Namen steht hierbei für "eXtended", L = "Low" und H = "High".

Zusätzlich hat die CPU noch einen Stack-Pointer SP für die Adresse des Stacks und einen Base-Pointer BP, der die Adresse eines optionalen Stackframes aufnimmt. Dazu kommen zwei weitere Register DI und SI, die für Destination-Index und Source-Index stehen. Diese Register nehmen die Pointer-Adressen von Strings auf, um schnelle Zeichenkettenoperationen durchführen zu können.

Beispiel: Um die Funktion strcpy() durchzuführen, wird das Counter-Register CX mit der Länge des Strings geladen. In SI kommt die Adresse des Strings und in DI die Adresse der Kopie. Dann wird der CPU-Befehl rep mov aufgerufen und der komplette String wird per Hardware kopiert.

Die 32-Bit CPUs bekamen 32-Bit Register, die als EAX, EBX, ECX, EDX, ESP, EBP, EDI und ESI bezeichent werden. E steht dabei für "Enhanced". Die 16-Bit Register (AX, BX,...) und 8-Bit Register (AL, AH, BL, BH,...) stehen auch weiterhin zur Verfügung. Wichtig dabei: Für Adresspointer werden immer die kompletten Register benötigt. Adresspointer benutzen immer die komplette Wortbreite der CPU, sonst ließe sich der Adressraum nicht erreichen

Auf 64-Bit CPUs heißen die Register RAX, RBX, ..., RDI, RSI. Es kommen noch 8 weitere Universalregister R8 ... R15 neu hinzu.


Speichervariablen

Speichervariablen liegen im Arbeitsspeicher und werden über ihre Speicheradresse angesprochen. Der Compiler ordnet die Speicheradresse einem Namen zu, der vom Programmierer im Quellcode festgelegt ist. Auf diese Weise erhalten Speichervariablen nach außen ebenfalls einen Namen. Im kompilierten Binärcode des Programms werden die Namen aber immer durch ihre Adressen ersetzt.

Der Zugriff auf den Inhalt einer Speichervariablen erfolgt im Code über ihren Namen. Beispiel:

int value = 10;

Dieser Befehl schreibt den Wert 10 in die Speicheradresse, die durch den Namen value addressiert wird. Der Programmierer braucht die Adresse von value nicht zu kennen. Bei Bedarf kann die Adresse leicht ermittelt werden:

int * address = &value;

Das schreibt die Adresse von value in die Speichervariable address. Das Zeichen & vor dem Variablennamen besagt, dass die Adresse der Variablen und nicht deren Inhalt verwendet wird. address enthält also nicht den Wert von value, sondern deren Ort im Speicher. Der Wert kann daraus aber ebenfalls leicht ermittelt werden:

int wert = *address;

Der Befehl schreibt den Inhalt der durch address festgelegten Adresse nach wert. Den Vorgang bezeichnet man auch als indirekte Adressierung. Bei der Schreibweise (Notation) von Adress-Operationen wird immer vom Stern * (asterisk) gebraucht gemacht.

Die Notation von C schreibt vor, dass Adress-Speichervariablen bei der Definition mit einem * gekennzeichnet werden. Solche Adress-Variablen werden allgemein als Pointer bezeichnet, weil sie auf eine Speicheradresse zeigen. Der Zugriff auf den Inhalt eines Pointers wird im Code ebenfalls durch den Stern * gekennzeichnet. Dem Zeichen * kommen damit zwei verschiedenen Bedeutungen zu, die man aber nicht verwechseln kann.

Bei der Definition eines Pointer wird * hinter den Datentyp gesetzt: int * pointer = ... (Die Schreibweise von int * pointer ist dabei egal. Man findet sowohl int* pointer als auch int *pointer)

Beim Zugriff auf den Inhalt der durch den Pointer addressierten Speicherstelle wird * vor den Variablennamen gesetzt: wert = *pointer ...

Der Datentyp des Pointers bestimmt, auf welchen Datentyp er zeigt. In diesem Fall ist der Datentyp int * und zeigt auf eine int Variable.

Auch Adressen von Variablen müssen immer durch & gekennzeichnet werden, sonst gibt es Probleme:

int * adress = value; // ohne &

Dieser Befehl ist fehlerhaft, weil hier nicht die Adresse von value, sondern deren Inhalt verwendet wird. Der Compiler wird einen Fehler melden, weil der Datentyp nicht zum Pointer passt.

Zusammengefasst: Ein * hinter einem Datentyp definiert einen Pointer. Ein * vor einem Pointer bezeichnet den Inhalt des vom Pointer addressierten Speichers. Das & vor einem Variablennamen bezeichnet deren Speicheradresse.

Datentypen von allen Pointern sind außerdem immer vom Typ void *. void bedeutet "Leere" und ist unbestimmt. Ein Zeiger auf void * zeigt einfach nur auf "irgendwas leeres". Sinnlos ist void * deshalb aber nicht. Dieser Datentyp wird oft verwendet, wenn der Datentyp noch nicht feststeht. void * ist sozusagen der Joker unter den Pointern. Bei der Deklaration von qsort() wird davon heftig Gebrauch gemacht.

void qsort(void *basis, size_t nmemb, size_t groesse, int (*vergleich)(const void *, const void *));

void * wird hier verwendet, weil qsort mit verschiedenen Arten von Pointern klarkommt. Der Programmierer kann unterschiedliche Datentypen zur Sortierung übergeben.

Eine weitere Verwendung von void * ist das Erzwingen von Adressen (typecast):

int * adress = (void *) value;

Das erzwingt die Verwendung des Wertes von value als Adresse. value hat den Wert 10 (s.o.). Die Variable address wird dadurch mit der Adresse 10 geladen. Auch für diese Art von Pointerbehandlungen gibt es sinnvolle Anwendungen.

Die korrekte Verwendung des Pointer-Datentyps ist deshalb extrem wichtig, weil nur so sichergestellt werden kann, dass der Pointer auf die richtigen Speichergröße zeigt. Ein char-Pointer char * zeigt auf eine 8-Bit Speichervariable und ein long-Pointer zeigt auf eine 32-Bit oder 64-Bit Speichervariable (ahhängig von CPU und Betriebssystem). Ein void-Pointer void * zeigt auf eine irgendeine (später genauer wählbare) Speichervariable.

Alle Pointer haben die gleiche Speichergröße, die der Wortbreite der CPU entspricht. Auf 32-Bit CPUs sind alle Pointer 32-Bit groß und auf 64-Bit CPUs beträgt die Größe 64-Bit. Es gibt Ausnahmen, die hier aber unwichtig sind. Wichtig ist dagegen, dass jeder Pointer zu dem Datentyp passen muss, auf den er zeigt.


Arrays

Arrays werden grundsätzlich über Pointer addressiert. Der Pointer zeigt dabei immer auf die Anfangsadresse. Im Programmcode wird der Pointer quasi mit dem Array gleichgesetzt. Wird ein Array an eine Funktion übergeben, dann wird in Wahrheit der Pointer übergeben. Dadurch ist C sehr schnell, weil nicht das ganze Array bewegt werden muss, wie in vielen anderen Sprachen. Beispiel:

int array[10];

Das erzeugt ein leeres Arrays mit 10 Integern. Leer bedeutet tatsächlich leer und nicht "mit 0 gefüllt". Das Array soll jetzt mit den Zahlen 0 ... 9 geladen werden:

for (int n = 0; n < 10; n++) array[n] = n;

Um den Pointer mit Namen array besser zu verstehen, kann man diese Codezeile auch anders formulieren.

for (int n = 0; n < 10; n++) *(array + n) = n;

Das sieht seltsam aus, aber es macht genau das gleiche wie oben. Zuerst wird das Innere der Klammer berechnet: array + n. Dann kommt das Äußere der Klammer dran: *(...) = n; Der Stern * bezeichnet den Inhalt der durch den berechneten Pointer in der Klammer adressierten Speicherstelle und dort hinein wird n geschrieben.

array ist ein int-Pointer. Erhöht man die Adresse um 1, dann zeigt der Pointer auf den nächsten Eintrag. Addiert man n zum Array, dann zeigt der Pointer auf den n-ten Eintrag. Die Magie dahinter ist, dass der Compiler nicht einfach die Adresse des Pointer um 1 erhöht, um zum nächsten Eintrag zu zeigen, sondern um die Größe eines Eintrags. Ein int sind 4 Byte, also erhöht der Compiler die Adresse um 4. Addiert man n zum Array, dann erhöht der Compiler die Adresse um n * 4.

Das Beispiel lässt sich noch etwas ausbauen:

for (int *a = array, n = 0; n < 10; a++, n++) *a = n;

Mit "a" wird ein Hilfspointer erzeugt, der später inkrementiert wird: a++. Diese Operation würde das Array zerstören, weil der Array-Pointer nicht mehr auf den Anfangsbereiche zeigt. Deshalb wird ein Hilfspointer verwendet und die Array-Adresse hineinkopiert. Die Inkrementierung des Pointers a++ addiert in jedem Schritt 4 zum alten Wert und nicht 1, wie bei der Inkrementierung von Integern. Die Adressberechnung funktioniert mit jeder beliebigen Größe von Array-Elementen und wird vom Compiler automatisch erledigt.


Strings

Strings (Zeichenketten) sind char-Arrays aus druckbaren Zeichen, mit einigen Besonderheiten. Alle Strings unter C werden mit einem Nullbyte (\0) abgeschlossen. Daran erkennen viele Funktionen das Ende des Arrays. Das kann bei falscher Verwendung einiger String-Funktionen zu Problemen führen. Außerdem legt der Compiler Strings üblicherweise im schreibgeschützten Speichersegment (.text) an. Strings können daher nicht nachträglich vom Programm geändert werden.

Üblicherweise werden Strings auf zwei Arten angelegt:

const char *text = "Das ist ein Text";
//oder
const char text[] = "Das ist ein Text";

Die 1. Methode wird wohl am häufigsten verwendet, ist aber etwas ineffektiv. Der Compiler legt als erstes die eigentliche Zeichenkette als anonymes char-Array im schreibgeschützten Speichersegment an und setzt dabei automatisch ein Nullbyte ans Ende. Anschließend legt er den char-Pointer 'text' an und lädt ihn mit der Adresse des anonymen char-Arrays. Diese Art von Text ist ein Pointer, der auf ein char-Array zeigt.

Die zweite Methode ist effektiver. Der Text wird unmittelbar als char-Array mit Nullbyte angelegt. Der Pointer 'text' zeigt dabei direkt auf den Anfang dieses Arrays. Der zusätzliche char-Pointer des 1. Beispiels entfällt.

Beide Arten von Strings funktionieren meist wie gewünscht, da der Compiler mit den unterschiedlichen Arten zurechtkommt. Allerdings sollte auch der Programmierer die Unterschiede kennen. Im ersten Beispiel ist der Text ein Pointer auf ein char-Array, im 2. ist der Text selber ein char-Array.

Um sich keine Probleme mit Pufferüberläufen einzuhandeln, sollte man die verwendeten String-Funktionen sehr gut kennen. Die Funktion strlen(string) liefert die Länge des Strings ohne Nullbyte zurück. Dagegen gibt sizeof(string) die Länge einschließlich Nullbyte an. Das funktioniert aber nur mit Strings des 2. Beispiels. Für Strings des 1. Beispiels ergibt sizeof(string) die Adresslänge des zusätzlichen char-Pointers in Byte.

Auch sind einige Funktionen wie strncpy() mit Vorsicht zu genießen. Falls der String länger als der zu kopierende Bereich ist, wird kein Nullbyte an die Kopie angehängt, was später im Programm zu einem Pufferüberlauf führen wird. Es ist daher guter Stil ans Ende des Strings explizit ein Nullbyte zu schreiben oder eine sichere Funktion zu verwenden, die das automatisch macht.

char *my_strncpy(char *Ziel, const char *Quelle, size_t n) {
  strncpy(Ziel, Quelle, n);
  Ziel[n] = \0;

  return Ziel;
}