Chapitre 1 · 7 h
Pointeurs
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.
Animation · 7 étapes
Un pointeur ne contient pas une valeur : il contient une adresse
- Une case nommée a, à une adresse — 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.
- p est déclaré, et ne pointe sur rien — p est une case, lui aussi — une case destinée à contenir une adresse, pas un entier. Non initialisé, il contient ce qui traînait en mémoire. Le déréférencer maintenant lirait une adresse quelconque : c'est le pointeur sauvage, et c'est une faute plus dangereuse que NULL, car elle ne plante pas toujours.
- p = &a — la flèche est posée — L'opérateur & rend l'adresse d'une variable. p contient maintenant 0x7ffc10, c'est-à-dire l'endroit où vit a. Deux chemins mènent désormais à la même case : le nom a, et le détour par p.
- *p = 20 — a change sans être nommée — C'est l'étape décisive du chapitre. L'étoile déréférence : « la case dont p donne l'adresse ». On n'a pas écrit « a », et pourtant a vaut 20. C'est exactement ce qui rend possible le passage par adresse — une fonction qui reçoit p modifie la variable de son appelant.
- int b = *p — lecture à travers le pointeur — Même opérateur, autre sens : ici *p est lu, pas écrit. b reçoit une COPIE de 20. Modifier b ensuite ne changerait ni a, ni *p : b est une troisième case, indépendante.
- p = NULL — la flèche est retirée — NULL est l'adresse dont on garantit qu'aucune donnée ne s'y trouve. Elle sert de valeur « ne pointe sur rien » vérifiable : on peut TESTER p avant de l'utiliser, ce qu'on ne peut pas faire d'un pointeur sauvage. C'est pourquoi on initialise à NULL par défaut.
- *p = 5 — erreur de segmentation — Écrire à l'adresse 0 touche une page que le système marque inaccessible : le matériel lève une exception et le processus est tué. C'est une bonne nouvelle — l'erreur est immédiate et localisée. Le même code avec un pointeur sauvage écrirait peut-être ailleurs sans rien casser tout de suite, et le programme planterait mille lignes plus loin.
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.
Quiz · 1 question
int T[5]; int *p = T; — que vaut p + 2 en octets, et que désigne *(p + 2) ?
- p + 2 octets, et *(p + 2) désigne le troisième octet du tableau — en octets
- p + 8 octets, car l'unité de l'arithmétique des pointeurs est l'ÉLÉMENT : 2 × sizeof(int) = 8. Et *(p + 2) désigne T[2] — en éléments
- p + 2 éléments seulement si le tableau est déclaré const — selon const
Réponse : L'unité de l'arithmétique des pointeurs est l'ÉLÉMENT POINTÉ, jamais l'octet : p + 2 avance de 2 × sizeof(*p), soit 8 octets pour des int, et un seul octet par unité si p était un char *. C'est le TYPE du pointeur qui fixe le pas, et c'est sa seconde raison d'être — la première étant de dire combien d'octets lire au déréférencement. Quant à *(p + 2), c'est la définition même de T[2] dans la norme : l'opérateur [] est une notation pour cette expression. D'où la curiosité, légale, que 2[T] désigne la même case que T[2], l'addition étant commutative.
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.
Quiz · 1 question
Pourquoi le passage par adresse n'est-il pas une exception à la règle « le C ne connaît que le passage par valeur » ?
- C'en est bien une : le C possède deux modes de passage, par valeur et par adresse — deux modes
- Parce que c'est un passage par valeur D'UNE ADRESSE : le pointeur est copié comme n'importe quel argument, mais une copie d'adresse désigne la même case que l'original — la fonction modifie ce que le paramètre DÉSIGNE, pas le paramètre — valeur d'une adresse
- Parce que les pointeurs sont exemptés de copie pour des raisons de performance — exemption
Réponse : La règle du chapitre 4 ne souffre aucune exception : tout argument est copié. Dans echanger(&x, &y), ce qui est copié est le NOMBRE que constitue l'adresse de x. Le paramètre a est une variable locale de la fonction, et lui affecter une nouvelle valeur — a = &z — n'aurait aucun effet chez l'appelant. Mais *a = 7 ne touche pas a : cela écrit dans la case dont a contient l'adresse, c'est-à-dire x. La distinction entre modifier le pointeur et modifier ce qu'il désigne est exactement celle qu'il faut tenir, et c'est ce que le dessin de la mémoire rend évident : deux flèches partant de deux cadres et aboutissant à la même case.
À 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.
Exercice de code
Manipulez une mémoire adressable : déréférencement, échange par adresse, arithmétique, et les trois fautes.
Point de départ
// ── 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 });
Solution
function creerMemoire(base = 0x1000) {
const octets = new Map();
let libre = base;
const TAILLES = { int: 4, char: 1, pointeur: 8 };
return {
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" + Number(adresse).toString(16) };
return c.valeur;
},
ecrire(adresse, valeur) {
const c = octets.get(adresse);
if (c === undefined) return { erreur: "Segmentation fault à l'adresse 0x" + Number(adresse).toString(16) };
c.valeur = valeur; return { ok: true };
},
liberer(adresse) { octets.delete(adresse); }, // pour le pointeur pendant
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 + " (" + c.type + ")").padEnd(16) + " = " + v);
}
},
};
}
const M = creerMemoire();
const NULL = 0;
console.log("— 1. adresse et déréférencement —");
const a = M.declarer("int", 10);
const p = M.declarer("pointeur", NULL);
M.ecrire(p, a); // p = &a
console.log(" après p = &a :");
M.vue({ a, p });
// *p = 20 : on écrit dans la case dont p CONTIENT l'adresse. Deux
// déréférencements imbriqués : lire p donne une adresse, écrire à celle-ci.
M.ecrire(M.lire(p), 20);
console.log(" après *p = 20 :");
M.vue({ a, p });
console.log(" « a » a changé sans avoir été nommée.");
console.log("");
console.log("— 2. échanger —");
const x = M.declarer("int", 3), y = M.declarer("int", 7);
function echangerParValeur(memoire, vx, vy) {
let cx = vx, cy = vy; const t = cx; cx = cy; cy = t;
}
function echangerParAdresse(memoire, px, py) {
// px et py CONTIENNENT des adresses : on échange ce qu'elles désignent.
const t = memoire.lire(px);
memoire.ecrire(px, memoire.lire(py));
memoire.ecrire(py, t);
}
echangerParValeur(M, M.lire(x), M.lire(y));
console.log(" par valeur : x = " + M.lire(x) + ", y = " + M.lire(y) + " (inchangés)");
echangerParAdresse(M, x, y);
console.log(" par adresse : x = " + M.lire(x) + ", y = " + M.lire(y) + " (échangés)");
console.log("");
console.log("— 3. arithmétique —");
const T = [];
for (const v of [10, 20, 30, 40, 50]) T.push(M.declarer("int", v));
const base = T[0];
// Le pas est sizeof(type), jamais 1 : c'est le TYPE du pointeur qui décide.
const avancer = (adresseBase, i, type) => adresseBase + i * M.taille(type);
for (let i = 0; i < 5; i++) {
const parIndice = T[i];
const parArithmetique = avancer(base, i, "int");
console.log(" T[" + i + "] à 0x" + parIndice.toString(16) +
" | *(T + " + i + ") à 0x" + parArithmetique.toString(16) +
" | valeur " + M.lire(parArithmetique) +
(parIndice === parArithmetique ? " identiques" : " DIFFÉRENTS"));
}
console.log(" Si le pas était 1 au lieu de 4, T + 1 tomberait au MILIEU de T[0].");
console.log("");
console.log("— 4. les trois fautes —");
console.log(" NULL déréférencé :", JSON.stringify(M.lire(NULL)));
const sauvage = M.declarer("pointeur", 0x9999); // non initialisé : valeur quelconque
console.log(" pointeur sauvage :", JSON.stringify(M.lire(M.lire(sauvage))));
const temporaire = M.declarer("int", 42);
const pendant = M.declarer("pointeur", temporaire);
M.liberer(temporaire); // le cadre disparaît
console.log(" pointeur pendant :", JSON.stringify(M.lire(M.lire(pendant))));
console.log(" Ici les trois s'annoncent parce que notre mémoire est modélisée.");
console.log(" En vrai, seul NULL plante à coup sûr : les deux autres tombent");
console.log(" souvent sur une adresse VALIDE, corrompent une donnée, et le");
console.log(" programme continue jusqu'à planter ailleurs, sans rapport.");
En travaux pratiques
Travaux pratiques 7 · 4 h
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.
Avant de commencer
- Les TP 4 à 6
- gdb, et les détecteurs -fsanitize=address et undefined
Énoncé
- 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.
- 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.
- 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.
- 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.
- 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. Indice : Une page fait 4096 octets ; tant qu'on reste dedans, rien n'arrête.
- 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.
- 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.
- 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.
C'est réussi quand
- 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
Correction
1. int *p = NULL; *p = 1; → SIGSEGV à l'adresse 0x0. TOUJOURS. Reproductible. 2. int *p; *p = 1; → adresse aléatoire : plante parfois, corrompt parfois 3. int *p = fonction_qui_rend_une_locale(); *p = 1; → l'adresse est VALIDE : ne plante presque jamais, corrompt la pile silencieusement 4. char *s = "texte"; s[0] = 'T'; → SIGSEGV : zone r--p, en lecture seule 5. int t[10]; int *p = t; for (i=0;i<100000;i++) *p++ = 0; → plante après ~1024 éléments : à la sortie de la PAGE
Cinq causes, deux symptômes seulement. Le cas 3 est le pire : l'adresse est valide, le matériel ne peut rien détecter, et la corruption se manifeste ailleurs, plus tard, dans du code innocent. C'est pourquoi « ça ne plante pas » ne veut pas dire « c'est correct » — la leçon centrale du chapitre.
gcc -g -O0 plantage.c -o plantage gdb ./plantage (gdb) run Program received signal SIGSEGV, Segmentation fault. 0x… in remplir (t=0x0, n=10) at plantage.c:12 12 t[i] = 0; (gdb) bt #0 remplir (t=0x0, n=10) at plantage.c:12 #1 main () at plantage.c:20 (gdb) print t $1 = (int *) 0x0
La pile d'appels donne le chemin complet, et l'affichage des arguments donne la cause : t vaut 0x0, donc l'appelant a transmis un pointeur nul — le vrai bogue est ligne 20, pas ligne 12. Sans -g, aucune de ces informations n'existe. Compiler en -g coûte de la taille de fichier, jamais de la vitesse.
int t[10]; /* 40 octets */ int *p = t; while (1) *p++ = 0; plante après environ 1024 écritures, pas après 10 pourquoi : la protection est par PAGE de 4096 octets. Tant qu'on écrit dans la page allouée, la MMU ne voit rien d'anormal — on écrase simplement les voisins.
Le matériel protège avec la granularité de la page, pas de la variable. C'est ce qui explique que dépasser de trois cases ne plante jamais, alors que dépasser de mille plante toujours. Le détecteur d'adresses comble exactement ce trou : il place des zones de garde AUTOUR de chaque objet et vérifie chaque accès, ce que le matériel ne sait pas faire.
gcc -fsanitize=address -g cas 3, pointeur pendant : ==8931==ERROR: AddressSanitizer: stack-use-after-return WRITE of size 4 at 0x7f… #0 in main plantage.c:20 Address is located in stack of thread T0 at offset 32 'local' (line 6) <== in frame of function 'mauvais' /* le cas qui ne plantait JAMAIS est détecté, avec la ligne de la variable morte ET la ligne qui y accède */
C'est le renversement à retenir de ce TP : l'outil trouve précisément les bogues qui ne se manifestent pas. Les trois à connaître — address pour la mémoire, undefined pour les débordements et décalages illicites, valgrind pour les fuites. Aucun ne remplace la relecture, tous les trois trouvent en une seconde ce qu'une relecture rate pendant une semaine.
int *pi; pi + 1 → adresse + 4 (sizeof(int)) char *pc; pc + 1 → adresse + 1 struct Ligne *pl; pl + 1 → adresse + 12 (sizeof(struct Ligne)) p + n vaut toujours adresse + n * sizeof(*p) d'où : t[i] ≡ *(t + i) ≡ *(i + t) ≡ i[t]
L'arithmétique est en ÉLÉMENTS, pas en octets, et c'est précisément ce qui fait que t[i] et *(t+i) sont la même chose — l'indexation n'est qu'une notation. Corollaire pratique : additionner un pointeur et une taille en octets est une erreur classique, qui saute d'autant plus loin que le type est grand.
1. tout pointeur est initialisé, à une adresse valide ou à NULL 2. après free, on remet à NULL — un double free devient inoffensif 3. on ne renvoie jamais l'adresse d'une locale 4. les littéraux se déclarent const char * — l'écriture ne compile plus 5. tout pointeur reçu d'ailleurs est vérifié non nul avant usage 6. -Wall -Wextra en développement, -fsanitize en test
Aucune de ces règles n'est astucieuse : ce sont des habitudes. Le C ne protège de rien, donc la protection est dans la discipline et dans les outils. La règle 4 mérite d'être soulignée — écrire const char * transforme une erreur d'exécution en erreur de compilation, ce qui est toujours le meilleur échange possible.
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
Flashcards · 5 cartes
- Que font & et *, et pourquoi l'étoile est-elle source de confusion ?
- &x rend l'ADRESSE de x ; *p désigne la CASE dont p contient l'adresse (déréférencement) — les deux sont réciproques. L'étoile a deux sens selon l'endroit : dans une DÉCLARATION, int *p signifie « p est un pointeur vers un int » ; dans une EXPRESSION, *p signifie « la case pointée ». D'où l'écriture int *p plutôt que int* p — l'étoile appartient à la variable, et int* a, b déclare un pointeur ET un entier.
- À quoi sert le type d'un pointeur, puisque tous font 8 octets ?
- À deux choses. Il dit COMBIEN D'OCTETS LIRE au déréférencement : *p sur un int * lit 4 octets en complément à deux, sur un char * un seul. Et il FIXE LE PAS de l'arithmétique : p + 1 avance de sizeof(*p) octets, donc 4 pour un int et 1 pour un char. L'unité de l'arithmétique des pointeurs est l'ÉLÉMENT, jamais l'octet.
- Quelle relation lie tableau et pointeur, et quelles différences subsistent ?
- Le nom d'un tableau employé dans une expression se convertit en POINTEUR SUR SON PREMIER ÉLÉMENT : T équivaut à &T[0], et T[i] est par définition *(T + i). Trois différences : sizeof rend la taille totale sur un tableau et 8 sur un pointeur ; on peut écrire p = T mais jamais T = p, le nom d'un tableau n'étant pas modifiable ; et &T a un type différent de &T[0], donc &T + 1 avance de tout le tableau.
- Pourquoi le passage par adresse ne contredit-il pas le passage par valeur ?
- Parce que c'est un passage par valeur D'UNE ADRESSE : le pointeur est copié comme tout argument, et lui réaffecter une valeur dans la fonction n'a aucun effet chez l'appelant. Mais *a = 7 ne touche pas a : cela écrit dans la case que a désigne. Trois usages : modifier une variable de l'appelant (le & de scanf), rendre plusieurs résultats, et éviter la copie d'une grosse structure — qu'on déclare alors const.
- Distinguez pointeur nul, sauvage et pendant.
- NULL ne désigne aucun objet valide, et son intérêt est d'être TESTABLE : le déréférencer provoque une erreur de segmentation immédiate, ce qui est la MEILLEURE des trois issues. Le pointeur SAUVAGE n'est pas initialisé : il contient ce qui traînait, et écrire à travers lui peut atteindre une adresse valide — corruption silencieuse, donc pire que NULL. Le pointeur PENDANT désigne une case qui n'existe plus : adresse d'une locale rendue, ou pointeur conservé après free. Règle : initialiser tout pointeur, à NULL faute de mieux.