La notion de pointeur est une notion essentielle du langage C, qui permet de manipuler explicitement la mémoire et d'avoir une conscience précise de ce qui se passe en mémoire. Il s'agit également d'une notion un peu délicate à prendre en main. Nous essayerons, donc, de poser les choses en détail.

1. Introduction et rappel : adresse d'une variable

En langage C, vous savez qu'on peut accéder à l'adresse mémoire de n'importe quelle variable avec l'opérateur éperluette & :


int main() {
  float x = 1.2; // la variable x est créée à une certaine adresse

  // affiche 1.2000
  printf("Valeur de la variable x  %f\n", x);

  // affiche, en hexadécimal, l'adresse à laquelle la variable i existe en mémoire
  printf("Adresse de la variable x %p\n", &x);
  // Rappel : dans la chaîne de format passée à la fonction printf(),
  // %p permet de formater une adresse en hexa
}
          

Dans ce code, la variable x

  • est créée quelque part en mémoire, à une certaine adresse (dans la zone mémoire appelée pile)
  • est de type float : elle donc occupe donc 4 octets en mémoire et permet de stocker un nombre flottant.
  • a pour valeur 1.2

Et (&x) (lire "adresse de x"):

  • est de type adresse d'un float
  • a pour valeur l'adresse de la variable x. Cette adresse où la variable va vivre en mémoire est choisie par le système, mais le développeur peut y accéder.

Puisqu'on peut accéder à l'adresse de n'importe quelle variable, pourquoi ne pourrait-on pas stocker cette adresse dans une autre variable ? Eh bien, c'est précisément ce que permet de faire une variable pointeur.

2. Déclarer et initialiser une variable pointeur

  • Un pointeur est une variable dont la valeur est une adresse mémoire - typiquement l'adresse d'une autre variable.
  • Le type d'une variable pointeur est "adresse du type de la variable pointée".

De plus :

  • On dit que le pointeur "pointe" la case mémoire dont l'adresse est stockée dans le pointeur
  • A partir du pointeur, il va être possible d'accéder à la case mémoire (à la variable) pointée.

Un pointeur est typé par le type de la chose pointée. Un "pointeur" (sans plus de précision) ne veut pas dire grand chose. On parlera par exemple de "pointeur sur un int", ou de "pointeur sur float", etc.

Pour déclarer un pointeur (une variable pointeur), on fait précéder le nom de la variable pointeur du symbole étoile *.

La syntaxe est donc :


[TYPE_DE_LA_CHOSE_POINTEE] * nom_choisi_pour_ma_variable_pointeur ;
// crée une variable nommée nom_choisi_pour_ma_variable_pointeur,
// de type "pointeur sur TYPE_DE_LA_CHOSE_POINTEE".
// Exemple :
float * p; // p est un pointeur - sur - float
              
Un premier exemple pas à pas...

 /**
  * Pointeur : première approche
  */
 int main() {
   int i = 5;
   int *unPtr; // un pointeur-sur-entier, non intialisé)

   printf("Valeur de i %d\n", i);
   printf("Valeur de unPtr %p\n", unPtr);

   unPtr = &i; // la valeur de unPtr est désormais l'adresse de i
               // <=> "unPtr pointe i"
   printf("Adresse de i %p\n", &i);
   printf("Valeur de p %p\n", unPtr);

   printf("Adresse de unPtr %p\n", &unPtr);
   return 0;
 }
                 

Les variables sont créées en mémoire. Le pointeur unPtr est une variable comme les autres : elle existe quelque part en mémoire.

La variable i (un int) occupe 4 octets.

La variable unPtr (un pointeur) occupe la taille d'une adresse mémoire. Sur un ordinateur récent, c'est 8 octets.

i vaut 5.

La variable unPtr n'a par contre pas été initialisée. Sa valeur dépend, comme d'habitude, des bits qui étaient allumés à cet endroit en mémoire, au moment de la création de la variable. On dit qu'elle est "aléatoire" (par exemple, peut-être, 0x0000ff00).

Ces deux appels de printf affichent donc, par exemple, dans le Terminal :


Valeur de i 5
Valeur de p 0x0000ff00
                    

On affecte à unPtr l'adresse de la variable i.

Dès lors, on dit que unPtr pointe la variable i.

Pour symboliser le fait que unPtr pointe i, on dessine usuellement une flèche allant de la case mémoire de unPtr vers la case mémoire de i.

Vérifions que la variable unPtr stocke bien désormais l'adresse de la variable i, en affichant tout cela dans le Terminal :


                        Adresse de i 0xbffff308
                        Valeur de p 0xbffff308
                     

