Création d'une machine d'arcade d'émulation. Partie 1

image

L'écriture d'un émulateur de machine d'arcade est un excellent projet éducatif, et dans ce tutoriel, nous examinerons très en détail l'ensemble du processus de développement. Vous voulez vraiment mettre la main sur le processeur? La création d'un émulateur est alors la meilleure façon de l'apprendre.

Vous aurez besoin de connaissances en C, ainsi que de connaissances en assembleur. Si vous ne connaissez pas le langage d'assemblage, l'écriture d'un émulateur est la meilleure façon de l'apprendre. Vous devrez également maîtriser les mathématiques hexadécimales (également appelées base 16 ou simplement «hex»). Je vais parler de ce sujet.

J'ai décidé de choisir un émulateur pour la machine Space Invaders, qui utilise le processeur 8080. Ce jeu et ce processeur sont très populaires, car sur Internet, vous pouvez trouver beaucoup d'informations à leur sujet. Vous en aurez besoin pour terminer le projet.

Le code source complet du tutoriel est téléchargé sur github . Si vous n'avez pas maîtrisé le travail avec git, alors sur la page github il y a un bouton "Télécharger ZIP" qui vous permet de télécharger l'archive avec tout le code.

Introduction aux nombres binaires et hexadécimaux


En mathématiques "ordinaires", le système de nombres décimaux est utilisé. Chaque chiffre du nombre peut avoir une valeur de zéro à neuf, et lorsque nous dépassons 9, nous ajoutons un au nombre dans le chiffre suivant et recommençons à partir de zéro. Tout cela est assez simple et direct, et vous n'y avez probablement jamais pensé.

Vous avez peut-être su ou entendu que les ordinateurs fonctionnent avec des données binaires. Les geeks informatiques appellent les mathématiques décimales base-10 et les appels binaires base-2. En notation binaire, chaque chiffre d'un nombre ne peut avoir que deux valeurs, zéro ou une. En code binaire, le nombre est le suivant: 0, 1, 10, 11, 100, 101, 110, 111, 1000. Ce ne sont pas des nombres décimaux, vous ne pouvez donc pas les appeler «zéro, un, dix, onze, cent, cent un». Ils sont prononcés comme "zéro, un, un zéro, un un, un zéro zéro", etc. Je lis rarement les nombres binaires à haute voix, mais si nécessaire, vous devez indiquer clairement le système numérique utilisé. Dix, onze et cent n'ont aucune signification en notation binaire.

En notation décimale, un nombre a les chiffres suivants: unités, dizaines, centaines, milliers, dizaines de milliers, etc. Dans le système binaire, les chiffres suivants: unités, deux, quatre, huit, etc. En informatique, la valeur de chaque bit binaire est appelée bit. 8 bits constituent un octet.

En termes binaires, une chaîne de nombres devient rapidement très longue. Pour représenter le nombre décimal 20 000 en termes binaires, 16 chiffres sont requis: 0b100111000100000. Pour résoudre ce problème, il est pratique d'utiliser un système numérique hexadécimal, également appelé base 16 (ou hex). En base 16, chaque chiffre contient 16 valeurs. Pour les valeurs de zéro à neuf, les mêmes caractères sont utilisés que dans la base-10, mais pour les 6 valeurs restantes, les substitutions sont utilisées sous la forme des 6 premières lettres de l'alphabet, de A à F.

Le compte dans le système hexadécimal est effectué comme suit: 0 1 2 3 4 5 6 7 8 9 ABCDEF 10 11 12, etc. Dans l'hexadécimal, les dizaines, les centaines et ainsi de suite n'ont pas la même signification qu'en décimal, donc les gens prononcent les nombres séparément. Par exemple, $ A57 est prononcé à haute voix par "A-cinq-sept". Pour plus de clarté, vous pouvez également ajouter un hex, par exemple, "A-cinq-sept-hex". Dans le système numérique hexadécimal, l'équivalent du nombre décimal 20 000 est 4E20 $ - une forme beaucoup plus compacte que 16 bits du système binaire.

