Registres et processeur
GameBoj – Étape 3

1 Introduction

Le but de cette troisième étape est :

  1. d'écrire des classes permettant de représenter un groupe de registres, dans lesquels des valeurs peuvent être stockées,
  2. de commencer l'écriture de la classe représentant le processeur du Game Boy, l'une des plus grosses et des plus complexes du projet, qui vous occupera durant plusieurs étapes.

Comme d'habitude, quelques explications sur les concepts à modéliser, ici les registres et le processeur, sont données en introduction. Avant cela, il convient toutefois d'examiner la manière dont les composants d'un système électronique sont synchronisés.

1.1 Synchronisation des composants

Les différents composants qui constituent le Game Boy — mémoires, processeurs, écran, etc. — fonctionnent indépendamment mais doivent néanmoins souvent collaborer, par exemple pour s'échanger de l'information via le bus.

Une manière de faire collaborer ces composants serait de les laisser travailler chacun à leur rythme, et de leur faire utiliser différents protocoles pour communiquer entre eux via le bus. Par exemple, si le processeur désirait lire le contenu de la mémoire à une adresse donnée, il pourrait demander à l'obternir via le bus, puis se mettre en attente jusqu'à l'arrivée de cette donnée, sans savoir combien de temps cela prendrait. Un tel système, dans lequel chaque composant travaille à un rythme qui lui est propre, est dit asynchrone.

En pratique, et pour des raisons qui ne seront pas décrites ici, il est très difficile de réaliser des systèmes asynchrones. La quasi-totalité des systèmes électroniques actuels, et en particulier des ordinateurs, sont donc des systèmes dit sychrones, dont les composants avancent à un rythme qui est dicté par une horloge (clock) commune.

Pour comprendre la différence entre ces deux types de systèmes, on peut utiliser l'analogie d'une galère — un bateau à rames sur lequel chaque rame est actionnée par un rameur différent. Pour faire avancer une telle galère, il faut décider comment organiser le travail des différents rameurs.

Une première solution est de les faire travailler de manière asynchrone, chacun ramant à son rythme : lent pour certains, rapide pour d'autres. Une galère organisée de la sorte n'avancerait probablement pas très efficacement, en raison des problèmes posés par les différences de rythme.

Une manière plus efficace d'organiser le travail des rameurs est donc de les synchroniser. Cela peut se faire par exemple en chargeant un membre de l'équipage de frapper à un rythme régulier sur un tambour, et en demandant aux rameurs de plonger leur rame dans l'eau à chaque coup. Leur travail devient alors synchronisé, et la galère avance rapidement — et en ligne droite.

Un système électronique synchrone fonctionne de la même manière, l'horloge jouant le rôle du tambour. L'horloge d'un système électronique est toutefois beaucoup plus rapide, puisqu'elle bat plusieurs millions, voire plusieurs milliards de fois par seconde. Celle du Game Boy, par exemple, bat à une fréquence de 222 Hz, soit un peu plus de 4 MHz — un battement toutes les 240 nano-secondes environ.

Il faut noter que dans un système réel, tous les composants, mémoires comprises, sont synchronisés par l'horloge. Dans notre simulation des mémoires, nous l'avons ignoré, car il s'agit dans ce cas d'un détail insignifiant. Dans le cas d'autres composants, par exemple du processeur décrit plus bas, il est toutefois capital de correctement simuler cet aspect afin que le système simulé se comporte comme le système réel.

1.2 Registres

De manière générale, un registre (register) est une mémoire capable de stocker un et un seul « entier » de taille fixe. Généralement, cette taille est celle du bus de données du système auquel appartient le registre. Ainsi, sur le Game Boy, la plupart des registres ont une taille de 8 bits.

Un registre joue un peu le même rôle qu'une variable d'un type entier en Java. Ainsi, un registre (8 bits) du Game Boy est assez similaire à une variable de type byte d'un programme Java, puisque l'un comme l'autre peuvent contenir une, et une seule, valeur de 8 bits. (Selon la même analogie, une mémoire ressemble à un tableau dans un programme Java — comme nous l'avons vu, les mémoires du Game Boy sont similaires à des tableaux Java de type byte[], puisque les unes comme les autres permettent de stocker un nombre fixe de valeurs de 8 bits.)

La plupart des composants non triviaux connectés au bus d'un système comme le Game Boy possèdent un ou plusieurs registres qui sont utilisés, en gros, dans trois buts différents — et pas forcément exclusifs :

  1. pour stocker des valeurs manipulées par le composant,
  2. pour stocker des informations relatives à la configuration du composant,
  3. pour stocker des informations relatives à l'état du composant.

Un exemple du premier cas d'utilisation est celui des registres du processeur, dans lesquels sont stockées les valeurs manipulées par le programme exécuté par ce dernier. Ainsi, comme nous le verrons ci-dessous, l'instruction de soustraction permet de soustraire une valeur de la valeur actuellement stockée dans l'un des registres du processeur, nommé A.

Un exemple du second cas d'utilisation est fourni par un registre du contrôleur LCD1 nommé LCDC, et qui permet la configuration de l'écran du Game Boy. Comme nous le verrons plus tard, un des bits de ce registre détermine si l'écran est allumé ou éteint. En changeant la valeur de ce bit, il est donc possible d'allumer ou d'éteindre l'écran, en fonction des besoins du programme.

Finalement, un exemple du troisième cas d'utilisation est fourni par un autre registre du contrôleur LCD nommé LY. Ce registre contient le numéro de la ligne de l'écran actuellement en cours de dessin. En lisant la valeur de ce registre, un programme peut déterminer quelle partie de l'image est en cours d'affichage, ce qui peut être utile pour réaliser certains effets spéciaux, comme nous le verrons plus tard.

1.2.1 Visibilité des registres

Les registres des différents composants peuvent être soit invisibles, soit visibles aux autres composants.

Lorsqu'un registre est invisible aux autres composants, seul le composant propriétaire du registre peut y accéder. Cette situation est un peu similaire aux attributs privés d'une classe Java.

Lorsqu'un registre est visible aux autres composants, son contenu est accessible à travers le bus à une addresse qui lui est réservée, soit en lecture seule, soit en lecture et en écriture. Dans ce cas, on parle de registre « mappé en mémoire » (memory-mapped register).

Par exemple, sur le Game Boy, le registre A du processeur, mentionné plus haut, est invisible aux autres composants. Dès lors, seul le processeur peut lire et/ou modifier son contenu.

Par contre, le registre LCDC mentionné plus haut est mappé en mémoire, à l'adresse FF4016. Dès lors, il est possible de connaître la configuration actuelle de l'écran LCD en lisant la valeur se trouvant à cette adresse, et de modifier cette configuration — p.ex. pour allumer ou éteindre l'écran — en écrivant à cette adresse.

1.2.2 Banc de registres

Plusieurs registres peuvent être groupés en un banc de registres (register file). Par exemple, en plus du registre A déjà mentionné, le processeur du Game Boy possède 7 autres registres similaires (nommés F, B, C, D, E, H et L). Ces 8 registres sont regroupés en un banc.

