C4 — Pointeurs et mémoireDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Programmation en C

Cours 4Pointeurs et mémoire

Le cœur du cours : manipuler des adresses, décider de la durée de vie des données, et rendre ce qu'on a emprunté.

2 chapitres · 13 h de travail estimé

  1. 1. Pointeurs7 h
  2. 2. Allocation dynamique6 h

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

  1. Une case nommée a, à une adresseLa 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.
  2. p est déclaré, et ne pointe sur rienp 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.
  3. p = &a — la flèche est poséeL'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.
  4. *p = 20 — a change sans être nomméeC'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.
  5. int b = *p — lecture à travers le pointeurMê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.
  6. p = NULL — la flèche est retiréeNULL 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.
  7. *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 :

T[i]    (T+i)T[i] \;\equiv\; *(T + i)

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 tableauen 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é constselon 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 adressedeux 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ètrevaleur d'une adresse
  • Parce que les pointeurs sont exemptés de copie pour des raisons de performanceexemption

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é

  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 pendantReprenez 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 interditeTentez 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 franchissementParcourez 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.
  6. Les lire, pas les subirPour 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 pointeursAffichez 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 rougeRemplacez 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

Les cinq plantages
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.

Lire un plantage sous gdb
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.

La page, et le seuil du plantage
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.

Le même bogue, vu par l'outil
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.

L'arithmétique de pointeurs
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.

Les règles qui suppriment les cinq cas
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.

Chapitre 2 · 6 h

Allocation dynamique

Pile et tas, malloc, calloc, realloc et free, durée de vie des données, fuites et pointeurs pendants, tableaux dynamiques et liste chaînée.

Un programme lit un nombre au clavier, puis doit stocker autant de valeurs. Avec les outils du bloc III, c'est impossible : la taille d'un tableau est fixée à la compilation, et l'on ne peut que surdimensionner au hasard — int T[1000], en espérant que mille suffira et en gaspillant si l'utilisateur en saisit trois.

La tentation suivante est pire :

int *creer(int n) {    int T[n];    return T;                    /* l'adresse d'une variable LOCALE */}

Le tableau vit dans le cadre d'appel, qui est détruit au retour. La fonction rend l'adresse d'une case qui n'existe plus — un pointeur pendant au sens du chapitre 7. Le programme compile, souvent s'exécute, et corrompt sa mémoire.

Ce chapitre donne la réponse correcte : allouer dans une zone dont on décide soi-même de la durée de vie.

Deux zones, deux régimes

Le chapitre 3 du cours de systèmes a décrit l'image mémoire d'un processus. Deux de ses régions nous intéressent.

  ┌────────────────────┐  │  pile (stack)      │  variables locales, paramètres, adresses de retour  │        ↓           │  gérée AUTOMATIQUEMENT : allouée à l'entrée d'un  │                    │  bloc, libérée à sa sortie  │        ↑           │  │  tas (heap)        │  allocation dynamique  └────────────────────┘  gérée À LA MAIN : vous allouez, vous libérez
PileTas
Allocationautomatiquemalloc
Libérationautomatique, à la sortie du blocfree, par vous
Durée de viecelle du blocjusqu'au free
Tailleconnue à la compilationdécidée à l'exécution
Capacitéquelques mégaoctetsla mémoire disponible
Vitessetrès rapide (déplacer un pointeur)plus lente (chercher un bloc libre)

La ligne décisive est la troisième. Sur le tas, la donnée survit à la fonction qui l'a créée — c'est précisément ce qu'il fallait, et c'est ce que la pile ne peut pas offrir.

Le prix est dans la deuxième ligne, et c'est tout le chapitre : ce que vous allouez, vous devez le rendre.

Les quatre fonctions

#include <stdlib.h> int *T = malloc(n * sizeof(int));      /* n int, contenu INDÉFINI */int *U = calloc(n, sizeof(int));       /* n int, tous mis à ZÉRO */T = realloc(T, m * sizeof(int));       /* redimensionne à m int */free(T);                                /* rend la mémoire */

Quatre remarques, une par ligne.

malloc ne connaît pas les types : elle prend un nombre d'octets et rend un pointeur générique. D'où l'idiome n * sizeof(int), et la variante préférable n * sizeof(*T) — qui reste correcte si le type de T change un jour.

