Erstellen eines Emulator-Arcade-Automaten. Teil 3

Bild

Teile eins und zwei .

8080 Prozessor Emulator


Emulator-Shell


Sie sollten nun über alle erforderlichen Kenntnisse verfügen, um mit der Erstellung eines 8080-Prozessoremulators zu beginnen.

Ich werde versuchen, meinen Code so klar wie möglich zu machen, jeder Opcode wird separat implementiert. Wenn Sie sich damit vertraut gemacht haben, möchten Sie es möglicherweise neu schreiben, um die Leistung zu optimieren oder Code wiederzuverwenden.

Zunächst werde ich eine Speicherstruktur erstellen, die Felder für alles enthält, was mir beim Schreiben eines Disassemblers notwendig erschien. Es wird auch einen Platz für einen Speicherpuffer geben, der RAM sein wird.

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; 

Erstellen Sie nun eine Prozedur mit einem Fehleraufruf, die das Programm mit einem Fehler beendet. Es wird ungefähr so ​​aussehen:

  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; //  } 

Lassen Sie uns einige Opcodes implementieren.

  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; } 

So. Für jeden Opcode ändern wir den Status und den Speicher, wie es ein auf einem echten 8080 ausgeführter Befehl tun würde.

Der 8080 hat ungefähr 7 Typen, je nachdem, wie Sie sie klassifizieren:

  • Datenübertragung
  • Arithmetik
  • Logisch
  • Zweige
  • Stapel
  • Eingabe-Ausgabe
  • Besonderes

Schauen wir uns jeden einzeln an.

Rechengruppe


Arithmetische Anweisungen sind viele der 256 Opcodes des 8080-Prozessors, die verschiedene Arten der Addition und Subtraktion enthalten. Die meisten arithmetischen Anweisungen arbeiten mit Register A und speichern das Ergebnis in A. (Register A wird auch als Akkumulator bezeichnet).

Es ist interessant festzustellen, dass diese Befehle Bedingungscodes beeinflussen. Statuscodes (auch Flags genannt) werden abhängig vom Ergebnis des ausgeführten Befehls gesetzt. Nicht alle Befehle wirken sich auf Flags aus, und nicht alle Teams, die sich auf Flags auswirken, wirken sich auf alle Flags gleichzeitig aus.

Flaggen 8080


In einem 8080-Prozessor werden Flags als Z, S, P, CY und AC bezeichnet.

  • Z (Null, Null) nimmt den Wert 1 an, wenn das Ergebnis Null ist
  • S (Vorzeichen) nimmt den Wert 1 an, wenn Bit 7 (das höchstwertige Bit, das höchstwertige Bit, MSB) des mathematischen Befehls gegeben ist
  • P (Parität, Parität) wird gesetzt, wenn das Ergebnis gerade ist, und wird zurückgesetzt, wenn es ungerade ist
  • CY (Übertrag) nimmt den Wert 1 an, wenn als Ergebnis des Befehls eine Übertragung oder Ausleihe in ein höherwertiges Bit durchgeführt wird
  • AC (Auxillary Carry) wird hauptsächlich für BCD-Mathematik (Binary Coded Decimal) verwendet. Weitere Informationen finden Sie im Handbuch. In Space Invaders wird dieses Flag nicht verwendet.

Statuscodes werden in bedingten Verzweigungsbefehlen verwendet. Beispielsweise führt JZ eine Verzweigung nur durch, wenn das Z-Flag gesetzt ist.

Die meisten Anweisungen haben drei Formen: für Register, für unmittelbare Werte und für den Speicher. Lassen Sie uns ein paar Anweisungen implementieren, um ihre Formulare zu verstehen und zu sehen, wie die Arbeit mit Statuscodes aussieht. (Beachten Sie, dass ich das Hilfsübertragungsflag nicht implementiere, da es nicht verwendet wird. Wenn ich es implementiert habe, konnte ich es nicht testen.)

Anmeldeformular


Hier ist eine beispielhafte Implementierung von zwei Anweisungen mit einem Registerformular; Im ersten Fall habe ich den Code bereitgestellt, um seine Arbeit verständlicher zu machen, und im zweiten Fall wird eine kompaktere Form vorgestellt, die dasselbe tut.

  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; } 

Ich emuliere 8-Bit-Mathematikbefehle mit einer 16-Bit-Zahl. Dies erleichtert es, Fälle zu verfolgen, in denen die Berechnungen einen Übertrag erzeugen.