Notez que cette notion de banc est assez vague et peut être utilisée pour désigner tout groupe de registres similaires stockés dans un même composant. Lorsqu'un composant est mis en œuvre physiquement, un banc de registres prend généralement la forme d'une petite mémoire contenant les registres côte à côte. De manière similaire, dans ce projet, nous représenterons un banc comme un tableau d'entiers de type byte contenant chacun la valeur d'un des registres du banc.

1.3 Processeur

Le processeur, ou unité de calcul centrale (central processing unit ou CPU en anglais), est le composant le plus important d'un ordinateur. Son rôle est d'exécuter le programme qui lui est soumis, et qui est composé d'une séquence d'instructions simples. Ces instructions permettent par exemple d'effectuer des calculs arithmétiques, de communiquer avec d'autres composants via le bus, ou alors de changer le cours de l'exécution du programme en fonction de certaines conditions.

Assez naturellement, le programme que doit exécuter le processeur est stocké dans l'une des mémoires connectées au bus du système. Schématiquement, le processeur passe donc son temps à :

  1. obtenir, via le bus, la prochaine instruction à exécuter,
  2. exécuter cette instruction, ce qui peut impliquer d'effectuer des calculs au moyen de l'UAL, de lire ou d'écrire des données via le bus, etc.
  3. recommencer avec l'instruction suivante.

Pour savoir où se trouve, en mémoire, l'instruction à exécuter, le processeur stocke son adresse dans un registre interne qui est souvent nommé compteur de programme (program counter ou PC). Ce registre est initialisé à 0 au démarrage du processeur, et il est modifié après chaque instruction pour désigner la prochaine instruction à exécuter. Généralement, cette instruction est simplement celle qui suit, dans la mémoire, celle qui vient d'être exécutée. Cela dit, il existe aussi des instructions dites de saut (jump) qui permettent de rediriger le compteur de programme vers une autre instruction, par exemple dans le but d'effectuer une boucle.

L'ensemble des instructions offertes par un processeur constituent ce que l'on nomme son jeu d'instructions (instruction set [architecture] ou ISA). Lorsqu'un nouveau processeur est développé, ses concepteurs doivent définir son jeu d'instructions, et choisir l'encodage des instructions (instruction encoding), c-à-d de la manière dont les différentes instructions sont représentées en mémoire, sous forme d'octets. L'encodage des instructions est très important, car il doit être à la fois compact — pour que le programme consomme le moins de mémoire possible — et facile à décoder — pour que le processeur puisse le faire aussi rapidement que possible. La conception d'un jeu d'instruction et de son encodage constituent donc des tâches complexes.

En plus d'un encodage, les différentes instructions reçoivent toutes un nom court évoquant leur rôle, traditionnellement écrit en majuscules. Par exemple, la plupart des processeurs offrent une instruction d'addition, et elle est généralement nommée ADD. Ces noms d'instructions ne sont bien entendu destinés qu'aux humains, le processeur ne manipulant que des instructions encodées sous la forme d'octets.

Les noms d'instructions sont à la base du langage assembleur (assembly language), un langage de programmation très simple permettant de programmer directement au moyen des instructions d'un processeur donné. L'assembleur est un langage textuel, beaucoup plus facile à comprendre pour les humains que la suite d'octets composant un programme destiné à un processeur.

Pour illustrer l'intérêt de l'assembleur, imaginons un processeur équipé d'une instruction d'addition nommée ADD, encodée par un seul octet valant par exemple 7B16. Pour écrire un programme ultra-simple composé de deux instructions d'addition, un programmeur pourrait bien entendu écrire directement ces octets :

7B 7B

mais une solution beaucoup plus agréable serait d'écrire ce programme en utilisant les noms des instructions le composant, ce qui pourrait ressembler à :

ADD
ADD

Cette seconde variante du programme est nettement plus facile à écrire et à comprendre. Dès lors, de nos jours, plus personne (ou presque) n'écrit de programmes sous la forme d'une séquence d'octets représentant les instructions encodées.2

Il faut toutefois noter qu'un programme en langage assembleur n'est pas, tel quel, exécutable par un processeur — celui-ci ne comprend en effet que les instructions encodées sous forme d'octets. Afin d'être exécuté, un programme en langage assembleur doit donc être transformé en une suite d'octets, tâche réalisée par un programme que l'on nomme un assembleur (assembler). Pour l'hypothétique processeur ci-dessus, l'assembleur serait donc capable de transformer la seconde version du programme (ADD ADD) en la première (7B 7B).

Un exemple de programme assembleur réel, pour le processeur du Game Boy, est donné plus bas.

1.4 Processeur du Game Boy

Le Game Boy est équipé d'un processeur poétiquement nommé le Sharp LR35902, et qui est une variante du très célèbre Zilog Z80. Les similarités entre les deux processeurs sont grandes, mais ils n'en sont pas moins différents en de nombreux points, p.ex. au niveau du jeu d'instruction ou même des registres à disposition du programmeur.

Comme tous les autres composants du Game Boy, le processeur est piloté par l'horloge du système dont la fréquence est, nous l'avons vu, d'environ 4 MHz. Toutefois, il se trouve que le processeur nécessite toujours un nombre de battements qui est un multiple de 4 pour exécuter une instruction. Dès lors, nous désignerons par le terme de cycle une durée de 4 battements d'horloge, et toutes les durées manipulées par notre simulateur seront exprimées en cycles. Etant donné qu'il y a 222 battements d'horloge par seconde, il y a 220 cycles par seconde, et un cycle dure donc un tout petit peu moins d'une micro-seconde.

1.4.1 Registres

Le processeur du Game Boy possède les registres suivants, qui ne sont pas visibles aux autres composants du système :

  • huit registres 8 bits nommés A, F, B, C, D, E, H et L, à usage (plus ou moins) général,
  • un registre 16 bits nommé PC, contenant le compteur de programme,
  • un registre 16 bits nommé SP, contenant l'adresse du sommet de la pile (voir plus bas),
  • d'autres registres liés à la gestion de ce que l'on nomme les interruptions, et qui seront décrits dans une prochaine étape.

Tous ces registres sont initialisés à 0 au démarrage du processeur.

Les huit registres 8 bits sont arrangés par paires (AF, BC, DE et HL), et certaines instructions permettent de manipuler une telle paire comme un registre 16 bits. Dans un tel cas, le premier registre contient les 8 bits de poids fort de la valeur 16 bits, le second ses 8 bits de poids faible.

Il faut noter que les registres 8 bits ne sont pas tous équivalents, et deux d'entre eux en particulier ont un rôle bien spécifique :

  • A, qui est parfois nommé l'accumulateur (accumulator), est le registre dans lequel le résultat de la plupart des opérations est stocké ; par exemple, comme nous le verrons à l'étape suivante, les instructions permettant de calculer un et logique bit à bit stockent leur résultat dans le registre A,
  • F est le registre contenant les fanions produits par l'UAL, dans ses 4 bits de poids fort (Z est le bit 7, N le bit 6, H le bit 5 et C le bit 4), ses 4 bits de poids faible valant toujours 0.

