📗 Cours 2 — Du tableau à la machine virtuelle

On commence avec une variable, on ajoute un tableau, on empile, on crée un langage, et on se retrouve avec un processeur logiciel. Tout ça par petites étapes.

1. Le tableau : premier conteneur

Au cours 1, on a vu les variables simples : int n = 5;. Une variable = une case mémoire. Mais si on veut plusieurs valeurs ? Par exemple, les 8 résultats de nos tests factorielle ? On pourrait écrire 8 variables, mais c'est fastidieux :

int test1_n = 0;   long long test1_res = 1;
int test2_n = 1;   long long test2_res = 1;
int test3_n = 2;   long long test3_res = 2;
/* … et on pleure à la 8e */

Heureusement, le C offre le tableau (array) : une collection d'éléments de même type, accessibles par un indice.

type nom[N] — déclare un tableau de N éléments de type type.
nom[i] — accède à l'élément d'indice i (compte à partir de 0).
📄 Premier tableau — la mémoire comme casier
#include <stdio.h>

int main() {
    int mem[8];               // un tableau de 8 entiers

    mem[0] = 42;               // écrire dans la case 0
    mem[1] = mem[0] * 2;      // lire et calculer

    printf("mem[0] = %d\n", mem[0]);
    printf("mem[1] = %d\n", mem[1]);

    // parcourir tout le tableau avec une boucle
    for (int i = 0; i < 8; i++)
        printf("mem[%d] = %d\n", i, mem[i]);

    return 0;
}
Nouveau mot-clé CRôle
type nom[T]Déclarer un tableau de T éléments
nom[i]Accéder à l'élément d'indice i (0 = premier)
🗂️ Un tableau, c'est un casier de consigne en gare : chaque casier a un numéro (l'indice) et contient une valeur (l'entier). La différence : en C, les casiers commencent à 0, pas à 1. Pourquoi ? Parce que les informaticiens comptent comme les hôtels de luxe : le rez-de-chaussée c'est le 0, le premier étage c'est le 1. Et il n'y a jamais de 13e case. (Bon, si, en fait.)

2. Premier pas : un tableau pour simuler une mémoire

Prenons un tableau un peu plus grand (256 cases) et appelons-le mem. Ce sera notre « mémoire » — on peut écrire dedans (STORE) et lire dedans (LOAD). C'est exactement comme ça que fonctionne la RAM d'un vrai ordinateur.

Ajoutons aussi un indice (ip) pour parcourir un « programme » qui serait une suite d'ordres stockée dans un autre tableau.

💡 Principe : un programme est une suite d'entiers dans un tableau. Chaque entier est une instruction. Un compteur (ip) indique quelle instruction exécuter. C'est le début de l'architecture de von Neumann (programme et données dans la même mémoire).
📄 Version 1 — mémoire + pointeur d'instruction
#include <stdio.h>

int mem[256];     // mémoire de données
int ip;          // instruction pointer

int programme[] = {
    10,          // instruction 10 = STORE à l'adresse suivante
    0,           // adresse 0
    20,          // instruction 20 = PRINT (affiche)
    99,          // instruction 99 = HALT (arrêt)
};

int main() {
    int taille = sizeof(programme) / sizeof(programme[0]);
    ip = 0;

    while (ip < taille) {
        int instr = programme[ip++];
        if (instr == 10) {       // STORE
            int addr = programme[ip++];
            mem[addr] = 42;    // on force 42 pour l'exemple
        }
        else if (instr == 20) { // PRINT
            printf("%d\n", mem[0]);
        }
        else if (instr == 99) { // HALT
            ip = taille;
        }
    }
    return 0;
}

C'est rudimentaire — une cascade de if qui teste des codes magiques (10, 20, 99). Mais le mécanisme est là : un tableau = le programme, un registre = l'indice, une boucle = le processeur. Félicitations, vous venez de réinventer le cycle de von Neumann.

🤖 Ce premier interpréteur est moche, mais c'est le vôtre. Il sait faire 3 choses : ranger en mémoire, afficher, et s'arrêter. C'est plus qu'un caillou, moins qu'un Arduino. Mais c'est extensible — et c'est ça le génie du truc.

3. Donner des noms aux instructions : enum

Des codes numériques 10, 20, 99… c'est illisible. Si on se trompe de numéro, le programme fait n'importe quoi sans prévenir. Le C propose une solution élégante : l'énumération (enum).

enum { STORE=10, PRINT=20, HALT=99 }; — associe des noms symboliques à des entiers. Le compilateur remplace STORE par 10 automatiquement.
// Avant — codes magiques
if (instr == 10) ...
else if (instr == 20) ...

int programme[] = {
    10, 0,    // STORE 0 ?
    20,        // PRINT ?
    99,        // HALT ?
};
enum { STORE = 10, PRINT = 20, HALT = 99 };

if (instr == STORE) ...
else if (instr == PRINT) ...

int programme[] = {
    STORE, 0,
    PRINT,
    HALT,
};
Nouveau mot-clé CRôle
enumDéclare un type énuméré : des constantes nommées qui valent 0, 1, 2… (ou la valeur explicite donnée)

Quand on laisse le compilateur numéroter tout seul :

enum { PUSH, DUP, DROP, SWAP, OVER };  // PUSH=0, DUP=1, DROP=2…
🏷️ enum, c'est l'étiqueteuse de bureau : fini les post-it « le code 10 c'est Store, le 20 Print… » écrits sur un coin de la table. Le code devient auto-documenté. Et si vous ajoutez une instruction au milieu, tout se renumérote tout seul. Magie.

4. Remplacer les if par un switch

Un if par instruction, ça devient vite long. Le C offre switch pour aiguiller selon la valeur d'une expression :

// Cascade if — long, lent
if (instr == PUSH) { … }
else if (instr == ADD) { … }
else if (instr == MUL) { … }
else { /* erreur */ }
// switch — clair, rapide
switch (instr) {
    case PUSH: … ; break;
    case ADD:  … ; break;
    case MUL:  … ; break;
    default:  /* erreur */;
}
Nouveau mot-clé CRôle
switch(x) { case v: … }Aiguillage selon la valeur de x (efficace, lisible)
breakSortir d'un switch ou d'une boucle
defaultCas par défaut (aucun case n'a matché)
💡 Piège classique : oublier le break fait « tomber » dans le case suivant (fallthrough). Parfois voulu, parfois catastrophique. En général, mettez break partout. Votre correcteur automatique vous remercie.
🚦 switch, c'est le panneau aiguilleur de votre code : « instruction = PUSH ? Allez à la voie 1. ADD ? Voie 2. HALT ? Sortie. » C'est plus rapide qu'une série de if, et ça dort mieux la nuit.

5. Ajouter une pile : deuxième tableau

Notre mémoire permet de stocker des valeurs, mais comment faire des calculs ? Ajoutons une pile (stack) — un tableau avec un sommet (sp).

Principe d'une pile :

📄 Version 2 — on ajoute une pile
int stack[128];   // pile de travail
int sp;            // sommet de pile (stack pointer)

void pousser(int v) {
    stack[sp++] = v;
}

int depiler(void) {
    return stack[--sp];
}

// Et dans la boucle d'exécution :
switch (instr) {
    case PUSH:
        pousser(programme[ip++]);          // empiler la valeur
        break;
    case ADD: {
        int a = depiler();
        int b = depiler();
        pousser(b + a);                     // b + a (ordre postfixé)
        break;
    }
    case MUL: … // pareil avec *
}

Avec une pile, on peut enchaîner les opérations en postfixé : PUSH 3 PUSH 4 ADD calcule 3 + 4 = 7.

📌 Pile : structure LIFO (Last In, First Out). Comme une pile d'assiettes — on prend la dernière posée. Essentielle pour les expressions arithmétiques, les appels de fonctions, et pour faire patienter les données.
🥞 La pile, c'est comme la pile de crêpes du petit-déjeuner : la dernière cuite est la première mangée (LIFO). Si vous essayez de prendre celle du milieu tout s'écroule. En programmation aussi : on empile, on dépile, et si on fait n'importe quoi (pile vide ou débordement), le programme se termine en segmentation fault — l'équivalent de la crêpe par terre.

6. Protéger la pile — les fonctions de vérification

Que se passe-t-il si on dépile alors que la pile est vide ? Ou si on empile alors qu'elle est pleine ? Le programme plante (ou pire, il continue avec des données corrompues).

Ajoutons des gardes :

void pousser(int v) {
    if (sp >= 128) {
        fprintf(stderr, "DEBORDEMENT DE PILE\n");
        exit(1);           // on s'arrête proprement
    }
    stack[sp++] = v;
}

int depiler(void) {
    if (sp <= 0) {
        fprintf(stderr, "PILE VIDE\n");
        exit(1);
    }
    return stack[--sp];
}
NouveauRôle
voidType « rien » — la fonction ne retourne pas de valeur
exit(1)Arrête immédiatement le programme (code 1 = erreur)
stderrFlux d'erreur (s'affiche à l'écran, mais séparé de stdout)
fprintfPermet d'écrire sur un flux (stderr, stdout, fichier…)
🛡️ Les gardes, c'est comme les barrières de sécurité sur les quais de métro : ça empêche les gens de tomber sur les voies. Ici, ça empêche votre programme de tomber dans les segmentation fault. « Mieux vaut prévenir que guérir » — proverbe de programmeur qui a passé 3 heures à débugger un stack overflow.

7. Les sauts conditionnels — donner vie au programme

Sans sauts, un programme s'exécute linéairement du début à la fin. Pour faire une boucle (comme notre factorielle), il faut pouvoir sauter à une instruction précédente, et tester des conditions.

Trois instructions de saut suffisent :

case JMP:  ip = programme[ip]; break;          // saut pur
case JZ: {
    int addr = programme[ip++];
    if (depiler() == 0) ip = addr;
    break;
}
case JNZ: {
    int addr = programme[ip++];
    if (depiler() != 0) ip = addr;
    break;
}

Remarquez : JMP lit l'adresse dans le programme MAIS n'incrémente pas ip après — l'adresse remplace ip. C'est un saut. Les autres instructions lisent leur argument et incrémentent ip normalement.

💡 Pourquoi JMP ne fait pas ip++ ? Parce que JMP addr signifie « la prochaine instruction est à addr ». Si on faisait ip = programme[ip++], on lirait l'adresse puis on l'incrémenterait — on sauterait à addr + 1. Pas ce qu'on veut. Donc JMP lit l'adresse sans incrémenter.
🦘 Sans sauts, un programme est un couloir sans porte. Avec JMP, c'est un couloir avec des portes partout. Avec JZ et JNZ, les portes ont des serrures à code. Vous pouvez enfin faire des boucles, des conditions, et toute la machinerie de l'algorithmique.

8. Structurer avec #define — les constantes de taille

Dans notre code, on a écrit int mem[256] et int stack[128]. Les nombres 256 et 128 apparaissent « en dur ». Si on veut les changer, il faut chercher partout. Le préprocesseur C offre #define pour nommer des constantes :

#define MEM_SIZE   256
#define STACK_SIZE 128

int mem[MEM_SIZE];
int stack[STACK_SIZE];

void pousser(int v) {
    if (sp >= STACK_SIZE) { … }
}
NouveauRôle
#define NOM valeurLe préprocesseur remplace NOM par valeur dans tout le fichier (textuel, avant compilation)
⚠️ #define n'est pas du C. C'est une directive du préprocesseur (étape avant la compilation). Elle fait un copier-coller textuel. Pas de point-virgule, pas de scope. Utilisez enum ou const quand c'est possible ; gardez #define pour les tailles de tableaux et les macros.
✂️ #define, c'est le « remplacer tout » du traitement de texte, mais en mieux. Vous écrivez MEM_SIZE une fois et le préprocesseur met 256 partout. Pratique. Mais attention : #define DOUBLE(x) x*x avec DOUBLE(1+2) donne… 1+2*1+2 = 5, pas 9. Les macros, c'est comme les armes à feu : puissant, mais on vise le pied à chaque utilisation.

9. Le désassembleur — voir ce qu'on exécute

Un programme representé comme un tableau d'entiers, c'est pratique pour la machine, mais illisible pour l'humain. Un désassembleur fait la traduction inverse : il prend le tableau et affiche chaque instruction en texte.

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[op], code[i + 1]);
            i += 2;    // instruction à 2 mots
        } else {
            printf("%s\n", nom[op]);
            i++;        // instruction à 1 mot
        }
    }
}

