Dossier XXVII · Informatique fondamentale

L'Ordinateur de 1983

Un interrupteur qui laisse passer le courant, ou pas. C'est tout ce qu'on a au départ. À la fin, une machine 8 bits qui compile du C, démarre un système, édite des fichiers sur un disque et va chercher une page sur Internet. Ce dossier refait le chemin, marche après marche — et vous met les mains dessus.

« Aucune étape n'est magique. Chaque étage est bête. C'est l'empilement qui devient intelligent. »

Intention

Personne ne sait comment marche un ordinateur

Ou plutôt : presque personne ne l'a vu en entier. On connaît un étage — le sien. L'électronicien sait ce qu'est une porte NON-ET, le développeur sait ce qu'est une boucle for, et entre les deux s'étend une zone grise où l'on préfère dire « et là, la magie opère ».

Il n'y a pas de magie. Il y a une douzaine d'idées, chacune assez simple pour tenir en un paragraphe, empilées les unes sur les autres jusqu'à ce que l'ensemble devienne capable de calculer une trajectoire lunaire ou d'afficher un chat.

Ce dossier reconstruit la pile complète, du courant qui passe jusqu'au unix$ qui clignote. Il s'appuie sur un objet précis : un simulateur logique nodal écrit par Samlepirate, qui contient, dans un seul onglet de navigateur, un ordinateur 8 bits complet — portes, processeur, mémoire, assembleur, compilateur C, amorceur, disque et système. Tout ce qui est affirmé ici a été vérifié dans son code source.

Et surtout : à chaque étage, vous pouvez toucher. Quinze expériences jalonnent le parcours — chacune montée dans son châssis d'instrument, interrupteurs, afficheurs et tube compris. La quatorzième est un processeur 8 bits qui s'exécute réellement dans cette page.

Le bit — il y a du courant, ou il n'y en a pas.

Le transistor — un interrupteur commandé par un autre fil.

Les portes — six décisions élémentaires sur un ou deux bits.

L'additionneur — la première machine qui calcule.

La mémoire — une boucle de courant qui se souvient d'elle-même.

L'ALU — huit opérations, trois drapeaux, une décision.

Le processeur — chercher, décoder, exécuter, recommencer.

Le langage — assembleur, puis C, puis un système.

Objectifs de lecture

Ce que vous saurez faire en refermant ce dossier

Lire un circuit

Reconnaître les six portes, suivre un signal d'un bout à l'autre d'un schéma, et comprendre pourquoi une retenue met huit fois plus de temps qu'une addition sur un bit.

Lire une machine

Savoir ce que contient un processeur — registres, drapeaux, compteur ordinal, pile — et suivre un cycle chercher-décoder-exécuter pas à pas.

Lire du code, tous les étages

Voir une ligne de C devenir de l'assembleur, l'assembleur devenir des octets, et les octets devenir un caractère à l'écran.

Avatar de Samlepirate Dossier réalisé par Samlepirate d'après son simulateur logique nodal — Simulateur Logique Nodal, un ordinateur 8 bits complet dans une page web

Chapitre premier

Le bit, ou l'art de ne rien savoir dire

Gravure macroscopique d'un interrupteur en silicium : une grille dorée sur une surface de wafer, une piste bleue qui s'arrête à la grille.
L'atome de la machine : un interrupteur commandé par un fil

Un ordinateur ne sait pas ce qu'est un nombre. Il ne sait pas ce qu'est une lettre, une couleur, une date. Il sait une seule chose, et il la sait très mal : est-ce qu'il y a du courant sur ce fil, oui ou non.

C'est le bit : une grandeur qui ne prend que deux valeurs. On les note 0 et 1, mais on pourrait dire « éteint / allumé », « bas / haut », « faux / vrai ». Ce choix binaire n'est pas une élégance mathématique : c'est un aveu de faiblesse. Un circuit électronique est incapable de distinguer proprement dix niveaux de tension pendant des milliards d'opérations par seconde sans se tromper. Deux, en revanche, il y arrive.

Toute la suite consiste à contourner cette pauvreté en mettant les bits côte à côte. Avec un bit, deux états. Avec deux bits, quatre. Avec huit, deux cent cinquante-six — et ces deux cent cinquante-six états suffiront pour représenter tous les caractères d'un clavier, tous les niveaux de rouge d'un pixel, ou tous les nombres de 0 à 255.

Le pouvoir d'un paquet de bits Combinatoire

Avec n fils, on distingue N états différents, parce que chaque fil ajouté double le nombre de combinaisons. Huit fils — un octet — donnent 256 valeurs, soit les entiers de 0 à 255. C'est exactement la largeur de la machine étudiée ici : c'est pour cela qu'on l'appelle une machine 8 bits.

Reste à donner du sens à ces états. Un paquet de huit bits ne « contient » pas le nombre 42 : on décide de le lire comme 42. La convention est celle de la numération de position, la même qu'à l'école primaire — sauf qu'au lieu de puissances de dix, on utilise des puissances de deux.

Lire un octet comme un nombre Numération binaire

Chaque bit bi pèse . Le bit 0 (le plus à droite) vaut 1, le bit 7 (le plus à gauche) vaut 128. Additionner les poids des bits à 1 donne la valeur. Rien d'autre ne se passe : le circuit, lui, ne fait qu'allumer ou éteindre huit fils.

Le transistor : un interrupteur qui obéit à un fil

Entre le bit abstrait et la porte logique, il y a un composant physique : le transistor. Dans le simulateur, il est modélisé de la façon la plus dépouillée possible — un interrupteur dont l'ouverture est commandée par un troisième fil, la grille.

Le transistor du simulateur modèle simplifié
OUT = IN,  si GATE = 1   ← l'interrupteur est fermé, le signal passe
OUT = 0,   si GATE = 0   ← l'interrupteur est ouvert, rien ne passe

Cette description est volontairement idéalisée : un vrai transistor MOS a des seuils, des courants de fuite, des temps de commutation, et il ne délivre pas un zéro parfait. Mais elle suffit à voir naître la logique, parce que deux transistors mis bout à bout ou côte à côte font déjà quelque chose de remarquable :

  • En série — le signal ne traverse que si les deux grilles sont à 1. C'est un ET.
  • En parallèle — le signal passe dès qu'au moins une grille est à 1. C'est un OU.
  • Seul — il recopie son entrée sous contrôle. C'est un tampon commandé.

Voilà le saut décisif, et il tient en une phrase : la logique n'est pas ajoutée à l'électricité, elle est une conséquence de la façon dont on branche les fils. À partir d'ici, on peut oublier les électrons et ne plus parler que de 0 et de 1.

EXP 01 Quatre interrupteurs font une porte Interactif

Voici la porte NON-ET telle qu'on la construit vraiment, en technologie CMOS : quatre transistors, deux en haut qui tirent la sortie vers le +5 V, deux en bas qui la tirent vers la masse. Actionnez A et B. Un transistor vert laisse passer ; un fil bleu porte un 1. Les grilles qui portent le même nom sont reliées entre elles.

Entrées Sortie0
Porte NON-ET en CMOS : deux transistors PMOS en parallèle vers le +5 V, deux transistors NMOS en série vers la masse +5 V masse P1 · PMOS bloqué P2 · PMOS bloqué A=0 B=0 N1 · NMOS bloqué N2 · NMOS bloqué A B sortie

Ce qui se passe

Règle des deux familles

Un PMOS conduit quand sa grille est à 0. Un NMOS conduit quand sa grille est à 1. Les deux familles sont exactement complémentaires — c'est de là que vient le C de CMOS.

C'est la raison pour laquelle le NON-ET est la porte naturelle du silicium, et le ET une porte dérivée : le montage ci-dessus inverse spontanément. Pour obtenir un ET, il faut lui ajouter un inverseur — deux transistors de plus. Le simulateur, lui, part du niveau au-dessus : il prend les six portes comme primitives.

EXP 02 Le texte, tel que la machine le range Interactif

Écrivez quelque chose. Chaque caractère devient un octet, rangé dans une case numérotée. Cliquez sur n'importe quel octet du vidage : la même suite de huit bits est relue de cinq façons différentes — et l'une d'elles est un ordre.

Texte

