Licence CC BY-NC-SA

Implémentation des instructions

Dernière mise à jour :

Maintenant que le terrain a été bien préparé, il ne nous reste plus qu’à simuler les instructions de calcul une par une, c’est-à-dire à écrire une fonction pour chaque opcode. Nous pourrons ensuite tester notre émulateur.

Jouer avec la mémoire

Jouer avec la mémoire

La plupart des instructions consistent à manipuler la mémoire et les différents pointeurs suivant certaines conditions. Par exemple, changer la valeur du program counter permet de sauter à un autre endroit du programme (ce qui peut être par exemple un appel de fonction). Ici, nous allons en coder quelques-unes.

1NNN

Effectue un saut à l’adresse NNN.

L’opcode 1NNN.

Il nous faut modifier la valeur du program counter. Puisqu’on l’incrémente de 2 à la fin de notre boucle d’émulation, nous allons lui donner la valeur NNN - 2, NNN étant récupéré grâce à des décalages de bits.


void opcode_1NNN(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    emulator->cpu.pc = (b3 << 8) + (b2 << 4) + b1 - 2;
}

2NNN

Exécute le sous-programme à l’adresse NNN.

L’opcode 2NNN.

Cette instruction est proche de la 1NNN. Cependant, ici il nous faut retenir l’adresse actuelle du program counter pour pouvoir y retourner après avoir exécuté le sous-programme à l’adresse NNN. On retient cette adresse dans le tableau jump, et on n’oublie pas d’incrémenter le nombre de sauts effectués (s’il est inférieur au nombre maximum de sauts possibles).

void opcode_2NNN(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu; 
    cpu->jump[cpu->jump_nb] = cpu->pc;
    if(cpu->jump_nb < MAX_JUMP)
        cpu->jump_nb++;

    cpu->pc = (b3 << 8) + (b2 << 4) + b1 - 2;
}

Il ne faudra pas oublier de déclarer la constante MAX_JUMP qui vaut 15.

00EE

Retourne à partir d’un sous-programme.

L’opcode 00EE.

Cette instruction va avec la précédente, on donne à pc son ancienne valeur qu’on prend dans le tableau jump. Bien sûr, on vérifie avant cela que le nombre de sauts effectués est bien positif et on n’oublie pas de décrémenter ce nombre si c’est le cas.

void opcode_00EE(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    if(cpu->jump_nb > 0)
    {
        cpu->jump_nb--;
        cpu->pc = cpu->jump[cpu->jump_nb];
    }
}

3XNN

Saute l’instruction suivante si VX est égal à NN.

L’opcode 3XNN.

Sauter une instruction revient à ajouter 2 au program counter.

void opcode_3XNN(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    if(cpu->V[b3] == (b2 << 4) + b1)
        cpu->pc += 2;
}

Faire des calculs

Cette section est l’endroit où le bit de carry va être utile. Il est par exemple utilisé lorsque le résultat d’un calcul est trop grand. Par exemple, si nous additionnons deux nombres de huis bits et que le résultat ne tient pas sur huit bits, ce résultat pourra être mis à un (en gros il indique qu’il y a une retenue).

x     =  11110101
y     =  00010010
x + y = 100000111 ne tient pas sur 8 bits.

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.

L’opcode 8XY4.

Si VX + VY ne peut pas tenir sur 8 bits (la taille de VX), alors il y a dépassement et le registre est mis à 1, sinon il est mis à 0. Cela revient à donner à ce registre la valeur de la comparaison VY > 0xFF - VY puisque 0XFF est la plus grande valeur qui tient sur 8 bits.

void opcode_8XY4(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    cpu->V[0xF] = cpu->V[b2] > 0xFF - cpu->V[b3];
    cpu->V[b3] += cpu->V[b2];
}

8XY7

VX = VY - VX. VF est mis à 0 quand il y a un emprunt et à 1 quand il n’y en a pas.

L’opcode 8XY7.

Il y a un emprunt si le résultat de l’opération est négatif, c’est-à-dire si VX > VY. On met donc VF à 1 si VX <= VY.

void opcode_8XY7(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    cpu->V[0xF] = cpu->V[b3] <= cpu->V[b2];
    cpu->V[b3] = cpu->V[b2] - cpu->V[b3];
}

CXNN

Définit VX à NN AND R avec R un nombre tiré au hasard (entre 0 et 255).

L’opcode CXNN.

