C3 — Tableaux et chaînesDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Programmation en C

Cours 3Tableaux et chaînes

Manipuler des données groupées dans un langage qui ne vérifie jamais les bornes, et savoir ce que cela implique.

2 chapitres · 8 h de travail estimé

  1. 1. Tableaux4 h
  2. 2. Chaînes de caractères4 h

Chapitre 1 · 4 h

Tableaux

Déclaration, indices et parcours, tableaux à deux dimensions, tableau passé en paramètre, et l'absence totale de vérification des bornes en C.

int T[5];T[10] = 42;

Ce programme compile sans avertissement et s'exécute sans erreur. Il écrit 42 vingt octets plus loin que la fin du tableau, sur ce qui s'y trouvait — une autre variable, l'adresse de retour de la fonction, n'importe quoi. Le programme continue, avec un état corrompu, et plantera peut-être mille lignes plus loin.

Le C ne vérifie jamais les bornes d'un tableau. Ce n'est pas un oubli de la norme, c'est une décision : vérifier coûterait une comparaison à chaque accès, et le C a été conçu pour écrire des systèmes d'exploitation. Le prix est une classe entière de vulnérabilités, et une bonne partie du chapitre 10.

Déclarer et parcourir

int notes[5] = {12, 15, 8, 17, 10};int vide[100] = {0};             /* toutes les cases à zéro */int deduit[] = {1, 2, 3};        /* taille déduite : 3 */

Trois faits à poser fermement.

Les indices vont de 0 à n−1. notes[0] est le premier, notes[4] le dernier, notes[5] n'existe pas. L'idiome for (int i = 0; i < n; i++) du chapitre 3 donne exactement les indices valides, et c'est la raison de l'employer systématiquement.

Les éléments sont contigus. Un int T[5] occupe vingt octets consécutifs, et l'adresse de T[i] vaut celle de T[0] plus i×4i \times 4. C'est l'accès en O(1)O(1) d'Algorithmique 1, et c'est aussi la localité spatiale du chapitre 7 d'architecture — parcourir un tableau dans l'ordre est ce qu'une machine fait le plus vite.

La taille est fixée à la compilation et ne peut pas changer. Un tableau dont la taille dépend de l'exécution demande l'allocation dynamique du chapitre 8.

Enfin, {0} initialise toutes les cases à zéro, alors qu'un tableau non initialisé contient n'importe quoi — même règle qu'au chapitre 2 pour les variables.

L'absence de vérification

Voici ce qui se passe réellement lors d'un débordement, et pourquoi c'est pire qu'une erreur.

L'expression T[i] est traduite en une adresse : début du tableau plus ii fois la taille d'un élément. Le compilateur émet ce calcul sans se demander si ii est valide — il ne le sait pas toujours, et le vérifier coûterait un test à chaque accès.

pile (adresses croissantes)┌──────────┬──────────┬──────────┬──────────┬──────────┬───────────┐│  T[0]    │  T[1]    │  T[2]    │  T[3]    │  T[4]    │  secret   │└──────────┴──────────┴──────────┴──────────┴──────────┴───────────┘                                              T[5] écrit ICI

Trois issues possibles, et la troisième est la plus dangereuse. L'adresse touchée peut être dans une page interdite : erreur de segmentation, le programme meurt, et c'est le meilleur cas — l'erreur est immédiate. Elle peut appartenir à une autre variable : corruption silencieuse, le programme continue avec des données fausses. Elle peut enfin être l'adresse de retour de la fonction, et un attaquant qui contrôle ce qui est écrit contrôle alors où le programme va sauter : c'est le débordement de tampon, sujet du chapitre 8 de cybersécurité.

La règle pratique est donc : la taille voyage avec le tableau. Toute fonction qui reçoit un tableau reçoit aussi son nombre d'éléments, et vérifie ses indices elle-même. Le langage ne le fera pas.

Tableaux à deux dimensions

int grille[3][4];                /* 3 lignes de 4 colonnes */grille[1][2] = 7;

La mémoire reste linéaire : le tableau est rangé ligne par ligne, et l'adresse de grille[i][j] se calcule comme

deˊbut+(i×4+j)×sizeof(int)\text{début} + (i \times 4 + j) \times \text{sizeof(int)}

Ce détail a une conséquence mesurable, celle de l'exercice du chapitre 7 d'architecture : parcourir par lignes suit l'ordre mémoire et exploite le cache ; parcourir par colonnes saute d'une ligne à l'autre à chaque accès et peut être plusieurs fois plus lent, pour exactement le même nombre d'opérations.

Un tableau en paramètre n'est pas copié

C'est l'exception à la règle du chapitre 4, et elle est majeure.

void modifier(int T[], int n) {    T[0] = 99;                   /* modifie le tableau de l'APPELANT */}

Un paramètre déclaré int T[] est en réalité un int * : ce qui est copié n'est pas le tableau mais l'adresse de son premier élément. La fonction travaille donc sur l'original.

Ce n'est pas une entorse au passage par valeur — l'adresse, elle, est bien copiée — mais une conséquence de la conversion tableau-pointeur que le chapitre 7 expliquera. Deux conséquences immédiates.

Passer un grand tableau ne coûte rien : huit octets, quelle que soit sa taille. C'est efficace, et c'est aussi pourquoi le C n'offre aucun moyen simple de passer un tableau en lecture seule — on écrit const int T[] pour l'exprimer.

sizeof ne fonctionne plus. Dans la fonction, sizeof(T) rend la taille d'un pointeur — 8 — et non celle du tableau. L'astuce sizeof(T)/sizeof(T[0]) fonctionne uniquement là où le tableau a été déclaré, et devient silencieusement fausse dans toute fonction. C'est précisément pourquoi il faut passer la taille en second paramètre.

Quiz · 1 question