Formular für unmittelbare Werte


Die Form für unmittelbare Werte ist fast dieselbe, außer dass das Byte nach dem Befehl die Quelle des hinzugefügten ist. Da "opcode" ein Zeiger auf den aktuellen Befehl im Speicher ist, ist opcode [1] sofort das nächste Byte.

  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; } 

Form für die Erinnerung


In der Speicherform wird ein Byte hinzugefügt, zu dem die in einem Paar von HL-Registern gespeicherte Adresse angibt.

  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; } 

Anmerkungen


Die übrigen arithmetischen Anweisungen werden auf ähnliche Weise implementiert. Ergänzungen:

  • In verschiedenen Versionen mit Übertrag (ADC, ACI, SBB, SUI) verwenden wir gemäß dem Referenzhandbuch Übertragsbits in den Berechnungen.
  • INX und DCX wirken sich auf Registerpaare aus, diese Befehle wirken sich nicht auf Flags aus.
  • DAD ist ein weiterer Befehl eines Registerpaares, der nur das Übertragsflag betrifft
  • INR und DCR beeinflussen das Übertragsflag nicht

Zweiggruppe


Nachdem Sie sich mit den Statuscodes befasst haben, wird die Zweigstellengruppe für Sie klar genug. Es gibt zwei Arten der Verzweigung: Übergänge (JMP) und Aufrufe (CALL). JMP setzt den PC nur auf den Wert des Sprungziels. CALL wird für Routinen verwendet, schreibt die Rücksprungadresse in den Stapel und weist dem PC dann die Zieladresse zu. RET kehrt von CALL zurück, empfängt die Adresse vom Stapel und schreibt sie auf den PC.

Sowohl JMP als auch CALL gehen nur zu absoluten Adressen, die nach dem Opcode in Bytes codiert sind.

Jmp


Der JMP-Befehl verzweigt bedingungslos zur Zieladresse. Es gibt auch bedingte Verzweigungsbefehle für alle Statuscodes (außer AC):

  • JNZ und JZ für Null
  • JNC und JC für die Migration
  • JPO und JPE für Parität
  • JP (Plus) und JM (Minus) für das Zeichen

Hier ist eine Implementierung einiger von ihnen:

  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; 

CALL und RET


CALL schiebt die Adresse des Befehls nach dem Aufruf auf den Stapel und springt dann zur Zieladresse. RET empfängt die Adresse vom Stapel und speichert sie auf dem PC. Bedingte Versionen von CALL und RET existieren für alle Zustände.

  • CZ, CNZ, RZ, RNZ für Null
  • CNC, CC, RNC, RC zur Übertragung
  • CPO, CPE, RPO, RPE für Parität
  • CP, CM, RP, RM für Zeichen

  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; 

Anmerkungen


  • Der PCHL-Befehl springt bedingungslos zu einer Adresse in einem Paar von HL-Registern.
  • Ich habe das zuvor diskutierte RST nicht in diese Gruppe aufgenommen. Es schreibt die Rücksprungadresse in den Stapel und springt dann zu der vordefinierten Adresse am unteren Rand des Speichers.

Logische Gruppe


Diese Gruppe führt logische Operationen aus (siehe den ersten Beitrag des Tutorials). Aufgrund ihrer Natur ähneln sie einer arithmetischen Gruppe darin, dass die meisten Operationen mit Register A (Laufwerk) arbeiten und die meisten Operationen Flags beeinflussen. Alle Operationen werden mit 8-Bit-Werten ausgeführt. In dieser Gruppe gibt es keine Befehle, die Registerpaare betreffen.

Boolesche Operationen


UND, ODER, NICHT (CMP) und "exklusiv oder" (XOR) werden als Boolesche Operationen bezeichnet. ODER und UND habe ich früher erklärt. Der NOT-Befehl (für den 8080-Prozessor heißt er CMA oder Komplementakkumulator) ändert einfach die Bitwerte - alle Einheiten werden zu Nullen und Nullen werden zu Einsen.

Ich nehme XOR als „Differenzerkenner“ wahr. Ihre Wahrheitstabelle sieht so aus:

xyErgebnis
000
011
101
110

