Programmer un émulateur

Chapitre 4 : Simulation des instructions

BestCoder

La Chip 8 ne sait exécuter que 35 opérations. Oui, vous avez bien entendu, ce sera aux programmeurs des jeux et applications de combiner les différentes opérations pour arriver à leurs fins. Nous nous contenterons d'implémenter les 35 opérations et notre émulateur sera opérationnel.

Commentez

- Titre : Programmer un émulateur

- Auteur : BestCoder

- Parution : 25 mars 2014

- Licence : Le contenu de cet article est rédigé par BestCoder et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- Navigation

- II. Introduction

- III. Le cadencement du CPU et les FPS

- III-A. Comment allons-nous procéder ?

- III-B. Récapitulatif

- IV. Lecture de l'opcode

- IV-A. Les décalages (bit shift)

- V. Identification de l'instruction

- V-A. Notre table de correspondance

- V-B. Trouver le masque et l'identifiant de l'opcode

- V-C. Trouver l'opcode à interpréter

- VI. Retour sur le graphique

- Navigation

Navigation

Tutoriel précédent : la base

Sommaire

Tutoriel suivant : exemples avec quelques instructions

II - Introduction

La Chip 8 ne sait exécuter que 35 opérations. Oui, vous avez bien entendu, ce sera aux programmeurs des jeux et applications de combiner les différentes opérations pour arriver à leurs fins. Nous nous contenterons d'implémenter les 35 opérations et notre émulateur sera opérationnel.

III - Le cadencement du CPU et les FPS

Il y a deux choses qu'il faut absolument connaître quand on veut programmer un émulateur :

- la vitesse d'exécution des instructions ou le cadencement de la machine ;

- la période de rafraîchissement de l'écran ou le nombre de FPS.

III-A - Comment allons-nous procéder ?

Nous savons qu'une console de jeu est un système embarqué et ceux qui ont déjà programmé pour de l'embarqué (microprocesseur, microcontrôleur, etc.) savent que dans le code, il faut systématiquement une boucle infinie ou un système de timer, car le code doit s'exécuter aussi longtemps que la console est en marche.

while("Machine Fonctionne")
{
regarder_ce_qu_il_faut_faire();
Le_faire();
}

La fréquence d'exécution des instructions reste assez méconnue. Dans notre cas, nous allons utiliser une fréquence de 250 hertz. Cette fréquence nous donne une période de 4 ms (rappel mathématique : f = 1/T avec T la période en secondes et f la fréquence en hertz).

Donc, toutes les quatre millisecondes, nous devons effectuer une opération. Soit quatre opérations toutes les seize millisecondes.

Pour les FPS aussi, la valeur est assez méconnue, on prendra donc la valeur la plus courante, à savoir 60. Soixante images par seconde, c'est une image toutes les 16,67 ms (1000 / 60 = 16,67). Un timer SDL aussi précis n'existe pas, on se limitera donc à une image toutes les 16 ms.

III-B - Récapitulatif

Il faut quatre opérations et une nouvelle image toutes les seize millisecondes. Donc, la fonction updateEcran sera appelée après l'exécution de quatre opérations. Je gère le tout dans le main. Nous allons nous contenter de la boucle principale (le fameux while(continuer)) et des SDL_Delay.

#include <SDL/SDL.h>
#include "cpu.h"

#define VITESSECPU 4 //nombre d'opérations par tour
#define FPS 16      //pour le rafraîchissement

void initialiserSDL() ;
void quitterSDL() ;
void pause() ;

int main(int argc, char *argv[])
{
initialiserSDL() ;
initialiserEcran() ;
initialiserPixel() ;

Uint8 continuer=1;

do
{
//On effectuera quatre opérations ici

updateEcran() ;
SDL_Delay(FPS) ; //une pause de 16 ms
}while(continuer==1);

pause();

return EXIT_SUCCESS;
}

void initialiserSDL()
{
atexit(quitterSDL) ;

if(SDL_Init(SDL_INIT_VIDEO)==-1)
{
fprintf(stderr,"Erreur lors de l'initialisation de la SDL %s",SDL_GetError());
exit(EXIT_FAILURE);
}

}

