Création d'une machine d'arcade d'émulation. 3e partie

image

Parties un et deux .

Émulateur de processeur 8080


Coque d'émulateur


Vous devriez maintenant avoir toutes les connaissances nécessaires pour commencer à créer un émulateur de processeur 8080.

Je vais essayer de rendre mon code aussi clair que possible, chaque opcode est implémenté séparément. Lorsque vous vous sentirez à l'aise avec celui-ci, vous voudrez peut-être le réécrire pour optimiser les performances ou réutiliser le code.

Pour commencer, je vais créer une structure mémoire qui contiendra des champs pour tout ce qui me paraissait nécessaire lors de l'écriture d'un désassembleur. Il y aura également une place pour un tampon de mémoire, qui sera de la RAM.

typedef struct ConditionCodes { uint8_t z:1; uint8_t s:1; uint8_t p:1; uint8_t cy:1; uint8_t ac:1; uint8_t pad:3; } ConditionCodes; typedef struct State8080 { uint8_t a; uint8_t b; uint8_t c; uint8_t d; uint8_t e; uint8_t h; uint8_t l; uint16_t sp; uint16_t pc; uint8_t *memory; struct ConditionCodes cc; uint8_t int_enable; } State8080; 

Créez maintenant une procédure avec un appel d'erreur qui mettra fin au programme avec une erreur. Cela ressemblera à ceci:

  void UnimplementedInstruction(State8080* state) { // pc    ,     printf ("Error: Unimplemented instruction\n"); exit(1); } int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: UnimplementedInstruction(state); break; case 0x01: UnimplementedInstruction(state); break; case 0x02: UnimplementedInstruction(state); break; case 0x03: UnimplementedInstruction(state); break; case 0x04: UnimplementedInstruction(state); break; /*....*/ case 0xfe: UnimplementedInstruction(state); break; case 0xff: UnimplementedInstruction(state); break; } state->pc+=1; //  } 

Implémentons quelques opcodes.

  void Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: break; //NOP -  ! case 0x01: //LXI B, state->c = opcode[1]; state->b = opcode[2]; state->pc += 2; //   2  break; /*....*/ case 0x41: state->b = state->c; break; //MOV B,C case 0x42: state->b = state->d; break; //MOV B,D case 0x43: state->b = state->e; break; //MOV B,E } state->pc+=1; } 

Voilà. Pour chaque opcode, nous changeons l'état et la mémoire, comme le ferait une commande exécutée sur un vrai 8080.

Le 8080 a environ 7 types, selon la façon dont vous les classifiez:

  • Transfert de données
  • Arithmétique
  • Logique
  • Succursales
  • Pile
  • Entrée-sortie
  • Spécial

Examinons chacun d'eux individuellement.

Groupe arithmétique


Les instructions arithmétiques sont parmi les 256 opcodes du processeur 8080, qui incluent différents types d'addition et de soustraction. La plupart des instructions arithmétiques fonctionnent avec le registre A et enregistrent le résultat dans A. (Le registre A est également appelé accumulateur).

Il est intéressant de noter que ces commandes affectent les codes de condition. Les codes d'état (également appelés drapeaux) sont définis en fonction du résultat de la commande exécutée. Toutes les commandes n'affectent pas les drapeaux et toutes les équipes affectant les drapeaux n'affectent pas tous les drapeaux à la fois.

Drapeaux 8080


Dans un processeur 8080, les drapeaux sont appelés Z, S, P, CY et AC.

  • Z (zéro, zéro) prend la valeur 1 lorsque le résultat est nul
  • S (signe) prend la valeur 1 lorsque le bit 7 (le bit le plus significatif, le bit le plus significatif, MSB) de la commande mathématique est donné
  • P (parité, parité) est défini lorsque le résultat est pair et est réinitialisé lorsqu'il est impair
  • CY (report) prend la valeur 1 lorsque, à la suite de la commande, un transfert ou un emprunt dans un bit d'ordre élevé est effectué
  • AC (Auxillary carry) est principalement utilisé pour les mathématiques BCD (décimal codé binaire). Pour plus de détails, consultez le manuel, dans Space Invaders, cet indicateur n'est pas utilisé.

Les codes d'état sont utilisés dans les commandes de branchement conditionnel, par exemple, JZ exécute le branchement uniquement si l'indicateur Z est défini.

La plupart des instructions ont trois formes: pour les registres, pour les valeurs immédiates et pour la mémoire. Implémentons quelques instructions pour comprendre leurs formulaires et voir à quoi ressemble le travail avec les codes d'état. (Notez que je n'implémente pas l'indicateur de transfert auxiliaire car il n'est pas utilisé. Si je l'ai implémenté, je ne pourrais pas le tester.)

Formulaire d'inscription


Voici un exemple d'implémentation de deux instructions avec un formulaire d'enregistrement; dans le premier, j'ai déployé le code pour en faciliter la compréhension et dans le second, une forme plus compacte qui fait la même chose est présentée.

  case 0x80: //ADD B { //      , //      uint16_t answer = (uint16_t) state->a + (uint16_t) state->b; //  :    , //    , //      if ((answer & 0xff) == 0) state->cc.z = 1; else state->cc.z = 0; //  :   7 , //    , //      if (answer & 0x80) state->cc.s = 1; else state->cc.s = 0; //   if (answer > 0xff) state->cc.cy = 1; else state->cc.cy = 0; //    state->cc.p = Parity( answer & 0xff); state->a = answer & 0xff; } //  ADD     case 0x81: //ADD C { uint16_t answer = (uint16_t) state->a + (uint16_t) state->c; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

J'émule des commandes mathématiques 8 bits avec un nombre 16 bits. Cela facilite le suivi des cas où les calculs génèrent un report.

Formulaire pour les valeurs immédiates


La forme des valeurs immédiates est presque la même, sauf que l'octet après la commande est la source de l'ajout. Puisque «opcode» est un pointeur vers la commande en cours en mémoire, opcode [1] sera immédiatement l'octet suivant.

  case 0xC6: //ADI  { uint16_t answer = (uint16_t) state->a + (uint16_t) opcode[1]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Forme pour la mémoire


Dans le formulaire pour la mémoire, un octet sera ajouté auquel l'adresse stockée dans une paire de registres HL indique.

  case 0x86: //ADD M { uint16_t offset = (state->h<<8) | (state->l); uint16_t answer = (uint16_t) state->a + state->memory[offset]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Remarques


Les instructions arithmétiques restantes sont implémentées de manière similaire. Ajouts:

  • Dans différentes versions avec carry (ADC, ACI, SBB, SUI), conformément au manuel de référence, nous utilisons des bits de carry dans les calculs.
  • INX et DCX affectent les paires de registres; ces commandes n'affectent pas les indicateurs.
  • DAD est une autre commande d'une paire de registres, elle n'affecte que le drapeau de report
  • INR et DCR n'affectent pas l'indicateur de portage

Groupe de succursales


Après avoir traité les codes d'état, le groupe de succursales deviendra suffisamment clair pour vous. Il existe deux types de branchement: les transitions (JMP) et les appels (CALL). JMP définit simplement le PC à la valeur de la destination de saut. CALL est utilisé pour les routines, il écrit l'adresse de retour dans la pile, puis attribue au PC l'adresse de destination. RET revient de CALL, reçoit l'adresse de la pile et l'écrit sur le PC.

JMP et CALL vont uniquement aux adresses absolues codées en octets après l'opcode.

Jmp


La commande JMP se branche inconditionnellement à l'adresse de destination. Il existe également des commandes de branchement conditionnelles pour tous les codes d'état (sauf pour AC):

  • JNZ et JZ pour zéro
  • JNC et JC pour la migration
  • JPO et JPE pour la parité
  • JP (plus) et JM (moins) pour le signe

Voici une implémentation de certains d'entre eux:

  case 0xc2: //JNZ  if (0 == state->cc.z) state->pc = (opcode[2] << 8) | opcode[1]; else //    state->pc += 2; break; case 0xc3: //JMP  state->pc = (opcode[2] << 8) | opcode[1]; break; 

APPEL et RET


CALL pousse l'adresse de l'instruction sur la pile après l'appel, puis saute à l'adresse de destination. RET reçoit l'adresse de la pile et l'enregistre sur le PC. Des versions conditionnelles de CALL et RET existent pour tous les états.

  • CZ, CNZ, RZ, RNZ pour zéro
  • CNC, CC, RNC, RC pour le transfert
  • CPO, CPE, RPO, RPE pour la parité
  • CP, CM, RP, RM pour signe

  case 0xcd: //CALL  { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; case 0xc9: //RET state->pc = state->memory[state->sp] | (state->memory[state->sp+1] << 8); state->sp += 2; break; 

Remarques


  • La commande PCHL saute inconditionnellement à une adresse dans une paire de registres HL.
  • Je n'ai pas inclus le RST discuté précédemment dans ce groupe. Il écrit l'adresse de retour dans la pile, puis saute à l'adresse prédéfinie au bas de la mémoire.

Groupe logique


Ce groupe effectue des opérations logiques (voir le premier post du tutoriel). De par leur nature, ils sont similaires à un groupe arithmétique dans la mesure où la plupart des opérations fonctionnent avec le registre A (lecteur) et la plupart des opérations affectent les indicateurs. Toutes les opérations sont effectuées sur des valeurs 8 bits, dans ce groupe, il n'y a pas de commandes affectant les paires de registres.

Opérations booléennes


ET, OU, NON (CMP) et "exclusif ou" (XOR) sont appelés opérations booléennes. OU et ET je l'ai expliqué plus tôt. La commande NOT (pour le processeur 8080, elle est appelée CMA ou accumulateur complémentaire) modifie simplement les valeurs de bits - toutes les unités deviennent des zéros et les zéros deviennent des unités.

Je perçois XOR comme un «identificateur de différence». Sa table de vérité ressemble à ceci:

xyRésultat
000
011
101
110

AND, OR et XOR ont une forme pour les registres, la mémoire et les valeurs immédiates. (CMP n'a qu'une commande sensible à la casse). Voici une implémentation d'une paire d'opcodes:

  case 0x2F: //CMA (not) state->a = ~state->a //  ,  CMA     break; case 0xe6: //ANI  { uint8_t x = state->a & opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); state->cc.p = parity(x, 8); state->cc.cy = 0; //  ,  ANI  CY state->a = x; state->pc++; //   } break; 

Commandes de changement cyclique


Ces commandes modifient l'ordre des bits dans les registres. Un décalage vers la droite les déplace d'un bit vers la droite, et un décalage vers la gauche - d'un bit vers la gauche:

(0b00010000) = 0b00001000

(0b00000001) = 0b00000010

Ils semblent être sans valeur, mais en réalité ce n'est pas le cas. Ils peuvent être utilisés pour se multiplier et se diviser par des puissances de deux. Prenons l'exemple du décalage vers la gauche. 0b00000001 est décimal 1, et le déplacer vers la gauche fait 0b00000010 , c'est-à-dire décimal 2. Si nous effectuons un autre décalage vers la gauche, nous obtenons 0b00000100 , soit 4. Un autre décalage vers la gauche, et nous multiplions par 8. Cela fonctionnera avec n'importe quel en chiffres: 5 ( 0b00000101 ) lorsqu'il est décalé vers la gauche donne 10 ( 0b00001010 ). Un autre décalage vers la gauche donne 20 ( 0b00010100 ). Un décalage vers la droite fait de même, mais pour la division.

Le 8080 n'a pas de commande de multiplication, mais il peut être implémenté à l'aide de ces commandes. Si vous comprenez comment procéder, vous recevrez des points bonus. Une fois une telle question m'a été posée lors d'un entretien. (Je l'ai fait, même si cela m'a pris quelques minutes.)

Ces commandes font tourner le lecteur de manière cyclique et n'affectent que le drapeau de transport. Voici quelques commandes:

  case 0x0f: //RRC { uint8_t x = state->a; state->a = ((x & 1) << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; case 0x1f: //RAR { uint8_t x = state->a; state->a = (state->cc.cy << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; 

Comparaison


La tâche de CMP et CPI est uniquement de définir des drapeaux (pour la ramification). Ils le font en soustrayant les drapeaux, mais pas en stockant le résultat.

  • De même: si deux nombres sont égaux, alors le drapeau Z est défini, car leur soustraction l'un de l'autre donne zéro.
  • Supérieur à: si A est supérieur à la valeur comparée, alors l'indicateur CY est effacé (car la soustraction peut être effectuée sans emprunter).
  • Plus petit: si A est inférieur à la valeur comparée, alors l'indicateur CY est défini (car A doit terminer l'emprunt pour terminer la soustraction).

