Skip to content

Niveau 2 · Chapitre 2.4

Pointeurs et tableaux

Un pointeur est une variable qui contient une adresse. Comment fonctionnent & et *, pourquoi l’arithmétique des pointeurs compte en éléments, comment un tableau se dégrade en pointeur et a[i] devient un seul chargement, les chaînes terminées par un zéro, NULL et les erreurs de segmentation, les écritures hors limites et les pointeurs de fonction — en C, en assembleur, et en exécution.

Le chapitre précédent a montré que chaque variable est une plage d’octets à une certaine adresse. Un pointeur est une variable dont la valeur est l’une de ces adresses. Rien de plus : sur une machine 64 bits, 8 octets contenant un nombre qui se trouve être une adresse mémoire. Ce qui rend les pointeurs utiles — et dangereux —, c’est ce que le C permet de faire avec ce nombre.

& et *

Deux opérateurs passent d’une variable à son adresse et inversement :

  • &x est l’adresse de x ;
  • *p est l’objet pointé par p : lire *p charge depuis l’adresse contenue dans p, et affecter *p = … y range une valeur.

L’exemple classique est une fonction qui échange deux variables. Le C passe les arguments par valeur, donc swap(x, y) n’échangerait que des copies. Passer leurs adresses permet à la fonction d’atteindre les variables de l’appelant :