void quitterSDL()
{
SDL_FreeSurface(carre[0]) ;
SDL_FreeSurface(carre[1]) ;
SDL_Quit() ;
}

void pause()
{

Uint8 continuer=1 ;

do
{
SDL_WaitEvent(&event) ;

switch(event.type)
{
case SDL_QUIT:
continuer=0;
break;
case SDL_KEYDOWN:
continuer=0;
break;
default: break ;
}
}while(continuer==1);

}

Le choix de 250 Hz et 60 FPS est subjectif. Le problème est que les caractéristiques de la Chip 8 sont très méconnues, mais pour beaucoup d'autres consoles, vous trouverez facilement la fréquence exacte de cadencement du CPU. Et 250 Hz, c'est relativement facile à simuler.

IV - Lecture de l'opcode

Pour connaître l'action à effectuer, il faut lire dans la mémoire. Mais le problème est qu'elle est en octets (8 bits) et que les opcodes sont, eux, de 16 bits.

Par exemple, pour l'opcode 8XY0 − qui, je le rappelle, est en hexadécimal − on a : 4 bits pour le « 0 » + 4 bits pour le « X » + 4 bits pour le « Y » + 4 bits pour le « 0 ». Ce qui nous donne 4 × 4 = 16 bits.

Si on effectue opcode = cpu.memoire[cpu.pc], il nous manquerait 8 bits. Il faut alors récupérer 16 bits dans la mémoire, à savoir cpu.memoire[cpu.pc] et cpu.memoire[cpu.pc+1].

Maintenant, il faut juste trouver un moyen pour les mettre « côte à côte » pour ne former qu'un unique nombre de 16 bits.

IV-A - Les décalages (bit shift)

Les décalages vont nous faciliter la vie. Il suffit de décaler vers la gauche cpu.memoire[cpu.pc] de 8 bits et de faire la somme avec cpu.memoire[cpu.pc+1]. On aura ainsi un opcode de 16 bits.

Donc, on aura en définitive opcode=cpu.memoire[cpu.pc]<<8+cpu.memoire[cpu.pc+1].

La fonction pour effectuer ce calcul sera introduite dans cpu.h et cpu.c.

Uint16 recupererOpcode() ; //prototype dans cpu.h

Uint16 recupererOpcode()   //dans cpu.c
{
return (cpu.memoire[cpu.pc]<<8)+cpu.memoire[cpu.pc+1];
}

On vient de passer une étape, mais il reste encore deux ou trois détails.

V - Identification de l'instruction

Après avoir récupéré notre opcode, il ne reste plus qu'à l'interpréter. Par interpréter, il faut comprendre effectuer l'opération qui lui est associée.

Jetons un coup d'œil à nos opcodes (qui sont en hexadécimal) : 0NNN, 00E0, 00EE, 1NNN, 2NNN, 3XNN, 4XNN, 5XY0, 6XNN, 7XNN, 8XY0, 8XY1, 8XY2, 8XY3, 8XY4, 8XY5, etc. (ces valeurs proviennent du tableau de la partie Quelle machine émuler ?).

Tous les X, Y et N sont supposés inconnus. Pour connaître l'action à exécuter, il faut donc trouver un moyen d'identifier chaque opcode en ne tenant pas compte de ces valeurs. Pour ce faire, nous allons utiliser les opérations bit à bit combinées à une grosse amélioration de Pouet_forever.

V-A - Notre table de correspondance

Nous avons 35 opérations à effectuer. Pour chacune d'elles, j'ai donc associé un nombre unique compris entre 0 et 34.

Ensuite, suivant le nombre obtenu, on effectuera l'opération souhaitée en utilisant un bloc switch.

V-B - Trouver le masque et l'identifiant de l'opcode

Prenons l'opcode 0x8XY2 comme exemple. Pour l'identifier, on doit vérifier que les 4 bits de poids fort donnent 8 et les 4 bits de poids faible donnent 2.

Pour ce faire, on peut effectuer l'opération 0x8XY2 & 0xF00F qui nous donne 8002. À chaque fois que l'on effectue opcode_quelconque & 0xF00F et qu'on trouve 8002, il s'agit donc de 8XY2. Ingénieux, n'est-ce pas ? (Honte à moi, grand copieur de Pouet_Forever.) L'opcode 8XY2 a donc pour masque 0xF00F et pour identifiant 0x8002.

