Les pointeurs

Un pointeur est une variable qui contient l’adresse d’une autre variable en mémoire. C’est l’un des concepts les plus puissants, les plus simple et les plus délicats du C. Mais peu d’étudiant arrivent a le maitriser.

Rêgle numéro 1 : n’importe quelle variable préfixé de & ajoute * au type (ajoute une indirection)
Rêgle numéro 2 : n’importe quelle variable préfixé de * enlèbe 1 * au type (supprime une indirection)

L’opérateur & donne l’adresse d’une variable :

int x = 5;      // x est une variable local, stockée à une adresse mémoire
int *ptr = &x;  // ptr contient l'adresse de x explicitement
int **pptr = &ptr;  // pptr contient l'adresse de ptr, qui elle même contient l'adresse de x

La variable ptr est de type int* (pointeur vers int). Elle stocke l’adresse mémoire de x.

Pour afficher une adresse :

printf("Adresse de x et valeur du pointeur : %p\n", ptr);
printf("Valeur du pointeur du pointeur: %p\n", pptr);

Ce qui donnera

Adresse de x et valeur du pointeur : 0x7ffde439f7b4
Valeur du pointeur du pointeur: 0x7ffde439f7a8

Les adresses ce suivent, c’est normal, nous somme dans la même stack frame.

L’opérateur * enlève un pointeur, c’est-à-dire qu’il accède à la valeur à l’adresse pointée :

int x = 5;
int *ptr = &x;

printf("%d\n", *ptr);  // affiche 5 (la valeur de x)
*ptr = 10;              // modifie x à travers le pointeur
printf("%d\n", x);     // affiche 10

Pointeurs et tableaux

En C, un tableau statique est essentiellement un pointeur vers son premier élément. Le nom d’un tableau se convertit automatiquement en pointeur. (voir déclaration et propriété des tableaux statiques et passage de tableau en paramètre des fonction)

int tab[] = {10, 20, 30, 40, 50};
int *ptr = tab;  // ptr pointe vers le premier élément

Puisque les données sont contigue en mémoire alors nous avons la propriété suivante : l’indexation tab[i] est équivalente à *(ptr + i) :

printf("%p\n", tab);    // 0x7ffc5966d6b0
printf("%p\n", ptr);    // 0x7ffc5966d6b0 (même addresse !)
printf("%d\n", tab[0]);    // 10
printf("%d\n", *ptr);       // 10 (même chose forcément)
printf("%d\n", *(ptr + 2)); // 30 (équivalent à tab[2])

Attention, n’oublié pas qu’en C nous manipulons toujours fondamentalement des données brutes. Ansi ptr + 2 est en réalité développé en ptr + 2 * sizeof(int). Le compilateur connais le type pointé int et déduit le déplacement mémoire nécéssaire !

Arithmétique des pointeurs

On peut effectuer des opérations arithmétiques sur les pointeurs. L’addition ou la soustraction tient compte de la taille du type pointé.

int tab[] = {10, 20, 30, 40, 50}; // addresse de base 0x7ffe4fd43130
int *ptr = tab;

ptr++;  // avance au prochain int (généralement +4 octets si int fait 4 octets)
printf("%p -> %d\n", ptr , *ptr);  // affiche 0x7ffe4fd43134 -> 20

ptr += 2;  // avance de 2 positions
printf("%p -> %d\n", ptr , *ptr);  // affiche 0x7ffe4fd4313c -> 40

ptr--;  // recule d'une position
printf("%p -> %d\n", ptr , *ptr);  // affiche 0x7ffe4fd43138 -> 30

Idem pour le code suivant :

char *cptr = (char *)1000;    // pointeur vers char (1 octet)
int *iptr = (int *)1000;      // pointeur vers int (généralement 4 octets)

char *c1 = cptr + 1;  // adresse 1001
int *i1 = iptr + 1;   // adresse 1004

printf("%p\n", cptr);  // 0x3e8 (1000)
printf("%p\n", c1);    // 0x3e9 (1001)
printf("%p\n", iptr);  // 0x3e8 (1000)
printf("%p\n", i1);    // 0x3ec (1004)

Soustraction de pointeurs

La soustraction de deux pointeurs du même type produit un ptrdiff_t, entier signé représentant la distance en éléments (non en octets) :

int tab[] = {10, 20, 30, 40, 50};
int *ptr1 = &tab[1];
int *ptr2 = &tab[4];

int difference = ptr2 - ptr1;  // 3 (distance en éléments, pas en octets)
printf("%d\n", difference);    // affiche 3

Donc en réalité le compilateur ajoute une division entière automatiquement :

  • 0x7ffe4fd43134 - 0x7ffe4fd43140 = 12 octets
  • 12 / sizeof(int) = 3 éléments

Comparaison de pointeurs

Les pointeurs reste des entier, donc ils peuvent être comparés avec <, <=, >, >=, ==, !=. Cela permet de tiré partie du fait que les données sont contigue.