AND, OR und XOR haben eine Form für Register, Speicher und unmittelbare Werte. (CMP hat nur einen Befehl, bei dem zwischen Groß- und Kleinschreibung unterschieden wird). Hier ist eine Implementierung eines Paares von 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; 

Zyklische Schaltbefehle


Diese Befehle ändern die Reihenfolge der Bits in den Registern. Eine Verschiebung nach rechts verschiebt sie um ein Bit nach rechts und eine Verschiebung nach links - um ein Bit nach links:

(0b00010000) = 0b00001000

(0b00000001) = 0b00000010

Sie scheinen wertlos zu sein, aber in Wirklichkeit ist dies nicht so. Sie können verwendet werden, um durch Zweierpotenzen zu multiplizieren und zu teilen. Nehmen Sie als Beispiel die Linksverschiebung. 0b00000001 ist dezimal 1, und 0b00000001 es nach links verschieben, wird es 0b00000010 , 0b00000010 dezimal 2. Wenn wir eine weitere Verschiebung nach links durchführen, erhalten wir 0b00000100 , 0b00000100 4. Eine weitere Verschiebung nach links, und wir multiplizieren mit 8. Dies funktioniert mit jedem durch Zahlen: 5 ( 0b00000101 ) ergibt bei 0b00001010 nach links 10 ( 0b00001010 ). Eine weitere Verschiebung nach links ergibt 20 ( 0b00010100 ). Eine Verschiebung nach rechts bewirkt dasselbe, jedoch zur Teilung.

Der 8080 verfügt nicht über einen Multiplikationsbefehl, kann jedoch mit diesen Befehlen implementiert werden. Wenn Sie verstehen, wie das geht, erhalten Sie Bonuspunkte. Einmal wurde mir eine solche Frage bei einem Interview gestellt. (Ich habe es getan, obwohl ich ein paar Minuten gebraucht habe.)

Diese Befehle drehen den Antrieb zyklisch und wirken sich nur auf das Übertragsflag aus. Hier sind einige Befehle:

  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; 

Vergleich


Die Aufgabe von CMP und CPI besteht nur darin, Flags (zum Verzweigen) zu setzen. Sie tun dies, indem sie Flags subtrahieren, aber das Ergebnis nicht speichern.

  • Gleichermaßen: Wenn zwei Zahlen gleich sind, wird das Z-Flag gesetzt, da ihre Subtraktion voneinander Null ergibt.
  • Größer als: Wenn A größer als der zu vergleichende Wert ist, wird das CY-Flag gelöscht (da die Subtraktion ohne Ausleihen erfolgen kann).
  • Kleiner: Wenn A kleiner als der Vergleichswert ist, wird das CY-Flag gesetzt (da A die Ausleihe abschließen muss, um die Subtraktion abzuschließen).

Es gibt Versionen dieser Befehle für Register, Speicher und Sofortwerte. Die Implementierung ist eine einfache Subtraktion, ohne das Ergebnis zu speichern:

  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 und STC


Sie vervollständigen die logische Gruppe. Sie werden verwendet, um das Übertragsflag zu setzen und zu löschen.

Gruppe von Eingabe-Ausgabe- und Spezialbefehlen


Diese Befehle können keiner anderen Kategorie zugewiesen werden. Ich werde sie der Vollständigkeit halber erwähnen, aber es scheint mir, dass wir wieder zu ihnen zurückkehren müssen, wenn wir beginnen, die Hardware von Space Invaders zu emulieren.

  • EI und DI aktivieren oder deaktivieren die Fähigkeit des Prozessors, Interrupts zu verarbeiten. Ich habe das Interrupt_enabled-Flag zur Prozessorstatusstruktur hinzugefügt und es mit diesen Befehlen gesetzt / zurückgesetzt.
  • Es scheint, dass RIM und SIM hauptsächlich für serielle E / A verwendet werden. Wenn Sie interessiert sind, können Sie das Handbuch lesen, aber diese Befehle werden in Space Invaders nicht verwendet. Ich werde sie nicht emulieren.
  • HLT ist ein Stopp. Ich glaube nicht, dass wir es emulieren müssen, aber Sie können Ihren Beendigungscode (oder Beendigungscode (0)) aufrufen, wenn Sie diesen Befehl sehen.
  • IN und OUT sind Befehle, mit denen das 8080-Prozessorgerät mit externen Geräten kommuniziert. Während wir sie implementieren, werden sie nichts anderes tun, als ihr Datenbyte zu überspringen. (Später werden wir zu ihnen zurückkehren).
  • NOP ist "keine Operation". Eine Anwendung von NOP besteht darin, das Timing des Bedienfelds zu steuern (die Ausführung dauert vier CPU-Zyklen).