Les codes 32 à 126 sont les 95 caractères imprimables de l'ASCII. La norme paraît en 1963 (ASA X3.4-1963) — mais sans minuscules : elles n'y sont ajoutées qu'à la révision de 1967. Au-delà de 127, il n'y a plus de norme unique : le même octet donne é, Ú ou un caractère de contrôle selon la table employée. Les octets concernés sont signalés en orange — c'est toute l'histoire des accents mal affichés.

Chapitre deuxième

Six portes, et le monde entier

Une porte logique prend un ou deux bits et en produit un. Elle n'a pas de mémoire, pas d'état, pas d'histoire : à entrées identiques, sortie identique, toujours. C'est une fonction, au sens le plus strict.

On peut donc la décrire entièrement par un tableau minuscule qu'on appelle une table de vérité : deux entrées binaires, quatre lignes, une colonne de sortie. Quatre bits de description, et la porte n'a plus de secret. Le simulateur en propose six, qui sont celles de toute l'électronique numérique :

Les six portes du simulateur, telles qu'elles sont définies dans son code source (src/logic/gates.ts).
PorteSe litSortie à 1 quand…Écriture
ET (AND)« et »les deux entrées valent 1a & b
OU (OR)« ou » inclusifau moins une entrée vaut 1a | b
OU-X (XOR)« ou » exclusifles entrées sont différentesa ^ b
NON-ET (NAND)« pas les deux »ce n'est pas le cas que les deux valent 1!(a & b)
NON-OU (NOR)« ni l'un ni l'autre »les deux entrées valent 0!(a | b)
NON (NOT)l'inverseurl'entrée unique vaut 0a ? 0 : 1

Trois remarques valent qu'on s'y arrête.

Le OU logique n'est pas le « ou » du français. Quand on dit « fromage ou dessert », on veut dire l'un ou l'autre mais pas les deux : c'est le OU exclusif. Le OU des circuits est inclusif — les deux, c'est vrai aussi. Cette ambiguïté du langage courant est une source d'erreurs constante, et elle explique pourquoi le OU-X (exclusive or, XOR) a fini par exister comme porte à part entière.

Le OU exclusif est un détecteur de différence. C'est la lecture la plus utile : si et seulement si A et B ne sont pas égaux. Cette propriété sera au cœur de l'addition, du comparateur, du chiffrement et des sommes de contrôle. Retenez-la : le OU-X compare.

Les portes inversées ne sont pas des curiosités. Le NON-ET et le NON-OU semblent des versions négatives, moins naturelles. En électronique, c'est exactement l'inverse : ce sont elles qui sont les moins chères à fabriquer, et le chapitre suivant montre qu'elles sont capables de tout.

Les lois de De Morgan Algèbre de Boole

Avec la notation classique : pour le ET, pour le OU, la barre pour la négation. En français : « pas (A et B) » équivaut à « (pas A) ou (pas B) ». Ces deux identités, énoncées par Augustus De Morgan au XIXe siècle, permettent de transformer n'importe quel circuit en un circuit équivalent où les inverseurs ont été déplacés — c'est le premier outil d'optimisation des ingénieurs.

EXP 03 Le banc d'essai des portes Interactif

Choisissez une porte, actionnez les interrupteurs A et B. Le fil devient bleu quand il porte un 1 — c'est la convention exacte du simulateur. La ligne correspondante de la table de vérité s'allume.

Entrées Sortie0
Schéma de la porte logique sélectionnée ET A=0 B=0 sortie

Table de vérité

ABSortie

Les six fonctions sont celles définies dans src/logic/gates.ts du simulateur. Aucune n'est approximée ici : les valeurs affichées sont calculées avec les mêmes opérateurs binaires.

Chapitre troisième

La porte qui suffit à tout

Si l'on ne devait garder qu'une seule porte, laquelle ? La réponse est contre-intuitive : ni le ET, ni le OU. Le NON-ET.

Le NON-ET (NAND) est dite universelle : n'importe quelle fonction logique, si compliquée soit-elle, peut être construite uniquement avec des NON-ET. Pas besoin d'inverseur, pas besoin de OU exclusif, pas besoin d'un catalogue de composants. Une seule brique, répétée.

La démonstration tient en trois montages, et vous pouvez les manipuler ci-dessous :

  • L'inverseur — on branche la même entrée sur les deux pattes. . Une porte.
  • Le ET — un NON-ET suivi d'un inverseur (lui-même un NON-ET). Deux portes.
  • Le OU — on inverse chaque entrée, puis on les passe dans un NON-ET. C'est De Morgan lu à l'envers. Trois portes.

Une fois le ET, le OU et le NON reconstruits, tout le reste suit : le OU exclusif, l'additionneur, la mémoire, le processeur. La chaîne entière repose sur une brique unique.

L'universalité du NON-ET Complétude fonctionnelle

Trois identités, une seule porte. Un ensemble d'opérateurs capable d'exprimer toute fonction booléenne est dit fonctionnellement complet ; le NON-ET l'est à lui seul, et le NON-OU aussi. Le résultat est publié par Henry M. Sheffer en 1913 — d'où le nom de barre de Sheffer donné à cette opération en logique. Charles Sanders Peirce l'avait pourtant établi dès 1880, dans un manuscrit resté inédit jusqu'en 1933 : la paternité, ici, revient à celui qui a publié.

Pourquoi le NON-ET plutôt que le ET ? Parce qu'en technologie CMOS — celle de toutes les puces modernes — un NON-ET à deux entrées se fabrique avec quatre transistors, tandis qu'un ET en demande six : il faut d'abord faire le NON-ET, puis l'inverser. La porte « naturelle » du silicium est la porte inversée. L'industrie a donc bâti des milliards de circuits sur la brique qui, sur le papier, paraissait la moins élégante.

EXP 04 Tout reconstruire avec un seul type de porte Interactif

Choisissez la fonction à reconstruire, puis actionnez A et B. Le montage n'utilise que des NON-ET. La ligne de vérification compare, à chaque instant, la sortie du montage avec celle de la porte de référence.

Entrées Montage0
Montage à base de portes NON-ET reconstruisant la fonction choisie

Le montage est simulé porte par porte, dans l'ordre de propagation. La colonne « référence » applique directement l'opérateur binaire du langage — si les deux colonnes coïncident sur les quatre combinaisons, l'équivalence est vérifiée.

Chapitre quatrième

La première machine qui calcule

Frise gravée : huit pavillons dorés identiques alignés, reliés par une ligne de retenue unique qui s'illumine de gauche à droite.
L'additionneur à propagation de retenue : huit étages identiques, une seule retenue qui traverse

Jusqu'ici, rien ne calcule. Les portes décident, elles ne comptent pas. Le passage à l'arithmétique tient dans une observation d'école primaire, transposée en base deux.

Posez l'addition 1 + 1 en binaire. Le résultat est 10 : un zéro, et une retenue. C'est tout le problème et toute la solution. Additionner deux bits produit deux bits : une somme et une retenue.

Le demi-additionneur

Regardez la table des quatre cas possibles :

Addition de deux bits — le demi-additionneur.
ABSommeRetenueRemarque
0000rien
0110un seul bit à 1
1010un seul bit à 1
1101ça déborde : on retient

La colonne « Somme » est exactement la table du OU exclusif. La colonne « Retenue » est exactement celle du ET. On n'a rien inventé : l'addition binaire était déjà dans les portes, il suffisait de les regarder sous le bon angle.

Le demi-additionneur 2 portes

Un OU exclusif pour la somme, un ET pour la retenue. On l'appelle demi-additionneur parce qu'il ne sait pas recevoir de retenue entrante — il ne peut donc pas être chaîné, et ne sert que pour le bit de poids le plus faible.

L'additionneur complet

Pour chaîner, il faut un troisième port d'entrée : la retenue venue du rang précédent, notée . On additionne alors trois bits, et le résultat tient toujours sur deux : une somme et une retenue sortante.

La construction canonique — celle du module « Additionneur 1-bit » livré avec le simulateur — enchaîne deux demi-additionneurs et réunit leurs retenues par un OU :

L'additionneur complet, 1 bit 5 portes

