À dire : Bonjour à tous. Nous commençons par le quiz sur AP2 : dix minutes, puis nous le corrigeons ensemble. … Aujourd'hui, nous reprenons les arbres d'AP2, mais au lieu de les évaluer, nous allons les traduire en octets pour une petite machine, puis faire tourner cette machine. Processeur, registre, octet, opcode, saut : chaque mot viendra au moment où il sert. Repères : quiz sur AP2 en ouverture, dix minutes de réponse puis cinq de correction, pris sur la marge du créneau (sujet dans quiz/quiz-3-interpreteur, chapitre 5 du poly). Lancement ensuite d'une quinzaine de minutes : six pour le programme, la compilation et le rythme, neuf pour les repères sur le processeur. Toute la classe suit ce cours ; l'inférence de types est un sujet d'AP9, évoqué en fin de séance.
À dire : Voici un programme de notre langage, très proche de celui qui fermait AP2. Que vaut-il ? … 32.4. En AP2, notre interpréteur obtenait ce résultat en reparcourant l'arbre, nœud par nœud, à chaque exécution. Aujourd'hui, nous faisons autrement : nous traduisons l'arbre une fois pour toutes en instructions très simples, puis une machine exécute ces instructions sans plus jamais voir l'arbre. Bonne nouvelle : nous partons d'arbres écrits à la main, donc personne n'a besoin d'avoir terminé son parser. Repères : faire prédire 32.4, puis distinguer le résultat du code qui permet de l'obtenir. Mêmes tuples AST qu'en AP2 ; le langage cible des exercices est montré avant chaque nouveau nœud.
À dire : Trois briques, trois verbes. Compiler, c'est traduire l'arbre en une liste d'instructions. Assembler, c'est encoder ces instructions en octets, en calculant leurs adresses. Exécuter, c'est confier ces octets à une machine virtuelle, une VM : une machine imaginaire, simulée par un programme Elixir, qui lit les octets et transforme son état. Chaque brique se teste sans les deux autres, et chacune a ses propres refus. Les vrais compilateurs sont découpés de la même façon, et depuis longtemps. Repères : ni exécutable ARM ou x86, ni bytecode BEAM. Refus typiques : un nom inconnu au compilateur, une étiquette absente à l'assembleur, une pile incorrecte à l'exécution. Le détail de chaque brique est repris dans son atelier.
À dire : Le mot « compilateur » remonte à Grace Hopper, en 1952. Mais c'est FORTRAN, chez IBM en 1957, qui convainc : l'équipe de John Backus, le même Backus que pour les grammaires, craignait qu'un code produit automatiquement soit trop lent pour être adopté, et son compilateur produit un code aussi rapide que celui écrit à la main. Pour nous, la différence entre interpréter et compiler tient au moment du travail : l'interpréteur décide à chaque nœud, à chaque exécution ; le compilateur décide une fois et laisse une suite d'instructions. Le contrat, lui, ne change pas : même programme, même résultat, ce que nous vérifierons en fin de séance. Repères : 1 min du lancement. Les deux moteurs doivent donner le même résultat pour le même arbre ; la dernière reprise le vérifie.
À dire : La séance suit le même rythme qu'en AP2 : quelques slides, un carnet, une reprise. Quatre étapes : d'abord le format des instructions en octets, puis la compilation d'un calcul et la machine qui l'exécute, puis les variables, enfin les choix de branche. Comme la dernière fois, vous recopiez vos propres modules d'une étape à la suivante, et une slide « Elixir pour cet atelier » rappelle, juste avant chaque consigne, les formes dont vous aurez besoin. En fin de séance, nous comparerons la machine à l'interpréteur, puis nous verrons ce qu'un typeur saurait avant d'exécuter. Mais d'abord, une machine, qu'est-ce que c'est ? Repères : montrer la page AP3 du site : quatre carnets, chacun avec ses slides et ses repères. Pour relire : poly, chapitre 6, et chapitre 3 pour les listes et les maps. Formes Elixir de la séance : binaires, with, maps, Enum.reduce, expliquées dans les carnets au point d'emploi. Durées des notes indicatives.
À dire : Avant de construire notre machine, regardons comment fonctionne une vraie, en quelques minutes et sans entrer dans l'électronique. Nous verrons la mémoire et ses adresses, le processeur et ses registres, le PC et le cycle lire, décoder, exécuter, puis le cache. Notre VM en reprendra certains et en laissera d'autres de côté ; je vous dirai lesquels. Repères : repères sur le processeur, 9 min en tout avec les cinq slides suivantes, pris dans le lancement. Le but est de lire les schémas et les traces des carnets, pas de parcourir une architecture matérielle complète.
À dire : Le processeur, ou CPU, est le circuit qui exécute des instructions élémentaires : additionner, comparer, copier. La mémoire de travail, la RAM, est une longue rangée de cases numérotées ; chaque case contient un octet, c'est-à-dire huit bits, un nombre entre 0 et 255. Le numéro d'une case est son adresse, et ce qu'elle contient est sa valeur : ici, à l'adresse 100, il y a la valeur 8. Le processeur ne calcule pas directement dans la mémoire : il copie d'abord les valeurs dans des registres, de petites cases nommées R0, R1, situées en lui. Quelle est la différence entre 100 et 8 sur ce dessin ? … L'un est une adresse, l'autre une valeur. Mais comment le processeur sait-il quelle instruction exécuter ? Repères : 2 min. Charger l'octet de l'adresse 100 copie 8 dans R0 sans le retirer de la mémoire. La taille des registres dépend de l'architecture : ne pas dire qu'ils font tous un octet ; les valeurs tiennent dans un octet pour le dessin seulement.
À dire : Le programme, lui aussi, est rangé en mémoire, sous forme d'octets. Un registre particulier, le PC, pour program counter ou compteur ordinal, contient l'adresse de la prochaine instruction à lire. Le processeur répète alors trois gestes : lire les octets à l'adresse du PC, les décoder pour savoir de quelle instruction il s'agit, puis l'exécuter ; en anglais, fetch, decode, execute. Sur le dessin, le PC vaut 9, et les trois octets lus forment `JUMP 13`, un saut : au lieu d'avancer à 12, l'adresse qui suit, le PC prend la valeur 13. Le PC ne compte donc pas les tours de boucle : c'est une adresse. Reste une question de vitesse. Repères : 2 min. Dans notre format, PUSH occupe 9 octets, donc PC passe de 0 à 9 ; JUMP en occupe 3. Les octets 0D 00 0D désignent JUMP puis sa cible 13 en hexadécimal : ne pas demander de les mémoriser. Demander ce que devient PC si la cible vaut 0. Modèle simple, pas une description des processeurs modernes.
À dire : Aller chercher une valeur dans la RAM est lent, comparé à la vitesse du processeur. Le matériel garde donc, tout près de lui, des copies de blocs de mémoire déjà lus : c'est le cache. Si l'adresse demandée s'y trouve, la lecture est rapide ; sinon, le bloc est chargé depuis la RAM. Quelle différence avec un registre ? … Un registre est désigné explicitement par l'instruction ; le cache, lui, est géré par le matériel, sans que le programme le nomme. Le cache change la durée d'une lecture, pas la valeur lue : notre VM, qui simule des résultats et non des durées, n'en a donc pas. Voyons maintenant où notre machine prendra ses opérandes. Repères : 2 min. Le dessin ne détaille ni les écritures ni les politiques de remplacement. Ne pas confondre avec un cache disque ou celui de Livebook. Aucun cache à écrire.
À dire : Une soustraction a besoin de deux valeurs. Une machine à registres les nomme dans l'instruction : `SUB R2, R0, R1` soustrait R1 de R0 et range le résultat dans R2. Notre machine fera autrement : c'est une machine à pile, comme la calculatrice du carnet 5 d'AP1. On empile 8, puis 3 ; `SUB` dépile les deux valeurs du sommet, calcule 8 moins 3 et empile 5. Rien à nommer, ce qui rendra notre compilateur bien plus simple. Le PC, lui, existe toujours : c'est un registre de contrôle. Repères : 2 min. Notation de gauche illustrative, indépendante d'ARM ou x86. Pile et registre ne sont pas deux noms de la même chose : la pile impose un ordre d'accès, un registre a un nom. Un matériel peut réaliser une pile avec de la mémoire et un pointeur ; notre simulateur utilise une liste. Pas de pointeur de pile ni d'allocation de registres aujourd'hui.
À dire : Un dernier repère, historique. En 1945, le rapport de von Neumann sur l'EDVAC décrit une machine dont le programme est rangé en mémoire, comme les données ; l'EDSAC de Cambridge en est l'une des premières réalisations, en 1949. Avant cela, un programme se câblait ou se lisait sur un ruban séparé. C'est pour cela que le PC est une adresse, et qu'un saut n'est qu'une nouvelle valeur du PC. Mais il y a une conséquence plus gênante : rien, dans un octet, ne dit s'il fait partie d'une instruction ou d'une donnée. C'est la question de notre premier atelier. Repères : 1 min, fin des repères sur le processeur. La reprise de l'atelier 01 reviendra sur l'octet 01.
À dire : Voici trois octets, écrits en Elixir entre doubles chevrons. S'agit-il de trois instructions, ou d'une seule ? … Impossible de répondre sans convention. Les octets ne portent pas leur sens tout seuls : il faut un accord entre celui qui écrit le programme et celui qui le lit, qu'on appelle un jeu d'instructions. Dans le nôtre, ces trois octets forment une seule instruction, `load 258`. Voyons pourquoi. Repères : atelier 01, 34 min en tout, dont 7 min de cours. Un AST n'est pas nécessaire ici : le carnet 01 ne traite que du format.
À dire : Dans notre jeu d'instructions, le premier octet d'une instruction est son opcode, le numéro de l'opération. Selon l'opcode, l'instruction s'arrête là, ou bien elle continue avec un opérande, une donnée qui l'accompagne. `PUSH` est suivi d'un nombre sur huit octets, donc il en occupe neuf ; `LOAD`, `STORE` et les sauts portent un entier sur deux octets, donc trois ; les autres tiennent en un seul. Les octets sont écrits en hexadécimal, deux chiffres par octet : `0D` vaut 13. Le programme `8.0 - 3.0` occupe donc 20 octets, et ses instructions commencent aux adresses 0, 9, 18 et 19. Repères : opcode 11 = load ; les octets 1 et 2 forment 1 × 256 + 2 = 258, poids fort d'abord. Tous les entiers et flottants sont en big endian. Octets en hexadécimal, adresses en décimal. Ne pas expliquer la norme IEEE 754 : float-big-64 l'encode pour nous.
À dire : Voici le contrat du carnet 01, ce qu'on appelle un codec : `encode/1` transforme une instruction Elixir en octets, et `decode_at/2` fait le chemin inverse à partir d'une adresse. Regardez la deuxième ligne : `load 258` donne les octets 11, 1 et 2. Pour écrire 258 sur deux octets, nous mettons l'octet de poids fort d'abord, 1, puis le poids faible, 2 : c'est l'ordre big endian. Et regardez ce que rend le décodeur : l'instruction, mais aussi l'adresse suivante, 18, parce qu'une instruction n'occupe pas toujours un seul octet. Le carnet fournit la clause de `halt` comme exemple ; vous écrivez les autres. Essayons maintenant de lire un programme à la main. Repères : binary est ici le programme 8.0 - 3.0. Erreurs au choix de l'élève, par ex. {:invalid_pc, pc}, {:unknown_opcode, pc, octet} ou {:truncated_operand, pc}. push refuse un entier Elixir comme 8 ; les opérandes de load, store et saut sont dans 0..65_535. L'adresse suivante vient de la taille décodée, jamais d'un pc + 1 systématique.
À dire : Voici le programme `8.0 - 3.0`, instruction par instruction ; chaque adresse suivante s'obtient en ajoutant la taille. Question : pourquoi l'adresse 10 n'est-elle pas un début d'instruction ? … Elle tombe au milieu du flottant du deuxième `PUSH`. Pire : à l'adresse 3, l'octet vaut 0, et un décodeur lancé là répondrait `HALT`, une instruction qui n'existe pas. Seul un parcours depuis l'adresse 0, en suivant les tailles, connaît les vrais débuts d'instructions. Le carnet vous demandera justement de prédire cela avant d'exécuter. Repères : à l'adresse 10, l'octet vaut 64, premier octet de 3.0, qui n'est pas un opcode. HALT arrête la machine avant toute lecture en 20. Le désassembleur fourni parcourt les tailles depuis 0 ; il ne cherche pas un octet qui aurait par hasard la valeur d'un opcode.
À dire : Pour écrire et lire ces octets, Elixir hérite d'une notation d'Erlang, conçue chez Ericsson pour décoder des protocoles de télécommunication bit à bit. Cet exemple lit le premier octet d'un en-tête réseau : quatre bits de version, puis quatre bits de longueur, sans boucle ni masque. Un motif binaire se lit comme un format : des tailles, un signe, un ordre des octets. Nous l'employons sur nos propres instructions ; voici les trois formes dont vous aurez besoin. Repères : 1 min. Ne pas faire écrire l'exemple IPv4. Tailles en bits par défaut ; big est l'ordre réseau, celui de nos opérandes.
À dire : Trois formes, toutes dans le carnet. Entre doubles chevrons, chaque nombre est un octet, et `::` précise un format : `float-big-64` pour un flottant sur huit octets, `unsigned-big-16` pour un entier sur deux. La même écriture construit un binaire à droite d'un `=` et le reconnaît à gauche, exactement comme le pattern matching d'AP1 : `"+" <> rest` devient `<<1, rest::binary>>`. Et si un motif ne correspond pas, par exemple parce qu'il manque des octets, Elixir passe à la clause suivante : c'est ainsi que vous repérerez un opérande tronqué. À vous. Repères : les tables d'opcodes du carnet sont des maps fournies : Map.fetch rend {:ok, v} ou :error. Le chapitre 6 du poly détaille ces motifs ; le projet backend CHIP-8 d'AP9 les réutilisera.
À dire : Ouvrez le carnet 01 et sauvegardez-le dans votre dossier AP3. Commencez par la prédiction sur papier : les adresses des quatre instructions, la taille du programme, et ce qu'on lirait à l'adresse 3. Écrivez ensuite le codec, d'abord pour `push`, `minus` et `halt`, puisque la première cellule de vérification ne demande que celles-là, puis étendez aux opérandes entiers. Vous avez vingt-deux minutes. Repères : 22 min, présentation interrompue. Passer dans les rangs pour vérifier que chacun distingue adresse et numéro d'instruction. Le décodage prépare la VM de l'étape 02.
À dire : Alors, que signifie l'octet 01 ? … `PUSH`, mais seulement s'il se trouve au début d'une instruction ; au milieu d'un flottant, il fait partie d'une valeur. Qui nous montre comment son décodeur sait où lire la suite ? … Grâce à l'adresse suivante qu'il rend. Comparez aussi vos encodages et vos décodages : un aller-retour réussi ne suffit pas toujours, car deux erreurs peuvent se compenser, d'où les exemples d'octets donnés dans le carnet. Nous savons lire des instructions ; il reste à les produire à partir d'un arbre, puis à les exécuter. Repères : reprise de 5 min. Faire nommer le contrat {:ok, instruction, next}. Revenir sur l'adresse 3 du programme 8.0 - 3.0, où un décodeur lit un HALT qui n'existe pas.
À dire : Deuxième étape : produire le code, puis l'exécuter. Le compilateur reçoit l'arbre de `8.0 - 3.0 * 2.0`, une soustraction dont l'enfant droit est une multiplication. Il ne calcule rien : il écrit des instructions. Dans quel ordre ? Quelle valeur faut-il empiler en premier pour que la pile donne le bon résultat ? Dessinez la pile. Repères : atelier 02, 36 min en tout, dont 9 min de cours. Résultat 2.0 ; séquence PUSH 8.0, PUSH 3.0, PUSH 2.0, MUL, SUB, HALT. Faire dessiner la pile sans donner le code de emit. Aucun parsing ni calcul de valeurs dans le compilateur.
À dire : Prenons le sous-exemple `8.0 - 3.0`. Le compilateur visite l'enfant gauche et produit `PUSH 8.0`, puis l'enfant droit et produit `PUSH 3.0`, puis seulement l'opération, `SUB`. C'est le parcours en postordre d'AP2 : les enfants avant le parent. À l'exécution, `SUB` dépile d'abord la droite, 3.0, puis la gauche, 8.0, et empile leur différence. Pourquoi tester avec une soustraction plutôt qu'une addition ? … Parce qu'une addition masquerait une inversion des opérandes. Repères : le sommet est à gauche dans toutes nos traces. La division révèle aussi l'inversion.
À dire : Ce code, vous l'avez déjà rencontré. Pour notre calcul, la calculatrice à pile du carnet 5 d'AP1 aurait lu `8 3 2 * -` : c'est exactement le postordre de l'arbre, qu'on appelle aussi notation polonaise inverse. Les machines à pile ont une longue histoire : le Burroughs B5000 en 1961, le P-code de Pascal, la machine virtuelle Java, WebAssembly. Leur intérêt : une instruction n'a pas à nommer ses opérandes, donc le code est compact et le compilateur simple. La BEAM, qui exécute Elixir, est en revanche une machine à registres, qui évite des empilements et va plus vite ; notre choix sert la lisibilité, pas la vitesse. Regardons maintenant ce que contient notre machine. Repères : 1 min. Le P-code a servi à porter Pascal : écrire une petite VM par machine suffisait, au lieu d'un compilateur complet. Notre VM n'est ni du bytecode BEAM ni du code natif.
À dire : Voici tout l'état de notre machine, à un instant précis : juste avant `SUB`. Le code est le binaire de l'étape 01, et il ne change jamais pendant l'exécution. Le PC vaut 18, l'adresse de `SUB`. La pile contient 3.0 au sommet, puis 8.0. Les cases locales sont vides pour l'instant ; elles serviront aux variables de l'étape 03. En Elixir, le PC sera un entier, la pile une liste dont la tête est le sommet, et les cases une map. Il n'y a ni registres de calcul ni cache : rien que ces données. Comment la machine passe-t-elle d'un état au suivant ? Repères : le budget de pas et la trace sont des outils du simulateur, pas des données du programme cible. Pas de pointeur de pile : la liste représente directement la pile. Pas de pile d'appels avant l'étape 05 facultative. Ne pas confondre la pile de valeurs de notre machine avec la pile d'appels d'Elixir, qui exécute l'émulateur.
À dire : La boucle de notre VM reprend le cycle du processeur. Elle lit et décode l'instruction au PC, grâce à votre codec de l'étape 01 ; elle l'exécute, c'est-à-dire qu'elle calcule un nouvel état ; puis elle recommence avec cet état. C'est une récursion terminale, comme vos accumulateurs d'AP1 : l'état voyage dans les arguments, et `HALT` joue le rôle du cas de base. Pour une opération ordinaire, le nouveau PC est l'adresse suivante rendue par le décodeur ; à l'étape 04, un saut pourra en choisir une autre. Repères : MiniVM.loop transporte le code, le pc, la pile, les cases, un budget de pas et la trace. Une erreur de transition rend {:error, {pc, raison}}, pour retrouver l'instruction fautive dans le listing. La trace mémorise l'état avant chaque instruction.
À dire : Voici les transitions que vous écrirez, sur le programme `8.0 - 3.0`. Chaque ligne donne la pile avant, l'instruction, puis la pile après. `SUB` dépile la droite puis la gauche, et empile gauche moins droite. `HALT` exige exactement une valeur sur la pile, et la rend. Mettez le PC à côté de chaque ligne : 0, 9, 18, 19. Le carnet fournit la transition de `PUSH` comme exemple ; vous écrivez les autres, puis la boucle. Comment être sûr que compilateur et machine s'accordent sur n'importe quel arbre ? Repères : erreurs si la pile est insuffisante, si un opérande est un booléen, ou si le diviseur est nul. HALT avec deux valeurs révèle du code émis en trop.
À dire : Il faut une propriété qui reste vraie à chaque nœud, un invariant : le code d'une expression, quand il réussit, laisse exactement une valeur de plus sur la pile, sans toucher à ce qu'il y a dessous. Pour une soustraction, le code de gauche laisse une valeur, celui de droite une autre, et l'opération remplace les deux par une seule. C'est un raisonnement par récurrence sur la forme de l'arbre : vrai pour une feuille, vrai pour un nœud dès que c'est vrai pour ses enfants. Nous garderons cet invariant avec les variables et les branches. Voici les formes Elixir de l'atelier. Repères : compilateur et VM se testent séparément : instructions écrites à la main pour la VM, sortie symbolique pour emit. Le carnet demande de concevoir ces deux algorithmes, pas de remplir une concaténation déjà écrite.
À dire : La pile est une liste : empiler, c'est `[value | stack]`, et le motif `[right, left | below]` dépile deux valeurs d'un coup. Attention à l'ordre : `right` est en tête, puisqu'il a été empilé en dernier. Pour enchaîner décodage et transition, `with` reprend son rôle d'AP2 : la première étape qui échoue sort du `with` telle quelle. Enfin, pour l'assembleur provisoire, `Enum.reduce_while/3` parcourt une liste et peut s'arrêter à la première erreur ; une récursion avec accumulateur convient tout aussi bien. À vous. Repères : le bout de code est simplifié, la boucle du carnet transporte aussi le budget et la trace. <> concatène deux binaires et <<>> est le binaire vide. Le carnet explique with, reduce_while et la capture & &1.pc au point d'emploi.
À dire : Ouvrez le carnet 02 et recopiez votre `MiniBytecode`. Le premier point de passage est une soustraction complète, de l'arbre à la valeur : compilation, transitions `push`, `minus` et `halt`, puis la boucle. La boucle, nous pouvons la construire ensemble si vous le souhaitez. L'addition et les autres opérations viennent ensuite par analogie. Vous avez vingt-deux minutes. Repères : 22 min. MiniCompiler.compile(ast) rend {:ok, binary} ; MiniVM.trace(binary, limit) rend {:ok, value, states}, chaque état décrivant la machine avant l'instruction. Adresses visitées attendues : 0, 9, 18, 19.
À dire : Montrez-nous les instructions et la pile pour `8.0 - 3.0 * 2.0`. … Résultat 2.0. Remplaçons maintenant la soustraction par une division, dans le même arbre : quel résultat attendez-vous ? … 8.0 divisé par 6.0, environ 1.333. Si vous obtenez 0.75, vos opérandes sont inversés, ce qu'une addition n'aurait jamais montré. Sauvegardez avant la pause ; nous donnerons ensuite une case de mémoire à chaque variable. Repères : reprise de 5 min. Bien dire que l'on change l'arbre : le texte 8.0 / 3.0 * 2.0 serait lu de gauche à droite par le parser et vaudrait 5.333. Comparer avec une tolérance ; ne pas se limiter à des nombres entiers.
À dire : Dix minutes de pause. Sauvegardez vos carnets avant de vous lever. En revenant, nous ferons disparaître les noms de variables du programme compilé. Repères : 10 min. Ne pas ajouter de démonstration pendant cette plage.
À dire : Notre machine ne connaît pas les noms : elle ne lit que des octets. Pourtant, ce programme lie `x` à 8.0, puis le relit. Le nom `x` aide à écrire le programme ; la machine peut-elle se contenter d'un numéro de case ? … Oui : le compilateur va décider que `x` occupe la case 0, et le binaire ne contiendra plus que ce numéro. Toute la question est de conserver la portée lexicale d'AP2 une fois les noms disparus. Repères : atelier 03, 35 min en tout, dont 8 min de cours. Aucun environnement de noms dans la VM.
À dire : Deux instructions font le lien entre la pile et les cases. `STORE 0` retire la valeur du sommet et l'écrit dans la case 0. `LOAD 0` copie la valeur de la case 0 sur la pile, sans la retirer de la case. Que se passe-t-il si l'on exécute `LOAD 0` avant toute écriture ? … Une erreur : la case n'est pas initialisée. Ces deux instructions occupent trois octets, comme vous les avez encodées à l'étape 01. Que se passe-t-il quand deux liaisons portent le même nom ? Repères : les accolades de la table sont une notation de dessin, pas du langage cible. Erreur du carnet : {:uninitialized_slot, numéro}.
À dire : Reprenons le masquage d'AP2 : un `x` extérieur, un `x` dans un bloc, puis `y`. Si les deux `x` partageaient la même case, le second écraserait le premier, et `x + y` vaudrait 14 au lieu de 9. Nous choisissons donc une règle simple : chaque déclaration reçoit une case neuve, ici 0, 1 puis 2. La table des noms du compilateur, elle, suit la portée : dans le bloc, `x` désigne la case 1 ; après le bloc, de nouveau la case 0. Repères : exemple du schéma et du carnet : let x = 2.0, let y = { let x = 7.0 x }, puis x + y vaut 9.0. Pas de recyclage des cases : simple à expliquer, moins compact qu'une vraie allocation.
À dire : Voici le contrat du compilateur au carnet 03. Il transporte deux informations, qui n'évoluent pas de la même façon. `names` est la table de la portée courante, d'un nom vers une case ; elle suit la structure de l'arbre, et la table d'un bloc ne sort pas du bloc. `state.slot` est le prochain numéro libre ; lui avance partout, même en sortant d'un bloc, pour ne jamais réutiliser une case encore utile. Après un bloc, on reprend donc la table du parent, mais le compteur avancé. Repères : contrat pédagogique du parcours interne. La valeur liée se compile avec l'ancienne table, puis la suite avec la nouvelle liaison. Ne pas transmettre au frère la table locale d'un enfant. let x = x x doit refuser un x initialement absent.
À dire : Voici ce que le compilateur produit pour `let x = 8.0` puis `x - 3.0`, écrit dans notre notation d'assemblage : une instruction par ligne, et un commentaire après le point-virgule. On calcule la valeur, on la range dans la case 0, on la relit, puis on calcule. Les adresses sont 0, 9, 12, 15, 24 et 25, soit 26 octets ; vous pourrez les vérifier avec votre décodeur. Repères : le point-virgule annote le listing seulement. Cet exemple est une trace de traduction, pas l'algorithme générique.
À dire : Une map associe des clés à des valeurs, comme un dictionnaire de Python, mais elle est immuable : `Map.put/3` rend une nouvelle map. `Map.fetch/2` rend `{:ok, valeur}` ou `:error`, ce qui permet de refuser un nom inconnu. Vous en manipulerez deux, à deux moments : `names` pendant la compilation, du nom vers la case, et `locals` pendant l'exécution, de la case vers la valeur. Enfin, `%{state | slot: …}` rend un état où seul le compteur a changé. À vous. Repères : faire bien distinguer la map du compilateur et celle de la VM. Le chapitre 3 du poly présente les maps ; le carnet rappelle Map.put, Map.fetch et la mise à jour au point d'emploi.
À dire : Ouvrez le carnet 03 et recopiez votre codec et votre VM. Commencez par la machine, avec `load` et `store`, puis vérifiez que `8.0 - 3.0` fonctionne encore. Passez ensuite au compilateur : vos anciennes clauses prennent trois arguments, et vous ajoutez `name` et `let`. Le point de passage est une trace de masquage juste, avec ses trois cases. Vous avez vingt-deux minutes. Repères : 22 min. Prioriser load et store et une trace de masquage ; l'adaptation de tous les cas du compilateur peut se poursuivre ensuite. Tester un bloc à gauche d'une addition avec un x extérieur relu à droite, pour détecter une fuite de portée.
À dire : Sur votre exemple de masquage, montrez-nous la table des noms, puis la mémoire finale de la machine. … Le compilateur a consommé les noms pour produire des numéros : dans le binaire, il ne reste que 0, 1 et 2. La mémoire finale garde même la case du `x` intérieur, que plus aucun nom ne désigne : elle est inaccessible parce que le code ne la relit plus, pas parce qu'elle aurait été effacée. Nos programmes calculent et nomment ; il leur reste à choisir. Repères : reprise de 5 min. Notre VM ne recycle pas les cases ; distinguer cette simplicité d'une VM de production.
À dire : Dernière étape du socle : le `case`. Le compilateur doit écrire le code des deux branches, puisqu'il ne sait pas d'avance laquelle sera prise. À l'exécution, pourtant, la machine ne doit en exécuter qu'une. Avec un PC qui avance toujours tout droit, c'est impossible. Que faut-il ajouter ? Repères : atelier 04, 45 min en tout, dont 10 min de cours. Le calcul du test laisse un booléen sur la pile ; le saut conditionnel le consomme. Aucune instruction case dans la machine : l'arbre devient du flot de contrôle.
À dire : Il faut des sauts, c'est-à-dire des instructions qui changent le PC. `JUMP_FALSE` dépile le booléen : s'il est faux, la machine saute à la branche fausse, en 16 ; sinon, elle continue tout droit dans la branche vraie. À la fin de la branche vraie, un `JUMP` saute par-dessus la branche fausse, jusqu'en 25. Sans ce second saut, que se passerait-il ? … Le chemin vrai exécuterait aussi la branche fausse et arriverait à `HALT` avec deux valeurs. Les deux chemins se rejoignent avec une seule valeur sur la pile : notre invariant est préservé. Mais comment le compilateur connaît-il ces adresses ? Repères : true en 0, jump_false 16 en 1, push 8.0 en 4, jump 25 en 13, push 3.0 en 16, halt en 25. Chemin vrai 0, 1, 4, 13, 25 ; chemin faux 0, 1, 16, 25.
À dire : Au moment où le compilateur écrit `JUMP_FALSE`, il ne connaît pas encore l'adresse de la branche fausse : il faudrait d'abord connaître la taille de toute la branche vraie. La solution consiste à écrire un nom à la place de l'adresse, une étiquette, et à marquer plus loin l'endroit qu'elle désigne. Une étiquette n'occupe aucun octet ; c'est l'assembleur qui remplacera chaque nom par une adresse. Cette idée est presque aussi vieille que les ordinateurs. Repères : c'est la forme d'un case, pas un parser d'assembleur texte à écrire : le carnet manipule une liste de tuples, dont {:label, name}. Adresses absolues ; programme d'au plus 65 535 octets.
À dire : En 1949, sur l'EDSAC, David Wheeler écrit les « initial orders », un tout petit programme chargé au démarrage qui lit des instructions notées en lettres et en décimal, et les traduit en binaire : l'un des tout premiers assembleurs. Le souci des sauts vers l'avant existait déjà, puisque la cible n'est pas connue quand on rencontre le saut. Notre solution tient en deux passes sur la liste, parce qu'une étiquette peut être utilisée avant d'être définie. Repères : 1 min. Une seule passe demanderait de revenir corriger les trous. Ne pas faire écrire un assembleur texte.
À dire : Première passe : on parcourt la liste en additionnant les tailles, sans rien encoder, et chaque fois qu'on rencontre une étiquette, on note l'adresse courante ; ici, `done` vaut 12. Deuxième passe : on reparcourt la liste, on retire les étiquettes, on remplace chaque nom par son adresse, et on encode : `JUMP done` devient `0D 00 0C`. Attention, cet exemple ne sert qu'à montrer l'assemblage : le `JUMP` saute le `PUSH`, donc le programme n'aurait pas de valeur à rendre. Et si un `case` en contient un autre ? Repères : passe 1 : une étiquette ne change pas le pc, une instruction l'avance de sa taille. Refuser les étiquettes dupliquées ou inconnues.
À dire : Un `case` peut en contenir un autre. Si les deux utilisaient les mêmes étiquettes, un saut intérieur pourrait rejoindre la fin du mauvais `case`. Le compilateur tient donc un compteur d'étiquettes, comme le compteur de cases : chaque `case` réserve un numéro neuf avant de compiler ses enfants, et en tire deux étiquettes, par exemple `{:else, n}` et `{:done, n}`. Repères : le compteur label fait partie de state, avec slot. Le compilateur n'a pas à additionner les tailles des branches : c'est le rôle de l'assembleur.
À dire : Avant d'exécuter, trois vérifications, chacune dans la brique qui peut la faire. L'assembleur vérifie que chaque étiquette utilisée est définie une seule fois. Le décodeur vérifie que les instructions sont complètes. La VM, avant de démarrer, vérifie que chaque saut vise un vrai début d'instruction, grâce à un désassemblage depuis 0 : souvenez-vous de l'adresse 3. Enfin, un budget de pas arrête un binaire qui bouclerait, ce qui ne prouve pas pour autant qu'un programme termine. Repères : un entier u16 représente 0 à 65 535 mais ne garantit pas une adresse valide. Un binaire écrit à la main peut boucler, même si le langage compilé n'a pas de boucle.
À dire : `Enum.reduce/3` est la version `Enum` de vos accumulateurs : il parcourt une liste en faisant évoluer un accumulateur, ici un couple formé de l'adresse courante et de la table des étiquettes. Vous pouvez aussi écrire la passe en récursion explicite, les deux sont acceptées. Les étiquettes sont des tuples, ce qui les rend distinctes d'un `case` à l'autre. Pour la validation des sauts, un `MapSet`, c'est-à-dire un ensemble, répond vite à la question « cette adresse est-elle un début d'instruction ? ». À vous. Repères : Totals.sum([h | t], total) du carnet 3 d'AP1 devient fn item, acc. IO.iodata_to_binary concatène les morceaux encodés ; le carnet le dit au point d'emploi.
À dire : Ouvrez le carnet 04 et recopiez vos trois modules. Commencez par l'assembleur en deux passes, sur la liste symbolique du tableau, puis les deux transitions de saut et la validation des cibles, puis la compilation du `case`. Gardez une trace correcte et expliquée : elle compte autant qu'un module terminé. Vous avez trente minutes. Repères : 30 min. Les extensions pour les rapides sont dans le carnet. Les élèves ne reçoivent pas de fonction assemble déjà terminée.
À dire : Prenons un même arbre et faisons-le calculer par votre interpréteur d'AP2, puis par votre VM. Trois tests sont particulièrement révélateurs : une soustraction pour l'ordre des opérandes, un masquage pour la portée des noms, une branche ignorée pour le flot de contrôle. Si les deux moteurs s'accordent sur ces trois-là, nous avons de bonnes raisons de croire que la traduction conserve le sens. Une différence demeure : le compilateur résout les noms dans toutes les branches, l'interpréteur seulement dans celle qu'il visite. Repères : reprise de 5 min. La référence enseignante peut servir d'oracle sans être incluse dans le carnet élève. Comparer des programmes dont tous les noms se résolvent. En mode « locomotive », enchaîner la correction de l'étape 4 au tableau, puis l'étape 05 ; sinon, passer directement au typeur.
À dire : Notre compilateur refuse encore les fonctions. Pour les exécuter, la machine doit sauter au code de la fonction puis, une fois le corps terminé, revenir juste après l'appel, avec les variables de l'appelant intactes. Il faut donc mémoriser, à chaque appel, l'adresse de retour et la mémoire de l'appelant : c'est ce qu'on appelle un cadre d'appel. Les cadres s'empilent quand une fonction en appelle une autre. Nous nous limitons aux fonctions fermées, dont le corps ne voit que ses paramètres. Repères : étape facultative et hors rendu, deux usages : les rapides l'ouvrent seuls après l'étape 4 ; ou, en locomotive, je corrige l'étape 4 au tableau et toute la classe enchaîne avec les modules corrigés. Ne pas promettre les fermetures : une variable libre est refusée à la compilation.
À dire : Voici le code de `fn(x) { x }(4.0)`, une fonction identité appelée avec 4.0. Le corps est placé sur place, mais un `jump` le saute : définir une fonction n'exécute pas son corps. `push_fn` empile la valeur fonction, qui n'est qu'une adresse et une arité ; on empile ensuite l'argument, et `call 1` empile un cadre avec l'adresse de retour, 25, avant de sauter en 3. Le corps range l'argument dans sa case, le relit, et `return` dépile le cadre, restaure la mémoire de l'appelant et revient en 25, avec 4.0 au sommet. Prédisez la profondeur des cadres à chaque instruction visitée. Repères : listing et adresses du carnet 05. Adresses visitées 0, 10, 14, 23, 3, 6, 9, 25 ; profondeur des cadres 1 pendant le corps, 0 sinon. Le prologue range les arguments dans l'ordre inverse de leur empilement.
À dire : Trois fonctions de liste suffisent. `Enum.split/2` coupe une liste après `n` éléments : appliqué à la pile, il sépare les `n` arguments du reste, en tête duquel se trouve la valeur fonction. `Enum.reverse/1` inverse une liste, ce qui sert au prologue, qui range les arguments dans l'ordre inverse. Et la pile des cadres est une simple liste, dont la longueur donne la profondeur. À vous. Repères : le carnet explique aussi Enum.reduce avec un couple environnement et état, pour attribuer une case à chaque paramètre.
À dire : Pour celles et ceux qui ont fini l'étape 4, ou pour toute la classe si nous l'avons corrigée ensemble : ouvrez le carnet 05 et recopiez vos modules de l'étape 4. Ajoutez d'abord les trois instructions au codec, puis la pile de cadres à la VM, puis la compilation des fonctions et des appels. Les vérifications commencent par l'identité, puis une fonction qui en appelle une autre. Ce carnet est facultatif et n'entre pas dans le rendu. Repères : pas de durée imposée ; en locomotive, le temps restant de l'atelier 04 après la correction. Variable libre refusée à la compilation, arité fausse et appel d'un nombre refusés à l'exécution. Autres prolongements possibles : repli des sous-arbres constants, table de constantes séparée.
À dire : Pour finir, deux programmes. Le premier, `True + 1.0` : notre machine l'exécute et s'arrête sur une erreur, au PC de l'addition. Le second rend 1.0 sans encombre, puisque la branche fausse n'est jamais exécutée. Un typeur, lui, examinerait ces programmes avant toute exécution, et les refuserait tous les deux : on n'additionne pas un booléen, et les deux branches d'un `case` doivent avoir le même type. En revanche, il laisserait passer `1.0 / 0.0`, bien typé mais faux à l'exécution. C'est le premier sujet d'approfondissement proposé en AP9 : vous y verrez comment un programme déduit ces types tout seul. Repères : environ 5 min, bilan compris. L'addition échoue au PC 10, après TRUE sur un octet et PUSH sur neuf. Le compilateur résout déjà les noms dans toutes les branches : c'est un premier contrôle statique. Le typeur ne garantit ni la terminaison ni l'absence de division par zéro. Ne pas dérouler l'algorithme : la page AP9 propose quatre carnets guidés, un diaporama de référence et un squelette mix.
À dire : Rangez vos carnets dans votre dossier AP3, avec vos essais et vos traces expliquées. Je vous préciserai les étapes à terminer pour le rendu du cycle, le 6 décembre ; l'étape 05 reste facultative et hors rendu. AP4 s'ouvrira par un quiz de dix minutes sur cette séance : relisez le chapitre 6 du poly, il suffit pour répondre. Si un bout de syntaxe vous manque, les chapitres 1 à 4 du poly sont là pour cela. Repères : dans les 5 min de clôture. Vérifier les cellules dans une session neuve, sauvegarder, commiter. Une trace argumentée et un contre-exemple montrent la compréhension même si le code est partiel. Les fiches Les octets et Le pattern matching binaire sont rattachées à AP3 sur la page de référence.