🖥️ Cours 2 — Machine Virtuelle à pile

Un mini processeur en C, avec son langage assembleur (inspiré de Forth), pour comprendre comment la machine exécute vraiment les programmes.

1. Pourquoi une machine virtuelle ?

Machine virtuelle (VM) : un programme qui simule un ordinateur. Elle a sa propre mémoire, ses propres registres, et exécute son propre langage assembleur — mais le tout tourne dans un programme C.

Pourquoi faire ça ?

💡 L'idée géniale : une VM, c'est un ordinateur imaginaire qu'on construit dans un vrai programme. Java a sa JVM, Python a sa VM, et même votre navigateur a une VM JavaScript. Les VM sont partout. La nôtre est juste… beaucoup plus modeste.
🏰 Construire une VM, c'est comme être dieu dans une boîte d'allumettes : vous créez les lois de la physique, le jeu d'instructions, l'espace mémoire. Et si votre création se rebelle, il suffit de planter le programme C. Pas de prière, pas de déluge — juste un segmentation fault.

2. Architecture de la VM

📋 Registres
ip, sp
⚙️ Unité de contrôle
fetch → decode → execute
📦 Mémoire
mem[256]
🥞 Pile
stack[128]

Les registres

RegistreRôleÉquivalent x86-64
ipInstruction Pointer — adresse de la prochaine instruction%rip
spStack Pointer — nombre d'éléments sur la pile%rsp (taille implicite)

Deux registres seulement. Pas de %rax, pas de %rbp, pas de flags. La pile fait tout le travail — c'est ça, l'idée du Forth.

La mémoire

mem[256] : 256 entiers signés (int). Chaque case contient soit une donnée, soit un caractère (pour les chaînes). Accès par LOAD addr et STORE addr.

La pile

stack[128] : 128 entiers. C'est le cœur de la machine. Toutes les opérations arithmétiques, les comparaisons, les appels, se font par la pile. Pas de registres accumulateurs — juste la pile. C'est radical, et ça s'appelle une machine à pile (stack machine).

🥞 Une machine à pile, c'est comme un restaurant où le seul moyen de transporter la nourriture est de l'empiler sur votre assiette. Pour ajouter deux nombres, vous les empilez, puis vous dites « ADD » et la machine les remplace par leur somme. Pas d'accumulateur, pas de registre temporaire — juste une pile toujours plus haute. C'est spartiate, mais ça marche.

3. Le jeu d'instructions

Chaque instruction est un entier (opcode). Certaines prennent un argument supplémentaire (valeur immédiate, adresse mémoire ou adresse de saut). L'exécution suit le cycle : fetch (lire opcode) → decode (identifier) → execute (appliquer).

Nos 23 opcodes se regroupent en 6 familles :

📦 Mouvement de données

InstructionEffetNotation pile
PUSH vEmpiler la valeur v… → … v
DUPDupliquer le sommeta → a a
DROPJeter le sommeta →
SWAPÉchanger les deux premiersa b → b a
OVERCopier le deuxième au sommeta b → a b a

➕ Arithmétique

InstructionEffetNotation pile
ADDa + ba b → (b + a)
SUBb − aa b → (b − a)
MULb × aa b → (b × a)
NEG−aa → (−a)
INCa + 1a → (a + 1)
DECa − 1a → (a − 1)
💡 Attention à l'ordre : SUB calcule b − a, pas a − b. C'est l'ordre postfixé : 5 3 SUB donne 2 (5 − 3), pas −2. On dépile d'abord a, puis b.

⚖️ Comparaison (rend 0 ou 1)

InstructionEffetNotation pile
EQb == aa b → (b == a)
NEb != aa b → (b != a)
GTb > aa b → (b > a)
LTb < aa b → (b < a)

🎯 Sauts

InstructionEffet
JMP addrSaut inconditionnel vers addr
JZ addrDépiler ; si = 0, sauter vers addr
JNZ addrDépiler ; si ≠ 0, sauter vers addr

💾 Accès mémoire

InstructionEffet
LOAD addrEmpiler mem[addr]
STORE addrDépiler dans mem[addr]

📤 Entrées-sorties