Pour tous les autres opcodes, le principe reste le même. Exemples :

Opcode

Masque

Identifiant

00E0

FFFF

00E0

1NNN

F000

1000

8XY3

F00F

8003

FX15

F0FF

F015

Je stocke le tout dans une structure que j'ai appelée JUMP.

//Dans cpu.h
#define NBROPCODE 35

typedef struct
{
Uint16 masque [NBROPCODE];   //la Chip 8 peut effectuer 35 opérations, chaque opération possédant son masque
Uint16 id[NBROPCODE];   //idem, chaque opération possède son propre identifiant

}JUMP;

JUMP jp;

void initialiserJump () ;

//Dans cpu.c

void initialiserJump ()
{

jp.masque[0]= 0x0000; jp.id[0]=0x0FFF;          /* 0NNN */
jp.masque[1]= 0xFFFF; jp.id[1]=0x00E0;          /* 00E0 */
jp.masque[2]= 0xFFFF; jp.id[2]=0x00EE;          /* 00EE */
jp.masque[3]= 0xF000; jp.id[3]=0x1000;          /* 1NNN */
jp.masque[4]= 0xF000; jp.id[4]=0x2000;          /* 2NNN */
jp.masque[5]= 0xF000; jp.id[5]=0x3000;          /* 3XNN */
jp.masque[6]= 0xF000; jp.id[6]=0x4000;          /* 4XNN */
jp.masque[7]= 0xF00F; jp.id[7]=0x5000;          /* 5XY0 */
jp.masque[8]= 0xF000; jp.id[8]=0x6000;          /* 6XNN */
jp.masque[9]= 0xF000; jp.id[9]=0x7000;          /* 7XNN */
jp.masque[10]= 0xF00F; jp.id[10]=0x8000;          /* 8XY0 */
jp.masque[11]= 0xF00F; jp.id[11]=0x8001;          /* 8XY1 */
jp.masque[12]= 0xF00F; jp.id[12]=0x8002;          /* 8XY2 */
jp.masque[13]= 0xF00F; jp.id[13]=0x8003;          /* BXY3 */
jp.masque[14]= 0xF00F; jp.id[14]=0x8004;          /* 8XY4 */
jp.masque[15]= 0xF00F; jp.id[15]=0x8005;          /* 8XY5 */
jp.masque[16]= 0xF00F; jp.id[16]=0x8006;          /* 8XY6 */
jp.masque[17]= 0xF00F; jp.id[17]=0x8007;          /* 8XY7 */
jp.masque[18]= 0xF00F; jp.id[18]=0x800E;          /* 8XYE */
jp.masque[19]= 0xF00F; jp.id[19]=0x9000;          /* 9XY0 */
jp.masque[20]= 0xF000; jp.id[20]=0xA000;          /* ANNN */
jp.masque[21]= 0xF000; jp.id[21]=0xB000;          /* BNNN */
jp.masque[22]= 0xF000; jp.id[22]=0xC000;          /* CXNN */
jp.masque[23]= 0xF000; jp.id[23]=0xD000;          /* DXYN */
jp.masque[24]= 0xF0FF; jp.id[24]=0xE09E;          /* EX9E */
jp.masque[25]= 0xF0FF; jp.id[25]=0xE0A1;          /* EXA1 */
jp.masque[26]= 0xF0FF; jp.id[26]=0xF007;          /* FX07 */
jp.masque[27]= 0xF0FF; jp.id[27]=0xF00A;          /* FX0A */
jp.masque[28]= 0xF0FF; jp.id[28]=0xF015;          /* FX15 */
jp.masque[29]= 0xF0FF; jp.id[29]=0xF018;          /* FX18 */
jp.masque[30]= 0xF0FF; jp.id[30]=0xF01E;          /* FX1E */
jp.masque[31]= 0xF0FF; jp.id[31]=0xF029;          /* FX29 */
jp.masque[32]= 0xF0FF; jp.id[32]=0xF033;          /* FX33 */
jp.masque[33]= 0xF0FF; jp.id[33]=0xF055;          /* FX55 */
jp.masque[34]= 0xF0FF; jp.id[34]=0xF065;          /* FX65 */

}

