- Pour comprendre le fonctionnement interne des ordinateurs et la manière dont les langages de programmation s’exécutent, l’auteur a implémenté lui-même une VM en C d’environ 250 lignes qui exécute des programmes assembleur sur l’architecture pédagogique LC-3
- La cible de l’implémentation est un petit modèle d’ordinateur avec 65 536 emplacements mémoire de 16 bits, 10 registres, 16 opcodes, des drapeaux de condition, des trap routines et des registres mappés en mémoire
- La boucle d’exécution fonctionne selon une structure fetch-decode-execute : elle lit l’instruction pointée par
PC, l’incrémente, interprète l’opcode, puis exécute des instructions commeADD,LDI,BR,JMPouTRAP - Le chargement d’un programme lit l’origin 16 bits au début du fichier objet pour le placer en mémoire, puis effectue un byte swap afin d’adapter le format big-endian de LC-3 au format little-endian utilisé par la plupart des ordinateurs modernes
- Les entrées clavier et la sortie console sont gérées par les trap routines et les registres mappés en mémoire
KBSR/KBDR, avec du code différent selon Unix/macOS et Windows pour la mise en tampon des entrées du terminal
Objectifs et prérequis du tutoriel
- Le tutoriel suit la mise en œuvre d’une machine virtuelle LC-3 permettant d’exécuter des programmes en langage assembleur
- Le code final fait environ 250 lignes en C, avec un fichier
lc3.cpour Unix etlc3-win.cpour Windows - Les prérequis sont la lecture de C ou C++ de base et l’arithmétique binaire
- Le code complet se trouve dans le repo GitHub, et le tutoriel lui-même est au format literate program : les blocs de code sont assemblés pour produire la source finale
Ce que fait une machine virtuelle
- Une VM est un programme qui se comporte comme un CPU et certains composants matériels
- Elle effectue des opérations arithmétiques
- Elle lit et écrit en mémoire
- Elle interagit avec des périphériques d’E/S
- Elle comprend son propre langage machine et exécute des programmes
- Selon son objectif, une VM peut reproduire fidèlement du matériel réel ou proposer une nouvelle architecture virtuelle pour faciliter le développement logiciel
- La JVM est un exemple représentatif de VM fournissant une plateforme d’exécution standard ; sur les appareils où la JVM est implémentée, les programmes Java, Kotlin et Clojure peuvent s’exécuter sans modification
- L’exécution isolée est aussi un usage important des VM
- Dans le garbage collection, la VM peut observer la pile et les références mémoire en dehors du programme en cours d’exécution
- Les smart contracts Ethereum s’exécutent dans une VM qui n’a pas accès au système de fichiers, au réseau ni au disque
Composition de l’architecture LC-3
- La cible de l’implémentation est LC-3, utilisé dans l’enseignement universitaire de l’architecture des ordinateurs et de l’assembleur
- La mémoire LC-3 comporte 65 536 emplacements, chacun stockant une valeur de 16 bits
- La capacité totale est de 128 Ko
- Dans l’implémentation C, elle est représentée par le tableau
uint16_t memory[MEMORY_MAX]
- Il y a 10 registres au total
R0àR7: 8 registres générauxPC: l’adresse mémoire de la prochaine instruction à exécuterCOND: le drapeau de condition du dernier résultat calculé
- Toutes les instructions LC-3 font 16 bits, et les 4 bits de gauche correspondent à l’opcode
- 16 opcodes sont définis
- Ils incluent
OP_BR,OP_ADD,OP_LD,OP_ST,OP_JSR,OP_AND,OP_LDR,OP_STR,OP_RTI,OP_NOT,OP_LDI,OP_STI,OP_JMP,OP_RES,OP_LEAetOP_TRAP
- Les drapeaux de condition indiquent le signe du dernier résultat calculé
FL_POS: positifFL_ZRO: zéroFL_NEG: négatif
Assembleur et langage machine
- Ce que la VM LC-3 exécute réellement n’est pas l’assembleur lisible par un humain, mais un tableau d’instructions machine 16 bits
- L’assembleur convertit l’assembleur LC-3 écrit en texte en instructions binaires 16 bits
- L’exemple
Hello Worldsuit le déroulé suivant.ORIG x3000: indique l’adresse mémoire où le programme sera chargéLEA R0, HELLO_STR: charge l’adresse de la chaîne dansR0PUTS: affiche la chaîne pointée parR0HALT: arrête le programme.STRINGZ "Hello World!": stocke les données de la chaîne dans le programme
.ORIGet.STRINGZne sont pas des instructions CPU, mais des directives de l’assembleur- Les conditions et les boucles sont implémentées avec des instructions de branchement proches d’un
goto, commeBRn LOOP
Procédure centrale de la boucle d’exécution
- L’exécution de la VM répète la même procédure
- Lire l’instruction à l’adresse du registre
PC - Incrémenter
PC - Obtenir l’opcode depuis les 4 bits de poids fort de l’instruction
- Exécuter le code correspondant à l’opcode
- Lire à nouveau l’instruction suivante
- Lire l’instruction à l’adresse du registre
- L’adresse de départ par défaut est
0x3000 - Certaines instructions modifient directement
PCpour faire sauter le flux d’exécution- Grâce aux instructions de branchement et de saut, les boucles et l’exécution conditionnelle restent possibles même avec une structure qui se contente sinon d’incrémenter
PC
- Grâce aux instructions de branchement et de saut, les boucles et l’exécution conditionnelle restent possibles même avec une structure qui se contente sinon d’incrémenter
- La boucle
mainappelle le code de traitement propre à chaque opcode viaswitch (op)- Elle traite
OP_ADD,OP_AND,OP_NOT,OP_BR,OP_JMP,OP_JSR,OP_LD,OP_LDI,OP_LDR,OP_LEA,OP_ST,OP_STI,OP_STRetOP_TRAP OP_RESetOP_RTIsont des opcodes inutilisés et peuvent être gérés parabort()
- Elle traite
Mode d’implémentation des instructions
ADDadditionne deux valeurs, stocke le résultat dans le registre de destination et met à jour les drapeaux de conditionADDa deux modes- Mode registre : le second opérande est lu depuis un autre registre
- Mode immédiat : le second opérande est lu depuis les 5 bits de poids faible
imm5de l’instruction
- Les valeurs plus courtes que 16 bits, comme
imm5, doivent être étendues en valeur 16 bits par extension de signe- Les valeurs positives sont complétées par des 0
- Les valeurs négatives sont complétées par des 1 afin de préserver la valeur d’origine
- Les instructions qui écrivent une valeur dans un registre mettent à jour
R_CONDavecupdate_flags- Si la valeur vaut 0 :
FL_ZRO - Si le bit de poids fort vaut 1 :
FL_NEG - Sinon :
FL_POS
- Si la valeur vaut 0 :
LDIest l’instruction « load indirect »- Elle applique une extension de signe au
PCoffset9de l’instruction - Elle l’ajoute au
PCcourant pour obtenir une adresse mémoire - Elle utilise ensuite la valeur stockée à cet emplacement comme adresse pour lire la donnée finale
- Elle stocke la valeur lue dans le registre de destination et met à jour les drapeaux de condition
- Elle applique une extension de signe au
Jeu d’instructions principal
- Arithmétique et opérations bit à bit
ADD: additionAND: AND bit à bitNOT: NOT bit à bit
- Flux de contrôle
BR: déplacePCen comparant les drapeaux de condition aux bits de condition de l’instructionJMP: définitPCà la valeur du registre indiquéRET: mot-clé séparé dans la spécification, mais cas particulier deJMPJSR,JSRR: stockent lePCcourant dansR7et sautent à l’emplacement de la sous-routine
- Lecture mémoire
LD: lit à l’adresse offset relative àPCLDI: suit une adresse indirecte supplémentaire avant de lireLDR: lit à l’adresse calculée avec un registre de base et un offsetLEA: stocke l’adresse effective elle-même dans un registre
- Écriture mémoire
ST: stocke à l’adresse offset relative àPCSTI: stocke en suivant une adresse indirecteSTR: stocke à l’adresse calculée avec un registre de base et un offset
Trap routines et E/S
- LC-3 fournit des trap routines pour les tâches communes et l’accès aux périphériques d’E/S
- Une trap routine peut être vue comme le système d’exploitation ou l’API de LC-3
- Les trap codes sont définis comme suit
TRAP_GETC = 0x20: saisie d’un caractère au clavier, sans écho dans le terminalTRAP_OUT = 0x21: sortie d’un caractèreTRAP_PUTS = 0x22: sortie d’une word stringTRAP_IN = 0x23: saisie d’un caractère puis écho dans le terminalTRAP_PUTSP = 0x24: sortie d’une byte stringTRAP_HALT = 0x25: arrêt du programme
- Dans le simulateur LC-3 officiel, les trap routines sont écrites en assembleur, mais dans cette VM elles sont implémentées sous forme de fonctions C
PUTSaffiche les caractères depuis l’adresse stockée dansR0jusqu’à rencontrerx0000- Les chaînes LC-3 ne stockent pas un caractère par octet comme les chaînes C, mais un caractère par emplacement mémoire
- Chaque emplacement mémoire faisant 16 bits, il est converti en
charlors de la sortie en C
- Le trap
HALTaffiche"HALT", puis met le drapeau d’exécution à 0 pour quitter la boucle de la VM
Chargement d’une image de programme
- Convertir un programme assembleur LC-3 en langage machine produit un fichier contenant un tableau d’instructions et de données
- Les premiers 16 bits du fichier objet sont l’origin, qui indique où placer le programme en mémoire
- Le loader lit d’abord l’origin, puis copie le reste des données en mémoire à partir de cette adresse
- Les programmes LC-3 sont au format big-endian
- La plupart des ordinateurs modernes étant little-endian,
swap16est appliqué à chaqueuint16_tchargé - Sur les ordinateurs big-endian, comme les anciens Mac PPC, il ne faut pas effectuer ce swap
- La plupart des ordinateurs modernes étant little-endian,
read_imageouvre le fichier en mode binaire, appelleread_image_file, puis ferme le fichier
Registres mappés en mémoire
- Les registres spéciaux auxquels on n’accède pas via la table classique des registres sont mappés à des adresses mémoire spécifiques
- Dans LC-3, deux registres mappés en mémoire doivent être implémentés
MR_KBSR = 0xFE00: keyboard status registerMR_KBDR = 0xFE02: keyboard data register
KBSRindique si une touche a été pressée, etKBDRstocke la touche presséeGETCbloque l’exécution jusqu’à recevoir une entrée, maisKBSRetKBDRinterrogent l’état du périphérique par polling, ce qui permet au programme de continuer à réagir pendant l’attente d’entrée- Les lectures mémoire passent par
mem_readau lieu de lire directement le tableau- Si l’adresse est
MR_KBSR, l’état du clavier est vérifié aveccheck_key() - S’il y a une touche, le bit de poids fort de
KBSRest activé et la valeur degetchar()est stockée dansKBDR - S’il n’y a pas de touche,
KBSRest réglé à 0
- Si l’adresse est
Gestion du terminal selon la plateforme
- Pour gérer correctement les entrées clavier et le comportement du terminal, il faut configurer la mise en tampon des entrées selon la plateforme
- L’implémentation Linux/macOS/UNIX utilise
termios,select, etc.- Elle désactive le mode canonique et l’écho
- Elle vérifie la disponibilité d’une entrée avec
select
- L’implémentation Windows utilise
GetStdHandle,GetConsoleMode,SetConsoleMode,_kbhit, etc.- Elle ajuste l’écho et la saisie ligne par ligne
- Elle vérifie les frappes avec
WaitForSingleObjectet_kbhit
- Au démarrage du programme,
disable_input_buffering()est appelé, puisrestore_input_buffering()à la fin - À la réception de
SIGINT, le programme restaure les paramètres du terminal, affiche un saut de ligne, puis se termine
Exécution et débogage de la VM
- Exemple de build de la VM
gcc lc3.c -o lc3-vm
- Pour l’exécuter, il faut passer en argument le fichier objet LC-3 assemblé
lc3-vm path/to/2048.obj
- Les fichiers objets fournis en exemple sont
2048.objetrogue.obj - L’exemple 2048 se contrôle avec les touches WASD
- Si le programme ne fonctionne pas correctement, il est probable qu’une instruction soit mal implémentée
- La méthode recommandée consiste à lire la source assembleur LC-3 et à exécuter pas à pas les instructions de la VM dans un débogueur
- Si l’exécution n’arrive pas à l’instruction attendue, il faut revérifier la spécification et l’implémentation de cette instruction
Option : implémentation C++ basée sur les génériques
- Une implémentation C++ plus courte est également présentée en option
- Comme plusieurs instructions partagent des opérations répétées — extension de signe, offset relatif à
PC, calcul d’adresse indirecte — l’exécution des instructions peut être vue comme un pipeline de petites étapes de traitement - En utilisant des templates C++ et des drapeaux de bits, seules les étapes nécessaires à chaque opcode sont incluses à la compilation
- Cette approche réduit la duplication de code et se rapproche davantage de la manière dont le câblage matériel réel occupe de l’espace physique sur une puce
- L’émulateur NES de Bisqwit est mentionné comme source de l’idée
Ressources et contributions
- atul-g a contribué une reference card qui résume le fonctionnement de tout le système
- Les implémentations dans différents langages sont regroupées sous le topic GitHub
lc3- C, C++, Go, Haskell, Java, JavaScript, Kotlin, Lua, OCaml, Python, Ruby, Rust, Swift, TypeScript, Zig, entre autres
- Pour faire apparaître sa propre implémentation dans la liste, il suffit d’ajouter le topic GitHub
lc3 - La prise en charge de la plateforme Windows a été apportée par inkydragon
- Le projet comporte une good first issue liée aux tests d’intégration
1 commentaires
Commentaires sur Hacker News
Quand j’étais ado, dans un cours d’introduction à l’informatique dans un community college, nous avions conçu un simple jeu d’instructions CPU, puis créé nous-mêmes une machine virtuelle et un assembleur pour écrire et exécuter des programmes en assembleur.
C’était étonnamment facile, et l’ordinateur m’a semblé beaucoup moins mystérieux.
De la conception d’un vrai CPU pour FPGA jusqu’à l’écriture d’un petit système d’exploitation et des programmes qui tournent dessus, j’ai l’impression qu’on pourrait apprendre toutes les couches de l’informatique de cette manière.
Si l’on met de côté les exigences de performance et de sécurité de l’informatique moderne, et que l’objectif est simplement « que ça fonctionne », ce domaine est étonnamment simple.
Si ma mémoire est bonne, il y a au minimum la segmentation mémoire, le mode protégé et une MMU.
C’était un petit ordinateur/assembleur écrit en BASIC sur PDP, et l’un des devoirs consistait à implémenter une multiplication simple en faisant des additions dans une boucle.
Un ami a plutôt modifié le programme pour ajouter une nouvelle instruction MUL, et le professeur n’a pas du tout apprécié.
Quelqu’un de curieux et désireux d’apprendre peut facilement assimiler ces couches de base, mais ce n’est pas le cas de quelqu’un qui veut « gagner de l’argent rapidement et devenir employable le plus vite possible ».
Livres recommandés :
Si quelqu’un a lu ces livres et peut ajouter un commentaire, ce serait utile à tout le monde.
Un émulateur Nintendo, un hyperviseur utilisant VT-x, un système d’exploitation multitâche traditionnel, l’interpréteur d’un nouveau langage de script, un optimiseur de requêtes SQL, un moteur d’expressions régulières, un moniteur de sécurité qui exécute du code de joueurs non fiables sur un serveur de jeu : tout cela semble partager très peu de considérations, mais ce sont tous des machines virtuelles.
Il existe même une machine virtuelle à pile dans le format terminfo qui spécifie les séquences d’échappement des terminaux en cellules de caractères.
Si l’on regarde en profondeur, ce qui fait qu’un ordinateur est un ordinateur au sens actuel, ce sont les machines virtuelles, et l’article de Turing de 1936 sur l’Entscheidungsproblem reposait lui aussi sur le fait que des machines virtuelles peuvent s’imiter mutuellement.
Après avoir regardé la série de Ben Eater sur un CPU sur breadboard, je n’ai plus qu’une envie : concevoir et émuler un CPU moi-même.
J’aimerais trouver le temps de m’asseoir et de le concevoir.
Je pense que les architectures pédagogiques comme Brookshear Machine ou Little Computer ne ressemblent absolument pas aux architectures réelles, au point d’être non seulement inutiles mais nuisibles.
J’ai vu des étudiants ayant suivi des cours qui utilisaient ce genre de choses comprendre les ordinateurs de façon encore plus déformée que des personnes n’ayant suivi aucun cours.
Pour la plupart des gens qui veulent simplement comprendre un peu comment fonctionne leur ordinateur, un cours sur les systèmes d’exploitation est plus utile, et si l’on n’a le temps que pour un court tutoriel, je recommanderais « Writing my own bootloader ».
https://dev.to/frosnerd/writing-my-own-boot-loader-3mld
Cela ne veut pas dire que le tutoriel « Write your own VM » est mauvais, mais, d’après mon expérience, la plupart des personnes qui le suivraient tireraient davantage profit d’un autre sujet.
Peux-tu expliquer davantage pourquoi LC-3 est mauvais pour étudier l’architecture des ordinateurs ?
Je comprends que ce soit complètement différent du matériel réel et trop simplifié, mais je me demande si c’est aussi mauvais du point de vue de l’écriture d’un émulateur de CPU.
C’était une machine décimale qui aurait pu être construite dans les années 1960, mais que plus personne n’a construite après les années 1970.
Ce type de système peut enseigner beaucoup de bases, mais les techniques de https://en.wikipedia.org/wiki/Hacker%27s_Delight reposent surtout sur les représentations numériques courantes, ce qui les rend difficiles à apprendre dans ce contexte.
Comme je ne connais pas bien, j’ai rapidement regardé Wikipédia ; vu la BD, je m’attendais à quelque chose d’étrange, mais au premier abord cela ne m’a pas paru si choquant.
On dirait un mélange de s/360, d’un peu de x86, et d’un tout petit peu d’ARM ou d’une autre architecture de type RISC ; il y a beaucoup d’omissions et de bizarreries, mais l’objectif semble être d’arriver rapidement à une implémentation fonctionnelle.
J’aimerais savoir ce qui te fait dire que c’est « non seulement inutile mais nuisible » pour l’enseignement.
Dans beaucoup de cours d’informatique en Inde, il semble qu’on utilise encore le 8086/8088.
Il permet notamment de faire un chargement doublement indirect via un mot relatif au PC placé au milieu.
Malgré cela, la soustraction doit être construite à partir de la négation, et la négation doit être construite avec NOT et ADD ,,#-1.
Vu l’espace limité d’encodage des instructions, NOT d,s = XOR d,s,#-1 aurait probablement été un meilleur usage.
À proprement parler, ce n’est pas une machine virtuelle, mais un émulateur.
Au sens descriptif, le terme peut s’appliquer, et avant l’ère de la virtualisation matérielle il y avait une certaine ambiguïté, mais aujourd’hui l’usage de loin le plus courant de « Virtual Machine » désigne un environnement qui utilise des fonctions de virtualisation matérielle comme VT-x.
La JVM est largement déployée, l’Ethereum VM est appelée EVM, https://www.linuxfoundation.org/hubfs/LF%20Research/The_Stat... décrit à plusieurs reprises BPF et eBPF comme des « virtual machines », et https://webassembly.org/ commence par : « WebAssembly (abrégé Wasm) est un format d’instructions binaires pour une machine virtuelle à pile ».
« Machine virtuelle » reste le terme le plus courant pour désigner une machine virtuelle.
Personnellement, je préfère des expressions comme « fictive machine », « fictious machine », « imaginary computer » ou « fantastic automaton », mais elles ont peu de chances d’être adoptées.
On ne peut pas toujours employer « émulateur » à la place de « machine virtuelle ».
On peut peut-être appeler wasmtime un émulateur, mais qualifier WebAssembly lui-même d’émulateur n’est pas exact : WebAssembly est la machine virtuelle que wasmtime émule.
Il est aussi courant d’appeler un émulateur une machine virtuelle, et une instance d’émulateur en cours d’exécution est également une machine virtuelle dans un autre sens.
Il est aussi légitime d’appeler « machine virtuelle » un environnement de virtualisation matérielle, et ce dernier sens recoupe dans une certaine mesure le précédent.
Dans le contexte actuel, cet usage peut être de loin le plus courant, mais ce n’est pas forcément le cas ailleurs.
Au sens le plus pur, une machine virtuelle n’est rien d’autre qu’un ordinateur fabriqué de toutes pièces, sans impliquer à quoi il servira ni comment il fonctionne.
L’article prend bien l’émulation de consoles classiques comme exemple, mais il est clair qu’au regard de la définition proposée, les machines virtuelles possibles sont bien plus nombreuses.
Le point essentiel est qu’une machine virtuelle est un concept abstrait, et qu’il en existe de très nombreux types.
Simulateurs, émulateurs, hyperviseurs, etc. sont tous des machines virtuelles, et il existe aussi des formes étranges de machines virtuelles qui n’ont pas encore reçu de nom.
Je ne cherche pas à être désagréable ; au contraire, c’est par respect, et parce que je veux clarifier ce terme pour les personnes qui apprennent.
« Machine virtuelle » est couramment employé pour tout logiciel qui exécute du langage machine ou du bytecode, quelle qu’en soit la raison.
Cela peut inclure la virtualisation, mais le terme est aussi souvent utilisé pour des runtimes de langage, comme la JVM de Java ou YARV de Ruby (Yet Another Ruby VM).
Le domaine où l’on entend plutôt moins souvent ce terme est justement l’émulation, en partie parce que la plupart des émulateurs modernes tendent à recourir à la recompilation dynamique du logiciel cible plutôt qu’à émuler un système complet.
Java est sans doute suffisamment répandu pour relever de l’« usage de loin le plus courant ».