Pointeur en C : explication avec des schémas mémoire
Les pointeurs en C expliqués avec de vrais schémas mémoire : adresse, & et *, swap, tableaux, pointeur NULL, pointeur de pointeur et pointeur sur fonction.
Tu lis int *p = &n;, puis *p = 7; trois lignes plus bas, et l'étoile n'a pas l'air de vouloir dire la même chose aux deux endroits. Normal : elle ne veut pas dire la même chose. Un pointeur devient simple dès qu'on regarde ce qu'il y a vraiment en mémoire, alors c'est ce qu'on va faire ici, avec des adresses affichées par les programmes eux-mêmes.
En bref
Un pointeur est une variable qui contient l'adresse d'une autre variable. &n donne l'adresse de n, et *p désigne la variable dont p contient l'adresse : écrire *p = 7 modifie donc n. Les pointeurs servent à laisser une fonction modifier les variables de l'appelant, à parcourir un tableau et à manipuler la mémoire allouée avec malloc. Un pointeur qui ne désigne rien vaut NULL, et le déréférencer est un comportement indéfini qui se termine le plus souvent en segmentation fault.
C'est quoi un pointeur en C ?
Un pointeur est une variable dont la valeur est une adresse mémoire. Chaque variable de ton programme occupe quelques octets quelque part en mémoire, et chacun de ces octets porte un numéro : son adresse. Un int occupe en général 4 octets, et l'adresse de la variable est celle du premier.
Le plus parlant est de les afficher. Le format %p de printf imprime une adresse ; la norme lui demande un void *, d'où la conversion (void *) devant chaque argument.
#include <stdio.h>
int main(void){ int n = 42; int *p = &n;
printf("n vaut %d\n", n); printf("n est rangé à l'adresse %p\n", (void *)&n); printf("p contient %p\n", (void *)p); printf("*p vaut %d\n", *p);
*p = 7; printf("après *p = 7, n vaut %d\n", n); printf("taille de n : %zu octets, taille de p : %zu octets\n", sizeof n, sizeof p); return 0;}$ gcc -Wall -Wextra -g adresse.c -o adresse$ ./adressen vaut 42n est rangé à l'adresse 0xfffffee986d4p contient 0xfffffee986d4*p vaut 42après *p = 7, n vaut 7taille de n : 4 octets, taille de p : 8 octets$ ./adressen vaut 42n est rangé à l'adresse 0xffffcb630bd4p contient 0xffffcb630bd4*p vaut 42après *p = 7, n vaut 7taille de n : 4 octets, taille de p : 8 octetsTrois choses à lire dans cette sortie. p contient exactement l'adresse de n : c'est tout ce qu'est un pointeur. Modifier *p a modifié n, parce que *p est n, vu à travers son adresse. Et l'adresse change d'une exécution à l'autre : Linux place la pile à un endroit aléatoire à chaque lancement (la randomisation de l'espace d'adresses). Ton code ne doit donc jamais dépendre de la valeur d'une adresse, seulement de ce qu'elle désigne.
Sur cette machine 64 bits, un pointeur occupe 8 octets, quelle que soit la taille de ce qu'il désigne. Voici la mémoire juste après la ligne int *p = &n; :
Pile (stack)
- intnn
- 42
- rangé à l'adresse 0xfffffee986d4
- int *p
- 0xfffffee986d4n
Et juste après *p = 7;. Le pointeur garde la même valeur, seule la variable désignée a changé :
Pile (stack)
- intnn
- 7
- int *p
- 0xfffffee986d4n
Lire & et * sans se tromper
Côté pointeurs, l'étoile a deux rôles selon l'endroit où elle apparaît (sans compter la multiplication). Dans une déclaration, elle annonce un pointeur : int *p déclare une variable p de type « pointeur vers int ». Dans une expression, c'est l'opérateur d'indirection : *p désigne l'objet dont p contient l'adresse. L'esperluette & placée devant une variable, elle, prend son adresse ; entre deux opérandes, a & b, c'est le ET bit à bit, une autre histoire.
| Écriture | Se lit | Dans adresse.c |
|---|---|---|
int *p; | p est un pointeur vers un int | déclaration, pas de valeur affichée |
&n | l'adresse de n | 0xfffffee986d4 |
p | l'adresse rangée dans p | 0xfffffee986d4 |
*p | l'int rangé à cette adresse | 42, puis 7 |
p = &n | p désigne désormais n | change le pointeur |
*p = 7 | l'int désigné reçoit 7 | change n |
Un piège de lecture : dans int *p, q;, seule p est un pointeur, q est un int ordinaire. L'étoile s'applique au nom qu'elle précède, pas à tous les noms de la ligne. Une déclaration par ligne évite la question.
À quoi sert un pointeur ?
Le premier usage, c'est de permettre à une fonction de modifier une variable qui appartient à une autre. En C, tous les arguments sont passés par valeur : la fonction reçoit une copie. Le programme suivant croit échanger x et y, et il affiche les adresses pour qu'on voie où vivent les copies.
#include <stdio.h>
void swap(int a, int b){ int tmp = a;
printf("dans swap : &a = %p, &b = %p\n", (void *)&a, (void *)&b); a = b; b = tmp;}
int main(void){ int x = 1; int y = 2;
printf("dans main : &x = %p, &y = %p\n", (void *)&x, (void *)&y); swap(x, y); printf("x = %d, y = %d\n", x, y); return 0;}$ gcc -Wall -Wextra -g copie.c -o copie$ ./copiedans main : &x = 0xfffffe695e7c, &y = 0xfffffe695e78dans swap : &a = 0xfffffe695e4c, &b = 0xfffffe695e48x = 1, y = 2a et b vivent à d'autres adresses que x et y. L'échange a bien eu lieu, mais entre les copies, qui disparaissent au return. Juste avant la fin de swap, la pile ressemble à ceci :
Pile (stack)
- intx
- 1
- à 0xfffffe695e7c
- inty
- 2
- à 0xfffffe695e78
- inta
- 2
- à 0xfffffe695e4c, une copie de x
- intb
- 1
- à 0xfffffe695e48, une copie de y
- inttmp
- 1
La correction : passer à swap les adresses de x et y, et travailler à travers elles.
void swap(int a, int b){ int tmp = a;
a = b; b = tmp;}/* appel : swap(x, y); */void swap(int *a, int *b){ int tmp = *a;
*a = *b; *b = tmp;}/* appel : swap(&x, &y); */#include <stdio.h>
void swap(int *a, int *b){ int tmp = *a;
*a = *b; *b = tmp;}
int main(void){ int x = 1; int y = 2;
swap(&x, &y); printf("x = %d, y = %d\n", x, y); return 0;}$ gcc -Wall -Wextra -g swap.c -o swap$ ./swapx = 2, y = 1a et b sont toujours des copies, mais des copies d'adresses : elles désignent les vraies variables de main.
Pile (stack)
- intxx
- 2
- intyy
- 1
- int *a
- x
- int *b
- y
- inttmp
- 1
swap_int
Écris swap_int, qui reçoit deux adresses d'entiers et échange les valeurs qui s'y trouvent, avec une variable temporaire. Les tests appellent swap_int(&x, &y) avec des valeurs comme 7 et 99, puis vérifient que x et y ont bien été échangées dans l'appelant.
Ce mécanisme explique aussi pourquoi scanf(3) veut &age et pas age : il doit écrire dans ta variable, il lui faut son adresse. Le même mécanisme permet à une fonction de rendre plusieurs résultats, en écrivant chacun à une adresse fournie par l'appelant.
div_mod_floor
Une fonction, deux résultats : div_mod_floor écrit le quotient à travers q et le reste à travers r. Les pointeurs sont la partie facile ; le vrai travail est de garder un reste jamais négatif alors que le / de C tronque vers zéro (-7 / 2 vaut -3, la réponse attendue est -4 reste 1).
L'arithmétique des pointeurs sur un tableau
Ajouter 1 à un pointeur le fait avancer d'un élément, pas d'un octet. Si p désigne notes[0], alors p + 1 désigne notes[1], quatre octets plus loin quand les éléments sont des int. La norme définit d'ailleurs t[i] comme une autre écriture de *(t + i).
#include <stdio.h>
int main(void){ int notes[4] = {12, 15, 9, 18}; int *p = notes;
for (int i = 0; i < 4; i++) printf("p + %d = %p, *(p + %d) = %d\n", i, (void *)(p + i), i, *(p + i)); printf("écart en octets entre p + 1 et p : %td\n", (char *)(p + 1) - (char *)p); printf("écart en éléments entre ¬es[3] et p : %td\n", ¬es[3] - p); return 0;}$ gcc -Wall -Wextra -g tableau.c -o tableau$ ./tableaup + 0 = 0xffffffb9f9c0, *(p + 0) = 12p + 1 = 0xffffffb9f9c4, *(p + 1) = 15p + 2 = 0xffffffb9f9c8, *(p + 2) = 9p + 3 = 0xffffffb9f9cc, *(p + 3) = 18écart en octets entre p + 1 et p : 4écart en éléments entre ¬es[3] et p : 3Les adresses avancent de 4 en 4 (c0, c4, c8, cc en hexadécimal) : le compilateur multiplie tout seul par sizeof(int). Dans l'autre sens, la différence de deux pointeurs compte des éléments, pas des octets, et son type est ptrdiff_t, qui s'affiche avec %td. En mémoire :
Pile (stack)
- int[4]notesnotes
- 1215918
- int *p
- 0xffffffb9f9c0notes
- p + 2 vaut 0xffffffb9f9c8, l'adresse de notes[2]
Une limite à connaître : un pointeur peut aller du premier élément jusqu'à la case juste après le dernier, mais pas au-delà, et cette case « juste après » ne doit jamais être déréférencée, ni en lecture ni en écriture. Sortir de cet intervalle est un comportement indéfini, même sans lecture. Un tableau n'est pas pour autant un pointeur (sizeof les distingue, par exemple) : la différence complète est dans Tableau ou pointeur en C.
Le pointeur NULL
NULL est la valeur qu'on donne à un pointeur qui ne désigne rien. La norme garantit qu'un pointeur nul est différent de l'adresse de n'importe quel objet, ce qui en fait un signal sûr pour dire « pas de résultat ». malloc renvoie NULL quand il n'a pas pu allouer, fopen quand il n'a pas pu ouvrir le fichier, et tes propres fonctions peuvent suivre la même convention :
#include <stdio.h>
int *trouver(int *t, int taille, int cherche){ for (int i = 0; i < taille; i++) if (t[i] == cherche) return &t[i]; return NULL;}
int main(void){ int notes[4] = {12, 15, 9, 18}; int *r = trouver(notes, 4, 9);
if (r != NULL) printf("9 trouvé à l'indice %td\n", r - notes); r = trouver(notes, 4, 20); if (r == NULL) printf("20 absent : trouver a renvoyé %p\n", (void *)r); return 0;}$ gcc -Wall -Wextra -g null.c -o null$ ./null9 trouvé à l'indice 220 absent : trouver a renvoyé (nil)La glibc affiche un pointeur nul sous la forme (nil) ; la norme laisse ce format au choix de chaque bibliothèque. La règle à retenir : teste un pointeur avant de le déréférencer dès qu'il vient d'une fonction qui peut échouer. Lire *r quand r vaut NULL est un comportement indéfini, qui se traduit le plus souvent sous Linux par un Segmentation fault immédiat. La méthode pour retrouver la ligne fautive est dans Segmentation fault en C.
Depuis C23, la norme ajoute nullptr, une constante de pointeur nul qui a son propre type. GCC 15 compile en C23 par défaut, donc int *r = nullptr; y fonctionne sans option. NULL reste valable partout. Le correcteur des exercices Grademe compile en -std=gnu17, où nullptr n'existe pas : dans les exercices, écris NULL.
Pointeur de pointeur : int **
Un pointeur est une variable comme une autre : il a lui aussi une adresse, qu'on peut ranger dans un pointeur de pointeur.
#include <stdio.h>
int main(void){ int n = 42; int *p = &n; int **pp = &p;
printf("&n = %p\n", (void *)&n); printf("p = %p\n", (void *)p); printf("&p = %p\n", (void *)&p); printf("pp = %p\n", (void *)pp); printf("*pp = %p\n", (void *)*pp); printf("**pp = %d\n", **pp); return 0;}$ gcc -Wall -Wextra -g double.c -o double$ ./double&n = 0xffffe7fc5c04p = 0xffffe7fc5c04&p = 0xffffe7fc5bf8pp = 0xffffe7fc5bf8*pp = 0xffffe7fc5c04**pp = 42Chaque étoile fait un saut : *pp suit la première flèche et donne p, **pp suit la seconde et donne n.
Pile (stack)
- intnn
- 42
- int *pp
- 0xffffe7fc5c04n
- int **pp
- 0xffffe7fc5bf8p
À quoi ça sert ? Au même problème que swap, un étage plus haut. Pour qu'une fonction modifie un int de l'appelant, il lui faut un int *. Pour qu'elle modifie un int * de l'appelant, par exemple pour lui confier un tableau qu'elle vient d'allouer, il lui faut un int **.
#include <stdio.h>#include <stdlib.h>
int creer_tableau(int **sortie, int taille){ int *t = malloc(taille * sizeof *t);
if (t == NULL) return -1; for (int i = 0; i < taille; i++) t[i] = i * i; *sortie = t; return 0;}
int main(void){ int *carres = NULL;
if (creer_tableau(&carres, 4) != 0) return 1; printf("%d %d %d %d\n", carres[0], carres[1], carres[2], carres[3]); free(carres); return 0;}$ gcc -Wall -Wextra -g pp.c -o pp$ ./pp0 1 4 9$ valgrind -q --leak-check=full ./pp0 1 4 9Avec -q, Valgrind n'affiche que les erreurs, et --leak-check=full compte chaque fuite comme une erreur : il n'a rien à signaler, le bloc est bien rendu par free. Juste après la ligne 12, la mémoire ressemble à ceci :
Pile (stack)
- int *carrescarres
- tab
- int **sortie
- carres
- inttaille
- 4
- int *t
- tab
Tas (heap)
Le bloc vit sur le tas, il survit donc au return de creer_tableau, contrairement à t qui disparaît avec la fonction. La différence entre les deux zones est le sujet de Pile et tas en C. Tu croiseras ** ailleurs : char **argv dans main, et toutes les fonctions qui modifient le début d'une liste chaînée.
Pointeur sur fonction, en bref
Une fonction a elle aussi une adresse, et un pointeur peut la contenir. On l'appelle ensuite comme la fonction elle-même.
#include <stdio.h>
int addition(int a, int b) { return a + b; }int produit(int a, int b) { return a * b; }
int main(void){ int (*op)(int, int) = addition;
printf("%d\n", op(6, 7)); op = produit; printf("%d\n", op(6, 7)); return 0;}$ gcc -Wall -Wextra -g fonction.c -o fonction$ ./fonction1342La déclaration se lit de l'intérieur : (*op) est un pointeur, (int, int) vers une fonction qui prend deux int, int au début est ce qu'elle renvoie. Les parenthèses autour de *op sont indispensables : sans elles, int *op(int, int); déclare une fonction qui renvoie un int *. L'usage le plus courant est de passer un comportement à une autre fonction, comme la fonction de comparaison que qsort reçoit pour trier n'importe quel tableau, ou le traitement que list_apply applique à chaque maillon d'une liste.
Les erreurs classiques avec les pointeurs
La plus fréquente est d'utiliser un pointeur avant de lui avoir donné une cible. Un pointeur local non initialisé contient une adresse que personne n'a choisie.
#include <stdio.h>
int main(void){ int *p;
*p = 3; printf("%d\n", *p); return 0;}$ gcc -Wall -Wextra -g noninit.c -o noninitnoninit.c: In function 'main':noninit.c:7:8: warning: 'p' is used uninitialized [-Wuninitialized] 7 | *p = 3; | ~~~^~~noninit.c:5:10: note: 'p' was declared here 5 | int *p; | ^$ ./noninitSegmentation faultPile (stack)
- int *p
- ? indéterminé
Ici le programme plante, ce qui est la meilleure issue possible. Si l'adresse au hasard était tombée dans une zone accessible, *p = 3 aurait écrasé une autre variable sans rien dire.
Les autres erreurs classiques sont des variantes du même problème, une adresse qui ne désigne plus rien de valide : renvoyer l'adresse d'une variable locale, utiliser un bloc après free, sortir d'un tableau. Elles sont détaillées, avec la sortie de gdb et de Valgrind, dans Segmentation fault en C.
Le visualiseur Grademe exécute un programme C ligne par ligne et dessine la pile, le tas et les flèches des pointeurs à chaque étape : les boutons « Visualiser » des programmes ci-dessus l'ouvrent directement. Pour pratiquer dans l'ordre, le module du parcours C consacré aux pointeurs enchaîne l'adresse, l'indirection, les paramètres de sortie et les intervalles.
Questions fréquentes
Quelle est la taille d'un pointeur en C ?
Sur les systèmes 64 bits courants (Linux, macOS ou Windows sur x86_64 et ARM64), un pointeur occupe 8 octets, quel que soit le type désigné : sizeof(int *) et sizeof(char *) valent tous deux 8. Sur un système 32 bits, c'est 4 octets. sizeof p te donne la réponse sur ta machine.
Pourquoi écrire (void *) dans printf("%p") ?
Parce que la norme demande à %p un pointeur vers void (ou, depuis C23, vers un type caractère). Passer un int * sans conversion marche sur la plupart des machines mais sort de ce que la norme garantit. -Wall ne dit rien, -Wpedantic le signale : format '%p' expects argument of type 'void *', but argument 2 has type 'int *'.
Quelle différence entre NULL, 0 et nullptr ?
Les trois donnent un pointeur nul quand on les affecte à un pointeur. 0 est une constante entière que la norme accepte comme constante de pointeur nul, NULL est une macro définie par les en-têtes standard, et nullptr, ajouté par C23, a son propre type, ce qui évite de le confondre avec un entier. Écris NULL (ou nullptr en C23), jamais 0, pour qu'on voie qu'il s'agit d'un pointeur.
Pourquoi l'adresse affichée change à chaque exécution ?
Parce que Linux randomise l'emplacement de la pile, du tas et des bibliothèques à chaque lancement d'un programme (c'est le réglage randomize_va_space du noyau). Ton programme ne doit jamais dépendre de la valeur numérique d'une adresse.
Un tableau, c'est un pointeur ?
Non. Le nom d'un tableau se convertit en pointeur vers son premier élément dans la plupart des expressions, d'où la confusion, mais sizeof d'un tableau donne sa taille totale, et on ne peut pas réaffecter un tableau. Le détail est dans Tableau ou pointeur en C.
Sources
