Un mini processeur en C, avec son langage assembleur (inspiré de Forth), pour comprendre comment la machine exécute vraiment les programmes.
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 ?
| Registre | Rôle | Équivalent x86-64 |
|---|---|---|
ip | Instruction Pointer — adresse de la prochaine instruction | %rip |
sp | Stack 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.
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.
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).
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 :
| Instruction | Effet | Notation pile |
|---|---|---|
PUSH v | Empiler la valeur v | … → … v |
DUP | Dupliquer le sommet | a → a a |
DROP | Jeter le sommet | a → |
SWAP | Échanger les deux premiers | a b → b a |
OVER | Copier le deuxième au sommet | a b → a b a |
| Instruction | Effet | Notation pile |
|---|---|---|
ADD | a + b | a b → (b + a) |
SUB | b − a | a b → (b − a) |
MUL | b × a | a b → (b × a) |
NEG | −a | a → (−a) |
INC | a + 1 | a → (a + 1) |
DEC | a − 1 | a → (a − 1) |
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.
| Instruction | Effet | Notation pile |
|---|---|---|
EQ | b == a | a b → (b == a) |
NE | b != a | a b → (b != a) |
GT | b > a | a b → (b > a) |
LT | b < a | a b → (b < a) |
| Instruction | Effet |
|---|---|
JMP addr | Saut inconditionnel vers addr |
JZ addr | Dépiler ; si = 0, sauter vers addr |
JNZ addr | Dépiler ; si ≠ 0, sauter vers addr |
| Instruction | Effet |
|---|---|
LOAD addr | Empiler mem[addr] |
STORE addr | Dépiler dans mem[addr] |
| Instruction | Effet |
|---|---|
PRINT | Dépiler et afficher le nombre |
PRTS addr | Afficher la chaîne en mémoire à addr (jusqu'au zéro) |
HALT | Arrêter l'exécution |
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
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
SWAP OVER MUL
se lit comme une phrase. « Échange, copie, multiplie » — on voit
ce qui se passe.
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).
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 Makefile a été enrichi avec des cibles pour la VM :
| Commande | Effet |
|---|---|
make vm-run | Compile et exécute la VM (désassemble + factorielle 0..12) |
make vm-asm | Affiche le désassemblage du programme VM |
make vm-pas | Lance la VM en mode pas à pas |
make clean | Nettoie 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
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.
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.
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.
La VM est volontairement minimale. Voici des pistes pour la suite :
| Piste | Difficulté | Description |
|---|---|---|
| Plus d'opcodes | ⭐ | Ajouter 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 utilisateur | ⭐ | Instruction 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) |
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.
| Terme | Définition |
|---|---|
| Machine virtuelle | Programme qui simule un ordinateur (registres, mémoire, jeu d'instructions) |
| Machine à pile | Architecture où les calculs se font via une pile (et non des registres accumulateurs) |
| Opcode | Code 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-Execute | Cycle fondamental de tout processeur |
| Désassembleur | Outil qui traduit le code machine (nombres) en texte lisible (instructions) |
| Forth | Langage 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 |