Eine weitere Anwendung von NOP ist die Codemodifikation. Angenommen, wir müssen den ROM-Code des Spiels ändern. Wir können nicht einfach unnötige Opcodes löschen, da wir nicht alle CALL- und JMP-Befehle ändern möchten (sie sind falsch, wenn mindestens ein Teil des Codes verschoben wird). Mit NOP können wir den Code loswerden. Das Hinzufügen von Code ist viel schwieriger! Sie können es hinzufügen, indem Sie irgendwo im ROM Speicherplatz suchen und den Befehl in JMP ändern.

Stapelgruppe


Wir haben die Mechanik für die meisten Teams in der Stapelgruppe bereits abgeschlossen. Wenn Sie die Arbeit mit mir gemacht haben, sind diese Befehle einfach zu implementieren.

PUSH und POP


PUSH und POP funktionieren nur mit Registerpaaren. PUSH schreibt ein Registerpaar in den Stapel, und POP nimmt 2 Bytes vom oberen Rand des Stapels und schreibt sie in ein Registerpaar.

Es gibt vier Opcodes für PUSH und POP, einen für jedes der Paare: BC, DE, HL und PSW. PSW ist ein spezielles Paar von Laufwerksflagregistern und Statuscodes. Hier ist meine Implementierung von PUSH und POP für BC und PSW. Es gibt keine Kommentare darin - ich denke nicht, dass es hier etwas besonders Kniffliges gibt.

  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 und XTHL


Es gibt zwei weitere Teams in der Stapelgruppe - SPHL und XTHL.

  • SPHL verschiebt HL zu SP (wodurch SP gezwungen wird, eine neue Adresse zu erhalten).
  • XTHL tauscht das, was sich oben auf dem Stapel befindet, mit dem, was sich in einem Paar HL-Registern befindet. Warum sollten Sie das tun müssen? Ich weiß nicht.

Ein bisschen mehr über Binärzahlen


Wenn Sie ein Computerprogramm schreiben, müssen Sie unter anderem die Art der Daten auswählen, die für die Zahlen verwendet werden - ob sie negativ sein sollen und wie groß sie maximal sein sollen. Für den CPU-Emulator benötigen wir den Datentyp, der mit dem Datentyp der Ziel-CPU übereinstimmt.

Signiert und nicht signiert


Als wir über Hex-Zahlen sprachen, betrachteten wir sie als vorzeichenlos - das heißt, jede Binärziffer der Hexadezimalzahl hatte einen positiven Wert und jede wurde als Zweierpotenz (Einheiten, zwei, vier usw.) betrachtet.

Wir haben uns mit der Frage der Computerspeicherung negativer Zahlen befasst. Wenn Sie wissen, dass die betreffenden Daten ein Vorzeichen haben, dh negativ sein können, können Sie eine negative Zahl am höchstwertigen Bit der Zahl (höchstwertiges Bit, MSB) erkennen. Wenn die Datengröße ein Byte beträgt, ist jede Zahl mit einem bestimmten MSB-Bitwert negativ und jede mit einem MSB von Null ist positiv.

Der Wert einer negativen Zahl wird als zusätzlicher Code gespeichert. Wenn wir eine vorzeichenbehaftete Zahl haben und das MSB gleich eins ist und wir herausfinden möchten, wie die Zahl lautet, können wir sie wie folgt konvertieren: Führen Sie ein binäres „NICHT“ für Hex-Zahlen aus und fügen Sie dann eine hinzu.

Für die Hex-Zahl 0x80 ist beispielsweise ein MSB-Bit gesetzt, dh es ist negativ. Das binäre "NICHT" der Zahl 0x80 ist 0x7f oder dezimal 127. 127 + 1 = 128. Das heißt, 0x80 in dezimal ist -128. Zweites Beispiel: 0xC5. Nicht (0xC5) = 0x3A = Dezimalzahl 58 +1 = Dezimalzahl 59. Das heißt, 0xC5 ist Dezimalzahl -59.