int tab[] = {10, 20, 30};
int *p1 = &tab[0]; // int* p1 = tab; identique
int *p2 = &tab[2]; // int* p1 = tab+2; identique

if (p1 < p2) {
    printf("p1 pointe avant p2\n");
}

if (p1 == &tab[0]) {
    printf("p1 pointe vers le premier élément\n");
}

Pas de vérification

Bien que contraire à la plupart des bonnes pratiques moderne, on peut créer des pointeurs pointant juste après le dernier élément d’un tableau (pour les bornes de boucles, par exemple) :

int tab[] = {10, 20, 30};
int *begin = tab;
int *end = tab + 3;  // pointe après le dernier élément pas de problème ce n'est qu'une adresse

for (int *ptr = begin; ptr < end; ptr++) {
    printf("%d\n", *ptr);
}

Toutefois, déréférencé un pointeur hors limites est undefined behavior. Il y a de grande chance que cela passe sans faire cracher l’application. Mais nous ne savons pas exactement ce qui a été modifié. Il est possible que le tableau tab soit légèrement plus grand lié a des notions de padding. Dans ce cas ce seras cette zone mémoire qui sera modifier. S’il ni a pas de padding alors probablement que ce sera la variable begin qui sera partielement modifié !

*(tab + 3) = 45; // Probleme je modifie des données lié au padding
*(tab + 4) = 45; // Probleme je modifie les 4 premiers octet de la variable begin

Stack frame :

Adresse Contenu Commentaire
0x1000 10 tab[0]
0x1004 20 tab[1]
0x1008 30 tab[2]
0x100C ??? padding = tab[3]
0x1010 adresse 0x1000 begin = tab[4] + tab[5]
0x1018 adresse 0x100C end = tab[6] + tab[7]

Pointeurs génériques avec void*

Un pointeur void* peut pointer vers n’importe quel type. Cela effectue également un éffacement du type.

int x = 42;
void *ptr = &x;  // pointeur générique AHHH on a perdu le type !

// *ptr = 8; // AHHH ça ne marche pas !
*(int*)ptr = -1; // Ouf on retombe sur nos pieds
*(char*)ptr = 8;  // ?

La dernière ligne est tout à fait possible, mais dans ce cas on ne modifira que le premier octet, c’est a la charge du programmeur de vérifier ce qu’il fait. Dans ce cas cela produira la valeur 0x08ffffff (puisque -1 a mis la mémoire a 0xffffffff) dont l’entier (int) qui auras une interprétation différente selon l’architecture (big endian ou little endian).

Pointeurs invalides et NULL

Un pointeur non initialisé contient une valeur arbitraire et déréférencé risque de crasher le programme (comportement indéfini). C’est le système d’exploitation qui gére ce cas. On initialise souvent un pointeur à NULL (c’est juste une macro NULL = 0) :

int *ptr = NULL;  // pointeur nul

if (ptr != NULL) {
    printf("%d\n", *ptr);
} else {
    printf("Pointeur nul\n");
}

Constantes et pointeurs

On peut utiliser const pour spécifier que la valeur pointée ou le pointeur lui-même ne peut pas être modifié :

int x = 5, y = 10;

int const *ptr1 = &x;  // const int*, pointeur vers constante
// *ptr1 = 20;  // ERREUR : ne peut pas modifier la valeur
ptr1 = &y;             // OK : peut changer le pointeur

int * const ptr2 = &x;  // constante pointeur, pointeur constant
*ptr2 = 20;            // OK : peut modifier la valeur
// ptr2 = &y;          // ERREUR : ne peut pas changer le pointeur

const int * const ptr3 = &x;  // constante pointeur vers constante
// *ptr3 = 20;         // ERREUR
// ptr3 = &y;          // ERREUR

C’est une propriété importante pour le programmeur et ses pairs. Cela permet d’indiqué ce qui ne seras pas modifier dans une fonction. Le compilateur utilisera également cette information pour certaines optimisation.

Pointeurs de fonction

Un pointeur peut aussi pointer vers une fonction. Cela permet de passer des fonctions en paramètre ou de stocker des références à des fonctions. C’est possible car en soit une fonction c’est une adresse de début d’instruction dans un code qui est chargé en mémoire. La syntaxe permet de dire “Je veux appelez une foncion”, sauvegarde l’adresse de retour, deplace l’éxécution a l’addresse donné, et le reste s’éffectue (allocation de la stack frame, etc).

int ajouter(int a, int b) {
    return a + b;
}

int multiplier(int a, int b) {
    return a * b;
}

int main(void) {
    int (*operation)(int, int);  // pointeur vers fonction
    operation = ajouter;
    printf("%d\n", operation(5, 3));  // appelle ajouter : 8

    operation = multiplier;
    printf("%d\n", operation(5, 3));  // appelle multiplier : 15
    return 0;
}