Adresse et déréférencement, pointeur et type pointé, passage par adresse, relation entre pointeur et tableau, arithmétique des pointeurs, pointeur nul.
Le chapitre 4 s'est achevé sur un échec : une fonction echanger parfaitement écrite qui ne
change rien, parce qu'elle ne reçoit que des copies. Voici la version qui fonctionne.
void echanger(int *a, int *b) { int t = *a; *a = *b; *b = t;} int main(void) { int x = 3, y = 7; echanger(&x, &y); /* on passe les ADRESSES */ printf("%d %d\n", x, y); /* affiche 7 3 */}Trois étoiles et deux esperluettes séparent le code qui échoue de celui qui marche. Ce chapitre explique ce qu'elles signifient — et c'est le point qui coince du semestre, celui qui décide souvent du reste.
Un conseil de méthode avant de commencer, et il n'est pas décoratif : dessinez la mémoire. Des cases, des adresses, des flèches. Tout ce qui suit devient évident sur un schéma et reste opaque sur du code seul.
Une variable a une adresse
Le chapitre 5 du cours d'architecture l'a posé : la mémoire est un tableau d'octets numérotés. Une variable occupe une plage de ces octets, et le numéro du premier est son adresse.
Deux opérateurs suffisent, et ils sont réciproques.
&x rend l'adresse de x. On lit « adresse de ».
*p désigne la case dont p contient l'adresse. On lit « la case pointée par », et
l'opération s'appelle le déréférencement.
int a = 10;int *p; /* p est un pointeur vers un int */p = &a; /* p contient l'adresse de a */printf("%d", *p); /* affiche 10 : la valeur DANS la case pointée */*p = 20; /* écrit 20 dans a, sans nommer a */La dernière ligne est le cœur du chapitre. a a changé sans être nommée. Deux chemins mènent
désormais à la même case : le nom a, et le détour par p.
La variable a occupe quatre octets quelque part. Cette place a une adresse — ici 0x7ffc10 — que le programme n'a pas choisie et qui changera d'une exécution à l'autre. Le nom « a » est une commodité du compilateur ; la machine, elle, ne connaît que l'adresse.
Une confusion à écarter tout de suite, parce qu'elle vient de la syntaxe. L'étoile a deux
sens selon l'endroit où elle apparaît. Dans une déclaration, int *p signifie « p est
un pointeur vers un int ». Dans une expression, *p signifie « la case pointée par p ».
Ce sont deux choses différentes qui s'écrivent pareil, et c'est pourquoi on préfère écrire
int *p plutôt que int* p : l'étoile appartient à la variable, pas au type — d'ailleurs
int* a, b; déclare un pointeur et un entier, ce qui surprend tout le monde une fois.
Le type pointé décide de tout
Un pointeur ne contient qu'un nombre — une adresse — et tous les pointeurs ont la même taille, 8 octets sur une machine 64 bits. À quoi sert alors le type ?
À deux choses, et elles sont essentielles.
Il dit combien d'octets lire. *p sur un int * lit quatre octets et les interprète en
complément à deux ; sur un char *, il en lit un. Le motif en mémoire est le même, la lecture
diffère — c'est exactement le propos du chapitre 2 d'architecture.
Il fixe le pas de l'arithmétique. C'est la section suivante.
L'arithmétique des pointeurs
int T[5] = {10, 20, 30, 40, 50};int *p = T; /* p pointe sur T[0] */p = p + 1; /* p pointe sur T[1] */p + 1 n'ajoute pas 1 à l'adresse : il ajoute sizeof(*p) octets, soit 4 pour un int.
L'unité de l'arithmétique des pointeurs est l'élément, pas l'octet. Sur un char *, le même
p + 1 avancerait d'un seul octet.
Trois opérations sont définies, et trois seulement : ajouter un entier à un pointeur, soustraire un entier, et soustraire deux pointeurs — ce qui rend le nombre d'éléments qui les séparent, pas le nombre d'octets. Additionner deux pointeurs n'a aucun sens et est refusé.
Ces opérations ne sont valides qu'à l'intérieur d'un même tableau — plus une case après la fin, position autorisée pour marquer la fin d'un parcours, mais qu'on n'a pas le droit de déréférencer. Sortir de ce cadre est un comportement indéfini.
Pointeur et tableau
Voici la relation qui explique le chapitre 5, et il faut l'énoncer précisément.
Le nom d'un tableau, employé dans une expression, se convertit en un pointeur sur son premier
élément. Donc T équivaut à &T[0], et l'indexation n'est qu'une notation :
C'est littéralement la définition de l'opérateur [] dans la norme. Une conséquence amusante et
parfaitement légale : puisque l'addition est commutative, T[3] et 3[T] désignent la même
case.
Trois différences subsistent, et elles comptent.
sizeof. Sur le tableau, il rend la taille totale ; sur un pointeur, 8. C'est ce qui casse
l'idiome du chapitre 5 dès qu'on passe le tableau en paramètre.
L'affectation. On peut écrire p = T, jamais T = p : le nom d'un tableau n'est pas une
variable modifiable, c'est une adresse fixée à la compilation.
&. &T a le type « pointeur vers un tableau de 5 int », différent de &T[0]. Les deux
ont la même valeur numérique et un comportement différent en arithmétique — &T + 1 avance de
vingt octets.
Le paramètre int T[] du chapitre 5 est donc exactement un int *, et l'on comprend
rétrospectivement pourquoi un tableau n'est jamais copié : c'est son adresse qui l'est.
int T[5]; int *p = T; — que vaut p + 2 en octets, et que désigne *(p + 2) ?
Le passage par adresse
On peut enfin relire l'introduction du chapitre.
void echanger(int *a, int *b) { int t = *a; *a = *b; *b = t; }echanger(&x, &y);Le C n'a pas changé de règle : les arguments sont toujours copiés. Ce qui est copié, cette fois, ce sont des adresses — et une copie d'adresse désigne la même case que l'original. La fonction ne modifie pas ses paramètres, elle modifie ce qu'ils désignent.
Le passage par adresse sert à trois choses en pratique.
Modifier une variable de l'appelant : c'est echanger, et c'est l'esperluette de
scanf("%d", &age) — cette fonction doit remplir votre variable, donc elle en reçoit l'adresse.
Rendre plusieurs résultats : une fonction C ne rend qu'une valeur, donc les autres sortent par des paramètres pointeurs. C'est l'idiome de la bibliothèque standard.
Éviter une copie coûteuse : passer une grosse structure par adresse coûte 8 octets au lieu
de sa taille entière. On la déclare alors const pour signifier qu'on ne la modifiera pas.
Le pointeur nul, et les deux autres façons de se tromper
NULL est une valeur de pointeur garantie ne désigner aucun objet valide. Son intérêt n'est pas
d'être « rien » mais d'être testable : if (p != NULL) a un sens, et c'est ce qui la
distingue des deux situations vraiment dangereuses.
Le pointeur sauvage est un pointeur non initialisé. Il contient ce qui traînait sur la pile,
et le déréférencer écrit à une adresse arbitraire — qui peut être valide, auquel cas rien ne
plante et une donnée quelconque est corrompue. C'est pire que NULL, parce que l'erreur est
silencieuse. D'où la règle : initialiser tout pointeur, à NULL faute de mieux.
Le pointeur pendant désigne une case qui n'existe plus. Deux façons de le fabriquer : rendre
l'adresse d'une variable locale, dont le cadre disparaît au retour, ou conserver un pointeur
après free — c'est le chapitre 8.
Déréférencer NULL provoque une erreur de segmentation immédiate, et c'est la meilleure des
trois issues : l'erreur est localisée là où elle se produit. Les deux autres corrompent
silencieusement, et le programme plante mille lignes plus loin.
Pourquoi le passage par adresse n'est-il pas une exception à la règle « le C ne connaît que le passage par valeur » ?
À vous
L'exercice modélise la mémoire comme un tableau d'octets adressables, avec des variables nommées, et vous fait manipuler des pointeurs dessus. C'est le dessin du cours, rendu exécutable.
Quatre temps. Poser une variable, prendre son adresse, la modifier par déréférencement — et
vérifier que la variable a changé. Écrire echanger par adresse, et le comparer à la version
par valeur du chapitre 4. Vérifier l'arithmétique : p + 1 avance de sizeof octets, et
T[i] donne bien la même case que *(T + i). Enfin, provoquer les trois fautes — NULL
déréférencé, pointeur sauvage, pointeur pendant sur une variable locale — et constater que la
première est la seule à s'annoncer.
Manipulez une mémoire adressable : déréférencement, échange par adresse, arithmétique, et les trois fautes.
// ── Une mémoire adressable ──────────────────────────────────────────────── function creerMemoire(base = 0x1000) { const octets = new Map(); let libre = base; const TAILLES = { int: 4, char: 1, pointeur: 8 }; return { // Réserve de la place et rend l'ADRESSE, comme le fait le compilateur // pour une variable locale. declarer(type, valeur = 0) { const adresse = libre; libre += TAILLES[type]; octets.set(adresse, { type, valeur }); return adresse; }, lire(adresse) { const c = octets.get(adresse); if (c === undefined) return { erreur: "Segmentation fault à l'adresse 0x" + adresse.toString(16) }; return c.valeur; }, ecrire(adresse, valeur) { const c = octets.get(adresse); if (c === undefined) return { erreur: "Segmentation fault à l'adresse 0x" + adresse.toString(16) }; c.valeur = valeur; return { ok: true }; }, taille: (type) => TAILLES[type], vue(noms) { for (const [nom, adr] of Object.entries(noms)) { const c = octets.get(adr); const v = c.type === "pointeur" ? "0x" + Number(c.valeur).toString(16) + " ──►" : c.valeur; console.log(" 0x" + adr.toString(16) + " " + nom.padEnd(4) + "(" + c.type + ")".padEnd(9) + " = " + v); } }, }; } const M = creerMemoire(); const NULL = 0; // ── 1. Adresse et déréférencement ───────────────────────────────────────── const a = M.declarer("int", 10); // int a = 10; const p = M.declarer("pointeur", NULL); // int *p = NULL; M.ecrire(p, a); // p = &a; // ← à écrire : *p = 20, c'est-à-dire écrire 20 dans la case DÉSIGNÉE par p // ── 2. Échanger, par valeur puis par adresse ────────────────────────────── function echangerParValeur(memoire, x, y) { // Les paramètres sont des COPIES : on travaille sur des cases à nous. let copieX = memoire.lire(x), copieY = memoire.lire(y); const t = copieX; copieX = copieY; copieY = t; // …et elles disparaissent ici. } function echangerParAdresse(memoire, px, py) { // ← à écrire : px et py CONTIENNENT les adresses de x et y } // ── 3. Arithmétique des pointeurs ───────────────────────────────────────── // Un tableau : cinq int consécutifs. const T = []; for (const v of [10, 20, 30, 40, 50]) T.push(M.declarer("int", v)); function avancer(adresseBase, i, type) { return adresseBase; // ← à écrire : le pas est sizeof(type), pas 1 } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Écrivez *p = 20 et vérifiez que « a » a changé sans être nommée. // 2. Écrivez echangerParAdresse, et comparez aux deux versions. // 3. Écrivez avancer() et vérifiez que T[i] et *(T + i) donnent la même case. // 4. Provoquez les trois fautes : NULL déréférencé, pointeur sauvage, // pointeur pendant. Laquelle s'annonce, laquelle se tait ? console.log("après p = &a :"); M.vue({ a, p });
En travaux pratiques
Provoquer une erreur de segmentation, puis la lire
Fabriquer délibérément cinq plantages différents, apprendre à les distinguer, et cesser de voir « Segmentation fault » comme un message unique et opaque.
- Les TP 4 à 6
- gdb, et les détecteurs -fsanitize=address et undefined
- 1. Le pointeur nul
Écrivez un programme qui déréférence un pointeur nul. Exécutez-le sous gdb et notez l'adresse fautive affichée.
- 2. Le pointeur non initialisé
Déclarez un pointeur sans l'initialiser et écrivez à travers lui. Exécutez plusieurs fois. Comparez la reproductibilité avec le cas précédent.
- 3. Le pointeur pendant
Reprenez la fonction du TP 4 qui renvoie l'adresse d'une locale, et utilisez le résultat après deux autres appels. Notez que le programme ne plante PAS toujours.
- 4. L'écriture en zone interdite
Tentez de modifier une chaîne littérale. Exécutez, puis regardez dans quelle zone mémoire elle se trouve avec la carte du TP 6 de Systèmes.
- 5. Le franchissement
Parcourez un tableau avec un pointeur, en allant assez loin pour sortir de la page. Trouvez au bout de combien d'éléments le programme plante enfin.
- 6. Les lire, pas les subir
Pour vos cinq programmes, obtenez la pile d'appels sous gdb et le rapport du détecteur d'adresses. Dressez le tableau : symptôme, cause, outil qui l'a trouvée.
- 7. Arithmétique de pointeurs
Affichez la valeur d'un pointeur sur int avant et après incrémentation, puis d'un pointeur sur char et sur une structure. Déduisez la règle.
- 8. Au fil rouge
Remplacez dans journal tous les parcours par indice par des parcours par pointeur. Vérifiez au détecteur qu'aucun ne sort, et comparez les temps.
- Vos cinq programmes plantent de cinq façons, et vous savez les distinguer
- Vous expliquez pourquoi le pointeur pendant est le plus dangereux des cinq
- Vous donnez de mémoire ce que vaut p+1 pour un pointeur sur une structure de 12 octets
Ce que la suite en fait
Le chapitre 8 donne aux pointeurs leur véritable emploi. Jusqu'ici, ils désignaient des cases qui existaient déjà ; on va maintenant créer de la mémoire, dont la durée de vie n'est plus liée à un bloc — et dont il faudra décider qui la rend.
C'est là que les listes chaînées d'Algorithmique 2 deviennent implémentables : la cellule du chapitre 5 de ce cours-là est une structure avec un champ pointeur, et le chaînage est exactement ce que ce chapitre vient de poser.
À retenir
Vous avez parcouru les 10 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.