Licence CC BY-NC-SA

Simulation des instructions

Dernière mise à jour :

La Chip 8 ne sait exécuter que 35 opérations. 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.

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.

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.

Tant que la machine fonctionne
    Regarder ce qu’il faut faire
    Le faire
Fin Tant que

La fréquence d’exécution des instructions reste assez méconnue. Ici, nous allons utiliser une fréquence de 250 hertz. Cette fréquence nous donne une période de 4 millisecondes (la période est en effet l’inverse de la fréquence) ce qui signifie que toutes les quatre millisecondes, nous devrons effectuer une opération. Cela équivaut à effectuer quatre opérations toutes les seize millisecondes.

Le nombre d’images par seconde (FPS) est lui aussi méconnu. Nous prendrons donc une valeur courante, à savoir 60. Nous afficherons donc 60 images par seconde soit une image environ toutes les 16 millisecondes.

Récapitulatif

Finalement, toutes les seize millisecondes (nous allons mettre ce 16 dans une constante FPS), il nous faudra faire quatre opérations et afficher une image. Donc, notre fonction update_screen sera appelé après l’exécution de quatre opérations. Nous aurons donc une fonction emulate de ce genre.

void emulate(struct s_emulator *emulator)
{
    while(!emulator->in.quit)
    {
        update_event(&emulator->in);
        if(emulator->in.resize)
            resize_screen(&emulator->screen); 

        /* On fait quatre opérations */
        update_screen(&emulator->screen);
        SDL_Delay(FPS);
    }
}

Notons que nous aurions pu gérer le temps d’une manière plus fine (par exemple en gardant en mémoire la dernière fois qu’une opération a été faite et la dernière fois que l’écran a été mis à jour).

Le choix de 250 Hz et de 60 FPS est assez objectif (comme nous l’avons dit, ces caractéristiques sont inconnues), mais a été fait intelligemment. En effet, la fréquence de 250 Hz est facile à simuler ici (elle consiste simplement à faire quatre opérations avant l’affichage).

Savoir quelles action effectuer

Récupération de l'opcode

Lorsqu’une action doit être effectuée, nous devons lire la mémoire à l’adresse du pointer counter pour regarder en quoi elle consiste. Néanmoins, les cases de la mémoire sont sur un octet (8 bits) alors que les opcodes sont sur deux octets (16 bits). Ainsi, lorsque nous regardons la mémoire, nous ne regardons qu’un nombre sur 8 bits.

L'opcode que nous voulons récupérer est finalement la concaténation du nombre à l’adresse du pointer counter et du nombre présent à la case suivante. Voyons ceci sur un exemple.

cpu->memory[cpu->pc]     vaut 1011 0110 = B5. 
cpu->memory[cpu->pc + 1] vaut 1101 1010 = DA.
Nous voulons l'opcode 10110110 11011010 = B5DA.

Pour « concaténer » les deux cases, nous allons décaler la première case de 8 bits vers la gauche, puis lui ajouter la seconde case. Regardons ce qu’il se passe avec la configuration mémoire de notre exemple précédent.

cpu->memory[cpu->pc] << 8 vaut 10110110 00000000.
cpu->memory[cpu->pc + 1] vaut           11011010.
L'addition des deux vaut bien  10110110 11011010.

Nous avons bien ce que nous voulons. Nous écrivons alors cette fonction get_opcode.

Uint16 get_opcode(struct s_cpu *cpu)
{
    return (cpu->memory[cpu->pc] << 8) + cpu->memory[cpu->pc + 1];
}

Identifier l'opcode

Une fois que nous avons l'opcode, il nous faut comprendre à quelle opération il est associée En fait, le problème est que des variables X, Y et N, supposés inconnues, peuvent apparaître dans nos opcodes. Par exemple, d’après la description de l'opcode 1NNN, il permet de « sauter à l’adresse 1NNN ».

Cela signifie qu’il nous faut une manière d’identifier chaque opcode sans tenir compte des variables qui y apparaissent (et il nous faudra également un moyen de récupérer ces variables).