Live · swap par pointeurs

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. void swap(int *a, int *b) {
  2. int t = *a;
  3. *a = *b;
  4. *b = t;
  5. }
  6. int main() {
  7. int x = 3;
  8. int y = 7;
  9. swap(&x, &y);
  10. return x * 10 + y;
  11. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Le programme renvoie 73 : x et y ont bien été échangés. Entrez dans swap et regardez le panneau des variables : a et b contiennent des adresses de pile — celles du x et du y de main —, et les écritures à travers eux modifient le cadre de main. Zoomez jusqu’à l’assembleur : *a = *b fait quatre instructions : charger le pointeur b depuis la pile, charger l’int qu’il désigne, charger le pointeur a, puis ranger la valeur à travers lui. Le matériel ne sait pas que ce sont des pointeurs ; il ne voit qu’un adressage indirect par registre.

Le type d’un pointeur dit ce qu’il désigne : un int * pointe vers des entiers de 4 octets, un char * vers des octets isolés. L’adresse elle-même est le même genre de nombre dans les deux cas. Le type indique seulement au compilateur combien d’octets charger ou ranger à cette adresse, et comment les interpréter.

L’arithmétique des pointeurs

Ajouter un entier à un pointeur le déplace d’éléments entiers, pas d’octets : si p est un int *, p + 1 est l’adresse 4 octets plus loin, l’int suivant. Le compilateur multiplie par la taille de l’élément à votre place. Soustraire deux pointeurs dans un même tableau donne le nombre d’éléments qui les séparent, donc le compilateur divise. clang -O2 compile long diff(int *p, int *q) { return p - q; } ainsi :

diff:
    mov rax, rdi
    sub rax, rsi      ; différence en octets
    sar rax, 2        ; ÷ 4 (sizeof(int)) : différence en int
    ret

L’arithmétique des pointeurs n’est définie qu’à l’intérieur d’un même tableau (plus une case après sa fin). Comparer ou soustraire des pointeurs vers des objets sans rapport est un comportement indéfini, même si la machine calculerait volontiers quelque chose.

Les tableaux ne sont pas des pointeurs, mais ils le deviennent

Un tableau est un nombre fixe d’éléments rangés côte à côte : int a[5] fait 20 octets contigus. Ce n’est pas un pointeur — sizeof(a) vaut 20 —, mais dans presque toutes les expressions, le nom d’un tableau se dégrade (decays) en pointeur vers son premier élément. Cette règle donne au C sa célèbre équivalence :

a[i]   ==   *(a + i)

L’indexation d’un tableau est de l’arithmétique de pointeurs suivie d’un déréférencement. Comme l’addition est commutative, i[a] a le même sens et compile, même si personne ne devrait l’écrire.

La dégradation a aussi lieu quand on passe un tableau à une fonction, ce qui explique qu’une fonction ne puisse pas connaître la taille du tableau reçu. clang le signale même :

int f(int a[10]) { return sizeof(a); }

warning: sizeof on array function parameter will return size of 'int *'
         instead of 'int[10]' [-Wsizeof-array-argument]

Le [10] du paramètre est ignoré ; a est un int *, et sizeof(a) vaut 8. C’est pourquoi les fonctions C qui prennent un tableau prennent aussi une longueur, comme sum(int *p, int n) plus bas.

Au niveau de la machine, a[i] tient en une seule instruction, grâce à l’adressage avec index mis à l’échelle. clang -O2 pour int get(int *a, int i) { return a[i]; } :

get:
    movsxd rax, esi                    ; étend l’index int à 64 bits avec son signe
    mov    eax, DWORD PTR [rdi+rax*4]  ; charge a + i*4
    ret

La même boucle peut s’écrire avec un index ou avec un pointeur qui avance. Faire avancer un pointeur du début du tableau jusqu’à sa fin est la forme idiomatique en C, et voici ce que cela donne pas à pas :

Live · Parcourir un tableau avec un pointeur

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int sum(int *p, int n) {
  2. int s = 0;
  3. int *end = p + n;
  4. while (p < end) {
  5. s += *p;
  6. p++;
  7. }
  8. return s;
  9. }
  10. int main() {
  11. int a[5] = {1, 2, 3, 4, 5};
  12. return sum(a, 5);
  13. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 15. Regardez p dans le panneau des variables augmenter de 4 à chaque p++, pendant que end reste fixe, 20 octets après le début.

Les chaînes sont des tableaux de char

Le C n’a pas de type chaîne. Une chaîne est un tableau de char qui se termine par un octet nul ('\0', de valeur 0), et on la manipule sous la forme d’un char * vers son premier caractère. Le littéral "pointer" occupe 8 octets : 7 lettres et le terminateur. Trouver la longueur, c’est avancer jusqu’au zéro :

Live · strlen : avancer jusqu’au zéro final

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int my_strlen(char *s) {
  2. char *p = s;
  3. while (*p)
  4. p++;
  5. return p - s;
  6. }
  7. int main() {
  8. return my_strlen("pointer");
  9. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 7. Ce choix est compact, mais ses conséquences se retrouvent partout : connaître la longueur d’une chaîne coûte un parcours complet, une chaîne ne peut pas contenir d’octet nul, et un terminateur manquant fait lire toutes les fonctions de chaînes au-delà de la fin du tampon.

Pointeurs de pointeurs, et NULL

Un pointeur est une variable, il a donc lui aussi une adresse : int **pp = &p; est un pointeur vers un pointeur, et **pp suit les deux. char **argv, la liste des arguments de main, en est l’exemple le plus connu : un tableau de pointeurs vers des chaînes.

Un pointeur qui ne pointe nulle part vaut NULL, c’est-à-dire l’adresse 0 en pratique. Le déréférencer ne renvoie pas n’importe quoi : les systèmes d’exploitation ne projettent délibérément jamais la première page de la mémoire, donc l’accès déclenche un défaut de page, et le noyau tue le processus avec une erreur de segmentation. Sous Linux, le shell affiche le code de sortie 139, soit 128 + 11, le signal 11 étant SIGSEGV. Le simulateur reproduit ce comportement :

Live · Déréférencer NULL

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int main() {
  2. int *p = 0;
  3. return *p;
  4. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

L’exécution s’arrête sur une erreur de segmentation à l’adresse 0. Laisser la page 0 non projetée transforme le bug de pointeur le plus courant en plantage immédiat plutôt qu’en corruption silencieuse. C’est pourquoi NULL est une bonne valeur pour réinitialiser un pointeur.

Personne ne vérifie les limites

Le C ne vérifie jamais les index de tableau. a[i] calcule une adresse et y accède, qu’elle soit dans le tableau ou non. Cette boucle écrit un élément de trop — i <= 4 devrait être i < 4 :

Live · Un élément trop loin

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int main() {
  2. int guard = 7;
  3. int a[4];
  4. for (int i = 0; i <= 4; i++)
  5. a[i] = 99;
  6. return guard;
  7. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 99, pas 7. a[4] désigne les 4 octets juste après le tableau, et dans ce cadre de pile, c’est là que vit guard. Le programme ne plante pas : il corrompt silencieusement une variable voisine. Compilé avec gcc -O0 pour Linux ARM64, le vrai programme renvoie aussi 99. Avec d’autres compilateurs ou d’autres options, la disposition change et c’est autre chose qui est écrasé : le comportement est indéfini, donc tout résultat est permis.

C’est la racine des dépassements de tampon (buffer overflows), historiquement la famille de failles de sécurité la plus exploitée : quand les données qui débordent viennent de l’entrée du programme, et que le cadre contient aussi l’adresse de retour de la fonction, un bug devient un moyen de prendre le contrôle du programme. Compilateurs et systèmes ajoutent aujourd’hui des défenses — canaris de pile, pile non exécutable, ASLR —, et des outils trouvent ces bugs pendant les tests : compilé avec -fsanitize=address, le même programme s’arrête sur l’écriture fautive et signale stack-buffer-overflow … WRITE of size 4 à la ligne 5. Des langages comme Rust, Java ou Python vérifient les limites à chaque accès, au prix d’une comparaison et d’un branchement.

Les pointeurs de fonction

Le code vit lui aussi en mémoire, donc les fonctions ont des adresses. Un pointeur de fonction en contient une, et appeler à travers lui saute vers la fonction désignée. La syntaxe de déclaration est réputée maladroite — ce sont les parenthèses autour de *op qui en font un pointeur vers une fonction plutôt qu’une fonction renvoyant un pointeur :

Live · Appeler à travers un pointeur de fonction

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int add(int a, int b) { return a + b; }
  2. int mul(int a, int b) { return a * b; }
  3. int apply(int (*op)(int, int), int x, int y) {
  4. return op(x, y);
  5. }
  6. int main() {
  7. return apply(add, 2, 3) * 10 + apply(mul, 2, 3);
  8. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 56 : apply a appelé add la première fois et mul la seconde. Dans l’assembleur, passer add n’est qu’un lea rax, [rip+add], l’adresse de la fonction, et op(x, y) devient call rax, un appel indirect. clang -O2 va encore plus loin :

apply:
    mov rax, rdi      ; le pointeur de fonction
    mov edi, esi      ; x → premier argument
    mov esi, edx      ; y → second argument
    jmp rax           ; appel terminal : op revient directement à l’appelant d’apply

Les pointeurs de fonction sont la façon dont le C réalise les fonctions de rappel (callbacks : qsort prend une fonction de comparaison), les tables de greffons, et ce que fait le C++ en coulisses pour les méthodes virtuelles. Au niveau du CPU, chacun d’eux est un branchement indirect, que le prédicteur de branchements doit deviner.

À retenir

  • Un pointeur est une variable qui contient une adresse ; &x prend une adresse et *p la suit. Le type d’un pointeur indique seulement au compilateur la taille et le sens de ce qui se trouve à l’adresse.
  • L’arithmétique des pointeurs compte en éléments : p + 1 ajoute sizeof(*p) octets, et p - q divise par cette taille.
  • Un tableau est une suite d’éléments contigus ; son nom se dégrade en pointeur vers le premier, donc a[i] vaut *(a + i), un seul chargement avec index mis à l’échelle. Un tableau passé à une fonction perd sa taille.
  • Les chaînes sont des tableaux de char terminés par un octet nul.
  • NULL est l’adresse 0 ; la page 0 n’est jamais projetée, donc la déréférencer provoque une erreur de segmentation (code de sortie 139 sous Linux).
  • Le C ne vérifie pas les limites : écrire au-delà d’un tableau écrase silencieusement ce qui suit, la racine des dépassements de tampon. Les sanitizers les détectent pendant les tests.
  • Les pointeurs de fonction contiennent des adresses de code ; appeler à travers eux est un call indirect.

Dans ce niveau

  1. 2.1Langages compilés, interprétés et compilés à la volée (JIT)
  2. 2.2Du code source au binaire qui s’exécute
  3. 2.3Variables, types et disposition en mémoire
  4. 2.4Pointeurs et tableaux
  5. 2.5Fonctions, appels et la pile
  6. 2.6Structures, malloc et le tas
  7. 2.7Flot de contrôle : if, boucles, switch
  8. 2.8Machines virtuelles à bytecode et ramasse-miettes