Erstellen eines Emulator-Arcade-Automaten. Teil 1

Bild

Das Schreiben eines Arcade-Maschinenemulators ist ein großartiges Bildungsprojekt, und in diesem Tutorial werden wir den gesamten Entwicklungsprozess sehr detailliert betrachten. Möchten Sie den Prozessor wirklich in die Finger bekommen? Dann ist das Erstellen eines Emulators der beste Weg, dies zu lernen.

Sie benötigen Kenntnisse in C sowie Kenntnisse in Assembler. Wenn Sie die Assemblersprache nicht kennen, ist das Schreiben eines Emulators der beste Weg, um sie zu lernen. Sie müssen auch die hexadezimale Mathematik beherrschen (auch als Basis 16 oder einfach als „Hex“ bezeichnet). Ich werde über dieses Thema sprechen.

Ich habe mich für einen Emulator für die Space Invaders-Maschine entschieden, die den 8080-Prozessor verwendet. Dieses Spiel und dieser Prozessor sind sehr beliebt, da Sie im Internet viele Informationen darüber finden können. Sie benötigen es, um das Projekt abzuschließen.

Der gesamte Quellcode des Tutorials wird auf github hochgeladen. Wenn Sie die Arbeit mit Git nicht beherrschen, finden Sie auf der Github-Seite eine Schaltfläche "ZIP herunterladen", mit der Sie das Archiv mit dem gesamten Code herunterladen können.

Einführung in Binär- und Hexadezimalzahlen


In der "normalen" Mathematik wird das Dezimalzahlensystem verwendet. Jede Ziffer der Zahl kann einen Wert von null bis neun haben. Wenn wir 9 überschreiten, addieren wir eins zur Zahl in der nächsten Ziffer und beginnen erneut bei Null. Das ist alles ganz einfach und unkompliziert, und Sie haben wahrscheinlich nie darüber nachgedacht.

Möglicherweise haben Sie gewusst oder gehört, dass Computer mit Binärdaten arbeiten. Computerfreaks nennen Base-10-Dezimalmathematik und Binär-Call-Base-2. In der binären Notation kann jede Ziffer einer Zahl nur zwei Werte haben, null oder eins. Im Binärcode lautet die Anzahl wie folgt: 0, 1, 10, 11, 100, 101, 110, 111, 1000. Dies sind keine Dezimalzahlen, daher können Sie sie nicht "Null, Eins, Zehn, Elf, Einhundert, Einhundertein" nennen. Sie werden ausgesprochen als "Null, Eins, Eins-Null, Eins-Eins, Eins-Null-Null" usw. Ich lese Binärzahlen selten laut vor, aber wenn nötig, müssen Sie das verwendete Zahlensystem klar angeben. Zehn, elf und einhundert haben in der binären Notation keine Bedeutung.

In Dezimalschreibweise hat eine Zahl die folgenden Ziffern: Einheiten, Zehner, Hunderter, Tausende, Zehntausende usw. Im binären System die folgenden Ziffern: Einheiten, Zweien, Vierer, Achtel usw. In der Informatik wird der Wert jedes Binärbits als Bit bezeichnet. 8 Bits bilden ein Byte.

In binären Begriffen wird eine Folge von Zahlen schnell sehr lang. Um die Dezimalzahl 20.000 binär darzustellen, sind 16 Ziffern erforderlich: 0b100111000100000. Um dieses Problem zu beheben, ist es zweckmäßig, ein Hexadezimalzahlensystem zu verwenden, das auch als Basis-16 (oder Hex) bezeichnet wird. In Basis 16 enthält jede Ziffer 16 Werte. Für Werte von null bis neun werden die gleichen Zeichen wie in Basis 10 verwendet, aber für die verbleibenden 6 Werte werden Substitutionen in Form der ersten 6 Buchstaben des Alphabets von A bis F verwendet.

Die Abrechnung im Hexadezimalsystem erfolgt wie folgt: 0 1 2 3 4 5 6 7 8 9 ABCDEF 10 11 12 usw. Im Hexadezimalbereich haben Zehner, Hunderter usw. nicht die gleiche Bedeutung wie im Dezimalbereich, daher sprechen die Menschen Zahlen separat aus. Zum Beispiel wird $ A57 laut als "A-fünf-sieben" ausgesprochen. Aus Gründen der Übersichtlichkeit können Sie auch Hex hinzufügen, z. B. "A-fünf-sieben-hex". Im Hexadezimalzahlensystem entspricht das Äquivalent der Dezimalzahl 20.000 $ 4E20 - eine viel kompaktere Form im Vergleich zu 16 Bit des Binärsystems.