Was bei Zahlen mit zusätzlichem Code überrascht, ist, dass wir mit ihnen wie mit vorzeichenlosen Zahlen Berechnungen durchführen können und sie weiterhin funktionieren . Der Computer muss mit Schildern nichts Besonderes tun. Ich werde einige Beispiele zeigen, die dies beweisen.

  Beispiel 1

      Dezimal-Hex-Binär    
       -3 0xFD 1111 1101    
    + 10 0x0A +0000 1010    
    ----- -----------    
        7 0x07 1 0000 0111    
                        ^ Dies wird im Übertragsbit aufgezeichnet

    Beispiel 2    

      Dezimal-Hex-Binär    
      -59 0xC5 1100 0101    
    + 33 0x21 +0010 0001    
    ----- -----------    
      -26 0xE6 1110 0110 


In Beispiel 1 sehen wir, dass das Addieren von 10 und -3 zu 7 führt. Das Additionsergebnis wurde übertragen, sodass das C-Flag gesetzt werden kann. In Beispiel 2 war das Additionsergebnis negativ, daher dekodieren wir dies: Nicht (0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Explosion des Gehirns!

Wenn Sie möchten, lesen Sie mehr über den zusätzlichen Code auf Wikipedia .

Datentypen


In C besteht eine Beziehung zwischen Datentypen und der Anzahl der für diesen Typ verwendeten Bytes. Tatsächlich interessieren wir uns nur für ganze Zahlen. Die Standard- / Old-School-C-Datentypen sind char, int und long sowie ihre Freunde char ohne Vorzeichen, int ohne Vorzeichen und long ohne Vorzeichen. Das Problem ist, dass diese Typen auf verschiedenen Plattformen und in verschiedenen Compilern unterschiedliche Größen haben können.

Daher ist es am besten, einen Datentyp für unsere Plattform auszuwählen, der die Größe der Daten explizit deklariert. Wenn Ihre Plattform stdint.h hat, können Sie int8_t, uint8_t usw. verwenden.

Die Größe einer Ganzzahl bestimmt die maximale Anzahl, die darin gespeichert werden kann. Bei vorzeichenlosen Ganzzahlen können Sie Zahlen von 0 bis 255 in 8 Bit speichern. Wenn Sie in hexadezimal übersetzen, ist dies von 0x00 bis 0xFF. Da 0xFF "alle Bits gesetzt" hat und der Dezimalzahl 255 entspricht, ist es völlig logisch, dass das Intervall einer vorzeichenlosen Einzelbyte-Ganzzahl 0-255 beträgt. Intervalle sagen uns, dass alle Größen von ganzen Zahlen genau gleich funktionieren - Zahlen entsprechen der Zahl, die erhalten wird, wenn alle Bits gesetzt sind.

TypIntervallHex
8-Bit ohne Vorzeichen0-2550x0-0xFF
8-Bit signiert-128-1270x80-0x7F
16-Bit ohne Vorzeichen0-655350x0-0xFFFF
16-Bit signiert-32768-327670x8000-0x7FFF
32-Bit ohne Vorzeichen0-42949672950x0-0xFFFFFFFFFF
32-Bit signiert-2147483648-21474836470x80000000-0x7FFFFFFF

Noch interessanter ist, dass -1 in jedem vorzeichenbehafteten Datentyp eine Zahl ist, für die alle Bits gesetzt sind (0xFF für vorzeichenbehaftetes Byte, 0xFFFF für vorzeichenbehaftete 16-Bit-Zahl und 0xFFFFFFFF für vorzeichenbehaftete 32-Bit-Zahl). Wenn die Daten als vorzeichenlos betrachtet werden, wird für alle gegebenen Bits die maximal mögliche Anzahl für diesen Datentyp erhalten.

Um Prozessorregister zu emulieren, wählen wir den Datentyp aus, der der Größe dieses Registers entspricht. Es lohnt sich wahrscheinlich, standardmäßig nicht signierte Typen auszuwählen und zu konvertieren, wenn Sie sie als signiert betrachten müssen. Zum Beispiel verwenden wir den Datentyp uint8_t, um ein 8-Bit-Register darzustellen.

Hinweis: Verwenden Sie einen Debugger, um Datentypen zu konvertieren


Wenn gdb auf Ihrer Plattform installiert ist, ist es sehr praktisch, es für die Arbeit mit Binärzahlen zu verwenden. Im Folgenden werde ich ein Beispiel zeigen. In der unten gezeigten Sitzung sind Zeilen, die mit # beginnen, Kommentare, die ich später hinzugefügt habe.

# /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 '?'


Wenn ich mit Hex-Zahlen arbeite, mache ich das immer in gdb - und das passiert fast jeden Tag. So viel einfacher als das Öffnen eines Programmierrechners mit einer grafischen Benutzeroberfläche. Öffnen Sie auf Linux- (und Mac OS X-) Computern zum Starten einer GDB-Sitzung einfach ein Terminal und geben Sie "GDB" ein. Wenn Sie Xcode unter OS X verwenden, können Sie nach dem Starten des Programms die Konsole in Xcode verwenden (die Konsole, an die die printf-Ausgabe ausgegeben wird). Unter Windows ist der GDB-Debugger von Cygwin erhältlich.

CPU Emulator Termination


Nachdem Sie all diese Informationen erhalten haben, sind Sie bereit für eine lange Reise. Sie müssen entscheiden, wie Sie den Emulator implementieren - entweder eine vollständige 8080-Emulation erstellen oder nur die Befehle implementieren, die zum Abschließen des Spiels erforderlich sind.

Wenn Sie sich für eine vollständige Emulation entscheiden, benötigen Sie einige weitere Tools. Ich werde im nächsten Abschnitt darüber sprechen.

Eine andere Möglichkeit besteht darin, nur die vom Spiel verwendeten Anweisungen zu emulieren. Wir werden weiterhin das riesige Switch-Konstrukt ausfüllen, das wir im Abschnitt Emulator Shell erstellt haben. Wir werden den folgenden Vorgang wiederholen, bis wir einen einzigen nicht realisierten Befehl haben:

  1. Starten Sie den Emulator mit ROM Space Invaders
  2. Der Aufruf wird beendet, UnimplementedInstruction()wenn der Befehl nicht bereit ist
  3. Emulieren Sie diese Anweisung
  4. Gehe zu 1

Das erste, was ich tat, als ich anfing, meinen Emulator zu schreiben, war, Code von meinem Disassembler hinzuzufügen. So konnte ich einen Befehl ausgeben, der wie folgt ausgeführt werden sollte:

  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); } 