Son contenu est indéfini, comme une variable locale. calloc met à zéro, ce qui coûte un peu et évite une classe d'erreurs ; on la préfère dès que le zéro a un sens.

realloc peut déplacer le bloc. S'il n'y a pas la place de l'agrandir sur place, elle en alloue un autre ailleurs, recopie, et libère l'ancien : tous les pointeurs vers l'ancien emplacement deviennent pendants. Elle a aussi un piège d'écriture — T = realloc(T, ...) perd le pointeur original si l'allocation échoue et que NULL est rendu. On écrit donc dans une variable temporaire, qu'on n'affecte à T qu'après vérification.

malloc peut échouer et rendre NULL. Le tester n'est pas une politesse : déréférencer le résultat sans vérifier transforme une pénurie de mémoire — situation gérable — en erreur de segmentation.

int *T = malloc(n * sizeof(*T));if (T == NULL) { fprintf(stderr, "mémoire insuffisante\n"); return 1; }

Qui libère ?

C'est la vraie difficulté du chapitre, et elle n'est pas technique.

Le C n'a pas de ramasse-miettes : la propriété d'un bloc est une convention, portée par la documentation et rien d'autre. Une fonction qui rend un pointeur alloué doit dire, en toutes lettres, que l'appelant devra le libérer. Une fonction qui reçoit un pointeur doit dire si elle le libère ou non.

/* Rend une chaîne allouée : à libérer par l'appelant. */char *dupliquer(const char *source);

Trois règles de discipline évitent l'essentiel des dégâts.

Un malloc, un free, et si possible dans la même fonction ou dans la fonction jumelle — creerPile et detruirePile. Une allocation dont la libération est ailleurs, dans un autre fichier, écrit par quelqu'un d'autre, est une fuite en puissance.

Libérer dans l'ordre inverse de la construction, surtout pour les structures imbriquées : libérer une liste chaînée demande de garder le pointeur suivant avant de libérer la cellule, faute de quoi on le lit dans un bloc déjà rendu.

Mettre à NULL après free. free(p) ne modifie pas p : il rend la mémoire, et p continue de pointer dessus — pointeur pendant. Poser p = NULL transforme une utilisation ultérieure en erreur de segmentation immédiate, donc diagnosticable, et rend le double free inoffensif puisque free(NULL) ne fait rien.

Les quatre fautes

Elles portent des noms, et le chapitre 10 donnera l'outil qui les détecte.

La fuite (memory leak) : un bloc alloué que plus aucun pointeur ne désigne. Il reste occupé jusqu'à la fin du programme. Sans conséquence sur un utilitaire qui s'arrête, fatal sur un service qui tourne des mois.

Le pointeur pendant : utiliser un bloc après free. Le comportement dépend de ce que l'allocateur a fait de la place entre-temps — d'où des bogues qui apparaissent et disparaissent selon la charge.

Le double free : libérer deux fois le même bloc corrompt les structures internes de l'allocateur, et le plantage survient bien plus tard, ailleurs.

Le débordement de tas : écrire au-delà du bloc alloué. Même mécanisme qu'au chapitre 5, mais sur le tas, où l'on écrase les métadonnées de l'allocateur.

Le point commun des quatre : la faute et le symptôme sont éloignés. C'est précisément pourquoi valgrind existe, et pourquoi le chapitre 10 lui est en partie consacré.

Quiz · 1 question

Pourquoi écrit-on p = NULL juste après free(p) ?

  • Pour indiquer à l'allocateur que le bloc est libre, sans quoi la mémoire n'est pas réellement renduepour l'allocateur
  • Parce que free ne modifie pas p : il rend la mémoire mais p continue de pointer dessus. Le mettre à NULL transforme une utilisation ultérieure en erreur immédiate, donc diagnosticable, et rend un second free inoffensifcontre le pointeur pendant
  • Pour éviter une fuite mémoire : sans cette ligne, le bloc reste allouécontre la fuite

Réponse : free rend la mémoire à l'allocateur, mais la VARIABLE p n'est pas touchée — elle contient toujours l'ancienne adresse. Deux fautes deviennent alors possibles : utiliser p, ce qui lit ou écrit dans un bloc qui peut avoir été réattribué à autre chose ; et refaire free(p), ce qui corrompt les structures internes de l'allocateur. Poser p = NULL neutralise les deux : déréférencer NULL plante IMMÉDIATEMENT, à l'endroit exact de la faute, ce qui est de loin le meilleur comportement possible ; et free(NULL) est explicitement défini par la norme comme ne faisant rien. Cela n'a en revanche aucun effet sur la fuite, qui vient d'un free OUBLIÉ, ni sur l'allocateur, qui a déjà tout ce qu'il lui faut.