Il existe des versions de ces commandes pour les registres, la mémoire et les valeurs immédiates. L'implémentation est une simple soustraction sans enregistrer le résultat:

  case 0xfe: //CPI  { uint8_t x = state->a - opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); //  ,    p -   state->cc.p = parity(x, 8); state->cc.cy = (state->a < opcode[1]); state->pc++; } break; 

CMC et STC


Ils complètent le groupe logique. Ils sont utilisés pour définir et effacer le drapeau de transport.

Groupe d'entrées-sorties et commandes spéciales


Ces commandes ne peuvent être attribuées à aucune autre catégorie. Je vais les mentionner par souci d'exhaustivité, mais il me semble que nous devrons y revenir lorsque nous commencerons à émuler le matériel de Space Invaders.

  • EI et DI activent ou désactivent la capacité du processeur à gérer les interruptions. J'ai ajouté l'indicateur interrupt_enabled à la structure d'état du processeur et je l'ai défini / réinitialisé à l'aide de ces commandes.
  • Il semble que RIM et SIM soient principalement utilisés pour les E / S série. Si vous êtes intéressé, vous pouvez lire le manuel, mais ces commandes ne sont pas utilisées dans Space Invaders. Je ne les imiterai pas.
  • HLT est un arrêt. Je ne pense pas que nous ayons besoin de l'émuler, mais vous pouvez appeler votre code quit (ou exit (0)) lorsque vous voyez cette commande.
  • IN et OUT sont des commandes que l'équipement du processeur 8080 utilise pour communiquer avec un équipement externe. Pendant que nous les implémentons, mais ils ne feront que sauter leur octet de données. (Plus tard, nous y reviendrons).
  • NOP est «aucune opération». Une des applications de NOP est de contrôler la synchronisation du panneau (il faut quatre cycles CPU pour s'exécuter).

Une autre application de NOP est la modification de code. Disons que nous devons changer le code ROM du jeu. Nous ne pouvons pas simplement supprimer les opcodes inutiles, car nous ne voulons pas modifier toutes les commandes CALL et JMP (elles seront incorrectes si au moins une partie du code se déplace). Avec NOP, nous pouvons nous débarrasser du code. Ajouter du code est beaucoup plus difficile! Vous pouvez l'ajouter en trouvant de l'espace quelque part dans la ROM et en changeant la commande en JMP.

Groupe de pile


Nous avons déjà terminé la mécanique de la plupart des équipes du groupe stack. Si vous avez fait le travail avec moi, ces commandes seront faciles à implémenter.

PUSH et POP


PUSH et POP ne fonctionnent qu'avec des paires de registres. PUSH écrit une paire de registres dans la pile, et POP prend 2 octets du haut de la pile et les écrit dans une paire de registres.

Il existe quatre opcodes pour PUSH et POP, un pour chacune des paires: BC, DE, HL et PSW. PSW est une paire spéciale de registres d'indicateurs de lecteur et de codes d'état. Voici mon implémentation de PUSH et POP pour BC et PSW. Il n'y a pas de commentaires - je ne pense pas qu'il y ait quelque chose de particulièrement délicat ici.

  case 0xc1: //POP B { state->c = state->memory[state->sp]; state->b = state->memory[state->sp+1]; state->sp += 2; } break; case 0xc5: //PUSH B { state->memory[state->sp-1] = state->b; state->memory[state->sp-2] = state->c; state->sp = state->sp - 2; } break; case 0xf1: //POP PSW { state->a = state->memory[state->sp+1]; uint8_t psw = state->memory[state->sp]; state->cc.z = (0x01 == (psw & 0x01)); state->cc.s = (0x02 == (psw & 0x02)); state->cc.p = (0x04 == (psw & 0x04)); state->cc.cy = (0x05 == (psw & 0x08)); state->cc.ac = (0x10 == (psw & 0x10)); state->sp += 2; } break; case 0xf5: //PUSH PSW { state->memory[state->sp-1] = state->a; uint8_t psw = (state->cc.z | state->cc.s << 1 | state->cc.p << 2 | state->cc.cy << 3 | state->cc.ac << 4 ); state->memory[state->sp-2] = psw; state->sp = state->sp - 2; } break; 

SPHL et XTHL


Il y a deux autres équipes dans le groupe de pile - SPHL et XTHL.

  • SPHL déplace HL vers SP (forçant SP à obtenir une nouvelle adresse).
  • XTHL échange ce qui se trouve au sommet de la pile avec ce qui se trouve dans une paire de registres HL. Pourquoi auriez-vous besoin de faire cela? Je ne sais pas.

Un peu plus sur les nombres binaires


Lors de l'écriture d'un programme informatique, l'une des décisions que vous devez prendre est de choisir le type de données utilisées pour les nombres - si vous voulez qu'ils soient négatifs et quelle devrait être leur taille maximale. Pour l'émulateur de CPU, nous avons besoin que le type de données corresponde au type de données du CPU cible.

Signé et non signé


Lorsque nous avons commencé à parler de nombres hexadécimaux, nous les avons considérés comme non signés - c'est-à-dire que chaque chiffre binaire du nombre hexadécimal avait une valeur positive, et chacun était considéré comme une puissance de deux (unités, deux, quatre, etc.).

Nous avons traité la question du stockage informatique des nombres négatifs. Si vous savez que les données en question ont un signe, c'est-à-dire qu'elles peuvent être négatives, alors vous pouvez reconnaître un nombre négatif par le bit le plus significatif du nombre (bit le plus significatif, MSB). Si la taille des données est d'un octet, alors chaque nombre avec une valeur de bit MSB donnée est négatif, et chacun avec un MSB zéro est positif.

La valeur d'un nombre négatif est stockée sous forme de code supplémentaire. Si nous avons un nombre signé, et que le MSB est égal à un, et que nous voulons savoir ce qu'est ce nombre, alors nous pouvons le convertir comme suit: effectuez le "NON" binaire pour les nombres hexadécimaux, puis ajoutez-en un.

Par exemple, pour un nombre hexadécimal 0x80, le bit MSB est défini, c'est-à-dire qu'il est négatif. Le «NON» binaire du nombre 0x80 est 0x7f, ou décimal 127. 127 + 1 = 128. Autrement dit, 0x80 en décimal est -128. Deuxième exemple: 0xC5. Pas (0xC5) = 0x3A = décimal 58 +1 = décimal 59. Autrement dit, 0xC5 est décimal -59.

Ce qui est surprenant dans les nombres avec du code supplémentaire, c'est que nous pouvons effectuer des calculs avec eux comme avec des nombres non signés, et ils fonctionneront toujours. L'ordinateur n'a rien à faire de spécial avec les panneaux. Je vais montrer quelques exemples pour le prouver.

  Exemple 1

      décimal hex binaire    
       -3 0xFD 1111 1101    
    + 10 0x0A +0000 1010    
    ----- -----------    
        7 0x07 1 0000 0111    
                        ^ Ceci est enregistré dans le bit de retenue

    Exemple 2    

      décimal hex binaire    
      -59 0xC5 1100 0101    
    + 33 0x21 +0010 0001    
    ----- -----------    
      -26 0xE6 1110 0110 


Dans l'exemple 1, nous voyons que l'ajout de 10 et de -3 résultats dans 7. Le résultat de l'addition a été transféré, de sorte que l'indicateur C peut être défini. Dans l'exemple 2, le résultat de l'addition était négatif, nous décodons donc ceci: Non (0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Explosion du cerveau!

Si vous le souhaitez, en savoir plus sur le code supplémentaire sur Wikipedia .

Types de données


En C, il existe une relation entre les types de données et le nombre d'octets utilisés pour ce type. En fait, nous ne nous intéressons qu'aux entiers. Les types de données C standard / old school sont char, int et long, ainsi que leurs amis unsigned char, unsigned int et unsigned long. Le problème est que sur différentes plates-formes et dans différents compilateurs, ces types peuvent avoir des tailles différentes.

Par conséquent, il est préférable de sélectionner un type de données pour notre plateforme qui déclare explicitement la taille des données. Si votre plate-forme possède stdint.h, vous pouvez utiliser int8_t, uint8_t, etc.

La taille d'un entier détermine le nombre maximum pouvant y être stocké. Dans le cas d'entiers non signés, vous pouvez stocker des nombres de 0 à 255 en 8 bits. Si vous traduisez en hexadécimal, alors c'est de 0x00 à 0xFF. Étant donné que 0xFF a "tous les bits définis" et qu'il correspond à 255 décimal, il est tout à fait logique que l'intervalle d'un entier non signé à un octet soit 0-255. Les intervalles nous indiquent que toutes les tailles d'entiers fonctionneront exactement de la même manière - les nombres correspondent au nombre obtenu lorsque tous les bits sont définis.

TapezIntervalleHex
8 bits non signé0-2550x0-0xFF
8 bits signé-128-1270x80-0x7F
16 bits non signé0-655350x0-0xFFFF
16 bits signés-32768-327670x8000-0x7FFF
32 bits non signé0-42949672950x0-0xFFFFFFFFFF
Signé 32 bits-2147483648-21474836470x80000000-0x7FFFFFFF

Encore plus intéressant, -1 dans chaque type de données signé est un nombre dont tous les bits sont définis (0xFF pour l'octet signé, 0xFFFF pour le numéro signé 16 bits et 0xFFFFFFFF pour le numéro signé 32 bits). Si les données sont considérées comme non signées, alors pour tous les bits donnés, le nombre maximum possible pour ce type de données est obtenu.

Pour émuler les registres du processeur, nous sélectionnons le type de données correspondant à la taille de ce registre. Il vaut probablement la peine de sélectionner les types non signés par défaut et de les convertir lorsque vous devez les considérer comme signés. Par exemple, nous utilisons le type de données uint8_t pour représenter un registre 8 bits.

Astuce: utilisez un débogueur pour convertir les types de données


Si gdb est installé sur votre plateforme, il est très pratique de l'utiliser pour travailler avec des nombres binaires. Ci-dessous, je vais montrer un exemple - dans la session ci-dessous, les lignes commençant par # sont des commentaires que j'ai ajoutés plus tard.

# /c, gdb
(gdb) print /c 0xFD
$1 = -3 '?'

# /x, gdb hex
# "p" "print"
(gdb) p /c 0xA
$2 = 10 '\n'

# 2 " "
(gdb) p /c 0xC5
$3 = -59 '?'
(gdb) p /c 0xC5+0x21
$4 = -26 '?'

# print , gdb
(gdb) p 0x21
$9 = 33

# , gdb,
# ,
(gdb) p 0xc5
$5 = 197 #
(gdb) p /c 0xc5
$3 = -59 '?' #
(gdb) p 0xfd
$6 = 253

# ( 32- )
(gdb) p /x -3
$7 = 0xfffffffd

# 1
(gdb) print (char) 0xff
$1 = -1 '?'
# 1
(gdb) print (unsigned char) 0xff
$2 = 255 '?'


Lorsque je travaille avec des nombres hexadécimaux, je le fais toujours dans gdb - et cela arrive presque tous les jours. Tellement plus facile que d'ouvrir la calculatrice d'un programmeur avec une interface graphique. Sur les machines Linux (et Mac OS X), pour démarrer une session gdb, ouvrez simplement un terminal et entrez «gdb». Si vous utilisez Xcode sur OS X, après avoir démarré le programme, vous pouvez utiliser la console à l'intérieur de Xcode (celle vers laquelle la sortie printf est sortie). Sous Windows, le débogueur gdb est disponible auprès de Cygwin.

Terminaison de l'émulateur de CPU


Après avoir reçu toutes ces informations, vous êtes prêt pour un long voyage. Vous devez décider comment implémenter l'émulateur - soit créer une émulation 8080 complète, soit implémenter uniquement les commandes nécessaires pour terminer le jeu.

Si vous décidez de faire une émulation complète, vous aurez besoin de quelques outils supplémentaires. J'en parlerai dans la prochaine section.

Une autre façon est d'émuler uniquement les instructions utilisées par le jeu. Nous continuerons à remplir cette énorme construction de commutateur que nous avons créée dans la section Emulator Shell. Nous répéterons le processus suivant jusqu'à ce que nous ayons une seule commande non réalisée:

  1. Lancez l'émulateur avec ROM Space Invaders
  2. L'appel se UnimplementedInstruction()termine si la commande n'est pas prête
  3. Émuler cette instruction
  4. Goto 1

La première chose que j'ai faite en commençant à écrire mon émulateur a été d'ajouter du code à partir de mon désassembleur. J'ai donc pu sortir une commande qui devrait être exécutée comme suit:

  int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; Disassemble8080Op(state->memory, state->pc); switch (*opcode) { case 0x00: //NOP /* ... */ } /*    */ printf("\tC=%d,P=%d,S=%d,Z=%d\n", state->cc.cy, state->cc.p, state->cc.s, state->cc.z); printf("\tA $%02x B $%02x C $%02x D $%02x E $%02x H $%02x L $%02x SP %04x\n", state->a, state->b, state->c, state->d, state->e, state->h, state->l, state->sp); } 

J'ai également ajouté du code à la fin pour afficher tous les registres et drapeaux d'état.

Bonne nouvelle: pour approfondir le programme de 50 000 équipes, nous n'avons besoin que d'un sous-ensemble des opcodes 8080. Je vais même vous donner une liste des opcodes qui doivent être implémentés:

OpcodeL'équipe
0x00Nop
0x01LXI B, D16
0x05DCR B
0x06MVI B, D8
0x09Papa b
0x0dDCR C
0x0eMVI C, D8
0x0fRrc
0x11LXI D, D16
0x13Inx d
0x19Papa d
0x1aLDAX D
0x21LXI H, D16
0x23Inx h
0x26MVI H, D8
0x29Papa h
0x31LXI SP, D16
0x32STA adr
0x36MVI M, D8
0x3aLda adr
0x3eMVI A, D8
0x56MOV D, M
0x5eMOV E, M
0x66MOV H, M
0x6fMOV L, A
0x77MOV M, A
0x7aMOV A, D
0x7bMOV A, E
0x7cMOV A, H
0x7eMOV A, M
0xa7ANA A
0xafXRA A
0xc1Pop b
0xc2Jnz adr
0xc3Jmp adr
0xc5PUSH B
0xc6ADI D8
0xc9Ret
0xcdAppeler adr
0xd1Pop d
0xd3OUT D8
0xd5PUSH D
0xe1Pop h
0xe5PUSH H
0xe6ANI D8
0xebXchg
0xf1POP PSW
0xf5PUSH PSW
0xfbEi
0xfeCPI D8

Ce ne sont que 50 instructions, et 10 d'entre elles sont des mouvements qui sont mis en œuvre de manière triviale.

Débogage


Mais j'ai de mauvaises nouvelles. Votre émulateur ne fonctionnera certainement pas correctement et les bogues dans ce code sont très difficiles à trouver. Si vous savez quelle commande se comporte mal (par exemple, une transition ou un appel qui va vers du code sans signification), vous pouvez essayer de corriger l'erreur en examinant votre code.

En plus d'examiner le code, il existe un autre moyen de résoudre le problème - en comparant votre émulateur avec celui qui fonctionne exactement. Nous supposons qu'un autre émulateur fonctionne toujours correctement, et toutes les différences sont des bogues dans votre émulateur. Par exemple, vous pouvez utiliser mon émulateur. Vous pouvez les exécuter manuellement en parallèle. Vous pouvez gagner du temps si vous intégrez mon code dans votre projet pour obtenir le processus suivant:

  1. Créez un état pour votre émulateur
  2. Créer un état pour le mien
  3. Pour la prochaine équipe
  4. Appeler votre émulateur avec votre état
  5. Appeler le mien avec ma fortune
  6. Comparez nos deux états
  7. Recherche d'erreurs dans les différences
  8. goto 3

Une autre façon consiste à utiliser manuellement ce site . Il s'agit d'un émulateur de processeur Javascript 8080 qui comprend même des ROM Space Invaders. Voici le processus:

  1. Redémarrez l'émulation Space Invaders en cliquant sur le bouton Space Invaders
  2. Appuyez sur le bouton "Exécuter 1" pour exécuter la commande.
  3. Nous exécutons la commande suivante dans notre émulateur
  4. Comparez l'état du processeur avec le vôtre
  5. Si les conditions correspondent, passez à 2
  6. Si les conditions ne correspondent pas, votre émulation d'instructions est erronée. Corrigez-le, puis recommencez à partir de l'étape 1.

J'ai utilisé cette méthode au début pour déboguer mon émulateur 8080. Je ne mentirai pas - le processus peut être long. En conséquence, beaucoup de mes problèmes se sont révélés être des erreurs de frappe et de copier-coller, qui après la détection étaient très faciles à résoudre.

Si vous exécutez votre code étape par étape, la plupart des 30 000 premières instructions sont exécutées dans un cycle d'environ 1a5f $. Si vous regardez javascript dans l' émulateur , vous pouvez voir que ce code copie des données à l'écran. Je suis sûr que ce code est appelé souvent.

Après le premier rendu de l'écran, après 50 000 commandes, le programme se retrouve coincé dans cette boucle sans fin:

  0ada LDA $20c0 0add ANA A 0ade JNZ $0ada 

Il attend que la valeur en mémoire à $ 20c0 passe à zéro. Étant donné que le code de cette boucle ne change pas exactement $ 20c0, il doit s'agir d'un signal venant d'ailleurs. Il est temps de parler d'émuler le «fer» d'une machine d'arcade.

Avant de passer à la section suivante, assurez-vous que votre émulateur de processeur tombe dans cette boucle sans fin.

Pour référence, voir mes sources .

Émulation complète 8080


Une leçon qui m'a coûté cher: ne mettez pas en place des équipes que vous ne pouvez pas tester. Il s'agit d'une bonne règle de base pour tout logiciel en cours de développement. Si vous ne vérifiez pas l'équipe, elle sera définitivement cassée. Et plus vous vous éloignez de sa mise en œuvre, plus il sera difficile de trouver des problèmes.

Il existe une autre solution si vous souhaitez créer un émulateur 8080 complet et vous assurer qu'il fonctionne. J'ai découvert un code pour 8080 appelé cpudiag.asm, conçu pour tester chaque commande de processeur 8080.

Je vous présente ce processus après le premier pour plusieurs raisons:

  1. Je voulais que la description de ce processus soit répétée pour un autre processeur. Je ne pense pas que l'analogue de cpudiag.asm existe pour tous les processeurs.
  2. Comme vous pouvez le voir, le processus est assez laborieux. Je pense qu'un novice dans le débogage du code assembleur rencontrera de grandes difficultés si ces étapes ne sont pas répertoriées.

C'est ainsi que j'ai utilisé ce test avec mon émulateur. Vous pouvez l'utiliser ou trouver une meilleure façon de l'intégrer.

Assemblage de test


J'ai essayé deux ou trois choses, mais j'ai donc décidé d'utiliser cette belle page . J'ai collé le texte cpudiag.asm dans le volet gauche et la construction s'est terminée sans aucun problème. Il m'a fallu une minute pour comprendre comment télécharger le résultat, mais en cliquant sur le bouton «Make Beautiful Code» en bas à gauche, j'ai téléchargé un fichier appelé test.bin, qui est le code compilé 8080. J'ai pu le vérifier à l'aide de mon désassembleur.

Téléchargez cpudiag.asm depuis le miroir de mon site Web.

Téléchargez cpudiag.bin (code compilé 8080) depuis mon site.

Téléchargement d'un test sur mon émulateur


invaders.* .

. -, ORG 00100H , , , 0x100 hex. 8080, , . , , , 0x100.

-, , . hex- JMP $0100 , . ( PC 0x100.)

Troisièmement, j'ai trouvé un bogue dans le code compilé. Je pense que la raison est le traitement incorrect de la dernière ligne de code STACK EQU TEMPP+256, mais je ne suis pas sûr. Quoi qu'il en soit, la pile pendant la compilation était située à 6 $ US, et les premiers PUSH ont commencé à réécrire le code. J'ai suggéré que la variable devrait également être décalée de 0x100, comme le reste du code, donc je l'ai corrigé en insérant «0x7» dans la ligne de code qui initialise le pointeur de pile.

Enfin, comme je n'ai pas implémenté la migration DAA ou auxiliaire dans mon émulateur, je modifie le code pour ignorer cette vérification (nous l'avons simplement ignoré en utilisant JMP).

  ReadFileIntoMemoryAt(state, "/Users/kpmiller/Desktop/invaders/cpudiag.bin", 0x100); //  ,   JMP 0x100 state->memory[0]=0xc3; state->memory[1]=0; state->memory[2]=0x01; //Fix the stack pointer from 0x6ad to 0x7ad // this 0x06 byte 112 in the code, which is // byte 112 + 0x100 = 368 in memory state->memory[368] = 0x7; //  DAA state->memory[0x59c] = 0xc3; //JMP state->memory[0x59d] = 0xc2; state->memory[0x59e] = 0x05; 

Le test tente de tirer une conclusion


Évidemment, ce test repose sur l'aide du système d'exploitation CP / M. J'ai découvert que CP / M a du code à 0005 $ qui imprime des messages sur la console et j'ai changé mon émulation CALL pour gérer ce comportement. Je ne sais pas si tout s'est bien passé, mais cela a fonctionné pour les deux messages que le programme essaie d'imprimer. Mon émulation CALL pour exécuter ce test ressemble à ceci:

  case 0xcd: //CALL  #ifdef FOR_CPUDIAG if (5 == ((opcode[2] << 8) | opcode[1])) { if (state->c == 9) { uint16_t offset = (state->d<<8) | (state->e); char *str = &state->memory[offset+3]; // - while (*str != '$') printf("%c", *str++); printf("\n"); } else if (state->c == 2) { //    ,   ,    printf ("print char routine called\n"); } } else if (0 == ((opcode[2] << 8) | opcode[1])) { exit(0); } else #endif { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; 

Avec ce test, j'ai trouvé plusieurs problèmes dans mon émulateur. Je ne sais pas lequel d'entre eux serait impliqué dans le jeu, mais s'ils l'étaient, alors il serait très difficile de les trouver.

J'ai continué et implémenté tous les opcodes (à l'exception de DAA et de ses amis). Il m'a fallu 3 à 4 heures pour résoudre les problèmes de mes défis et en implémenter de nouveaux. Il était nettement plus rapide que le processus manuel que j'ai décrit ci-dessus - avant de trouver ce test, j'ai passé plus de 4 heures sur le processus manuel. Si vous pouvez comprendre cette explication, je recommande d'utiliser cette méthode au lieu de comparer manuellement. Cependant, connaître le processus manuel est également une grande compétence, et si vous souhaitez émuler un autre processeur, vous devez y revenir.

Si vous ne pouvez pas effectuer ce processus ou si cela semble trop compliqué, alors il vaut vraiment la peine de choisir l'approche décrite ci-dessus avec deux émulateurs différents exécutés à l'intérieur de votre programme. Lorsque plusieurs millions de commandes apparaissent dans le programme et que des interruptions sont ajoutées, il sera impossible de comparer manuellement deux émulateurs.

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


All Articles