Aller au contenu

M99 est un ordinateur inventé par Martin Quinson et Philippe Marquet.

Cliquer ici pour les sources

Présentation du M99

Le M99 est une machine dotée de 100 cases mémoire (la grille en haut de la feuille), et d'un processeur (en bas de la feuille).

La mémoire est composée de 100 mots mémoire de 3 chiffres (valeur de 000 à 999). Ces 100 mots mémoire sont adressables par des adresses codées sur 2 chiffres. Cette mémoire va contenir données et instructions.

Le processeur dispose de deux registres généraux nommés A et B, et d'un registre accumulateur/résultat nommé R. Ces registres sont de 3 chiffres, mais au contraire de la mémoire, ils ont un signe. Ils peuvent donc contenir des valeurs comprises entre -999 et 999.

Le processeur dispose aussi d'un quatrième registre nommé PC (Program Counter). C'est le pointeur d'instruction, contenant l'adresse mémoire de la prochaine instruction à exécuter. Lorsqu'on utilise le M99, on peut noter le numéro de l'instruction à exécuter dans la case prévue à cette effet, mais en pratique, il est plus simple de le matérialiser avec un "pion" situé sur une des cases de la grille mémoire, ou même de suivre avec son doigt.

Unité arithmétique et logique

L'unité arithmétique et logique – UAL – est en charge d'effectuer les calculs. Les opérandes et résultats sont dans les registres, A et B pour les opérandes, R pour le résultat.

Unité de commande

L'unité de commande pilote l'ordinateur. Son cycle de fonctionnement comporte 3 étapes :

  1. charger l'instruction depuis la case mémoire pointée par PC vers la zone dédiée dans l'UAL (la case sous le registre PC). Incrémenter ensuite le PC.
  2. décoder l'instruction : à partir des 3 chiffres codant l'instruction, identifier quelle est l’opération à réaliser en utilisant le pense-bête à droite de l'UAL.
  3. exécuter l'instruction.

Jeu d'instruction

code mnémonique instruction à réaliser
0 x y STR xy copie le contenu du registre R dans le mot mémoire d'adresse xy
1 x y LDA xy copie le mot mémoire d’adresse xy dans le registre A
2 x y LDB xy copie le mot mémoire d’adresse xy dans le registre B
3 x y MOV x y copie registre Rx dans Ry (R0: R; R1: A; R2: B)
4 - - opérations arithmétique et logiques
4 0 0 ADD ajoute les valeurs des registres A et B, produit le résultat dans R
4 0 1 SUB soustrait la valeur du registre B à celle du registre A, produit le résultat dans R
. . . etc
5 x y JMP x y branche en xy (PC reçoit la valeur xy)
6 x y JPP x y branche en xy si la valeur du registre R est positive
7 x y JEQ x y saute une case (PC += 2) si la valeur du registre R est égale à xy
8 x y JNE x y saute une case (PC += 2) si la valeur du registre R est différent de xy

Notez que pour JEQ et JNE, on a PC += 2 si la condition est respectée car l'opération saute une case (PC++) après l'étape de chargement par l'unité de commande (PC++).

Boot et arrêt

La machine démarre avec la valeur nulle comme pointeur d'instruction (PC=0) et elle s'arrête si le pointeur d'instruction vaut 99.

On peut donc utiliser le mnémonique HLT comme synonyme de JMP 99.

Entrées/sorties

Les entrées/sortries sont "mappées" en mémoire: Écrire le mot mémoire 99 écrit sur le terminal, tandis que les valeurs saisies sur le terminal seront lues dans le mot mémoire 99.

Exercice 1

Objectifs:

  • prise en main du M99

  • comprendre l'encodage des opcode dans la mémoire

  • comprendre le cycle fetch/decode/execute

Q1: Que fait le programme chargé à l'adresse 0 ?

Pour répondre, il faut appliquer le cycle fetch/decode/exec aux données qui sont dans les premières adresses de la mémoire. Traduire les valeurs numériques en mémoire est nécessaire.