Ich denke, das Hexadezimalsystem wurde aufgrund einer sehr natürlichen Umwandlung von binär nach hexadezimal und umgekehrt gewählt. Jede hexadezimale Ziffer entspricht 4 Bits (4 Bits) einer ähnlichen Binärzahl. 2 hexadezimale Ziffern bilden ein Byte (8 Bits). Eine einzelne hexadezimale Ziffer kann als Knabbern bezeichnet werden, und einige Leute schreiben sie sogar als „Nybble“ durch y.

Jede hexadezimale Ziffer besteht aus 4 Binärziffern
HexA.57
Binär101001010111

Beim Schreiben von C-Code wird angenommen, dass die Zahl dezimal ist (Basis-10), sofern nicht anders angegeben. Um dem C-Compiler mitzuteilen, dass die Zahl binär ist, fügen wir die Zahl Null und den Buchstaben b in Kleinbuchstaben hinzu: 0b1101101 . Die Hexadezimalzahl kann in C-Code geschrieben werden, indem am Anfang von Null und x in Kleinbuchstaben 0xA57 wird: 0xA57 . Einige Assemblersprachen verwenden das Dollarzeichen $: $A57 , um eine Hex-Zahl anzugeben.

Wenn Sie darüber nachdenken, ist der Zusammenhang zwischen Binär-, Hexadezimal- und Dezimalzahlen ziemlich offensichtlich, aber für den ersten Ingenieur, der vor der Erfindung des Computers daran gedacht hatte, sollte dies ein Moment der Einsicht geworden sein.

Das alles verstanden? Großartig.

Eine kurze Einführung in den Prozessor


Wenn Sie dies bereits wissen, können Sie den Abschnitt sicher überspringen.

Eine Zentraleinheit (CPU) ist eine Maschine zum Ausführen von Programmen. Die Grundblöcke der CPU sind Register und Anweisungen. Als Softwareentwickler können Sie diese Register als Variablen behandeln. In unserem 8080-Prozessor gibt es unter anderem 8-Bit-Register mit den Namen A, B, C, D und E. Diese Register können als der folgende C-Code interpretiert werden:

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

Alle Prozessoren haben auch einen Programmzähler (Programmzähler, PC). Sie können es als Zeiger nehmen.

 unsigned char* pc; 

Für eine CPU ist ein Programm eine Folge von Hexadezimalzahlen. Jeder Assembler-Befehl in 8080 entspricht 1-3 Bytes im Programm. Um herauszufinden, welcher Befehl welcher Nummer entspricht, ist das Prozessorhandbuch (oder andere Informationen zum 8080-Prozessor aus dem Internet) hilfreich.

Die Namen von Befehlen (Anweisungen) sind häufig Mnemoniken aus den von diesen Befehlen ausgeführten Operationen. Die Mnemonik zum Laden in 8080 ist MOV (Verschieben), und ADD wird verwendet, um die Addition durchzuführen.

Beispiele


Der aktuelle Befehlswert, der vom Befehlszähler angezeigt wird, ist 0x79. Dies entspricht der MOV A,C Anweisung MOV A,C 8080-Prozessors. Dieser Assembler-Code im C-Code sieht wie folgt aus: A=C; .

Wenn stattdessen der Wert im PC 0x80 wäre, würde der Prozessor ADD B ausführen ADD B In C entspricht dies der Zeichenfolge A = A + B; .

Eine vollständige Liste der 8080-Prozessoranweisungen finden Sie hier . Um unseren Emulator zu implementieren, werden wir diese Informationen verwenden.

Timings


In der CPU erfordert die Ausführung jedes Befehls eine bestimmte Zeit (Timing), gemessen in Zyklen. In modernen Prozessoren kann es schwierig sein, diese Informationen zu erhalten, da das Timing von vielen verschiedenen Aspekten abhängt. Bei älteren Prozessoren wie dem 8080 sind die Timings jedoch konstant, und diese Informationen werden häufig vom Prozessorhersteller bereitgestellt. Beispielsweise dauert ein Übertragungsbefehl von Register zu Register MOV 1 Zyklus.

Timing-Informationen sind nützlich, um effizienten Code in den Prozessor zu schreiben. Ein Programmierer kann versuchen, Anweisungen zu vermeiden, deren Ausführung viele Zyklen dauert.