De même, la paire HL joue un rôle spécial car plusieurs instructions utilisent son contenu comme une adresse 16 bits dont les 8 bits de poids fort sont dans H, ceux de poids faibles dans L. C'est d'ailleurs de cet usage que provient le nom des registres : H signifie high, L signifie low.

Finalement, le registre SP a pour but de toujours contenir une adresse, raison pour laquelle il fait 16 bits et non 8. Cette adresse est celle de ce que l'on nomme le sommet de la pile (stack), et le nom du registre vient de là, SP signifiant stack pointer (pointeur de pile). Le fonctionnement de la pile sera expliqué dans une étape ultérieure, pour l'instant il n'est pas nécessaire de comprendre son but exact.

1.4.2 Instructions

Le processeur du Game Boy possède un relativement grand nombre d'instructions, qui seront décrites dans cette étape et les deux suivantes. Ces instructions peuvent être groupées en quatre catégories principales :

  1. les instructions de chargement et de stockage, qui permettent de transférer des données entre le bus et les registres du processeur, ou alors entre les registres eux-mêmes,
  2. les instructions arithmétiques et logiques, qui appliquent les opérations de l'UAL à des données de provenances diverses,
  3. les instructions de contrôle, qui permettent de contrôler l'exécution du programme, p.ex. en effectuant un saut ou en appelant une fonction,
  4. les instructions ne faisant partie d'aucune des catégories précédentes.

Dans le cadre de cette étape, seules les instructions de la première catégorie seront examinées en détail — et simulées —, les autres le seront dans des étapes ultérieures. Avant de décrire ces instructions, il convient de dire quelques mots concernant leur encodage.

1.4.3 Encodage des instructions

Chaque instruction du processeur du Game Boy est encodée en 1, 2 ou 3 octets. De manière générale, le premier octet permet, à lui seul, de déterminer à quel genre d'instruction on a affaire — p.ex. une addition, ou alors un chargement. Les 1 ou 2 octets suivants éventuellement n'ont que pour but de contenir une valeur de 8 ou 16 bits qui sert de paramètre à l'instruction3.

Etant donné que le premier octet de l'encodage d'une instruction est celui qui détermine le genre d'opération effectuée par l'instruction, on l'appelle l'opcode (pour operation code).

La table ci-dessous illustre quatre exemples d'encodage d'instructions du processeur du Game Boy. Chaque ligne présente une instruction différentes et, pour chacune d'entre elles, la syntaxe assembleur est donnée, suivie de l'encodage et d'une description de son effet sur le processeur. Dans la colonne de gauche, notez que les constantes hexadécimales sont précédées d'un signe dollar ($), qui est l'équivalent assembleur du préfixe 0x de Java.

Assembleur Encodage Effet
HALT 76 arrête le processeur
DEC B 05 B = B - 1
DEC C 0D C = C - 1
LD A, $56 3E 56 A = 0x56
LD BC, $5678 01 78 56 BC = 0x5678 (B = 0x56, C = 0x78)

Comme expliqué plus haut, le premier octet de l'encodage de chaque instruction est son opcode, tandis que les éventuels autres constituent un de ses paramètres. Par exemple, à l'avant-dernière ligne, le second octet de l'encodage est 56, qui est le paramètre donné à l'instruction LD. La dernière ligne illustre quant à elle une particularité de l'encodage, à savoir que la constante (hexadécimale) 16 bits 5678 est encodée par les octets 78 et 56, dans cet ordre ! En d'autres termes, l'octet de poids faible est placé avant celui de poids fort.

Cette manière d'ordonner les octets en mémoire en allant des poids faibles aux poids forts est nommée petit-boutienne (little endian en anglais). La convention inverse, consistant à placer les octets de poids forts d'abord, est dite grand-boutienne (big endian). Des détails sur l'origine de ces termes étranges sont donnés sur la page Wikipedia qui leur est consacrée.

En comparant les lignes 2 et 3 de la table ci-dessus, on constate qu'elles contiennent deux instructions qui sont très similaires, puisque toutes les deux décrémentent le contenu d'un registre — B dans le premier cas, C dans le second. Ces deux instructions ont un opcode différent (05 et 0D respectivement), mais elles sont clairement liées étant donné que leur effet est semblable. Elles forment ce que nous nommerons une famille d'instructions, c-à-d des instructions d'opcode différent mais de comportement similaire.

Comme on peut s'y attendre, l'encodage des instructions constituant une famille est construit de manière systématique. Dans le cas de la famille d'instructions DEC, par exemple, l'encodage est le suivant (en binaire) :

00rrr101

rrr représente les 3 bits qui déterminent le registre à décrémenter, et dont la signification est donnée par la table ci-dessous :

rrr Registre
000 B
001 C
010 D
011 E
100 H
101 L
110 aucun
111 A

Ainsi, DEC B s'encode en binaire comme 00000101 (0516) et DEC C s'encode en binaire comme 00001101 (0D16). Plusieurs autres familles d'instructions utilisent le même encodage des registres pour spécifier l'un de leurs arguments. Par exemple, la famille permettant d'additionner au contenu du registre A le contenu d'un autre registre est encodée ainsi :

10000rrr

Il s'ensuit que l'instruction ADD A,B s'encode 10000000 en binaire (8016).

Certaines familles d'instructions décrites plus bas travaillent sur les paires de registres, et chaque paire est encodée au moyen de deux bits dans l'opcode de l'instruction. L'encodage utilisé est le suivant :

rr Paire
00 BC
01 DE
10 HL
11 AF (SP dans certains cas)

Comme la dernière ligne le montre, les deux bits 11 sont soit utilisés pour désigner la paire AF, soit pour désigner le registre 16 bits SP, en fonction des instructions.

1.4.4 Exemple de programme

Pour donner une idée du jeu d'instructions du processeur du Game Boy, voyons comment écrire un petit programme permettant de calculer \(\cal{F}_{11}\), le douzième terme de la fameuse suite de Fibonacci. Pour mémoire, cette suite est définie ainsi :

\begin{align*} \cal{F}_0 &= 0\\ \cal{F}_1 &= 1\\ \cal{F_n} &= \cal{F}_{n-2} + \cal{F}_{n-1}\ \ (n \ge 2) \end{align*}

et ses 12 premiers termes sont :

\(n\) 0 1 2 3 4 5 6 7 8 9 10 11
\(\cal{F}_n\) 0 1 1 2 3 5 8 13 21 34 55 89

