Aller au contenu principal

Le code intermédiaire

Ce que ce chapitre apporte

  • Exécuter un code intermédiaire à pile et suivre son état instruction par instruction.
  • Traduire une expression en notation post-fixée, opérandes d'abord.
  • Expliquer pourquoi l'absence de registres rend ce code portable, et ce que cela reporte à l'exécution.
  • Décrire ce que fait un compilateur à la volée, et ce qu'il coûte au démarrage.
  • Définir un assemblage, son manifeste, et l'exécution managée.
  • Distinguer .NET Framework, .NET Core et .NET, et nommer les deux modes de déploiement.
« Le compilateur ne produit pas des instructions machine : il produit un code intermédiaire, qu'une machine virtuelle traduit au moment de l'exécution. » Cette phrase se récite partout et ne se représente nulle part. Elle reste un article de foi tant qu'on n'a pas vu ce code-là fonctionner, et la documentation des éditeurs ne le montre jamais, parce qu'elle décrit un écosystème et non un mécanisme. Ce chapitre fait l'inverse : il exécute ce code intermédiaire une instruction par clic, puis met la même expression à côté de ce qu'un vrai processeur demanderait. La différence se lit sans un mot d'explication, et elle explique à elle seule le mot « portable ».

Une machine sans registres

Voici le code produit pour l'expression (3 + 4) * 2. Il s'exécute une instruction par clic, et la colonne de droite est tout son état.

écrit dans le programme(3 + 4) * 2pas 0 sur 5
  1. empiler 3
  2. empiler 4
  3. ajouter
  4. empiler 2
  5. multiplier
pile (0)
vide
Le code intermédiaire de (3 + 4) * 2. La colonne de droite est la pile, et c'est tout ce que la machine possède.

Cinq instructions, et aucun nom de registre. La machine ne possède qu'une pile : empiler y pose une valeur, et chaque opération retire ses opérandes du sommet puis y repose son résultat. À la fin il reste une seule valeur, qui est le résultat.

C'est la propriété centrale, et elle mérite d'être énoncée telle quelle :

À retenir
Ce code ne mentionne aucun registre, donc il ne suppose rien du processeur : ni combien il en a, ni comment ils s'appellent, ni combien de bits ils portent. C'est exactement ce qui lui permet de voyager d'une machine à l'autre.

La même expression, pour un vrai processeur

Voici maintenant la même expression, écrite pour un processeur RISC-V. Celui-là a des registres, et il faut donc en choisir.

RISC-V RV32Ipas 0 sur 4pc = 0x0

programme

  1. 0x0li t0, 3addi t0, zero, 3
  2. 0x4li t1, 4addi t1, zero, 4
  3. 0x8add t2, t0, t1
  4. 0xcslli t2, t2, 1

registres

t00
t10
t20

Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.

La même expression pour une machine à registres. Suivre t0, t1 et t2 : il a fallu décider lesquels employer.

Le résultat est le même, et tout le reste diffère. Il a fallu nommer trois registres, décider lesquels, et savoir qu'il y en avait assez. Multiplier par deux s'écrit ici comme un décalage d'un bit, parce que ce processeur-là ne sait pas multiplier sans une extension optionnelle de son jeu d'instructions.

Autrement dit : ce code est juste pour ce processeur, et faux pour le suivant. Voilà le problème que le code intermédiaire résout, et voilà le travail qu'il reporte.

Définition

Un code intermédiaire est un jeu d'instructions qui n'appartient à aucun processeur réel. Il est produit par le compilateur, indépendamment de la machine cible, et traduit en instructions machine seulement au moment de l'exécution, sur la machine qui exécute.

L'ordre post-fixé

La règle de traduction tient en une phrase : les opérandes d'abord, l'opérateur ensuite. Cette écriture s'appelle la notation post-fixée, et elle a une propriété remarquable : elle n'a besoin d'aucune parenthèse.

Comparer les deux lignes suivantes, qui ne calculent pas la même chose alors que les mêmes nombres y figurent dans le même ordre.

écrit dans le programme3 + 4 * 2pas 0 sur 5
  1. empiler 3
  2. empiler 4
  3. empiler 2
  4. multiplier
  5. ajouter
pile (0)
vide
3 + 4 × 2, où la multiplication passe avant. La pile monte à trois avant de redescendre.

Ici la pile atteint trois valeurs, parce que le 3 doit attendre que la multiplication soit finie. Dans la figure précédente elle n'en portait jamais plus de deux. La hauteur de la pile mesure ce que le calcul doit retenir en attente, et c'est une information que la version parenthésée ne montre pas.

