Instructions de contrôle
Gameboj – Étape 5
1 Introduction
Le but de cette étape est de terminer la classe Cpu
modélisant le processeur du Game Boy en lui ajoutant :
- la gestion de la mémoire vive qu'il contient,
- la gestion des instructions dites « de contrôle »,
- la gestion des interruptions.
Cela fait, la classe GameBoy
doit également être complétée afin d'y ajouter le processeur, désormais totalement fonctionnel.
Ces différentes notions sont introduites ci-dessous, de même que la pile, qui joue un rôle important pour les instructions de contrôle et la gestion des interruptions.
1.1 Mémoire haute
Le processeur du Game Boy a la particularité de contenir une mémoire vive de 127 octets, nommée mémoire haute (high RAM). Etant donné que cette mémoire est interne au processeur, il peut y accéder sans devoir passer par le bus. Comme nous le verrons ultérieurement, cela peut être utile dans certaines situations.
Le processeur expose la mémoire haute sur le bus, de l'adresse FF8016 à l'adresse FFFE16 (incluse) — voir la carte mémoire de l'étape 1. La mémoire haute se trouve donc tout en haut de l'espace d'adressage du Game Boy, d'où son nom.
1.2 Pile
Pour mémoire, en informatique on appelle pile (stack) une collection dont les éléments sont toujours ajoutés ou supprimés de la même extrémité, que l'on nomme le sommet de la pile (top of stack).
La plupart des processeurs, dont celui du Game Boy, utilisent une pile pour sauvegarder temporairement des valeurs, et il est utile de comprendre comment cette pile fonctionne.
1.2.1 Pile du Game Boy
La pile du processeur du Game Boy ne peut contenir que des valeurs 16 bits. Elles sont stockées côte à côte en mémoire, celles insérées le plus récemment se trouvant aux adresses les plus basses. Dès lors, la pile croît vers le bas de l'espace d'adressage, ce qui est légèrement contre-intuitif (mais fréquent pour les piles de processeurs).
L'adresse du sommet de la pile est stockée dans le registre (16 bits) SP
, que l'on nomme le pointeur de pile (stack pointer) pour cette raison. L'adresse qu'il contient est donc toujours celle du premier octet de la valeur 16 bits se trouvant au sommet de la pile. Attention toutefois, comme les octets des valeurs 16 bits sont placés en mémoire en ordre petit-boutien, cet octet est l'octet de poids faible de la valeur au sommet de la pile.
Pour faciliter la gestion de la pile, le processeur offre plusieurs instructions permettant de manipuler le registre SP
et/ou la mémoire à l'adresse qu'il désigne. Par exemple, les instructions PUSH
et POP
décrites à l'étape 3 permettent respectivement de placer une paire de registres sur la pile, et de l'en retirer. Pour mémoire, l'effet de ces instructions est le suivant :
Assembleur | Effet |
---|---|
PUSH r16 |
SP -= 2; BUS[SP] = r |
POP r16 |
r = BUS[SP]; SP += 2 |
Ainsi, PUSH
décrémente SP
de deux unités afin de faire de la place pour les deux octets de la paire de registres à stocker, puis les stocke. Pour sa part, POP
lit les deux octets au sommet de la pile, les place dans la paire de registres, puis ajuste SP
afin de tenir compte de leur suppression de la pile.
1.2.2 Exemple d'utilisation de la pile
Le programme assembleur ci-dessous illustre le fonctionnement de la pile en stockant 123416 dans la paire de registre BC
, 567816 dans la paire DE
, puis en échangeant le contenu des deux paires via la pile. Remarquez que la première instruction initialise le registre SP
de manière à ce que la pile commence au sommet de la mémoire haute.
1: LD SP, $FFFF 2: LD BC, $1234 3: LD DE, $5678 4: PUSH BC 5: PUSH DE 6: POP BC 7: POP DE 8: HALT
Le fonctionnement de ce programme est illustré par la table ci-dessous, qui montre l'état des (paires de) registres BC
, DE
et SP
, ainsi que de la mémoire aux adresses allant de FFFB16 à FFFE16, après l'exécution de chacune des 7 premières instructions du programme. (Les points d'interrogation représentent des valeurs inconnues stockées dans la mémoire haute au moment où ce bout de programme est exécuté).
BC |
DE |
SP |
FFFB |
FFFC |
FFFD |
FFFE |
|
---|---|---|---|---|---|---|---|
1: | 0000 |
0000 |
FFFF |
? | ? | ? | ? |
2: | 1234 |
0000 |
FFFF |
? | ? | ? | ? |
3: | 1234 |
5678 |
FFFF |
? | ? | ? | ? |
4: | 1234 |
5678 |
FFFD |
? | ? | 34 |
12 |
5: | 1234 |
5678 |
FFFB |
78 |
56 |
34 |
12 |
6: | 5678 |
5678 |
FFFD |
78 |
56 |
34 |
12 |
7: | 5678 |
1234 |
FFFF |
78 |
56 |
34 |
12 |
Il est important de noter que l'instruction POP
ne fait aucune écriture en mémoire ! Dès lors, les deux octets qui se trouvaient au sommet de la pile sont toujours présents en mémoire après une instruction POP
, par contre ils ne sont plus considérés comme faisant partie de la pile car le registre SP
contient une adresse plus haute que la leur.
Pour bien comprendre le fonctionnement du programme assembleur ci-dessus, on peut en écrire un similaire en Java, en utilisant une instance de ArrayDeque
pour représenter la pile. Les instructions PUSH
et POP
ont alors pour équivalent les méthodes addFirst
et removeFirst
.
1: Deque<Short> stack = new ArrayDeque<>(); 2: short BC = 0x1234; 3: short DE = 0x5678; 4: stack.addFirst(BC); 5: stack.addFirst(DE); 6: BC = stack.removeFirst(); 7: DE = stack.removeFirst();
En affichant les valeurs de BC
, DE
et de la pile après chaque ligne, on obtient :
1: BC = 0000 DE = 0000 stack = [ ] 2: BC = 1234 DE = 0000 stack = [ ] 3: BC = 1234 DE = 5678 stack = [ ] 4: BC = 1234 DE = 5678 stack = [ 0x1234 ] 5: BC = 1234 DE = 5678 stack = [ 0x5678, 0x1234 ] 6: BC = 5678 DE = 5678 stack = [ 0x1234 ] 7: BC = 5678 DE = 1234 stack = [ ]
ce qui correspond assez directement à la table plus haut.
1.2.3 Dépassement de capacité
Lors du développement d'un programme pour Game Boy, il faut décider quelle zone mémoire dédier à la pile, d'une part afin de correctement initialiser SP
, et d'autre part pour connaître la capacité maximale de la pile.
Dans l'exemple ci-dessus, on pourrait décider de dédier la totalité de la mémoire haute du processeur à la pile, ce qui implique qu'elle aurait une capacité maximale de 64 valeurs 16 bits (127 octets/2). Si on tentait d'y stocker plus de 64 valeurs, il se produirait ce que l'on nomme un dépassement de capacité de la pile — stack overflow en anglais, d'où le nom du fameux site Web.
Il faut bien noter qu'en cas de dépassement de capacité, aucune erreur ne sera détectée par le processeur, qui n'a aucune idée de l'endroit où la portion de mémoire dédiée à la pile se termine. Il écrira donc joyeusement des valeurs aux adresses précédant la mémoire haute — dans notre exemple — ce qui aura des effets probablement catastrophiques mais dont l'origine sera difficile à diagnostiquer.
1.2.4 Utilisation
La pile d'un processeur est utilisée pour sauvegarder le contenu de registres que l'on désire temporairement utiliser à d'autres fins.
Par exemple, un programme désirant temporairement utiliser les registres B
et C
afin de faire un calcul — sans autant perdre définitivement leur contenu actuel — peut stocker leur valeur actuelle sur la pile au moyen d'une instruction PUSH
, utiliser B
et C
à sa guise, puis restaurer leur ancienne valeur au moyen d'une instruction POP
.
D'autre part, comme nous le verrons plus bas, la pile est aussi utilisée pour sauvegarder le compteur de programme lors d'un appel de fonction, afin de pouvoir le restaurer une fois la fonction terminée.
1.3 Instructions de contrôle
La totalité des instructions décrites dans les deux précédentes étapes s'exécutent en séquence. Cela signifie que dès que le processeur a terminé l'exécution d'une instruction, il continue avec celle qui la suit directement en mémoire.
Pour pouvoir écrire des programmes intéressants, il est toutefois nécessaire d'avoir à disposition des instructions dites « de contrôle », qui permettent de changer le cours de l'exécution. Ces instructions permettent p.ex. d'exécuter du code différent selon le résultat d'un test, d'effectuer des boucles, ou d'appeler des fonctions. Leur but est d'exprimer le même genre de choses que l'on exprime en Java avec des constructions comme le if
ou le switch
, les différents types de boucles — while
, do…while
, for
— et les appels de méthodes.
1.4 Instructions conditionnelles
Les instructions de contrôle du processeur du Game Boy existent généralement en deux variantes : inconditionnelle et conditionnelle.
Une instruction inconditionnelle est une instruction dont l'effet sur l'état du processeur est toujours réalisé. Toutes les instructions examinées dans les étapes précédentes sont de ce type.
Une instruction conditionnelle, par contre, a un effet qui n'est réalisé que si une condition donnée est vraie. Si cette condition est fausse, rien ne se passe, et l'instruction se comporte en gros comme une instruction NOP
— à la différence près qu'elle peut prendre plus d'un cycle pour s'exécuter.
Sur le processeur du Game Boy, la condition attachée aux instructions conditionnelles est très simple et ne peut rien faire d'autre que tester l'un ou l'autre des fanions Z
et C
stockés dans le registre F
. Au total, il y a quatre conditions différentes, qui sont encodées au moyen de 2 bits de l'opcode des instructions conditionnelles, selon la table ci-dessous :
cc |
Condition | Signification |
---|---|---|
00 |
NZ |
Fanion Z faux (not zero) |
01 |
Z |
Fanion Z vrai (zero) |
10 |
NC |
Fanion C faux (no carry) |
11 |
C |
Fanion C vrai (carry) |
Un exemple d'instruction conditionnelle est l'instruction de saut, JP
, décrite plus bas. Cette instruction a été utilisée dans le programme donné en exemple à l'étape 3 et qui calcule le douzième terme de la suite de Fibonacci. Pour mémoire, elle y était utilisée ainsi :
JP NZ, 0006
La signification de cette instruction conditionnelle est la suivante : si la condition NZ
est vraie, c-à-d si le fanion Z
est faux, alors elle effectue un saut à l'adresse 6 ; sinon, c-à-d si le fanion Z
est vrai, alors elle n'a aucun effet et se comporte comme une instruction NOP
d'une durée de 3 cycles.
Il faut noter que les instructions conditionnelles ont la particularité importante de s'exécuter en un nombre de cycles qui dépend de si la condition qui leur est attachée est vraie ou non. Par exemple, l'instruction JP
s'exécute en 3 cycles si la condition est fausse, mais en 4 cycles si elle est vraie.
1.5 Sauts
Les sauts (jumps) sont les instructions de contrôle les plus simples, et leur but est de changer le cours de l'exécution du programme en modifiant le contenu du compteur de programme.
Les instructions de saut du processeur du Game Boy peuvent être conditionnelles ou non, et la destination du saut — c-à-d l'adresse à placer dans le registre PC
— peut être spécifiée de deux manières différentes :
- de manière absolue (instruction
JP
), c-à-d sous forme d'une valeur de 16 bits, - de manière relative (instruction
JR
, pour jump relative), c-à-d sous forme d'une valeur de 8 bits signée à ajouter à l'adresse de l'instruction suivant celle de saut.
Chaque type de saut a ses avantages et inconvénient :
- les sauts absolus ont l'avantage de pouvoir sauter à n'importe quelle adresse, mais l'inconvénient de nécessiter 3 octets pour être encodées (1 pour l'opcode, 2 pour l'adresse 16 bits),
- les sauts relatifs ont l'inconvénient de ne pouvoir sauter que à une adresse se trouvant à une distance de -128 à +127 octets de l'instruction suivant le saut, mais l'avantage de ne nécessiter que 2 octets pour être encodées (1 pour l'opcode, 1 pour la distance de saut).
Les instructions de saut sont décrites par la table ci-dessous, dans laquelle PC'
représente l'adresse de l'instruction qui suit celle en cours d'exécution :
Assembleur | Opcode | Effet |
---|---|---|
JP n16 |
11000011 |
PC = nn |
JP cc, n16 |
110cc010 |
PC = nn (ssi cc est vrai) |
JP HL |
11101001 |
PC = HL |
JR e8 |
00011000 |
PC = PC' + e |
JR cc, e8 |
001cc000 |
PC = PC' + e (ssi cc est vrai) |
L'utilité des sauts relatifs peut être illustrée au moyen du programme d'exemple donné dans l'énoncé de l'étape 3. A l'adresse A16, celui-ci contient un saut absolu à l'adresse 6
, encodé de la manière illustrée par la table ci-dessous (qui est une version abrégée de celle de l'étape 3) :
Adresse | Octet(s) | Instruction |
---|---|---|
… |
… |
… |
0006 |
57 |
LD D, A |
… |
… |
… |
000A |
C2 06 00 |
JP NZ, 0006 |
000D |
76 |
HALT |
Ce saut peut être remplacé par un saut relatif, encodé au moyen de 2 octets seulement. On obtient alors :
Adresse | Octet(s) | Instruction |
---|---|---|
… |
… |
… |
0006 |
57 |
LD D, A |
… |
… |
… |
000A |
20 FA |
JR NZ, $FA |
000C |
76 |
HALT |
L'opcode de l'instruction de saut relatif JR
vaut 2016 et son argument est FA16. Cet argument est une valeur 8 bits qui doit être interprétée en complément à deux, ce qui donne -6. Dès lors, l'adresse de destination du saut est C16 - 6 = 6, comme attendu.
1.6 Appels et retours de fonctions
En plus des instructions de saut, le processeur du Game Boy offre des instructions permettant d'effectuer des appels de fonctions. Les fonctions sont, en gros, l'équivalent assembleur des méthodes statiques en Java.
Les instructions d'appel et de retour sont décrites dans la table ci-dessous. Les fonction push
et pop
utilisée dans la description de leur effet permettent respectivement de placer et de retirer une valeur 16 bits de la pile. Elles font exactement la même chose que les instructions PUSH
et POP
décrites à l'étape 3, si ce n'est qu'elles sont utilisées ici pour sauvegarder et restaurer le contenu du registre PC
.
Assembleur | Opcode | Effet |
---|---|---|
CALL n16 |
11001101 |
push(PC'); PC = nn |
CALL cc, n16 |
110cc100 |
push(PC'); PC = nn (ssi cc est vrai) |
RST n3 |
11nnn111 |
push(PC'); PC = 8 × n |
RET |
11001001 |
PC = pop() |
RET cc |
110cc000 |
PC = pop() (ssi cc est vrai) |
Notez que l'instruction RST
est presque identique à l'instruction CALL
, la seule différence est que l'adresse de la fonction à appeler est calculée en fonction de la valeur de 3 bits fournie en paramètre et stockée dans l'opcode.
Comme cette table l'illustre, les instructions d'appel (CALL
et RST
) sauvegardent l'adresse de la prochaine instruction sur la pile avant de sauter à l'adresse donnée. On appelle l'adresse sauvegardée l'adresse de retour (return address). C'est en effet l'adresse à laquelle l'exécution du programme doit continuer une fois la fonction terminée. Pour cette raison, l'instruction de retour (RET
) ne fait rien d'autre que placer cette adresse dans le compteur de programme.
1.6.1 Exemple de fonction
L'exemple ci-dessous montre comment le douzième terme de la suite de Fibonacci (89) peut être calculé au moyen d'une fonction récursive en assembleur. Il n'est pas nécessaire de le comprendre en détail, mais l'idée est de définir une fonction fib
calculant récursivement le terme de la suite de Fibonacci dont l'index lui est passé dans le registre A
. Cette fonction retourne le résultat dans le registre A
, et garantit qu'elle préserve le contenu de tous les registres 8 bits — sauf A
lui-même, bien entendu.
LD SP, $FFFF ; initialise le pointeur de pile LD A, 11 CALL fib ; calcule fib(11) HALT ; ici, A = 89 ;; fib - calcule un terme de la suite de Fibonacci ;; argument : ;; A = i, l'index du terme à calculer ;; retour : ;; A = F(i), la valeur du terme d'index i fib: CP A, 2 ; i < 2 ? RET C ; si oui, on retourne i [car fib(i) = i] PUSH BC ; sauvegarde B et C DEC A ; A = i - 1 LD B, A ; B = i - 1 CALL fib ; A = fib(i - 1), calculé récursivement LD C, A ; C = fib(i - 1) LD A, B ; A = i - 1 DEC A ; A = i - 2 CALL fib ; A = fib(i - 2), calculé récursivement ADD A, C ; A = fib(i - 2) + fib(i - 1) POP BC ; restaure B et C RET ; retourne
Attention, le C
dans l'instruction RET C
représente le fanion C
et pas le registre C
, et signifie qu'on ne retourne de la fonction à cet endroit que si ce fanion est vrai.
Le programme Java ci-dessous calcule le même terme de la suite de Fibonacci que le programme assembleur ci-dessus, selon le même principe, et peut faciliter la compréhension de ce dernier.
public static void main(String[] args) { System.out.println(fib(11)); } static int fib(int i) { if (i < 2) return i; else return fib(i - 1) + fib(i - 2); }
1.7 Interruptions
Il est fréquent que le programme exécuté par un processeur doive réagir rapidement à un événement externe. Par exemple, un jeu exécuté par le Game Boy doit pouvoir réagir immédiatement lorsqu'un bouton du clavier est pressé. De même, le pointeur de souris affiché sur l'écran d'un ordinateur de bureau doit se déplacer au moindre mouvement de la souris.
La question se pose donc de savoir comment le programme actuellement en cours d'exécution sur le processeur peut être rapidement informé de l'occurrence d'un tel événement externe.
La solution consiste à offrir ce que l'on appelle des interruptions (interrupts). Les composants externes au processeur ont la possibilité de lever (raise) une telle interruption, forçant ainsi le processeur à interrompre temporairement l'exécution normale du programme, et à exécuter à la place un gestionnaire d'interruption (interrupt handler). Ce gestionnaire est une espèce de fonction, dont le seul but est de traiter l'événement externe signalé par l'interruption. Lorsque ce gestionnaire se termine, l'exécution normale du programme se poursuit là où elle avait été interrompue par l'arrivée de l'interruption.
Assez naturellement, le processeur utilise la pile pour sauvegarder l'adresse de l'instruction à laquelle poursuivre l'exécution lorsqu'une interruption se produit. On peut donc voir les interruptions comme des appels de fonction — la fonction appelée étant le gestionnaire correspondant à l'interruption — qui ne sont pas provoqués explicitement par le programme au moyen d'une instruction comme CALL
, mais implicitement par l'arrivée d'un événement externe.
Les interruptions d'un processeur sont similaires à celles de la vie réelle. Par exemple, lorsqu'une personne travaille à une tâche particulière et que son téléphone sonne, elle interrompt temporairement son travail pour y répondre et, une fois l'appel terminé, reprend son travail là où elle s'était interrompue.
1.7.1 Interruptions sur le Game Boy
Le processeur du Game Boy possède un total de cinq interruptions, levées par différents composants externes. Elles sont décrites par la table ci-dessous :
No | Nom | Levée par |
---|---|---|
0 | VBLANK |
Le contrôleur LCD |
1 | LCD_STAT |
Le contrôleur LCD |
2 | TIMER |
Le minuteur |
3 | SERIAL |
Le contrôleur sériel |
4 | JOYPAD |
Le clavier |
Le numéro associé à chaque interruption a deux buts : premièrement, il définit un ordre de priorité qui permet de résoudre les conflits lorsque plusieurs interruptions sont levées simultanément, et d'autre part il permet d'associer à chaque interruption un bit dans les registres IE
et IF
décrits ci-après.
1.7.2 Registres
Pour gérer les interruptions, le processeur du Game Boy possède trois registres, qui n'ont pas encore été décrits jusqu'à présent et qui sont :
IME
(pour interrupt master enable), qui est un registre 1 bit — une valeur booléenne — qui détermine si les interruptions sont activées ou non,IE
(pour interrupt enable), qui est un registre 8 bits dont les bits 4 à 0 déterminent si l'interruption correspondante est activée,IF
(pour interrupt flags), qui est un registre 8 bits dont les bits 4 à 0 déterminent si l'interruption correspondante est actuellement levée.
Le registre IME
a priorité sur le registre IE
, c-à-d que si IME
est faux, alors les interruptions sont totalement désactivées, indépendemment des bits de IE
. Par contre, si IME
est vrai, alors seules les interruptions dont le bit est à 1 dans le registre IE
sont activées.
Le registre IME
n'est pas mappé en mémoire, par contre les deux autres le sont : IE
à l'adresse FFFF16 et IF
à l'adresse FF0F16. Dès lors, il est possible de connaître leur valeur en effectuant une lecture à ces adresses, et de modifier leur valeur en écrivant à ces adresses. Notez que cela implique qu'il est tout à fait possible de provoquer une interruption en écrivant dans le registre IF
!
1.7.3 Gestion
Avant d'exécuter une instruction, le processeur détermine si une interruption doit être gérée. Pour cela, il regarde si :
IME
est vrai, et- il existe au moins un bit valant 1 à la fois dans
IE
et dansIF
.
Si tel est le cas, il gère l'interruption ainsi :
IME
est mis à faux, pour éviter que le gestionnaire d'interruption ne soit lui-même interrompu,- le numéro de l'interruption à gérer,
i
, est déterminé comme étant l'index du bit de poids le plus faible valant 1 à la fois dansIE
et dansIF
— ce qui implique que plus le numéro d'une interruption est bas, plus sa priorité est élevée, - le bit d'index
i
deIF
est mis à 0 pour exprimer le fait que l'interruption correspondante a été traitée — ou, plus exactement, est sur le point de l'être, - le compteur de programme actuel est sauvegardé sur la pile,
- l'adresse du gestionnaire d'interruption, valant 4016 + 8 ×
i
, est placée dans le compteur de programme.
Le processeur a besoin de temps pour effectuer ces différentes actions, donc la première instruction du gestionnaire n'est exécutée que 5 cycles après la détection de l'interruption.
Par exemple, si à un instant donné, le processeur s'apprête à exécuter l'instruction à l'adresse 123416 et IE
contient 111102, IF
contient 100112 et IME
est vrai, alors :
- le processeur détermine que l'interruption 1 (
LCD_STAT
) doit être gérée, - il met à 0 le bit 1 de
IF
, qui vaut maintenant 100012, - il sauvegarde le compteur de programme actuel, 123416, sur la pile,
- il charge l'adresse 4816 dans le compteur de programme.
Dès lors, la prochaine instruction qui sera exécutée, 5 cycles plus tard, est celle dont l'opcode est à l'adresse 4816.
1.7.4 Instructions
Le processeur du Game Boy possède plusieurs instructions liées à la gestion des interruptions. Les deux premières permettent simplement d'activer ou de désactiver globalement les interruptions, en modifiant le registre IME
:
Assembleur | Opcode | Effet |
---|---|---|
EI |
11111011 |
IME = true |
DI |
11110011 |
IME = false |
La dernière combine les effets des instructions EI
et RET
. Son but est d'être utilisée à la place d'une instruction RET
normale à la fin d'un gestionnaire d'interruption, pour simultanément réactiver les interruptions et poursuivre l'exécution là où l'interruption l'avait interrompue.
Assembleur | Opcode | Effet |
---|---|---|
RETI |
11011001 |
IME = true; PC = pop() |
1.8 Instructions d'arrêt
Deux instructions permettent d'arrêter temporairement le Game Boy, dans le but d'économiser de l'énergie :
Assembleur | Opcode | Effet |
---|---|---|
HALT |
01110110 |
arrête le processeur |
STOP |
00010000 |
arrête le système |
En pratique, STOP
n'est presque jamais utilisée, donc nous ne prendrons même pas la peine de la simuler.
L'instruction HALT
a pour effet d'arrêter le processeur du Game Boy, qui n'exécute alors plus aucune instruction. Il reste dans cet état jusqu'à ce qu'une interruption dont le bit vaut 1 dans IE
soit levée, même si IME
est faux.
En d'autres termes, si le processeur est à l'arrêt suite à une instruction HALT
, le fait qu'il existe au moins un bit valant 1 à la fois dans IE
et dans IF
provoquera son réveil, quelle que soit la valeur du fanion IME
. Une fois réveillé, le processeur exécutera :
- la première instruction du gestionnaire d'interruption, si
IME
est vrai, ou - l'instruction qui suit le
HALT
, siIME
est faux.
2 Mise en œuvre Java
La mise en œuvre de cette étape consiste à terminer la classe Cpu
et à compléter la classe GameBoy
.
2.1 Classe Cpu
L'interface publique de la classe Cpu
doit être augmentée pour offrir la possibilité à ses utilisateurs de lever des interruptions. Cela implique l'ajout d'une énumération pour décrire les interruptions, et d'une méthode pour en lever une.
L'énumération est nommée Interrupt
et implémente l'interface Bit
car à chaque interruption correspond un bit dans chacun des registres IE
et IF
. Elle est déclarée ainsi :
public enum Interrupt implements Bit { VBLANK, LCD_STAT, TIMER, SERIAL, JOYPAD }
La méthode permettant de lever une interruption est quant à elle :
void requestInterrupt(Interrupt i)
, qui lève l'interruption donnée, c-à-d met à 1 le bit correspondant dans le registreIF
.
En plus de ces ajouts à l'interface publique de la classe Cpu
, il faut modifier les méthodes read
et write
, qui étaient vides jusqu'à présent. En effet, elles doivent désormais donner accès à la mémoire haute et aux registres IE
et IF
. Pour mémoire, ces différents éléments ont des addresses qui sont données par des constantes du fichier AddressMap
fourni et qui sont :
AddressMap.REG_IE
pour le registreIE
,AddressMap.REG_IF
pour le registreIF
,AddressMap.HIGH_RAM_START
etAddressMap.HIGH_RAM_END
pour la mémoire haute (n'oubliez pas que la première valeur est inclusive, alors que la seconde est exclusive).
N'oubliez pas que les méthodes read
et write
doivent vérifier la validité de leurs arguments !
Notez que nous vous conseillons de ne pas utiliser un contrôleur mémoire (RamController
) pour la mémoire haute, car cela ne fait que compliquer le code. La classe RamController
n'est destinée à être utilisée que pour les mémoires connectées directement sur le bus, pas celles contenues dans d'autres composants.
2.1.1 Conseils de programmation
- Augmentation de la méthode
dispatch
Comme pour l'étape précédente, il convient d'augmenter le
switch
de la méthodedispatch
avec les cas correspondants aux familles des instructions décrites plus haut. Pour faciliter votre travail, nous vous fournissons une fois encore la liste des cas à traiter pour cette étape sous la forme de code Java que vous pouvez simplement copier dans votre projet.// Jumps case JP_HL: { } break; case JP_N16: { } break; case JP_CC_N16: { } break; case JR_E8: { } break; case JR_CC_E8: { } break; // Calls and returns case CALL_N16: { } break; case CALL_CC_N16: { } break; case RST_U3: { } break; case RET: { } break; case RET_CC: { } break; // Interrupts case EDI: { } break; case RETI: { } break; // Misc control case HALT: { } break; case STOP: throw new Error("STOP is not implemented");
Notez que comme le simulateur ne gérera pas l'instruction
STOP
, le cas correspondant lève simplement une exception. - Familles
La correspondance entre les familles que nous avons choisies et les instructions est triviale pour cette étape, et résumée dans la table ci-dessous.
Famille Instruction(s) en assembleur JP_HL
JP HL
JP_N16
JP 0
/JP 1
/ … /JP $FFFF
JP_CC_N16
JP NZ, 0
/JP Z, 0
/ … /JP C, $FFFF
JR_E8
JR -128
/JR -127
/ … /JR 127
JR_CC_E8
JR NZ, -128
/JR Z, -128
/ … /JR C, 127
CALL_N16
CALL 0
/CALL 1
/ … /CALL $FFFF
CALL_CC_N16
CALL NZ, 0
/CALL Z, 0
/ … /CALL C, $FFFF
RST_U3
RST 0
/RST 1
/ … /RST 7
RET
RET
RET_CC
RET NZ
/RET Z
/RET NC
/RET C
EDI
EI
/DI
RETI
RETI
HALT
HALT
Notez juste le fait que les instructions
EI
etDI
ont été groupées dans une unique famille, car elles ont un effet très similaire. - Instructions conditionnelles
Lors du traitement des instructions conditionnelles, il faut déterminer si la condition qui leur est associée est vraie ou fausse. Or en examinant l'encodage des différentes instructions conditionnelles, vous verrez que leur condition est toujours stockée dans les bits 3 et 4 de leur opcode. Il vaut donc la peine de définir une méthode privée auxiliaire capable d'extraire la condition d'un opcode et de tester si elle est vraie en consultant le fanion correspondant du registre
F
.De plus, rappelez-vous que toutes les instructions conditionnelles s'exécutent en un nombre de cycle qui dépend de si la condition est vraie ou fausse. Il faut bien entendu en tenir compte lors de la simulation, ce qui est simplifié par le fait que l'attribut
additionalCycles
du typeOpcode
contient le nombre de cycles supplémentaires nécessaires à l'exécution de l'instruction correspondante si la condition est vraie. - Gestion des interruptions
Une difficulté de cette étape est de gérer correctement les interruptions. Nous vous conseillons pour ce faire de découper la méthode
cycle
de votre classeCpu
en deux parties :- la méthode
cycle
elle-même, qui ne fait rien d'autre que déterminer si oui ou non le processeur doit faire quelque chose durant ce cycle, et si oui appelle la méthodereallyCycle
ci-dessous, et si non retourne simplement, - la méthode
reallyCycle
, qui regarde si les interruptions sont activées (c-à-d siIME
est vrai) et si une interruption est en attente, auquel cas elle la gère comme décrit plus haut ; sinon, elle exécute normalement la prochaine instruction.
Pour déterminer le numéro de l'interruption à gérer, les méthodes
lowestOneBit
etnumberOfLeadingZeros
de la classeInteger
peuvent être utiles.D'autre part, pour déterminer l'adresse du gestionnaire correspondant à une interruption, sachez que l'interface
AddressMap
offre un tableau nomméINTERRUPTS
et contenant les adresses des gestionnaires d'interruption. Ce tableau doit être indexé par le numéro de l'interruption (0 pourVBLANK
, 1 pourLCD_STAT
, etc.). - la méthode
- Instruction
HALT
L'instruction
HALT
est un peu problématique car elle arrête le processeur pour une durée indéterminée. Pour mémoire, après avoir exécuté une instructionHALT
, le processeur ne continuera à exécuter des instructions que si une interruption est levée, et si celle-ci est activée, c-à-d que le bit correspondant du registreIE
vaut 1.Pour gérer cela, nous vous conseillons de procéder ainsi :
- lors de l'exécution de l'instruction
HALT
, donnez ànextNonIdleCycle
la valeur maximale possible (Long.MAX_VALUE
), - dans la méthode
cycle
, sinextNonIdleCycle
vaut cette valeur maximale, et qu'une interruption est en attente, forceznextNonIdleCycle
à la valeur de l'argument passé à la méthode, puis appelezreallyCycle
.
- lors de l'exécution de l'instruction
- Instruction
RST
L'instruction
RST
fait un appel de fonction à une adresse qui est déterminé par la valeur de 3 bits stockée dans son opcode, comme décrit ci-dessus.L'interface
AddressMap
contient un tableau nomméRESETS
, similaire àINTERRUPTS
, qui contient l'adresse du début des 8 fonctions que l'instructionsRST
peut appeler.
2.2 Classe GameBoy
Etant donné que vous avez maintenant terminé la classe modélisant le processeur, il est temps de modifier la classe GameBoy
pour ajouter une instance du processeur au système.
Cela implique premièrement de modifier le constructeur de cette classe pour qu'il crée un processeur (c-à-d une instance de Cpu
) et l'attache au bus.
D'autre part, il faut fournir une méthode publique permettant d'obtenir le processeur du Game Boy :
Cpu cpu()
, qui retourne le processeur du Game Boy.
De plus, étant donné que votre Game Boy simulé contient désormais un composant piloté par l'horloge (le processeur), il convient d'ajouter une méthode permettant de faire avancer la simulation, et une autre permettant de connaître le nombre de cycles déjà simulés :
void runUntil(long cycle)
, qui simule le fonctionnement du GameBoy jusqu'au cycle donné moins 1, ou lève l'exceptionIllegalArgumentException
si un nombre (strictement) supérieur de cycles a déjà été simulé,long cycles()
, qui retourne le nombre de cycles déjà simulés.
Pour simuler le fonctionnement du Game Boy, la méthode runUntil
ne fait rien d'autre qu'appeler de manière répétée la méthode cycle
de tous les composants pilotés par une horloge existant dans le système. A l'heure actuelle, le seul composant de ce type est le processeur.
Au moyen de cette nouvelle version de la classe GameBoy
, il devient possible d'effectuer une simulation jusqu'à un certain cycle, p.ex. 2 dans l'exemple ci-dessous :
GameBoy g = new GameBoy(null); g.runUntil(2); System.out.println(g.cycles());
L'appel à runUntil
a pour effet d'appeler la méthode cycle
du processeur du Game Boy deux fois de suite : la première fois avec l'argument 0, la seconde avec l'argument 1. Ensuite, la valeur affichée à l'écran par la dernière ligne est 2.
(Notez bien qu'à l'heure actuelle aucun programme n'est disponible à l'adresse 0, donc en pratique cet exemple de simulation ne fait rien d'intéressant. Son seul but est d'illustrer l'utilisation des méthodes runUntil
et cycles
. Il vous faudra encore attendre une étape avant de pouvoir simuler des programmes en utilisant uniquement la classe GameBoy
.)
2.3 Tests
Comme d'habitude, nous vous fournissons un fichier de vérification de signatures contenu dans une archive Zip à importer dans votre projet.
De plus, pour vous aider à débuter vos tests, nous vous fournissons ci-dessous la déclaration d'un tableau Java contenant l'encodage du programme de calcul de Fibonacci1 récursif présenté à la §1.6.1.
En plaçant son premier octet à l'adresse 0 et en simulant ensuite l'exécution du programme qu'il contient jusqu'à ce que le PC
vaille 8 — adresse de l'instruction HALT
—, vous devriez constater qu'à ce moment le registre A
contient 89, comme attendu.
new byte[] { (byte)0x31, (byte)0xFF, (byte)0xFF, (byte)0x3E, (byte)0x0B, (byte)0xCD, (byte)0x0A, (byte)0x00, (byte)0x76, (byte)0x00, (byte)0xFE, (byte)0x02, (byte)0xD8, (byte)0xC5, (byte)0x3D, (byte)0x47, (byte)0xCD, (byte)0x0A, (byte)0x00, (byte)0x4F, (byte)0x78, (byte)0x3D, (byte)0xCD, (byte)0x0A, (byte)0x00, (byte)0x81, (byte)0xC1, (byte)0xC9, };
3 Résumé
Pour cette étape, vous devez :
- terminer la classe
Cpu
en lui ajoutant la gestion de la mémoire haute, des instructions de contrôle et des interruptions, et compléter la classeGameBoy
en lui ajoutant le processeur et la méthoderunUntil
, comme décrit ci-dessus, - documenter la totalité des entités publiques que vous avez définies,
- rendre votre code au plus tard le 23 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
En réalité, cet encodage ne correspond pas tout à fait au programme donné plus haut, car un NOP
y apparaît juste après l'instruction HALT
. Cette instruction a été insérée automatiquement par l'assembleur utilisé, pour contourner un bug du processeur du Game Boy. Elle n'a toutefois aucune influence sur le comportement du programme.