Wichtiger für uns ist, dass wir Timing-Informationen verwenden, um den Prozessor zu emulieren. Damit das Spiel wie auf dem Original funktioniert, müssen die Anweisungen mit der richtigen Geschwindigkeit ausgeführt werden. Einige Emulatoren geben sich viel Mühe, aber wenn wir dazu kommen, müssen wir uns entscheiden, welche Genauigkeit wir erreichen wollen.

Logische Operationen


Bevor wir das Thema Binär- und Hexadezimalzahlen schließen, sollten wir über logische Operationen sprechen. Sie sind wahrscheinlich bereits daran gewöhnt, Logik in Ihrem Code zu verwenden, beispielsweise in Konstrukten wie if ((conditionA) and (conditionB)) . In Programmen, die direkt mit Hardware arbeiten, müssen Sie häufig einzelne Zahlenbits bearbeiten.

UND-Betrieb


Hier sind alle möglichen Ergebnisse der UND-Operation (UND) (Wahrheitstabelle) zwischen zwei Einzelbitzahlen.

xyErgebnis
000
010
100
111

Das Ergebnis von UND ist nur dann gleich Eins, wenn beide Werte gleich Eins sind. Wenn wir zwei Zahlen mit der UND-Operation kombinieren, ist UND für jedes Bit einer Zahl UND mit dem entsprechenden Bit der anderen Zahl. Das Ergebnis wird in diesem Bit der Zielnummer gespeichert. Wahrscheinlich besser, um nur ein Beispiel zu betrachten:

binärhex
Quelle x01101011$ 6B
Quelle y11010010$ D2
x UND y01000010$ 42

In C ist die logische UND-Verknüpfung ein einfaches kaufmännisches Und "&".

Operation ODER (ODER)


Die ODER-Verknüpfung funktioniert auf ähnliche Weise. Der einzige Unterschied besteht darin, dass das Ergebnis gleich eins ist, wenn mindestens einer der Werte von x oder y gleich eins ist.

xyErgebnis
000
011
101
111

binärhex
Quelle x01101011$ 6B
Quelle y11010010$ D2
x ODER y11111011$ Fb

In C wird eine logische ODER-Verknüpfung durch einen vertikalen Balken "|" angezeigt.

Warum ist das wichtig?


In vielen älteren Prozessoren und insbesondere in Arcade-Maschinen erfordert das Spiel oft, nur mit einem Bit der Zahl zu arbeiten. Oft gibt es einen ähnlichen Code:

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

In Beispiel 1 ist die im Speicher zugewiesene Adresse $ 2043 die Adresse der Tasten auf dem Bedienfeld. Dieser Code liest und reagiert auf die gedrückte Taste. (Natürlich wird dieser Code in Space Invaders in Assemblersprache sein!)

In Beispiel 2 möchte das Spiel eine LED-Anzeige aufleuchten lassen, die sich in Bit 6 der im Speicher zugewiesenen $ 2089-Adresse befindet. Der Code sollte den vorhandenen Wert lesen, nur ein Bit ändern und ihn zurückschreiben.

In Beispiel 3 müssen Sie den Indikator von Beispiel 2 ausschalten, damit der Code Bit 6 der Adresse $ 2089 zurücksetzt. Dies kann erreicht werden, indem die UND-Operation für das Indikatorsteuerbyte mit einem Wert ausgeführt wird, für den nur Bit 6 Null ist. Wir werden also nur 6 beeinflussen und die verbleibenden Bits unverändert lassen.

Dies wird normalerweise als "Maske" bezeichnet. In C wird eine Maske normalerweise mit dem Operator NOT geschrieben, der durch eine Tilde ("~") gekennzeichnet ist. Anstatt ~0x40 schreiben, schreibe ich einfach ~0x40 und erhalte die gleiche Nummer, aber ohne großen Aufwand.

Einführung in die Assemblersprache


Wenn Sie dieses Tutorial lesen, sind Sie wahrscheinlich mit Computerprogrammierung vertraut, beispielsweise in Java oder Python. Mit diesen Sprachen können Sie viel Arbeit in nur wenigen Codezeilen erledigen. Code gilt als geschickt geschrieben, wenn er in möglichst wenigen Zeilen so viel Arbeit wie möglich leistet und möglicherweise sogar die Funktionalität der integrierten Bibliotheken nutzt. Solche Sprachen werden "Hochsprachen" genannt.

In der Assemblersprache sind dagegen keine lebensrettenden Funktionen integriert, und möglicherweise sind viele einfache Codezeilen erforderlich, um einfache Aufgaben auszuführen. Assemblersprache wird als einfache Sprache betrachtet. Darin muss man sich daran gewöhnen, im Stil zu denken: "Welche spezifische Abfolge von Schritten muss unternommen werden, um diese Aufgabe zu erledigen?"

