Lire méthodiquement une erreur de compilation, gdb pas à pas, détection de fuites avec valgrind, assertions et jeux de tests, conventions d'écriture.
Un programme qui fonctionne du premier coup n'enseigne rien. Un programme qui plante, si l'on sait pourquoi, enseigne la moitié du cours — et le C offre pour cela un terrain d'une richesse inégalée, puisqu'il ne protège de rien.
Ce dernier chapitre n'ajoute aucune notion du langage. Il donne les quatre outils sans lesquels tout ce qui précède reste inexploitable : lire une erreur, dérouler au débogueur, traquer une fuite, et se prémunir par des tests.
Un préalable de méthode, qui vaut mieux que tous les outils : diagnostiquer plutôt que deviner. Modifier le code au hasard jusqu'à ce que le symptôme disparaisse ne corrige rien — cela déplace la faute. La démarche est toujours la même : reproduire de façon fiable, réduire au plus petit cas qui échoue, formuler une hypothèse, la vérifier par une mesure.
Lire une erreur de compilation
Le chapitre 1 a donné les trois règles : corriger la première erreur, lire le numéro de ligne comme un indice et non comme une adresse, et ne jamais ignorer un avertissement. Voici comment elles s'appliquent.
calcul.c:12:5: error: expected ';' before 'return'Le compilateur signale la ligne 12, et le point-virgule manquant est à la ligne 11 — il ne s'en aperçoit qu'en rencontrant le mot suivant. La règle : quand une erreur porte sur un point-virgule ou une accolade, regarder la ligne précédente.
Trois familles se distinguent par leur émetteur, comme au chapitre 1.
error: 'truc' undeclared vient du compilateur : identifiant jamais déclaré, souvent une
faute de frappe ou un #include manquant.
error: expected ... vient du parseur : la syntaxe. La cascade est ici la plus trompeuse —
une accolade non fermée engendre des dizaines de messages à partir de la ligne suivante.
undefined reference to 'truc' vient de l'éditeur de liens, et n'a rien d'une erreur de
syntaxe : le fichier objet manque, la bibliothèque n'est pas passée, ou la fonction est déclarée
sans être définie.
Enfin, deux avertissements qui sont presque toujours de vrais bogues :
warning: 'x' is used uninitialized — le chapitre 2 — et
warning: control reaches end of non-void function, qui signale un chemin d'exécution sans
return, dont la valeur rendue sera quelconque.
gdb, pas à pas
Le débogueur exécute le programme sous contrôle : on l'arrête où l'on veut, on inspecte, on avance d'une ligne.
La condition préalable est de compiler avec -g, qui conserve les noms de variables et les
numéros de ligne. Sans lui, gdb n'affiche que des adresses.
gcc -Wall -g -o prog prog.cgdb ./prog| Commande | Effet |
|---|---|
break 42 / break maFonction | poser un point d'arrêt |
run | lancer |
next | ligne suivante, sans entrer dans les appels |
step | ligne suivante, en entrant dans les appels |
print x / print *p / print T[3] | afficher une valeur |
backtrace | la pile d'appels — qui a appelé qui |
continue | reprendre jusqu'au prochain arrêt |
watch x | s'arrêter dès que x change |
Deux usages valent d'être connus précisément.
Le post-mortem d'une erreur de segmentation. On lance simplement run ; au plantage, gdb
s'arrête sur la ligne fautive. backtrace donne le chemin d'appels qui y a mené, et print sur
chaque pointeur montre lequel vaut NULL ou une adresse absurde. C'est trente secondes de
travail, contre une heure de printf disséminés.
Le watch. Quand une variable prend une valeur aberrante sans qu'on sache où, watch x
arrête le programme à l'instruction exacte qui la modifie. C'est l'outil des corruptions à
distance du chapitre 5 — celles où un débordement de tableau écrase une variable voisine.
Le débogueur ne remplace pas la réflexion : il répond à une question précise. Y arriver sans hypothèse revient à regarder défiler des valeurs.
valgrind
gdb traite les plantages ; valgrind traite ce qui ne plante pas encore. Il exécute le
programme dans une machine virtuelle et surveille chaque accès mémoire.
valgrind --leak-check=full ./progIl détecte exactement les quatre fautes du chapitre 8, et sa sortie se lit ainsi :
Invalid write of size 4 at 0x109189: main (prog.c:12) Address 0x4a47054 is 0 bytes after a block of size 20 alloc'd at 0x484A...: malloc by 0x109165: main (prog.c:9) LEAK SUMMARY: definitely lost: 40 bytes in 1 blocksTrois choses à y lire. La nature : Invalid write est un débordement, Invalid read une
lecture hors bloc ou après libération, definitely lost une fuite. Le lieu de la faute :
prog.c:12. Et surtout le lieu de l'allocation : prog.c:9 — l'information qui manque
toujours, puisqu'elle relie le symptôme à l'origine.
Deux nuances utiles. definitely lost désigne un bloc dont plus aucun pointeur n'existe ;
still reachable désigne un bloc non libéré mais encore atteignable à la fin — souvent bénin.
Et valgrind ralentit l'exécution d'un facteur dix à cinquante : on l'emploie sur un jeu de
tests réduit, pas sur la charge réelle.
Une alternative moderne mérite d'être citée : gcc -fsanitize=address, qui instrumente le
programme à la compilation. Deux à trois fois plus lent seulement, donc utilisable en continu
pendant le développement.
Un programme affiche des valeurs aberrantes mais ne plante jamais. Quel outil employer en premier, et pourquoi ?
Assertions et jeux de tests
Les outils précédents cherchent une faute déjà commise. Les deux qui suivent l'empêchent d'arriver jusqu'à la mise en service.
Une assertion vérifie une condition qui doit être vraie par construction. Si elle est fausse, le programme s'arrête immédiatement, en indiquant le fichier et la ligne.
#include <assert.h> int diviser(int a, int b) { assert(b != 0); /* précondition */ return a / b;}Le contrat est précis : une assertion documente une erreur de programmation, pas une
situation attendue. Une saisie utilisateur invalide n'est pas une assertion — c'est un cas à
traiter. Un pointeur NULL là où l'invariant l'interdit, si.
Deux propriétés à connaître. La macro NDEBUG désactive toutes les assertions à la
compilation, ce qui est l'usage en production. Conséquence immédiate : on ne met jamais
d'effet de bord dans une assertion — assert(i++ < n) cesse d'incrémenter en production, et
le programme se comporte différemment selon le mode de compilation, ce qui est le pire des
bogues.
Un jeu de tests est un programme qui exécute des cas et vérifie les résultats. Il n'a pas besoin d'une bibliothèque pour commencer :
static int echecs = 0;#define VERIFIER(cond) do { \ if (!(cond)) { printf("ÉCHEC %s:%d %s\n", __FILE__, __LINE__, #cond); echecs++; } \} while (0) VERIFIER(maximum(3, 7) == 7);VERIFIER(maximum(-1, -5) == -1);VERIFIER(maximum(4, 4) == 4);Ce qui distingue un bon jeu de tests n'est pas leur nombre mais le choix des cas : les bornes — zéro, un élément, tableau vide, valeur maximale du type —, les cas dégénérés, et chaque bogue corrigé, transformé en test pour qu'il ne revienne pas.
Conventions
Elles ne sont pas de la décoration : elles réduisent le nombre de fautes possibles.
Compiler avec -Wall -Wextra, et traiter tout avertissement comme une erreur. C'est la
mesure la plus rentable du chapitre, et de loin.
Nommer ce que fait la chose, en évitant les abréviations sauf pour les compteurs de boucle. Un nom exact vaut trois lignes de commentaire.
Écrire des fonctions courtes, qui font une chose. Une fonction qui tient à l'écran se relit ; au-delà, on ne relit plus, on parcourt.
Employer const partout où c'est vrai. Sur un paramètre pointeur, const documente que la
fonction ne modifiera pas la donnée — et le compilateur le vérifie.
Initialiser à la déclaration, et poser les pointeurs à NULL après free — les deux règles
des chapitres 2 et 8.
Indenter de façon cohérente. Peu importe le style ; ce qui compte est qu'il soit le même
partout, et clang-format s'en charge sans discussion.
Pourquoi ne doit-on jamais écrire assert(i++ < n) ?
À vous
L'exercice donne un programme bogué et l'outillage pour le diagnostiquer, plutôt que la correction.
Trois temps. Un débogueur miniature : vous posez un point d'arrêt, vous inspectez l'état à
chaque pas, et vous localisez l'instruction exacte où une variable prend une valeur aberrante —
c'est le watch du cours. Puis un rapport de type valgrind sur l'allocateur du chapitre 8,
qu'il faut apprendre à lire : nature de la faute, ligne fautive, ligne de l'allocation.
Enfin l'écriture d'un jeu de tests sur une fonction dont trois cas limites sont faux — vous les
trouverez en cherchant les bornes, pas en multipliant les cas ordinaires.
Localisez une faute au watch, lisez un rapport valgrind, puis écrivez un jeu de tests par les bornes.
// ── 1. Un débogueur miniature ───────────────────────────────────────────── // Chaque « instruction » est une fonction qui modifie l'état. Le débogueur // exécute pas à pas, affiche l'état, et surveille une variable. function executer(programme, surveillee) { const etat = {}; let precedente; programme.forEach((instruction, ligne) => { instruction.faire(etat); const v = etat[surveillee]; const change = v !== precedente; console.log(" ligne " + String(ligne + 1).padStart(2) + " " + instruction.texte.padEnd(30) + JSON.stringify(etat) + (change && precedente !== undefined ? " << " + surveillee + " a changé" : "")); precedente = v; }); return etat; } // Un calcul de moyenne, avec une faute quelque part. const PROGRAMME = [ { texte: "somme = 0", faire: (e) => { e.somme = 0; } }, { texte: "n = 0", faire: (e) => { e.n = 0; } }, { texte: "somme += 12", faire: (e) => { e.somme += 12; e.n++; } }, { texte: "somme += 15", faire: (e) => { e.somme += 15; e.n++; } }, { texte: "somme += 9", faire: (e) => { e.somme += 9; } }, // ← n oublié { texte: "moyenne = somme / n", faire: (e) => { e.moyenne = e.somme / e.n; } }, ]; // ── 2. Lire un rapport de type valgrind ─────────────────────────────────── const RAPPORT = [ "Invalid write of size 4", " at 0x109189: remplir (tri.c:23)", " Address 0x4a47054 is 0 bytes after a block of size 40 alloc'd", " at 0x484A899: malloc", " by 0x109165: main (tri.c:11)", "", "LEAK SUMMARY:", " definitely lost: 40 bytes in 1 blocks", " still reachable: 128 bytes in 2 blocks", ]; // ── 3. Un jeu de tests ──────────────────────────────────────────────────── let echecs = 0; function verifier(condition, description) { if (!condition) { console.log(" ÉCHEC : " + description); echecs++; } } // La fonction à tester : rend l'indice du maximum d'un tableau. function indiceDuMax(T, n) { let meilleur = 0; for (let i = 1; i < n; i++) if (T[i] > T[meilleur]) meilleur = i; return meilleur; } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Lancez executer() en surveillant « n » : à quelle ligne l'état // diverge-t-il de ce que vous attendiez ? C'est le watch de gdb. // 2. Lisez RAPPORT : quelle est la nature de la faute, où se produit-elle, // et où le bloc concerné a-t-il été alloué ? Quelle ligne corriger ? // 3. Écrivez des tests pour indiceDuMax : cherchez les BORNES (tableau d'un // élément, valeurs égales, tableau vide) plutôt que des cas ordinaires. console.log("— exécution pas à pas, surveillance de « n » —"); executer(PROGRAMME, "n");
En travaux pratiques
Trouver le bogue, pas le deviner
Remplacer la relecture et les affichages par une méthode : reproduire, réduire, localiser — avec les outils qui font le travail à votre place.
- Le fil rouge journal, complet
- gdb, valgrind, les détecteurs, et de quoi écrire des tests
- 1. Un programme sabotté
Échangez votre fil rouge avec un binôme après y avoir introduit trois bogues : un plantage, un résultat faux, une fuite. Chacun doit trouver les trois de l'autre, en chronométrant.
- 2. Reproduire d'abord
Pour chaque bogue trouvé, écrivez la plus petite entrée qui le déclenche. Réduisez-la jusqu'à ce qu'enlever une ligne fasse disparaître le problème.
- 3. Le débogueur
Placez un point d'arrêt conditionnel qui ne se déclenche qu'au tour de boucle fautif. Inspectez les variables, remontez la pile, et identifiez l'appelant responsable.
- 4. Le point d'observation
Utilisez un point d'observation sur une variable qui change sans raison apparente. Notez la ligne exacte qui l'a modifiée.
- 5. Les assertions
Ajoutez des assertions aux entrées et sorties de vos fonctions, exprimant les contrats écrits au TP 4. Vérifiez qu'une assertion se déclenche sur une entrée invalide, puis compilez avec NDEBUG.
- 6. Les tests
Écrivez une dizaine de tests couvrant les cas limites : fichier vide, une seule ligne, ligne tronquée, très longue ligne, caractères accentués. Faites-les tourner par make test.
- 7. Le filet complet
Faites passer tout le fil rouge sous les trois outils, avec les avertissements en erreurs. Corrigez jusqu'à ce que tout soit propre.
- 8. Le bogue par recherche
Introduisez un bogue plusieurs commits en arrière dans votre dépôt, puis retrouvez le commit fautif par recherche dichotomique automatisée.
- Vous trouvez les trois bogues de votre binôme, et vous savez lequel a été le plus long
- Votre entrée réduite tient en trois lignes
- make test passe, et échoue si vous cassez une seule fonction
- Compilation propre sous -Wall -Wextra -Werror et les trois outils
Ce que ce cours laisse
Dix chapitres plus tôt, la question était de savoir écrire, compiler et déboguer en C. Le parcours a été le suivant.
Les trois premiers blocs ont transposé en C ce qu'Algorithmique 1 avait posé : types, contrôle, fonctions, tableaux. Rien de conceptuellement neuf, mais un compilateur exigeant et un langage qui ne pardonne pas. Le bloc IV a introduit la seule notion réellement nouvelle — l'adresse, la durée de vie, la propriété d'un bloc — et c'est elle qui justifie d'enseigner le C. Les blocs V et VI ont donné la forme et l'outillage.
Ce qu'il faut en garder tient peut-être en une phrase. En C, tout ce que le langage ne vérifie pas, c'est vous qui devez le vérifier : les bornes d'un tableau, la validité d'un pointeur, la libération d'un bloc, le retour d'une lecture. Ce n'est pas une faiblesse du langage, c'est son contrat — il vous donne la machine du cours d'architecture, telle quelle, et ne s'interpose pas.
C'est aussi ce qui rend intelligible ce que les autres langages font à votre place. Un
ramasse-miettes, une vérification de bornes, un type string : après ce semestre, ce ne sont
plus des évidences invisibles mais des services dont vous savez le prix — et ce que l'on paie
quand on choisit de s'en passer.
À retenir
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.