Deux structures qui deviennent possibles

Le tableau dynamique. On alloue une capacité initiale, et quand elle est atteinte on double avec realloc. Doubler plutôt qu'ajouter une case est ce qui rend l'insertion amortie en O(1)O(1) : nn insertions coûtent au total O(n)O(n), puisque les recopies successives forment une série géométrique. C'est ainsi que sont faits les tableaux extensibles de tous les langages, et c'est le « O(1)O(1) amorti » du chapitre 5 d'Algorithmique 2.

La liste chaînée. La cellule du chapitre 5 d'Algorithmique 2 devient enfin implémentable :

typedef struct Cellule {    int valeur;    struct Cellule *suivant;     /* le chaînage : un pointeur */} Cellule; Cellule *n = malloc(sizeof(*n));n->valeur = 12;n->suivant = tete;               /* d'abord raccrocher la suite */tete = n;                        /* puis déplacer la tête */

Les deux dernières lignes sont exactement celles du cours d'algorithmique, dans le même ordre — et l'on voit maintenant pourquoi il compte : tete = n d'abord rendrait l'ancienne liste inatteignable, donc définitivement fuite, puisque plus aucun pointeur ne la désignerait.

La flèche -> est une commodité : n->valeur s'écrirait sinon (*n).valeur, avec des parenthèses obligatoires car . est plus prioritaire que *.

Quiz · 1 question

Que reproche-t-on à l'écriture T = realloc(T, nouvelleTaille) ?

  • Rien : c'est l'idiome recommandé, realloc gérant elle-même l'ancien blocrien à redire
  • Si realloc échoue, elle rend NULL sans libérer l'ancien bloc — mais l'affectation vient d'écraser T : le seul pointeur vers l'ancien bloc est perdu, donc fuite garantie ET données perduesperte du pointeur en cas d'échec
  • realloc ne peut pas agrandir un bloc, seulement le rétrécirlimite de realloc

Réponse : En cas de succès, l'écriture est correcte. En cas d'ÉCHEC, realloc rend NULL et laisse l'ancien bloc intact et alloué — comportement délibéré, pour que l'appelant puisse se rabattre dessus. Mais l'affectation T = realloc(...) a déjà écrasé T avec NULL : plus personne ne connaît l'adresse de l'ancien bloc. On a donc perdu les données ET fui la mémoire, en une ligne. L'écriture correcte passe par une temporaire : void *tmp = realloc(T, n); if (tmp != NULL) T = tmp; else /* traiter l'échec, T reste valide */. À retenir aussi : en cas de succès, realloc peut avoir DÉPLACÉ le bloc, ce qui rend pendants tous les autres pointeurs qui visaient l'ancien emplacement.

À vous

L'exercice écrit un allocateur miniature — une zone découpée en blocs, avec malloc et free — puis l'instrumente pour détecter les quatre fautes. C'est un valgrind en trente lignes, et c'est ce qui rend les fautes visibles avant le chapitre 10.

Quatre temps. Allouer, écrire, libérer, et constater qu'un bilan de fin de programme signale les blocs jamais rendus. Provoquer un pointeur pendant, puis un double free, et voir l'allocateur les diagnostiquer. Écrire le tableau dynamique qui double sa capacité, et compter les recopies pour vérifier qu'elles sont bien en O(n)O(n) au total. Enfin, construire et libérer une liste chaînée — en gardant le pointeur suivant avant de libérer la cellule, sinon l'allocateur vous le dira.

Exercice de code

Écrivez un allocateur instrumenté, détectez les quatre fautes, puis mesurez le doublement de capacité.

Point de départ

