Vue d'ensemble
En C, une variable n'est pas une boîte abstraite : c'est un emplacement physique dans la mémoire de l'ordinateur, repéré par un numéro appelé adresse. Comprendre les pointeurs, c'est comprendre comment manipuler directement ces emplacements. C'est le cœur du C, et la raison pour laquelle une fonction peut, ou ne peut pas, modifier une variable de l'appelant.
Tant que ce chapitre n'est pas maîtrisé, le C reste flou : les tableaux, les chaînes, l'allocation dynamique, les structures chaînées reposent toutes sur les pointeurs. On avance donc lentement, une notion à la fois, en décortiquant chaque ligne.
int *p) ; opérateurs & (adresse de) et * (déréférencement) ; passage d'arguments par valeur et modification via un pointeur ; lien entre tableaux et pointeurs (t[i] et *(t+i)) ; allocation dynamique avec malloc et libération avec free ; pièges classiques (fuite mémoire, pointeur non initialisé, pointeur nul).
Prérequis
- Déclarer des variables et des fonctions en C (fiche C — Premiers pas).
- Écrire une boucle
foret une conditionif. - Connaître les types de base
int,char, et la notion de tableauint t[5]. - Utiliser
printfavec les formats%det%p.
Les pointeurs, ça se débloque en une séance. Beaucoup d'élèves de MP2I calent ici, puis tout devient limpide dès qu'un mentor déroule la mémoire avec eux, case par case. Nos mentors alumni X · Centrale · Mines l'ont enseigné des dizaines de fois.
Trouver un mentor →Adresse et pointeur
Quand tu écris int x = 42;, le compilateur réserve quelque part en mémoire une zone, de la taille d'un int (souvent 4 octets), pour y ranger la valeur 42. Cette zone a une position dans la mémoire : son adresse.
L'adresse d'une variable est le numéro de l'emplacement mémoire où cette variable est stockée. On l'obtient avec l'opérateur & : &x se lit « adresse de x ». C'est un nombre (affiché en hexadécimal par %p), mais on ne l'écrit jamais à la main : on laisse la machine le fournir.
Un pointeur est une variable dont la valeur est une adresse. On dit qu'il « pointe vers » la variable qui se trouve à cette adresse. Le type précise vers quoi il pointe : int *p déclare un pointeur vers un int, char *c un pointeur vers un char. L'étoile fait partie du type : p n'est pas un int, c'est « un pointeur vers un int ».
Deux opérateurs, exactement inverses l'un de l'autre, font tout le travail :
&x— de la variable vers son adresse (« où estx? »).*p— de l'adresse vers la valeur (« que vaut ce qui est à l'adressep? »). On appelle cela déréférencer.
#include <stdio.h>
int main(void) {
int x = 42; // une variable entiere
int *p = &x; // p recoit l'adresse de x : p pointe vers x
printf("%d\n", x); // la valeur de x
printf("%d\n", *p); // la valeur pointee par p, donc celle de x
*p = 99; // on ecrit 99 a l'adresse pointee : on modifie x
printf("%d\n", x); // x vaut maintenant 99
return 0;
}int x = 42;On crée un entier x valant 42 ; le compilateur lui réserve une adresse en mémoire.int *p = &x;On déclare un pointeur vers un int et on l'initialise avec &x, l'adresse de x. Désormais p « pointe vers » x. Le type int * doit correspondre au type de x (un int).printf("%d\n", x);Affiche directement la valeur de x : 42.printf("%d\n", *p);*p déréférence : on va à l'adresse contenue dans p et on lit la valeur qui s'y trouve, soit celle de x. Affiche aussi 42.*p = 99;Ici *p est à gauche du = : on écrit 99 à l'adresse pointée par p. Comme p pointe vers x, cela change x lui-même.printf("%d\n", x);Affiche 99 : x a bien été modifié à travers le pointeur, sans qu'on écrive x = 99.int *p), l'étoile fait partie du type et signifie « pointeur vers ». Dans une expression (*p), l'étoile est l'opérateur de déréférencement, « la valeur pointée par ». Même symbole, deux rôles : ne les confonds pas.
& et * s'annulent. Pour n'importe quelle variable x, l'expression *(&x) vaut exactement x : on prend l'adresse de x, puis on lit la valeur à cette adresse, donc on retombe sur x.
Passage par valeur : l'échange qui échoue
En C, quand on appelle une fonction, ses arguments sont copiés. La fonction travaille sur des copies locales ; modifier une copie ne change rien chez l'appelant. C'est le passage par valeur, et c'est la règle par défaut pour tous les types de base.
Illustration avec la fonction canonique : échanger deux entiers. Voici la version naïve, qui ne marche pas.
#include <stdio.h>
void echange_faux(int a, int b) {
int tmp = a;
a = b;
b = tmp;
}
int main(void) {
int x = 3, y = 7;
echange_faux(x, y);
printf("x = %d, y = %d\n", x, y); // x = 3, y = 7 (rien n'a change !)
return 0;
}void echange_faux(int a, int b)La fonction reçoit deux int par valeur : a et b sont des copies de x et y. Elle ne renvoie rien (void).int tmp = a;On sauvegarde la copie a dans une variable temporaire.a = b; b = tmp;On échange bien a et b… mais ce sont les copies locales. Les vraies variables x et y de main ne sont jamais touchées.echange_faux(x, y);À l'appel, C copie les valeurs 3 et 7 dans a et b. Au retour, ces copies sont détruites ; x et y valent toujours 3 et 7.printf(...)Affiche x = 3, y = 7 : l'échange a échoué. C'est le piège central du passage par valeur.int ne peut pas modifier la variable de l'appelant. Elle n'en a qu'une copie. Pour agir sur la variable originale, il faut lui donner son adresse, c'est-à-dire un pointeur.
Modifier une variable via un pointeur : l'échange qui marche
La solution : ne pas passer les valeurs, mais les adresses. La fonction reçoit alors des pointeurs et, en déréférençant, agit directement sur les variables de l'appelant.
#include <stdio.h>
void echange(int *a, int *b) {
int tmp = *a; // on lit la valeur pointee par a
*a = *b; // on ecrit dans la case pointee par a la valeur pointee par b
*b = tmp; // on ecrit dans la case pointee par b l'ancienne valeur
}
int main(void) {
int x = 3, y = 7;
printf("avant : x = %d, y = %d\n", x, y);
echange(&x, &y); // on passe les ADRESSES de x et y
printf("apres : x = %d, y = %d\n", x, y);
return 0;
}void echange(int *a, int *b)La fonction reçoit deux pointeurs vers des int. a contient l'adresse d'une variable, b l'adresse d'une autre. Elle pourra donc agir sur ces variables.int tmp = *a;*a lit la valeur stockée à l'adresse a. On la sauvegarde dans tmp. Ici tmp reçoit 3.*a = *b;À gauche *a désigne « la case pointée par a », à droite *b vaut « la valeur pointée par b ». On écrit donc 7 dans la variable pointée par a (c'est x).*b = tmp;On écrit l'ancienne valeur (3) dans la variable pointée par b (c'est y). L'échange est complet et il a lieu sur les vraies variables.echange(&x, &y);On passe &x et &y, les adresses de x et y. La fonction reçoit ainsi de quoi atteindre les originaux, pas des copies.printf("apres ...")Affiche apres : x = 7, y = 3 : cette fois l'échange a bien eu lieu.| Étape | Instruction | x (= *a) | y (= *b) | tmp |
|---|---|---|---|---|
| 0 | état initial | 3 | 7 | — |
| 1 | tmp = *a; | 3 | 7 | 3 |
| 2 | *a = *b; | 7 | 7 | 3 |
| 3 | *b = tmp; | 7 ✓ | 3 ✓ | 3 |
- Côté fonction : déclare le paramètre comme un pointeur (
int *aau lieu deint a). - Dans la fonction : pour lire la variable, utilise
*a; pour écrire dedans, mets*aà gauche du=. - Côté appel : passe l'adresse de la variable avec
&(par exempleechange(&x, &y)).
& de scanf("%d", &n); n'est pas magique : scanf doit écrire dans n, il lui faut donc l'adresse de n. C'est exactement le même mécanisme que echange.
Tableaux et pointeurs
En C, tableaux et pointeurs sont intimement liés. Le nom d'un tableau, utilisé dans une expression, vaut l'adresse de sa première case. C'est pourquoi on peut passer un tableau à une fonction sans le copier : on passe en réalité un pointeur vers son début.
Si t est un tableau, alors t vaut l'adresse de t[0], et pour tout indice i valide :
L'addition t + i n'ajoute pas i octets : elle avance de i cases, c'est-à-dire de i fois la taille d'un élément. C'est l'arithmétique des pointeurs.
#include <stdio.h>
int main(void) {
int t[4] = {10, 20, 30, 40};
printf("%d\n", t[2]); // notation habituelle
printf("%d\n", *(t + 2)); // notation pointeur : meme case
printf("%d\n", *t); // *t vaut t[0]
return 0;
}int t[4] = {10, 20, 30, 40};Un tableau de 4 entiers. Les 4 cases sont contiguës en mémoire ; t désigne l'adresse de la première.printf("%d\n", t[2]);Notation classique : la case d'indice 2, donc 30.printf("%d\n", *(t + 2));t + 2 est l'adresse de la case 2 (on avance de 2 cases depuis le début) ; * lit sa valeur. Affiche aussi 30 : c'est exactement t[2].printf("%d\n", *t);*t vaut *(t + 0), donc t[0], soit 10.sizeof n'y donne plus la taille totale. Il faut donc toujours transmettre la longueur n en paramètre à côté du tableau.
Allocation dynamique : malloc et free
Un tableau int t[4] a une taille fixée à la compilation. Mais souvent on ne connaît le nombre d'éléments qu'à l'exécution (saisi par l'utilisateur, lu dans un fichier…). Il faut alors allouer dynamiquement la mémoire, avec malloc, et la rendre quand on a fini, avec free.
malloc(taille) (de memory allocation, déclaré dans <stdlib.h>) réserve un bloc de taille octets et renvoie l'adresse du début de ce bloc, ou NULL si la réservation échoue. Pour un tableau de n entiers, on demande n * sizeof(int) octets. free(p) rend au système le bloc dont p est le début.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int n = 5;
int *t = malloc(n * sizeof(int)); // un tableau de n entiers
if (t == NULL) return 1; // securite : malloc a-t-il reussi ?
for (int i = 0; i < n; i++) {
t[i] = i * i; // 0, 1, 4, 9, 16
}
int somme = 0;
for (int i = 0; i < n; i++) {
somme += t[i];
}
printf("somme = %d\n", somme); // 0+1+4+9+16 = 30
free(t); // on rend la memoire
return 0;
}int *t = malloc(n * sizeof(int));On demande de quoi stocker n entiers, soit n * sizeof(int) octets. malloc renvoie l'adresse du bloc ; on la range dans le pointeur t. On peut ensuite utiliser t comme un tableau.if (t == NULL) return 1;Si la mémoire manque, malloc renvoie NULL. Toujours tester : déréférencer NULL fait planter le programme.for (int i = 0; i < n; i++) { t[i] = i * i; }On remplit les cases. t[i] fonctionne exactement comme pour un tableau statique, grâce à l'équivalence t[i] ≡ *(t+i).somme += t[i];Seconde boucle : on additionne les cases. La somme des carrés 0+1+4+9+16 vaut 30.printf("somme = %d\n", somme);Affiche somme = 30.free(t);On rend le bloc au système. Sans ce free, la mémoire reste réservée jusqu'à la fin du programme : c'est une fuite mémoire.malloc réclame son free. Oublier free laisse le bloc réservé pour rien. Dans une boucle qui alloue à répétition sans libérer, la consommation grimpe jusqu'au plantage. Règle mnémotechnique : un malloc, un free.
free, ni deux fois free. Après free(t), l'adresse dans t n'est plus valable : y lire ou y écrire, ou refaire free(t), est un comportement indéfini. Bonne habitude : t = NULL; juste après le free.
int *p; *p = 5; sans jamais avoir donné d'adresse valable à p écrit à une adresse au hasard : plantage probable. Un pointeur doit toujours pointer vers quelque chose de valable (une variable via &, ou un bloc malloc) avant d'être déréférencé.
Segfault, fuite, double free : les erreurs mémoire font perdre des heures. Un mentor t'apprend à les lire, à les prévenir, et à raisonner sur la mémoire comme un pro. Nos mentors alumni X · Centrale · Mines sont passés par là.
Trouver un mentor →Récap final — Ce qu'il faut absolument retenir
Les pointeurs manipulent des adresses. Deux opérateurs inverses (& pour prendre l'adresse, * pour lire ou écrire la valeur pointée), une règle d'or (le C passe par valeur, donc pour modifier il faut passer un pointeur), et une discipline mémoire (un malloc, un free). Vérifie que tu peux répondre oui à chaque question.
- Sais-tu expliquer la différence entre une variable, sa valeur et son adresse ?
- Sais-tu ce que déclare
int *p, et ce que valent&xet*p? - Sais-tu pourquoi
echange_faux(int a, int b)ne modifie rien, alors queechange(int *a, int *b)fonctionne ? - Sais-tu écrire une fonction qui modifie une variable de l'appelant (paramètre pointeur puis appel avec
&) ? - Sais-tu justifier l'équivalence
t[i]≡*(t + i)et dire ce que vaut*t? - Sais-tu allouer un tableau de
nentiers avecmalloc(n * sizeof(int))et tester le retour ? - Sais-tu ce qu'est une fuite mémoire et où placer le
free? - Sais-tu pourquoi il ne faut jamais déréférencer un pointeur non initialisé ou
NULL?
Exercices corrigés
Écris une fonction void triple(int *p) qui multiplie par 3 la variable pointée par p. Montre un main qui déclare int n = 4;, appelle triple, puis affiche n. Qu'affiche le programme ?
Voir la correction détaillée
La fonction reçoit un pointeur. Pour multiplier la variable par 3, on lit sa valeur avec *p, on multiplie, et on réécrit dans *p :
#include <stdio.h>
void triple(int *p) {
*p = *p * 3; // on ecrit 3 * (valeur pointee) a l'adresse p
}
int main(void) {
int n = 4;
triple(&n); // on passe l'ADRESSE de n
printf("%d\n", n); // affiche 12
return 0;
}À l'appel, p reçoit &n, donc *p désigne n. La ligne *p = *p * 3; calcule 4 * 3 = 12 et l'écrit dans n. Le programme affiche 12. Si on avait écrit void triple(int p) avec p = p * 3;, on n'aurait modifié qu'une copie et n serait resté 4.
Écris une fonction int somme(int *t, int n) qui renvoie la somme des n premières cases du tableau t, en utilisant l'écriture pointeur *(t + i) plutôt que t[i]. Teste-la sur {5, 1, 4, 2}.
Voir la correction détaillée
Le paramètre t est un pointeur vers le début du tableau. On parcourt les cases avec l'arithmétique des pointeurs :
#include <stdio.h>
int somme(int *t, int n) {
int s = 0;
for (int i = 0; i < n; i++) {
s += *(t + i); // equivaut a t[i]
}
return s;
}
int main(void) {
int tab[4] = {5, 1, 4, 2};
printf("%d\n", somme(tab, 4)); // affiche 12
return 0;
}*(t + i) lit la case d'indice i : c'est exactement t[i]. La somme 5 + 1 + 4 + 2 = 12. Noter qu'à l'appel somme(tab, 4) on passe tab (l'adresse de sa première case) sans & : le nom d'un tableau vaut déjà cette adresse. On doit transmettre 4 car la fonction ne connaît pas la taille du tableau.
Écris une fonction int *copie(int *t, int n) qui alloue un nouveau tableau de n entiers, y recopie les n cases de t, et renvoie l'adresse du nouveau tableau. Qui doit faire le free, et pourquoi la fonction ne peut-elle pas le faire elle-même ?
Voir la correction détaillée
On alloue avec malloc, on teste le retour, on recopie, on renvoie le pointeur :
#include <stdio.h>
#include <stdlib.h>
int *copie(int *t, int n) {
int *c = malloc(n * sizeof(int));
if (c == NULL) return NULL; // echec d'allocation
for (int i = 0; i < n; i++) {
c[i] = t[i]; // recopie case par case
}
return c; // on renvoie l'adresse du nouveau bloc
}
int main(void) {
int tab[3] = {7, 8, 9};
int *d = copie(tab, 3);
printf("%d %d %d\n", d[0], d[1], d[2]); // 7 8 9
free(d); // c'est a l'APPELANT de liberer
return 0;
}La fonction renvoie l'adresse du bloc malloc. Elle ne peut pas faire free(c) avant de renvoyer : elle rendrait la mémoire, et l'appelant recevrait une adresse invalide (bloc libéré). C'est donc l'appelant qui possède le bloc et doit appeler free(d) quand il a fini. C'est la règle du C : celui qui reçoit un pointeur issu d'un malloc est responsable de sa libération. Sortie du programme : 7 8 9.