Pour vérifier qu’un nombre correspond à un opcode, nous allons utiliser un ET logique. L’idée est de mettre à zéro les bits correspondants à une variable et de garder les autres bits du nombre. Regardons ce qu’il faut faire sur deux exemples.

Avec l’opcode 1NNN

On veut garder les quatre premiers bits et supprimer les 16 derniers. On fait donc un ET logique avec le masque F000. Si le nombre que l’on teste est de la bonne forme, le ET renverra 1000, sinon il renverra autre chose.

1A20 & F000 = 1000, OK.
2A20 & F000 = 2000, NOK

Avec l’opcode FX33

On veut supprimer le deuxième groupe de quatre bits et garder le reste. On fait donc un ET logique avec le masque F0FF. Si le nombre que l’on teste est de la bonne forme, le ET renverra F033, sinon il renverra autre chose.

F233 & F0FF = F033, OK.
F243 & F0FF = F043, NOK.
D230 & F0FF = D030, NOK.

Une table de correspondance

Ces deux exemples nous permettent de comprendre le principe principal. À chaque opcode est associé un masque et un identifiant. Le ET logique d’un nombre et d’un masque est égal à l’identifiant si et seulement si l'opcode est le bon. Nous allons créer une structure qui contient pour chaque opcode le masque et l’identifiant correspondant. Un champ jump_table de ce type est également rajouté à notre CPU.

#define OPCODE_NB 35

struct s_jump
{
    Uint16 mask [OPCODE_NB];
    Uint16 id[OPCODE_NB];
};

Nous pouvons maintenant écrire une fonction get_action. Elle renverra l’indice de l'opcode correspondant dans notre tableau s_jump. Il suffit de tester tous les opcodes jusqu’à trouver le bon (il y a 35 actions, nous pouvons renvoyer un Uint8.

Uint8 get_action(struct s_jump *table, Uint16 opcode)
{
    for(size_t i = 0; i < OPCODE_NB; i++)
    {
        if((table->mask[i] & opcode) == table->id[i])
            return i;
    }
    fprintf(stderr, "Bad action, unknwown opcode %d.\n", opcode);
    return 0;
}

Initialisation de la table