Ich habe am Ende auch Code hinzugefügt, um alle Register und Statusflags anzuzeigen.

Gute Nachrichten: Um in das Programm für 50.000 Teams einzutauchen, benötigen wir nur eine Teilmenge der 8080-Opcodes. Ich werde sogar eine Liste der Opcodes geben, die implementiert werden müssen:

OpcodeDas Team
0x00Nein
0x01LXI B, D16
0x05DCR B.
0x06MVI B, D8
0x09Vater 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
0xc5DRÜCKEN B.
0xc6ADI D8
0xc9Ret
0xcdRufen Sie adr
0xd1Pop d
0xd3OUT D8
0xd5DRÜCKEN D.
0xe1Pop h
0xe5DRÜCKEN H.
0xe6ANI D8
0xebXchg
0xf1POP PSW
0xf5PSW DRÜCKEN
0xfbEi
0xfeCPI D8

Dies sind nur 50 Anweisungen, und 10 davon sind Bewegungen, die trivial implementiert werden.

Debuggen


Aber ich habe schlechte Nachrichten. Ihr Emulator wird mit ziemlicher Sicherheit nicht richtig funktionieren, und Fehler in einem solchen Code sind sehr schwer zu finden. Wenn Sie wissen, welcher Befehl sich schlecht verhält (z. B. ein Übergang oder ein Aufruf von bedeutungslosem Code), können Sie versuchen, den Fehler zu beheben, indem Sie Ihren Code untersuchen.

Neben der Überprüfung des Codes gibt es noch eine andere Möglichkeit, das Problem zu beheben: Vergleichen Sie Ihren Emulator mit einem Emulator, der mit Sicherheit funktioniert. Wir gehen davon aus, dass ein anderer Emulator immer korrekt funktioniert und alle Unterschiede Fehler in Ihrem Emulator sind. Zum Beispiel können Sie meinen Emulator verwenden. Sie können sie manuell parallel ausführen. Sie können Zeit sparen, wenn Sie meinen Code in Ihr Projekt integrieren, um den folgenden Prozess zu erhalten:

  1. Erstellen Sie einen Status für Ihren Emulator
  2. Erstelle einen Zustand für mich
  3. Für das nächste Team
  4. Rufen Sie Ihren Emulator mit Ihrem Status an
  5. Ich rufe meine mit meinem Vermögen an
  6. Vergleichen Sie unsere beiden Staaten
  7. Suche nach Fehlern in irgendwelchen Unterschieden
  8. gehe zu 3