Un programme Java permettant de calculer et d'afficher le douzième terme est donné ci-dessous. Notez qu'il est écrit dans un style étrange, avec des variables portant des noms peu évocateurs. Ceci est volontaire, et a pour but de faciliter la comparaison avec le programme Game Boy plus bas. Vous ne devriez toutefois pas avoir trop de peine à le comprendre en notant que, au début de la boucle, la variable b contient \(\cal{F}_{n-2}\), tandis que a contient \(\cal{F}_{n-1}\). A la fin de cette même boucle, b contient \(\cal{F}_{n-1}\) tandis que a contient \(\cal{F}_n\).

 1: byte b = 0;             // b = F(0)
 2: byte a = 1;             // a = F(1)
 3: byte c = 10;            // nb d'itérations restantes
 4: do {
 5:   byte d = a;           // d = F(n-1)
 6:   a = (byte)(a + b);    // a = F(n-1) + F(n-2) [= F(n)]
 7:   b = d;                // b = F(n-1)
 8:   c = (byte)(c - 1);    // c = c - 1
 9: } while (c != 0);
10: 
11: System.out.println(a);  // affiche 89

Ce programme Java peut être traduit assez directement en un programme assembleur pour le Game Boy, présenté ci-dessous. Pour le déchiffrer, il faut savoir que chaque ligne (sauf la 4) contient une instruction du processeur. Ces instructions sont identifiées par leur nom (LD, ADD, DEC, JP et HALT ici) et prennent généralement un ou plusieurs paramètres. Dans cet exemple, les paramètres sont soit des registres (A, B, C et D), soit des valeurs entières (0, 1, 10), soit des conditions (NZ à la ligne 9), soit des étiquettes (loop à cette même ligne). L'étiquette loop est définie à la ligne 4, et sa valeur est celle de l'adresse de l'instruction qui suit, qui est la première instruction de la boucle.