La somme vaut 1 quand un nombre impair d'entrées vaut 1 — c'est la généralisation du OU exclusif à trois termes. La retenue sort si A et B valent tous deux 1 (on génère une retenue), ou si exactement l'un des deux vaut 1 et qu'une retenue arrivait déjà (on la propage). Cette distinction entre génération et propagation est la clé de toutes les optimisations d'additionneurs rapides.

Cinq portes : deux OU exclusifs, deux ET, un OU. C'est le décompte réel du circuit livré dans le simulateur — on peut l'ouvrir et les compter.

Anti-intox — un décompte à corriger

La documentation du simulateur annonce, pour ce module, « 3 XOR + 2 AND + 1 OR = 6 gates ». Le circuit réellement construit dans le code (src/data/prebuiltModules.ts) contient 2 OU-X, 2 ET et 1 OU, soit 5 portes — et par conséquent 40 portes pour l'additionneur 8 bits, et non 48. La documentation compte une porte de trop ; c'est le code qui fait foi, et le chiffre correct est celui de la théorie classique.

Huit étages, une retenue qui traverse

Il ne reste plus qu'à empiler. On place huit additionneurs complets côte à côte ; la retenue sortante de chacun devient la retenue entrante du suivant. C'est l'additionneur à propagation de retenue (ripple carry adder) — littéralement : la retenue « ondule » d'un bout à l'autre.

La chaîne de retenue 8 bits
bit 0 :  A0 + B0 + Cin  →  S0, C0
bit 1 :  A1 + B1 + C0   →  S1, C1
bit 2 :  A2 + B2 + C1   →  S2, C2
  ...
bit 7 :  A7 + B7 + C6   →  S7, Cout   ← le débordement au-delà de 255

Cette simplicité a un coût, et c'est le premier vrai compromis d'architecture du dossier : le bit 7 ne peut pas être calculé avant que la retenue ait traversé les sept étages précédents. Le temps de calcul est proportionnel au nombre de bits.

Le prix de la simplicité Temps de propagation

Avec n le nombre de bits. Sur 8 bits, c'est acceptable. Sur 64 bits, ce serait catastrophique : c'est pourquoi les processeurs modernes utilisent des additionneurs à anticipation de retenue (), qui calculent les retenues en parallèle à partir des signaux de génération et de propagation, au prix de beaucoup plus de portes. Le temps devient alors proportionnel à plutôt qu'à .

Le neuvième bit — celui qui sort par le haut et n'a nulle part où aller — porte un nom qui reviendra sans cesse dans ce dossier : la retenue, le drapeau carry. C'est la trace de ce que la machine a été incapable de garder.

EXP 05 L'additionneur complet, porte par porte Interactif

Actionnez A, B et Cin. Chaque fil s'allume en bleu quand il porte un 1 ; les cinq portes du montage réagissent. Huit combinaisons, huit lignes : la table complète est en face.

Somme0 Cout0
Schéma d'un additionneur complet 1 bit : deux OU exclusifs, deux ET, un OU A=0 B=0 Cin=0 XOR 1 & AND 1 XOR 2 & AND 2 ≥1 OR Somme Cout

Table de vérité — 3 entrées

ABCinSCout

Montage identique au module « Additionneur 1-bit » du simulateur : deux demi-additionneurs (XOR 1 / AND 1, puis XOR 2 / AND 2) dont les retenues sont réunies par un OU.

EXP 06 L'addition 8 bits et la retenue qui traverse Interactif · animé

Réglez les deux opérandes, puis lancez la propagation pour voir la retenue avancer d'étage en étage, exactement comme dans le circuit. Le bit de gauche est le poids fort (128), celui de droite le poids faible (1).

poids
A
B
retenue entrante
Somme

Résultat

Ce qui se passe

Calcul strictement identique à la fonction add8() du simulateur : huit additionneurs complets chaînés, retenue propagée du bit 0 vers le bit 7.

Chapitre cinquième

Comparer, c'est soustraire et regarder ce qui reste

Un ordinateur qui ne sait qu'additionner ne fait rien d'intéressant. Ce qui le rend utile, c'est la capacité de bifurquer : faire une chose si un nombre est plus grand qu'un autre, une autre sinon. Or comparer deux nombres n'est pas une opération de plus. C'est une soustraction qu'on jette.

Calculez et ne gardez pas le résultat. Gardez seulement trois informations sur ce résultat — trois bits qu'on appelle des drapeaux (flags) :

Les trois drapeaux du processeur étudié, tels qu'ils sont calculés dans son code (src/cpu/cpu.ts).
DrapeauNomVaut 1 quand…Ce qu'il révèle
ZZérole résultat sur 8 bits est exactement 0A = B — égalité
CRetenuele calcul complet est sorti de l'intervalle 0–255A < B — emprunt, ou débordement d'une addition
NNégatifle bit 7 du résultat vaut 1 (valeur ≥ 128)le signe, si l'on lit en complément à deux

Trois bits, et la comparaison complète est faite. Si Z vaut 1, les nombres sont égaux. Si Z vaut 0 et C vaut 1, alors A est strictement inférieur à B. Si Z et C valent tous deux 0, A est strictement supérieur. Il n'existe pas de quatrième cas.

Le comparateur, en trois drapeaux Arithmétique 8 bits

Les crochets valent 1 si la condition est vraie, 0 sinon. Le drapeau N reprend simplement le bit 7 du résultat tronqué : il n'a de sens que si l'on décide de lire les octets comme des entiers signés — ce que ce processeur ne fait pas par défaut, puisque ses entiers vont de 0 à 255.

Sur ce processeur, l'instruction qui fait ce travail s'appelle CMP : elle calcule la différence, met les drapeaux à jour, et jette le résultat. Le registre A n'est pas modifié. Les instructions de saut conditionnel — JZ, JNZ, JC, JNC, JN — lisent ensuite ces drapeaux et décident d'aller ailleurs ou non.

Ce qu'il faut retenir

Il n'y a pas de « circuit de comparaison » séparé dans cette machine. Comparer, c'est soustraire ; décider, c'est lire trois bits. Toute la logique conditionnelle d'un programme — les if, les while, les for — finit dans ces trois fils.

Anti-intox — le piège n° 1 de l'assembleur

Sur cette machine comme sur les vraies, certaines instructions modifient les drapeaux et d'autres non. STA (ranger A en mémoire) ne les touche pas. POP, LDM, INC, DEC les modifient tous.

Glisser un POP entre un CMP et un JZ détruit silencieusement la comparaison : le saut teste alors le résultat du POP. Le programme ne plante pas, il se trompe. C'est la source de bug la plus classique de la programmation en assembleur — et elle n'a rien d'exotique : les processeurs x86 et ARM ont exactement le même piège.

EXP 07 Le comparateur : trois drapeaux, quatre décisions Interactif

Réglez A et B. La machine calcule A − B, met les drapeaux à jour et jette le résultat. En dessous, l'état de chaque saut conditionnel : pris ou non pris.

Ce que fait CMP

ZéroZ = 0 RetenueC = 0 NégatifN = 0

Les sauts conditionnels

InstructionConditionDécision

Les drapeaux sont calculés exactement comme dans updateFlags() du processeur simulé : Z sur le résultat tronqué à 8 bits, N sur son bit 7, C sur le résultat complet avant troncature.

Chapitre sixième

L'unité arithmétique et logique

On sait additionner. On sait comparer. On sait faire des ET et des OU sur des bits. Il ne reste qu'à ranger tout cela dans une seule boîte, avec une manette pour choisir ce qu'on veut.

Cette boîte s'appelle l'ALUArithmetic Logic Unit, unité arithmétique et logique. C'est le cœur calculatoire du processeur. Elle prend deux nombres de 8 bits, un code d'opération de 3 bits, et rend un résultat de 8 bits accompagné des trois drapeaux.

Trois bits de commande, donc opérations possibles. Ni plus, ni moins — et c'est exactement ce que propose celle du simulateur :

Les huit opérations de l'ALU 8 bits, avec leur code de commande.
op2 op1 op0OpérationEffetRetenue
0 0 0ADDA + B1 si le total dépasse 255
0 0 1SUBA − B1 si le résultat passe sous 0
0 1 0ANDA & B
0 1 1ORA | B
1 0 0XORA ^ B
1 0 1NOT~A(B ignoré)
1 1 0SHLA << 1reçoit le bit 7 éjecté
1 1 1SHRA >> 1reçoit le bit 0 éjecté