00: LDA 10  // Charge le contenu de la case 10 dans le registre A
01: LDB 11  // Charge le contenu de la case 11 dans le registre B
02: SUB     // R := A - B
03: JPP 7   // Si R > 0 alors PC := 7
04: MOV B R // B := A
05: STR 99  // Copie R en 99, c'est-à-dire, affiche R à l'écran
06: JMP 99  // Arrête le programme
07: MOV A R // A := B
08: STR 99  // Copie R en 99, donc affiche R à l'écran
09: JMP 99  // Arrête le programme
10: 123     // Utilisé seulement comme une donnée, sans signification 
11: 42      // Utilisé seulement comme une donnée, sans signification 

Donc au final, ce programme affiche 123, car 123 > 42.

Q2: Que fait le programme débutant à l'adresse 13?

13: LDA 99  // Charge une entrée utilisateur dans A
14: MOV A R // R := A
15: STR 10  // Copie l'entrée utilisateur en 10
16: LDA 99  // Charge une entrée utilisateur dans A
17: MOV A R // R := A
18: STR 11  // Copie l'entrée utilisateur en 11
19: JMP 0   // Branche l'exécution sur 0

Donc au final, ce programme demande deux entrées à l'utilisateur avant d'exécuter le programme précédent (qui affichera le plus grand d'entre eux).

Q3: Écrire un programme affichant le minimum de deux entrées clavier

On peut l'écrire à partir de l'adresse 20 en mémoire, et on n'a pas besoin d'écrire ce qu'on lit en mémoire puisqu'on l'utilise immédiatement.

20: 199; LDA 99  // input A
21: 299; LDB 99  // input B
22: 401; SUB
23: 610; JPP 27  // JMP 27 si R>0, ie si A>B
24: 320; MOV A R // Copie A dans R
25: 099; STR 99  // Affiche A
26: 599; JMP 99  // Halt
27: 320; MOV B R // Copie B dans R
28: 099: STR 99  // Affichage B
29: 599: JMP 99  // Halt

Exercice 2

Objectifs: - Modifier un programme en assembleur - Voir l'intérêt d'un compilateur, et d'un langage de haut niveau

Q1: Que fait le programme débutant à l'adresse 40 (pour les entrées 5 et 2)?

Il calcule le produit des deux entrées et affiche le résultat

Q2: Peut-on raccourcir ce programme ?

On peut passer à 21 cases en stockant x et y dans les cases 40 et 41 car on n'a plus besoin de ce code une fois qu'on l'a exécuté.

Il faut ensuite déplacer les cases 61 et 62 (qui sont respectivement la valeur 1 et le résultat) dans les cases 59 et 60. Cette opération est plus simple en nommant les variables, c'est à dire en écrivant par exemple "LDB un" à la place de "LDB 61" dans la case 51.

On peut même tomber à 19 cases en utilisant une ruse: au lieu d'énumérer les trois opérations nécessaires pour afficher le contenu de A avant de s'arrêter, on branche vers l'endroit du programme 1 qui fait cela (JMP 04).

Notez que réutiliser les bouts d'un autre programme en sautant au milieu de son code est considéré comme "très sale" par la plupart des programmeurs. En pratique on ne veut absolument jamais faire quelque chose d'aussi dangereux avec de vrais programmes car cela les rend difficile à lire et à comprendre. On ne fait pas des programmes que pour la machine, mais aussi (surtout) pour que d'autres humains les comprennent.

  56: 310: MOV A R     ->   56: 504: JMP 04
  57: 099: STR 99
  58: 599: HLT

Q3: Corrigez ce programme quand la seconde entrée vaut 0

En effet, notre programme calcule par exemple 5*0 = 5 car il ajoute x au résultat dans tous les cas. Pour corriger, il faut d'abord vérifier s'il y a besoin d'ajouter x et ensuite seulement le faire.

La première solution est d'ajouter un JMP juste avant la boucle pour entrer au bon endroit de la boucle (sur le décrément de y). Mais cela fait un code spagetti assez désagréable.

La seconde solution est de réécrire le corps de boucle pour faire le décrément du compteur avant l'addition au résultat, au prix de légères contortions pour sortir de la boucle au bon moment

  46: 162: LDA res    ->  46: 162: LDA y
  47: 259: LDB x          47: 261: LDB un
  48: 400: ADD            48: 401: SUB
  49: 062: STR res        49: 899: JNE 99 // on suppose que -1 overflow en 99
  50: 160: LDA y          50: 556: JMP fin // Sort de la boucle
  51: 261: LDB un         51: 162: LDA res
  52: 401: SUB            52: 259: LDB x
  53: 060: STR y          53: 400: ADD
  54: 646: JPP 46         54: 062: STR res
                          55: 546: JMP 46 // Retour début boucle
                          56: 162: LDA res
                          57: 504: JMP 04 // Utilise la fin du prog 1

L'instruction de la ligne 49 est assez discutable. Son objectif est de tester si R==-1, mais on n'a pas de nombres négatifs dans la mémoire. On suppose donc ici qu'un nombre négatif en registre sera traduit en son complément à 100 à l'usage. C'est assez réaliste de ce que font les vrais ordinateurs.

Au final, les deux solutions sont assez diffiles à relire: soit on commence la première boucle en sautant au milieu, soit on sort de la dernière boucle en sautant depuis le milieu. C'est quand même plus simple d'utiliser un langage de haut niveau et un compilateur :)

