C5 — Génération de code intermédiaireDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 3 · Compilation

Cours 5Génération de code intermédiaire

Passer de l'arbre à une représentation linéaire, et traduire les constructions du langage — à fond if et while.

2 chapitres · 10 h de travail estimé

  1. 1. Représentations intermédiaires5 h
  2. 2. Traduction des constructions5 h

Chapitre 1 · 5 h

Représentations intermédiaires

Code à trois adresses ; quadruplets et triplets ; arbre syntaxique abstrait annoté ; aperçu de la forme SSA.

Le front-end est terminé : l'arbre qui en sort est correct et typé. Commence la synthèse — la production de code. Mais on ne saute pas directement de l'arbre au code machine : on passe par une représentation intermédiaire, ce pivot entre front-end et back-end dont le chapitre 1 expliquait l'intérêt combinatoire.

Ce chapitre présente la représentation intermédiaire la plus répandue, le code à trois adresses, et les formes voisines. L'idée maîtresse : linéariser l'arbre en une suite d'instructions élémentaires, proches de la machine mais encore indépendantes d'un processeur précis.

Pourquoi une représentation intermédiaire

L'arbre syntaxique est une structure arborescente ; une machine exécute une suite linéaire d'instructions. Entre les deux, un fossé. On pourrait le franchir d'un coup, mais une représentation intermédiaire apporte trois bénéfices décisifs :

  • elle est indépendante de la machine cible : le même code intermédiaire se traduit ensuite vers x86, ARM ou un bytecode — c'est le pivot du chapitre 1 ;
  • elle est assez simple pour être optimisée : les optimisations du chapitre 11 travaillent dessus, pas sur l'arbre ni sur le code machine ;
  • elle rapproche progressivement de la machine, sans en épouser tous les détails — un bon niveau d'abstraction pour raisonner.

Le code à trois adresses

Le code à trois adresses est une suite d'instructions ayant chacune au plus un opérateur et au plus trois opérandes — d'où le nom : deux sources, une destination.

résultat = opérande1  opérateur  opérande2

Une expression arborescente comme x * (y + 3) - 4 se linéarise ainsi :

t1 = y + 3t2 = x * t1t3 = t2 - 4

Chaque nœud interne de l'arbre devient une instruction, et l'ordre respecte l'évaluation : les sous-arbres avant leur parent. Les résultats intermédiaires sont rangés dans des temporairest1, t2, t3 — créés à la volée, autant qu'il en faut. On est passé de l'arbre à une suite d'opérations élémentaires : un vrai pas vers la machine, qui exécute une instruction après l'autre.

La génération suit exactement le schéma dirigé par la syntaxe du chapitre 7. C'est même le même parcours d'arbre que la vérification de types : un attribut synthétisé remonte des feuilles, mais au lieu d'un type, il porte désormais l'adresse (le temporaire) où se trouve le résultat du nœud. On a remplacé l'attribut « type » par l'attribut « code ». L'exercice le construit.

Quiz · 1 question

Pourquoi passe-t-on par une représentation intermédiaire comme le code à trois adresses, plutôt que de traduire l'arbre directement en code machine ?

  • Parce que le code machine est trop rapide à produire directementvitesse
  • Parce qu'elle est indépendante de la machine cible (pivot réutilisable) et assez simple pour porter les optimisations, tout en rapprochant l'arbre d'une forme linéairepivot + optimisation
  • Parce que l'arbre syntaxique ne contient pas assez d'informationinformation manquante

Réponse : La représentation intermédiaire est le pivot du chapitre 1 : indépendante de la cible, elle se traduit ensuite vers n'importe quelle machine (un back-end par cible). Elle est aussi le bon niveau pour OPTIMISER — ni trop haut (l'arbre), ni trop bas (le code machine, encombré de détails de registres). Enfin, elle linéarise l'arbre en instructions élémentaires, franchissant progressivement le fossé structure → suite. L'arbre typé contient bien toute l'information nécessaire ; c'est justement ce qu'on linéarise.

Quadruplets et triplets

Le code à trois adresses est une notation ; concrètement, on le stocke de deux façons.

Les quadruplets représentent chaque instruction par quatre champs : (opérateur, arg1, arg2, résultat). t1 = y + 3 devient (+, y, 3, t1). Les temporaires y sont nommés explicitement, ce qui permet de réordonner ou de déplacer les instructions librement — un atout majeur pour l'optimisation (chapitre 11).