Les deux dernières méritent une explication, car ce sont les plus rentables de toute l'informatique. Décaler les bits d'un cran vers la gauche revient à multiplier par deux ; d'un cran vers la droite, à diviser par deux. Dans les deux cas, aucun calcul n'est effectué : on renomme les fils, c'est tout.

Le décalage, ou la multiplication gratuite Optimisation

Un décalage coûte un seul cycle et zéro logique arithmétique. C'est pourquoi le compilateur C du simulateur applique une réduction de force : il transforme x * 8 en trois décalages à gauche, x / 4 en deux décalages à droite, et x % 8 en un simple ET avec 7. La division entière par une puissance de deux est arrondie vers le bas — les bits éjectés à droite sont définitivement perdus.

Une remarque de vocabulaire, pour finir. Le drapeau « retenue » ne signifie pas la même chose selon l'opération : après une addition, il annonce un débordement au-delà de 255 ; après une soustraction, un emprunt sous zéro ; après un décalage, il recueille le bit qui vient d'être éjecté. Un seul fil, trois significations — l'économie de moyens poussée jusqu'à l'ambiguïté. C'est au programmeur de savoir ce qu'il vient de demander.

EXP 08 L'ALU aux commandes Interactif

Choisissez l'opération avec les trois interrupteurs de commande — ou directement dans la liste. Réglez A et B, lisez le résultat et les drapeaux.

Code d'opération
A
B
Résultat

Sortie

Drapeaux

ZéroZ = 0 RetenueC = 0 NégatifN = 0

Sémantique reprise ligne à ligne du nœud alu8 du simulateur : troncature à 8 bits, retenue selon l'opération, drapeaux Z et N calculés sur le résultat tronqué.

Chapitre septième

Se souvenir : la boucle qui se mord la queue

Grille de cellules mémoire gravée s'enfonçant dans l'obscurité ; au premier plan, une cellule ouverte montre deux portes qui se font face dans une boucle de lumière fermée.
Une cellule mémoire : deux portes qui s'alimentent l'une l'autre, indéfiniment

Toutes les portes vues jusqu'ici ont une propriété commune : elles oublient. Coupez l'entrée, la sortie disparaît. Il n'y a nulle part où l'information puisse rester. Or sans mémoire, il n'y a ni variable, ni programme, ni ordinateur.

La solution est d'une élégance déconcertante, et elle consiste à faire exactement ce que tout schéma logique interdit : rebrancher la sortie sur l'entrée.

Le verrou : deux portes qui se tiennent

Prenez deux portes NON-ET. Branchez la sortie de la première sur une entrée de la seconde, et la sortie de la seconde sur une entrée de la première. Vous venez de créer une boucle de rétroaction.

Que se passe-t-il ? Supposez que la première sorte un 1. Ce 1 entre dans la seconde, qui sort donc un 0. Ce 0 revient dans la première, qui — puisqu'un NON-ET avec une entrée à 0 sort forcément 1 — confirme son 1. Le système est stable. Il tiendra ainsi tant qu'il y aura du courant.

Et l'état inverse est tout aussi stable. Deux états stables, donc : c'est une bascule bistable, un latch. Elle a mémorisé un bit — non pas en le stockant quelque part, mais en continuant à le produire. Une mémoire électronique est un circuit qui n'arrête jamais de se répéter la même chose.

Le verrou D du simulateur 1 NON + 4 NON-ET = 5 portes
NAND1(D, WE)        →  S̄        ← ordre « mettre à 1 »
NOT(D)               →  D̄
NAND2(D̄, WE)         →  R̄        ← ordre « mettre à 0 »

NAND3(S̄, )          →  Q        ┐ les deux portes croisées
NAND4(R̄, Q)          →          ┘ se maintiennent mutuellement

