MISE À JOUR: J'ai tellement aimé cette question que j'en ai fait le sujet de mon blog le 18 novembre 2011 . Merci pour la grande question!
Je me suis toujours demandé: quel est le but de la pile?
Je suppose que vous voulez dire la pile d'évaluation du langage MSIL, et non la pile réelle par thread lors de l'exécution.
Pourquoi y a-t-il un transfert de la mémoire vers la pile ou "chargement"? D'un autre côté, pourquoi y a-t-il un transfert de la pile vers la mémoire ou "stockage"? Pourquoi ne pas simplement les avoir tous mis en mémoire?
MSIL est un langage "machine virtuelle". Des compilateurs comme le compilateur C # génèrent du CIL , puis au moment de l'exécution un autre compilateur appelé le compilateur JIT (Just In Time) transforme l'IL en code machine réel qui peut s'exécuter.
Répondons donc d'abord à la question "pourquoi avoir MSIL?" Pourquoi ne pas simplement demander au compilateur C # d'écrire du code machine?
Parce que c'est moins cher de le faire de cette façon. Supposons que nous ne l'avons pas fait de cette façon; supposons que chaque langue doit avoir son propre générateur de code machine. Vous avez vingt langages différents: C #, JScript .NET , Visual Basic, IronPython , F # ... Et supposons que vous ayez dix processeurs différents. Combien de générateurs de code devez-vous écrire? 20 x 10 = 200 générateurs de code. Ça fait beaucoup de travail. Supposons maintenant que vous souhaitiez ajouter un nouveau processeur. Vous devez écrire le générateur de code vingt fois, un pour chaque langue.
De plus, c'est un travail difficile et dangereux. Écrire des générateurs de code efficaces pour des puces dont vous n'êtes pas un expert est un travail difficile! Les concepteurs de compilateurs sont des experts de l'analyse sémantique de leur langage, et non de l'allocation efficace des registres de nouveaux jeux de puces.
Supposons maintenant que nous le fassions de la manière CIL. Combien de générateurs CIL devez-vous écrire? Un par langue. Combien de compilateurs JIT devez-vous écrire? Un par processeur. Total: 20 + 10 = 30 générateurs de code. De plus, le générateur de langage vers CIL est facile à écrire car CIL est un langage simple, et le générateur de code CIL vers machine est également facile à écrire car CIL est un langage simple. Nous nous débarrassons de toutes les subtilités de C # et VB et ainsi de suite et tout "abaisser" à un langage simple qui est facile d'écrire une gigue pour.
Avoir un langage intermédiaire réduit le coût de la production d' un nouveau compilateur de langage radicalement . Cela réduit également considérablement le coût de la prise en charge d'une nouvelle puce. Vous voulez prendre en charge une nouvelle puce, vous trouvez des experts sur cette puce et leur faire écrire une gigue CIL et vous avez terminé; vous prenez ensuite en charge toutes ces langues sur votre puce.
OK, nous avons donc établi pourquoi nous avons MSIL; car avoir une langue intermédiaire abaisse les coûts. Pourquoi alors le langage est-il une "machine à empiler"?
Parce que les machines à empiler sont conceptuellement très simples à gérer pour les rédacteurs de compilateurs de langage. Les piles sont un mécanisme simple et facile à comprendre pour décrire les calculs. Les machines à empiler sont également très faciles à gérer sur le plan conceptuel. L'utilisation d'une pile est une abstraction simplificatrice, et donc encore une fois, elle réduit nos coûts .
Vous demandez "pourquoi avoir une pile?" Pourquoi ne pas tout faire directement de mémoire? Eh bien, réfléchissons-y. Supposons que vous souhaitiez générer du code CIL pour:
int x = A() + B() + C() + 10;
Supposons que nous ayons la convention que "ajouter", "appeler", "stocker" et ainsi de suite, toujours retirer leurs arguments de la pile et mettre leur résultat (s'il y en a un) sur la pile. Pour générer du code CIL pour ce C #, nous disons simplement quelque chose comme:
load the address of x // The stack now contains address of x
call A() // The stack contains address of x and result of A()
call B() // Address of x, result of A(), result of B()
add // Address of x, result of A() + B()
call C() // Address of x, result of A() + B(), result of C()
add // Address of x, result of A() + B() + C()
load 10 // Address of x, result of A() + B() + C(), 10
add // Address of x, result of A() + B() + C() + 10
store in address // The result is now stored in x, and the stack is empty.
Supposons maintenant que nous l'avons fait sans pile. Nous le ferons à votre façon, où chaque opcode prend les adresses de ses opérandes et l'adresse à laquelle il stocke son résultat :
Allocate temporary store T1 for result of A()
Call A() with the address of T1
Allocate temporary store T2 for result of B()
Call B() with the address of T2
Allocate temporary store T3 for the result of the first addition
Add contents of T1 to T2, then store the result into the address of T3
Allocate temporary store T4 for the result of C()
Call C() with the address of T4
Allocate temporary store T5 for result of the second addition
...
Tu vois comment ça se passe? Notre code devient énorme car nous devons allouer explicitement tout le stockage temporaire qui, normalement, par convention, irait simplement sur la pile . Pire encore, nos opcodes eux-mêmes deviennent tous énormes car ils doivent tous maintenant prendre comme argument l'adresse dans laquelle ils vont écrire leur résultat et l'adresse de chaque opérande. Une instruction "add" qui sait qu'elle va retirer deux choses de la pile et y mettre une chose peut être un seul octet. Une instruction d'ajout qui prend deux adresses d'opérande et une adresse de résultat va être énorme.
Nous utilisons des opcodes basés sur la pile car les piles résolvent le problème commun . À savoir: je veux allouer du stockage temporaire, l'utiliser très bientôt et ensuite m'en débarrasser rapidement quand j'aurai fini . En faisant l'hypothèse que nous avons une pile à notre disposition, nous pouvons rendre les opcodes très petits et le code très concis.
MISE À JOUR: Quelques réflexions supplémentaires
Soit dit en passant, cette idée de réduire considérablement les coûts en (1) spécifiant une machine virtuelle, (2) écrivant des compilateurs qui ciblent le langage VM, et (3) écrivant des implémentations de la VM sur une variété de matériel, n'est pas du tout une nouvelle idée. . Il ne provenait pas de MSIL, LLVM, du bytecode Java ou d'autres infrastructures modernes. La première mise en œuvre de cette stratégie que je connaisse est la machine pcode de 1966.
La première fois que j'ai personnellement entendu parler de ce concept, j'ai appris comment les implémenteurs d'Infocom ont réussi à faire fonctionner Zork sur autant de machines différentes. Ils ont spécifié une machine virtuelle appelée Z-machine et ont ensuite créé des émulateurs Z-machine pour tout le matériel sur lequel ils voulaient exécuter leurs jeux. Cela a eu l'énorme avantage supplémentaire qu'ils pouvaient implémenter la gestion de la mémoire virtuelle sur les systèmes primitifs 8 bits; un jeu peut être plus volumineux que ce qu'il pourrait contenir en mémoire, car il peut simplement paginer le code depuis le disque quand il en a besoin et le jeter lorsqu'il a besoin de charger un nouveau code.