Je pense que le système hexadécimal a été choisi en raison d'une conversion très naturelle du binaire en hexadécimal et vice versa. Chaque chiffre hexadécimal correspond à 4 bits (4 bits) d'un nombre binaire similaire. 2 chiffres hexadécimaux constituent un octet (8 bits). Un seul chiffre hexadécimal peut être appelé grignotage, et certaines personnes l'écrivent même par y comme «nybble».

Chaque chiffre hexadécimal est composé de 4 chiffres binaires
HexUn57
Binaire101001010111

Lors de l'écriture du code C, on pense que le nombre est décimal (base-10), sauf indication contraire. Pour indiquer au compilateur C que le nombre est binaire, nous ajoutons le nombre zéro et la lettre b en minuscules, comme ceci: 0b1101101 . Le nombre hexadécimal peut être écrit en code C en ajoutant au début de zéro et x en minuscule: 0xA57 . Certaines langues d'assemblage utilisent le signe dollar $: $A57 pour indiquer un nombre hexadécimal.

Si vous y réfléchissez, la connexion entre les nombres binaires, hexadécimaux et décimaux est assez évidente, mais pour le premier ingénieur, qui y avait pensé avant l'invention de l'ordinateur, cela aurait dû devenir un moment de réflexion.

Vous avez compris tout ça? Super.

Une brève introduction au processeur


Si vous le savez déjà, vous pouvez ignorer la section en toute sécurité.

Une unité centrale de traitement (CPU) est une machine conçue pour exécuter des programmes. Les blocs fondamentaux de la CPU sont les registres et les instructions. En tant que développeur de logiciels, vous pouvez traiter ces registres comme des variables. Dans notre processeur 8080, entre autres registres, il existe des registres 8 bits appelés A, B, C, D et E. Ces registres peuvent être interprétés comme le code C suivant:

 unsigned char A, B, C, D, E; 

Tous les processeurs ont également un compteur de programmes (Program Counter, PC). Vous pouvez le prendre comme pointeur.

 unsigned char* pc; 

Pour un CPU, un programme est une séquence de nombres hexadécimaux. Chaque instruction en langage assembleur en 8080 correspond à 1 à 3 octets dans le programme. Afin de savoir quelle commande correspond à quel numéro, le manuel du processeur (ou toute autre information sur le processeur 8080 sur Internet) est utile.

Les noms des commandes (instructions) sont souvent des mnémoniques des opérations effectuées par ces commandes. Le mnémonique pour le chargement en 8080 est MOV (déplacer) et ADD est utilisé pour effectuer l'addition.

Des exemples


La valeur de mémoire actuelle indiquée par le compteur d'instructions est 0x79. Ceci est conforme à l'instruction MOV A,C processeur 8080. Ce code d'assemblage en code C ressemble à A=C; .

Si, à la place, la valeur dans le PC était 0x80, alors le processeur exécuterait ADD B En C, cela correspond à la chaîne A = A + B; .

Une liste complète des instructions du processeur 8080 peut être trouvée ici . Pour implémenter notre émulateur, nous utiliserons ces informations.

Timings


Dans le CPU, l'exécution de chaque instruction nécessite un certain temps (timing), mesuré en cycles. Dans les processeurs modernes, ces informations peuvent être difficiles à obtenir, car les délais dépendent de nombreux aspects différents. Mais dans les processeurs plus anciens comme le 8080, les délais sont constants et ces informations sont souvent fournies par le fabricant du processeur. Par exemple, une instruction de transfert d'un registre vers un registre MOV prend 1 cycle.

Les informations de synchronisation sont utiles pour écrire du code efficace dans le processeur. Un programmeur peut chercher à éviter les instructions qui prennent plusieurs cycles à compléter.

Plus important pour nous, nous utiliserons des informations de synchronisation pour émuler le processeur. Pour que le jeu fonctionne de la même manière que sur l'original, les instructions doivent être exécutées à la bonne vitesse. Certains émulateurs y mettent beaucoup d'efforts, mais lorsque nous y arriverons, nous devrons décider quelle précision nous voulons obtenir.

Opérations logiques