Pourquoi les parenthèses disparaissent
En notation post-fixée, la place de l'opérateur suffit à dire sur quoi il porte : il s'applique toujours aux deux valeurs qui viennent d'être posées. Il n'existe donc aucune ambiguïté à lever, et aucune règle de priorité à connaître. La priorité a été résolue une fois pour toutes par le compilateur, au moment de produire cet ordre.

Des variables et des sauts

Une expression ne fait pas un programme. Il faut ranger des valeurs et revenir en arrière, et le code intermédiaire dispose pour cela d'exactement ce qu'il faut : ranger et charger pour la mémoire nommée, sauter et ses variantes conditionnelles pour le branchement.

Le programme ci-dessous compte à rebours. Il vaut la peine d'y suivre la pile pendant le test, parce que sauter-si-zero consomme la valeur qu'il examine, ce qui oblige à la dupliquer d'abord.

code intermédiairepas 0 sur 31
  1. boucle :
    charger n
  2. dupliquer
  3. sauter-si-zero fin
  4. afficher
  5. charger n
  6. empiler 1
  7. soustraire
  8. ranger n
  9. sauter boucle
  10. fin :
    jeter
pile (0)
vide
variablesn = 3
Un compte à rebours. Observer pourquoi il faut dupliquer avant de tester : le saut conditionnel consomme la valeur qu'il examine.

Ce programme montre aussi la discipline qu'impose une pile : le jeter final n'est pas décoratif. Sans lui, la valeur zéro qui a servi au dernier test resterait en l'air.

Ce qu'un code fautif donne

Une machine à pile se trompe d'une façon très particulière, et très reconnaissable : elle demande une valeur qui n'y est pas.

code intermédiairepas 0 sur 2
  1. empiler 7
  2. ajouter
pile (0)
vide
Un code fautif : « ajouter » réclame deux valeurs, il n'y en a qu'une. La figure s'arrête là où la pile manque.

C'est la raison pour laquelle une machine virtuelle vérifie le code intermédiaire avant de l'exécuter. Elle simule les hauteurs de pile sur tous les chemins possibles, et refuse de charger un code qui pourrait dépiler dans le vide. Un processeur, lui, n'a rien à vérifier : il exécute ce qu'on lui donne, juste ou faux.

Ce que la machine virtuelle fait de ce code

Le code intermédiaire ne s'exécute pas tel quel sur le matériel. Un moteur le traduit, et le moment où il le fait est tout le sujet.

Définitions

Le compilateur à la volée (JIT, Just In Time) traduit le code intermédiaire en instructions machine au moment où une méthode est appelée pour la première fois. Le résultat est conservé pour les appels suivants. C'est lui qui choisit enfin les registres.

La compilation anticipée (AOT) traduit à l'avance, à la construction du livrable. Le démarrage est immédiat, au prix d'un livrable lié à une architecture.

La compilation par paliers produit d'abord une version rapide à générer, puis la remplace par une version optimisée si la méthode se révèle souvent appelée.

Pourquoi la première exécution est plus lente
Chaque méthode est traduite lors de son premier appel. Une application démarre donc plus lentement qu'un binaire natif, puis atteint une vitesse comparable une fois les chemins chauds compilés. Ce n'est pas un défaut de conception : c'est le prix de la portabilité, payé au démarrage plutôt qu'à la compilation.

Le détour a un second avantage, moins souvent cité et plus profond. Le moteur dispose, au moment de traduire, d'informations que le compilateur n'avait pas : le processeur réel, ses extensions disponibles, et même les valeurs qui reviennent souvent. Il peut donc produire un code que la compilation anticipée ne pouvait pas produire.

L'assemblage, et ce qu'il transporte

Définition

Un assemblage est l'unité de déploiement et de versionnement de la plateforme .NET. Il prend la forme d'un fichier .dll ou .exe et contient le code intermédiaire, les métadonnées décrivant les types, et un manifeste.

Le manifeste est ce qui distingue un assemblage d'un simple fichier compilé. Il porte le nom de l'assemblage, sa version, sa culture, éventuellement une signature, et la liste des assemblages dont il dépend avec les versions attendues. C'est lui qui permet au moteur de charger les bonnes dépendances sans qu'un fichier de configuration extérieur ait à les énumérer.

Le langage intermédiaire de cette plateforme s'appelle le CIL, et il est commun à tous ses langages : C#, F# et Visual Basic produisent le même. C'est pourquoi une bibliothèque écrite dans l'un s'emploie depuis les autres sans adaptation.

L'exécution managée

Définition