V-C - Trouver l'opcode à interpréter

Le plus difficile est fait, il ne reste plus qu'à implémenter un algorithme nous permettant de retrouver le nombre associé à un opcode. Pour chaque opcode, il faut récupérer son identifiant en appliquant un & avec le masque et le comparer avec ceux de notre structure JUMP. Un exemple vaut mieux que mille discours.

Prenons le nombre 0x8475. Grâce à notre structure JUMP, nous devons être en mesure de retrouver 15, qui est le nombre associé aux opcodes 0x8XY5.

Pour cela, il faut parcourir la structure JUMP pour trouver à quel indice i la condition 0x8475 & jp.masque[i]== jp.id[i] est vraie.

Pour ce cas-ci, i vaut 15, on a donc 0x8475 & jp.masque[15] == jp.id[15], soit 0x8475 & 0xF00F == 0x8005, ce qui est vrai. Pour toutes les autres valeurs de i, cette condition sera toujours fausse. Vérifiez par vous-mêmes pour voir.

Voici le code de cet algorithme :

//Dans cpu.h
Uint8 recupererAction(Uint16) ;

//Dans cpu.c

Uint8 recupererAction(Uint16 opcode)
{
Uint8 action;
Uint16 resultat;

for(action=0;action<NBROPCODE;action++)
{
resultat= (jp.masque[action]&opcode);  /* On récupère les bits concernés par le test, l'identifiant de l'opcode */

if(resultat == jp.id[action]) /* On a trouvé l'action à effectuer */
break; /* Plus la peine de continuer la boucle car la condition n'est vraie qu'une seule fois*/
}

return action;  //on renvoie l'indice de l'action à effectuer
}

Cas spécial : si vous regardez le masque et l'identifiant de l'opcode 0NNN, vous verrez que opcode &0x0000 − qui est toujours égal à 0x0000 − est toujours différent de 0xFFFF. En gros, on ne pourra jamais retrouver action = 0. Comme je l'avais dit dans la partie de présentation de la Chip 8, « 0NNN appelle le programme de la RCA 1802 à l'adresse NNN. », cela ne nous intéresse donc pas.

À présent, pour simuler une instruction, il suffit de placer notre bloc switch.

//Dans cpu.h

void interpreterOpcode(Uint16) ;

//Dans cpu.c