// ── Un allocateur instrumenté ─────────────────────────────────────────────
function creerAllocateur() {
  const blocs = new Map();     // adresse -> { taille, vivant, contenu }
  const fautes = [];
  let prochaine = 0x2000;

  return {
    malloc(octets) {
      const adresse = prochaine;
      prochaine += octets + 16;                  // + marge, comme un vrai tas
      blocs.set(adresse, { taille: octets, vivant: true, contenu: new Array(octets).fill(null) });
      return adresse;
    },
    calloc(n, taille) {
      const a = this.malloc(n * taille);
      blocs.get(a).contenu.fill(0);
      return a;
    },
    ecrire(adresse, decalage, valeur) {
      const b = blocs.get(adresse);
      if (!b) { fautes.push("écriture à une adresse jamais allouée"); return; }
      if (!b.vivant) { fautes.push("ÉCRITURE APRÈS LIBÉRATION à 0x" + adresse.toString(16)); return; }
      // ← à écrire : refuser un décalage hors du bloc (débordement de tas)
      b.contenu[decalage] = valeur;
    },
    lire(adresse, decalage) {
      const b = blocs.get(adresse);
      if (!b) { fautes.push("lecture à une adresse jamais allouée"); return undefined; }
      if (!b.vivant) { fautes.push("LECTURE APRÈS LIBÉRATION à 0x" + adresse.toString(16)); return undefined; }
      return b.contenu[decalage];
    },
    free(adresse) {
      const b = blocs.get(adresse);
      if (!b) { fautes.push("free d'une adresse jamais allouée"); return; }
      // ← à écrire : détecter le double free
      b.vivant = false;
    },
    bilan() {
      let fuite = 0, n = 0;
      for (const [a, b] of blocs) if (b.vivant) { fuite += b.taille; n++; }
      console.log("   " + n + " bloc(s) jamais libéré(s), " + fuite + " octets perdus");
      for (const f of fautes) console.log("   FAUTE : " + f);
      if (n === 0 && fautes.length === 0) console.log("   aucune fuite, aucune faute");
    },
  };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Complétez la détection du débordement de bloc et du double free.
// 2. Écrivez un tableau dynamique qui DOUBLE sa capacité, et comptez les
//    recopies pour n insertions. Comparez à la stratégie « + 1 case ».
// 3. Construisez puis libérez une liste chaînée. Attention à l'ordre :
//    lire le champ « suivant » AVANT de libérer la cellule.

const A = creerAllocateur();
const t = A.malloc(4 * 5);
A.ecrire(t, 0, 42);
console.log("relu :", A.lire(t, 0));
A.free(t);
A.bilan();

Solution

function creerAllocateur() {
  const blocs = new Map();
  const fautes = [];
  let prochaine = 0x2000;
  let recopies = 0;

  return {
    malloc(octets) {
      const adresse = prochaine;
      prochaine += octets + 16;
      blocs.set(adresse, { taille: octets, vivant: true, contenu: new Array(octets).fill(null) });
      return adresse;
    },
    calloc(n, taille) { const a = this.malloc(n * taille); blocs.get(a).contenu.fill(0); return a; },
    realloc(adresse, octets) {
      const b = blocs.get(adresse);
      const neuf = this.malloc(octets);
      // realloc RECOPIE puis libère : c'est ce coût qu'on va compter.
      for (let i = 0; i < Math.min(b.taille, octets); i++) {
        blocs.get(neuf).contenu[i] = b.contenu[i];
        recopies++;
      }
      b.vivant = false;
      return neuf;
    },
    ecrire(adresse, decalage, valeur) {
      const b = blocs.get(adresse);
      if (!b) { fautes.push("écriture à une adresse jamais allouée"); return; }
      if (!b.vivant) { fautes.push("ÉCRITURE APRÈS LIBÉRATION à 0x" + adresse.toString(16)); return; }
      // Débordement de tas : au-delà du bloc, on écrase les métadonnées de
      // l'allocateur, et le plantage survient bien plus tard, ailleurs.
      if (decalage < 0 || decalage >= b.taille) {
        fautes.push("DÉBORDEMENT DE BLOC : décalage " + decalage + " dans un bloc de " + b.taille);
        return;
      }
      b.contenu[decalage] = valeur;
    },
    lire(adresse, decalage) {
      const b = blocs.get(adresse);
      if (!b) { fautes.push("lecture à une adresse jamais allouée"); return undefined; }
      if (!b.vivant) { fautes.push("LECTURE APRÈS LIBÉRATION à 0x" + adresse.toString(16)); return undefined; }
      return b.contenu[decalage];
    },
    free(adresse) {
      if (adresse === 0) return;                 // free(NULL) ne fait rien
      const b = blocs.get(adresse);
      if (!b) { fautes.push("free d'une adresse jamais allouée"); return; }
      if (!b.vivant) { fautes.push("DOUBLE FREE à 0x" + adresse.toString(16)); return; }
      b.vivant = false;
    },
    recopies: () => recopies,
    bilan(titre) {
      let fuite = 0, n = 0;
      for (const [, b] of blocs) if (b.vivant) { fuite += b.taille; n++; }
      console.log("   " + titre);
      console.log("      " + n + " bloc(s) jamais libéré(s), " + fuite + " octets perdus");
      for (const f of fautes) console.log("      FAUTE : " + f);
      if (n === 0 && fautes.length === 0) console.log("      aucune fuite, aucune faute");
    },
  };
}

console.log("— 1. les quatre fautes —");
const A = creerAllocateur();
const t = A.malloc(4 * 5);
A.ecrire(t, 0, 42);
A.ecrire(t, 19, 7);        // décalage hors du bloc de 20 octets ? non : 19 < 20, ok
A.ecrire(t, 25, 7);        // débordement
A.free(t);
A.lire(t, 0);              // lecture après libération
A.free(t);                 // double free
const fuite = A.malloc(64); // jamais libéré
A.bilan("bilan");

console.log("");
console.log("— 2. tableau dynamique : doubler contre ajouter une case —");
for (const [nom, croissance] of [["doubler", (c) => c * 2], ["+ 1 case", (c) => c + 1]]) {
  const B = creerAllocateur();
  let capacite = 1, taille = 0;
  let bloc = B.malloc(capacite * 4);
  for (let i = 0; i < 1000; i++) {
    if (taille === capacite) { capacite = croissance(capacite); bloc = B.realloc(bloc, capacite * 4); }
    B.ecrire(bloc, taille, i);
    taille++;
  }
  console.log("   " + nom.padEnd(10) + " : " + String(B.recopies()).padStart(6) +
              " recopies pour 1000 insertions" +
              (nom === "doubler" ? "   (O(n) au total, donc O(1) amorti)" : "   (O(n²))"));
}

console.log("");
console.log("— 3. construire et libérer une liste chaînée —");
const C = creerAllocateur();
const VALEUR = 0, SUIVANT = 1;
let tete = 0;
for (const v of [3, 2, 1]) {
  const n = C.malloc(2 * 8);
  C.ecrire(n, VALEUR, v);
  C.ecrire(n, SUIVANT, tete);   // d'abord raccrocher la suite
  tete = n;                      // puis déplacer la tête
}
const vus = [];
for (let c = tete; c !== 0; c = C.lire(c, SUIVANT)) vus.push(C.lire(c, VALEUR));
console.log("   liste : " + vus.join(" -> "));

// L'ordre est imposé : lire le champ suivant AVANT de libérer la cellule,
// sinon on lit dans un bloc déjà rendu.
let c = tete;
while (c !== 0) {
  const suivant = C.lire(c, SUIVANT);
  C.free(c);
  c = suivant;
}
C.bilan("après libération");

En travaux pratiques

Travaux pratiques 8 · 4 h

Gérer la mémoire soi-même

Allouer ce dont on ne connaît pas la taille à l'avance, mesurer une fuite, et rencontrer les trois fautes d'allocation que valgrind détecte.

Avant de commencer

  • Le TP 7 : pointeurs et outils
  • valgrind installé

Énoncé

  1. Le tableau de taille inconnueÉcrivez une fonction qui lit tous les entiers d'un fichier dans un tableau alloué dynamiquement, en doublant la capacité quand elle est pleine. Testez sur 0, 1 et un million de valeurs.
  2. L'allocation qui échoueDemandez délibérément une allocation énorme et vérifiez le retour. Puis retirez le test et observez ce que fait le programme.
  3. Mesurer une fuiteÉcrivez une boucle qui alloue sans libérer et surveillez la mémoire du processus. Passez ensuite valgrind et lisez le résumé.
  4. Les trois fautesProvoquez successivement une double libération, une utilisation après libération, et une libération d'un pointeur non alloué. Notez ce que dit valgrind pour chacune.
  5. realloc, et son piègeUtilisez realloc en réaffectant le résultat au pointeur d'origine, puis provoquez un échec d'allocation. Expliquez la fuite. Écrivez ensuite la forme correcte. Indice : Si realloc échoue, il renvoie NULL — et l'ancien bloc, lui, existe toujours.
  6. Qui libèreÉcrivez une fonction qui renvoie une chaîne allouée. Documentez le contrat, puis écrivez la fonction de libération qui va avec, et utilisez-la partout.
  7. Au fil rougeFaites lire à journal un fichier de taille quelconque, en allouant ce qu'il faut. Le programme doit se terminer avec zéro octet perdu selon valgrind.
  8. Mesurer le coûtComparez un million de petites allocations à une seule grande découpée à la main. Chronométrez les deux.

C'est réussi quand

  • Votre lecteur gère un million de valeurs sans connaître la taille à l'avance
  • valgrind annonce « All heap blocks were freed » sur votre fil rouge
  • Vous savez écrire la forme correcte de realloc sans la relire

Correction

Le tableau qui granditlecture.c
int *valeurs = NULL;
size_t n = 0, capacite = 0;

while (fscanf(f, "%d", &v) == 1) {
  if (n == capacite) {
      size_t nouvelle = capacite ? capacite * 2 : 16;
      int *tmp = realloc(valeurs, nouvelle * sizeof *valeurs);
      if (!tmp) { free(valeurs); return -1; }    /* forme CORRECTE */
      valeurs = tmp;
      capacite = nouvelle;
  }
  valeurs[n++] = v;
}

Le doublement donne un coût amorti constant par insertion : n insertions coûtent au total O(n) copies, pas O(n²). C'est exactement le mécanisme des tableaux dynamiques de tous les langages, et vous le retrouverez démontré en Algorithmique 2. Notez sizeof *valeurs plutôt que sizeof(int) : si le type change, la ligne reste juste.

Le retour qu'on oublie de tester
int *p = malloc(100000000000UL);
if (!p) { fprintf(stderr, "mémoire insuffisante\n"); return 1; }

sans le test :
p vaut NULL, puis *p = 0 → Segmentation fault
mais AILLEURS que là où l'allocation a échoué

Tester le retour ne sauve pas le programme, il rend l'échec LISIBLE : un message clair au bon endroit plutôt qu'un plantage cinquante lignes plus loin. Sur un système Linux avec surréservation (TP 6 de Systèmes), malloc réussit d'ailleurs souvent quand même — l'échec surviendra au premier accès, et pas sous une forme que votre test puisse voir.

Le rapport de valgrind
valgrind --leak-check=full ./journal acces.log

==4412== HEAP SUMMARY:
==4412==   in use at exit: 40,960 bytes in 3 blocks
==4412==   total heap usage: 1,204 allocs, 1,201 frees
==4412== 
==4412== 40,960 bytes in 3 blocks are definitely lost
==4412==    at malloc (vg_replace_malloc.c:381)
==4412==    by lire_lignes (lecture.c:24)
==4412==    by main (main.c:12)

/* objectif : */
==4412== All heap blocks were freed -- no leaks are possible

valgrind donne la ligne de l'ALLOCATION perdue, pas celle où le manque se voit — c'est ce qui le rend utilisable. « definitely lost » signifie qu'aucun pointeur ne mène plus au bloc ; « still reachable » signifie qu'il n'a pas été libéré mais reste atteignable, ce qui est bénin en fin de programme et grave dans une boucle.

Les trois fautes
free(p); free(p);        → Invalid free() / delete / delete[]
                          (le tas est corrompu : peut être EXPLOITABLE)

free(p); *p = 1;         → Invalid write of size 4
                          Address is 0 bytes inside a block of size 40 free'd

int t[10]; free(t);      → Invalid free(): pas une adresse du tas

/* la règle qui neutralise les deux premières */
free(p); p = NULL;       /* free(NULL) est autorisé et ne fait rien */

Remettre à NULL après libération transforme une double libération en opération inoffensive et une utilisation après libération en plantage net à l'endroit exact. Deux caractères qui convertissent des bogues exploitables en erreurs franches : le meilleur rapport de tout le cours.

Le piège de realloc
/* FAUX : si realloc échoue, l'ancien bloc est PERDU */
p = realloc(p, n);
if (!p) return -1;          /* fuite : l'ancien p n'existe plus nulle part */

/* CORRECT */
void *tmp = realloc(p, n);
if (!tmp) { free(p); return -1; }
p = tmp;

realloc peut renvoyer une adresse DIFFÉRENTE — le bloc a peut-être été déplacé —, et tous les pointeurs qui visaient l'intérieur de l'ancien bloc deviennent pendants. Deux conséquences : passer par une variable temporaire, et ne jamais garder de pointeur vers l'intérieur d'un tableau susceptible d'être réalloué. C'est le même piège que l'invalidation des itérateurs dans les langages à collections.

Le coût de l'allocation
1 000 000 malloc(16) + free     : 0,094 s
1 malloc(16 Mo) découpé à la main : 0,004 s

malloc doit chercher un bloc libre, découper, mettre à jour
les métadonnées, et peut demander de la mémoire au noyau

Un facteur 20, sans changer un seul calcul. C'est pourquoi les programmes qui allouent beaucoup de petits objets de même taille utilisent une réserve — un grand bloc découpé à l'avance. Le principe est le même qu'au TP 8 d'Architecture et qu'au tampon de printf : regrouper les opérations coûteuses au lieu de les répéter.

Ce que la suite en fait

Le bloc V donne aux données une forme et une persistance : la structure struct regroupe des champs de types différents — et l'on vient déjà d'en écrire une, la cellule de liste — puis les fichiers les font survivre à la fin du programme.

Le chapitre 10 fournit enfin l'outillage. valgrind détecte exactement les quatre fautes de ce chapitre, sur du vrai code, sans instrumentation à écrire soi-même — et il donne la ligne de l'allocation fautive, ce qui est la seule information réellement utile quand la faute et le symptôme sont à mille lignes l'un de l'autre.

À retenir

Flashcards · 5 cartes

Qu'est-ce qui distingue la pile du tas, et pourquoi le tas est-il nécessaire ?
La PILE est gérée automatiquement : allouée à l'entrée d'un bloc, libérée à sa sortie, très rapide, mais de taille connue à la compilation et de durée de vie limitée au bloc. Le TAS est géré à la main : vous allouez, vous libérez, avec une taille décidée à l'exécution et une donnée qui SURVIT à la fonction qui l'a créée. C'est ce dernier point qui le rend nécessaire — une fonction ne peut pas rendre l'adresse d'une de ses locales, dont le cadre est détruit au retour.
Quelles précautions entourent malloc et realloc ?
malloc prend un nombre d'OCTETS et rend un contenu INDÉFINI — on écrit n * sizeof(*T), qui reste correct si le type change, et calloc met à zéro. Elle peut ÉCHOUER et rendre NULL : le tester transforme une pénurie gérable en erreur de segmentation si on l'omet. realloc peut DÉPLACER le bloc (tous les autres pointeurs deviennent pendants) et, en cas d'échec, rend NULL sans libérer l'ancien : écrire T = realloc(T, n) perd alors le seul pointeur vers les données. On passe par une temporaire.
Quelles sont les trois règles de discipline sur la propriété d'un bloc ?
Le C n'a pas de ramasse-miettes : la propriété est une CONVENTION portée par la documentation. 1) Un malloc, un free, si possible dans la même fonction ou dans la fonction jumelle (creerPile / detruirePile). 2) Libérer dans l'ordre inverse de la construction, en gardant le pointeur suivant AVANT de libérer une cellule. 3) Poser p = NULL après free.
Nommez les quatre fautes d'allocation et leur point commun.
La FUITE : un bloc que plus aucun pointeur ne désigne — anodin sur un utilitaire, fatal sur un service qui tourne des mois. Le POINTEUR PENDANT : utiliser un bloc après free. Le DOUBLE FREE : corrompt les structures de l'allocateur. Le DÉBORDEMENT DE TAS : écrire au-delà du bloc, ce qui écrase ses métadonnées. Point commun : la faute et le symptôme sont ÉLOIGNÉS — d'où valgrind.
Pourquoi un tableau dynamique double-t-il sa capacité au lieu d'ajouter une case ?
Parce que doubler rend l'insertion amortie en O(1) : les recopies successives forment une série géométrique dont la somme est O(n) pour n insertions. Ajouter une case à chaque fois donnerait une recopie par insertion, soit O(n²) au total. C'est ainsi que sont faits les tableaux extensibles de tous les langages, et c'est le « O(1) amorti » d'Algorithmique 2.