Une fonction reçoit int T[] et calcule sizeof(T)/sizeof(T[0]) pour connaître le nombre d'éléments. Que vaut ce calcul ?

  • Le nombre d'éléments du tableau : c'est l'idiome standardle bon nombre
  • 8 divisé par 4, soit 2, quelle que soit la taille réelle : le paramètre est un POINTEUR, et sizeof rend la taille du pointeur — d'où l'obligation de passer la taille en paramètretoujours 2
  • Une erreur de compilation, car sizeof ne s'applique pas aux paramètreserreur

Réponse : L'idiome sizeof(T)/sizeof(T[0]) fonctionne, mais UNIQUEMENT dans la portée où le tableau a été déclaré — là où le compilateur connaît sa taille. Dès qu'un tableau est passé en paramètre, il se convertit en pointeur sur son premier élément : le paramètre int T[] est strictement équivalent à int *T, et sizeof(T) rend 8, la taille d'un pointeur sur une machine 64 bits. Sur des int de 4 octets, le calcul donne donc invariablement 2. Le plus dangereux est que ce code compile sans avertissement (gcc -Wall en émet un, encore une raison de l'activer) et qu'il donne un résultat plausible : un tableau de deux éléments existe. D'où la règle : LA TAILLE VOYAGE AVEC LE TABLEAU, en second paramètre.

Quiz · 1 question

Pourquoi int T[5]; T[10] = 42; ne provoque-t-il ni erreur de compilation ni erreur d'exécution ?

  • Parce que le compilateur agrandit automatiquement le tableau si nécessaireagrandissement
  • Parce que T[i] est traduit en un simple calcul d'adresse, sans aucun test : l'écriture atteint la mémoire voisine, qui appartient à autre chose — corruption silencieuse plutôt qu'erreurcalcul d'adresse sans test
  • Parce que 42 tient dans un int, donc l'écriture est valide où qu'elle ailletaille de la valeur

Réponse : Le compilateur traduit T[i] en « adresse de T plus i fois la taille d'un élément », et émet ce calcul sans le vérifier — il ne connaît pas toujours i, et le tester coûterait une comparaison à chaque accès, ce que le C refuse de payer. L'écriture atteint donc les vingt octets suivants, qui appartiennent à autre chose. Trois issues : page interdite et erreur de segmentation, ce qui est le MEILLEUR cas puisque l'erreur est immédiate ; autre variable écrasée, donc corruption silencieuse et plantage à retardement ; ou adresse de retour de la fonction, auquel cas un attaquant qui contrôle la donnée écrite contrôle où le programme saute. C'est le débordement de tampon, et c'est pourquoi valgrind existe.

À vous

L'exercice modélise un cadre de pile — un tableau et ses variables voisines dans une même zone mémoire — puis vous fait écrire hors bornes pour observer ce qui est écrasé.

Vous verrez les trois issues : la corruption d'une variable voisine, l'écrasement d'une valeur sensible, et l'accès à une adresse interdite. Vous écrirez ensuite la version défensive, où la fonction reçoit la taille et vérifie ses indices — la seule protection dont on dispose en C.

La dernière partie mesure le parcours d'une matrice par lignes et par colonnes, comme au chapitre 7 d'architecture, mais cette fois sur la représentation linéaire du C : c'est le même tableau, et le calcul d'adresse rend l'écart évident.

Exercice de code

Écrivez hors bornes et observez ce qui est écrasé, puis écrivez la version défensive.

Point de départ

// ── Un cadre de pile, avec ses variables voisines ─────────────────────────
// La zone est un tableau d'octets ; chaque variable occupe une plage.
function creerCadre() {
  const OCTETS = 40;
  const memoire = new Array(OCTETS).fill(0);
  const PLAN = {
    "T[0..4]":      { debut: 0,  taille: 20 },   // int T[5], 4 octets chacun
    "secret":       { debut: 20, taille: 4 },
    "adresseRetour":{ debut: 24, taille: 4 },
  };
  const INTERDIT = 36;   // au-delà : page non allouée

  function nomDe(octet) {
    for (const [nom, p] of Object.entries(PLAN)) {
      if (octet >= p.debut && octet < p.debut + p.taille) return nom;
    }
    return "zone non allouée";
  }

  return {
    plan: PLAN,
    // Écrit un int de 4 octets à l'indice i du tableau T. AUCUN contrôle.
    ecrireT(i, valeur) {
      const octet = 0 + i * 4;
      if (octet >= INTERDIT) return { erreur: "Segmentation fault (adresse " + octet + ")" };
      memoire[octet] = valeur;
      return { touche: nomDe(octet), octet };
    },
    lire(nom) {
      const p = PLAN[nom];
      return memoire[p.debut];
    },
    poser(nom, v) { memoire[PLAN[nom].debut] = v; },
  };
}