Lien à l'informatique

Le M99 est un ordinateur en papier, assez simple à utiliser avec seulement un crayon, mais il a été pensé pour être relativement réaliste des vrais ordinateurs.

  • La mémoire d'un vrai ordinateur est également découpée en mots mémoires, chacun étant doté d'une adresse unique. En général, les vrais ordinateurs utilisent des mots de 1 octet (8 bits). Les ordinateurs 32bits peuvent avoir jusqu'à 2³² mots (soit un peu plus de 4Go de mémoire) tandis que les ordinateurs 64bits peuvent en avoir jusqu'à 2⁶⁴ en théorie (18 Exaoctets, 18.10¹⁸ octets).

  • Les vrais processeurs ont également des registres afin de gérer au mieux le problème de la barrière mémoire. Ils ont également des caches pour optimiser les échanges entre la mémoire et le CPU. Là où lire en mémoire peut demander une centaine de cycles CPU, lire en cache prend entre 10 et 30 cycles. Le M99 n'a pas de caches pour simplifier.

  • Les vrais programmes sont également écrits sous forme d'opcodes en mémoire des vrais ordinateurs, avec le préfixe indiquant l'opération tandis que le sufixe indique les opérandes. Le jeu d'opérations élémentaires disponibles varie beaucoup d'un processeur à l'autre.

Pour le M99, nous avons choisi d'utiliser des mots mémoires de trois positions décimales, ce qui contraint fortement le nombre d'instructions disponibles. Ces contraintes sont parfaitement réalistes de celles que doivent résoudre les fabriquants de CPU. Ajouter des instructions simplifie l'écriture de programmes efficaces, mais complique grandement le processeur, qui devient plus cher et plus énergivore.

Les processeurs de la famille RISC (reduced instruction set CPU) visent la simplicité et n'offrent que peu d'instructions tandis que ceux de la famille CISC (complex instruction set CPU) offrent des opérations optimisées plus rares, comme des opérations vectorielles.

Il serait faux de dire que l'une des familles est vraiment préférable à l'autre. Il s'agit plutôt de deux compromis différents entre complexité du processeur et complexité des programmes. Les processeurs des téléphones portables sont souvent des RISC (par exemple du constructeur ARM) tandis que ceux des ordinateurs sont souvent des CISC (par exemple des constructeurs Intel ou AMD).

  • Gérer les entrées/sorties au travers d'adresses particulières de l'espace d'adressage du bus mémoire est parfaitement réaliste. En revanche, il est rare d'avoir plusieurs périphériques à la même adresse et on aurait pu séparer les lectures du clavier et les écritures à l'écran dans des zones mémoire différentes. De plus, nous avons ignoré toute la synchronisation qu'un vrai processeur doit faire pour échanger avec les périphériques, souvent bien plus lents que lui.

