cursus.

Cours 4 · Pointeurs et mémoireLeçon 1 sur 2

Pointeurs

7 h de lecture10 sections Version PDF

À la fin de cette leçon, vous saurez

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 · étape 1 / 70:00 / 0:11

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.

Prêt à lancer · 0:00 / 0:11
Étapes

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 · vérifiez votre compréhension Sans réponse

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.

Quiz · vérifiez votre compréhension Sans réponse

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.

Exercice · JavaScript · à vous de jouer

Manipulez une mémoire adressable : déréférencement, échange par adresse, arithmétique, et les trois fautes.

En attente
// ── 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 });

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 7 · sur machine

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.

4 h
Avant de commencer
  • Les TP 4 à 6
  • gdb, et les détecteurs -fsanitize=address et undefined
  1. 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. 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. 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. 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. 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. 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. 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. 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.

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

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 · 1 / 5Toucher pour retourner
Fin de la leçon

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.