unPtr est une variable... qui existe quelque part en mémoire. Rien n'empèche donc d'afficher l'adresse de unPtr si ça nous amuse ! Cette ligne affiche donc dans le terminal, dans notre cas :


Adresse de unPtr 0xbffff300
                        

Un second code, un peu plus complet (mais toujours pas bien utile, vous avez raison...) :


 int main() {
   float x = 1.2;
   float * pointeur_sur_un_float; // Création d'une variable pointeur.
   // Le nom de cette variable est pointeur_sur_un_float
   // Le type de cette variable est "pointeur - sur - float".
   // Comme toute variable déclarée, cette variable existera quelque part en mémoire.
   // Pour le moment, on ne l'a pas initialisée : sa valeur est, comme d'habitude, aléatoire.

   pointeur_sur_un_float = &x;  // Affecte l'adresse de x à la variable pointeur_sur_un_float
   // Desormais, la valeur de la variable pointeur_sur_un_float est l'adresse de la variable x.
   // On dit que pointeur_sur_un_float "pointe" x

   // Affichons deux fois l'adresse de x :
   printf("Adresse de la variable x : %p\n", &x);
   printf("Adresse de la variable x : %p\n", pointeur_sur_un_float);

   int i = 1;
   int * p; // p est un pointeur sur un int
   p = &i;  // on affecte l'adresse de i à la variable p
   // Desormais, la valeur de la variable p est l'adresse de la variable i.
   // On dit que p "pointe" i

  double *p1, *p2, x;
  // déclaration de 3 variables.
  // p1 et p2 sont deux pointeurs - sur - double.
  // x est un double.
  return 0;
 }
           

Supposons que j'ai un classeur de 100 pochettes transparentes. Les pochettes sont numérotées de 0 à 99. Dans chaque pochette, on peut glisser un nombre.

Dans la pochette numéro 5, je peux glisser le nombre 3.14.

Dans la pochette numéro 1, je peux glisser le numéro de la pochette précédente : 5.