Extensions M999

Deux extensions possibles pour la M99 afin d'en faire un M999 (tout en restant compatible avec le M99). Le M99 est un ordinateur en papier, et le M999 un ordinateur en carton. Le M9 serait-il l'ordinateur-plume?

Registres génériques

On ajoute trois registres génériques (R3, R4 et R5) et on augmente la sémantique du MOV comme suit:

MOV x y - x=0: la valeur à écrire vient de R (comme avant) - x=1: la valeur à écrire vient de A (comme avant) - x=2: la valeur à écrire vient de B (comme avant) - x=3: la valeur à écrire vient de R3 - x=4: la valeur à écrire vient de R4 - x=5: la valeur à écrire vient de R5 - x=6: la valeur à écrire est 0 (le nombre zéro) - x=7: la valeur à écrire est 1 (le nombre un)

  • y=0: SET R: la valeur est écrite dans R (comme avant)
  • y=1: SET A: la valeur est écrite dans A (comme avant)
  • y=2: SET B: la valeur est écrite dans B (comme avant)
  • y=3: SET R3: la valeur est écrite dans R3
  • y=4: SET R4: la valeur est écrite dans R4
  • y=5: SET R5: la valeur est écrite dans R5
  • y=6: SWAP R3: le registre source x est échangé avec R3
  • y=7: SWAP R4: le registre source x est échangé avec R4
  • y=8: SWAP R5: le registre source x est échangé avec R5

Cette extension permet de parler de la barrière mémoire. Par exemple, on peut refaire une multiplication qui tienne uniquement en registre, et compter le temps que ça prend en considérant que les accès mémoire (instructions LDA, LDB et STR) sont 100 fois plus longues que les instructions n'utilisant que les registres (toutes les autres).

On peut introduire cette extension en parlant du problème du register spilling (où on n'a pas assez de registre pour couvrir les besoin).

Pile et fonctions

Pour pouvoir faire des appels de fonction propre, on ajoute un registre SB (stack base -- initialisé à 98) et les opcodes suivants:

  • 48x: PSH x : Rx est écrit dans la case pointée par SB, puis SB--
  • 49x: POP x : SB++ puis le contenu de @SB est copié dans Rx
  • 9xy: CAL xy: PSH PC (@SB:=PC; SB++) puis PC := xy
  • 409: RET : POP PC (PC:=@SB; SB--)

La factorielle pourrait être un exemple pas trop fastidieux à mettre en oeuvre pour réutiliser la multiplication vue plus haut.

FUN: // Début de la fonction POP A // Le paramètre était sur la pile LDB 61 // B := mem[61] = 1 SUB JPP #REC // On saute si A > 1 MOV B -> R // R := 1 RET // postcondition: R=A! car A==1 et R==1 REC: // Cas récursif de la fonction PSH R // On avait R = A-1, donc on pousse le paramètre du cas récursif CALL #FUN // Appel récursif MOV R -> B // R=(A-1)!, que l'on place dans B MULT // R := A * (A-1)!, donc R := A! RET // postcondition: R=A!

Appel de fonction: LDA 99 // A := une valeur lue depuis l'entrée PSH A CAL #FUN STR 99 // écrit R (ie, A!) sur la sortie

On peut même faire un saut calculé en détournant ces instructions: pour sauter à l'adresse stockée dans R2, je fais "PSH R2; RET"

Pour aller plus loin

Ceux qui aiment bien ces petits jeux autour de l'assembleur devraient regarder du coté des jeux suivants:

  • Core War où des programmes s'exécutent tous ensemble dans la mémoire d'un ordinateur. Chacun tente de stoper les autres en écrivant dans leur mémoire.

  • Human Resource Machine. Un ensemble de défis à résoudre à l'aide de programmes en pseudo-assembleur le plus court possible (payant).

  • Little Man Computer Battle Tank Game Simulateur de petits tanks à programmer dans un petit assembleur pédagogique. C'est un bel outil, dommage que l'assembleur choisi soit si peu réaliste et qu'il n'y ait pas plus d'exercices à résoudre.