On utilise un tableau nom[] qui associe à chaque opcode son nom textuel :

static const char *nom[] = {
    "PUSH", "DUP", "DROP", "SWAP", "OVER",
    "ADD", "SUB", "MUL", "NEG", "INC", "DEC",
    "EQ", "NE", "GT", "LT",
    "JMP", "JZ", "JNZ",
    "LOAD", "STORE",
    "PRINT", "PRTS", "HALT"
};
Nouveau mot-clé CRôle
staticPortée limitée au fichier (visibilité interne)
constLa valeur ne peut pas être modifiée après initialisation
char *nom[]Tableau de chaînes de caractères (chaînes littérales)
🔍 Le désassembleur, c'est comme les sous-titres d'un film en VO : vous entendez le code machine (les entiers) et vous lisez ce que ça veut dire (les instructions). Pratique quand on veut comprendre pourquoi le programme saute à l'adresse 19 alors qu'on pensait 23.

10. Assemblage final — le programme factorielle

Maintenant qu'on a tous les ingrédients, écrivons un programme pour notre VM : la factorielle. On le définit comme un tableau d'entiers, avec des constantes pour les adresses :

enum { N = 0, MSG = 200 };    // adresses mémoire
enum { LOOP = 6, END = 19 };  // étiquettes de saut