On écrit une fonction pour tirer un nombre entre deux bornes et on l’utilise pour ce qu’on veut. Il ne faudra pas oublier d’utiliser srand au début de notre main. Notons que cet opcode revient en gros à tirer un nombre entre 0 et NN, même si ce n’est pas exactement ce qui est demandé. Ici, nous allons respecter la spécification exacte.

double random_double(void)
{
    return rand() / (RAND_MAX + 1.);
}

int rand_int(int min, int max)
{
    return random_double() * (max - min + 1) + min;
}

void opcode_CXNN(struct s_emulator->emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    emulator->cpu.V[b3] = rand_int(0, 0xFF) & ((b2 << 4) + b1);
}

FX33

Stocke dans la mémoire le code décimal représentant VX (dans I, I+1, I+2).

FX33

Le code décimal, communément appelé BCD, est la représentation d’un nombre en base 10. Pour cette instruction, on doit stocker les centaines dans memory[I], les dizaines dans memory[I+1] les dizaines et les unités dans memory[I+2]. Le nombre ne peut avoir de milliers ou plus puisqu’il est sur 8 bits.

void opcode_FX33(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    Uint8 nb = cpu->V[b3];
    cpu->memory[cpu->I + 2] = nb % 10;
    cpu->memory[cpu->I + 1] = (nb/10) % 10;
    cpu->memory[cpu->I] = nb / 100;
}