Das Wichtigste, was Sie über die Assembler-Sprache wissen müssen, ist, dass jede Zeile in einen Prozessorbefehl übersetzt wird.

Betrachten Sie eine solche Konstruktion aus der C-Sprache:

 int a = b + 100; 

In der Assemblersprache muss diese Aufgabe in der folgenden Reihenfolge ausgeführt werden:

  1. Laden Sie die Adresse der Variablen B in Register 1
  2. Laden Sie den Inhalt dieser Speicheradresse in Register 2
  3. Addiere den direkten Wert 0x64 zu Register 2
  4. Laden Sie die Adresse der Variablen A in Register 1
  5. Schreiben Sie den Inhalt von Register 2 in die in Register 1 gespeicherte Adresse

Im Code sieht es ungefähr so ​​aus:

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

Folgendes ist zu beachten:

  • In einer höheren Sprache entscheidet der Compiler, wo die Variablen im Speicher abgelegt werden sollen. Wenn Sie Code in Assembler schreiben, sind Sie selbst für jede Speicheradresse verantwortlich, die Sie verwenden werden.
  • In den meisten Assemblersprachen bedeuten Klammern "Speicher an dieser Adresse".
  • In den meisten Assemblersprachen bezeichnet # eine algebraische Zahl, die auch als Sofortwert bezeichnet wird. In Zeile 1 des obigen Beispiels schreibt der Code beispielsweise tatsächlich den Wert # 0x1000, um a1 zu registrieren. Wenn der Code wie move.l a1, ($1000) erhält a1 den Speicherinhalt unter der Adresse 0x1000.
  • Jeder Prozessor hat seine eigene Assemblersprache, und das Portieren von Code von einem Prozessor zu einem anderen kann schwierig sein.
  • Dies ist keine echte Prozessorassemblersprache, ich habe sie als Beispiel angeführt.

Eines haben jedoch High-Level-Smart-Programmierer und Assembler-Assistenten gemeinsam. Assembler-Programmierer halten es für eine Ehre, die Aufgabe so effizient wie möglich zu erledigen und die Anzahl der verwendeten Anweisungen zu minimieren. Der Code für Arcade-Automaten ist normalerweise stark optimiert und alle Säfte werden aus jedem zusätzlichen Byte und Zyklus herausgepresst.

Stapel


Lassen Sie uns etwas mehr über die Assemblersprache sprechen. In jedem recht komplexen Computerprogramm werden in Assembler Unterprogramme verwendet. Die meisten CPUs haben eine Struktur, die als Stapel bezeichnet wird.

Stellen Sie sich einen Stapel in Form eines Stapels vor. Wenn wir eine Nummer speichern müssen, legen wir sie oben auf den Stapel. Wenn wir es zurückbringen müssen, nehmen wir es von der Oberseite des Stapels. Assembler-Programmierer nennen das Poppen der Nummer auf dem Stapel "Push", und das Herausspringen wird als "Pop" bezeichnet.

Angenommen, mein Programm muss eine Unterroutine aufrufen. Ich kann ähnlichen Code schreiben:

  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  .. 

Der oben gezeigte Code schiebt die Werte d0, d1, a0 und a1 auf den Stapel. Die meisten Prozessoren verwenden einen Stapelzeiger. Dies kann ein reguläres Register sein, das üblicherweise als Stapelzeiger verwendet wird, oder ein spezielles Register mit Funktionen für bestimmte Anweisungen.

Auf Prozessoren der 68K-Serie wird der Stapelzeiger nur durch Konvention bestimmt, andernfalls handelt es sich um ein reguläres Register. In unserem 8080-Prozessor ist das SP-Register ein spezielles Register. Es verfügt über PUSH- und POP-Befehle, die in nur einem Befehl vom Stapel geschrieben und eingeblendet werden.

In unserem Emulatorprojekt schreiben wir keinen Code von Grund auf neu. Wenn Sie jedoch Programme in Assemblersprache analysieren müssen, ist es gut zu lernen, solche Konstruktionen zu erkennen.

Hochsprachen


Beim Schreiben eines Programms in einer höheren Sprache werden alle Vorgänge zum Speichern und Wiederherstellen von Registern bei jedem Funktionsaufruf ausgeführt. Wir denken nicht an sie, weil der Compiler sich mit ihnen befasst. Funktionsaufrufe in einer Hochsprache können viel Speicher und Prozessorzeit in Anspruch nehmen.

