📗 Cours 2 — Du tableau à la
machine virtuelle

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.

1. Du problème au processeur logiciel

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.

📘 Cours 1
factorielle.c
💡 Tableaux & enum
nouvelles briques
🧱 Pile & opcodes
architecture VM
⚙️ Bytecode factorielle
programme exécutable
🖥️ Le cours 1, c'était « regardez ce qu'on peut faire avec un processeur ». Le cours 2, c'est « regardez comment on construit un processeur ». C'est comme passer de conducteur à mécano — sauf qu'ici, on ne se salit pas les mains.

2. Tableaux et énumérations — les nouvelles briques

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.

2.1 Les tableaux — des variables qui se suivent

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
SyntaxeSens
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
🐛 Attention aux débordements : C ne vérifie JAMAIS que l'indice est valide. 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é.
Le C et la vérification des indices, c'est comme les ceintures de sécurité dans une voiture de course : ça n'existe pas, et si vous sortez de la piste, vous le saurez. Mais vous le saurez bien trop tard.

2.2 Les énumérations — des mots pour les chiffres

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 };
CodeSans enumAvec enum
Déclarationint op = 0;int op = PUSH;
Testif (op == 0)if (op == PUSH)
Tableau{0, 42, 21, 4}{PUSH, 42, PRINT, HALT}
💡 Bonne pratique : utilisez des enum pour tous vos codes d'opération, codes d'erreur, et constantes nommées. Votre futur vous remerciera — surtout à 3h du matin.
🔑 Pourquoi « énumération » ? Parce qu'on énumère les valeurs possibles une par une. Le compilateur leur attribue automatiquement des entiers (0, 1, 2…). On peut aussi donner des valeurs explicites : enum { N = 0, MSG = 200 };.
🏷️ Sans les enum, la VM ressemblerait à un code secret : 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.

3. La pile — L'architecture Forth

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.

3.1 Qu'est-ce qu'une pile ?

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).

5
sommet

Pile = [3, 7, 5] — le sommet est 5, la base est 3.

OpérationAvantAprè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
}
🍽️ La pile, c'est comme la vaisselle après un dîner : la dernière assiette posée est la première lavée. Sauf que la vaisselle, ça déborde rarement sur votre terminal. Enfin, ça dépend du dîner.

3.2 Notation postfixée (RPN)

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éePostfixée (RPN)Évaluation VM
2 + 32 3 +PUSH 2, PUSH 3, ADD → pile: [5]
(1 + 2) × 31 2 + 3 ×PUSH 1, PUSH 2, ADD, PUSH 3, MUL → pile: [9]
1 + 2 × 31 2 3 × +PUSH 1, PUSH 2, PUSH 3, MUL, ADD → pile: [7]
🔑 RPN : l'avantage. Pas besoin de parenthèses ni de règles de priorité. Chaque opérateur s'applique aux valeurs qui sont sur la pile, dans l'ordre. C'est simple pour la machine… et pour l'humain, après un temps d'adaptation.
🧮 Les calculatrices HP utilisaient la RPN dans les années 70-80. Les ingénieurs adoraient. Les comptables pleuraient. Aujourd'hui, on peut reconnaître un vieux de la vieille s'il demande encore « mais pourquoi ma Casio a des parenthèses ? 2 3 + ENTER, c'était tellement mieux ! »

4. Architecture de la VM

Maintenant qu'on a les briques (tableaux, enum, pile), on peut assembler la machine. Voici les composants :

4.1 Les registres

RegistreRôle
ipInstruction Pointer — adresse de l'instruction en cours d'exécution (compteur ordinal)
spStack Pointer — indice du sommet de pile (prochain libre)
int ip;
int sp;

4.2 La mémoire

int mem[MEM_SIZE];     // mémoire de données (256 mots)
int stack[STACK_SIZE];  // pile de travail (128 mots)

4.3 Le cycle d'exécution

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.