Le CLR (Common Language Runtime) est le moteur d'exécution de la plateforme. Il charge les assemblages, compile le code intermédiaire, gère la mémoire, applique les contrôles de type et de sécurité, et traite les exceptions.

Un code exécuté sous son contrôle est dit managé. Un code compilé directement en instructions machine, hors de ce contrôle, est dit non managé.

Prise en chargeCe que cela évite
Allocation et libération de la mémoirefuites, libérations doubles, pointeurs invalides
Vérification des types au chargementconversions incohérentes détectées trop tard
Contrôle des accès mémoiredépassements de tableau silencieux
Gestion structurée des exceptionscodes de retour ignorés
Managé ne veut pas dire sans souci de mémoire
Le ramasse-miettes libère la mémoire devenue inaccessible. Il ne libère pas les ressources qui ne sont pas de la mémoire : fichiers ouverts, connexions réseau, verrous, poignées système.
Ces ressources-là restent à la charge du programmeur, à travers l'interface IDisposable et le bloc using. Le chapitre La gestion de la mémoire y revient en détail.

Framework, Core, et la suite

Trois noms coexistent dans la documentation, et ils ne désignent pas des versions successives d'une même chose.

.NET Framework.NET Core.NET 5 et suivants
SystèmesWindows seulementWindows, Linux, macOSWindows, Linux, macOS
MoteurCLRCoreCLRCoreCLR
Distributioninstallé sur la machineembarqué ou installéembarqué ou installé
Code sourceferméouvertouvert
Évolutionmaintenanceremplacé par .NET 5ligne actuelle

Le .NET Framework reste installé sur des parcs Windows entiers et continue d'être maintenu, mais il ne reçoit plus de nouveautés. .NET Core l'a réécrit pour être multiplateforme et modulaire. À partir de la version 5, les deux lignes ont été réunies sous le nom .NET, sans qualificatif, ce qui explique le saut de numérotation qui évite la confusion avec .NET Framework 4.

Le nom d'une version ne dit pas sa compatibilité
.NET Framework 4.8 et .NET 8 ne sont pas deux versions du même produit. Un projet écrit pour l'un ne se compile pas nécessairement pour l'autre, malgré la proximité des noms et du langage.
Le point de compatibilité s'appelle .NET Standard : une spécification d'interface que les deux implémentent, et à laquelle une bibliothèque peut se conformer pour être utilisable des deux côtés.

Le déploiement

Deux modes, qui n'engagent pas la même chose.

Le déploiement dépendant du framework produit un livrable léger, qui suppose que le moteur d'exécution soit déjà installé sur la machine cible et dans une version compatible. C'est le mode qui convient à un parc maîtrisé.

Le déploiement autonome embarque le moteur d'exécution avec l'application. Le livrable pèse plusieurs dizaines de mégaoctets, ne dépend de rien, et fige la version du moteur. Une faille corrigée dans le moteur suppose alors de republier l'application.

Le lien avec la conteneurisation
Un conteneur pousse le raisonnement du déploiement autonome jusqu'à son terme : il embarque l'application, le moteur d'exécution et les bibliothèques système dont ils ont besoin.
L'image obtenue s'exécute à l'identique sur le poste du développeur, sur le serveur d'intégration et en production, ce qui supprime la catégorie d'incidents où le programme fonctionne d'un côté et pas de l'autre. Le chapitre Conteneurs et images y revient.

Ce que ce mécanisme n'a rien de propre à .NET

Le vocabulaire change, le mécanisme ne change pas.

code intermédiairemoteurtraduction
.NETCILCLRà la volée, par paliers
JavabytecodeJVMà la volée, par paliers
Pythonbytecode .pycCPythoninterprété, sans traduction en machine
WebAssemblyWasmnavigateurà la volée

Les trois premiers sont des machines à pile, comme celle des figures de ce chapitre. Le quatrième aussi. Reconnaître la structure une fois dispense de la réapprendre à chaque plateforme, et c'est la raison pour laquelle ce chapitre commence par la pile plutôt que par le nom d'un produit.

Exercices type

Traduire (8 - 3) * (2 + 1) en code à pile, et dire quelle hauteur la pile atteint.

Les opérandes d'abord, l'opérateur ensuite, et ce deux fois avant la multiplication finale.

écrit dans le programme(8 - 3) * (2 + 1)pas 0 sur 7
  1. empiler 8
  2. empiler 3
  3. soustraire
  4. empiler 2
  5. empiler 1
  6. ajouter
  7. multiplier
pile (0)
vide
(8 - 3) × (2 + 1). La pile monte à trois : le premier résultat attend que le second soit calculé.