Eine andere Möglichkeit besteht darin, diese Site manuell zu verwenden . Dies ist ein 8080 Javascript-Prozessor-Emulator, der sogar ROM Space Invaders enthält. Hier ist der Prozess:

  1. Starten Sie die Space Invaders-Emulation neu, indem Sie auf die Schaltfläche Space Invaders klicken
  2. Drücken Sie die Taste „Run 1“, um den Befehl auszuführen.
  3. Wir führen den folgenden Befehl in unserem Emulator aus
  4. Vergleichen Sie den Prozessorstatus mit Ihrem
  5. Wenn die Bedingungen übereinstimmen, gehe zu 2
  6. Wenn die Bedingungen nicht übereinstimmen, ist Ihre Anweisungsemulation fehlerhaft. Korrigieren Sie es und beginnen Sie erneut mit Schritt 1.

Ich habe diese Methode am Anfang verwendet, um meinen 8080-Emulator zu debuggen. Ich werde nicht lügen - der Prozess kann langwierig sein. Infolgedessen stellten sich viele meiner Probleme als Tippfehler und Fehler beim Kopieren und Einfügen heraus, die nach der Erkennung sehr einfach zu beheben waren.

Wenn Sie Ihren Code Schritt für Schritt ausführen, werden die meisten der ersten 30.000 Anweisungen in einem Zyklus von etwa $ 1a5f ausgeführt. Wenn Sie sich Javascript im Emulator ansehen , können Sie sehen, dass dieser Code Daten auf den Bildschirm kopiert. Ich bin sicher, dass dieser Code oft aufgerufen wird.

Nach dem ersten Rendern des Bildschirms bleibt das Programm nach 50.000 Befehlen in dieser Endlosschleife stecken:

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

Es wartet, bis sich der Wert im Speicher bei $ 20c0 auf Null ändert. Da der Code in dieser Schleife $ 20c0 nicht genau ändert, muss es sich um ein Signal von einem anderen Ort handeln. Es ist Zeit, über die Emulation des „Eisens“ eines Arcade-Automaten zu sprechen.

Bevor wir mit dem nächsten Abschnitt fortfahren, stellen Sie sicher, dass Ihr CPU-Emulator in diese Endlosschleife fällt.

Als Referenz siehe meine Quellen .

Volle 8080-Emulation


Eine Lektion, die mich viel gekostet hat: Implementieren Sie keine Teams, die Sie nicht testen können. Dies ist eine gute Faustregel für jede in der Entwicklung befindliche Software. Wenn Sie das Team nicht überprüfen, wird es definitiv kaputt sein. Und je weiter Sie sich von der Implementierung entfernen, desto schwieriger wird es, Probleme zu finden.

Es gibt eine andere Lösung, wenn Sie einen vollständigen 8080-Emulator erstellen und sicherstellen möchten, dass er funktioniert. Ich habe einen Code für 8080 namens cpudiag.asm entdeckt, der zum Testen jedes 8080-Prozessorbefehls entwickelt wurde.

Ich führe Sie aus mehreren Gründen nach dem ersten in diesen Prozess ein:

  1. Ich wollte, dass die Beschreibung dieses Prozesses für einen anderen Prozessor wiederholt wird. Ich glaube nicht, dass das Analogon von cpudiag.asm für alle Prozessoren existiert.
  2. Wie Sie sehen können, ist der Prozess ziemlich mühsam. Ich denke, ein Anfänger beim Debuggen von Assembler-Code wird große Schwierigkeiten haben, wenn diese Schritte nicht aufgeführt sind.

So habe ich diesen Test mit meinem Emulator verwendet. Sie können es verwenden oder einen besseren Weg finden, um es zu integrieren.

Baugruppe testen


Ich habe ein paar Dinge ausprobiert, aber als Ergebnis habe ich mich für diese schöne Seite entschieden . Ich habe den Text cpudiag.asm in den linken Bereich eingefügt und den Build ohne Probleme abgeschlossen. Ich habe eine Minute gebraucht, um herauszufinden, wie ich das Ergebnis herunterladen kann. Durch Klicken auf die Schaltfläche „Make Beautiful Code“ unten links habe ich eine Datei namens test.bin heruntergeladen, die den Code 8080 kompiliert. Ich konnte dies mit meinem Disassembler überprüfen.