🔄 Fetch → Decode → Execute. Les vrais processeurs font ça des milliards de fois par seconde. Notre VM fait ça… moins de fois par seconde. Mais au moins, on peut voir ce qui se passe.

5. Le jeu d'instructions (Opcodes)

Le jeu d'instructions de notre VM comporte 23 opcodes, répartis en catégories. Chaque opcode est un entier (grâce à notre enum).

📦 Poussée / Duplication

OpcodeEffet
PUSH vEmpiler v
DUPa → a a
DROPa →
SWAPa b → b a
OVERa b → a b a

🔢 Arithmétique

OpcodeEffet
ADDa b → (b + a)
SUBa b → (b - a)
MULa b → (b * a)
NEGa → (-a)
INCa → (a + 1)
DECa → (a - 1)

⚖️ Comparaisons

OpcodeEffet
EQa b → (b == a) [0/1]
NEa b → (b != a)
GTa b → (b > a)
LTa b → (b < a)

🎯 Sauts

OpcodeEffet
JMP aip = a (saut inconditionnel)
JZ aSi sommet==0, ip = a
JNZ aSi sommet≠0, ip = a

💾 Mémoire

OpcodeEffet
LOAD aEmpiler mem[a]
STORE aDépiler → mem[a]

📤 Entrées-sorties

OpcodeEffet
PRINTDépiler et afficher le nombre
PRTS aAfficher la chaîne en mem[a]
HALTArrê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.

🎮 23 opcodes. C'est moins que le jeu d'instructions de votre grille-pain connecté. Mais avec ça, on peut calculer n'importe quoi. En théorie. En pratique, on calcule surtout la factorielle. Mais c'est déjà pas mal pour un grille-pain.

6. Programme : Factorielle en bytecode

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.

6.1 Algorithme en assembleur VM

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

6.2 Encodage en tableau d'entiers

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,
};
ConceptEn CEn bytecode VM
Variable nint n = 5;STORE N (depuis la pile)
Variable reslong long res = 1;PUSH 1 (sur la pile)
Boucle forfor (i=2; i<=n; i++)DUP, LOAD N, GT, JNZ END … INC, JMP LOOP
Multiplicationres *= i;SWAP, OVER, MUL, SWAP
Affichageprintf(" = %lld", res);PRTS MSG, PRINT
🧩 On a écrit le même programme de 3 façons différentes en deux cours : en C pur (cours 1), en assembleur VM (avec des étiquettes), et en bytecode (tableau d'entiers). La prochaine étape, c'est de l'écrire en binaire à la main ? Non, même nous on a nos limites. (Mais si vous voulez, c'est dans le cours 3.)

7. Désassembleur et mode pas-à-pas

Pour déverminer (debugger) notre VM, on a deux outils : le désassembleur et le mode pas-à-pas.

7.1 Désassembleur

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

7.2 Mode pas-à-pas

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
}
Pile: [1]
PUSH 2
Pile: [1 2]
🔑 Déverminage = debugging. Le mode pas-à-pas permet de suivre l'exécution instruction par instruction, comme un printf géant qui raconte sa vie. C'est le meilleur ami du programmeur — juste après le café.
🐛 Le mode pas-à-pas, c'est comme regarder un film au ralenti : on voit le héros rater la marche et s'étaler. C'est moins spectaculaire, mais ça évite les erreurs. Et ça prend moins de pop-corn.

8. Détails d'implémentation

Quelques détails importants sur le code de la VM.

8.1 Gestion de la pile

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

8.2 Initialisation de la mémoire

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
}
💡 Stocker des chaînes dans un tableau d'entiers ? Chaque caractère tient dans un int (c'est du gaspillage, mais c'est simple). On marque la fin par un 0 (comme en C).

8.3 Programme complet

Tout le code tient dans vm.c (~326 lignes) :

9. L'héritage Forth

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.