Haben Sie jemals einen Programmabsturz beim Aufrufen eines Unterprogramms in einer Endlosschleife erlebt? Dies kann passieren, weil bei jedem Funktionsaufruf Registerwerte auf den Stapel verschoben wurden und der Speicher irgendwann nicht mehr über genügend Speicher verfügt. (Wenn der Stapel zu groß wird, wird dies als Stapelüberlauf oder Stapelüberlauf bezeichnet.)

Möglicherweise haben Sie von Inline-Funktionen gehört. Sie vermeiden das Speichern und Wiederherstellen von Registern, indem sie den Routinecode in die aufrufende Funktion aufnehmen. Der Code wird größer, aber dank dessen werden mehrere Befehle und Lese- / Schreibvorgänge im Speicher gespeichert.

Konventionen aufrufen


Wenn Sie ein Assembler-Programm schreiben, das nur Ihren Code aufruft, können Sie selbst entscheiden, wie die Routinen miteinander kommunizieren. Wie kehre ich beispielsweise nach Abschluss der Routine zur aufrufenden Funktion zurück? Eine Möglichkeit besteht darin, die Absenderadresse in ein bestimmtes Register zu schreiben. Die andere besteht darin, die Absenderadresse oben auf dem Stapel zu platzieren. Sehr oft hängt die Entscheidung davon ab, was der Prozessor unterstützt. Der 8080 verfügt über einen CALL-Befehl, der die Rücksprungadresse einer Funktion auf den Stapel überträgt. Vielleicht verwenden Sie diesen 8080-Befehl, um Unterprogrammaufrufe zu implementieren.

Eine weitere Entscheidung muss getroffen werden. Liegt die Registererhaltung in der Verantwortung der aufrufenden Funktion oder Unterroutine? Im obigen Beispiel werden die Register von der aufrufenden Funktion gespeichert. Aber was ist, wenn wir 32 Register haben? Das Speichern und Wiederherstellen von 32 Registern, wenn eine Routine nur einen kleinen Teil davon verwendet, ist Zeitverschwendung.

Der Kompromiss kann ein gemischter Ansatz sein. Angenommen, wir wählen eine Richtlinie, in der eine Routine die Register r10-r32 verwenden kann, ohne deren Inhalt zu speichern, r1-r9 jedoch nicht zerstören kann. In einer ähnlichen Situation kennt die aufrufende Funktion Folgendes:

  • Bei der Rückkehr von einer Funktion bleibt der Inhalt von r1-r9 unverändert
  • Ich kann mich nicht auf den Inhalt von r10-r32 verlassen
  • Wenn ich nach dem Aufrufen einer Unterroutine einen Wert in r10-r32 benötige, muss ich ihn vor dem Aufrufen irgendwo speichern

Ebenso kennt jede Routine Folgendes:

  • Ich kann r10-r32 zerstören
  • Wenn ich r1-r9 verwenden möchte, muss ich den Inhalt speichern und wiederherstellen, bevor ich zu der Funktion zurückkehre, die mich aufgerufen hat

Abi


Auf den meisten modernen Plattformen werden solche Richtlinien von Ingenieuren erstellt und in Dokumenten veröffentlicht, die als ABI (Application Binary Interface) bezeichnet werden. Dank dieses Dokuments wissen Compiler-Ersteller, wie sie Code kompilieren, der von anderen Compilern kompilierten Code aufrufen kann. Wenn Sie Assembler-Code schreiben möchten, der in einer solchen Umgebung funktionieren kann, müssen Sie ABI kennen und den entsprechenden Code schreiben.

Die Kenntnis von ABI hilft auch beim Debuggen von Code, wenn Sie keinen Zugriff auf die Quelle haben. Das ABI definiert die Position von Parametern für Funktionen. Wenn Sie also ein Unterprogramm berücksichtigen, können Sie diese Adressen untersuchen, um zu verstehen, was an die Funktionen übergeben wird.

Zurück zum Emulator


Der meiste handgeschriebene Assembler-Code, insbesondere für ältere Prozessoren und Arcade-Spiele, folgt nicht ABI. Programme werden zusammengestellt und haben möglicherweise nicht viele Routinen. Jede Routine speichert und stellt Register nur im Notfall wieder her.

Wenn Sie verstehen möchten, was das Programm tut, markieren Sie zunächst die Adressen, die für CALL-Befehle bestimmt sind.

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


All Articles