// ── Version défensive ─────────────────────────────────────────────────────
// La seule protection en C : la taille voyage avec le tableau.
function ecrireSur(cadre, i, n, valeur) {
  // ← à écrire : refuser si i est hors de [0, n[, sinon écrire
  return cadre.ecrireT(i, valeur);
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez T[5], T[6] et T[12]. Que touche chacun ?
// 2. Écrivez ecrireSur() et vérifiez qu'elle refuse proprement.
// 3. Comparez le parcours d'une matrice 4x4 par lignes et par colonnes :
//    même nombre d'accès, quelle différence sur les adresses visitées ?

const c = creerCadre();
c.poser("secret", 1234);
c.poser("adresseRetour", 9999);
console.log("T[2] :", JSON.stringify(c.ecrireT(2, 42)));

Solution

function creerCadre() {
  const OCTETS = 40;
  const memoire = new Array(OCTETS).fill(0);
  const PLAN = {
    "T[0..4]":       { debut: 0,  taille: 20 },
    "secret":        { debut: 20, taille: 4 },
    "adresseRetour": { debut: 24, taille: 4 },
  };
  const INTERDIT = 36;

  function nomDe(octet) {
    for (const [nom, p] of Object.entries(PLAN)) {
      if (octet >= p.debut && octet < p.debut + p.taille) return nom;
    }
    return "zone non allouée";
  }

  return {
    plan: PLAN,
    ecrireT(i, valeur) {
      const octet = i * 4;
      if (octet >= INTERDIT) return { erreur: "Segmentation fault (adresse " + octet + ")" };
      memoire[octet] = valeur;
      return { touche: nomDe(octet), octet };
    },
    lire(nom) { return memoire[PLAN[nom].debut]; },
    poser(nom, v) { memoire[PLAN[nom].debut] = v; },
  };
}

function ecrireSur(cadre, i, n, valeur) {
  // La seule protection dont on dispose en C : la vérifier soi-même, avec
  // une taille qu'on s'est donné la peine de transmettre.
  if (i < 0 || i >= n) return { erreur: "indice " + i + " hors de [0, " + n + "[" };
  return cadre.ecrireT(i, valeur);
}

console.log("— écriture sans contrôle —");
const c = creerCadre();
c.poser("secret", 1234);
c.poser("adresseRetour", 9999);
for (const i of [2, 4, 5, 6, 12]) {
  const r = c.ecrireT(i, 42);
  const etiquette = "T[" + i + "]";
  if (r.erreur) console.log("   " + etiquette.padEnd(7) + "-> " + r.erreur);
  else console.log("   " + etiquette.padEnd(7) + "-> octet " + String(r.octet).padStart(2) +
                   ", touche « " + r.touche + " »" +
                   (i >= 5 ? "   << HORS BORNES, et aucune erreur" : ""));
}
console.log("   secret vaut maintenant " + c.lire("secret") +
            " (il valait 1234) et adresseRetour " + c.lire("adresseRetour") + " (il valait 9999)");
// T[5] a écrasé une variable voisine : corruption silencieuse. T[6] a écrasé
// l'adresse de retour : c'est là qu'un attaquant prend la main. T[12] tombe
// dans une page interdite, et c'est le seul cas où le programme s'arrête —
// donc le plus favorable, contre toute intuition.

console.log("");
console.log("— version défensive —");
const d = creerCadre();
for (const i of [2, 5]) {
  const r = ecrireSur(d, i, 5, 42);
  console.log("   T[" + i + "] -> " + (r.erreur ?? "écrit dans « " + r.touche + " »"));
}

console.log("");
console.log("— matrice 4x4 : ordre des adresses visitées —");
const N = 4;
const adresse = (i, j) => (i * N + j) * 4;
const parLignes = [], parColonnes = [];
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) parLignes.push(adresse(i, j));
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) parColonnes.push(adresse(i, j));
console.log("   par lignes   : " + parLignes.join(" "));
console.log("   par colonnes : " + parColonnes.join(" "));
const saut = (t) => t.slice(1).reduce((s, a, k) => s + Math.abs(a - t[k]), 0);
console.log("   distance totale parcourue : " + saut(parLignes) + " octets par lignes, " +
            saut(parColonnes) + " par colonnes");
// Mêmes seize accès, mêmes seize adresses — mais dans un ordre qui saute.
// Le cache rapporte une ligne de 64 octets à chaque défaut : par lignes,
// elle sert quatre fois de suite ; par colonnes, une seule.

En travaux pratiques

Travaux pratiques 5 · 3 h

Sortir du tableau, exprès

Constater que le C ne vérifie aucun indice, mesurer ce que cela permet, et adopter les deux outils qui rendent l'erreur visible.

Avant de commencer

  • Les TP 1 à 4
  • gcc avec -fsanitize=address

Énoncé

  1. Écrire à côtéDéclarez un tableau de cinq entiers et écrivez à l'indice 5, 6, puis 100. Compilez avec -Wall et exécutez. Notez ce qui plante et ce qui ne plante pas.
  2. Écraser une variable choisieDéclarez une variable juste après le tableau, affichez son adresse et celle du tableau, puis modifiez-la en écrivant hors du tableau. Vous venez de faire un débordement dirigé. Indice : La différence des adresses vous donne l'indice à viser.
  3. L'outil qui voitRecompilez avec le détecteur d'adresses et relancez. Lisez le rapport : il donne le tableau, l'indice, et la ligne. Comparez à votre diagnostic sans outil.
  4. sizeof, et où il mentAffichez la taille d'un tableau dans la fonction qui le déclare, puis dans une fonction à laquelle vous le passez. Expliquez l'écart.
  5. Tableau à deux dimensionsCréez une matrice, remplissez-la par lignes puis par colonnes, et chronométrez. Retrouvez le résultat du TP 7 d'Architecture, cette fois dans votre propre code.
  6. Au fil rougeAjoutez à journal un histogramme du nombre de lignes par heure, sur 24 cases. Faites-le d'abord sans contrôle d'indice, testez avec une heure invalide, puis protégez.
  7. La bonne façon de passer un tableauRéécrivez toutes vos fonctions prenant un tableau pour qu'elles prennent aussi sa taille. Ajoutez const là où c'est possible, et vérifiez que le compilateur refuse une modification.

C'est réussi quand

  • Vous provoquez un débordement qui ne plante PAS, et vous savez pourquoi c'est le cas dangereux
  • Le détecteur d'adresses vous donne la ligne exacte que vous cherchiez à la main
  • Toutes vos fonctions reçoivent la taille du tableau qu'elles parcourent

Correction

Aucun contrôle, jamais
int t[5] = {0};
t[5]   = 42;   /* hors bornes : compile, s'exécute, NE PLANTE PAS */
t[6]   = 42;   /* idem */
t[100] = 42;   /* peut-être hors de la pile → Segmentation fault */

gcc -Wall : aucun avertissement sur t[i] avec i variable

Le C ne vérifie pas les indices, et c'est un CHOIX de conception : la vérification coûterait un test à chaque accès. Le cas dangereux n'est pas celui qui plante — c'est celui qui ne plante pas, corrompt une donnée voisine, et se manifeste bien plus tard, ailleurs, sous une forme incompréhensible.

Le débordement dirigé
int t[5];
int secret = 1;
printf("%p %p\n", (void*)t, (void*)&secret);
0x7ffd4c00  0x7ffd4c14      → 20 octets d'écart = 5 entiers

t[5] = 999;
printf("%d\n", secret);   → 999

