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 :

  1. la gestion de la mémoire vive qu'il contient,
  2. la gestion des instructions dites « de contrôle »,
  3. 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 pilestack 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 :

  1. de manière absolue (instruction JP), c-à-d sous forme d'une valeur de 16 bits,
  2. 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 dans IF.

Si tel est le cas, il gère l'interruption ainsi :

  1. IME est mis à faux, pour éviter que le gestionnaire d'interruption ne soit lui-même interrompu,
  2. 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 dans IE et dans IF — ce qui implique que plus le numéro d'une interruption est bas, plus sa priorité est élevée,
  3. le bit d'index i de IF 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,
  4. le compteur de programme actuel est sauvegardé sur la pile,
  5. 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 :

  1. le processeur détermine que l'interruption 1 (LCD_STAT) doit être gérée,
  2. il met à 0 le bit 1 de IF, qui vaut maintenant 100012,
  3. il sauvegarde le compteur de programme actuel, 123416, sur la pile,
  4. 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, si IME 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 registre IF.

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 registre IE,
  • AddressMap.REG_IF pour le registre IF,
  • AddressMap.HIGH_RAM_START et AddressMap.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

  1. Augmentation de la méthode dispatch

    Comme pour l'étape précédente, il convient d'augmenter le switch de la méthode dispatch 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.

  2. 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 et DI ont été groupées dans une unique famille, car elles ont un effet très similaire.

  3. 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 type Opcode contient le nombre de cycles supplémentaires nécessaires à l'exécution de l'instruction correspondante si la condition est vraie.

  4. 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 classe Cpu en deux parties :

    1. 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éthode reallyCycle ci-dessous, et si non retourne simplement,
    2. la méthode reallyCycle, qui regarde si les interruptions sont activées (c-à-d si IME 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 et numberOfLeadingZeros de la classe Integer 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 pour VBLANK, 1 pour LCD_STAT, etc.).

  5. 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 instruction HALT, 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 registre IE vaut 1.

    Pour gérer cela, nous vous conseillons de procéder ainsi :

    1. lors de l'exécution de l'instruction HALT, donnez à nextNonIdleCycle la valeur maximale possible (Long.MAX_VALUE),
    2. dans la méthode cycle, si nextNonIdleCycle vaut cette valeur maximale, et qu'une interruption est en attente, forcez nextNonIdleCycle à la valeur de l'argument passé à la méthode, puis appelez reallyCycle.
  6. 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'instructions RST 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'exception IllegalArgumentException 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 classe GameBoy en lui ajoutant le processeur et la méthode runUntil, 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

1

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.