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 :
&xest l’adresse dex;*pest l’objet pointé parp: lire*pcharge depuis l’adresse contenue dansp, 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 :
À 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.
- void swap(int *a, int *b) {
- int t = *a;
- *a = *b;
- *b = t;
- }
- int main() {
- int x = 3;
- int y = 7;
- swap(&x, &y);
- return x * 10 + y;
- }
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 :
À 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.
- int sum(int *p, int n) {
- int s = 0;
- int *end = p + n;
- while (p < end) {
- s += *p;
- p++;
- }
- return s;
- }
- int main() {
- int a[5] = {1, 2, 3, 4, 5};
- return sum(a, 5);
- }
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 :
À 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.
- int my_strlen(char *s) {
- char *p = s;
- while (*p)
- p++;
- return p - s;
- }
- int main() {
- return my_strlen("pointer");
- }
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 :
À 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.
- int main() {
- int *p = 0;
- return *p;
- }
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 :
À 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.
- int main() {
- int guard = 7;
- int a[4];
- for (int i = 0; i <= 4; i++)
- a[i] = 99;
- return guard;
- }
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 :
À 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.
- int add(int a, int b) { return a + b; }
- int mul(int a, int b) { return a * b; }
- int apply(int (*op)(int, int), int x, int y) {
- return op(x, y);
- }
- int main() {
- return apply(add, 2, 3) * 10 + apply(mul, 2, 3);
- }
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 ;
&xprend une adresse et*pla 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 + 1ajoutesizeof(*p)octets, etp - qdivise 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
charterminés par un octet nul. NULLest 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
callindirect.