Les triplets économisent le champ résultat : chaque instruction est (opérateur, arg1, arg2), et l'on désigne son résultat par le numéro de l'instruction qui l'a produit. Plus compact, mais fragile : réordonner les instructions casse toutes les références par numéro.

QuadrupletsTriplets
Résultattemporaire nomménuméro de l'instruction
Tailleun champ de plusplus compact
Réordonnancementfacilecasse les références

En pratique, on privilégie les quadruplets précisément parce que l'optimisation a besoin de déplacer les instructions.

Arbre annoté et forme SSA

Deux compléments, pour situer le paysage.

L'arbre syntaxique abstrait annoté est une représentation intermédiaire de plus haut niveau : on garde l'AST (chapitre 3) et on y accroche les informations calculées — types, temporaires, adresses. Certains compilateurs génèrent le code directement depuis cet arbre annoté ; d'autres le linéarisent d'abord en trois adresses. Les deux approches coexistent.

La forme SSA (Static Single Assignment) est le raffinement moderne du code à trois adresses : chaque variable n'y est affectée qu'une seule fois. Une variable réaffectée devient une suite de versions (x1, x2, x3…). Cette contrainte, en apparence anodine, simplifie énormément les optimisations du chapitre 11 : savoir « d'où vient » une valeur devient trivial, puisque chaque nom a une définition unique. C'est la représentation interne de LLVM, et le standard des compilateurs optimisants actuels. En L3, il suffit d'en connaître le principe.

Quiz · 1 question

Pourquoi préfère-t-on souvent les quadruplets aux triplets pour représenter le code à trois adresses ?

  • Parce que les triplets ne peuvent pas représenter les opérations arithmétiquesexpressivité
  • Parce que les quadruplets nomment explicitement les résultats (temporaires), ce qui permet de réordonner et déplacer les instructions librement — indispensable à l'optimisationréordonnancement pour l'optimisation
  • Parce que les triplets occupent plus de mémoiretaille

Réponse : Les triplets sont plus compacts (pas de champ résultat), mais désignent le résultat d'une instruction par son NUMÉRO : déplacer ou réordonner les instructions casse ces références. Les quadruplets nomment chaque résultat par un temporaire explicite (op, arg1, arg2, res), si bien qu'on peut librement déplacer, supprimer ou insérer des instructions — exactement ce dont l'optimisation (chapitre 11) a besoin. Les deux représentent aussi bien les opérations ; l'avantage décisif des quadruplets est le réordonnancement.

À vous

L'exercice engendre la représentation intermédiaire du compilateur du TP : du code à trois adresses depuis un AST d'expression. C'est le même parcours d'arbre qu'au chapitre 7 — un attribut synthétisé qui remonte — mais l'attribut est désormais le temporaire portant le résultat, et chaque opération émet une instruction t = a op b.

Vous verrez l'arbre se linéariser en une suite d'instructions élémentaires, et vous reconnaîtrez les formats quadruplets et triplets. C'est le socle sur lequel le chapitre suivant traduira les vraies constructions du langage.

Exercice de code

Engendrez du code à trois adresses depuis un AST : même parcours qu'au chapitre 7, mais l'attribut synthétisé est désormais le temporaire qui porte le résultat. Chaque opération devient une instruction « t = a op b ». Observez la linéarisation de l'arbre, et les formats quadruplets/triplets.

Point de départ

// Le code à TROIS ADRESSES : chaque instruction a au plus un opérateur et
// trois opérandes -> « resultat = op1 op op2 ». Les résultats intermédiaires
// sont stockés dans des TEMPORAIRES t1, t2, ... créés à la volée.
//
// AST d'une expression, comme au chapitre 7 :
//   { op: "nb", val }              { op: "var", nom }
//   { op: "+"|"-"|"*"|"/", g, d }

const instructions = [];
let compteur = 0;
function nouveauTemp() { return "t" + (++compteur); }
function emettre(txt) { instructions.push(txt); }