Avant de clore le sujet des nombres binaires et hexadécimaux, nous devrions parler des opérations logiques. Vous êtes probablement déjà habitué à utiliser la logique dans votre code, par exemple, dans des constructions comme if ((conditionA) and (conditionB)) . Dans les programmes qui fonctionnent directement avec du matériel, vous devez souvent manipuler des bits de nombres individuels.

ET opération


Voici tous les résultats possibles de l'opération ET (ET) (table de vérité) entre deux nombres à un seul bit.

xyRésultat
000
010
100
111

Le résultat de AND n'est égal à l'unité que lorsque les deux valeurs sont égales à l'unité. Lorsque nous combinons deux nombres avec l'opération ET, ET pour chaque bit d'un nombre est ET avec le bit correspondant de l'autre nombre. Le résultat est stocké dans ce bit du numéro de destination. Il vaut probablement mieux regarder un exemple:

binairehex
source x011010116 G $
source y11010010$ D2
x ET y0100001042 $ US

En C, l'opération AND logique est une simple esperluette "&".

Opération OU (OU)


L'opération OR fonctionne de manière similaire. La seule différence est que le résultat sera égal à un si au moins une des valeurs de x ou y est égale à un.

xyRésultat
000
011
101
111

binairehex
source x011010116 G $
source y11010010$ D2
x OU y11111011$ Fb

En C, une opération OU logique est indiquée par une barre verticale "|".

Pourquoi est-ce important?


Dans de nombreux processeurs plus anciens, et en particulier dans les machines d'arcade, le jeu nécessite souvent de travailler avec un seul bit du nombre. Il existe souvent un code similaire:

  /*  1:     */ char *buttons_ptr = (char *)0x2043; char buttons = *buttons_ptr; if (buttons & 0x4) HandleLeftButton(); /*  2:  LED-    */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led | 0x40; //,  LED   6 *LED_pointer = led; /*  3:   LED- */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led & 0xBF; //  6 *LED_pointer = led; 

Dans l'exemple 1, l'adresse 2043 $ allouée en mémoire est l'adresse des boutons du panneau de commande. Ce code lit et répond au bouton enfoncé. (Bien sûr, dans Space Invaders, ce code sera en langage assembleur!)

Dans l'exemple 2, le jeu veut allumer un indicateur LED, qui se trouve dans le bit 6 de l'adresse de 2089 $ allouée en mémoire. Le code doit lire la valeur existante, modifier un seul bit et la réécrire.

Dans l'exemple 3, vous devez désactiver l'indicateur de l'exemple 2, donc le code devrait réinitialiser le bit 6 de l'adresse 2089 $. Cela peut être fait en effectuant l'opération ET pour l'octet de commande d'indicateur avec une valeur pour laquelle seul le bit 6 est nul. Nous n'affecterons donc que 6, en laissant les bits restants inchangés.

Ceci est généralement appelé un «masque». En C, un masque est généralement écrit à l'aide de l'opérateur NOT, désigné par un tilde ("~"). Par conséquent, au lieu d'écrire 0xBF , j'écris simplement ~0x40 et j'obtiens le même nombre, mais sans mettre beaucoup d'efforts.

Introduction au langage d'assemblage


Si vous lisez ce didacticiel, vous connaissez probablement la programmation informatique, par exemple en Java ou en Python. Ces langages vous permettent de faire beaucoup de travail en seulement quelques lignes de code. Le code est considéré comme intelligemment écrit s'il fait autant de travail que possible sur le moins de lignes possible, peut-être même en utilisant les fonctionnalités des bibliothèques intégrées. Ces langues sont appelées «langues de haut niveau».

En langage d'assemblage, en revanche, il n'y a pas de fonctions de sauvetage intégrées, et de nombreuses lignes de code simples peuvent être nécessaires pour effectuer des tâches simples. Le langage d'assemblage est considéré comme un langage de bas niveau. Dans ce document, vous devez vous habituer à penser dans le style de "quelle séquence spécifique d'étapes doit être prise pour terminer cette tâche?"

La chose la plus importante que vous devez savoir sur le langage assembleur est que chaque ligne est traduite en une seule commande de processeur.