int programme[] = {
    STORE, N,          // mem[N] = n
    PUSH, 1,          // res = 1
    PUSH, 2,          // i = 2
    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,         // retour boucle
    DROP,              // jeter i, garder res
    PRTS, MSG,         // afficher " = "
    PRINT,             // afficher res
    HALT,
};

Le programme se lit comme un algorithme : « STORE N, PUSH 1, PUSH 2, LOOP: DUP, LOAD N, GT, JNZ END… » — chaque mot a un sens précis. C'est ça, l'assembleur : une notation lisible pour du code machine.

1 Tableau = mémoire
2 Pile de travail
3 enum + switch
4 Sauts conditionnels
5 VM + factorielle
🧩 Regardez le chemin parcouru : on est parti d'une variable, on a ajouté un tableau, une pile, des noms symboliques, un switch, des sauts… et on se retrouve avec un programme assembleur qui calcule une factorielle. Chaque étape est simple. Mises bout à bout, elles fabriquent un ordinateur. C'est ça, le layering en informatique.

11. Mode pas à pas

Quand on ne comprend pas ce que fait un programme, on peut l'exécuter pas à pas : une instruction à la fois, en voyant l'état de la pile. La VM accepte l'option --pas-a-pas qui active ce mode :

if (pas_a_pas) {
    afficher_instr(code, ip);
    afficher_pile();
    getchar();       // attendre Entrée
}