InstructionEffet
PRINTDépiler et afficher le nombre
PRTS addrAfficher la chaîne en mémoire à addr (jusqu'au zéro)
HALTArrêter l'exécution
🎮 23 instructions. C'est tout. Un processeur x86-64 en a des centaines. Le premier microprocesseur (Intel 4004, 1971) en avait 46. Notre VM est donc techniquement plus simple qu'un CPU qui tenait dans une calculette. Et pourtant, elle calcule des factorielles. Preuve que la puissance n'est pas dans le nombre d'instructions mais dans ce qu'on en fait.

4. Le programme factorielle expliqué

Notre programme calcule n!. La valeur de n est sur la pile au démarrage, et le résultat y est laissé à la fin.

Algorithme (en assembleur VM) :

    STORE N        — mémoriser n dans mem[0]
    PUSH 1         — res = 1
    PUSH 2         — i = 2
  LOOP:
    DUP            — dupliquer i
    LOAD N         — empiler n
    GT             — i > n ?
    JNZ END        — si oui, fin
    SWAP           — échanger : res i  →  i res
    OVER           — i res i
    MUL            — i res*i
    SWAP           — res*i i
    INC            — i+1
    JMP LOOP
  END:
    DROP           — jeter i, garder res
    PRTS MSG       — afficher " = "
    PRINT          — afficher res
    HALT
  

État de la pile à chaque étape (pour n = 3)

  instr          pile après
  ─────────────  ──────────────────
  STORE N        [vide]
  PUSH 1         [1]
  PUSH 2         [1, 2]
  DUP            [1, 2, 2]
  LOAD N         [1, 2, 2, 3]
  GT             [1, 2, 0]   ← 2 > 3 ? non (0)
  JNZ END       [1, 2]    ← 0 == 0, on ne saute pas
  SWAP           [2, 1]
  OVER           [2, 1, 2]
  MUL            [2, 2]    ← 1 * 2
  SWAP           [2, 2]
  INC            [2, 3]
  JMP LOOP       [2, 3]
  DUP            [2, 3, 3]
  LOAD N         [2, 3, 3, 3]
  GT             [2, 3, 0]   ← 3 > 3 ? non (0)
  JNZ END       [2, 3]
  SWAP           [3, 2]
  OVER           [3, 2, 3]
  MUL            [3, 6]    ← 2 * 3
  SWAP           [6, 3]
  INC            [6, 4]
  JMP LOOP       [6, 4]
  DUP            [6, 4, 4]
  LOAD N         [6, 4, 4, 3]
  GT             [6, 4, 1]   ← 4 > 3 ? oui (1)
  JNZ END       [6, 4]    ← 1 ≠ 0, on saute
  DROP           [6]      ← on jette i
  PRTS MSG       [6]      ← affiche " = "
  PRINT          []      ← affiche 6 et dépile
  HALT           stop
  
🔍 Regardez bien la trace : la pile monte et descend comme un yo-yo. Chaque instruction l'équilibre différemment. Le génie du Forth, c'est que la pile est auto-documentée : SWAP OVER MUL se lit comme une phrase. « Échange, copie, multiplie » — on voit ce qui se passe.

5. Le cycle d'exécution (fetch-decode-execute)

Le cœur de la VM est une boucle simple :

while (ip < taille) {
    // 1. FETCH  : lire l'instruction à l'adresse ip
    int instr = code[ip++];

    // 2. DECODE : aiguiller selon l'opcode
    switch (instr) {
        case PUSH: // 3. EXECUTE
            pousser(code[ip++]);
            break;
        case ADD:  ...
    }
}

C'est exactement ce que fait un vrai processeur, en plus compliqué et en plus parallèle. Le principe est le même depuis 1945 (architecture de von Neumann).

🚀 Fetch-Decode-Execute : le cycle fondamental de tout processeur. 1. On lit l'instruction. 2. On comprend ce qu'elle demande. 3. On l'exécute. 4. On passe à la suivante. Des milliards de fois par seconde.
⏱️ Notre VM fait le cycle… à la vitesse du C. Pas des milliards par seconde, plutôt des centaines de milliers. Mais le principe est là. C'est comme comparer une trottinette à une Formule 1 : les deux ont un volant, des roues, et vont tout droit. (Bon, la trottinette va moins vite, mais elle consomme moins de silicium.)

6. Mode pas à pas

Le mode pas à pas (--pas-a-pas) affiche chaque instruction et l'état de la pile avant de l'exécuter. Utile pour comprendre ce qui se passe — ou pour déverminer un programme qui ne marche pas.

     0:  STORE 0    pile[5]        ← on attend Entrée
     2:  PUSH  1    pile[]
     4:  PUSH  2    pile[1]

Chaque getchar() attend un appui sur Entrée. C'est rudimentaire, mais ça fait le travail.

🐢 Le mode pas à pas, c'est le ralenti du processeur. L'équivalent de « et là, je prends le nombre, je le mets sur la pile, et là je le multiplie… » Version non-speech, juste des chiffres qui défilent. C'est moins glamour qu'un film d'action, mais plus utile pour comprendre.

7. Compilation et exécution

Le Makefile a été enrichi avec des cibles pour la VM :

CommandeEffet
make vm-runCompile et exécute la VM (désassemble + factorielle 0..12)
make vm-asmAffiche le désassemblage du programme VM
make vm-pasLance la VM en mode pas à pas
make cleanNettoie tout (factorielle + VM)

Compilation manuelle :

gcc -Wall -Wextra -std=c99 -o vm vm.c
./vm                     # exécution normale
./vm --pas-a-pas         # mode pas à pas
💡 Pour le cours : commencez par make vm-asm pour voir le programme, puis make vm-pas pour suivre l'exécution pas à pas. Les élèves verront la pile vivre en direct.

8. Le désassembleur intégré

La VM sait afficher son propre programme sous forme lisible — c'est le désassembleur. Il prend le tableau d'entiers et le traduit en texte :

   0:  STORE 0
   2:  PUSH  1
   4:  PUSH  2
   6:  DUP
   7:  LOAD  0
   9:  GT
  10:  JNZ   19
  12:  SWAP
  13:  OVER
  14:  MUL
  15:  SWAP
  16:  INC
  17:  JMP   6
  19:  DROP
  20:  PRTS  200
  22:  PRINT
  23:  HALT

Chaque ligne donne l'adresse (ip) et l'instruction. Les instructions à argument (PUSH, JMP, LOAD, STORE, JZ, JNZ, PRTS) sont suivies de leur opérande.

🔧 Le désassembleur, c'est le traducteur simultané du code machine. Vous avez un tableau d'entiers illisibles (24, 0, 13, 1…) et il vous sort STORE 0, PUSH 1. C'est magique, mais c'est juste un if sur l'opcode dans une boucle. La magie, c'est juste de l'ingénierie qu'on ne comprend plus.

9. Variations et prolongements

La VM est volontairement minimale. Voici des pistes pour la suite :

PisteDifficultéDescription
Plus d'opcodesAjouter AND, OR, NOT, MOD, PICK, ROT
Variables locales⭐⭐Un registre bp (base pointer) et des instructions LOADBP, STOREBP
Pile de retour⭐⭐Ajouter une seconde pile pour les appels de fonction (CALL, RET)
Entrée utilisateurInstruction INPUT qui lit un entier au clavier
Assembleur texte⭐⭐⭐Écrire un mini-assembleur qui lit du texte (comme le source Forth donné dans vm.c)
Mode graphique⭐⭐⭐Afficher la pile et la mémoire en direct (ncurses ou SDL)
🎯 Idée de TP : ajouter l'instruction LOOP qui exécute DUP LOAD N GT JNZ END SWAP OVER MUL SWAP INC JMP LOOP END DROP en une seule instruction micro-codée. Les élèves verront comment on construit des instructions complexes à partir de primitives simples. C'est exactement comme ça que les vrais processeurs CISC fonctionnent.
🧱 La VM est votre LEGO. Vous pouvez ajouter des briques, en enlever, les combiner différemment. Un processeur RISC (ARM, RISC-V), c'est une VM qui a été gravée dans du silicium. Notre VM, c'est du RISC en logiciel — aucun risque de brûler votre carte mère si vous vous trompez. (Sauf si vous mettez le feu à votre ordinateur, mais ça, c'est un autre problème.)

10. Résumé — Vocabulaire à retenir

TermeDéfinition
Machine virtuelleProgramme qui simule un ordinateur (registres, mémoire, jeu d'instructions)
Machine à pileArchitecture où les calculs se font via une pile (et non des registres accumulateurs)
OpcodeCode numérique représentant une instruction (ex: 7 = LOAD)
Instruction Pointer (ip)Registre contenant l'adresse de la prochaine instruction à exécuter
Stack Pointer (sp)Registre contenant le nombre d'éléments sur la pile (ou l'adresse du sommet)
Fetch-Decode-ExecuteCycle fondamental de tout processeur
DésassembleurOutil qui traduit le code machine (nombres) en texte lisible (instructions)
ForthLangage de programmation basé sur une machine à pile, postfixé, minimaliste
Postfixé (RPN)Notation où l'opérateur est écrit après ses opérandes : 1 2 ADD au lieu de 1 + 2
📚 Vous savez maintenant ce qu'est une VM, comment elle tourne, et pourquoi la pile n'est pas qu'un truc de lessive. Prochaine étape : ajouter une FPU, un cache L3, et faire tourner Linux dessus. (Bon, peut-être pas. Mais on peut rêver.)