// ── À VOUS : engendrer() rend le nom où se trouve le résultat du nœud ────────
// (attribut SYNTHÉTISÉ « adresse » : il remonte, comme le type au ch. 7)
function engendrer(n) {
  if (n.op === "nb")  return String(n.val);   // une constante est déjà une adresse
  if (n.op === "var") return n.nom;           // une variable aussi
  // opération binaire : engendrer d'abord les deux fils (récursion),
  // créer un temporaire, émettre « t = adrG op adrD », renvoyer t.
  const adrG = engendrer(n.g);
  const adrD = engendrer(n.d);
  // à compléter : const t = nouveauTemp(); emettre(...); return t;
  return "?";
}

// ── Expression : x * (y + 3) - 4 ────────────────────────────────────────────
const ast = {
  op: "-",
  g: { op: "*", g: { op: "var", nom: "x" },
                d: { op: "+", g: { op: "var", nom: "y" }, d: { op: "nb", val: 3 } } },
  d: { op: "nb", val: 4 },
};

const adrFinale = engendrer(ast);
console.log("Code à trois adresses :");
instructions.forEach((i, k) => console.log("  " + (k + 1) + ": " + i));
console.log("résultat dans : " + adrFinale);

Solution

function engendrer(n) {
  if (n.op === "nb")  return String(n.val);
  if (n.op === "var") return n.nom;
  const adrG = engendrer(n.g);   // d'abord le sous-arbre gauche
  const adrD = engendrer(n.d);   // puis le droit
  const t = nouveauTemp();
  emettre(t + " = " + adrG + " " + n.op + " " + adrD);
  return t;                       // le résultat du nœud est dans ce temporaire
}

// ── Code engendré pour x * (y + 3) - 4 ──────────────────────────────────────
//   1: t1 = y + 3
//   2: t2 = x * t1
//   3: t3 = t2 - 4
//   résultat dans : t3
//
// ── Ce que l'exercice enseigne ──────────────────────────────────────────────
//
// 1. Le code à trois adresses LINÉARISE l'arbre : chaque nœud interne devient
//    UNE instruction élémentaire, et l'ordre des instructions respecte
//    l'ordre d'évaluation (les fils avant le parent). On est passé d'une
//    structure arborescente à une suite d'instructions — un pas vers la
//    machine, qui exécute une instruction après l'autre.
//
// 2. Les TEMPORAIRES portent les résultats intermédiaires. « engendrer » est
//    un attribut SYNTHÉTISÉ, exactement comme le type au chapitre 7 : il
//    remonte des feuilles, mais renvoie une ADRESSE au lieu d'un type. Même
//    parcours d'arbre, autre attribut — c'est la traduction dirigée par la
//    syntaxe.
//
// 3. Ce format se stocke de deux manières équivalentes :
//    - QUADRUPLETS : (op, arg1, arg2, res) — « (+ , y, 3, t1) ». Les
//      temporaires sont nommés explicitement ; on peut réordonner les
//      instructions librement (utile pour l'optimisation, chapitre 11).
//    - TRIPLETS : (op, arg1, arg2), le résultat étant désigné par le NUMÉRO
//      de l'instruction. Plus compact, mais réordonner casse les références.
//
// 4. La forme SSA (Static Single Assignment) va plus loin : chaque variable
//    n'est affectée QU'UNE fois (x1, x2, ...), ce qui rend les optimisations
//    du chapitre 11 beaucoup plus simples à raisonner. C'est la représentation
//    interne de LLVM.

Ce que la suite en fait

Vous savez linéariser une expression. Mais un programme, ce sont surtout des constructions de contrôle : affectations, conditions, boucles, appels de fonctions. Les traduire en code à trois adresses demande de gérer les sauts — car if et while n'existent pas au niveau intermédiaire, seulement des branchements conditionnels vers des étiquettes.

Le chapitre 9 s'y attaque, avec une attention particulière à if et while — les plus importants — et à l'évaluation en court-circuit des expressions booléennes. C'est le chapitre le plus dense du bloc.

À retenir

Flashcards · 4 cartes