ForthNotre VM
Mots (words) définis par l'utilisateurOpcodes prédéfinis (23)
Pile de données + pile de retour1 pile de données
Interpréteur interactifProgramme pré-compilé en tableau
Compilateur « threadé »C'est nous le compilateur (à la main)
🏛️ Forth, c'est le punk du langage : minimaliste, rapide, et incompréhensible pour les non-initiés. Notre VM, c'est le punk adouci : on garde l'esprit, mais on met des commentaires.
🧪 Variante — pile de retour : Ajoutez une deuxième pile pour gérer les appels de fonction (RPUSH, RPOP, CALL, RET). C'est ce que font les vrais processeurs (et Forth). Indice : il faut un deuxième tableau rstack[] et un rsp.

10. Tests et exécution

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
💡 Pourquoi 0 à 12 et pas plus ? Parce que 13! = 6 227 020 800 dépasse la capacité d'un int 32 bits (2 147 483 647). La VM utilise des int pour la pile. On pourrait passer à long long — c'est un exercice !

11. Exercices et variations

Pour aller plus loin, voici quelques pistes. La difficulté est indiquée par ★ (facile) à ★★★ (costaud).

★☆☆ Exercice 1 — Fibonacci : Écrire un programme VM qui calcule le n-ième nombre de Fibonacci. Indice : il faut gérer deux variables (a, b) en mémoire. La suite : 0, 1, 1, 2, 3, 5, 8, 13…
★☆☆ Exercice 2 — PGCD : Écrire un programme VM qui calcule le PGCD de deux nombres (algorithme d'Euclide). Les deux entrées sont sur la pile au lancement.
★★☆ Exercice 3 — Nouvel opcode MOD : Ajouter l'opcode MOD (reste de la division entière) à la VM. Il faut :
  1. Ajouter MOD dans l'enum (avant HALT)
  2. Ajouter le nom dans les tableaux de noms
  3. Ajouter le case MOD dans le switch
  4. Utiliser % en C
★★☆ Exercice 4 — AND/OR/XOR : Ajouter les opcodes bit-à-bit (AND, OR, XOR). Pour les mordus de logique binaire.
★★★ Exercice 5 — Pile de retour : Ajouter une pile de retour et les opcodes CALL et RET pour appeler des sous-programmes. C'est le Graal de la VM : la réutilisabilité.
★★★ Exercice 6 — Compilateur : Écrire un programme C qui lit une expression arithmétique en notation infixée (ex: "(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.
🎯 6 exercices, de la promenade de santé au marathon. Si vous faites les 6, vous maîtrisez la VM mieux que votre grille-pain. Et vous pouvez commencer à envisager d'écrire votre propre langage de programmation. Parce que c'est ça, la puissance du C : avec des tableaux et des enum, on construit des mondes.

12. Pour aller plus loin

Ce qu'on a vu dans ce cours :

ConceptDétail
TableauxSéquences contiguës d'éléments, accès par indice
EnumNoms 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 virtuelleProcesseur logiciel avec mémoire, pile, jeu d'instructions
FETCH-DECODE-EXECUTECycle d'exécution d'une instruction
BytecodeProgramme encodé en tableau d'entiers
DésassembleurTraduit le bytecode en texte lisible
Mode pas-à-pasExécution ralentie pour déverminage
ForthLangage à pile qui a inspiré notre VM
🔑 Le message à retenir : Un processeur, c'est juste une boucle qui lit des entiers dans un tableau et fait des opérations selon la valeur lue. Une VM, c'est la même chose, mais en logiciel. Le matériel, c'est juste une VM optimisée avec des transistors.

Pistes pour la suite :

🚀 On a commencé par « qu'est-ce qu'un entier ? » au cours 1. On termine avec une machine virtuelle qui exécute des programmes en bytecode. Si vous aviez dit ça à vous-même il y a deux semaines, vous auriez rigolé. Mais le C, c'est comme les Legos : avec des tableaux, des enum, et des boucles, on construit un ordinateur dans l'ordinateur. C'est le serpent qui se mord la queue — mais en moins douloureux.