Considérez une telle construction à partir du langage C:

 int a = b + 100; 

En langage assembleur, cette tâche devra être effectuée dans l'ordre suivant:

  1. Charger l'adresse de la variable B dans le registre 1
  2. Charger le contenu de cette adresse mémoire dans le registre 2
  3. Ajouter une valeur directe 0x64 pour enregistrer 2
  4. Charger l'adresse de la variable A dans le registre 1
  5. Écrire le contenu du registre 2 à l'adresse stockée dans le registre 1

Dans le code, cela ressemblera à ceci:

  lea a1, #$1000 ;   a lea a2, #$1008 ;   b move.l d0,(a2) add.l d0, #$64 mov (a1),d0 

Il convient de noter les éléments suivants:

  • Dans un langage de haut niveau, le compilateur décide où placer les variables en mémoire. Lorsque vous écrivez du code dans l'assembleur, vous êtes vous-même responsable de chaque adresse mémoire que vous utiliserez.
  • Dans la plupart des langages d'assemblage, les crochets signifient «mémoire à cette adresse».
  • Dans la plupart des langages d'assembleur, # désigne un nombre algébrique, également appelé valeur immédiate. Par exemple, à la ligne 1 de l'exemple ci-dessus, le code écrit en fait la valeur # 0x1000 pour enregistrer a1. Si le code ressemblait à move.l a1, ($1000) , alors a1 recevrait le contenu de la mémoire à l'adresse 0x1000.
  • Chaque processeur possède son propre langage d'assemblage, et le portage de code d'un processeur à un autre peut être difficile.
  • Ce n'est pas un vrai langage d'assemblage de processeur, je l'ai proposé comme exemple.

Cependant, il y a une chose en commun entre les programmeurs intelligents de haut niveau et les assistants d'assemblage. Les programmeurs assembleurs considèrent comme un honneur de terminer la tâche aussi efficacement que possible et de minimiser le nombre d'instructions utilisées. Le code des machines d'arcade est généralement hautement optimisé et tous les jus sont extraits de chaque octet et cycle supplémentaire.

Piles


Parlons un peu plus du langage d'assemblage. Dans tout programme informatique assez complexe dans les sous-programmes d'assembleur sont utilisés. La plupart des processeurs ont une structure appelée pile.

Imaginez une pile sous la forme d'une pile. Si nous devons enregistrer un numéro, nous le mettons en haut de la pile. Lorsque nous devons le ramener, nous le prenons du haut de la pile. Les programmeurs assembleurs appellent popping le nombre sur la pile «push», et le faire sortir est appelé «pop».

Disons que mon programme doit appeler un sous-programme. Je peux écrire un code similaire:

  0x1000 move.l (sp), d0 ;  d0   0x1004 add.l sp, #4 ;     0x1008 move.l (sp), d1 ;  d1   0x1010 add.l sp, #4 ;  .. 0x1014 move.l (sp), a0 0x1018 add.l sp, #4 0x101C move.l (sp), a1 0x1020 add.l sp, #4 0x1024 move.l (sp), #0x1030 ;   0x1028 add.l sp, #4 0x102C jmp #0x2040 ;   - 0x2040 0x1030 move.l a1, (sp) ;    0x1034 sub.l sp, #4 ;    0x1038 move.l a0, (sp) ;    0x103c sub.l sp, #4  .. 

Le code montré ci-dessus pousse les valeurs d0, d1, a0 et a1 sur la pile. La plupart des processeurs utilisent un pointeur de pile. Il peut s'agir d'un registre normal, par convention utilisé comme pointeur de pile, ou d'un registre spécial avec des fonctions pour certaines instructions.

Sur les processeurs de la série 68K, le pointeur de pile n'est déterminé que par convention, sinon c'est un registre régulier. Dans notre processeur 8080, le registre SP est un registre spécial. Il a des commandes PUSH et POP qui écrivent et sautent de la pile en une seule commande.

Dans notre projet d'émulateur, nous n'écrirons pas de code à partir de zéro. Mais si vous avez besoin d'analyser des programmes en langage assembleur, il est bon d'apprendre à reconnaître de telles constructions.