Qu'est-ce que le code à trois adresses, et comment se relie-t-il à l'arbre ?
Une suite d'instructions ayant au plus un opérateur et trois opérandes : « résultat = op1 opérateur op2 ». Il LINÉARISE l'arbre : chaque nœud interne devient une instruction, dans l'ordre d'évaluation (fils avant parent), les résultats intermédiaires allant dans des TEMPORAIRES créés à la volée. C'est le même parcours d'arbre que la vérification de types, avec un attribut synthétisé « adresse » au lieu de « type ».
Pourquoi passer par une représentation intermédiaire plutôt que traduire directement en code machine ?
Trois raisons : elle est indépendante de la machine cible (pivot réutilisable — un back-end par processeur, chapitre 1) ; elle est assez simple pour porter les optimisations (chapitre 11) ; elle rapproche progressivement l'arbre d'une forme linéaire proche de la machine, sans en épouser tous les détails. C'est le bon niveau d'abstraction pour la synthèse.
Quelle est la différence entre quadruplets et triplets ?
Quadruplets : (opérateur, arg1, arg2, résultat) — le résultat est un temporaire NOMMÉ, donc on peut réordonner/déplacer librement (bon pour l'optimisation). Triplets : (opérateur, arg1, arg2) — le résultat est désigné par le NUMÉRO de l'instruction, plus compact mais fragile (réordonner casse les références). On préfère les quadruplets pour l'optimisation.
Qu'est-ce que la forme SSA, et quel est son intérêt ?
Static Single Assignment : chaque variable n'est affectée QU'UNE seule fois (une variable réaffectée devient x1, x2, x3…). Cette contrainte simplifie énormément les optimisations : « d'où vient cette valeur » a une réponse unique, puisque chaque nom a une définition unique. C'est la représentation interne de LLVM et le standard des compilateurs optimisants.

Chapitre 2 · 5 h

Traduction des constructions

Expressions et affectations ; expressions booléennes en court-circuit ; if et boucles par patchage de listes ; appels de fonctions ; accès aux tableaux et aux structures.

Le chapitre 8 a linéarisé une expression en code à trois adresses. Mais un programme, ce sont surtout des constructions de contrôle : affectations, conditions, boucles, appels. Les traduire révèle une vérité inconfortable et féconde : au niveau intermédiaire, ni if ni while n'existent. Il n'y a que des étiquettes et des sauts. Toute la richesse du contrôle se ramène à ce squelette — celui du processeur.

C'est le chapitre le plus dense du bloc. On y consacre l'essentiel à if et while, les plus importants, et à l'évaluation en court-circuit des booléens — au lieu de survoler toutes les constructions.

Expressions et affectations

Le cas de base prolonge directement le chapitre 8. Une affectation x = e engendre le code qui calcule e dans un temporaire, puis copie ce temporaire dans x :

x = a * b + 1      devient      t1 = a * b                                t2 = t1 + 1                                x  = t2

C'est de la traduction dirigée par la syntaxe (chapitre 7) : l'attribut synthétisé « adresse » remonte de l'arbre de e, et la racine émet la copie finale. Rien de nouveau, sinon le point de destination.

Le contrôle par sauts

Voici le cœur du chapitre. Une machine ne connaît que deux primitives de contrôle, qu'on adopte au niveau intermédiaire :

  • ifFalse C goto L : sauter à l'étiquette L si la condition C est fausse ;
  • goto L : saut inconditionnel.

Toute structure de haut niveau se traduit en ces primitives. Le if (C) alors :

    ifFalse (C) goto Lfin    ... code de « alors » ...Lfin:

Le if (C) alors sinon enjambe le bloc sinon par un goto après le bloc alors :

    ifFalse (C) goto Lsinon    ... code de « alors » ...    goto LfinLsinon:    ... code de « sinon » ...Lfin:

Et le while (C) corps, qui teste en tête, revient tester après chaque tour :

Ldebut:    ifFalse (C) goto Lfin    ... code du corps ...    goto LdebutLfin:

Ces trois schémas sont à connaître par cœur : ce sont les briques dont tout programme impératif est fait. L'exercice construit celui du while.

Quiz · 1 question

Comment traduit-on « while (C) { corps } » en code à trois adresses ?

  • Par une instruction « while » spéciale, conservée telle quelle au niveau intermédiaireinstruction while native
  • Par une étiquette de test en tête, un « ifFalse (C) goto Lfin » pour sortir, le corps, puis « goto Ldebut » pour revenir tester ; Lfin aprèstest en tête + sauts
  • En dépliant la boucle : on recopie le corps autant de fois qu'elle s'exécuteradéroulage

Réponse : Au niveau intermédiaire, « while » n'existe pas : on le ramène à des étiquettes et des sauts. Schéma : Ldebut (test en tête) ; ifFalse (C) goto Lfin (sortir si faux) ; corps ; goto Ldebut (retourner tester) ; Lfin. On ne peut pas déplier la boucle, car le nombre d'itérations dépend des données (inconnu à la compilation). C'est ce squelette de sauts qui exprime la répétition — le même que produira le processeur.

Expressions booléennes et court-circuit

Comment évaluer la condition C elle-même, quand c'est une expression booléenne composée comme a < b && c < d ? Deux stratégies, mais une seule est correcte pour un langage courant.

On pourrait calculer la valeur booléenne complète (évaluer a < b, puis c < d, puis le &&), la ranger dans un temporaire, et la tester. Mais la sémantique de la plupart des langages impose l'évaluation en court-circuit : dès que a < b est faux, le résultat du && est faux, et c < d ne doit pas être évalué du tout.

Ce n'est pas une optimisation, c'est une exigence sémantique. Elle est vitale quand la seconde condition a un effet de bord, ou pourrait échouer :

p != NULL && p->x > 0

Ici, p->x ne doit être lu que si p est non nul. Un calcul complet déréférencerait p même quand il est nul — un plantage. Le court-circuit l'interdit.

On l'obtient en traduisant && directement en sauts, jamais en calculant un booléen :

a < b && c < d      devient      ifFalse (a < b) goto Lfaux                                 ifFalse (c < d) goto Lfaux                                 goto Lvrai

Si a < b est faux, on saute à Lfaux avant même d'atteindre le test de c < d. Le || symétrique saute vers Lvrai dès qu'une condition est vraie. C'est la seconde partie de l'exercice.

Le patchage de listes

Un problème pratique surgit à l'émission. Quand on écrit ifFalse (C) goto Lfin, l'étiquette Lfin n'est pas encore connue : elle marque un point du code qu'on n'a pas encore engendré (un saut vers l'avant). Comment remplir la cible d'un saut qu'on émet avant de savoir où il mène ?

