Programmation en C · C1 Prendre en main le langage · Chapitre 1 · 5 h
De la source à l'exécutable
Préprocesseur, compilation, édition de liens ; structure d'un programme et rôle de main ; lire un message d'erreur du compilateur.
Un débutant tape gcc hello.c, obtient undefined reference to printf, et cherche l'erreur
dans sa syntaxe. Il ne la trouvera pas : ce message ne vient pas du compilateur. Il vient de
l'éditeur de liens, un programme différent, appelé bien après, et qui reproche autre chose.
C'est le premier obstacle du cours, et il n'est pas conceptuel : gcc est un pilote qui
enchaîne quatre programmes distincts, chacun avec ses erreurs propres. Savoir lequel a
parlé, c'est déjà avoir résolu la moitié du problème.
Pourquoi le C
Le choix demande d'être justifié, car le C n'est ni le plus accueillant ni le plus productif des langages.
Il est le complément direct de l'architecture : un pointeur est une adresse au sens du
chapitre 5, un char est un octet, un décalage de bits est l'instruction du chapitre 6. Ce
que ce cours-là décrivait de l'extérieur devient ici manipulable.
Il est le langage des systèmes : le noyau Linux, les bibliothèques standard, les interpréteurs de Python et de Java sont écrits en C, et tous les appels système du cours de systèmes se présentent comme des fonctions C.
Il est enfin un contrepoint utile à Java en programmation orientée objet. Avoir géré la mémoire à la main rend le ramasse-miettes intelligible au lieu de le rendre invisible — on sait ce qu'il fait, parce qu'on l'a fait soi-même.
Il vient avec une contrepartie qu'il vaut mieux annoncer : le C ne protège de rien. Pas de vérification des bornes, pas de gestion automatique de la mémoire, et des comportements dits indéfinis où le programme peut faire n'importe quoi — y compris fonctionner. C'est le prix de la transparence, et c'est aussi ce qui rend le bloc VI indispensable.
Les quatre étages
hello.c │ ① préprocesseur cpp gcc -E hello.c ▼hello.i source pur C, sans directive, avec les en-têtes recopiés │ ② compilation cc1 gcc -S hello.i ▼hello.s assembleur — celui du chapitre 6 d'architecture │ ③ assemblage as gcc -c hello.s ▼hello.o fichier objet : code machine, mais adresses non résolues │ ④ édition de liens ld gcc hello.o -o hello ▼hello exécutableLe préprocesseur ne comprend pas le C : il fait de la manipulation de texte. Il recopie le
contenu des fichiers désignés par #include, remplace les #define, et supprime les portions
écartées par #ifdef. Un #include n'est pas un import : c'est un copier-coller, et c'est
ce qui explique la taille du fichier .i — plusieurs centaines de kilooctets pour un
hello.c de cinq lignes.
Le compilateur traduit le C en assembleur. C'est lui qui signale les erreurs de syntaxe et de typage, et lui seul travaille un fichier à la fois : il ne sait rien des autres fichiers du projet.
L'assembleur traduit l'assembleur en code machine. Il produit un fichier objet dont les appels à des fonctions extérieures sont laissés en blanc, sous forme de symboles non résolus.
L'éditeur de liens rassemble les fichiers objets et les bibliothèques, et remplit ces blancs. Il ne connaît que des noms de symboles : il ignore le C, les types et les lignes de code.
D'où le diagnostic annoncé en ouverture. undefined reference to truc signifie : « quelqu'un
appelle truc, et je ne trouve nulle part sa définition ». Trois causes possibles, et aucune
n'est une faute de syntaxe — le fichier qui définit truc n'a pas été passé à la compilation,
la bibliothèque manque (-lm pour les fonctions mathématiques), ou la fonction est déclarée
sans être définie.
Un premier programme
#include <stdio.h> int main(void) { printf("Bonjour\n"); return 0;}Quatre remarques, une par ligne.
#include <stdio.h> recopie les déclarations des fonctions d'entrée/sortie, dont printf.
L'en-tête ne contient pas le code de printf — seulement sa signature, pour que le compilateur
sache la vérifier. Le code, lui, est dans la bibliothèque C, et c'est l'éditeur de liens qui
l'y trouvera. Les chevrons désignent les en-têtes du système ; les guillemets, ceux du projet.
int main(void) est le point d'entrée. Le système lance toujours cette fonction-là, et son
type de retour n'est pas décoratif : c'est le code de retour du chapitre 2 du cours de
systèmes, celui que le shell teste dans un if.
printf écrit sur la sortie standard — le descripteur 1. Le \n n'est pas de la décoration :
sans lui, la sortie reste dans le tampon dont parlait le chapitre 8 du même cours, et peut ne
jamais apparaître si le programme plante.
return 0 signifie « tout s'est bien passé ». Toute autre valeur signale une erreur, et c'est
la convention que respecte l'ensemble du monde Unix.
Quiz · 1 question
Un projet compile sans erreur avec gcc -c sur chaque fichier, mais l'édition de liens échoue sur « undefined reference to calculer ». Que peut-on en déduire ?
- La fonction calculer contient une erreur de syntaxe que seul l'éditeur de liens détecte — erreur de syntaxe
- La fonction est DÉCLARÉE quelque part — sinon le compilateur aurait protesté — mais sa DÉFINITION n'est fournie à personne : fichier objet oublié à l'édition de liens, bibliothèque manquante, ou fonction jamais écrite — définition absente
- Il manque un #include dans le fichier appelant — include manquant
Réponse : La clé est de savoir QUI parle. Le compilateur a validé chaque fichier isolément : la syntaxe et les types sont bons, et il a donc vu une déclaration de calculer — sans quoi il aurait signalé un identifiant inconnu. L'éditeur de liens, lui, ne connaît ni C ni syntaxe : il rassemble des symboles et remplit les blancs. Son message dit exactement « quelqu'un appelle ce nom, je n'en trouve pas la définition ». Trois causes : le .o contenant la définition n'a pas été passé à la ligne de commande, la bibliothèque manque (le classique -lm pour sqrt), ou la fonction a été déclarée dans un en-tête mais jamais écrite. Un #include manquant produirait au contraire une erreur du COMPILATEUR, en amont.
Lire un message d'erreur
C'est la compétence la plus rentable du bloc I, et elle s'apprend par trois règles.
Toujours commencer par la première erreur. Le compilateur, une fois désorienté, en produit souvent des dizaines de fantômes. Un point-virgule oublié ligne 12 peut engendrer quarante messages sur les lignes 13 à 50, qui disparaissent tous en corrigeant le premier.
Lire le numéro de ligne comme un indice, pas comme une adresse. L'erreur est signalée là où le compilateur s'est aperçu du problème, souvent une ligne après sa cause — c'est typiquement le cas du point-virgule manquant.
Distinguer erreur et avertissement. Une erreur arrête la compilation. Un avertissement la laisse passer, et signale un code légal mais suspect : conversion qui perd de l'information, variable utilisée sans être initialisée, comparaison entre signé et non signé. Ce sont souvent de vrais bogues.
D'où la ligne de commande à prendre comme habitude dès le premier TP :
gcc -Wall -Wextra -g -o programme source.c-Wall -Wextra activent les avertissements — le compilateur en tait beaucoup par défaut, pour
des raisons de compatibilité historique. -g conserve les informations de débogage, sans
lesquelles gdb du chapitre 10 ne pourra pas afficher vos noms de variables. Et l'on prend
l'habitude de traiter tout avertissement comme une erreur : c'est plus rapide que de le
diagnostiquer trois heures plus tard au débogueur.
Quiz · 1 question
Pourquoi le fichier produit par gcc -E sur un hello.c de cinq lignes fait-il plusieurs centaines de kilooctets ?
- Parce que le préprocesseur y ajoute le code machine des fonctions utilisées — code machine ajouté
- Parce que #include est un COPIER-COLLER de texte : tout le contenu de stdio.h, et de tous les en-têtes qu'il inclut à son tour, se retrouve dans le fichier — copier-coller récursif
- Parce que le fichier est encodé en un format intermédiaire plus verbeux — format verbeux
Réponse : Le préprocesseur ne comprend pas le C : il fait de la manipulation de texte, et #include signifie littéralement « recopie ici le contenu de ce fichier ». Or stdio.h inclut lui-même d'autres en-têtes, qui en incluent d'autres : le résultat est l'union de plusieurs dizaines de fichiers de déclarations. Ce ne sont QUE des déclarations — les signatures des fonctions, des types, des macros — et pas une ligne de code exécutable : le code de printf vit dans la bibliothèque C compilée, et c'est l'éditeur de liens qui l'y trouvera. C'est aussi ce qui explique la lenteur de compilation des gros projets C, et l'existence des gardes d'inclusion qui empêchent qu'un même en-tête soit recopié dix fois.
À vous
Le C ne s'exécute pas dans un navigateur : l'exercice simule donc la chaîne de compilation en JavaScript, ce qui a l'avantage de rendre visible ce que chaque étage fait — et surtout ce qu'il refuse.
Vous écrirez un préprocesseur qui traite #define et #include, puis un éditeur de liens qui
collecte les symboles définis et les symboles appelés, et signale les non résolus. Le jeu de
tests contient les trois erreurs classiques : un symbole jamais défini, une définition présente
dans un fichier qu'on a oublié de passer, et une double définition.
Exercice de code
Simulez le préprocesseur et l'éditeur de liens, puis provoquez les deux erreurs classiques.
Point de départ
// ── Les fichiers du projet ────────────────────────────────────────────────
const FICHIERS = {
"calcul.h": [
"#define PI 3.14159",
"int aire(int r);", // déclaration seule
"int perimetre(int r);", // déclarée, jamais définie : piège
].join("\n"),
"calcul.c": [
'#include "calcul.h"',
"int aire(int r) { return PI * r * r; }",
].join("\n"),
"main.c": [
'#include "calcul.h"',
"int main(void) {",
" int a = aire(3);",
" int p = perimetre(3);", // appelée, jamais définie
" return a + p;",
"}",
].join("\n"),
};
// ── ① Préprocesseur ───────────────────────────────────────────────────────
// Manipulation de TEXTE : recopier les #include, remplacer les #define.
function preprocesseur(nom, vus = new Set()) {
if (vus.has(nom)) return ""; // garde d'inclusion
vus.add(nom);
const defines = {};
const sortie = [];
for (const ligne of FICHIERS[nom].split("\n")) {
const inc = /^#include "(.+)"$/.exec(ligne.trim());
if (inc) { sortie.push(preprocesseur(inc[1], vus)); continue; }
const def = /^#define (\w+) (.+)$/.exec(ligne.trim());
if (def) { defines[def[1]] = def[2]; continue; }
// ← à écrire : remplacer chaque macro connue par sa valeur
sortie.push(ligne);
}
return sortie.join("\n");
}
// ── ② Compilation : un fichier à la fois ──────────────────────────────────
// On récolte les symboles DÉFINIS et les symboles APPELÉS.
function compiler(source) {
const definis = [...source.matchAll(/^(?:int|void) (\w+)\s*\([^)]*\)\s*\{/gm)].map((m) => m[1]);
const appeles = [...source.matchAll(/(\w+)\s*\(/g)].map((m) => m[1])
.filter((n) => !["int", "void", "if", "while", "for", "return"].includes(n));
return { definis, appeles: [...new Set(appeles)].filter((n) => !definis.includes(n)) };
}
// ── ④ Édition de liens ────────────────────────────────────────────────────
function editerLiens(objets) {
const erreurs = [];
// ← à écrire : rassembler tous les symboles définis, puis vérifier que
// chaque symbole appelé en trouve un. Signaler aussi les doublons.
return erreurs;
}
// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Faites remplacer les macros par le préprocesseur (PI doit disparaître).
// 2. Écrivez editerLiens, avec les DEUX erreurs : symbole non résolu et
// définition multiple.
// 3. Compilez main.c SEUL, puis main.c + calcul.c. Quelle erreur reste ?
console.log("— calcul.c après préprocesseur —");
console.log(preprocesseur("calcul.c"));
Solution
const FICHIERS = {
"calcul.h": ["#define PI 3.14159", "int aire(int r);", "int perimetre(int r);"].join("\n"),
"calcul.c": ['#include "calcul.h"', "int aire(int r) { return PI * r * r; }"].join("\n"),
"main.c": ['#include "calcul.h"', "int main(void) {", " int a = aire(3);",
" int p = perimetre(3);", " return a + p;", "}"].join("\n"),
"geo.c": ['#include "calcul.h"', "int aire(int r) { return 3 * r * r; }"].join("\n"),
};
function preprocesseur(nom, vus = new Set()) {
if (vus.has(nom)) return "";
vus.add(nom);
const defines = {};
const sortie = [];
for (const ligne of FICHIERS[nom].split("\n")) {
const inc = /^#include "(.+)"$/.exec(ligne.trim());
if (inc) { sortie.push(preprocesseur(inc[1], vus)); continue; }
const def = /^#define (\w+) (.+)$/.exec(ligne.trim());
if (def) { defines[def[1]] = def[2]; continue; }
// Substitution textuelle pure : le préprocesseur ne sait pas ce qu'est
// une variable, il remplace un motif par un autre. D'où les surprises
// classiques des macros mal parenthésées.
let l = ligne;
for (const [nomMacro, valeur] of Object.entries(defines)) {
l = l.replace(new RegExp("\\b" + nomMacro + "\\b", "g"), valeur);
}
sortie.push(l);
}
return sortie.filter((x) => x !== "").join("\n");
}
function compiler(source) {
const definis = [...source.matchAll(/^(?:int|void) (\w+)\s*\([^)]*\)\s*\{/gm)].map((m) => m[1]);
const appeles = [...source.matchAll(/(\w+)\s*\(/g)].map((m) => m[1])
.filter((n) => !["int", "void", "if", "while", "for", "return"].includes(n));
return { definis, appeles: [...new Set(appeles)].filter((n) => !definis.includes(n)) };
}
function editerLiens(objets) {
const erreurs = [];
const table = {};
// Une seule passe pour collecter, une seconde pour résoudre : l'éditeur
// de liens ne connaît que des NOMS, ni types ni lignes de code.
for (const o of objets) {
for (const d of o.definis) {
if (table[d]) erreurs.push("multiple definition of " + d +
" (" + table[d] + " et " + o.nom + ")");
else table[d] = o.nom;
}
}
for (const o of objets) {
for (const a of o.appeles) {
if (!table[a]) erreurs.push("undefined reference to " + a + " (appelé depuis " + o.nom + ")");
}
}
return erreurs;
}
function construire(noms) {
const objets = noms.map((n) => ({ nom: n.replace(".c", ".o"), ...compiler(preprocesseur(n)) }));
console.log(" objets : " + objets.map((o) =>
o.nom + " définit [" + o.definis.join(",") + "] appelle [" + o.appeles.join(",") + "]").join(" | "));
const erreurs = editerLiens(objets);
if (erreurs.length === 0) console.log(" édition de liens : ok");
else for (const e of erreurs) console.log(" ld: " + e);
}
console.log("— calcul.c après préprocesseur (PI a disparu) —");
console.log(preprocesseur("calcul.c"));
console.log("");
console.log("— gcc -c main.c puis ld main.o —");
construire(["main.c"]);
// Deux symboles manquent : aire, dont la définition est dans un fichier
// qu'on n'a pas passé, et perimetre, qui n'existe nulle part.
console.log("");
console.log("— gcc main.c calcul.c —");
construire(["main.c", "calcul.c"]);
// aire est résolu. perimetre reste introuvable : il était DÉCLARÉ dans
// l'en-tête, donc le compilateur n'a rien eu à redire — c'est bien
// l'éditeur de liens qui s'en aperçoit, et lui seul.
console.log("");
console.log("— gcc main.c calcul.c geo.c —");
construire(["main.c", "calcul.c", "geo.c"]);
// Et l'erreur symétrique : deux fichiers définissent aire.
En travaux pratiques
Travaux pratiques 1 · 2 h
Ouvrir la chaîne de compilation
Exécuter séparément les quatre étapes qu'une seule commande cache, puis poser le squelette du programme qui grandira pendant tout le semestre.
Avant de commencer
- gcc et make installés
- Un éditeur de texte, et un terminal
Énoncé
- Les quatre étapes, une par une — À partir d'un programme d'une ligne, produisez successivement le fichier prétraité, l'assembleur, l'objet, puis l'exécutable. Regardez la taille de chacun. Indice : Les options -E, -S, -c arrêtent gcc à chaque étape.
- Ce que fait le préprocesseur — Comptez les lignes de votre source, puis celles du fichier prétraité. Trouvez d'où viennent les autres, et cherchez-y la déclaration de printf.
- Activer les avertissements — Écrivez un programme qui utilise une variable non initialisée et oublie une valeur de retour. Compilez sans option, puis avec -Wall -Wextra. Comparez.
- Séparer en deux fichiers — Créez le fil rouge du semestre : un fichier principal et un module de comptage, avec son en-tête. Compilez les deux séparément puis liez-les.
- L'erreur de l'éditeur de liens — Déclarez une fonction dans l'en-tête sans l'écrire. Compilez chaque fichier — ça passe. Liez — ça échoue. Expliquez la différence entre les deux messages.
- Le Makefile — Écrivez un Makefile qui reconstruit uniquement ce qui a changé. Modifiez un seul fichier et vérifiez que l'autre n'est pas recompilé.
C'est réussi quand
- Vous nommez les quatre étapes et ce qui entre et sort de chacune
- -Wall -Wextra vous signale au moins deux problèmes que la compilation muette laissait passer
- make ne recompile que le fichier modifié
- Vous distinguez une erreur de compilation d'une erreur d'édition de liens
Correction
gcc -E bonjour.c -o bonjour.i # préprocesseur : 800 lignes gcc -S bonjour.i -o bonjour.s # compilation : assembleur gcc -c bonjour.s -o bonjour.o # assemblage : code machine gcc bonjour.o -o bonjour # édition de liens : exécutable bonjour.c : 120 octets bonjour.i : 26 Ko ← stdio.h a été recopié en entier bonjour.o : 1,5 Ko bonjour : 16 Ko
Une seule commande cache quatre programmes différents. Savoir lequel a échoué divise par dix le temps de diagnostic : une erreur de macro vient du premier, une erreur de syntaxe du deuxième, un symbole introuvable du quatrième.
grep -n "printf" bonjour.i extern int printf (const char *__restrict __format, ...); #include ne « charge » rien : il RECOPIE le fichier à cet endroit
L'en-tête ne contient que des DÉCLARATIONS — le nom, les types, rien du corps. Le code de printf est ailleurs, dans la bibliothèque, et n'est raccroché qu'à l'édition de liens. C'est cette séparation qui permet de compiler un fichier sans posséder le code des autres, et c'est elle qui explique l'étape 5.
int main(void) {
int x;
printf("%d\n", x + 1); /* x non initialisé */
}
gcc essai.c → aucun message. Compile.
gcc -Wall -Wextra essai.c
warning: 'x' is used uninitialized
warning: control reaches end of non-void functionPar défaut, le compilateur en dit le minimum. -Wall -Wextra ne sont pas des options pour perfectionnistes : ce sont les seules qui rendent le C utilisable. La discipline à prendre dès aujourd'hui, c'est -Wall -Wextra -Werror — le dernier transformant tout avertissement en erreur, ce qui empêche de les accumuler.
/* journal.h — les DÉCLARATIONS */
#ifndef JOURNAL_H
#define JOURNAL_H
int compter_lignes(const char *chemin);
#endif
/* journal.c — les DÉFINITIONS */
#include "journal.h"
int compter_lignes(const char *chemin) { … }
/* main.c */
#include "journal.h"
int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "usage: journal FICHIER\n"); return 1; }
printf("%d lignes\n", compter_lignes(argv[1]));
return 0;
}Les gardes d'inclusion empêchent qu'un en-tête inclus deux fois ne déclare deux fois la même chose. C'est le premier réflexe de tout en-tête C, sans exception. Ce squelette est le point de départ : chaque TP du semestre lui ajoutera une capacité.
# compilation : le fichier COURANT est incohérent journal.c:12: error: 'compter' undeclared # édition de liens : tous les fichiers compilent, rien ne les relie /usr/bin/ld: undefined reference to 'compter_lignes'
Le compilateur ne voit qu'un fichier à la fois : une fonction DÉCLARÉE lui suffit, il fait confiance. L'éditeur de liens, lui, voit tout et exige que chaque promesse soit tenue. « undefined reference » signifie donc toujours : déclarée, jamais définie, ou définie dans un fichier que vous avez oublié de lier.
CC = gcc CFLAGS = -Wall -Wextra -std=c17 -g journal: main.o journal.o $(CC) $^ -o $@ main.o: main.c journal.h journal.o: journal.c journal.h clean: rm -f *.o journal
make compare les DATES : une cible plus ancienne que ses dépendances est reconstruite, les autres non. C'est pourquoi journal.h figure dans les dépendances — modifier l'en-tête doit tout recompiler, et l'oublier produit les incohérences les plus déroutantes qui soient, où le programme exécuté ne correspond plus au code lu.
Ce que la suite en fait
Le chapitre 2 remplit le programme : types, variables, opérateurs, et les entrées/sorties
formatées dont printf n'est que le représentant le plus visible. On y verra que la taille
d'un type n'est pas une abstraction mais un nombre d'octets — celui du chapitre 2
d'architecture.
La chaîne de compilation reviendra au chapitre 4, quand le projet cessera de tenir dans un seul fichier : les en-têtes, la compilation séparée et le Makefile sont exactement la gestion de ce que ce chapitre vient de décrire. Et le chapitre 10 y ajoutera les outils qui inspectent le résultat.
À retenir
Flashcards · 4 cartes
- Quels sont les quatre étages de la chaîne de compilation, et que produit chacun ?
- 1) PRÉPROCESSEUR : manipulation de texte — recopie les #include, remplace les #define, écarte les #ifdef → fichier .i. 2) COMPILATEUR : traduit le C en assembleur, signale syntaxe et typage, travaille UN FICHIER À LA FOIS → .s. 3) ASSEMBLEUR : produit du code machine avec des symboles non résolus → .o. 4) ÉDITEUR DE LIENS : rassemble les .o et les bibliothèques, remplit les blancs — il ne connaît que des NOMS → exécutable.
- Que signifie « undefined reference to truc », et qui parle ?
- C'est l'ÉDITEUR DE LIENS, pas le compilateur : ce n'est donc jamais une erreur de syntaxe. Il dit « quelqu'un appelle ce nom, je n'en trouve pas la définition ». Trois causes : le fichier objet contenant la définition n'a pas été passé à la ligne de commande, une bibliothèque manque (-lm pour les fonctions mathématiques), ou la fonction est déclarée dans un en-tête sans être écrite.
- Que fait exactement #include, et pourquoi le fichier prétraité est-il énorme ?
- C'est un COPIER-COLLER de texte, pas un import : le contenu du fichier désigné est recopié à cet endroit, et ce fichier en inclut d'autres à son tour. D'où plusieurs centaines de kilooctets pour un hello.c de cinq lignes. Ce ne sont QUE des déclarations — signatures, types, macros — et pas une ligne exécutable : le code de printf est dans la bibliothèque compilée. Chevrons pour les en-têtes du système, guillemets pour ceux du projet.
- Quelles sont les trois règles de lecture d'un message d'erreur, et quelle ligne de commande adopter ?
- 1) Toujours corriger la PREMIÈRE erreur : un point-virgule oublié peut engendrer quarante messages fantômes. 2) Le numéro de ligne est un indice, pas une adresse : l'erreur est signalée là où le compilateur s'en aperçoit, souvent une ligne après sa cause. 3) Distinguer erreur (arrête) et AVERTISSEMENT (laisse passer un code légal mais suspect — souvent un vrai bogue). Habitude : gcc -Wall -Wextra -g, et traiter tout avertissement comme une erreur.