WE = 1  →  Q suit D      (la cellule est transparente)
WE = 0  →  Q tient       (la boucle s'auto-alimente, D n'a plus d'effet)

Le fil WE (write enable, autorisation d'écriture) est le verrou proprement dit. À 1, la cellule laisse passer la donnée. À 0, elle se referme sur elle-même, et rien de ce qui se passe sur D ne l'atteint plus. Cinq portes pour mémoriser un bit ; quarante pour mémoriser un octet.

Du verrou au registre, puis à la mémoire

Un verrou transparent pose un problème pratique : tant que WE est à 1, la sortie suit l'entrée en temps réel, et le moindre parasite passe. On préfère donc une variante qui ne capture qu'à un instant précis — le registre à front d'horloge, dont le chapitre suivant explique le mécanisme.

Au-dessus, on empile : huit cellules partageant le même signal d'écriture forment un registre 8 bits. Un millier de registres, munis d'un décodeur d'adresse, forment une mémoire vive (SRAM). Le principe reste identique — c'est une question de quantité, et de la logique qui choisit à quelle cellule on parle.

Le décodeur d'adresse Adressage

La mémoire de cette machine tient sur 13 bits d'adresse, soit 8 192 octets — c'est la valeur codée en dur dans son jeu d'instructions (MEMORY_SIZE = 8192, masque d'adresse 0x1FFF). C'est peu : un texte de trois pages. C'est aussi assez pour un système d'exploitation, comme la suite le montrera.

La hiérarchie de vitesse, elle, est la même que dans n'importe quelle machine moderne, à quelques ordres de grandeur près : les registres sont dans le processeur et répondent immédiatement ; la mémoire vive est à côté et coûte un accès ; le disque est ailleurs, et coûte cher. Un ordinateur passe une part considérable de son temps à déplacer des octets entre ces trois étages.

EXP 09 Mémoriser un bit — le verrou en action Interactif

Mettez WE à 1 : la sortie Q suit D. Remettez WE à 0, puis changez D autant que vous voulez — Q ne bouge plus. Vous venez de mémoriser. La boucle de rétroaction, en bas du schéma, est ce qui tient la valeur.

Q mémorisé0
Schéma d'un verrou D construit avec quatre portes NON-ET et un inverseur D=0 WE=0 NON NOT NON-ET 1 NON-ET 2 NON-ET 3 → Q NON-ET 4 → Q̄ Q boucle de rétroaction croisée — c'est elle qui « tient » le bit

Journal de la cellule

Montage identique au module « Mémoire 1-bit » du simulateur. La boucle est résolue par itérations successives jusqu'à stabilisation, exactement comme le fait son moteur de simulation.

Chapitre huitième

Le battement

Il manque encore quelque chose d'essentiel, et ce n'est pas un composant de calcul : c'est du temps.

Un circuit purement combinatoire n'a pas de temps. Les signaux se propagent, chacun à sa vitesse, à travers des chemins de longueurs différentes. Pendant quelques nanosecondes après un changement d'entrée, les sorties sont dans un état transitoire absurde — des valeurs intermédiaires qui n'ont aucun sens et qu'on appelle des aléas. Si un registre les capture, le calcul est faux.

D'où l'horloge : un signal qui alterne indéfiniment entre 0 et 1, à intervalles réguliers. Elle ne calcule rien. Elle ne transporte aucune information. Elle dit une seule chose, et elle la dit tout le temps : maintenant.

Période et fréquence Cadencement

L'horloge du simulateur se règle entre 0,5 et 10 Hz — assez lentement pour qu'on voie les signaux bouger à l'œil nu. Une machine de 1983 tournait autour du mégahertz : le 6510 du Commodore 64 était cadencé à ≈ 1 MHz, le Z80 du ZX Spectrum à 3,5 MHz. Un processeur de bureau actuel dépasse les 4 milliards de battements par seconde, soit un milliard de fois plus vite que le simulateur — mais il fait, à chaque battement, exactement le même genre de choses.

Le front, pas le niveau

Ce qui compte n'est pas que l'horloge soit à 1, mais l'instant de la transition de 0 vers 1 : le front montant. C'est là, et seulement là, que les registres regardent leurs entrées et décident de les recopier.

Le registre à front montant comportement exact
Horloge :  0   0   1   1   0   0   1   1   0
                              
              front montant   front montant
              (on capture)     (on capture)

À chaque front montant :
  si RST = 1  →  valeur ← 0            (remise à zéro)
  sinon si LOAD = 1 →  valeur ← D      (capture de l'entrée)
  sinon           →  valeur inchangée   (maintien)

Entre deux fronts : la valeur ne bouge pas, quoi qu'il arrive sur D.

C'est cette discipline qui rend un ordinateur prévisible. Entre deux fronts, la logique combinatoire a tout le temps de se stabiliser ; au front suivant, on ne capture que des valeurs propres. Le rôle de l'horloge n'est pas d'accélérer la machine, c'est de synchroniser ses erreurs — de garantir qu'à l'instant où l'on regarde, tout est arrivé.

Et c'est aussi la limite ultime de la vitesse d'un processeur. La période de l'horloge doit être plus longue que le plus lent des chemins combinatoires — le chemin critique. Souvenez-vous du chapitre IV : sur un additionneur à propagation de retenue 8 bits, ce chemin critique, c'est la retenue qui traverse les huit étages. Voilà pourquoi le choix de l'additionneur détermine la fréquence maximale de toute la machine.

EXP 10 L'horloge et le registre : capturer au bon instant Interactif · chronogramme

Lancez l'horloge, puis changez D n'importe quand. Observez : la sortie Q ne bouge qu'aux fronts montants, marqués en pointillés. Coupez LOAD et le registre ignore tout.

Chronogramme de l'horloge, de l'entrée D et de la sortie Q du registre

État du registre

Journal des fronts

Comportement identique au nœud register8 du simulateur : détection de front par mémorisation de l'état précédent de l'horloge, priorité à la remise à zéro sur le chargement.

Chapitre neuvième

Assembler la machine

Plan architectural gravé d'une machine de von Neumann : une tour à gauche, une nef de cellules au centre, une forge octogonale à droite, cinq annexes en bas, reliées par un circuit lumineux.
Le plan de la machine : compteur, mémoire, unité de calcul, périphériques — et le circuit fermé qui les relie

Nous avons maintenant tous les organes : de quoi calculer (l'ALU), de quoi retenir (les registres et la mémoire), de quoi choisir (les multiplexeurs), et de quoi rythmer (l'horloge). Il reste à décider comment les brancher — et ce choix a un nom.

C'est l'architecture de von Neumann, formulée en 1945 dans le rapport sur l'EDVAC : une mémoire unique qui contient à la fois les données et les instructions. Le programme n'est plus un câblage, ni des cartes perforées à changer à la main : c'est un nombre parmi d'autres, rangé au même endroit que le reste.

Cette confusion volontaire entre code et données est l'idée la plus lourde de conséquences de toute l'informatique. Elle rend possible le compilateur — un programme qui écrit des programmes. Elle rend possible le système d'exploitation — un programme qui en charge d'autres. Elle rend possible, aussi, le virus et le débordement de tampon, puisque rien ne distingue physiquement une instruction d'une donnée.

Les organes du processeur

Les registres du processeur 8 bits du simulateur.
RegistreNomRôleLargeur
AAccumulateurLe registre principal. Tout résultat de calcul y atterrit.8 bits
BRegistre auxiliaireLe second opérande des opérations à deux entrées.8 bits
PCCompteur ordinalL'adresse de la prochaine instruction à exécuter.13 bits utiles
SPPointeur de pileLe sommet de la pile ; démarre en haut de la mémoire, à 0x1FFF.13 bits utiles
IRRegistre d'instructionContient l'opcode en cours de décodage.8 bits
Z C NDrapeauxLa mémoire du dernier calcul : nul, débordé, négatif.1 bit chacun

Deux registres de travail. Un ordinateur entier tient sur deux registres de huit bits. Tout le reste — les tableaux, les chaînes de caractères, les structures, les images — vit en mémoire, et doit faire l'aller-retour par A ou par B pour être touché.

La boucle éternelle

Le processeur ne fait qu'une chose, et il la refait jusqu'à ce qu'on l'arrête :

  1. Chercher (fetch) — lire en mémoire l'octet situé à l'adresse contenue dans PC.
  2. Décoder (decode) — regarder cet octet et savoir de quelle instruction il s'agit, et si elle est suivie d'un opérande.
  3. Exécuter (execute) — faire ce qu'elle dit : additionner, ranger, sauter, afficher.
  4. Avancer — ajouter à PC la taille de l'instruction, et recommencer.

C'est tout. Ce cycle est la définition même d'un processeur, et il n'a pas changé depuis 1945. Un cœur de processeur moderne en exécute plusieurs par cycle d'horloge, dans le désordre, en spéculant sur les branchements — mais la boucle reste celle-là.

Ce qu'il faut retenir

Un programme n'est pas « lancé » : il est déroulé. La seule chose qui avance, c'est un nombre — le compteur ordinal. Un saut, un appel de fonction, une boucle, un plantage : ce sont tous des façons différentes d'écrire une nouvelle valeur dans PC.

EXP 11 Le cycle chercher–décoder–exécuter, pas à pas Interactif · animé

Un programme de quatre instructions est en mémoire. Avancez d'une phase à la fois pour voir quelles unités s'allument et quels bus transportent quoi — ou lancez l'exécution automatique.

Programme chargé en 0x0000
Chemin de données du processeur : compteur ordinal, mémoire, registre d'instruction, registres, ALU
1 · ChercherPC donne l'adresse, la mémoire renvoie l'octet.
2 · DécoderL'octet entre dans IR : le processeur sait quoi faire.
3 · ExécuterL'ALU calcule, on range, on saute, on affiche.
4 · AvancerPC += taille de l'instruction. On recommence.

Le programme en mémoire

État de la machine

PC0x000 IR A0 B0

Les quatre phases sont une décomposition pédagogique : le processeur simulé exécute en réalité une instruction complète par appel de sa fonction step(). Le découpage chercher / décoder / exécuter / avancer est celui de son code, phase par phase.

Chapitre dixième

Huit mille cent quatre-vingt-douze octets

Toute la mémoire de la machine tient dans 8 192 cases. Il n'y a pas de mémoire virtuelle, pas de pagination, pas de protection : une seule étendue plate, numérotée de 0 à 8191, et une convention pour se répartir la place.

Cette convention n'est pas imposée par le matériel — le processeur ne sait rien de ce découpage. C'est le compilateur et l'amorceur qui se mettent d'accord, et qui s'y tiennent :

0x0000 – 0x0FFF
Code du programme4 096 octets
0x1000 – 0x100F
Variables globales16 octets — 16 maximum
0x1010 – 0x1017
Brouillon du compilateur8 octets
0x1018 – 0x101F
Arguments passés par l'amorceur8 octets
0x1020 – 0x17FF
Cadres de fonctions — variables locales2 016 octets, réutilisés
0x1800 – 0x1FFF
Pile — croît vers le bas depuis 0x1FFF2 048 octets

Trois choses méritent d'être soulignées dans ce plan.

Le code est plafonné à 4 096 octets. Pas 4 096 lignes : 4 096 octets. Comme une instruction pèse un ou trois octets, cela fait entre 1 300 et 4 000 instructions. C'est la contrainte structurante de toute la programmation sur cette machine, et c'est pourquoi son compilateur C consacre autant d'efforts à réduire la taille du code produit.

Il n'y a que seize variables globales. Seize octets, adresses 0x1000 à 0x100F. La dix-septième est refusée par le compilateur, avec une erreur. Un tableau global de dix cases en consomme dix à lui seul.

La pile croît vers le bas. Elle démarre à 0x1FFF, tout en haut, et descend à chaque empilement. Les cadres de fonctions, eux, montent depuis 0x1020. Les deux zones se font face et se rapprochent : si elles se rencontrent, la machine écrase silencieusement ses propres données. Ce phénomène — le débordement de pile — est ici parfaitement visible, alors qu'il est masqué par le système d'exploitation sur une machine moderne.

Le coût d'un appel de fonction Pile

Chaque CALL empile l'adresse de retour sur deux octets (l'adresse est sur 16 bits). Pour une fonction récursive, le compilateur sauvegarde en plus le cadre courant avant l'appel et le restaure au retour ; pour une fonction ordinaire, il s'en dispense entièrement. Avec 2 048 octets de pile, une récursion profonde finit par mordre sur les données — et rien ne l'en empêche : il n'y a aucun garde-fou à l'exécution.

Anti-intox — ce que la documentation du projet dit d'un peu vite

Deux valeurs circulent dans la documentation du simulateur pour le disque externe : 8 Ko / 32 pages d'un côté, 64 Ko / pages de 256 octets de l'autre. Le code fait foi : DRIVE_SIZE = 65536 et DRIVE_PAGE_SIZE = 256, soit 64 Ko répartis en 256 pages. La mention « 8 Ko » est un vestige d'une version antérieure.

La documentation du projet est riche et précise, mais elle est écrite à la main : quand elle diverge du code, c'est le code qui décrit la machine.

EXP 12 Les huit mille cases, une par pixel Interactif · animé

Toute la mémoire de la machine, à l'échelle : un pixel par octet, 128 colonnes, 64 lignes, l'adresse 0 en haut à gauche. Survolez pour lire une adresse. Puis lancez la récursion sans fin et regardez la pile descendre depuis 0x1FFF jusqu'à mordre sur les cadres de fonctions — sans que rien ne l'en empêche.

Pointeur de pile

SP — adresse du sommet, en hexadécimal

Journal

La récursion est exécutée par le vrai processeur de ce dossier, pas par une animation : chaque niveau consomme trois octets — deux pour l'adresse de retour empilée par CALL, un pour le PUSH. La collision affichée est celle que produit le compte réel des octets.

Chapitre onzième

Le vocabulaire de la machine

Le processeur lit des octets et les interprète. La liste des octets qui veulent dire quelque chose, avec leur signification exacte, s'appelle le jeu d'instructions — l'ISA, Instruction Set Architecture. C'est le contrat entre le matériel et tout ce qui tournera dessus.

Celui de cette machine compte 62 instructions. À titre de comparaison, le 6502 en avait 56, le Z80 environ 250, un processeur x86 moderne plusieurs milliers avec ses extensions. Soixante-deux, c'est assez peu pour tenir sur une page — et assez pour faire tourner un système.

L'encodage : un ou trois octets

Comment sait-on où finit une instruction et où commence la suivante ? Par une convention magnifiquement simple : le premier bit de l'opcode le dit.

Longueur variable, décidée par le premier octet encodage
opcode 0x00 … 0x7F1 octet   l'instruction se suffit à elle-même
opcode 0x80 … 0xFF3 octets  suivie d'un opérande 16 bits, poids faible d'abord

  INC01
  LDA 4280 2A 00           ← 42 = 0x2A, puis l'octet de poids fort
  JMP 0x0103A0 03 01           ← petit-boutiste : 03 avant 01

Autrement dit : si le bit 7 de l'opcode vaut 1, il y a un opérande. Le décodeur n'a qu'un seul bit à regarder. C'est exactement le genre de décision qu'on prend quand on conçoit le matériel en même temps que le langage.

L'ordre des octets de l'opérande — le moins significatif en premier — s'appelle le petit-boutisme (little-endian). C'est la convention du x86 et de la plupart des ARM ; l'inverse, le gros-boutisme, régnait sur les Motorola 68000 et reste la norme des protocoles réseau. Le nom vient des Voyages de Gulliver, où deux nations entrent en guerre pour savoir par quel bout casser un œuf à la coque. C'est Danny Cohen qui a importé la métaphore en informatique, en 1980, dans une note au titre éloquent : On Holy Wars and a Plea for Peace — une plaisanterie d'ingénieur devenue vocabulaire officiel.

Les soixante-deux mots

Le vocabulaire complet, groupé par famille. La colonne « drapeaux » est celle qu'il faut connaître par cœur avant d'écrire une boucle.

Jeu d'instructions complet, relevé dans src/cpu/isa.ts. « Drapeaux » indique si l'instruction met à jour Z, C et N.
CodeMnémoniqueTailleEffetDrapeaux
0x00NOP1ne fait rien
0x0FHLT1arrête le processeur
0x01INC1A ← A + 1oui
0x02DEC1A ← A − 1oui
0x03NOT1A ← NON A, bit à bitoui
0x04SHL1A ← A décalé à gauche (×2)oui
0x05SHR1A ← A décalé à droite (÷2)oui
0x06TAB1B ← A
0x07TBA1A ← Boui
0x08ADDB1A ← A + Boui
0x09SUBB1A ← A − Boui
0x13ANDB1A ← A ET Boui
0x14ORB1A ← A OU Boui
0x15XORB1A ← A OU-X Boui
0x16CMPB1drapeaux ← A − B, sans rangeroui
0x17MULB1A ← A × Boui
0x18DIVB1A ← A ÷ B (0 si B = 0)oui
0x19MODB1A ← A modulo B (0 si B = 0)oui
0x0AINA1A ← caractère saisi (0 si rien)oui
0x0BGETKEY1A ← état de la touche d'indice Aoui
0x0CRAND1A ← octet pseudo-aléatoireoui
0x0DSLEEP1attend A cycles
0x11PUSH1empile A
0x12POP1A ← dépileoui
0x10RET1dépile l'adresse de retour (16 bits) et y saute
0x20OUTA1affiche A comme caractère
0x21OUTD1affiche A comme nombre décimal
0x1ECLCON1efface la console
0x22DRAW1trace le pixel (A, B) dans la couleur courante
0x23CLR1efface l'écran graphique
0x1A · 0x1B · 0x1CCOLR · COLG · COLB1fixe le rouge / vert / bleu du tracé depuis A
0x24 · 0x25DRVRD · DRVWR1lit / écrit un octet du disque externe
0x26 · 0x27DRVCLR · DRVPG1efface le disque / choisit la page courante
0x1DHTTPIN1A ← octet suivant de la réponse HTTPoui
0x80 · 0x81LDA · LDB3A ← valeur immédiate · B ← valeur immédiateLDA seul
0x82 · 0x83ADD · SUB3A ← A ± valeur immédiateoui
0x84 · 0x85 · 0x86AND · OR · XOR3opération bit à bit avec une valeur immédiateoui
0x87CMP3drapeaux ← A − valeur, sans rangeroui
0x90 · 0x92STA · STB3MEM[adr] ← A · MEM[adr] ← B
0x91 · 0x93LDM · LBM3A ← MEM[adr] · B ← MEM[adr]LDM seul
0x94LDAI3A ← MEM[adr + A] — lecture indexéeoui
0x95STAI3MEM[adr + B] ← A — écriture indexée
0x96 · 0x97HTTPGET · HTTPPOST3lance une requête HTTP décrite en mémoire
0xA0JMP3saut inconditionnel
0xA1 · 0xA2JZ · JNZ3saut si Z / si non Z
0xA3 · 0xA4JC · JNC3saut si retenue / si pas de retenue
0xA5JN3saut si drapeau négatif
0xB0CALL3empile l'adresse de retour (2 octets) et saute
0xC0OUT3affiche une valeur immédiate comme caractère

L'assembleur : deux passes, et c'est réglé

Écrire ces octets à la main serait insupportable. On écrit donc des mnémoniquesLDA 42 plutôt que 80 2A 00 — et un programme traduit. Ce programme s'appelle un assembleur, et c'est le plus simple des traducteurs : une ligne de source donne une instruction machine, sans réorganisation ni optimisation.

Une seule difficulté, mais elle est réelle : les étiquettes. Quand on écrit JMP boucle, l'assembleur doit remplacer boucle par une adresse — qu'il ne connaît pas encore si l'étiquette est plus bas dans le fichier. D'où la solution classique, en deux passes :

  1. Première passe — parcourir tout le source en comptant les octets, sans rien produire, pour noter l'adresse de chaque étiquette.
  2. Seconde passe — reparcourir en produisant les octets, en remplaçant chaque étiquette par l'adresse relevée à la première passe.

C'est exactement ce que fait l'assembleur du simulateur — et c'est aussi ce que fait celui qui tourne dans l'expérience suivante.

EXP 13 Le décodeur : un seul bit décide de tout Interactif

Composez un octet en cliquant sur ses bits. Le bit 7, cerclé d'or, est le seul que le décodeur regarde pour savoir si l'instruction est suivie d'un opérande. Sous le décodeur, un flux d'octets bruts : voyez où le processeur découpe.

Octet à décoder

Verdict du décodeur

Découpage d'un flux d'octets

Octets

Le découpage est celui de la fonction de désassemblage de cette page, qui applique la règle du simulateur : opcode < 0x80 → l'instruction fait un octet ; opcode ≥ 0x80 → deux octets d'opérande suivent, poids faible d'abord. Un octet qui ne correspond à aucun opcode connu est rendu tel quel, en .db.

Chapitre douzième

La machine, ici, maintenant

Onze chapitres de théorie appellent une vérification. Voici donc un processeur 8 bits complet — assembleur deux passes, mémoire de 8 192 octets, pile, registres, drapeaux, console — qui s'exécute réellement dans cette page, dans votre navigateur, sans rien télécharger.

Il implémente le jeu d'instructions du chapitre précédent, à l'exception des périphériques matériels (écran graphique, clavier, disque, réseau) qui n'auraient pas de sens ici. Tout le reste est là : les mêmes opcodes, les mêmes drapeaux, le même encodage petit-boutiste, la même pile qui descend depuis 0x1FFF.

Les sept programmes fournis ont été assemblés et exécutés sur le processeur d'origine avant d'être placés ici. Leurs sorties correspondent, octet pour octet.

EXP 14 L'ordinateur 8 bits — assembleur et processeur Exécutable
Programmes

Registres

A0 B0 PC0x000 SP0x1FFF
ZéroZ = 0 RetenueC = 0 NégatifN = 0

Console

Mémoire — zone des variables (0x1000)

Assembleur deux passes et processeur écrits pour cette page, mais conformes ligne à ligne au jeu d'instructions du simulateur. Les périphériques graphiques, clavier, disque et réseau ne sont pas implémentés ici — pour ceux-là, ouvrez le simulateur complet ↗.

Chapitre treizième

Écrire en C sur une machine de 1983

L'assembleur marche. Il est aussi épuisant : trois lignes pour incrémenter une variable, une étiquette pour chaque if, et pas de fonction sans gérer soi-même la pile. Au-dessus, il faut un langage.

Le simulateur embarque un vrai compilateur C — pas un interpréteur, pas un simulateur de C : un compilateur qui produit du texte assembleur, lequel passe ensuite par l'assembleur du chapitre précédent pour devenir des octets. Le dialecte est réduit, mais la chaîne est complète, et elle a quatre étages.

La chaîne de compilation 4 étages
  code C
     │
     ▼
1 · PRÉPROCESSEUR   remplace les #define — substitution de texte, rien de plus
     │
     ▼
2 · ANALYSEUR LEXICAL   découpe le texte en lexèmes :
     │                      « int x = 42; »  →  [int] [x] [=] [42] [;]
     ▼
3 · ANALYSEUR SYNTAXIQUE   construit un arbre qui dit la structure
     │                          (descente récursive, avec priorités d'opérateurs)
     ▼
4 · GÉNÉRATEUR DE CODE   parcourt l'arbre et émet de l'assembleur
     │
     ▼
  texte ASM  →  [assembleur deux passes]  →  octets  →  [processeur]

Voir la traduction

Rien ne vaut un exemple. Voici trois programmes C, avec l'assembleur réellement produit par le compilateur du simulateur — sorties obtenues en exécutant sa chaîne de compilation, pas reconstituées à la main.

Source C

              
Assembleur produit compilateur du simulateur

              

Trois observations sur ces traductions.

Le compilateur n'est pas naïf. Pour x + y, il ne passe pas par la pile : il charge x dans A, y dans B, et fait un ADDB. Il replie les constantes (2 + 3 devient LDA 5), supprime les branches mortes, élimine les fonctions jamais appelées, et remplace les multiplications par des puissances de deux par des décalages. Ces optimisations lui ont fait gagner environ 31 % de taille de code sur ses trente-six exemples embarqués.

Les variables ont des adresses fixes. 0x1020, 0x1021 : ce sont les cadres de fonctions du chapitre X. Le compilateur y range les locales et les paramètres, et réutilise les mêmes octets pour des fonctions qui ne coexistent jamais.

La récursion coûte cher, et seulement là où il faut. Regardez l'exemple récursif : avant l'appel, un PUSH sauvegarde le cadre courant ; après le RET, un POP le restaure. Pour une fonction ordinaire, ce va-et-vient disparaît complètement.

Un C qui mord

Ce dialecte est petit, et ses limites sont pédagogiquement précieuses parce qu'elles rendent visibles des choses que le C moderne cache :

  • tous les int sont des entiers non signés sur 8 bits — 0 à 255, avec repliement silencieux au-delà ;
  • les chaînes sont des tableaux terminés par un zéro, et rien d'autre ;
  • les tableaux ne sont pas vérifiés : écrire dans t[12] d'un tableau de 8 cases écrase la variable d'à côté, sans avertissement ;
  • les tableaux passés en paramètre sont copiés à l'aller et au retour — ce n'est pas la sémantique de pointeur du vrai C, et deux paramètres tableau recevant le même tableau ne s'aliasent pas ;
  • pas de pointeurs, pas de structures, pas de switch, pas de flottants, pas d'allocation dynamique ;
  • la division par zéro ne plante pas : elle rend 0, comme le processeur.
Ce qu'il faut retenir

Le débordement de tampon n'est pas un bug du C : c'est ce qui arrive quand personne ne vérifie, et que le code et les données partagent la même mémoire plate. Sur cette machine, on peut le provoquer en trois lignes et regarder la variable voisine se faire écraser. C'est probablement la meilleure leçon de sécurité informatique qu'on puisse donner en cinq minutes.

Chapitre quatorzième

Le système, le disque et l'invite qui clignote

Terminal à phosphore vert dans une pièce sombre, écran bombé, scanlines, plateaux de stockage à côté, arborescence dorée en arrière-plan.
Le dernier étage : un système, un disque, une invite qui attend

Il reste une marche, et c'est la plus étrange : faire en sorte que la machine puisse charger et lancer d'autres programmes que celui qu'on lui a donné. C'est-à-dire, au fond, qu'elle cesse d'être une machine à un seul usage.

Cela demande trois choses : un amorceur qui démarre, un stockage qui survit à l'extinction, et un format pour s'y retrouver.

Le disque : ce qui reste

La mémoire vive s'efface à chaque réinitialisation. Le disque externe de cette machine, non — c'est sa propriété définissante. Il fait 65 536 octets découpés en 256 pages de 256 octets, et le processeur y accède par quatre instructions seulement : choisir une page, lire un octet, écrire un octet, tout effacer.

Quatre instructions. À partir de là, tout le reste — les fichiers, les répertoires, les programmes installés — est une convention que le logiciel s'impose à lui-même.

Le format de disque partagé convention logicielle
octet 0       marqueur magique          ← « ce disque est formaté »
octet 1       version du système de fichiers
octet 0x10    début du répertoire       64 entrées
page  4       début des données des fichiers

Chaque entrée de répertoire contient :
   nom · type · page de début · nombre de pages · taille

   type 1 = fichier texte      type 2 = programme exécutable

Un système de fichiers, c'est cela : une zone qui dit où sont les choses, et une zone où sont les choses. Il n'y a pas de magie supplémentaire dans ext4 ou NTFS — seulement des dizaines d'années d'optimisations, de journalisation et de tolérance aux pannes empilées sur cette même idée.

L'amorçage : le programme qui charge les programmes

Au démarrage, le processeur commence à l'adresse 0x0000, quoi qu'il arrive. Pour qu'il puisse lancer un programme choisi, il faut d'abord y placer un programme qui sache choisir : l'amorceur (bootloader).

Celui-ci s'installe en haut de la mémoire, affiche une invite, lit une commande, cherche le fichier correspondant dans le répertoire du disque, recopie le programme vers l'adresse 0x0000, puis y saute. Le programme chargé dispose alors de tout l'espace de code normal, comme s'il avait été le seul depuis le début.

Et quand il exécute HLT, la machine ne meurt pas : l'amorceur reprend la main et réaffiche son invite. C'est, en trois phrases, la définition fonctionnelle d'un système d'exploitation — un programme qui survit aux programmes.

Une session sur le disque livré commandes réelles du shell
unix$ ls
fichiers   motd  readme  story  url  result  DIGITS  LETTERS
programmes hello  sysinfo  uname  pwd  bootcat  argdump  wc  head
            wget  ascii  upper  echoio  plot  nano  glxnano  glxsh
            cp  mv  grep  jsonp

unix$ run bootcat readme      ← lance un programme avec un fichier en argument
unix$ run wget url            ← récupère une URL et écrit la réponse dans « result »
unix$ run glxnano readme      ← éditeur plein écran sur l'écran graphique
unix$ free                    ← état de la mémoire
unix$ help

Le disque livré avec la machine contient une petite distribution : cinq fichiers texte, deux fichiers de polices binaires (DIGITS et LETTERS, indispensables à l'affichage graphique) et vingt programmes — utilitaires en ligne de commande, éditeurs de texte, client HTTP. Les noms sont familiers — ls, cat, grep, head, wc, cp, mv, wget, nano — et ce n'est pas une coquetterie : ce sont les mêmes rôles, réduits à leur plus simple expression.

Le passage d'arguments, en huit octets

Comment un programme lancé par run bootcat readme sait-il quel fichier ouvrir ? Par la méthode la plus directe qui soit : l'amorceur résout le nom avant de sauter, et dépose le résultat à une adresse convenue.

Le bloc d'arguments, en 0x1018 8 octets
0x1018   nombre d'arguments (0 ou 1)
0x1019   page du répertoire où se trouve l'entrée
0x101A   position dans cette page
0x101B   type de l'entrée (texte ou programme)
0x101C   première page de données
0x101D   nombre de pages
0x101E   taille en octets
0x101F   indice de l'entrée dans le répertoire

Huit octets à une adresse fixe : voilà l'ancêtre direct du argv du C, des variables d'environnement, et de tout ce qui permet à un programme de savoir sur quoi il travaille. La chaîne est complète, du transistor à l'argument de ligne de commande.

Et Internet, dans tout ça ?

Trois instructions du processeur — HTTPGET, HTTPPOST, HTTPIN — lancent des requêtes et lisent la réponse octet par octet. Mais il faut être exact sur ce point : ce n'est pas une pile TCP/IP construite en portes logiques. C'est un pont vers le fetch() du navigateur hôte, exposé comme un périphérique. Le simulateur le dit lui-même dans sa documentation, et c'est honnête : implémenter Ethernet, IP, TCP et TLS à ce niveau représenterait un projet d'une tout autre ampleur.

La leçon reste valable : du point de vue du programme, le réseau est un périphérique — on lui écrit une adresse, on lit des octets, et un drapeau dit « ce n'est pas encore arrivé ».

Chapitre quinzième

Ce que 255 nous a appris

On pourrait croire que le petit format de cette machine est un défaut à excuser. C'est l'inverse : ses limites sont exactement ce qui la rend instructive, parce qu'elles rendent visibles des phénomènes que les machines modernes se contentent de masquer.

Le débordement d'entier

Ici, 255 + 1 = 0. Le neuvième bit n'existe pas ; il part dans le drapeau de retenue, et le compteur repart de zéro. Rien ne plante, rien n'avertit — le programme continue avec un nombre faux.

Cette arithmétique modulaire n'est pas une bizarrerie du jouet. C'est celle de tous les processeurs. Le 4 juin 1996, le vol inaugural d'Ariane 5 s'est achevé par la destruction du lanceur une quarantaine de secondes après le début de la séquence de vol. La cause, établie par la commission d'enquête : trente-sept secondes après le début de la séquence d'allumage du moteur principal, la conversion d'une valeur de vitesse horizontale d'un flottant 64 bits vers un entier signé 16 bits a débordé — la valeur ne tenait pas dedans, et cette conversion-là, contrairement à d'autres, n'était pas protégée. Le même mécanisme qu'ici, avec quelques chiffres de plus, et un lanceur emportant les quatre satellites scientifiques Cluster.

L'arithmétique du repliement Modulo 256

Le résultat rangé est 44, et la seule trace de l'erreur est le drapeau de retenue. Si le programme ne le teste pas, l'information est définitivement perdue. C'est pourquoi JC et JNC existent — et pourquoi les langages modernes proposent des additions « vérifiées » qui coûtent un test de plus.

Le débordement de tampon

Un tableau de huit cases occupe huit octets consécutifs. Écrire dans la case 12 écrit quatre octets plus loin — c'est-à-dire dans la variable suivante, ou dans un cadre de fonction, ou dans la pile. Il n'y a aucune vérification, ni à la compilation, ni à l'exécution.

Sur cette machine, on peut faire l'expérience et voir, dans le visualiseur de mémoire, la variable d'à côté changer de valeur. Sur une machine moderne, la même erreur produit soit un plantage incompréhensible, soit — bien pire — un programme qui continue à tourner en obéissant à des données qu'un attaquant a écrites. Toute une famille de failles de sécurité tient dans cette absence de vérification.

Le plafond de code

4 096 octets. Un programme trop gros est refusé, point final. Cette contrainte oblige à un exercice devenu rare : choisir ce qu'on n'écrit pas. Elle explique aussi pourquoi les logiciels de cette époque tenaient sur une disquette — non pas par vertu, mais parce qu'il n'y avait pas d'autre option.

« Vous vous souvenez de l'époque où afficher une seule lettre à l'écran relevait déjà du combat de boss ? Vous vous souvenez quand votre plus grand nombre sérieux, adulte, responsable, c'était 255 ? »

Ce qui n'a pas changé depuis 1983

Un processeur moderne est infiniment plus rapide, plus large, plus parallèle. Il exécute plusieurs instructions par cycle, dans le désordre, en devinant à l'avance quelles branches seront prises. Il a des caches à trois niveaux, une unité de calcul flottant, des instructions vectorielles, de la virtualisation matérielle, du chiffrement câblé.

Et pourtant. Il cherche toujours une instruction à l'adresse contenue dans un compteur ordinal. Il additionne toujours avec des retenues qui se propagent. Il compare toujours en soustrayant. Il se souvient toujours grâce à des boucles de rétroaction. Il décide toujours en regardant trois ou quatre bits de drapeaux. Il déborde toujours en silence.

La complexité s'est empilée ; les fondations n'ont pas bougé. C'est pour cela qu'une machine de 8 192 octets peut encore expliquer celle sur laquelle vous lisez ces lignes.

EXP 15 Le compteur qui repasse par zéro Interactif · animé

Un registre de huit bits, un afficheur, et rien d'autre. Ajoutez, doublez, laissez tourner : à 255, le neuvième bit n'a nulle part où aller. La lampe retenue s'allume le temps d'une opération — c'est la seule trace, et si le programme ne la lit pas, l'information est perdue.

Lecture non signée — 0 à 255

Opérations Retenue

Les huit bits, inchangés

registre A

Deux lectures, un seul octet

La bascule « lecture signée » ne touche à aucun bit. Elle change seulement la convention : en lecture signée, le bit 7 cesse de valoir +128 pour valoir −128, et l'intervalle devient −128 … +127. Le processeur, lui, ne sait pas laquelle des deux vous avez choisie — c'est le programme qui décide, et c'est le programme qui se trompe.

Le calcul est celui du processeur du dossier : troncature à huit bits, drapeau de retenue levé quand le résultat complet sort de l'intervalle 0–255. Exactement ce qui a détruit Ariane 501 le 4 juin 1996, à une largeur de registre près.

« Aucune étape n'est magique. C'est l'empilement qui devient intelligent. »

Quinze chapitres plus tôt, on n'avait qu'un fil sur lequel il y a du courant, ou pas. On a maintenant une machine qui compile, qui démarre, qui édite des fichiers et qui va chercher une page sur Internet. Entre les deux, il n'y a eu aucune rupture — seulement une douzaine d'idées, chacune assez simple pour tenir en un paragraphe, posées les unes sur les autres. Le OU exclusif qui devient une addition. La rétroaction qui devient une mémoire. La soustraction qu'on jette qui devient une décision. Le nombre qu'on incrémente qui devient un programme. Et l'octet rangé en mémoire qui, selon l'endroit où l'on braque le compteur ordinal, est tantôt une donnée, tantôt un ordre. Comprendre un ordinateur, ce n'est pas apprendre une technologie : c'est vérifier soi-même, marche après marche, qu'il n'y avait rien à croire sur parole.