La pile atteint trois valeurs. Le résultat de la soustraction reste en attente pendant tout le calcul de la seconde parenthèse, ce qui est exactement ce qu'un registre aurait eu à retenir.

Pourquoi un code intermédiaire est-il vérifiable alors qu'un code machine ne l'est pas ?

Parce que ses instructions portent leur effet sur la pile dans leur seule définition. ajouter retire deux valeurs et en repose une, quoi qu'il arrive.

Un vérificateur peut donc parcourir tous les chemins du code et calculer la hauteur de la pile à chaque point, sans exécuter quoi que ce soit. Si un chemin mène à un ajouter avec moins de deux valeurs, le code est refusé au chargement.

Sur un processeur, la même garantie est hors d'atteinte : un registre peut contenir n'importe quoi, une adresse se calcule à l'exécution, et rien ne distingue une donnée d'un pointeur. C'est le prix de la vitesse.

Un même livrable .NET s'exécute sur un portable ARM et sur un serveur x86-64. Où se fait la différence ?

Au dernier moment, et sur la machine cible.

Le compilateur a produit du CIL, qui ne mentionne ni les registres d'ARM ni ceux de x86-64. Le moteur installé sur chaque machine traduit ce CIL en instructions de son processeur, au premier appel de chaque méthode.

La conséquence pratique est que le livrable n'a pas à être construit deux fois, et la contrepartie est qu'un moteur doit être présent des deux côtés. C'est précisément l'arbitrage que tranche le choix entre déploiement dépendant du framework et déploiement autonome.

Un programme démarre lentement puis accélère. Que s'est-il passé ?

La compilation par paliers a fait son travail.

Au premier appel, chaque méthode est traduite par une version du compilateur à la volée réglée pour produire vite, et non pour produire bien. Le programme démarre donc avec un code machine médiocre.

Le moteur compte ensuite les appels. Les méthodes qui reviennent souvent sont recompilées avec les optimisations complètes, et remplacées à chaud. Au bout de quelques secondes d'activité, les chemins qui comptent tournent au régime d'un binaire natif.

C'est aussi pourquoi une mesure de performance faite sur les premières secondes d'exécution ne veut rien dire.

Vérification

Vérification rapideon peut se reprendre

1.Que contient un code intermédiaire à pile que ne contient pas un code machine ?

2.Dans quel ordre s'écrivent les opérandes et l'opérateur ?

3.Que mesure la hauteur maximale atteinte par la pile ?

4.Quand le code intermédiaire devient-il des instructions machine ?

5.Pourquoi une machine virtuelle peut-elle refuser un code avant de l'exécuter ?

6.Qu'est-ce qui distingue un assemblage d'un simple fichier compilé ?

La méthode

Devant une plateforme qui annonce un code intermédiaire, se poser ces questions dans cet ordre.

  1. Quelle est la machine abstraite ? À pile dans la plupart des cas, et cela se vérifie en regardant si les instructions nomment des registres.
  2. Qui traduit, et quand ? À la volée au premier appel, à l'avance à la construction, ou jamais dans le cas d'un interpréteur pur.
  3. Ce que la traduction tardive achète : un seul livrable pour toutes les architectures, et une optimisation qui connaît la machine réelle.
  4. Ce qu'elle coûte : un démarrage plus lent, un moteur à installer, et des mesures de performance faussées sur les premières secondes.
  5. Ce que le livrable embarque : le moteur, ou seulement l'application. C'est ce choix, et non le langage, qui décide de la taille et des mises à jour.

Synthèse

  • Un code intermédiaire est un jeu d'instructions qui n'appartient à aucun processeur réel, produit par le compilateur et traduit à l'exécution.
  • Il s'écrit le plus souvent pour une machine à pile : empiler pose une valeur, chaque opération retire ses opérandes du sommet et y repose son résultat.
  • Il ne nomme aucun registre, et c'est cette absence qui le rend portable. Choisir les registres est le travail que la traduction reporte au dernier moment.
  • L'écriture est post-fixée, opérandes puis opérateur, ce qui supprime les parenthèses et les règles de priorité.
  • La hauteur de la pile mesure ce que le calcul doit retenir en attente.
  • Les effets sur la pile étant connus d'avance, une machine virtuelle vérifie le code avant de le charger. Un processeur ne le peut pas.
  • La compilation à la volée traduit au premier appel, la compilation par paliers remplace à chaud le code des méthodes souvent appelées, la compilation anticipée traduit à la construction.
  • Sur .NET, l'unité est l'assemblage, que son manifeste décrit, et le moteur est le CLR. Le mécanisme est le même sur Java, Python et WebAssembly : seuls les noms changent.