void interpreterOpcode(Uint16 opcode)
{
Uint8 b4;

b4= recupererAction(opcode);   //permet de connaître l'action à effectuer

switch(b4)
{
case 0:{
//Cet opcode n'est pas implémenté
break;
}
case 1:{
//00E0 : efface l'écran
break;
}

case 2:{//00EE : revient du saut

break;
}
case 3:{ //1NNN : effectue un saut à l'adresse 1NNN
break;
}
case 4:{
//2NNN : appelle le sous-programme en NNN, mais on revient ensuite
break;
}

// etc. jusqu'à 34

Il n'y a aucune condition à poser sur X, Y, N, NN et NNN, ces valeurs seront utilisées pour réaliser l'instruction souhaitée.

Par exemple, pour l'opcode 0x8XY2, on aura :

8XY2

Définit VX à VX AND VY.

Le code pour le réaliser est le suivant : il faut récupérer X et Y et effectuer V[X]=V[X]&V[Y];.

Pour récupérer les valeurs de X, Y, NN et NNN, il faut prendre les 12 bits de poids faible et les associer si l'opération en a besoin.

//Ce code sera placé dans la fonction interpreterOpcode
Uint8 b3,b2,b1;

b3=(opcode&(0x0F00))>>8;  //on prend les 4 bits, b3 représente X
b2=(opcode&(0x00F0))>>4;  //idem, b2 représente Y
b1=(opcode&(0x000F));     //on prend les 4 bits de poids faible

/*
Pour obtenir NNN par exemple, il faut faire (b3<<8) + (b2<<4) + (b1)

*/

Passons maintenant au clou du spectacle : le graphique.

VI - Retour sur le graphique

Pour dessiner à l'écran, la Chip 8 dispose d'un unique opcode (une seule instruction permet de dessiner à l'écran).

DXYN

Dessine un sprite aux coordonnées (VX, VY).
Le sprite a une largeur de 8 pixels et une hauteur de pixels N.
Chaque rangée de 8 pixels est lue comme codée en binaire à partir de l'emplacement mémoire.
I ne change pas de valeur après l'exécution de cette instruction.

Les dessins sont établis à l'écran uniquement par l'intermédiaire de sprites, qui font 8 pixels de large et avec une hauteur qui peut varier de 1 à 15 pixels. Les sprites sont codés en binaire. Pour une valeur de 1, le pixel correspondant est allumé et pour une valeur 0, aucune opération n'est effectuée. Si un pixel d'un sprite est dessiné sur un pixel de l'écran déjà allumé, alors les deux pixels sont éteints. Le registre de retenue (VF) est mis à 1 à cet effet.

Comme vous le voyez sur l'image, chaque sprite peut être considéré comme un tableau à deux dimensions. Pour parcourir tout le sprite, il faudra donc deux boucles imbriquées.

Pour le codage des lignes, on récupère les valeurs dans la mémoire en commençant à l'adresse I. Si par exemple, on doit dessiner un sprite en (0,0) avec une hauteur de 3 et le codage memoire[I]=11010101, memoire[I+1]=00111100 et memoire[I+2]=11100011 //ces nombres sont en binaire, nous devrons obtenir :

Ensuite, si l'on souhaite dessiner un autre sprite en (0,0) avec une hauteur de 2 et le codage memoire[I]=01110101, memoire[I+1]=01110000, nous devrons obtenir :

Pour réaliser tout cela, il faut donc récupérer la couleur du pixel à dessiner, la comparer avec son ancienne valeur et agir en conséquence. On gardera en tête que pour « 0 », la couleur désirée est le noir, et pour « 1 », le blanc.

Voici le code C/SDL qui permet de réaliser tout ce bazar.

void dessinerEcran(Uint8 b1,Uint8 b2, Uint8 b3)
{
Uint8 x=0,y=0,k=0,codage=0,j=0,decalage=0;
cpu.V[0xF]=0;

for(k=0;k<b1;k++)
{
codage=cpu.memoire[cpu.I+k];//on récupère le codage de la ligne à dessiner

y=(cpu.V[b2]+k)%L;//on calcule l'ordonnée de la ligne à dessiner, on ne doit pas dépasser L

for(j=0,decalage=7;j<8;j++,decalage--)
{
x=(cpu.V[b3]+j)%l; //on calcule l'abscisse, on ne doit pas dépasser l

if(((codage)&(0x1<<decalage))!=0)//on récupère le bit correspondant
{   //si c'est blanc
if( pixel[x][y].couleur==BLANC)//le pixel était blanc
{
pixel[x][y].couleur=NOIR; //on l'éteint
cpu.V[0xF]=1; //il y a donc collusion

}
else //sinon
{
pixel[x][y].couleur=BLANC;//on l'allume
}

}

}
}

}

Ligne importante

(codage)&(0x1<<decalage)

Tout d'abord, il faut savoir que 0x1=00000001 en binaire sur 8 bits. L'instruction 0x1<<decalage permet de placer le « 1 » à l'endroit correspondant au codage de notre pixel.

Si par exemple, nous voulons dessiner le troisième pixel de la ligne, j vaut 2 (l'indice commence par 0) et decalage vaut 5. Donc 0x1<<decalage=00100000 en binaire sur 8 bits. Le « 1 » se place au troisième rang (il faut compter en partant de la gauche).

Donc, pour récupérer le bit correspondant à notre pixel, il suffit d'appliquer le and ou & et le tour est joué.

Je vous donne en prime tout le bloc switch de notre émulateur.

void interpreter(Uint16 opcode)
{
Uint8 b4,b3,b2,b1;

b3=(opcode&(0x0F00))>>8;  //on prend les 4 bits représentant X
b2=(opcode&(0x00F0))>>4;  //idem pour Y
b1=(opcode&(0x000F));     //les 4 bits de poids faible

b4= recupererAction(opcode);

switch(b4)
{
case 0:{
//Cet opcode n'est pas implémenté.
break;
}
case 1:{
//00E0 efface l'écran.
break;
}

case 2:{//00EE revient du saut.

break;
}
case 3:{ //1NNN effectue un saut à l'adresse 1NNN.

break;
}
case 4:{
//2NNN appelle le sous-programme en NNN, mais on revient ensuite.

break;
}
case 5:{//3XNN saute l'instruction suivante si VX est égal à NN.

break;
}
case 6:{//4XNN saute l'instruction suivante si VX et NN ne sont pas égaux.

break;
}
case 7:{
//5XY0 saute l'instruction suivante si VX et VY sont égaux.

break;
}

case 8:{
//6XNN définit VX à NN.

break;
}
case 9:{
//7XNN ajoute NN à VX.

break;
}
case 10:{
//8XY0 définit VX à la valeur de VY.

break;
}
case 11:{
//8XY1 définit VX à VX OR VY.

break;
}
case 12:{
//8XY2 définit VX à VX AND VY.

break;
}
case 13:{
//8XY3 définit VX à VX XOR VY.

break;
}
case 14:{
//8XY4 ajoute VY à VX. VF est mis à 1 quand il y a un dépassement de mémoire (carry), et à 0 quand il n'y en pas.

break;
}
case 15:{
//8XY5 VY est soustraite de VX. VF est mis à 0 quand il y a un emprunt, et à 1 quand il n'y a en pas.

break;
}
case 16:{

//8XY6 décale (shift) VX à droite de 1 bit. VF est fixé à la valeur du bit de poids faible de VX avant le décalage.

break;
}
case 17:{
//8XY7 VX = VY - VX. VF est mis à 0 quand il y a un emprunt et à 1 quand il n'y en a pas.
break;
}
case 18:{
//8XYE décale (shift) VX à gauche de 1 bit. VF est fixé à la valeur du bit de poids fort de VX avant le décalage.

break;
}

case 19:{

//9XY0 saute l'instruction suivante si VX et VY ne sont pas égaux.

break;
}
case 20:{
//ANNN affecte NNN à I.

break;
}
case 21:{
//BNNN passe à l'adresse NNN + V0.

break;

}
case 22:{

//CXNN définit VX à un nombre aléatoire inférieur à NN.

break;

}

case 23:{
//DXYN dessine un sprite aux coordonnées (VX, VY).

dessinerEcran(b1,b2,b3);

break;

}
case 24:{
//EX9E saute l'instruction suivante si la clé stockée dans VX est pressée.

break;
}
case 25:{
//EXA1 saute l'instruction suivante si la clé stockée dans VX n'est pas pressée.

break;
}

case 26:{
//FX07 définit VX à la valeur de la temporisation.

break;
}
case 27:{
//FX0A attend l'appui sur une touche et la stocke ensuite dans VX.

break;
}

case 28:{
//FX15 définit la temporisation à VX.

break;
}
case 29:{
//FX18 définit la minuterie sonore à VX.

break;
}
case 30:{
//FX1E ajoute à VX I. VF est mis à 1 quand il y a overflow (I+VX>0xFFF), et à 0 si tel n'est pas le cas.

break;
}

case 31:{
//FX29 définit I à l'emplacement du caractère stocké dans VX. Les caractères 0-F (en hexadécimal) sont représentés par une police 4x5.

break;
}

case 32:{
//FX33 stocke dans la mémoire le code décimal représentant VX (dans I, I+1, I+2).

break;
}
case 33:{

//FX55 stocke V0 à VX en mémoire à partir de l'adresse I.

break;
}
case 34:{
//FX65 remplit V0 à VX avec les valeurs de la mémoire à partir de l'adresse I.

break;
}

default: {//si ça arrive, il y un truc qui cloche

break;
}

}
cpu.pc+=2; //on passe au prochain opcode

}

Je vous fais remarquer le pc+=2 à la fin du bloc switch. Après avoir exécuté l'opcode, il faut passer au suivant en incrémentant pc de 2 et non pas de 1. En effet, dans la fonction de récupération de l'opcode, on prend memoire[pc]etmemoire[pc+1]

Le gros du travail vient d'être effectué. Il ne nous reste plus qu'à remplir les cases vides de notre switch avec les instructions qu'il faut, puis le tour est joué.

Navigation

Tutoriel précédent : la base

Sommaire

Tutoriel suivant : exemples avec quelques instructions