En fait, nous avons maintenant tout fait… Sauf initialiser notre table, c’est-à-dire trouver pour chaque opcode le masque et l’identifiant qu’il faut (et les mettre dans le champ jump_table de notre CPU.

Ce n’est pas très compliqué de trouver les masques et les identifiants. Pour un opcode ABCD, le masque est constitué de F pour les groupes de 4 bits à garder et de 0 pour ceux à supprimer (les variables), et l’identifiant est constitué de 0 pour les groupes de 4 bits à supprimer et du même nombre que celui de l'opcode pour ceux à garder. Par exemple, FX33 donne le masque F0FF et l’identifiant F033. Ceci nous permet d’écrire cette fonction d’initialisation (qui est appelée dans la fonction d’initialisation du CPU).

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

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

Si nous regardons le masque et l’identifiant de l’opcode 0NNN, nous voyons que opcode & 0x0000 est toujours différent de 0xFFFF. L’égalité ne sera donc jamais vérifiée. Cependant, comme nous l’avons dit en présentant la Chip 8, « 0NNN appelle le programme de la RCA 1802 à l’adresse NNN » ce qui ne nous intéresse pas. On n’a donc pas à s’occuper de cet opcode. En fait, on renvoie même 0 (qui correspond à cet opcode) en cas d’erreur.

Simulation des instructions

Une fonction d’interprétation

Maintenant, pour simuler une instruction, il nous faut juste récupérer la valeur de l’instruction à effectuer et suivant cette valeur faire ce qu’il faut. Comme nous l’avons déjà dit, certaines instructions nécessitent les variables de l'opcode. Pour récupérer ces valeurs, nous utilison des décalages de bits. Ceci mène à cette fonction.

void interpret(struct s_emulator *emulator)
{
    Uint16 opcode = get_opcode(&emulator->cpu);
    Uint8 b3,b2,b1;
    b3 = (opcode & (0x0F00)) >> 8;  /* les 4 bits de poids fort, b3 représente X */
    b2 = (opcode & (0x00F0)) >> 4;  /* idem, b2 représente Y */
    b1 = (opcode & (0x000F));       /* les 4 bits de poids faible */
    Uint8 action = get_action(&emulator->cpu.jump_table, opcode);
    switch(action)
    {
        case 0: /* opcode non implémenté. */
        break;
        case 1: /* opcode 00E0, effacer l'écran. */
        break;
        case 2: /* opcode 00EE, revenir du saut. */
        break;
        /* etc. jusqu'à 34 */
    default:
    break;
    }
    emulator->cpu.pc += 2;
}

Pour passer à l’instruction suivante, on incrémente cpu->pc de 2 et pas de 1. En effet, l'opcode étant sur deux octets, l’instruction suivante est à l’adresse cpu->pc + 2.

Il nous faut maintenant implémenter les différentes instructions. Heureusement, la plupart sont assez simples. Nous allons implémenter chaque opcode dans une fonction de prototype suivant.

void opcode_00E0(struct s_emulator *emulator, Uint8 b1, Uint8 b2, Uint8 b3);

On a donc 34 fonctions à écrire.

En fait, en ayant une fonction pour chaque opcode avec chaque fonction qui a le même prototype, on peut s’amuser à utiliser un tableau de pointeurs de fonctions opcode_functions dans lequel on mettra les différentes fonctions. Notre swicth à 35 case se simplifie alors de la manière suivante.

if(action < OPCODE_NB)
    (*opcode_functions[action])(emulator, b1, b2, b3);

Ici, il faudra cependant définir une fonction qui ne fait rien pour l'opcode associé à 0 (0NNN), ou encore vérifier que action est supérieur à 1, et d’autres moyens sont encore possibles.

Belle manière d’utiliser des pointeurs de fonctions, non ?

Un exemple avec une instruction graphique

Commençons par implémenter les instructions graphiques. En fait, la Chip 8 ne dispose que d’un seul opcode pour dessiner à l’écran. Il s’agit de l’opcode DXYN. Voyons sa description.

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. Il ne change pas de valeur après l’exécution de cette instruction.

L'opcode DXYN.

Voyons également une partie de la description du graphique de la Chip 8.

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.

Représentation d'un sprite.
Représentation d’un sprite.

Comme nous pouvons le voir sur l’image, chaque sprite peut être considéré comme un tableau à deux dimensions. Pour parcourir tout le sprite, il nous 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. Par exemple, si nous devons 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, nous ferions cela.

dessin_sprite1
dessin_sprite1

Ensuite, si nous devons 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 ceci.

dessin_sprite2
dessin_sprite2

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 (cette convention nous va très bien, vu que nous avons traité nos pixels comme des booléens).

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

    for(size_t i = 0; i < b1; i++)
    {
        Uint8 codage = cpu->memory[cpu->I + i];
        Uint8 y = cpu->V[b2] + i;

        for(size_t j = 0; j < 8; j++)
        {
            Uint8 x = cpu->V[b3] + j;
            /* Si le bit vaut 1 et que les coordonnées ne sont pas en dehors de l'écran. */
            if(y < PIXEL_BY_HEIGHT && x < PIXEL_BY_WIDTH && ((codage << j) & 0b10000000))
            {
                if((screen->pixels[x][y] == WHITE))
                    cpu->V[0xF] = 1;
                screen->pixels[x][y] = !screen->pixels[x][y]; /* Change la couleur du bit. */
            }
        }
    }
}

Ici, pour récupérer le bit correspondant, nous prenons le codage de la ligne (sur 8 bits), nous le décalons de j de manière à avoir le bit correspondant en tant que bit de poids fort, puis nous récupérons ce bit avec un ET binaire. Nous profitons allègrement du fait que nous représentons nos pixels comme des booléens pour changer la couleur du bit.


Le gros du travail vient d’être effectué. Il ne nous reste plus qu’à coder les opcodes restants, puis le tour est joué. Courage, on entrevoit le bout du tunnel !