Des tableaux aux enum, de la pile au bytecode —
on fabrique une machine virtuelle à pile en 300 lignes de C.
Parce qu'un processeur logiciel, c'est plus facile à débuguer.
Au cours précédent, on a écrit factorielle.c —
un programme C qui calcule n! et l'affiche. C'était concret,
ça marchait, et on pouvait le tester.
Maintenant, on va se poser une question étrange : comment ça marche, un processeur ? Pas le processeur physique, avec ses transistors et ses nanomètres — ça, c'est pour les électroniciens. Mais le modèle d'exécution : comment une machine fait-elle pour exécuter des instructions les unes après les autres ?
La réponse, c'est la Machine Virtuelle (VM) : un mini-processeur logiciel, avec sa propre mémoire, sa propre pile, et son propre jeu d'instructions. On va le programmer en C, en utilisant les concepts du cours 1 (variables, boucles, tableaux, fonctions) pour construire quelque chose de plus gros.
Dans le cours 1, on a vu les variables simples (int,
long long, etc.) et les boucles. Mais pour une VM,
il nous faut plus : des séquences de valeurs
(la mémoire, la pile, le programme) et des
noms symboliques pour les instructions.
Un tableau, c'est une suite contiguë de variables du même type. On y accède par un indice (ou index).
int mem[256]; // un tableau de 256 entiers mem[0] = 42; // écrire dans la case 0 int x = mem[0]; // lire la case 0 for (int i = 0; i < 256; i++) mem[i] = 0; // initialiser tout le tableau
| Syntaxe | Sens |
|---|---|
int t[10]; | Déclare un tableau de 10 entiers |
t[0] | Premier élément (indice 0) |
t[9] | Dernier élément (indice taille-1) |
t[i] | Accès à l'élément d'indice i |
mem[256] ou
mem[-1] accèdent à de la mémoire qui n'est pas
dans le tableau — c'est un buffer overflow,
source classique de bugs et de failles de sécurité.
Une énumération (enum)
permet de donner des noms à des entiers. C'est plus lisible
que de se rappeler que l'instruction 0 c'est PUSH, 1 c'est DUP…
enum { PUSH, // = 0 DUP, // = 1 DROP, // = 2 ADD, // = 3 HALT // = 4 }; int programme[] = { PUSH, 42, PRINT, HALT };
| Code | Sans enum | Avec enum |
|---|---|---|
| Déclaration | int op = 0; | int op = PUSH; |
| Test | if (op == 0) | if (op == PUSH) |
| Tableau | {0, 42, 21, 4} | {PUSH, 42, PRINT, HALT} |
enum { N = 0, MSG = 200 };.
if (instr == 12) … — c'est quoi le 12 ? MUL ? GT ?
On est obligés de compter les opcodes dans l'ordre. Avec les enum,
c'est if (instr == MUL) — clair comme de l'eau
de roche. Ou comme de l'eau de roche avec des parenthèses.
L'architecture de notre VM est une machine à pile, aussi appelée architecture postfixée ou Forth-like. C'est l'une des plus simples à implémenter : les opérations prennent leurs arguments sur une pile et y déposent leur résultat.
Une pile (anglais : stack) fonctionne comme une pile d'assiettes : on ne peut empiler ou dépiler que par le dessus. C'est le principe LIFO (Last In, First Out).
Pile = [3, 7, 5] — le sommet est 5, la base est 3.
| Opération | Avant | Après |
|---|---|---|
pousser(9) | [3, 7, 5] | [3, 7, 5, 9] |
depiler() | [3, 7, 5] | [3, 7] — retourne 5 |
sommet() | [3, 7, 5] | [3, 7, 5] — retourne 5 |
Dans notre VM, la pile est implémentée par un tableau et un indice de sommet (sp) :
int stack[STACK_SIZE]; // le tableau int sp; // stack pointer : indice du prochain libre void pousser(int v) { if (sp >= STACK_SIZE) { fprintf(stderr, "DEBORDEMENT DE PILE\n"); exit(1); } stack[sp++] = v; // sp pointe toujours sur la case libre } int depiler(void) { if (sp <= 0) { fprintf(stderr, "PILE VIDE\n"); exit(1); } return stack[--sp]; // décrémenter puis lire }
Dans la vie de tous les jours, on écrit les opérations en
notation infixée : 2 + 3.
La VM utilise la notation postfixée
(RPN = Reverse Polish Notation) :
2 3 +.
| Infixée | Postfixée (RPN) | Évaluation VM |
|---|---|---|
2 + 3 | 2 3 + | PUSH 2, PUSH 3, ADD → pile: [5] |
(1 + 2) × 3 | 1 2 + 3 × | PUSH 1, PUSH 2, ADD, PUSH 3, MUL → pile: [9] |
1 + 2 × 3 | 1 2 3 × + | PUSH 1, PUSH 2, PUSH 3, MUL, ADD → pile: [7] |
Maintenant qu'on a les briques (tableaux, enum, pile), on peut assembler la machine. Voici les composants :
| Registre | Rôle |
|---|---|
ip | Instruction Pointer — adresse de l'instruction en cours d'exécution (compteur ordinal) |
sp | Stack Pointer — indice du sommet de pile (prochain libre) |
int ip; int sp;
int mem[MEM_SIZE]; // mémoire de données (256 mots) int stack[STACK_SIZE]; // pile de travail (128 mots)
mem[] sert à stocker des variables et des chaînesstack[] sert aux calculscode[] séparéLa VM fonctionne en boucle :
while (ip < taille) { int instr = code[ip++]; // 1. FETCH : lire l'instruction switch (instr) { // 2. DECODE : identifier l'opcode case PUSH: // 3. EXECUTE : exécuter pousser(code[ip++]); break; case ADD: { int a = depiler(); int b = depiler(); pousser(b + a); break; } // ... autres opcodes ... } }
C'est le fameux cycle FETCH → DECODE → EXECUTE (cherchez « architecture de Von Neumann »). La seule différence avec un vrai processeur, c'est la taille. Et la vitesse. Et le fait que ça tienne dans 300 lignes de C.
Le jeu d'instructions de notre VM comporte 23 opcodes,
répartis en catégories. Chaque opcode est un entier (grâce
à notre enum).
| Opcode | Effet |
|---|---|
PUSH v | Empiler v |
DUP | a → a a |
DROP | a → |
SWAP | a b → b a |
OVER | a b → a b a |
| Opcode | Effet |
|---|---|
ADD | a b → (b + a) |
SUB | a b → (b - a) |
MUL | a b → (b * a) |
NEG | a → (-a) |
INC | a → (a + 1) |
DEC | a → (a - 1) |
| Opcode | Effet |
|---|---|
EQ | a b → (b == a) [0/1] |
NE | a b → (b != a) |
GT | a b → (b > a) |
LT | a b → (b < a) |
| Opcode | Effet |
|---|---|
JMP a | ip = a (saut inconditionnel) |
JZ a | Si sommet==0, ip = a |
JNZ a | Si sommet≠0, ip = a |
| Opcode | Effet |
|---|---|
LOAD a | Empiler mem[a] |
STORE a | Dépiler → mem[a] |
| Opcode | Effet |
|---|---|
PRINT | Dépiler et afficher le nombre |
PRTS a | Afficher la chaîne en mem[a] |
HALT | Arrêter la machine |
Les opcodes avec un argument (comme PUSH,
JMP, LOAD) consomment un mot
supplémentaire dans le programme après l'opcode.
Les autres (comme ADD, DUP)
n'ont pas d'argument.
Maintenant qu'on a la machine, écrivons un programme. On va implémenter la factorielle (déjà vue au cours 1), mais cette fois en bytecode VM plutôt qu'en C.
L'algorithme classique, traduit en instructions VM :
; Algorithme factorielle en pseudo-assembleur VM ; Entrée : n est sur la pile ; Sortie : n! affiché STORE N ; mémoriser n dans mem[N] PUSH 1 ; res = 1 PUSH 2 ; i = 2 LOOP: DUP ; i i LOAD N ; i i n GT ; i (i > n) JNZ END ; si i > n, fin SWAP ; i res OVER ; i res i MUL ; i res*i SWAP ; res*i i INC ; res*i i+1 JMP LOOP ; retourner en début de boucle END: DROP ; res PRTS MSG ; afficher " = " PRINT ; afficher res HALT ; fin du programme
Le même programme, encodé en C :
enum { N = 0, MSG = 200 }; enum { LOOP = 6, END = 19 }; int programme[] = { /* 0 */ STORE, N, /* 2 */ PUSH, 1, /* 4 */ PUSH, 2, /* 6 */ DUP, /* 7 */ LOAD, N, /* 9 */ GT, /* 10 */ JNZ, END, /* 12 */ SWAP, /* 13 */ OVER, /* 14 */ MUL, /* 15 */ SWAP, /* 16 */ INC, /* 17 */ JMP, LOOP, /* 19 */ DROP, /* 20 */ PRTS, MSG, /* 22 */ PRINT, /* 23 */ HALT, };
| Concept | En C | En bytecode VM |
|---|---|---|
| Variable n | int n = 5; | STORE N (depuis la pile) |
| Variable res | long long res = 1; | PUSH 1 (sur la pile) |
| Boucle for | for (i=2; i<=n; i++) | DUP, LOAD N, GT, JNZ END … INC, JMP LOOP |
| Multiplication | res *= i; | SWAP, OVER, MUL, SWAP |
| Affichage | printf(" = %lld", res); | PRTS MSG, PRINT |
Pour déverminer (debugger) notre VM, on a deux outils : le désassembleur et le mode pas-à-pas.
Il transforme le tableau d'entiers en texte lisible :
void desassembler(int code[], int taille) { int i = 0; while (i < taille) { printf("%4d: ", i); int op = code[i]; if (op == PUSH || op == JMP || /* … */) { printf("%-5s %d\n", nom_opcode(op), code[i + 1]); i += 2; } else { printf("%s\n", nom_opcode(op)); i++; } } }
Résultat :
0: STORE 0
2: PUSH 1
4: PUSH 2
6: DUP
7: LOAD 0
9: GT
10: JNZ 19
12: SWAP
…
23: HALT
Avec l'option --pas-a-pas, la VM s'arrête
après chaque instruction et affiche l'état de la pile :
if (pas_a_pas) { afficher_instr(code, ip); afficher_pile(); getchar(); // attendre une touche }
printf géant qui raconte sa vie.
C'est le meilleur ami du programmeur — juste après le café.
Quelques détails importants sur le code de la VM.
La pile est initialisée vide (sp = 0) avant chaque exécution. La valeur à calculer (l'entrée du programme) est poussée sur la pile avant de lancer la VM.
pousser(n); // déposer l'entrée executer(programme, taille); // lancer int res = stack[0]; // récupérer le résultat
La mémoire est remise à zéro avant chaque programme, et on y place les chaînes de caractères :
void initialiser_memoire(void) { for (int i = 0; i < MEM_SIZE; i++) mem[i] = 0; mem[MSG] = ' '; // " = \0" mem[MSG+1] = '='; mem[MSG+2] = ' '; mem[MSG+3] = 0; // terminateur nul }
int (c'est du gaspillage,
mais c'est simple). On marque la fin par un 0 (comme en C).
Tout le code tient dans vm.c (~326 lignes) :
Notre VM s'inspire directement du langage Forth (créé par Chuck Moore dans les années 70). Forth, c'est : une pile, des mots (opcodes), et une philosophie minimaliste.
| Forth | Notre VM |
|---|---|
| Mots (words) définis par l'utilisateur | Opcodes prédéfinis (23) |
| Pile de données + pile de retour | 1 pile de données |
| Interpréteur interactif | Programme pré-compilé en tableau |
| Compilateur « threadé » | C'est nous le compilateur (à la main) |
rstack[]
et un rsp.
Pour compiler et tester la VM :
make vm # compiler vm.c make vm-run # exécuter les tests (0! à 12!) ./vm # exécution directe ./vm --pas-a-pas # mode déverminage
Les tests calculent n! pour n = 0 à 12 et comparent avec
les valeurs attendues. Si tout est OK, le programme affiche
13 passes, 0 echoues et retourne 0.
=== Désassemblage du programme === 0: STORE 0 2: PUSH 1 … 23: HALT === Factorielle 0 à 12 === 0 = 1 OK 1 = 1 OK 2 = 2 OK 3 = 6 OK 4 = 24 OK 5 = 120 OK 6 = 720 OK 7 = 5040 OK 8 = 40320 OK 9 = 362880 OK 10 = 3628800 OK 11 = 39916800 OK 12 = 479001600 OK 13 passes, 0 echoues
int 32 bits (2 147 483 647). La VM utilise
des int pour la pile. On pourrait passer à
long long — c'est un exercice !
Pour aller plus loin, voici quelques pistes. La difficulté est indiquée par ★ (facile) à ★★★ (costaud).
MOD (reste de la division
entière) à la VM. Il faut :
MOD dans l'enum (avant HALT)case MOD dans le switch% en CAND, OR,
XOR). Pour les mordus de logique binaire.
CALL et
RET pour appeler des sous-programmes. C'est
le Graal de la VM : la réutilisabilité.
"(1 + 2) * 3") et la compile en
bytecode VM. C'est l'étape suivante — et c'est exactement
ce qu'on fera au cours 3.
Ce qu'on a vu dans ce cours :
| Concept | Détail |
|---|---|
| Tableaux | Séquences contiguës d'éléments, accès par indice |
| Enum | Noms symboliques pour des entiers |
| Pile (stack) | Structure LIFO, push/pop au sommet |
| Notation postfixée (RPN) | Opérateurs après les opérandes, pas de parenthèses |
| Machine virtuelle | Processeur logiciel avec mémoire, pile, jeu d'instructions |
| FETCH-DECODE-EXECUTE | Cycle d'exécution d'une instruction |
| Bytecode | Programme encodé en tableau d'entiers |
| Désassembleur | Traduit le bytecode en texte lisible |
| Mode pas-à-pas | Exécution ralentie pour déverminage |
| Forth | Langage à pile qui a inspiré notre VM |
Pistes pour la suite :
FADD,
FMUL), chaînes, tableaux, etc.