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.
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.
- ▸ empiler 3
- empiler 4
- ajouter
- empiler 2
- multiplier
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 :
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.
programme
- 0x0li t0, 3addi t0, zero, 3
- 0x4li t1, 4addi t1, zero, 4
- 0x8add t2, t0, t1
- 0xcslli t2, t2, 1
registres
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
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.
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.
- ▸ empiler 3
- empiler 4
- empiler 2
- multiplier
- ajouter
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.
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.
- boucle :▸ charger n
- dupliquer
- sauter-si-zero fin
- afficher
- charger n
- empiler 1
- soustraire
- ranger n
- sauter boucle
- fin :jeter
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.
- ▸ empiler 7
- ajouter
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.
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.
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
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
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 charge | Ce que cela évite |
|---|---|
| Allocation et libération de la mémoire | fuites, libérations doubles, pointeurs invalides |
| Vérification des types au chargement | conversions incohérentes détectées trop tard |
| Contrôle des accès mémoire | dépassements de tableau silencieux |
| Gestion structurée des exceptions | codes de retour ignorés |
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èmes | Windows seulement | Windows, Linux, macOS | Windows, Linux, macOS |
| Moteur | CLR | CoreCLR | CoreCLR |
| Distribution | installé sur la machine | embarqué ou installé | embarqué ou installé |
| Code source | fermé | ouvert | ouvert |
| Évolution | maintenance | remplacé par .NET 5 | ligne 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 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.
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édiaire | moteur | traduction | |
|---|---|---|---|
| .NET | CIL | CLR | à la volée, par paliers |
| Java | bytecode | JVM | à la volée, par paliers |
| Python | bytecode .pyc | CPython | interprété, sans traduction en machine |
| WebAssembly | Wasm | navigateur | à 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.
- ▸ empiler 8
- empiler 3
- soustraire
- empiler 2
- empiler 1
- ajouter
- multiplier
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
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.
- Quelle est la machine abstraite ? À pile dans la plupart des cas, et cela se vérifie en regardant si les instructions nomment des registres.
- Qui traduit, et quand ? À la volée au premier appel, à l'avance à la construction, ou jamais dans le cas d'un interpréteur pur.
- Ce que la traduction tardive achète : un seul livrable pour toutes les architectures, et une optimisation qui connaît la machine réelle.
- 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.
- 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 :
empilerpose 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.