Dans cette analogie :

  • Le classeur, toutes ses pochettes <=> la mémoire du processeur.
  • Une pochette <=> une variable C. La pochette est à un certaine adresse dans le classeur (son numéro) et contient à tout instant une certaine valeur.
  • La pochette numéro 5 <=> une variable contenant une valeur "utile" par exemple de type double
  • La pochette numéro 1 <=> un pointeur. En effet, la pochette numéro 5 contient non pas un nombre quelconque, mais un nombre qui représente le numéro (l'adresse) d'une autre pochette.
  • La pochette numéro 1 contient le numéro de la pochette numéro 5 <=> une variable pointeur contient l'adresse d'une autre variable.

Une pointeur est une variable comme une autre. Elle occupe un certain nombre d'octets en mémoire, pour stocker l'adresse de la variable pointée.

Combien une variable pointeur occupe-t-elle d'octets en mémoire ?

Une première remarque est que cela ne dépend pas du type de la variable pointée. Qu'on déclare un pointeur - sur - char char * p1; ou un pointeur - sur - double double * p2;, dans tous les cas, le pointeur ne stockera qu'une adresse mémoire. La taille occupée par un pointeur et donc toujours la même dans votre programme, quelle que soit la chose pointée.

Elle dépend, par contre, de la machine pour laquelle votre programme est compilé puis sur laquelle il est exécuté.

Sur un ordinateur récent (architecture 64 bits, telle que X86_64) équipé d'un système d'exploitation 64 bits, comme ceux de l'école par exemple, une adresse mémoire est codée sur 8 octets. Un pointeur occupe donc 8 octets en mémoire (64 bits).

Jusque dans les années 2000, la mémoire adressable était souvent limitée à 4 Giga octets, soit 2 puissance 32 octets (architecture i386 par exemple). Une adresse mémoire était codée sur 4 octets et une variable pointeur occupait 4 octets.

Sur un microcontrôleur, ce peut être encore différent...

Une petite précision maintenant...

Dans la section précédente, on a dit que l'instruction float * ptr; déclare une variable nommée ptr qui est de type pointeur - sur - float.

Cette formulation n'est en fait pas tout à fait précise. Il serait plus adéquat de dire que l'instruction float * ptr; déclare une variable nommée ptr telle que (*ptr) soit de type float (ce qui implique bien sûr que ptr est bien de type pointeur - sur - float).

En effet, cette seconde formulation permet de mieux comprendre ce qui se passe lorsqu'on déclare plusieurs variables sur une même ligne, comme dans l'exemple suivant :


double *p1, x=29.8, y, *p2 = &x;
            

Cette ligne déclare 4 variables : p1, x, y et p2, telles que *p1, x, y et et *p2 sont de type double. Autrement dit : x et y sont deux variables de type double , et p1 et p2 deux variables de type pointeur - sur - double.

Remarque : avec cette ligne, seules les variables x et p2 ont été initialisées : x vaut 29.8 et p2 pointe la variable x. A l'inverse, y et p1 ne sont initialisées : leur valeur est "aléatoire". Ce qui est vraiment très mal, comme expliqué dans la section 4.

3. Opérateur d'indirection * : accéder à la case mémoire pointée

L'opérateur d'indirection (ou "de déréférencement") de pointeur, noté étoile *, permet d'accéder à l'adresse pointée.

Ainsi, si unPointeur est une variable pointeur, alors * unPointeur -EST- la case mémoire pointée par le pointeur.

En conséquence...

Si on écrit :


                   double x = 87.;
                   double *ptr = &x;      // ptr est un pointeur qui pointe la variable x
             

alors on a les équivalences suivantes :


                   &x    <=>    ptr       // ptr vaut l'adresse de x
                   x     <=>    *ptr      // *ptr est la même chose que x
             

Ainsi donc, on peut utiliser une variable pointeur pour manipuler la variable pointée, comme dans les exemples suivants.

L'opérateur d'indirection * pour déréférencer un pointeur.

    /**
     * Déréférencement de pointeur avec *
     */
    int main() {
      int i, j;
      i=9;
      int *p = &i; // un pointeur qui pointe la variable i

      printf("Valeur de i %d. Ou encore : %d\n", i, *p);

      *p = 29 ;
      printf("Nouvelle valeur de i : %d\n", i);

      j = *p + 2;
      printf("Valeur de j %d\n", j);

      p = &j; // p pointe j, maintenant

      *p = *p  * 2;
      printf("Valeur de j %d\n", j);

      printf("Donnez une valeur pour j : \n");
      scanf("%d", p);

      return 0;
    }
                    

i vaut 5.

Le pointeur p pointe i.

j n'est pas initialisée (aléatoire).

Puisque p pointe i, alors *p est la même chose que i.

Cette ligne affiche donc dans le Terminal :


Valeur de i 5. Ou encore : 5
                         

On utilise l'opérateur d'indirection * sur le pointeur pour changer la valeur de la variable pointée, c'est à dire la valeur de i.

Le printf() affiche donc dans le Terminal :


Nouvelle valeur de i : 29
                       

Puisque *p (c'est à dire la variable i) vaut 29, désormais j vaut 31.

Le printf() affiche donc dans le Terminal :


Valeur de j : 31
                       

Désormais, p pointe j.

Le printf() affiche dans le Terminal :


Valeur de j : 62
                       

Puisque p pointe j, ou (c'est un synonyme) p a pour valeur l'adresse de j, on peut utiliser p dans un scanf() pour changer la valeur de j.

Et voici un code exemple un peu plus complet :


int main() {
  int k = 4;
  int * p1 ;  // la variable p1 est un pointeur - sur - entier

  p1 = &k;    // p1 pointe désormais la variable k
  // donc :     *p1 <=> k     et      p1 <=> &k

  printf("Valeur de k %d\n", k); //  affiche 4

  // On peut aussi afficher la valeur de k en utilisant le pointeur :
  printf("Valeur de k, en utilisant le pointeur %d\n", *p1); //  affiche 4 aussi !

  // On peut aussi utiliser le pointeur pour changer la valeur de k :
  *p1 = 89;   // Affecte 89 à la chose pointée par p1... c'est à dire à la variable k
  printf("Valeur de k %d\n", k);    //  affiche 89
  printf("Valeur de k %d\n", *p1);  //  affiche aussi 89

  double x;
  printf("donnez la valeur de x\n");
  scanf("%lf", &x);
  printf("valeur de x %lf\n", x); // bon... affiche la valeur de x, entrée par l'utilisateur

  double *px = &x ; // px est un pointeur qui pointe x
  printf("donnez une nouvelle valeur pour x\n");
  scanf("%lf", px); // puisque px pointe x, alors px <=> &x.
  // Donc cette ligne va mettre dans x la nouvelle valeur donnée par l'utilisateur.

  // On affiche 2 fois la nouvelle valeur de x, 2eme valeur entrée par l'utilisateur
  printf("valeur de x %lf\n", x);
  printf("valeur de x %lf\n", *px);
}
          

On reprend nos petites pochettes.

Dans la pochette numéro 5, je glisse le nombre 67.7.

Dans la pochette numéro 1, je glisse le numéro de la pochette précédente, c'est à dire 5 : la pochette 1 "pointe" la pochette 5.

Je donne le classeur à Paulette, et lui demande de retrouver le nombre secret, en lui indiquant que la pochette 1 contient le numéro de la pochette qui contient ce nombre secret. Pour retrouver ce nombre, Paulette :

  • Ouvre la pochette 1 et lit le numéro (l'adresse) de l'autre pochette : 5.
  • Elle se rend à la pochette numéro 5 et l'ouvre
  • Elle lit le nombre dans la pochette 5.

Je donne le classeur à René, et lui dit de mettre 3.14 à la place du nombre secret, sachant que la pochette 1 contient le numéro de la pochette qui contient ce nombre secret. Pour cela, Paulette :

  • Ouvre la pochette 1 et lit le numéro (l'adresse) de l'autre pochette : c'est 5.
  • Elle se rend à la pochette 5 et y glisse 3.14

Dans ces deux actions, on pourrait dire que que Paulette et René ont "déréfencé" la pochette (le pointeur) numéro (situé à l'adresse) 1.

4. Initialisation d'un pointeur et mot clé NULL

Comme n'importe quelle variable en C, une variable pointeur n'est pas automatiquement initialisée lors de sa décalaration.

Un pointeur non intialisé aura pour valeur une adresse "aléatoire" : il pointe n'importe où en mémoire !

Le développeur a, comme pour toute variable, la responsabilité d'initialiser explicitement les pointeurs qu'il déclare.

Pour initialiser un pointeur, plusieurs possibilités :

  • à l'adresse d'une variable déjà déclarée : int * ptr = &unEntier;
  • à une adresse obtenue lors d'une allocation dynamique de la mémoire : int * ptr = calloc(3, sizeof(int)); (cf. la fiche XXXXXXX allocation dynamique à venir)
  • dans le cas où on travaille avec du hardware, à l'adresse d'un registre précisé dans la documentation du fabriquant : int * ptr = 0xFF0598AB; // où la documentation indique que ce registre, par exemple, permet d'allumer une diode d'une carte PCI
  • enfin, lorsqu'on ne sait pas encore où le pointeur va pointeur, on l'initialisera toujours à la valeur NULL :

La valeur de l'identifieur NULL signifie "adresse invalide".

Il convient de l'utiliser lorsqu'un pointeur ne pointe "nul part" :


                int * unPointeurQuOnSaitPasEncoreOuQuIlPointe = NULL ; // un pointeur valant NULL,
                                                                       // <=> qui ne pointe nul part
              

Considérons le code suivant :


                 int main() {
                   int i = 8, j = 4;
                   int * p1 ;  // la variable p1 est un pointeur sur entier.
                   // p1 n'a pas été initalisé => sa valeur est aléatoire.
                   // Donc p1 pointe n'importe où en mémoire !

                   *p1 = 87; // on utilise le pointeur non initialisé => danger !!

                   printf("i %d ; j %d", i, j);
                  }
             

Lorsqu'on compile ce code, le compilateur est très content : du point de vue des types et de la syntaxe, tout va bien.

Lorsqu'on exécute le binaire compilé, ça se complique... Le comportement de ce programme est en fait aléatoire :

  • Le plus souvent, la valeur (aléatoire) de p1 fait que p1 pointe une adresse à laquelle notre programme n'a pas le droit d'accéder. En conséquence, ligne 5, lorsque la ligne *p1 = 87; cherche à affecter 87 à cette adresse, le programme va s'arrêter subitement avec une erreur de type Segmentation fault (erreur d'accès mémoire : tentative d'accès à une case mémoire interdite).
  • Parfois, la valeur (aléatoire) de p1 sera l'adresse d'une autre variable, par exemple l'adresse de i ou de j, ou encore une adresse à laquelle votre programme a le droit d'accéder mais que vous ne gérez pas explictement dans votre code (l'adresse du pointeur de pile par exemple). Dans ce cas, ligne 5, *p1 = 87;, va affecter 87 à cette adresse. Et donc, sans même vous en rendre compte ni que vous le maitrisiez, la valeur de la variable i, ou de la variable j, ou d'une autre case mémoire utilisée par votre programme, va changer ! Et là, c'est la catastrophe... Car plus tard (peut-être des heures plus tard) votre programme va exploiter cette variable dont la valeur a aléatoirement été modifiée.

Un tel comportement aléatoire du programme du fait de pointeurs pointant n'importe où (on parle de "soupe de pointeurs" dans le jargon des développeurs) doit devenir vôtre bête noire.

On écrira donc, si on ne sait pas (encore) où pointe notre pointeur :


                   int main() {
                     int i = 8, j = 4;
                     int * p1 = NULL;  // on initialise p1 à NULL tant qu'on ne sait pas où le faire pointer

                     // ... de telle sorte qu'on évite le comportement aléatoire :
                     //  si on a le malheur de déréférencer le pointeur p1
                     // avant de le faire "pointer quelque part",
                     // on est sûr qu'une segmentation fault se produit  :
                     *p1 = 87; // => arrêt immédiat et systématique du programme
                               // au lieu d'un comportement aléatoire. Ouf !
                     printf("i %d ; j %d", i, j);
                    }
               

Pour finir :

Lorsque vous utilisez un pointeur, il est essentiel que vous ayez à tout instant conscience de ce qu'il pointe : soit rien (il convient qu'il valle NULL en ce cas), soit une case mémoire valide.

5. Convention de nommage

Dans notre grande série "utiliser des noms signifiants pour vos variables et fonctions", aujourd'hui, nous conseillons fortement de :

Préfixer le nom d'un pointeur par ptr, p_, p, (ou autre chose de votre invention) lors de sa déclaration.

En effet, même si un pointeur est somme toute une variable comme les autres (si ce n'est que sa valeur est une adresse, au lieu d'une bonne vieille valeur entière, d'un float, d'un Koala ou d'autre chose...), l'expérience montre qu'il est fort utile que le nom du pointeur indique au premier coup d'oeuil le fait que c'est un pointeur.

On n'écrira donc PAS, par exemple :


 int main() {
   int i = 8;
   int * j = &i;  // j point i
   *j = 89; // affecte 89 à la chose pointée par j, donc à i.
 }
           

mais plutôt :


int main() {
  int i = 8;
  int * ptr = &i;  // "ptr" dénote bien le fait que la variable est un pointeur
  *ptr = 89; // affecte 89 à la chose pointée par ptr, donc à i.

  // ou encore :
  // int * p = &i;
  // int * p_i = &i;
  // int * p1 = &i;
  // etc.
 }
            

S'entrainer

La base #1... On considère le code suivant :

 /**
  * Une jolie fonction ppale
  */
 int main() {
   int i = 12;
   int j = 13;
   int * p = &i;
   *p = 0;
   p = &j;
   *p = 1;
   j = 56;
   printf("A %d B %d C %d\n", i, j, *p);
 }
    
Ce code...
... ne compile pas. Compile et s'exécute corrrectement. Il affiche A 0 B 56 C 1 Compile et s'exécute corrrectement. Il affiche A 0 B 56 C 56 Compile mais provoque une erreur de segmentation lorsqu'on l'exécute. Un petit schéma sur papier où on dessine les variables et leur valeur au cours du temps pour tracer le programme peut aider...
La base #2... On considère le code suivant :

 /**
  * Bidouillons avec des pointeurs
  */
 int main() {
   double * ptr;
   double x;
   double y;
   *ptr = 3.14;
   x = *ptr;
   printf("A %lf B %lf\n", x, *ptr);
 }
    
Ce code...
... ne compile pas : le pointeur n'est pas initialisé. Compile et s'exécute corrrectement. Il affiche A 3.14000 B 3.14000 Compile mais provoque une erreur de segmentation lorsqu'on l'exécute. Compile et parfois provoque une erreur de segmentation à l'exécution, parfois affiche A 3.14000 B 3.14000.

Le pointeur ptrn'est pas initialisé. Lorsqu'on lance le programme, sa valeur (une adresse) est donc aléatoire : il pointe n'importe où en mémoire.

Parfois (... le plus souvent dans ce cas simple...), il pointera une case mémoire invalide, à laquelle votre programme n'a pas le droit d'accéder. A la ligne 8, lorsqu'on accède à la case mémoire pointée (ici : pour en changer la valeur), il se produit alors une erreur de segmentation.

Mais il est possible aussi que la valeur aléatoire du pointeur non initialisé soit, aléatoirement, une adresse valide. Par exemple celle de la variable x, ou celle de la variable y, ou celle du pointeur lui-même, ou une adresse d'une autre partie de la zone mémoire allouée au programme par le système d'exploitation. Dans ce cas, le programme semble fonctionner correctement... Et là, c'est le drame : il vous semble que tout va bien... alors que votre code a un bon-gros-bug-qui-tâche, qui fera "planter" le programme un jour ou l'autre !