À chaque instruction, la VM affiche l'instruction puis la pile, et attend qu'on appuie sur Entrée. On peut suivre le déroulement comme un film au ralenti.

🔬 Déverminage (debugging) : le processus qui consiste à trouver et corriger les bugs. Le mode pas à pas est l'outil le plus basique — et souvent le plus efficace — pour comprendre ce que fait vraiment un programme.
🐌 Le pas à pas, c'est l'équivalent de regarder un match de foot en arrêt sur image : on voit exactement qui a la pile au moment du MUL. C'est lent, c'est fastidieux, mais on comprend tout. Et on finit par trouver pourquoi le JNZ a sauté alors qu'il aurait dû rester.

12. Bilan — vocabulaire du cours

Les nouveaux concepts et mots-clés C rencontrés :

ConceptMot-clé CUtilité
Tableautype nom[T]Collection d'éléments indicés
ÉnumérationenumConstantes nommées pour les opcodes
Aiguillageswitch / case / breakDispatch efficace sur les opcodes
Fonction sans retourvoidProcédure (pousser, depiler)
Constante de taille#defineParamétrage de la VM
Portée fichierstaticVisibilité interne des fonctions/tableaux
ImmuabilitéconstDonnées en lecture seule
Tableau de chaîneschar *nom[]Noms des opcodes pour le désassembleur
Flux d'erreurstderr / fprintfMessages d'erreur
Arrêt immédiatexit(1)Plantage contrôlé

Nouveaux concepts d'architecture :

🎓 Vous avez survécu. Non seulement vous avez écrit des tableaux en C, mais vous avez compris comment ils peuvent servir de mémoire, de pile, de programme. Un processeur, c'est juste des tableaux qu'on manipule intelligemment. Et maintenant, quand vous écrivez int mem[256], vous savez que vous tenez entre vos mains une brique élémentaire de l'informatique.

13. Pour aller plus loin

La VM complète (instructions, programme, tests) est documentée ici :

🖥️ Documentation complète de la VM → vm-doc.html

DocumentCe que vous y trouverez
vm-doc.htmlTous les opcodes, la trace de factorielle, le désassembleur
asm-doc.htmlLes instructions assembleur x86-64 du vrai processeur — vous verrez des movq, imulq… comme notre VM mais en moins simple
processor-doc.htmlLe processeur réel : ALU, registres, cache, pipeline — c'est notre VM en beaucoup plus gros et beaucoup plus rapide
cours1-introduction.htmlBases du C : types, boucles, fonctions — les fondations du cours 1
🔍 Exercice : ajoutez une instruction MOD (reste de division) à la VM. Combien de lignes de C faut-il modifier ? Réponse : 3 — un case dans le switch, un nom dans le tableau du désassembleur, et un cas dans la fonction d'affichage. C'est ça, la puissance d'une VM bien conçue.
🚀 Vous êtes maintenant capables de modifier un processeur. Pas un vrai (pas encore), mais un processeur logiciel. C'est comme si on vous donnait les plans d'un CPU et qu'on vous disait « ajoute une instruction qui calcule la moyenne ». La prochaine étape : graver votre VM dans du silicium. (Ou pas. Mais c'est possible. Théoriquement.)