La technique du patchage de listes (backpatching) répond à cela, et permet d'engendrer tout le code en une seule passe :

  1. on émet le saut avec une cible en blanc ;
  2. on retient l'instruction incomplète dans une liste (la liste des sauts à compléter) ;
  3. quand l'étiquette de destination devient connue, on parcourt la liste et on remplit toutes les cibles d'un coup.

C'est un mécanisme élégant mais techniquement dense — la raison pour laquelle ce bloc mérite qu'on s'attarde sur if et while plutôt que de courir après toutes les constructions. En comprendre le principe suffit en L3 : émettre, retenir, compléter.

Appels, tableaux, structures

Les constructions restantes se traduisent selon des schémas que l'on cite ici pour la complétude.

Un appel de fonction f(a, b) engendre le passage des paramètres puis l'appel :

param aparam bt = call f, 2      (2 = nombre d'arguments)

Les détails — où vont les paramètres, comment la fonction retourne — relèvent de l'environnement d'exécution du chapitre 10.

L'accès à un tableau T[i] calcule une adresse : base(T) + i × taille_élément. C'est pourquoi T[i] équivaut à *(T + i) — l'indexation est de l'arithmétique d'adresses. L'accès à un champ de structure s.champ se traduit de même, par un décalage fixe connu de la table des symboles : base(s) + décalage(champ).

Ces schémas partagent tous la même logique : ramener une construction de haut niveau à des opérations à trois adresses sur des adresses et des sauts.

Quiz · 1 question

Pourquoi l'évaluation en court-circuit de « p != NULL && p->x > 0 » est-elle une exigence sémantique, et non une simple optimisation ?

  • Parce qu'elle rend le programme plus rapide en évitant un calculgain de vitesse
  • Parce que « p->x » ne doit être évalué que si p est non nul : sans court-circuit, on déréférencerait p même quand il est NULL, provoquant un plantage — le résultat dépend de l'ordre d'évaluationcorrection : ne pas déréférencer NULL
  • Parce que && est prioritaire sur !=priorité d'opérateurs

Réponse : Le court-circuit change ce que le programme FAIT, pas seulement sa vitesse. « p->x » ne doit être lu que si « p != NULL » est vrai ; sans court-circuit, on évaluerait les deux conditions, donc on déréférencerait p même nul — un plantage. C'est pourquoi on traduit && directement en sauts (ifFalse (p != NULL) goto Lfaux avant le test de p->x), garantissant que la seconde condition n'est atteinte que si la première est vraie. C'est de la correction, pas de l'optimisation.

À vous

L'exercice traduit deux constructions au cœur du chapitre : une boucle while en étiquettes et sauts, puis l'évaluation en court-circuit d'un &&. Vous constaterez que while n'existe pas au niveau intermédiaire — seulement son squelette de sauts — et que le court-circuit se traduit directement en branchements, garantissant qu'une seconde condition n'est jamais évaluée à tort.

C'est la couche du compilateur du TP qui transforme un programme structuré en une suite d'instructions et de sauts, prête pour la machine.

Exercice de code

Traduisez une boucle while en code à trois adresses (étiquettes et sauts), puis l'évaluation en court-circuit d'un && dans la condition. Comprenez que if/while n'existent pas au niveau intermédiaire, et que le court-circuit est une exigence sémantique — pas une simple optimisation.

Point de départ

// Au niveau intermédiaire, il n'y a ni « while » ni « if » : seulement des
// étiquettes (Ln:) et des sauts. Deux primitives suffisent :
//   ifFalse C goto L   : sauter à L si la condition C est fausse
//   goto L             : saut inconditionnel
//
// On traduit :   while (x < n) { x = x + 1; }
//
// Schéma standard d'une boucle while :
//   Ldebut:                      <- étiquette de test
//     ifFalse (x < n) goto Lfin  <- si faux, on sort
//     ...corps...
//     goto Ldebut                <- on retourne tester
//   Lfin:

let code3a = [];
let nEtiq = 0;
function nouvelleEtiquette() { return "L" + (++nEtiq); }
function emettre(s) { code3a.push(s); }

// ── À VOUS : traduireWhile(cond, corps) ─────────────────────────────────────
// 'cond' est une chaîne (ex. "x < n"), 'corps' un tableau d'instructions déjà
// engendrées (ex. ["x = x + 1"]). Émettre le schéma ci-dessus.
function traduireWhile(cond, corps) {
  const Ldebut = nouvelleEtiquette();
  const Lfin = nouvelleEtiquette();
  // à compléter :
  //   emettre(Ldebut + ":");
  //   emettre("ifFalse (" + cond + ") goto " + Lfin);
  //   for (const i of corps) emettre("  " + i);
  //   emettre("goto " + Ldebut);
  //   emettre(Lfin + ":");
}

traduireWhile("x < n", ["x = x + 1"]);
console.log("while (x < n) x = x + 1;");
code3a.forEach((s) => console.log("   " + s));

// ── Court-circuit d'un && ───────────────────────────────────────────────────
// « a < b && c < d » ne s'évalue PAS en calculant les deux comparaisons : si
// « a < b » est faux, on saute directement à Lfaux SANS évaluer « c < d ».
// À VOUS : compléter le court-circuit pour sauter vers Lfaux dès le 1er faux.
code3a = []; nEtiq = 0;
function traduireEt(condG, condD, Lvrai, Lfaux) {
  // à compléter : si condG est faux -> Lfaux ; sinon tester condD ;
  //               si condD est faux -> Lfaux ; sinon goto Lvrai.
}
const Lv = "Lvrai", Lf = "Lfaux";
traduireEt("a < b", "c < d", Lv, Lf);
console.log("\na < b && c < d (court-circuit) :");
code3a.forEach((s) => console.log("   " + s));

Solution

function traduireWhile(cond, corps) {
  const Ldebut = nouvelleEtiquette();
  const Lfin = nouvelleEtiquette();
  emettre(Ldebut + ":");
  emettre("ifFalse (" + cond + ") goto " + Lfin);
  for (const i of corps) emettre("  " + i);
  emettre("goto " + Ldebut);
  emettre(Lfin + ":");
}
// while (x < n) x = x + 1; devient :
//   L1:
//     ifFalse (x < n) goto L2
//     x = x + 1
//     goto L1
//   L2:

function traduireEt(condG, condD, Lvrai, Lfaux) {
  emettre("ifFalse (" + condG + ") goto " + Lfaux);   // 1er faux -> on abandonne
  emettre("ifFalse (" + condD + ") goto " + Lfaux);   // 2e faux -> idem
  emettre("goto " + Lvrai);                            // les deux vrais
}
// a < b && c < d (court-circuit) :
//   ifFalse (a < b) goto Lfaux    <- si a < b est faux, c < d n'est JAMAIS évalué
//   ifFalse (c < d) goto Lfaux
//   goto Lvrai

// ── Ce que l'exercice enseigne ──────────────────────────────────────────────
//
// 1. AU NIVEAU INTERMÉDIAIRE, if et while N'EXISTENT PAS. Il n'y a que des
//    étiquettes et des sauts (goto, ifFalse ... goto). Toute structure de
//    contrôle se ramène à ce squelette — c'est déjà le niveau de la machine.
//
// 2. Le schéma du WHILE : tester en tête (Ldebut), sortir si faux (vers Lfin),
//    exécuter le corps, revenir tester (goto Ldebut). Le if est plus simple :
//    « ifFalse C goto Lfin ; corps ; Lfin: ». Le if-else ajoute un « goto »
//    par-dessus le bloc else pour l'enjamber après le bloc then.
//
// 3. Le COURT-CIRCUIT est une propriété SÉMANTIQUE, pas une optimisation :
//    « a < b && c < d » ne DOIT PAS évaluer « c < d » si « a < b » est faux.
//    C'est vital quand la 2e condition a un effet ou pourrait planter :
//    « p != NULL && p->x > 0 » ne déréférence p que s'il est non nul. On
//    l'obtient en traduisant && directement en SAUTS, pas en calculant un
//    booléen puis en testant.
//
// 4. Les étiquettes de destination ne sont pas toujours connues au moment où
//    l'on émet le saut (« goto ??? » vers l'avant). La technique du PATCHAGE
//    DE LISTES (backpatching) consiste à laisser la cible en blanc, à retenir
//    l'instruction dans une liste, et à la COMPLÉTER une fois l'étiquette
//    connue. C'est ce qui permet d'engendrer le code en une seule passe.

Ce que la suite en fait

Le code intermédiaire est produit : expressions, affectations, contrôle, appels — tout est ramené à des instructions à trois adresses et à des sauts. Mais il reste abstrait sur un point majeur : vivent les variables, et comment un appel de fonction s'organise réellement en mémoire.

Le bloc VI descend au niveau de la machine. Le chapitre 10 traite l'organisation mémoire — segments, pile d'appels, enregistrements d'activation — c'est-à-dire atterrissent les param et les variables locales de ce chapitre. Le chapitre 11 produira enfin le code cible et l'optimisera.

À retenir

Flashcards · 4 cartes

Comment traduit-on if, if-else et while en code à trois adresses ?
Par des étiquettes et des sauts (if/while n'existent pas au niveau intermédiaire). if : « ifFalse (C) goto Lfin ; corps ; Lfin: ». if-else : ajouter « goto Lfin » après le bloc « alors » pour enjamber le « sinon ». while (test en tête) : « Ldebut: ; ifFalse (C) goto Lfin ; corps ; goto Ldebut ; Lfin: ». Ce sont les briques de tout programme impératif.
Qu'est-ce que l'évaluation en court-circuit, et pourquoi est-ce une exigence sémantique ?
« a && b » n'évalue b que si a est vrai ; « a || b » n'évalue b que si a est faux. Ce n'est PAS une optimisation : « p != NULL && p->x > 0 » ne doit déréférencer p que s'il est non nul — sinon plantage. On l'obtient en traduisant && / || directement en SAUTS (ifFalse ... goto), jamais en calculant un booléen complet puis en le testant.
Qu'est-ce que le patchage de listes (backpatching), et quel problème résout-il ?
Quand on émet un saut vers l'avant (ifFalse C goto Lfin), la cible Lfin n'est pas encore connue. Le backpatching : émettre le saut avec une cible en blanc, retenir l'instruction dans une liste, puis COMPLÉTER toutes les cibles d'un coup quand l'étiquette devient connue. Cela permet d'engendrer le code en une seule passe — mécanisme élégant mais dense.
Comment se traduisent l'accès à un tableau T[i] et à un champ de structure s.champ ?
Ce sont de l'arithmétique d'ADRESSES. T[i] calcule base(T) + i × taille_élément — c'est pourquoi T[i] équivaut à *(T + i). s.champ calcule base(s) + décalage(champ), le décalage étant un offset fixe connu de la table des symboles. Un appel f(a,b) émet « param a ; param b ; t = call f, 2 », les détails relevant de l'environnement d'exécution (chapitre 10).