Dans la suite, nous nous consacrerons aux opcodes dédiés aux entrées-sorties de la console (les opcodes 00E0, FX29, EXA1, EX9E et FXQA. Avant de faire cela, il vaut mieux coder tous les opcodes restants (ce n’est pas très compliqué, nous pouvons nous inspirer de ceux déjà codés).

Les sorties de la console

La gestion de l’écran

Après avoir codé les opcodes d’affichage, nous pourrons enfin tester quelques petites roms.

00E0

Efface l’écran

L’opcode 00E0.

Pour effacer l’écran, il suffit de mettre tous les pixels en noir, ce que nous faisons avec notre méthode clear_screen.

void opcode_00E0(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    clear_screen(&emulator->screen);
}

Le mode de dessin intégré

Nous allons maintenant nous occuper d’un opcode qui permet de dessiner les chiffres et les lettres de A à F.

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.

L’opcode FX29.

Cette description seule est difficilement compréhensible, mais un petit tour sur le Web, nous apprend que la Chip 8 possède en mémoire les caractères 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E et F. Ils sont codés en binaire et ont tous une largeur de 4 pixels et une longueur de 5 pixels. Voici à quoi ressemblent les trois premiers caractères, suivi de leurs codages en binaire puis en hexadécimal.

Caractère    Binaire      Hexa

****        1111 0000     0xF0
*  *        1001 0000     0x90
*  *        1001 0000     0x90
*  *        1001 0000     0x90
****        1111 0000     0xF0

  *         0010 0000     0x20
 **         0110 0000     0x60 
  *         0010 0000     0x20
  *         0010 0000     0x20
 ***        0111 0000     0x70

****        1111 0000     0xF0
   *        0001 0000     0x10
****        1111 0000     0xF0
*           1000 0000     0x8
****        1111 0000     0xF0
Codage des caractères.

Tous ces caractères seront stockés dans memory à partir de l’adresse 0. Rappelons-nous, les 512 premiers octets sont inutilisés, ils trouvent leur utilité. Chaque chiffre occupera cinq cases en mémoire, comme le montre le tableau. Le caractère 0 occupera donc les cases de 0 à 4, le caractère 1 celles de 5 à 9, etc.

Écrivons une fonction pour initialiser la mémoire avec ces données. Nous l’utiliserons dans notre fonction d’initialisation du CPU.

void initialize_memory_fonts(Uint8 *memory)
{
    memory[0]=0xF0;memory[1]=0x90;memory[2]=0x90;memory[3]=0x90; memory[4]=0xF0; // O
    memory[5]=0x20;memory[6]=0x60;memory[7]=0x20;memory[8]=0x20;memory[9]=0x70; // 1
    memory[10]=0xF0;memory[11]=0x10;memory[12]=0xF0;memory[13]=0x80; memory[14]=0xF0; // 2
    memory[15]=0xF0;memory[16]=0x10;memory[17]=0xF0;memory[18]=0x10;memory[19]=0xF0; // 3
    memory[20]=0x90;memory[21]=0x90;memory[22]=0xF0;memory[23]=0x10;memory[24]=0x10; // 4
    memory[25]=0xF0;memory[26]=0x80;memory[27]=0xF0;memory[28]=0x10;memory[29]=0xF0; // 5
    memory[30]=0xF0;memory[31]=0x80;memory[32]=0xF0;memory[33]=0x90;memory[34]=0xF0; // 6
    memory[35]=0xF0;memory[36]=0x10;memory[37]=0x20;memory[38]=0x40;memory[39]=0x40; // 7
    memory[40]=0xF0;memory[41]=0x90;memory[42]=0xF0;memory[43]=0x90;memory[44]=0xF0; // 8
    memory[45]=0xF0;memory[46]=0x90;memory[47]=0xF0;memory[48]=0x10;memory[49]=0xF0; // 9
    memory[50]=0xF0;memory[51]=0x90;memory[52]=0xF0;memory[53]=0x90;memory[54]=0x90; // A
    memory[55]=0xE0;memory[56]=0x90;memory[57]=0xE0;memory[58]=0x90;memory[59]=0xE0; // B
    memory[60]=0xF0;memory[61]=0x80;memory[62]=0x80;memory[63]=0x80;memory[64]=0xF0; // C
    memory[65]=0xE0;memory[66]=0x90;memory[67]=0x90;memory[68]=0x90;memory[69]=0xE0; // D
    memory[70]=0xF0;memory[71]=0x80;memory[72]=0xF0;memory[73]=0x80;memory[74]=0xF0; // E
    memory[75]=0xF0;memory[76]=0x80;memory[77]=0xF0;memory[78]=0x80;memory[79]=0x80; // F
}

void initialize_cpu(struct s_cpu *cpu)
{
    memset(cpu, 0, sizeof(*cpu));
    initialize_memory_fonts(cpu->memory);
    initialize_jump_table(&cpu->jump_table);
    cpu->pc = START_ADDRESS;
}

Nous comprenons alors ce que veut dire « Définit I à l’emplacement du caractère stocké dans VX ». Si VX vaut 0 alors on met 0 (l’adresse du caractère 0) dans I, s’il vaut 1, on met 5 (l’adresse du caractère 1) dans I, etc. En clair, on met 5 * VX dans I.

void opcode_FX29(struct s_emulator *emulator Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    cpu->I = 5 * cpu->V[b3];
}

Le son

Cette minuterie est utilisée pour les effets sonores. Lorsque sa valeur est différente de zéro, un signal sonore est émis.

La minuterie sonore.

Le système sonore de la Chip 8 est relativement simple. Il nous suffit de jouer un bip sonore lorsque le compteur de son est différent de 0 (et de le mettre ensuite à 0 si c’est le cas). Nous allons utiliser SDL_mixer pour cela. Nous faisons son initialisation et sa fermeture dans les fonctions initialize_SDL et destroy_SDL et nous rajoutons un champ sound (de type Mix_Music *) à notre structure s_emulator.

Finalement, nous modifions la boucle principale de la fonction emulate pour jouer le son si la condition est respectée.

if(cpu->sound_counter != 0)
{
    Mix_PlayMusic(sound, 1);
    cpu->sound_counter = 0;
}

Tester l’émulateur

Nous avons maintenant de quoi tester notre émulateur. Nous pouvons trouver des roms sur ce dépôt Github. Nous n’avons pas encore codé les opcodes liés au clavier, mais nous pouvons tester des roms qui affichent des choses. Nous pouvons tester la plupart des programmes, (Chip8 Picture par exemple) et même certaines démos comme Maze. Voici un main que nous pouvons utiliser.

int main(int argc, char *argv[])
{
    int status = -1;
    struct s_emulator = {0};
    srand(time(NULL));
    if(0 == initialize_emulator(&emulator))
    {
        if(0 == load_rom(&emulator.cpu, "Maze.ch8"))
        {
            status = 0;
            emulate(&emulator);
        }
        destroy_emulator(&emulator);
    }
    return status;
}
Chip8.
Chip8.
Maze.
Maze.

Notons que BestCoder a écrit une petite rom qui permet de tester un peu le bon fonctionnement de l’émulateur.

Le clavier de la Chip 8

L’entrée est faite avec un clavier qui possède 16 touches allant de 0 à F. Les touches « 8 », « 4 », « 6 » et « 2 » sont généralement utilisées pour l’entrée directionnelle.

1

2

3

C

4

5

6

D

7

8

9

E

A

0

B

F

Clavier de la Chip 8.

Pour simuler ce clavier, nous allons utiliser le pavé numérique et la touche de direction droite. Ce choix est purement subjectif. Nous pouvons bien sûr choisir n’importe quelles autres touches.

7

8

3

*

4

5

6

-

1

2

9

+

0

.

Entrée

Correspondance avec le clavier de l’ordinateur.

Traitement des entrées

Nous allons utiliser un tableau de booléens key dans notre structure s_cpu et un tableau key_table dans notre structure s_emulator. Gérer les entrées consistera à regarder les valeurs de la variable in pour mettre à jour le tableau key du CPU.

Le tableau key_table permet de faire la correspondance entre les touches du clavier de l’ordinateur et celui de la Chip 8 (ainsi, à la case 0, nous aurons SDL_SCANCODE_KP_0, à la case 1, nous aurons SDL_SCANCODE_KP_7, etc.). Avec ce tableau, nous pouvons facilement changer les touches de notre émulateur (il suffit de changer une case du tableau). Nous pourrions par exemple permettre à l’utilisateur de l’émulateur de changer ses touches en cours de jeu, ou même les charger à partir d’un fichier de paramètres.

Pour commencer, nous écrivons une fonction d’initialisation de key_table, fonction appelée lors de l’initialisation de l’émulateur.

void initialize_key_table(int *table)
{
    table[0] = SDL_SCANCODE_KP_0;
    table[1] = SDL_SCANCODE_KP_7;
    table[2] = SDL_SCANCODE_KP_8;
    table[3] = SDL_SCANCODE_KP_9;
    table[4] = SDL_SCANCODE_KP_4;
    table[5] = SDL_SCANCODE_KP_5;
    table[6] = SDL_SCANCODE_KP_6;
    table[7] = SDL_SCANCODE_KP_1;
    table[8] = SDL_SCANCODE_KP_2;
    table[9] = SDL_SCANCODE_KP_3;
    table[10] = SDL_SCANCODE_RIGHT;
    table[11] = SDL_SCANCODE_KP_PERIOD;
    table[12] = SDL_SCANCODE_KP_MULTIPLY;
    table[13] = SDL_SCANCODE_KP_MINUS;
    table[14] = SDL_SCANCODE_KP_PLUS;
    table[15] = SDL_SCANCODE_KP_ENTER;
}

Notre fonction pour gérer les évènements est alors facile à écrire. Nous l’utilisons juste après avoir mis à jour notre variable in, dans la boucle principale de l’émulateur.

void manage_input(struct s_emulator *emulator)
{
    struct s_cpu *cpu = &emulator->cpu;
    struct s_input *in = &emulator->in;
    for(size_t i = 0; i < 16; i++)
        cpu->key[i] = in->key[emulator->key_table[i]];
}

On peut maintenant s’occuper des opcodes qui nous restent.

Les opcodes du clavier

EXA1 et EX9E

Saute l’instruction suivante si la clé stockée dans VX n’est pas pressée.

L’opcode EXA1.

Saute l’instruction suivante si la clé stockée dans VX est pressée.

L’opcode EX9E.

Ces deux instructions sont relativement simples. La variable VX contiendra un nombre entre 0 et 15 (pour nos 16 touches). Il faudra vérifier la valeur de key[VX] et agir en conséquence.

void opcode_EX9E(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    if(cpu->key[cpu->V[b3]])
        cpu->pc += 2;
}

void opcode_EXA1(struct s_emulator *emulator Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    if(!cpu->key[cpu->V[b3]])
        cpu->pc += 2;
}

FX0A

L’appui sur une touche est attendu, puis la valeur correspondante est stockée dans VX.

L’opcode FX0A.

Là, il nous faut attendre l’appui d’une touche. Pour ce faire, nous allons rester dans la fonction de l'opcode et avoir une boucle dont on ne sortira que si on appuie sur une touche (ou que l’on quitte l’émulateur).

void opcode_FX0A(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3)
{
    struct s_cpu *cpu = &emulator->cpu;
    struct s_input *in = &emulator->in;
    while(!in->quit)
    {
        SDL_bool old_key[16];
        memcpy(old_key, cpu->key, sizeof(old_key));
        update_event(in);
        if(in->resize)
            resize_screen(&emulator->screen);
        manage_input(emulator);
        for(size_t i = 0; i < 16; i++)
            /* On retourne si une touche qui n'avait pas été pressée vient de l'être. */
            if(cpu->key[i] && !old_key[i]) 
                return;
    }
}

Maintenant, nous pouvons tester notre émulateur sur des jeux, par exemple sur le casse-brique Breakout.

Breakout.
Breakout.

Ça fait du bien d’obtenir ces résultats. Nous pouvons dire que l’émulation est vraiment magique. Avec des décalages par-ci, des XOR par-là, on arrive à faire des choses incroyables ! Finalement, tout est bien qui finit bien. Notre émulateur donne des résultats plus que satisfaisants.