Laden Sie cpudiag.asm aus dem Spiegel auf meiner Website herunter.

Laden Sie cpudiag.bin (kompilierter Code 8080) von meiner Website herunter .

Laden Sie einen Test auf meinen Emulator hoch


Anstatt Invasoren zu laden. * Dateien lade ich diese Binärdatei.

Hier ergeben sich kleine Schwierigkeiten. Erstens enthält der Quell-Assembler-Code eine Zeile, ORG 00100Hdh, die gesamte Datei wird unter der Annahme kompiliert, dass sich die erste Codezeile in 0x100 hex befindet. Ich hatte noch nie zuvor Code in Assembler 8080 geschrieben, daher wusste ich nicht, was diese Zeile bewirkt. Ich brauchte nur eine Minute, um herauszufinden, dass alle Zweigstellenadressen falsch waren und der Speicher bei 0x100 beginnen musste.

Zweitens muss ich, da mein Emulator von vorne anfängt, zuerst zum echten Code übergehen. Nachdem ich den Hex-Wert an der Null-Adresse in den Speicher eingefügt hatte JMP $0100, habe ich mich damit befasst. (Oder Sie können den PC einfach mit einem Wert von 0x100 initialisieren.)

Drittens habe ich einen Fehler im kompilierten Code gefunden. Ich denke, der Grund ist die falsche Verarbeitung der letzten Codezeile STACK EQU TEMPP+256, aber ich bin mir nicht sicher. Wie dem auch sei, der Stapel während der Kompilierung befand sich bei $ 6ad, und die ersten PUSH begannen, den Code neu zu schreiben. Ich schlug vor, dass die Variable wie der Rest des Codes auch um 0x100 versetzt werden sollte, also habe ich sie behoben, indem ich "0x7" in die Codezeile eingefügt habe, die den Stapelzeiger initialisiert.

Da ich in meinem Emulator keine DAA- oder Zusatzmigration implementiert habe, ändere ich den Code, um diese Prüfung zu überspringen (wir überspringen sie nur mit 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; 

Der Test versucht, eine Schlussfolgerung zu ziehen


Offensichtlich stützt sich dieser Test auf die Hilfe des CP / M-Betriebssystems. Ich fand heraus, dass CP / M einen Code für $ 0005 hat, der Nachrichten an die Konsole druckt, und änderte meine CALL-Emulation, um dieses Verhalten zu behandeln. Ich bin nicht sicher, ob alles richtig gelaufen ist, aber es hat für die beiden Nachrichten funktioniert, die das Programm zu drucken versucht. Meine CALL-Emulation zum Ausführen dieses Tests sieht folgendermaßen aus:

  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; 

Bei diesem Test habe ich mehrere Probleme in meinem Emulator gefunden. Ich bin mir nicht sicher, welcher von ihnen in das Spiel involviert sein würde, aber wenn sie es wären, wäre es sehr schwierig, sie zu finden.

Ich habe alle Opcodes implementiert (mit Ausnahme von DAA und seinen Freunden). Ich habe 3-4 Stunden gebraucht, um Probleme in meinen Herausforderungen zu beheben und neue zu implementieren. Es war definitiv schneller als der oben beschriebene manuelle Prozess - bevor ich diesen Test fand, verbrachte ich mehr als 4 Stunden mit dem manuellen Prozess. Wenn Sie diese Erklärung herausfinden können, empfehle ich, diese Methode zu verwenden, anstatt sie manuell zu vergleichen. Die Kenntnis des manuellen Prozesses ist jedoch auch eine große Fähigkeit. Wenn Sie einen anderen Prozessor emulieren möchten, sollten Sie darauf zurückgreifen.

Wenn Sie diesen Vorgang nicht ausführen können oder er zu kompliziert erscheint, lohnt es sich auf jeden Fall, den oben beschriebenen Ansatz mit zwei verschiedenen Emulatoren zu wählen, die in Ihrem Programm ausgeführt werden. Wenn mehrere Millionen Befehle im Programm erscheinen und Interrupts hinzugefügt werden, ist es unmöglich, zwei Emulatoren manuell zu vergleichen.

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


All Articles