Rien de magique : le tableau et la variable sont voisins sur la pile, et l'indice 5 tombe exactement sur la seconde. C'est le principe de toutes les attaques par débordement de tampon — écrire au-delà d'un tableau pour atteindre quelque chose de choisi, jusqu'à l'adresse de retour vue au TP 6 d'Architecture. La disposition n'est pas garantie par la norme, mais elle est parfaitement prévisible sur une machine donnée.

Le détecteur d'adresses
gcc -fsanitize=address -g journal.c && ./a.out

==12345==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 4 at 0x7ffd4c14 thread T0
  #0 0x… in main journal.c:12
Address is located in stack of thread T0 at offset 36
'tab' (line 10) <== 0 bytes to the right of this 20-byte region

Le tableau, sa ligne de déclaration, la taille de l'accès, la ligne fautive. Ce que vous avez cherché dix minutes à la main, l'outil le donne en une seconde. Le coût est un ralentissement d'environ 2, ce qui est parfaitement acceptable en développement et en test. À utiliser systématiquement, jamais en production.

sizeof, et la dégénérescence
void f(int t[]) { printf("%zu\n", sizeof t); }   /* 8 : un POINTEUR */

int main(void) {
  int t[5];
  printf("%zu\n", sizeof t);   /* 20 : le tableau entier */
  f(t);
}

Passé à une fonction, un tableau « dégénère » en pointeur sur son premier élément : la taille est PERDUE, définitivement. C'est pourquoi la taille doit toujours être passée à côté — et c'est aussi pourquoi la notation int t[] dans un paramètre est trompeuse : elle signifie exactement int *t. La préférer à l'étoile est un choix de lisibilité, pas de sémantique.

L'histogramme, et sa protection
int par_heure[24] = {0};

/* sans contrôle : une heure lue à 99 écrit hors du tableau */
par_heure[heure]++;

/* protégé */
if (heure < 0 || heure >= 24) { lignes_invalides++; continue; }
par_heure[heure]++;

La donnée vient d'un fichier, donc de l'extérieur, donc elle n'est pas fiable. La règle est générale : toute valeur venue de l'extérieur — fichier, réseau, argument, saisie — est validée AVANT de servir d'indice, de taille ou de longueur. C'est le même principe que la validation de strtol au TP 2, et il ne souffre aucune exception.

La signature correcte
/* avant */
int somme(int t[]);

/* après : taille explicite, et const quand on ne modifie pas */
int somme(const int *t, size_t n);

somme accepte désormais un tableau constant, et le compilateur
REFUSE toute écriture dans t à l'intérieur de la fonction

const est une vérification gratuite, faite à la compilation, et c'est aussi de la documentation qui ne peut pas devenir fausse. size_t plutôt que int pour une taille évite les valeurs négatives — au prix de la vigilance sur les comparaisons signé/non signé du TP 2.

Ce que la suite en fait

Le chapitre 6 applique tout cela au cas particulier le plus répandu : une chaîne de caractères est un tableau de char, avec une convention supplémentaire — un marqueur de fin. Les pièges de ce chapitre s'y aggravent, parce que la longueur n'est plus une donnée mais un résultat à calculer.

Le chapitre 7 expliquera enfin pourquoi un tableau passé en paramètre devient un pointeur, et le chapitre 8 lèvera la dernière limite : allouer un tableau dont la taille n'est connue qu'à l'exécution.

À retenir

Flashcards · 4 cartes

