Chapitre 1 · 6 h
Anatomie d'un compilateur
Compilation contre interprétation ; les phases d'analyse et de synthèse ; table des symboles et gestion des erreurs comme services transversaux ; front-end, back-end et l'intérêt de les séparer.
Vous savez déjà, depuis la Théorie des langages, reconnaître un langage avec un automate, l'engendrer avec une grammaire, et découper un texte en unités lexicales. Ce cours répond à la question qui suit naturellement : une fois le texte reconnu, comment le traduit-on en un programme qui s'exécute ?
Un compilateur est l'un des plus beaux objets du génie logiciel — une machine qui lit un langage et en écrit un autre, en préservant le sens. Ce premier chapitre en donne la carte : les phases, leur ordre, ce que chacune reçoit et produit. Chaque bloc du cours en remplira ensuite une case, et le TP du semestre les assemblera toutes dans un compilateur unique pour un mini-langage impératif.
Compiler ou interpréter
Il y a deux façons d'exécuter un programme écrit dans un langage de haut niveau.
Un compilateur traduit le programme entier vers un autre langage (souvent le code machine) une fois pour toutes ; le résultat s'exécute ensuite seul, sans le compilateur. Un interpréteur exécute le programme directement, instruction par instruction, sans produire de traduction autonome.
| Compilation | Interprétation | |
|---|---|---|
| Moment de l'analyse | une fois, avant exécution | à chaque exécution |
| Vitesse d'exécution | rapide (code natif) | plus lente (surcoût permanent) |
| Souplesse | figée à la compilation | dynamique, exécution directe |
| Exemples | C, Rust, Go | Python, Ruby (historiquement) |
La frontière est floue en pratique : Java compile vers un bytecode, ensuite interprété (ou recompilé à la volée) par une machine virtuelle. C'est d'ailleurs la cible que visera le TP — produire du code pour une petite machine virtuelle est plus simple que viser un vrai processeur, et n'enlève rien à la démarche. Ce cours porte sur la compilation, mais l'essentiel de ses phases (tout le front-end, plus bas) est commun aux deux mondes : un interpréteur analyse le programme exactement comme un compilateur, il diffère seulement dans ce qu'il fait de l'arbre à la fin.
Analyse et synthèse
Un compilateur se divise en deux grands mouvements.
L'analyse (ou front-end) décompose le programme source pour en extraire la structure et le sens : elle lit du texte et en construit une représentation interne. Si le programme est incorrect, c'est l'analyse qui le refuse, avec des messages d'erreur.
La synthèse (ou back-end) construit le programme cible à partir de cette représentation : elle produit du code, et l'optimise.
Cette césure n'est pas cosmétique — c'est l'idée d'ingénierie centrale du chapitre, on y revient plus bas. Retenez déjà la forme générale : on remonte du texte vers une représentation abstraite, puis on redescend de cette abstraction vers du code concret.
Le schéma complet
Voici la chaîne des phases, dans l'ordre. C'est le plan du cours ; gardez-le sous les yeux.
programme source (texte) │ ┌────────▼─────────┐ │ analyse lexicale │ texte → suite de lexèmes (bloc II) └────────┬─────────┘ ┌────────▼──────────┐ │ analyse syntaxique│ lexèmes → arbre syntaxique (bloc III) └────────┬──────────┘ ┌────────▼──────────┐ │ analyse sémantique│ arbre → arbre vérifié et typé (bloc IV) └────────┬──────────┘ ┌────────▼──────────────┐ │ code intermédiaire │ arbre → représentation linéaire (bloc V) └────────┬──────────────┘ ┌────────▼──────────┐ │ optimisation │ code → code équivalent, meilleur (bloc VI) └────────┬──────────┘ ┌────────▼──────────┐ │ génération de code │ → code cible (bloc VI) └────────┬──────────┘ ▼ programme cibleChaque phase consomme la sortie de la précédente : l'analyse syntaxique ne voit jamais le texte, seulement les lexèmes ; l'analyse sémantique ne voit que l'arbre. C'est ce qui rend l'ensemble modulaire — et ce qui explique quelle erreur est détectée où. Une addition entre un entier et une chaîne est bien formée lexicalement et syntaxiquement ; seule l'analyse sémantique, qui raisonne sur le sens, peut la refuser.
Deux services transversaux
Deux fonctions ne sont pas des phases : elles accompagnent toutes les phases, du début à la fin.
La table des symboles enregistre tout ce qu'on sait des identificateurs du programme — nom, type, portée, emplacement mémoire. Elle est remplie pendant l'analyse (déclarations) et consultée jusqu'à la génération de code. C'est la mémoire partagée du compilateur, et le chapitre 6 lui est consacré.
La gestion des erreurs doit détecter les fautes, les rattacher à la bonne phase et au bon endroit du source, et si possible poursuivre l'analyse pour signaler plusieurs erreurs d'un coup plutôt que de s'arrêter à la première. La qualité des messages d'erreur est, pour l'utilisateur, la moitié de la valeur d'un compilateur.
Quiz · 1 question
Le programme « y = 3 + 'bonjour' » (additionner un entier et une chaîne) est refusé par le compilateur. Quelle phase le détecte, et pourquoi pas plus tôt ?
- L'analyse lexicale, car elle lit les caractères en premier — premier contact avec le texte
- L'analyse sémantique : la phrase est bien formée lexicalement et syntaxiquement, mais elle n'a pas de sens — c'est une incompatibilité de types — bien formé mais dénué de sens
- L'analyse syntaxique, car l'addition est une règle de grammaire — règle grammaticale
Réponse : « 3 », « + » et « 'bonjour' » sont des lexèmes parfaitement valides (rien à redire au lexical), et « expression + expression » respecte la grammaire (rien à redire au syntaxique). Le problème est le SENS : additionner un entier et une chaîne n'est pas défini. Seule l'analyse sémantique, qui connaît les types (via la table des symboles), peut le voir. Les phases forment un filtre : on ne vérifie le sens que d'un texte déjà bien formé.
Front-end, back-end : pourquoi séparer
Reprenons la césure analyse/synthèse, car c'est l'idée qui structure toute l'industrie du compilateur.
Le front-end dépend du langage source : il sait lire du C, ou du Java, ou votre mini-langage. Le back-end dépend de la machine cible : il sait produire du code x86, ou ARM, ou du bytecode. Entre les deux, une représentation intermédiaire (bloc V) sert de pivot : le front-end y aboutit, le back-end en part.
L'intérêt est combinatoire. Avec langages et machines, une approche monolithique demanderait compilateurs distincts. En passant par un pivot commun, il suffit de front-ends et back-ends, soit composants — chaque nouveau langage réutilise tous les back-ends existants, et chaque nouvelle machine, tous les front-ends. C'est exactement l'architecture de projets comme LLVM ou GCC : un front-end par langage, un back-end par processeur, une représentation intermédiaire au centre. La séparation n'est pas une élégance théorique, c'est ce qui rend l'écosystème viable.
Quiz · 1 question
Une entreprise veut compiler 4 langages vers 5 architectures de processeurs. Combien de composants faut-il avec une représentation intermédiaire commune, contre une approche monolithique ?
- 20 dans les deux cas : chaque paire langage-machine est indépendante — produit dans les deux cas
- 9 avec un pivot (4 front-ends + 5 back-ends) contre 20 sans (4 × 5 compilateurs complets) — somme contre produit
- 20 avec un pivot, 9 sans — inversé
Réponse : Sans pivot, chaque couple (langage, machine) exige un compilateur complet : 4 × 5 = 20. Avec une représentation intermédiaire commune, on écrit un front-end par langage (4) et un back-end par machine (5), soit 4 + 5 = 9 composants — et tout front-end se combine avec tout back-end. On passe d'un produit à une somme : c'est le gain qui justifie la séparation front-end / back-end, et l'architecture de LLVM ou GCC.
À vous
L'exercice met à l'épreuve la compréhension du pipeline, qui est tout l'objet de ce chapitre : ranger chaque erreur dans la phase qui la détecte. Vous verrez pourquoi l'analyseur lexical ne peut pas repérer une erreur de type, pourquoi une parenthèse manquante relève du syntaxique, et pourquoi les phases forment un filtre en cascade.
Savoir où une erreur est attrapée, c'est déjà savoir dans quelle partie du compilateur — et du TP — elle se corrige.
Exercice de code
Rangez chaque erreur dans la phase du compilateur qui la détecte : lexicale, syntaxique ou sémantique. Comprenez pourquoi l'analyseur lexical ne peut pas voir une erreur de type, et pourquoi les phases forment un filtre en cascade.
Point de départ
// Un compilateur est une CHAÎNE de phases. Chacune reçoit la sortie de la
// précédente et ne détecte que les erreurs de son niveau :
//
// texte --[lexical]--> lexèmes --[syntaxique]--> arbre --[sémantique]--> arbre typé
//
// - lexicale : un caractère ou un lexème impossible (ex. « @ », « 12abc »)
// - syntaxique : des lexèmes valides mal AGENCÉS (ex. « x = ; », parenthèse)
// - sémantique : une phrase bien formée mais qui n'a pas de SENS
// (ex. additionner un entier et une chaîne, variable inconnue)
//
// À VOUS : complétez classer() pour ranger chaque erreur dans sa phase.
const CAS = [
{ src: "x = 12abc + 1", attendu: "lexicale" }, // 12abc : nombre mal formé
{ src: "x = (3 + 4", attendu: "syntaxique" }, // parenthèse jamais fermée
{ src: "y = 3 + \"bonjour\"", attendu: "semantique" }, // entier + chaîne
{ src: "z = @val", attendu: "lexicale" }, // @ : caractère interdit
{ src: "if x > 0 then", attendu: "syntaxique" }, // then sans bloc, forme cassée
{ src: "w = variableInconnue", attendu: "semantique" }, // identifiant non déclaré
];
function classer(src) {
// Indices à repérer (dans l'ordre lexical -> syntaxique -> sémantique) :
// - un caractère hors alphabet, ou un chiffre collé à des lettres -> lexicale
// - parenthèse non fermée, ou une construction incomplète -> syntaxique
// - type incompatible, ou identifiant inconnu -> sémantique
return "?"; // à compléter
}
for (const c of CAS) {
const r = classer(c.src);
console.log((r === c.attendu ? " ok " : " ✗ ") + c.attendu.padEnd(11) + " | " + c.src);
}
Solution
function classer(src) {
// 1. Lexical : un caractère hors alphabet, ou un lexème impossible.
if (/@/.test(src)) return "lexicale";
if (/\b\d+[a-zA-Z]/.test(src)) return "lexicale"; // 12abc
// 2. Syntaxique : lexèmes valides, mais mal agencés.
// parenthèses déséquilibrées, ou 'then' sans suite exploitable.
const ouv = (src.match(/\(/g) || []).length;
const fer = (src.match(/\)/g) || []).length;
if (ouv !== fer) return "syntaxique";
if (/\bthen\s*$/.test(src)) return "syntaxique";
// 3. Sémantique : bien formé, mais sans sens.
return "semantique";
}
// ── Ce que l'exercice enseigne ──────────────────────────────────────────────
//
// 1. Chaque phase ne voit que SON niveau. L'analyseur lexical ne connaît que
// les lexèmes : il ne peut PAS savoir qu'on additionne un entier et une
// chaîne (c'est du sens, donc sémantique), ni qu'une parenthèse manque
// (c'est de l'agencement, donc syntaxique). « 12abc » l'arrête, parce que
// ce n'est ni un nombre ni un identifiant valide.
//
// 2. L'ordre est un FILTRE : un texte qui passe le lexical peut échouer au
// syntaxique, qui peut échouer au sémantique. On ne vérifie le sens que
// d'un arbre déjà bien formé — inutile de typer une phrase qui n'analyse
// même pas.
//
// 3. C'est pourquoi la table des symboles et la gestion des erreurs sont des
// services TRANSVERSAUX : la première est remplie au sémantique mais
// servira jusqu'à la génération de code ; la seconde doit rattacher chaque
// message à la bonne phase pour être utile au programmeur.
//
// Retenir la phase d'une erreur, c'est déjà savoir dans quelle partie du
// compilateur la corriger.
Ce que la suite en fait
Ce chapitre a posé la carte ; les dix suivants la parcourent, dans l'ordre du flot.
Le bloc II descend dans la première phase, l'analyse lexicale — là où la Théorie des langages rejoint directement la compilation, puisqu'un analyseur lexical n'est rien d'autre qu'un automate fini. Puis le bloc III, cœur du cours, construit l'arbre syntaxique par analyse descendante et ascendante ; le bloc IV lui donne un sens ; les blocs V et VI le transforment enfin en code.
Gardez en tête le fil du TP : à partir du prochain chapitre, vous écrivez un vrai compilateur, une couche par bloc, sur un mini-langage unique. Le schéma de ce chapitre en est le plan de montage.
À retenir
Flashcards · 4 cartes
- Quelle est la différence entre compilation et interprétation, et qu'ont-elles en commun ?
- Un compilateur TRADUIT le programme entier vers un autre langage une fois pour toutes ; l'exécution est ensuite autonome et rapide. Un interpréteur EXÉCUTE le programme directement, instruction par instruction, à chaque fois. Point commun essentiel : tout le front-end (analyses lexicale, syntaxique, sémantique) est identique — un interpréteur analyse le programme comme un compilateur, il diffère seulement dans ce qu'il fait de l'arbre.
- Quelles sont les phases d'un compilateur, dans l'ordre, et que produit chacune ?
- Analyse lexicale (texte → lexèmes), analyse syntaxique (lexèmes → arbre), analyse sémantique (arbre → arbre vérifié et typé), génération de code intermédiaire (arbre → représentation linéaire), optimisation, génération de code cible. Chaque phase consomme la sortie de la précédente — d'où le filtre en cascade : une erreur n'est détectée que par la phase de son niveau.
- Quels sont les deux services transversaux d'un compilateur ?
- La table des symboles (nom, type, portée, emplacement des identificateurs — remplie à l'analyse, consultée jusqu'à la génération de code) et la gestion des erreurs (détecter, rattacher à la bonne phase et au bon endroit, poursuivre pour signaler plusieurs fautes à la fois). Ce ne sont pas des phases : elles accompagnent toutes les phases.
- Pourquoi sépare-t-on front-end et back-end, et quel gain cela apporte-t-il ?
- Le front-end dépend du langage SOURCE, le back-end de la machine CIBLE, et une représentation intermédiaire commune sert de pivot. Pour m langages et n machines, on n'écrit alors que m + n composants (m front-ends + n back-ends) au lieu de m × n compilateurs complets — chaque langage réutilise tous les back-ends, chaque machine tous les front-ends. C'est l'architecture de LLVM et GCC.