A la droite de chacune des instructions (sauf la dernière) se trouve un commentaire décrivant son effet. Ce commentaire — uniquement destiné au lecteur humain — est précédé d'un point-virgule (;) qui est l'équivalent assembleur de la double barre oblique (//) de Java.

 1:         LD B, 0        ; B = 0
 2:         LD A, 1        ; A = 1
 3:         LD C, 10       ; C = 10
 4: loop:                  ; do {
 5:         LD D, A        ;   D = A
 6:         ADD A, B       ;   A = A + B
 7:         LD B, D        ;   B = D
 8:         DEC C          ;   C = C - 1;  Z = (C == 0)
 9:         JP NZ, loop    ; } while (C != 0)
10:         HALT

L'effet de ces instructions devrait être relativement facile à comprendre grâce au commentaire qui leur est attaché, et grâce à la correspondance très directe existant entre les lignes 1 à 9 de ce programme et celles du programme Java donné plus haut.

La manière dont l'équivalent de la boucle do…while est programmé mérite toutefois quelques explications. Le corps de cette boucle est constitué des lignes 5 à 8. La ligne 9, quant à elle, contient ce que l'on nomme un saut conditionnel (conditional jump en anglais, d'où le nom JP). Son but est d'exécuter une fois encore le corps de la boucle, en revenant à la ligne 4, si et seulement si C est différent de 0. Pour ce faire, elle teste si le fanion Z de l'UAL est faux, ce qui est exprimé par la condition NZ (not zero), et n'effectue le saut que dans ce cas. Si la condition est fausse — c-à-d si le fanion Z est vrai — le saut ne se fait pas, et l'exécution se poursuit avec l'instruction suivante, ici HALT. La raison pour laquelle il est possible de savoir si C est nul en testant le fanion Z de l'UAL est que l'instruction DEC de la ligne 8 ne se contente pas de décrémenter C : elle modifie de plus les fanions de l'UAL, dont Z, en fonction du résultat — comme indiqué dans le commentaire qui lui est attaché.

Notez que la description ci-dessus n'est pas tout à fait exacte, car la ligne 4 ne contient aucune instruction, et il n'est donc pas possible d'y sauter. La ligne 4 contient une étiquette (label), nommée ici loop, qui identifie l'adresse de l'instruction qui la suit, ici celle de la ligne 5. Dès lors, la destination du saut conditionnel de la ligne 9 est l'instruction de la ligne 5, qui est bien la première du corps de la boucle.

Comme tout programme assembleur, celui donné ci-dessus n'est pas, dans sa version textuelle, directement exécutable par le processeur du Game Boy. Pour qu'il le soit, il faut l'assembler, c-à-d le transformer en une suite d'octets représentant les différentes instructions. En faisant cela, on obtient la séquence de 14 octets suivante (donnée en hexadécimal)4 :

06 00 3E 01 0E 0A 57 80 42 0D C2 06 00 76

Lorsqu'on donne cette séquence d'octets au processeur d'un Game Boy pour qu'il l'exécute, il effectue le nombre attendu d'itérations de la boucle avant d'arriver à l'instruction HALT de la dernière ligne, qui stoppe son exécution. A ce moment, le registre A contient 89, comme attendu.

La table ci-dessous donne la correspondance entre les différentes instructions du programme et leur encodage sous forme d'octets, en supposant que la première instruction est placée en mémoire à l'adresse 0.

Adresse Octet(s) Instruction
0000 06 00 LD B, 0
0002 3E 01 LD A, 1
0004 0E 0A LD C, 10
0006 57 LD D, A
0007 80 ADD A, B
0008 42 LD B, D
0009 0D DEC C
000A C2 06 00 JP NZ, 0006
000D 76 HALT

Cet exemple de programme ayant donné une idée des instructions offertes par le processeur du Game Boy, il est temps de les décrire de manière plus systématique.

1.4.5 Instructions de chargement et de stockage

Dans cette première étape consacrée au processeur, deux catégories d'instructions seront traitées : les instructions sans effet, et les instructions de chargement et de stockage. Dans les descriptions qui suivent, trois informations sont données pour chaque instruction :

  1. sa syntaxe assembleur,
  2. l'encodage binaire de son opcode,
  3. son effet sur l'état du processeur et/ou des composants connectés au bus.

Dans la description de la syntaxe, les conventions suivantes sont utilisées :

r8
représente un registre 8 bits quelconque (A, B, C, D, E, H ou L, mais pas F),
r16
représente une paire de registres quelconque (AF, BC, DE ou HL) ; comme cela a été dit plus haut, dans certains cas qui seront systématiquement indiqués, AF est remplacé par SP,
n8
représente une valeur de 8 bits,
n16
représente une valeur de 16 bits,
[XXX]
représente la valeur à l'adresse donnée par XXX.

Dans la description de l'effet des instructions, la convention suivante est utilisée :

r ou s
représente un registre dont l'identité est encodée dans l'opcode,
n
représente la valeur 8 bits suivant l'opcode de l'instruction,
nn
représente la valeur 16 bits suivant l'opcode de l'instruction,
BUS[a]
représente une lecture ou écriture sur le bus, à l'adresse a.

Les instructions sans effet sont décrites d'abord, suivies des instructions de chargement, de stockage et de déplacement.

1.4.6 Instructions sans effet

Comme leur nom l'indique, les instructions sans effet n'ont aucun effet sur l'état du processeur, ou presque : leur seul effet est de consommer un certain temps (1 cycle dans le cas du Game Boy) pour être exécutées.

Les instructions sans effet ne sont pas aussi inutiles qu'on peut le penser, car on peut p.ex. les utiliser pour laisser passer du temps sans rien faire. Dès lors, tous les processeurs possèdent une instruction sans effet, généralement nommée NOP ou NOOP (pour no operation).

Le processeur du Game Boy est particulier en ce qu'il ne possède pas une instruction sans effet, mais huit ! La première, nommée NOP, est la « vraie » instruction sans effet. Les autres sont rigoureusement des instructions permettant de copier le contenu d'un registre dans un autre. Toutefois, lorsqu'on les utilise pour copier le contenu d'un registre dans ce même registre, elles n'ont aucun effet.

En résumé, les instructions sans effet ont l'une des deux formes suivantes :

Assembleur Opcode Effet
NOP 00000000 aucun
LD r8, r8 01rrrsss aucun (ssi r = s)

Les deux registres r et s de la seconde famille sont encodés selon la convention donnée plus haut.

1.4.7 Instructions de chargement

Les instructions de chargement (load instructions en anglais) ont pour but de transférer une valeur depuis un composant connecté au bus — souvent la mémoire — vers un registre du processeur.

Toutes ces instructions sont nommées LD, pour load et leur premier argument est le registre dans lequel la donnée doit être écrite, le second est sa provenance.

  Assembleur Opcode Effet
  LD r8, [HL] 01rrr110 r = BUS[HL]
  LD A, [HL+] 00101010 A = BUS[HL]; HL += 1
  LD A, [HL-] 00111010 A = BUS[HL]; HL -= 1
LD A, [$FF00 + n8] 11110000 A = BUS[0xFF00 + n]
LD A, [$FF00 + C] 11110010 A = BUS[0xFF00 + C]
  LD A, [n16] 11111010 A = BUS[nn]
  LD A, [BC] 00001010 A = BUS[BC]
  LD A, [DE] 00011010 A = BUS[DE]
  LD r8, n8 00rrr110 r = n
LD r16, n16 00rr0001 r = nn
  POP r16 11rr0001 r = BUS[SP]; SP += 2

La raison d'être des deux instructions marquées d'un obèle (†), qui permettent de lire des données provenant de la plage d'adresses FF0016 à FFFF16, est que cette plage est très fréquemment utilisée. En effet, elle contient presque tous les registres mappés en mémoire des différents composants. Notez que l'adresse FF0016 est fournie par l'interface AddressMap que nous vous avons donnée, sous le nom de REGS_START, que vous pouvez utiliser dans votre code.

L'instruction marquée avec un double obèle (‡) permet de charger une valeur 16 bits dans l'une des paires de registres BC, DE ou HL, ou dans le pointeur de pile SP. Dès lors, si les deux bits rr de l'opcode valent 11, alors la valeur est chargée dans le registre SP, et pas dans la paire AF.

1.4.8 Instructions de stockage

Les instructions de stockage (store instructions en anglais) ont pour but de transférer une valeur depuis un registre du processeur vers la mémoire — plus généralement, vers n'importe quel composant attaché au bus.

Malheureusement, toutes ces instructions sont aussi nommées LD, ce qui peut porter à confusion. Toutefois, l'ordre des arguments est cohérent, et comme précédemment, le premier représente l'endroit où la donnée est écrite, le second sa provenance.

Assembleur Opcode Effet
LD [HL], r8 01110rrr BUS[HL] = r
LD [HL+], A 00100010 BUS[HL] = A; HL += 1
LD [HL-], A 00110010 BUS[HL] = A; HL -= 1
LD [$FF00 + n8], A 11100000 BUS[0xFF00 + n] = A
LD [$FF00 + C], A 11100010 BUS[0xFF00 + C] = A
LD [n16], A 11101010 BUS[nn] = A
LD [BC], A 00000010 BUS[BC] = A
LD [DE], A 00010010 BUS[DE] = A
LD [HL], n8 00110110 BUS[HL] = n
LD [n16], SP 00001000 BUS[nn] = SP
PUSH r16 11rr0101 SP -= 2; BUS[SP] = r

1.4.9 Instructions de copie

Les instructions de copie, aussi dites de déplacement (move instructions en anglais) ont pour but de transférer une valeur d'un registre du processeur à un autre.

Malheureusement encore, ces instructions sont elles aussi nommées LD.

Assembleur Opcode Effet
LD r8, r8 01rrrsss r = s (ssi rs)
LD SP, HL 11111001 SP = HL

2 Mise en œuvre Java

2.1 Enumération Opcode

Pour faciliter votre travail, nous mettons à votre disposition une archive Zip dont le contenu est à importer dans votre projet. Elle contient un seul fichier nommé Opcode.java et définissant le type énuméré Opcode. Celui-ci décrit les opcodes du processeur du GameBoy et a la structure suivante :

public enum Opcode {
  // Direct (non-prefixed) opcodes
  ADD_A_B(Kind.DIRECT, Family.ADD_A_R8, 0x80, 1, 1),
  // … beaucoup d'autres opcodes

  // Prefixed opcodes
  RLC_B(Kind.PREFIXED, Family.ROTC_R8, 0x00, 2, 2),
  // … beaucoup d'autres opcodes

  public enum Kind { DIRECT, PREFIXED }
  public enum Family {
    NOP,
    // … beaucoup d'autres familles
  }

  public final Kind kind;
  public final Family family;
  public final int encoding;
  public final int totalBytes;
  public final int cycles, additionalCycles;

  private Opcode(Kind kind,
		 Family family,
		 int encoding,
		 int totalBytes,
		 int cycles,
		 int additionalCycles) {
    // …
  }
  // … second constructeur
}

Le type énuméré Opcode a trois particuliarités :

  1. ses éléments possèdent des attributs (kind, family, etc.),
  2. il possède deux constructeurs (privés),
  3. il contient deux autres types énumérés, Kind et Family.

Les constructeurs ont pour unique but de donner leurs valeurs aux différents attributs, et peuvent être ignorés. Les attributs eux-même, par contre, sont très importants et ont la signification suivante :

  • kind décrit la sorte d'instructions correspondant à l'opcode ; pour cette étape, seule la sorte DIRECT nous intéresse, la sorte PREFIXED sera décrite ultérieurement,
  • family décrit la famille d'instructions auquel l'opcode appartient,
  • encoding donne l'encodage de l'opcode sous la forme d'une valeur 8 bits comprise entre 0 et FF16,
  • totalBytes donne la taille totale de l'instruction correspondant à l'opcode,
  • cycles donne le nombre de cycles nécessaires à l'exécution de l'instruction,
  • additionalCycles donne le nombre de cycles supplémentaires nécessaires à l'exécution de l'instruction ; il vaut 0 dans la plupart des cas, et ne sera utilisé que dans une étape ultérieure.

Par exemple, l'opcode correspondant à l'instruction LD B, D est décrit par l'élément suivant du type énuméré Opcode, à la ligne 115 du fichier Opcode.java :

LD_B_D(Kind.DIRECT, Family.LD_R8_R8, 0x42, 1, 1)

Cela signifie que l'opcode en question correspond à une instruction de type non-préfixé, sa famille est LD_R8_R8 (qui est celle de toutes les instructions ayant la forme LD r8,r8), son encodage est 4216, la taille totale de l'instruction est de un octet, et son exécution dure un cycle.

Les noms des familles ont été choisis de manière à être aussi évocateurs que possible. Pour clarifier leur signification, la table ci-dessous donne la correspondance entre les familles à traiter pour cette étape et les instructions correspondantes :

Famille Instruction(s) en assembleur
NOP NOP / LD A, A / LD B, B / … / LD L, L
LD_R8_HLR LD A, [HL] / LD B, [HL] / … / LD L, [HL]
LD_A_HLRU LD A, [HL+] / LD A, [HL-]
LD_A_N8R LD A, [$FF00 + 0] / … / LD A, [$FF00 + $FF]
LD_A_CR LD A, [$FF00 + C]
LD_A_N16R LD A, [0] / … / LD A, [$FFFF]
LD_A_BCR LD A, [BC]
LD_A_DER LD A, [DE]
LD_R8_N8 LD A, 0 / LD B, 0 / … / LD L, $FF
LD_R16SP_N16 LD SP, 0 / LD BC, 0 / … / LD HL, $FFFF
POP_R16 POP AF / POP BC / POP DE / POP HL
LD_HLR_R8 LD [HL], A / LD [HL], B / … / LD [HL], L
LD_HLRU_A LD [HL+], A / LD [HL-], A
LD_N8R_A LD [$FF00 + 0], A / … / LD [$FF00 + $FF], A
LD_CR_A LD [$FF00 + C], A
LD_N16R_A LD [0], A / … / LD [$FFFF], A
LD_BCR_A LD [BC], A
LD_DER_A LD [DE], A
LD_HLR_N8 LD [HL], 0 / LD [HL], 1 / … / LD [HL], $FF
LD_N16R_SP LD [0], SP / LD [1], SP / … / LD [$FFFF], SP
PUSH_R16 PUSH AF / PUSH BC / PUSH DE / PUSH HL
LD_R8_R8 LD A, B / LD A, C / … / LD L, H
LD_SP_HL LD SP, HL

2.2 Interface Register

L'interface Register du paquetage ch.epfl.gameboj, publique, a pour but d'être implémentée par les types énumérés représentant les registres d'un même banc. Par exemple, les registres du processeurs seront représentés par un type énuméré ressemblant à ceci :

enum Reg implements Register { A, F, B, C, D, E, H, L }

L'interface Register est extrêmement similaire à l'interface Bit de l'étape 1, mais a un rôle différent. Elle n'offre qu'une seule méthode publique et par défaut, nommée index et retournant la même valeur que celle retournée par la méthode ordinal.

2.3 Classe RegisterFile

La classe RegisterFile du paquetage ch.epfl.gameboj, publique et finale, représente un banc de registres 8 bits.

Il s'agit d'une classe générique dont l'unique paramètre de type représente le type des registres du banc. Bien entendu, ce paramètre possède le type Register comme borne supérieure.

La classe RegisterFile possède un unique constructeur :

  • RegisterFile(E[] allRegs), qui construit un banc de registres 8 bits dont la taille (c-à-d le nombre de registres) est égale à la taille du tableau donné.

Notez que ce constructeur est destiné à être toujours appelé avec en argument le résultat de la méthode values du type énuméré représentant les registres. (Pour mémoire, en Java, tous les types énumérés possèdent une méthode publique et statique nommée values retournant un tableau contenant la totalité des éléments du type énuméré, dans leur ordre de déclaration.)

Par exemple, pour créer un banc de quatre registres R1 à R4, on écrira :

enum Reg implements Register { R1, R2, R3, R4 }
RegisterFile<Reg> registerFile =
  new RegisterFile<>(Reg.values());

En plus de ce constructeur, la classe RegisterFile offre les méthodes publiques suivantes :

  • int get(E reg), qui retourne la valeur 8 bits contenue dans le registre donné, sous la forme d'un entier compris entre 0 (inclus) et FF16 (inclus),
  • void set(E reg, int newValue), qui modifie le contenu du registre donné pour qu'il soit égal à la valeur 8 bits donnée ; lève IllegalArgumentException si la valeur n'est pas une valeur 8 bits valide,
  • boolean testBit(E reg, Bit b), qui retourne vrai si et seulement si le bit donné du registre donné vaut 1,
  • void setBit(E reg, Bit bit, boolean newValue), qui modifie la valeur stockée dans le registre donné pour que le bit donné ait la nouvelle valeur donnée.

2.4 Interface Clocked

L'interface Clocked du paquetage ch.epfl.gameboj.component, publique, représente un composant piloté par l'horloge du système. Elle n'offre qu'une seule méthode, qui est abstraite :

  • void cycle(long cycle), qui demande au composant d'évoluer en exécutant toutes les opérations qu'il doit exécuter durant le cycle d'index donné en argument.

Cette méthode a pour but de faire évoluer l'état de tous les composants du Game Boy de manière synchrone. Pour cela, au début de la simulation, elle sera appelée sur tous les composants (processeurs, contrôleur LCD, etc.) avec la valeur 0. Ensuite, elle sera à nouveau appelée sur tous les composants, mais avec la valeur 1. Et ainsi de suite.

Les composants peuvent dès lors faire l'hypothèse que la toute première fois que leur méthode cycle sera appelée, le paramètre sera 0. Ensuite il sera 1, et ainsi de suite. Cela dit, il n'est pas demandé qu'ils vérifient explicitement cette condition.

2.5 Classe Cpu

La classe Cpu du paquetage ch.epfl.gameboj.component.cpu, publique et finale, représente le processeur du Game Boy. Son interface publique est relativement simple, mais il s'agit de la classe la plus complexe du projet et il est donc très important de l'organiser correctement en interne. De nombreux conseils de programmation sont donnés plus bas à ce sujet, lisez-les avant de commencer à programmer !

La classe Cpu implémente les interfaces Component et Clocked, étant donné qu'elle modélise un composant attaché au bus et piloté par l'horloge.

Les méthodes read et write provenant de Component ne font, dans leur version actuelle, rien du tout (ce qui implique que read retourne systématiquement NO_DATA). Elles seront modifiées dans les étapes suivantes.

La classe Cpu offre également une mise en œuvre de la méthode cycle de l'interface Clocked. Cette méthode doit, en gros, exécuter la prochaine instruction désignée par le compteur de programme (registre PC). Toutefois, étant donné que de nombreuses instructions prennent plus d'un cycle pour s'exécuter, cette méthode ne doit parfois rien faire du tout. Plus de détails à ce sujet sont donnés dans les conseils de programmation plus bas.

Finalement, la classe Cpu offre une méthode publique dont le seul but est de faciliter les tests, et dont le nom peu commun a été choisi en fonction :

  • int[] _testGetPcSpAFBCDEHL(), qui retourne un tableau contenant, dans l'ordre, la valeur des registres PC, SP, A, F, B, C, D, E, H et L.

(Cette méthode aurait pu être protégée, mais pour simplifier les choses, nous avons décidé de la rendre publique.)

2.5.1 Conseils de programmation

La classe Cpu est l'une des plus grosses du projet, et il est donc capital de bien l'organiser afin qu'elle reste compréhensible. Nous vous donnons donc quelques conseils ci-dessous sur la manière de procéder, entre autres en vous suggérant quelles entités privées — types énumérés, attributs et méthodes — créer.

Etant donné que les conseils qui suivent concernent la partie privée de la classe, vous êtes libres de les ignorer, partiellement ou totalement. Toutefois, ne le faites que si vous avez de bonnes raisons de le faire, et souvenez-vous que votre projet sera également jugé en fonction de son style. Ne le négligez donc pas !

  1. Registres

    Pour stocker les registres 8 bits du processeur, il faut bien entendu utiliser une instance de la classe RegisterFile, ce qui implique de définir un type énuméré pour les registres, qui pourrait ressembler à ceci :

    private enum Reg implements Register {
      A, F, B, C, D, E, H, L
    }
    

    Tout comme les types énumérés Kind et Family du fichier Opcode.java, celui-ci peut être déclaré dans la classe Cpu et rendu privé, donc totalement invisible de l'extérieur !

    En plus de ce type Reg, il est conseillé de définir un autre type énuméré pour les paires de registres (AF, BC, DE et HL), qui pourrait se nommer Reg16 (ce type apparaît plus loin, raison pour laquelle il est mentionné).

  2. Méthode cycle

    La plus grosse partie du code de la classe Cpu se cache derrière la méthode cycle, puisque c'est uniquement lorsqu'elle est appelée que le processeur exécute des instructions. Cela ne signifie pas pour autant que cette méthode doit être gigantesque, au contraire !

    La première chose dont il faut se rendre compte est que beaucoup d'instructions prennent plusieurs cycles pour s'exécuter, et il arrivera donc souvent que la méthode cycle n'ait rien à faire du tout5. Dès lors, il est conseillé d'ajouter à la classe Cpu un attribut, nommé p.ex. nextNonIdleCycle, qui donne la valeur du prochain cycle durant lequel le processeur aura effectivement quelque chose à faire. La méthode cycle peut alors commencer par vérifier si elle a quelque chose à faire ou non, et retourner directement si tel n'est pas le cas.

    Si elle ne doit pas retourner immédiatement, la méthode cycle doit obtenir l'opcode de la prochaine instruction à exécuter (référencé par le compteur de programme) et exécuter cette instruction. Il est préférable de laisser ce travail à une méthode séparée, décrite ci-après.

  3. Exécution d'une instruction

    Pour exécuter la prochaine instruction, le processeur doit lire son opcode depuis le bus, puis l'utiliser pour déterminer que faire. Ce travail peut être confié à une méthode séparée, nommée p.ex. dispatch et qui, étant donné un octet contenant un opcode, exécute l'instruction correspondante — en lisant ou écrivant, au besoin, des valeurs depuis le bus ou les registres.

    La description qui précède suggère l'utilisation d'un switch en Java pour exécuter le code de l'instruction correspondant à l'opcode. Et effectivement, la méthode dispatch consiste principalement en un (gros) switch, mais sur la famille d'instruction correspondant à l'opcode, et pas sur l'opcode lui-même.

    L'extrait de code ci-dessous, que vous pouvez copier dans le corps de votre switch, contient un cas par famille à traiter dans cette étape. Notez que le premier cas, qui correspond à la famille des instructions sans effet, est déjà correct tel quel !

    case NOP: {
    } break;
    case LD_R8_HLR: {
    } break;
    case LD_A_HLRU: {
    } break;
    case LD_A_N8R: {
    } break;
    case LD_A_CR: {
    } break;
    case LD_A_N16R: {
    } break;
    case LD_A_BCR: {
    } break;
    case LD_A_DER: {
    } break;
    case LD_R8_N8: {
    } break;
    case LD_R16SP_N16: {
    } break;
    case POP_R16: {
    } break;
    case LD_HLR_R8: {
    } break;
    case LD_HLRU_A: {
    } break;
    case LD_N8R_A: {
    } break;
    case LD_CR_A: {
    } break;
    case LD_N16R_A: {
    } break;
    case LD_BCR_A: {
    } break;
    case LD_DER_A: {
    } break;
    case LD_HLR_N8: {
    } break;
    case LD_N16R_SP: {
    } break;
    case LD_R8_R8: {
    } break;
    case LD_SP_HL: {
    } break;
    case PUSH_R16: {
    } break;
    

    Vous noterez qu'entre le case et le break de chaque cas se trouve un bloc, actuellement vide, entouré par des accolades ({ et }). Le but de ces blocs est de permettre la déclaration de variables locales aux différents cas.

    La question qui se pose encore est de savoir comment, étant donné un octet contenant un opcode, obtenir la famille qui correspond. Le plus simple est de construire, au moment de la création de la classe Cpu, un tableau de familles indexé par les 256 opcodes possibles. Ce tableau peut être construit au moyen d'une méthode statique nommée p.ex. buildOpcodeTable se basant sur les valeurs du type énuméré Opcode fourni pour construire la table. Attention à ne prendre en compte que les éléments dont la sorte est DIRECT !

    private static final Opcode[] DIRECT_OPCODE_TABLE =
      buildOpcodeTable(Opcode.Kind.DIRECT);
    

    Pour mémoire, en Java, un tableau contenant toutes les valeurs d'un type énuméré donné peut s'obtenir en appelant la méthode statique values sur ce type. Dès lors, il est possible d'itérer sur tous les éléments du type énuméré Opcode au moyen d'une boucle comme :

    for (Opcode o: Opcode.values()) { /* … */ }
    

    En plus de son effet direct sur l'état du processeur, une instruction a également deux effets indirects : d'une part le compteur de programme PC doit être mis à jour pour référencer la prochaine instruction, et d'autre part un certain nombre de cycles doivent s'écouler avant que la prochaine instruction ne puisse être exécutée.

    Pour mettre à jour le compteur de programme, vous pouvez bien entendu utiliser l'attribut totalBytes du type Opcode, et pour mettre à jour l'attribut nextNonIdleCycle, l'attribut cycles.

    Notez qu'il est fortement conseillé de ne pas essayer de mettre à jour le compteur de programme progressivement, p.ex. en l'avançant d'un octet après avoir lu l'opcode de l'instruction, puis d'un autre après avoir lu son éventuel argument 8 bits, etc. Procéder de cette manière peut sembler élégant à ce stade du projet, mais risque de vous jouer des tours lors de la gestion des sauts. Même si vous ne comprenez pas encore pourquoi à ce stade, suivez notre conseil !

  4. Accès au bus

    Beaucoup d'instructions du processeur — y compris certaines qui seront décrites dans des étapes ultérieures — lisent ou écrivent des valeurs depuis le bus.

    Cela implique premièrement d'avoir accès au bus du système ! Pour cela, il convient de redéfinir la méthode attachTo héritée de Component, afin de stocker le bus dans un attribut de la classe Cpu.

    D'autre part, il est judicieux d'offrir un certain nombre de méthodes (privées !) d'accès au bus pour simplifier la mise en œuvre des instructions. La liste suivante donne une idée des méthodes dont vous pourriez avoir besoin :

    • int read8(int address), qui lit depuis le bus la valeur 8 bits à l'adresse donnée,
    • int read8AtHl(), qui lit depuis le bus la valeur 8 bits à l'adresse contenue dans la paire de registres HL,
    • int read8AfterOpcode(), qui lit depuis le bus la valeur 8 bits à l'adresse suivant celle contenue dans le compteur de programme, c-à-d à l'adresse PC+1,
    • int read16(int address), qui lit depuis le bus la valeur 16 bits à l'adresse donnée,
    • int read16AfterOpcode(), qui lit depuis le bus la valeur 16 bits à l'adresse suivant celle contenue dans le compteur de programme, c-à-d à l'adresse PC+1,
    • void write8(int address, int v), qui écrit sur le bus, à l'adresse donnée, la valeur 8 bits donnée,
    • void write16(int address, int v), qui écrit sur le bus, à l'adresse donnée, la valeur 16 bits donnée,
    • void write8AtHl(int v), qui écrit sur le bus, à l'adresse contenue dans la paire de registres HL, la valeur 8 bits donnée,
    • void push16(int v), qui décrémente l'adresse contenue dans le pointeur de pile (registre SP) de 2 unités, puis écrit à cette nouvelle adresse la valeur 16 bits donnée,
    • int pop16(), qui lit depuis le bus (et retourne) la valeur 16 bits à l'adresse contenue dans le pointeur de pile (registre SP), puis l'incrémente de 2 unités.

    Attention : souvenez-vous que lorsqu'une valeur 16 bits est placée en mémoire, ses 8 bits de poids faible précèdent ceux de poids fort.

  5. Gestion des paires de registres

    Beaucoup d'instructions du processeur lisent ou écrivent des valeurs des paires de registres. Là aussi, il vaut la peine de n'écrire ce code qu'une seule fois et de le placer dans des méthodes (privées !). Voici les méthodes que nous vous suggérons d'offrir :

    • int reg16(Reg16 r), qui retourne la valeur contenue dans la paire de registres donnée,
    • void setReg16(Reg16 r, int newV), qui modifie la valeur contenue dans la paire de registres donnée, en faisant attention de mettre à 0 les bits de poids faible si la paire en question est AF,
    • void setReg16SP(Reg16 r, int newV), qui fait la même chose que setReg16 sauf dans le cas où la paire passée est AF, auquel cas le registre SP est modifié en lieu et place de la paire AF.
  6. Extraction de paramètres

    Plusieurs familles d'instructions possèdent des paramètres, p.ex. l'identité d'un registre, et certains de ces paramètres sont encodés dans l'octet formant l'opcode. Il est dès lors utile d'avoir des méthodes permettant d'extraire ces paramètres d'un opcode, par exemple :

    • Reg extractReg(Opcode opcode, int startBit), qui extrait et retourne l'identité d'un registre 8 bits de l'encodage de l'opcode donné, à partir du bit d'index donné,
    • Reg16 extractReg16(Opcode opcode), qui fait la même chose que extractReg mais pour les paires de registres (ne prend pas le paramètre startBit car il vaut 4 pour toutes les instructions du processeur),
    • int extractHlIncrement(Opcode opcode), qui retourne -1 ou +1 en fonction du bit d'index 4, qui est utilisé pour encoder l'incrémentation ou la décrémentation de la paire HL dans différentes instructions.

2.6 Tests

Comme pour l'étape précédente, nous ne vous fournissons plus de tests mais un fichier de vérification de signatures contenu dans une archive Zip à importer dans votre projet.

3 Références

Les personnes désirant avoir plus d'informations concernant les instructions du processeur du Game Boy et/ou leur encodage pourront se reporter aux références externes suivantes, toutes deux en anglais :

  • CPU opcode reference, qui liste la totalité des instructions du processeur du Game Boy, donne une brève description de leur effet et différentes autres informations,
  • Gameboy CPU (LR35902) instruction set, qui présente, sous forme de table, l'encodage de toutes les instructions du processeur du Game Boy et différentes autres informations (notez que, dans ce document, le terme cycle est utilisé pour désigner ce que nous désignons par le terme battement d'horloge).

4 Résumé

Pour cette étape, vous devez :

  • écrire les classes et interfaces Register, RegisterFile, Clocked et Cpu selon les indications données plus haut,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 9 mars 2018 à 16h30, via le système de rendu.

Ce rendu est un rendu testé, auquel 18 points sont attribués, au prorata des tests unitaires passés avec succès. Notez que la documentation de votre code ne sera pas évaluée avant le rendu intermédiaire. Dès lors, si vous êtes en retard, ne vous en préoccupez pas pour l'instant.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus. Souvenez-vous qu'aucun retard, aussi insignifiant soit-il, ne sera toléré !

Notes de bas de page

1

Le contrôleur LCD est le composant qui gère l'écran à cristaux liquides (liquid-crystal display ou LCD en anglais) du Game Boy.

2

A vrai dire, peu de gens écrivent encore des programmes directement en assembleur de nos jours, à part dans des domaines spécialisés comme l'informatique embarquée. Les langages de plus haut niveau, et surtout indépendants du processeur, comme Java, lui sont généralement préférés.

3

Cela n'est pas tout à fait exact, et nous verrons à l'étape suivante que les instructions dites préfixées nécessitent au moins 2 octets pour être identifiées. Nous ignorerons cela pour l'instant.

4

Cet assemblage peut se faire au moyen d'un assembleur pour Game Boy. Celui que nous avons utilisé pour cet exemple se nomme rgbasm et fait partie du projet RGBDS.

5

Notez que le véritable processeur du Game Boy est actif à chaque cycle, et si certaines instructions prennent plus d'un cycle à s'exécuter, c'est uniquement en raison de limitations du matériel. Par exemple, il n'est pas possible de faire plus d'un accès au bus par cycle. Dès lors, toute instruction dont l'encodage fait 2 octets aura besoin d'au moins deux cycles pour s'exécuter. Dans notre simulation, les instructions sont exécutées instantanément durant leur premier cycle, après quoi notre processeur simulé attend sans rien faire. Strictement parlant, cela est incorrect et constitue l'une des plus grosses approximation faite par notre simulation. Heureusement, peu de programmes dépendent de ce comportement.