Que se passe-t-il exactement lors d'un accès hors bornes en C ?
Rien de particulier : T[i] est traduit en « adresse de T + i × taille d'un élément », sans aucun test — vérifier coûterait une comparaison par accès, que le C refuse de payer. L'accès atteint donc la mémoire voisine. Trois issues : page interdite et ERREUR DE SEGMENTATION (le meilleur cas, l'erreur est immédiate) ; autre variable écrasée et corruption silencieuse ; adresse de retour écrasée, et c'est le débordement de tampon exploitable.
Pourquoi sizeof(T)/sizeof(T[0]) échoue-t-il dans une fonction ?
Parce qu'un tableau passé en paramètre se convertit en POINTEUR sur son premier élément : int T[] est strictement équivalent à int *T. sizeof(T) rend alors 8, la taille d'un pointeur, et le calcul donne invariablement 2 sur des int. L'idiome ne fonctionne que dans la portée où le tableau a été DÉCLARÉ. D'où la règle : la taille voyage avec le tableau, en second paramètre.
Un tableau passé en paramètre est-il copié ? Est-ce une entorse au passage par valeur ?
NON copié : ce qui est copié est l'ADRESSE de son premier élément, donc la fonction travaille sur l'original et peut le modifier. Ce n'est pas une entorse au passage par valeur — l'adresse, elle, est bien copiée — mais une conséquence de la conversion tableau-pointeur. Avantage : passer un grand tableau coûte 8 octets. Pour interdire la modification, on écrit const int T[].
Comment un tableau à deux dimensions est-il rangé, et quelle conséquence pratique ?
LIGNE PAR LIGNE, dans une mémoire linéaire : l'adresse de grille[i][j] vaut début + (i × nbColonnes + j) × sizeof(élément). Conséquence mesurable : parcourir PAR LIGNES suit l'ordre mémoire et exploite le cache ; parcourir PAR COLONNES saute d'une ligne à l'autre à chaque accès et peut être plusieurs fois plus lent — pour exactement le même nombre d'opérations.

Chapitre 2 · 4 h

Chaînes de caractères

Le caractère nul terminal, les fonctions de string.h, les pièges de strcpy et le dépassement de tampon, la manipulation caractère par caractère.

Le ver Morris de 1988 — celui qui ouvrait le chapitre 1 de l'UE de cybersécurité — s'est propagé, entre autres, par un débordement de tampon dans le démon fingerd. Le code fautif appelait gets(), une fonction qui lit une ligne dans un tampon sans jamais savoir quelle taille il fait. Le ver envoyait une ligne plus longue que le tampon, écrasait l'adresse de retour, et prenait la main.

gets a été retirée de la norme C en 2011, après vingt-trois ans de bons et loyaux services aux attaquants. Mais le problème qu'elle illustre n'a pas disparu : il est inhérent à la façon dont le C représente les chaînes, et c'est le sujet de ce chapitre.

Une chaîne est un tableau, plus une convention

Le C n'a pas de type chaîne. Une chaîne est un tableau de char terminé par un caractère nul, noté '\0', de code 0.

char mot[] = "chat"; ┌─────┬─────┬─────┬─────┬──────┐│ 'c' │ 'h' │ 'a' │ 't' │ '\0' │└─────┴─────┴─────┴─────┴──────┘   0     1     2     3     4        ← taille du tableau : 5, pas 4

Toute la suite découle de ce schéma.

La longueur n'est pas stockée : elle se calcule, en parcourant jusqu'au marqueur. C'est la différence de fond avec les chaînes de Python ou de Java, qui portent leur longueur.

Il faut toujours une case de plus. Un tableau destiné à contenir nn caractères doit être déclaré de taille n+1n+1. L'oubli du +1 est la faute la plus fréquente du chapitre, et elle produit un débordement d'exactement une case — le genre d'erreur qui passe les tests.

Un marqueur perdu est une chaîne infinie. Si le '\0' est écrasé, toutes les fonctions de chaîne continuent de lire au-delà du tableau, jusqu'à tomber par hasard sur un octet nul — ou sur une page interdite.

Deux déclarations qu'il ne faut pas confondre :

char a[] = "chat";      /* tableau de 5 char, modifiable, sur la pile */char *b  = "chat";      /* pointeur vers une chaîne littérale, EN LECTURE SEULE */

Modifier b[0] est un comportement indéfini : les littéraux sont généralement placés dans une zone protégée en écriture, et le programme meurt. On écrit donc const char *b pour que le compilateur le rappelle.

Les fonctions de string.h

FonctionRôleCoût
strlen(s)longueur, sans le '\0'O(n)O(n)
strcpy(dst, src)copie, marqueur comprisO(n)O(n)
strcmp(a, b)0 si égales, signe de la différence sinonO(n)O(n)
strcat(dst, src)concatène à la fin de dstO(n+m)O(n+m)
strchr(s, c)première occurrence de cO(n)O(n)
strncpy, strncatversions bornées par une tailleO(n)O(n)

La colonne des coûts mérite plus d'attention qu'on ne lui en accorde. strlen est linéaire, puisqu'il faut parcourir jusqu'au marqueur. Écrire

for (int i = 0; i < strlen(s); i++)      /* QUADRATIQUE */

recalcule la longueur à chaque tour : la boucle devient O(n2)O(n^2). Sur une chaîne d'un million de caractères, c'est la différence entre instantané et plusieurs minutes. On calcule la longueur une fois, avant la boucle.

Deux rappels de syntaxe qui coûtent cher. strcmp rend 0 quand les chaînes sont égales, donc if (strcmp(a, b)) teste la différence — le piège du chapitre 3. Et l'on ne compare jamais deux chaînes avec ==, qui compare les adresses : a == b est vrai seulement si les deux pointeurs désignent le même tableau.

Les pièges

Ils viennent tous du même endroit : aucune de ces fonctions ne connaît la taille de la destination.

strcpy(dst, src) copie jusqu'au marqueur de src, sans se demander si dst peut l'accueillir.

char petit[8];strcpy(petit, "une chaîne beaucoup trop longue");   /* débordement */

Le débordement du chapitre 5, avec ses trois issues — dont l'écrasement de l'adresse de retour, qui est exactement le mécanisme du ver Morris.

Les versions bornées, strncpy et strncat, prennent une taille maximale. Elles sont plus sûres, mais elles ont leurs propres pièges : strncpy ne pose pas le marqueur si la source remplit exactement la destination, ce qui produit une chaîne non terminée. Il faut donc écrire dst[n-1] = '\0' après coup. Et strncat prend en argument la place restante, pas la taille du tampon — une source d'erreur classique.

D'où les recommandations d'usage : gets est interdite, on emploie fgets qui prend une taille ; sprintf est à remplacer par snprintf pour la même raison ; et sur les systèmes qui les offrent, strlcpy et strlcat font ce que strncpy aurait dû faire.

Quiz · 1 question

Pourquoi for (int i = 0; i < strlen(s); i++) est-il quadratique, et comment le corriger ?

  • Parce que l'accès s[i] est lui-même linéaire : il faut parcourir la chaîne depuis le débutaccès linéaire
  • Parce que strlen est appelée à CHAQUE TOUR et parcourt toute la chaîne pour trouver le marqueur : n tours × n caractères. On calcule la longueur une fois, avant la bouclestrlen appelée n fois
  • Parce que la comparaison entre int et size_t force une conversion coûteuse à chaque tourconversion de type

Réponse : L'accès s[i] est bien en O(1) — un simple calcul d'adresse, comme au chapitre 5. Le coupable est la CONDITION : elle est réévaluée à chaque tour, et strlen doit à chaque fois parcourir la chaîne du début jusqu'au marqueur, puisque la longueur n'est stockée nulle part. Résultat : n tours, chacun coûtant n, soit O(n²). Sur un million de caractères, c'est la différence entre quelques millisecondes et plusieurs minutes. Le correctif tient en une ligne : size_t n = strlen(s); puis i < n. À noter qu'un compilateur optimisant peut parfois sortir l'appel de la boucle s'il prouve que la chaîne n'est pas modifiée — mais compter là-dessus est fragile, et le code reste trompeur pour le lecteur.

Manipuler caractère par caractère

Un char est un entier — le chapitre 2 d'architecture l'avait posé — et l'arithmétique directe sur les codes ASCII est parfaitement légitime en C.

char c = '7';int chiffre = c - '0';           /* 55 − 48 = 7 */char maj = c - 'a' + 'A';        /* bascule minuscule → majuscule */

Le - '0' est l'idiome de conversion caractère vers chiffre, et il fonctionne parce que les chiffres sont consécutifs dans la table. L'écart entre minuscule et majuscule vaut 32, soit un seul bit — le chapitre 2 d'architecture l'avait noté.

Ces manipulations sont si fréquentes que ctype.h les encapsule : isdigit, isalpha, isspace, toupper, tolower. Il vaut mieux les employer, pour une raison qui n'est pas cosmétique : elles tiennent compte des paramètres régionaux, alors que les comparaisons directes supposent l'ASCII et se cassent sur les caractères accentués — dont le chapitre 2 d'architecture rappelait qu'ils occupent plusieurs octets en UTF-8.

C'est d'ailleurs la limite à connaître de tout ce chapitre : strlen compte des octets, pas des caractères. Sur « été », elle rend 5. Manipuler du texte réellement international demande une bibliothèque dédiée, et le C seul n'y suffit pas.

Quiz · 1 question

char nom[5] ; strcpy(nom, « Alice ») — combien d'octets sont copiés, et pourquoi est-ce un débordement ?

  • Aucun débordement : « Alice » fait exactement 5 caractères, qui tiennent dans nom[5]ça passe
  • Débordement d'une case : strcpy copie AUSSI le marqueur de fin, donc 6 octets dans un tableau de 5 — l'octet nul est écrit juste après la finune case de trop
  • Débordement de 5 cases : strcpy ne tient jamais compte de la taille de la destinationcinq cases

Réponse : C'est l'oubli du + 1, et la plus fréquente des fautes du chapitre. « Alice » compte cinq lettres, mais une chaîne C en occupe SIX : les cinq caractères plus le marqueur '\\0', que strcpy copie fidèlement puisqu'il fait partie de la chaîne source. L'octet nul est donc écrit un cran après la fin du tableau — un débordement d'exactement une case, qui écrase la variable voisine ou un octet de bourrage. C'est le genre d'erreur qui passe tous les tests : selon l'agencement de la pile, l'octet écrasé peut n'avoir aucune conséquence visible pendant des mois. La règle : un tableau destiné à n caractères se déclare de taille n + 1, et l'on emploie snprintf ou strlcpy plutôt que strcpy.

À vous

L'exercice réimplémente strlen, strcpy et strcmp sur un tableau de caractères simulé, avec un tampon de taille fixe et une variable voisine — le cadre du chapitre 5.

Trois choses à faire tomber. Le débordement du +1 oublié, en voyant l'octet nul écraser le voisin. Le marqueur perdu, en écrasant le '\0' et en constatant que strlen part au-delà du tampon. Et la boucle quadratique, en comptant les caractères examinés avec strlen dans la condition puis en dehors — le rapport se voit dès une chaîne de cent caractères.

Exercice de code

Réimplémentez strcpy, strlen et strcmp, puis faites tomber le « + 1 », le marqueur perdu et la boucle quadratique.

Point de départ

// La mémoire : un tampon de 8 octets, puis une variable voisine.
function creerMemoire() {
  const m = new Array(16).fill(0);
  const TAMPON = 0, TAILLE = 8, VOISIN = 8;
  m[VOISIN] = 1234;
  return {
    tampon: TAMPON, taille: TAILLE,
    ecrire(i, code) { m[i] = code; },
    lire(i) { return m[i]; },
    voisin: () => m[VOISIN],
    vue() {
      return m.slice(0, 12).map((c, i) => {
        const s = c === 0 ? "\\0" : (i >= VOISIN ? String(c) : String.fromCharCode(c));
        return (i === VOISIN ? "| " : "") + s;
      }).join(" ");
    },
  };
}

// Pose une chaîne littérale dans le tampon, SANS aucun contrôle — comme strcpy.
function monStrcpy(m, depart, texte) {
  let i = 0;
  for (; i < texte.length; i++) m.ecrire(depart + i, texte.charCodeAt(i));
  // ← à écrire : et le marqueur de fin ? combien d'octets au total ?
  return i;
}

// Longueur : on compte jusqu'au marqueur. AUCUNE borne.
function monStrlen(m, depart, compteur = { n: 0 }) {
  let i = 0;
  while (m.lire(depart + i) !== 0) { compteur.n++; i++; if (i > 40) return -1; }
  return i;
}

function monStrcmp(m, a, b) {
  return 0;   // ← à écrire : 0 si égales, sinon le signe de la différence
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Faites copier le marqueur par monStrcpy, puis copiez « Alice » dans le
//    tampon de 8 : combien d'octets ? Et « personnage » ?
// 2. Écrasez le marqueur et relancez monStrlen : que lit-elle ?
// 3. Comptez les caractères examinés avec strlen DANS la condition d'une
//    boucle, puis calculée une seule fois avant.

const m = creerMemoire();
monStrcpy(m, 0, "chat");
console.log("mémoire :", m.vue());
console.log("longueur :", monStrlen(m, 0), "| voisin :", m.voisin());

Solution

function creerMemoire() {
  const m = new Array(16).fill(0);
  const TAMPON = 0, TAILLE = 8, VOISIN = 8;
  m[VOISIN] = 1234;
  return {
    tampon: TAMPON, taille: TAILLE,
    ecrire(i, code) { m[i] = code; },
    lire(i) { return m[i]; },
    voisin: () => m[VOISIN],
    vue() {
      return m.slice(0, 12).map((c, i) => {
        const s = c === 0 ? "\\0" : (i >= VOISIN ? String(c) : String.fromCharCode(c));
        return (i === VOISIN ? "| " : "") + s;
      }).join(" ");
    },
  };
}

function monStrcpy(m, depart, texte) {
  let i = 0;
  for (; i < texte.length; i++) m.ecrire(depart + i, texte.charCodeAt(i));
  // Le marqueur FAIT PARTIE de la chaîne : strcpy le copie aussi, d'où
  // texte.length + 1 octets écrits. C'est le « + 1 » qu'on oublie.
  m.ecrire(depart + i, 0);
  return i + 1;
}

function monStrcpyBorne(m, depart, texte, taille) {
  // Ce que strncpy aurait dû être : on réserve la dernière case au marqueur.
  const n = Math.min(texte.length, taille - 1);
  for (let i = 0; i < n; i++) m.ecrire(depart + i, texte.charCodeAt(i));
  m.ecrire(depart + n, 0);
  return { ecrits: n + 1, tronque: n < texte.length };
}

function monStrlen(m, depart, compteur = { n: 0 }) {
  let i = 0;
  while (m.lire(depart + i) !== 0) { compteur.n++; i++; if (i > 40) return -1; }
  return i;
}

function monStrcmp(m, a, b) {
  let i = 0;
  while (m.lire(a + i) !== 0 && m.lire(a + i) === m.lire(b + i)) i++;
  // On compare les octets À LA PREMIÈRE DIFFÉRENCE, marqueurs compris : c'est
  // ce qui fait que 0 signifie « égales », d'où le piège du if (strcmp(...)).
  return m.lire(a + i) - m.lire(b + i);
}

console.log("— 1. le « + 1 » —");
for (const texte of ["chat", "Alice", "personnage"]) {
  const m = creerMemoire();
  const ecrits = monStrcpy(m, 0, texte);
  const debord = ecrits > m.taille;
  console.log("   « " + texte.padEnd(11) + " » : " + ecrits + " octets dans un tampon de " +
    m.taille + (debord ? "   << DÉBORDEMENT, voisin = " + m.voisin() : "   ok"));
}
console.log("   « Alice » fait 5 lettres et 6 octets : le marqueur déborde d'exactement");
console.log("   une case, ce qui passe la plupart des tests.");

console.log("");
console.log("— version bornée —");
const b = creerMemoire();
const r = monStrcpyBorne(b, 0, "personnage", b.taille);
console.log("   " + b.vue() + "   tronquée : " + r.tronque + ", voisin intact : " + b.voisin());

console.log("");
console.log("— 2. le marqueur perdu —");
const p = creerMemoire();
monStrcpy(p, 0, "chat");
console.log("   longueur normale : " + monStrlen(p, 0));
p.ecrire(4, 88);                  // on écrase le '\0' par un 'X'
console.log("   marqueur écrasé  : " + monStrlen(p, 0) +
            "   (lecture au-delà du tampon, jusqu'à un zéro fortuit ou la garde)");

console.log("");
console.log("— 3. strlen dans la condition —");
const q = creerMemoire();
for (let i = 0; i < 100; i++) q.ecrire(i, 65 + (i % 26));
q.ecrire(100, 0);
let dans = { n: 0 }, dehors = { n: 0 };
for (let i = 0; i < monStrlen(q, 0, dans); i++) { /* corps vide */ }
const n = monStrlen(q, 0, dehors);
for (let i = 0; i < n; i++) { /* corps vide */ }
console.log("   strlen DANS la condition : " + dans.n + " caractères examinés");
console.log("   strlen calculée une fois : " + dehors.n + " caractères examinés");
console.log("   rapport : " + Math.round(dans.n / dehors.n) + " fois plus, et il croît avec n.");

En travaux pratiques

Travaux pratiques 6 · 3 h

Le caractère nul, et tout ce qui en dépend

Manipuler des chaînes C en comprenant que leur longueur n'est écrite nulle part, et réaliser le découpage de lignes dont le fil rouge a besoin.

Avant de commencer

  • Le TP 5 : tableaux et débordements
  • Le TP 2 d'Architecture, sur UTF-8

Énoncé

  1. Compter les octetsDéclarez une chaîne littérale et affichez sa longueur par strlen et sa taille par sizeof. Expliquez la différence d'exactement un.
  2. Perdre le zéroRemplissez un tableau de dix caractères avec dix lettres, sans terminateur, puis affichez-le avec %s. Recommencez plusieurs fois et notez la variabilité.
  3. Le débordement classiqueCopiez une chaîne de vingt caractères dans un tampon de dix avec strcpy. Exécutez, puis recompilez avec le détecteur d'adresses.
  4. La fausse correctionRemplacez par strncpy avec la taille du tampon. Affichez le résultat et cherchez le nouveau problème. Écrivez ensuite la version réellement correcte. Indice : strncpy ne garantit PAS d'écrire le terminateur.
  5. ComparerComparez deux chaînes de contenu identique avec l'opérateur d'égalité, puis avec strcmp. Expliquez pourquoi la première forme est parfois vraie.
  6. Découper une ligneÉcrivez une fonction qui découpe une ligne en champs sur un séparateur, sans utiliser strtok. Testez avec des champs vides et des séparateurs consécutifs.
  7. Au fil rougeBranchez ce découpage dans journal : extraire l'adresse, la date, le code de réponse de chaque ligne. Testez sur une ligne tronquée et sur une ligne vide.
  8. Les accentsComptez les caractères d'une chaîne accentuée avec strlen, puis à la main. Écrivez une fonction qui compte les vrais caractères UTF-8.

C'est réussi quand

  • Vous expliquez pourquoi sizeof vaut strlen plus un sur un littéral
  • Votre version de la copie est sûre ET termine toujours la chaîne
  • Votre découpage gère un champ vide entre deux séparateurs
  • Votre compteur UTF-8 donne 8 pour « éléphant »

Correction

Une chaîne, c'est une convention
char s[] = "abc";
strlen(s)  → 3      (compte jusqu'au zéro, exclu)
sizeof s   → 4      (a, b, c, et le '\0')

en mémoire : 'a' 'b' 'c' '\0'

La longueur n'est stockée NULLE PART : strlen la calcule en parcourant jusqu'au terminateur, donc en temps linéaire. Une boucle écrite avec strlen dans sa condition relit donc toute la chaîne à chaque tour — le classique O(n²) invisible. Toutes les particularités de ce TP découlent de cette seule convention.

Sans terminateur
char t[10];
for (int i = 0; i < 10; i++) t[i] = 'A' + i;
printf("%s\n", t);

→ ABCDEFGHIJ suivi de déchets, jusqu'au premier octet nul
 rencontré par hasard — longueur variable d'une exécution à l'autre

printf avec %s lit jusqu'au zéro, et il ne peut pas savoir que votre tableau s'arrête. C'est une lecture hors bornes, avec toutes les conséquences du TP 5 — y compris la divulgation du contenu de la pile, qui est une classe de faille à part entière.

Les trois copies
char buf[10];

strcpy(buf, "chaîne bien trop longue");    /* DÉBORDEMENT */

strncpy(buf, source, sizeof buf);           /* pas de débordement,
                                             mais PAS DE '\0' si
                                             source fait 10 ou plus */

/* la version correcte */
strncpy(buf, source, sizeof buf - 1);
buf[sizeof buf - 1] = '\0';

/* ou, plus lisible, en vérifiant la troncature */
int n = snprintf(buf, sizeof buf, "%s", source);
if (n >= (int)sizeof buf) { /* tronqué : c'est une ERREUR, pas un détail */ }

strncpy est un faux ami : conçu à l'origine pour des champs de taille fixe non terminés, il ne garantit pas le zéro final. snprintf est préférable parce qu'il termine toujours ET indique par sa valeur de retour ce qu'il AURAIT écrit — donc s'il y a eu troncature. Une troncature silencieuse est un bogue de sécurité, pas un désagrément cosmétique.

Comparer deux chaînes
char *a = "bonjour", *b = "bonjour";
a == b        → parfois VRAI : le compilateur a fusionné les littéraux
strcmp(a, b)  → 0 : contenus identiques

char c[] = "bonjour", d[] = "bonjour";
c == d        → FAUX : deux tableaux distincts

L'égalité compare des ADRESSES, jamais des contenus. Elle peut être vraie par accident quand le compilateur mutualise deux littéraux identiques, ce qui rend le bogue intermittent selon le niveau d'optimisation. Toute comparaison de chaînes passe par strcmp, sans exception.

Le découpage, sans strtok
int decouper(char *ligne, char sep, char *champs[], int max) {
  int n = 0;
  char *debut = ligne;
  for (char *p = ligne; ; p++) {
      if (*p == sep || *p == '\0') {
          if (n >= max) return -1;
          char fin = *p;
          *p = '\0';              /* on coupe SUR PLACE */
          champs[n++] = debut;
          debut = p + 1;
          if (fin == '\0') break;
      }
  }
  return n;
}

Le découpage modifie la ligne en y plaçant des zéros, et les champs pointent DEDANS : aucune allocation, aucune copie. Contrairement à strtok, cette fonction rend les champs vides — deux séparateurs consécutifs donnent un champ de longueur zéro, ce qui est presque toujours l'information voulue. Et elle n'a pas d'état statique, donc reste utilisable avec plusieurs fils d'exécution.

Compter des caractères, pas des octets
strlen("éléphant") → 10 octets, pour 8 caractères

int caracteres_utf8(const char *s) {
  int n = 0;
  for (; *s; s++)
      if ((*s & 0xC0) != 0x80) n++;   /* on ignore les octets 10xxxxxx */
  return n;
}

Un octet de continuation UTF-8 commence toujours par les bits 10 : les ignorer revient à ne compter que les premiers octets de chaque caractère. C'est le TP 2 d'Architecture rendu utile. Retenez la conséquence : en C, une chaîne est une suite d'OCTETS, et tout code qui coupe, tronque ou aligne du texte doit savoir dans quel encodage il travaille.

Ce que la suite en fait

Le bloc IV explique enfin ce que ce chapitre a manipulé sans le nommer. char *b = "chat" est un pointeur, strcpy(dst, src) reçoit deux adresses, et la conversion tableau-pointeur du chapitre 5 est la raison pour laquelle une fonction de chaîne peut modifier la chaîne de son appelant.

Le chapitre 8 lèvera aussi la contrainte de taille fixe : une chaîne dont la longueur n'est connue qu'à l'exécution s'alloue dynamiquement — et il faudra alors ne pas oublier le +1 au moment du malloc, où l'erreur est exactement la même.

À retenir

Flashcards · 4 cartes

Comment le C représente-t-il une chaîne, et quelles conséquences ?
Un TABLEAU DE CHAR terminé par le caractère nul '\0'. Trois conséquences : la LONGUEUR N'EST PAS STOCKÉE, elle se calcule en parcourant jusqu'au marqueur (contrairement à Python ou Java) ; il faut TOUJOURS une case de plus — un tableau pour n caractères se déclare de taille n+1 ; et un marqueur écrasé donne une chaîne « infinie », les fonctions lisant au-delà du tableau jusqu'à un octet nul fortuit.
Pourquoi strlen dans la condition d'une boucle est-il quadratique ?
Parce que la condition est réévaluée à chaque tour, et que strlen parcourt toute la chaîne pour trouver le marqueur — la longueur n'étant stockée nulle part. n tours coûtant n chacun donnent O(n²) : sur un million de caractères, la différence entre quelques millisecondes et plusieurs minutes. Correctif : calculer la longueur UNE FOIS, avant la boucle.
Pourquoi strcpy est-il dangereux, et que valent les versions bornées ?
strcpy copie jusqu'au marqueur de la SOURCE, sans jamais connaître la taille de la destination : d'où le débordement de tampon, mécanisme du ver Morris. strncpy et strncat prennent une taille, mais ont leurs propres pièges — strncpy NE POSE PAS le marqueur si la source remplit exactement la destination, et strncat attend la place RESTANTE, pas la taille du tampon. En pratique : jamais gets (retirée de la norme), fgets à la place ; snprintf plutôt que sprintf ; strlcpy si disponible.
Pourquoi c - '0' convertit-il un caractère en chiffre, et quelle est la limite du chapitre ?
Parce qu'un char EST un entier et que les chiffres sont consécutifs dans la table ASCII : '7' vaut 55, '0' vaut 48, la différence donne 7. De même l'écart minuscule/majuscule vaut 32, soit un seul bit. On préfère toutefois ctype.h (isdigit, toupper) qui tient compte des paramètres régionaux. LIMITE : strlen compte des OCTETS, pas des caractères — sur « été » elle rend 5, puisqu'un accent occupe deux octets en UTF-8.