Langues de haut niveau


Lors de l'écriture d'un programme dans un langage de haut niveau, toutes les opérations de sauvegarde et de restauration des registres sont effectuées à chaque appel de fonction. Nous n'y pensons pas, car le compilateur les traite. Les appels de fonction dans un langage de haut niveau peuvent prendre beaucoup de mémoire et de temps processeur.

Avez-vous déjà vu un programme se bloquer lors de l'appel d'un sous-programme dans une boucle infinie? Cela peut se produire car chaque appel de fonction a poussé les valeurs de registre sur la pile et à un moment donné, la mémoire a été épuisée. (Si la pile devient trop grande, cela s'appelle débordement de pile ou débordement de pile.)

Vous avez peut-être entendu parler des fonctions en ligne. Ils évitent de sauvegarder et de restaurer les registres en incluant le code de routine dans la fonction appelante. Le code devient plus grand, mais grâce à cela, plusieurs commandes et opérations de lecture / écriture en mémoire sont enregistrées.

Conventions d'appel


Lorsque vous écrivez un programme assembleur qui n'appelle que votre code, vous pouvez décider vous-même comment les routines communiqueront entre elles. Par exemple, comment retourner à la fonction appelante une fois la routine terminée? Une façon consiste à écrire l'adresse de retour dans un registre spécifique. L'autre consiste à placer l'adresse de retour au-dessus de la pile. Très souvent, la décision dépend de ce que le processeur prend en charge. Le 8080 a une commande CALL qui pousse l'adresse de retour d'une fonction sur la pile. Vous utiliserez peut-être cette commande 8080 pour implémenter des appels de sous-programme.

Une décision de plus doit être prise. La conservation du registre relève-t-elle de la fonction appelante ou du sous-programme? Dans l'exemple ci-dessus, les registres sont stockés par la fonction appelante. Mais que faire si nous avons 32 registres? Sauvegarder et restaurer 32 registres lorsqu'une routine n'en utilise qu'une petite fraction sera une perte de temps.

Le compromis peut être une approche mixte. Supposons que nous choisissions une politique dans laquelle une routine peut utiliser les registres r10-r32 sans enregistrer leur contenu, mais ne peut pas détruire r1-r9. Dans une situation similaire, la fonction appelante connaît les éléments suivants:

  • Au retour d'une fonction, le contenu de r1-r9 restera inchangé
  • Je ne peux pas dépendre du contenu du r10-r32
  • Si j'ai besoin d'une valeur dans r10-r32 après avoir appelé un sous-programme, alors avant de l'appeler, je dois l'enregistrer quelque part

De même, chaque routine connaît les éléments suivants:

  • Je peux détruire r10-r32
  • Si je veux utiliser r1-r9, je dois enregistrer le contenu et le restaurer avant de retourner à la fonction qui m'a appelé

Abi


Sur la plupart des plates-formes modernes, ces politiques sont créées par des ingénieurs et publiées dans des documents appelés ABI (Application Binary Interface). Grâce à ce document, les créateurs de compilateurs savent comment compiler du code pouvant appeler du code compilé par d'autres compilateurs. Si vous souhaitez écrire du code assembleur qui peut fonctionner dans un tel environnement, vous devez connaître ABI et écrire du code conformément à celui-ci.

Connaître ABI permet également de déboguer du code lorsque vous n'avez pas accès à la source. L'ABI définit l'emplacement des paramètres des fonctions, donc lorsque vous envisagez un sous-programme, vous pouvez examiner ces adresses pour comprendre ce qui est transmis aux fonctions.

Retour à l'émulateur


La plupart des codes d'assemblage écrits à la main, en particulier pour les processeurs et les jeux d'arcade plus anciens, ne suivent pas ABI. Les programmes sont assemblés et peuvent ne pas avoir beaucoup de routines. Chaque routine enregistre et restaure les registres uniquement en cas d'urgence.

Si vous voulez comprendre ce que fait le programme, il serait bien de commencer par marquer les adresses ciblées pour les commandes CALL.

Source: https://habr.com/ru/post/fr418635/


All Articles