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.

Par
Publié le

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.

adresse.cVisualiser
#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;}
Terminal
$ 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 octets

Trois 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; :

Après int *p = &n;

Pile (stack)

main()
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é :

Après *p = 7;

Pile (stack)

main()
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.

ÉcritureSe litDans adresse.c
int *p;p est un pointeur vers un intdéclaration, pas de valeur affichée
&nl'adresse de n0xfffffee986d4
pl'adresse rangée dans p0xfffffee986d4
*pl'int rangé à cette adresse42, puis 7
p = &np désigne désormais nchange le pointeur
*p = 7l'int désigné reçoit 7change 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.

copie.cÀ éviter
#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;}
Terminal
$ gcc -Wall -Wextra -g copie.c -o copie$ ./copiedans main : &x = 0xfffffe695e7c, &y = 0xfffffe695e78dans swap : &a = 0xfffffe695e4c, &b = 0xfffffe695e48x = 1, y = 2

a 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 :

Fin de swap(x, y) : les copies sont échangées, pas les originaux

Pile (stack)

main()
intx
1
à 0xfffffe695e7c
inty
2
à 0xfffffe695e78
swap(a, b)
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.

Par valeurÀ éviter
void swap(int a, int b){    int tmp = a;
    a = b;    b = tmp;}/* appel : swap(x, y); */
Par adresseCorrect
void swap(int *a, int *b){    int tmp = *a;
    *a = *b;    *b = tmp;}/* appel : swap(&x, &y); */
swap.cVisualiser
#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;}
Terminal
$ gcc -Wall -Wextra -g swap.c -o swap$ ./swapx = 2, y = 1

a et b sont toujours des copies, mais des copies d'adresses : elles désignent les vraies variables de main.

Fin de swap(&x, &y) : a et b désignent x et y

Pile (stack)

main()
intxx
2
intyy
1
swap(a, b)
int *a
x
int *b
y
inttmp
1
C

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.

Ouvrir l'exercice

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.

C

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).

Ouvrir l'exercice

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).

tableau.cVisualiser
#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 &notes[3] et p : %td\n", &notes[3] - p);    return 0;}
Terminal
$ 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 &notes[3] et p : 3

Les 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 :

p désigne le début de notes, p + 2 désigne la case 2

Pile (stack)

main()
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 :

null.c
#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;}
Terminal
$ 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.

double.c
#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;}
Terminal
$ gcc -Wall -Wextra -g double.c -o double$ ./double&n  = 0xffffe7fc5c04p   = 0xffffe7fc5c04&p  = 0xffffe7fc5bf8pp  = 0xffffe7fc5bf8*pp = 0xffffe7fc5c04**pp = 42

Chaque étoile fait un saut : *pp suit la première flèche et donne p, **pp suit la seconde et donne n.

pp désigne p, qui désigne n

Pile (stack)

main()
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 **.

pp.cVisualiser
#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;}
Terminal
$ gcc -Wall -Wextra -g pp.c -o pp$ ./pp0 1 4 9$ valgrind -q --leak-check=full ./pp0 1 4 9

Avec -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 :

Après la ligne 12, *sortie = t : carres reçoit l'adresse du bloc

Pile (stack)

main()
int *carrescarres
tab
creer_tableau()
int **sortie
carres
inttaille
4
int *t
tab

Tas (heap)

tabint[4]malloc(4 * sizeof(int))
0149

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.

fonction.c
#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;}
Terminal
$ gcc -Wall -Wextra -g fonction.c -o fonction$ ./fonction1342

La 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.

noninit.cÀ éviter
#include <stdio.h>
int main(void){    int *p;
    *p = 3;    printf("%d\n", *p);    return 0;}
Terminal
$ 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 fault
Ligne 7 : p ne désigne rien de valide

Pile (stack)

main()
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.

Exécute ton code pas à pas dans le visualiseur

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.

Pointers
7 exercices

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

À toi d'écrire le code.