8-Bit-Breadboard-Computer auf Basis einer 6502-CPU - Erstes Programm in Maschinensprache

Im letzten Teil des Projekts haben wir unseren Breadboard Computer um ROM und RAM Speicher erweitert und das CPU-Modul mit einem W65C02 Prozessor und dem Clock-Modul, das den Takt angibt aus den voherigen Teilen betrieben.

Dabei haben wir eigentlich nur aus dem ROM gelesen, und hier auch nur NOP-Befehle (OpCode 0xEA). Bei der Adress-Umschaltung/Überlauf von 0xFFFF auf 0x0000 haben wir zwar mitgekriegt, dass die Umschaltung nach Adresse (unterer Teil RAM, ab 0x8000 ROM) funktioniert, aber wir haben noch nicht getestet, ob das Schreiben ins RAM und Wiederlesen aus dem RAM funktioniert.

Das wäre doch eine schöne Aufgabe für ein erstes Programm: Schreiben eines Wertes in das RAM und dann lesen dieses Wertes aus dem RAM und schauen, ob das richtig funktioniert.

Programmieren werden wir dieses erste Programm in hartem Maschinencode, also direkt mit den Byte-Opcodes. Einmal, um dort auch einmal reingeschnuppert zu haben, wie die direkteste Programmierung funktioniert und zum zweiten, weil wir uns noch nicht mit Assembler und Co. rumschlagen wollen.

Wir werden die entsprechenden OpCodes als Bytewerte direkt so in das EEPROM schreiben und dann in den Breadboard Computer einsetzen, um es dort ausführen zu lassen. Dazu benutzen wir den Hex-Editor des Eprommers und schreiben das PRogramm anschließend mit ihm.

Doch erstmal müssen wir das Programm schreiben. Dazu müssen wir zuerst ein klein bisschen über die Architektur des 6502 kennenlernen.

Einblick in die Architektur des 6502

Wie der 6502er grundsätzlich funktioniert, haben wir ja schon mitbekommen: Beim Reset lädt er die Programmstart-Adresse von 0xFFFC/D und springt dann dorthin, um das Programm auszuführen. Dazu setzt er den Program Counter auf diese Adresse. Dann holt er sich das Datenbyte von dieser Adresse, interpretiert sie als OpCode und führt diesen aus.

Register

Dazu muss sich die CPU allerlei Dinge merken, etwa, welcher OpCode gerade auszuführen ist, an welcher Adresse das Programm gerade steht usw. Die Speicherstellen innerhalb des Prozessors nennt man Register. Wir hätten:

Program Counter (PC, 2 Byte breit: PCL (low Byte) und PCH (high Byte))

Der 16 Bit breite PC merkt sich die aktuelle Adresse, an dem das Programm steht. Mit Sprunganweisungen (z. B. JMP) können wir die CPU anweisen, diesen zu ändern und woanders weiterzumachen.

Instruction Register (IR)

Hier wird der OpCode (Operation Code, also der auszuführende Befehl) abgelegt, nachdem er vom Datenbus geholt wurde. Er wird unterstützt von der

Timing Control Unit (TCU)

Diese weiß, wieviele Zyklen ein OpCode dauert und timet die Ausführung.

Die Ausführung von OpCode geschieht in der

Arithmetic and Logic Unit (ALU)

Hier werden die Befehle ausgeführt, das heißt, die Informationen in den internen Registern miteinander verknüpft, je nach OpCode. Das Ergebnis der Ausführung spiegelt sich in Registerwerten und sogenannten Flags wider. Flags sind 1-Bit-Anzeigen (wie eine LED, die leuchtet oder nicht) z. B. für Überlauf, Negativ oder Nullwert.

Processor Status Register (P)

Hier werden die ganzen Flags abgelegt, die die ALU errechnet hat. Das Register ist 8 Bit breit und beeinhaltet die Flags:

N V 1 B D I Z C Die ersten vier genannten können dazu benutzt werden, unter bestimmten Bedingungen (eben die, die die Flags angeben) eine Programm-Verzweiung (einen Sprung an einer andere Adresse) auszuführen oder nicht. Das Register, mit dem wir am meisten zu tun haben werden, sozusagen das User-Register ist der

Accumulator (A)

Hier können wir 8-Bit-Werte (0x00 bs 0xFF), also ein Datenbyte hineinladen (OpCode LDA) oder den Inhalt an einer anzugebenen Adresse speichern (OpCode STA). So ziemlich alles an Werten geht durch den Accumulator.

Es gibt unterschiedliche Arten, den Accu zu adressieren. Wir können z. B. direkt einen Wert hineinladen oder einen Wert, der an einer angegebenen Adresse steht, oder aber auch den von einer angegebenen Adresse plus einen bestimmten Index, den wir vorher in den Index-Registern gespeichert haben.

Index Register (X, Y)

Der Inhalt dieser Register gibt an, um wieviel bei der indezierten, indirekten Adressierung der angegebene Adresswert erhöht wird, um von dort dann den Wert zu holen. Das hört sich erst einmal sehr kompliziert an, vereinfacht aber z. B. die Programmierung von For-Schleifen.

Stack Pointer Register (S)

Bei Sprünge, die zurückkehren, muss gespeichert werden, an welche Adresse nach Ausführung einer Unterroutine wieder zurückgesprungen werden soll. Dies geschieht im sogenannten Stack (Stapel). Hier sind alle Rücksprungadressen aufgelistet. Das Stack Pointer Register zeigt an, welcher Speicherstelle des Stacks die Aktuelle ist. Das Stack Pointer Register ist 8 Bit breit.

Der Stack selbst ist im Speicherbereich 0x0100 bis 0x01ff hinterlegt, also 256 Bytes groß. Jeder Stackeintrag benötigt 2 Bytes für eine Adresse. Das macht eine Speicherkapazität von 128 Rücksprungadressen. Wurde also 128 mal (ohne Rückkehr) verzweigt, wird es einen Stack-Überlauf (Stack Overflow Error) geben. Das kann schnell passieren, wenn sich eine Routine versehentlich selbst aufruft.


Quelle: W65C02S-Datenblatt


Adressierungsarten

Es gibt verschiedene Arten der Adressierung, mal gibt man einen Wert direkt an, mal holt man ihn aus einer Adresse, wo er hinterlegt ist. Und manchmal benutzt man noch einen Offset, der zur Adresse hinzuaddiert wird. Diese Adressierungsarten haben bestimmte Namen und bestimmte OpCodes.

Es ist wichtig, sie zu kennen, um den richtigen OpCode auszuwählen.

Direkt (#, Immediate)

Es wird direkt der angegebene Wert benutzt.

Syntax: OpCode Wert
Beispiel: LDA# 55 -> (0x9A 0x55) -> lädt 0x55 in den Accu.

Direkt mit Accumulator (A)

Er wird der Wert im Accu benutzt.

Syntax: OpCode
Beispiel: INCA -> (0x1A) -> Erhöht den Wert im Accu um 1.

Absolut (a)

Es wird der Wert, der an der angegebenen Adresse steht, benutzt

Syntax: OpCode Low High
Beispiel: LDA 00 81 -> (0xAD 0x00 0x81) -> holt den Wert aus 0x8100 und schreibt ihn in den Accu.

Absolut indirekt ((a))

Eigentlich wie Absolut, nur steht an der Adresse noch nicht der Wert, sondern eine zweite Adresse, an der dann schließlich der Wert steht. Wird für Sprungadressen-Tabellen benutzt.

Syntax: OpCode Low High
Beispiel: JMP(a) 00 81 -> (0x6C 0x00 0x81) -> holt aus 0x8100 die Adresse, an der die Adresse steht, an die gesprungen werden soll.

Absolut indexiert (a,x bzw. a,y)

Es wird der Wert, der an der angegebenen Adresse steht plus das, was im X bzw. Y-Register steht, benutzt

Syntax: OpCode Low High
Beispiel: LDAx 00 81 -> (0xBD 0x00 0x81) -> angenommen im X-Register steht 0x10: addiert diese 0x10 zu 0x8100 und holt den Wert aus 0x8110 und schreibt ihn in den Accu.

Zero Page absolut (zp) oder indexiert (zp,x bzw. zp,y)

Wie absolut bzw. indexiert, nur steht die Adresse in den ersten 256 Bytes und benötigt keine Highbyte-Angabe. Es wird also ein Byte gespart.

Syntax: OpCode Low
Beispiel: LDAzp 00 -> (0xA5 0x00) -> holt den Wert aus 0x0000 und schreibt ihn in den Accu.

Program Counter relativ (r)

Wird für Verzweigungen benutzt, um N Bytes (Offset) hinter die aktuelle PC-Adresse zu springen.

Syntax: OpCode N
Beispiel: BEQr 10 -> (0xF0 0x10) -> angenommen im PC steht 0x8100 und das Z-Flag ist 1: der PC wird um 16 erhöht, also die dort stehenden Befehle übersprungen.

Wem die Adressierungsarten jetzt einen Knoten ins Gehirn gemacht haben: nicht so schlimm, die meisten werden wir am Anfang nicht brauchen. Und später kommt man dann von selbst drauf, dass es doch toll wäre, wenn es eine Adressierungsart gäbe, die das und das könnte. Und siehe da: es gibt sie.

Erweiterte Speichertabelle

Wir können unsere Speichertabelle also um ein paar Angabe erweitern: Adr.(hex) Beschreibung 0000 RAM Zero-Page: schneller Speicherzugriff mit zp-OpCodes ... RAM Zero-Page: schneller Speicherzugriff mit zp-OpCodes 00FF RAM Zero-Page: schneller Speicherzugriff mit zp-OpCodes 0100 RAM Stack: Hinterlegung von Rücksprungadressen ... RAM Stack: Hinterlegung von Rücksprungadressen 01FF RAM Stack: Hinterlegung von Rücksprungadressen 0200 RAM frei für User-Variablen etc. ... RAM frei für User-Variablen etc. 7FFF RAM frei für User-Variablen etc. 8000 ROM (per DIP-Switch eingeblendete Programmbank von 0 bis 15) ... ROM Programm und Konstanten FFF9 ROM Programm und Konstanten FFFA ROM Sprungvektor für BRK/IRQ Low-Byte FFFB ROM Sprungvektor für BRK/IRQ High-Byte FFFC ROM Sprungvektor für RES Low-Byte = Programmstartadresse FFFD ROM Sprungvektor für RES High-Byte = Programmstartadresse FFFE ROM Sprungvektor für NMI Low-Byte FFFF ROM Sprungvektor für NMI High-Byte

Unser erstes Programm in Maschinensprache

Wenden wir uns jetzt ein paar ersten Befehlen zu, mit dem wir unser RAM testen können.

Schreiben wir zuerst 1, 2, 4, 8, ... 128 in die 8 Speicheradressen ab 0X1000 und lesen wir sie danach wieder aus. Adr. Assembler Bytecode (hex) Bedeutung (hex) 8000 LDA# 01 A9 01 lade 01 (rechte LED) in den Accu 8002 STA 1000 8D 00 10 speichere den Accu-Inhalt an RAM-Adresse 1000 8005 LDA# 02 A9 02 lade 02 (2. LED von rechts) in den Accu 8007 STA 1001 8D 01 10 speichere den Accu-Inhalt an RAM-Adresse 1001 800A LDA# 04 A9 04 lade 04 (3. LED von rechts) in den Accu 800C STA 1002 8D 02 10 speichere den Accu-Inhalt an RAM-Adresse 1002 800F LDA# 08 A9 08 lade 08 (4. LED von rechts) in den Accu 8011 STA 1003 8D 03 10 speichere den Accu-Inhalt an RAM-Adresse 1003 8014 LDA# 10 A9 10 lade 10 (5. LED von rechts) in den Accu 8016 STA 1004 8D 04 10 speichere den Accu-Inhalt an RAM-Adresse 1004 8019 LDA# 20 A9 20 lade 20 (6. LED von rechts) in den Accu 801B STA 1005 8D 05 10 speichere den Accu-Inhalt an RAM-Adresse 1005 801E LDA# 40 A9 40 lade 40 (7. LED von rechts) in den Accu 8020 STA 1006 8D 06 10 speichere den Accu-Inhalt an RAM-Adresse 1006 8023 LDA# 80 A9 80 lade 80 (linke LED) in den Accu 8025 STA 1007 8D 07 10 speichere den Accu-Inhalt an RAM-Adresse 1007 8028 LDA 1000 AD 00 10 lade den Wert aus 1000 (sollte 01 sein) 802B LDA 1001 AD 01 10 lade den Wert aus 1001 (sollte 02 sein) 802E LDA 1002 AD 02 10 lade den Wert aus 1002 (sollte 04 sein) 8031 LDA 1003 AD 03 10 lade den Wert aus 1003 (sollte 08 sein) 8034 LDA 1004 AD 04 10 lade den Wert aus 1004 (sollte 10 sein) 8037 LDA 1005 AD 05 10 lade den Wert aus 1005 (sollte 20 sein) 803A LDA 1006 AD 06 10 lade den Wert aus 1006 (sollte 40 sein) 803D LDA 1007 AD 07 10 lade den Wert aus 1007 (sollte 80 sein) 8040 JMP 8000 4C 00 80 springe an den Anfang des Programmes bei 8000 Unser Maschinenprogramm besteht also aus der Bytefolge A9 01 8D 00 10 A9 02 8D 01 10 A9 04 8D 02 10 A9 08 8D 03 10 A9 10 8D 04 10 A9 20 8D 05 10 A9 40 8D 06 10 A9 80 8D 07 10 AD 00 10 AD 01 10 AD 02 10 AD 03 10 AD 04 10 AD 05 10 AD 06 10 AD 07 10 4C 00 80 die wir in den Hex-Editor des Eprommers einfügen:



An 7FFC/D müssen wir noch unsere Programmstartadresse eintragen: "00 80" für 0x8000. Den Rest des Chips lassen wir auf 0xEA. Zum einen stört das nicht und zum anderen sehen wir an einem eventuellen Hochzählen des Program Counters, dass die CPU außerhalb des vorgesehenen Adressbereiches agiert, ohne dass sie gleich mit illegalen OpCodes abstürzt.



Danach können wir den neuen Speicherinhalt auf den Flash-Chip flashen:



Test des Programmes

Nehmen wir den Flash-Chip jetzt aus dem Eprommer und setzen ihn in den Breadboard-Computer.

Was wir erwarten ist: Und genau so verhält sich auch unser Programm auch.

Wir haben also die richtigen OpCodes mit den richtigen Adressierungsarten herausgesucht, in den Hex-Editor des Eprommers übertragen und an die richtige Stelle im Flash geflasht.

Und außerdem funktioniert unser RAM so, wie wir uns das vorgestellt haben: das Umschalten des Zugriffs bei Adresse unter 0x8000 auf das RAM funktioniert, das Versorgen des OE und R/W-Pins funktioniert richtig und das SRAM tut auch das, was es soll: die Daten speichern und wieder hergeben.

Aber am Besten lässt sich das natürlich am lebenden Objekt, also unserem Breadboard-Computer nachvollziehen:



Nachtrag 2020-06-13

Beim letzten mal hatte ich ja ein paar Probleme mit dem Schalter, der wie von Geisterhand von manuell auf Auto-Takt umschaltete.

Den habe ich jetzt durch einen anderen ausgetauscht und noch einmal das Taktsignal am Oszilloskop überprüft.

Ergebnis: Alles in Ordnung, es werden zwei Operationen bei einem Schreib-Taktzyklus ausgeführt, aber das ist gewollt so und macht den 6502 schneller.

Der neue Umschalter wird nur noch runtergedrückt und muss nicht mehr zur Seite geschoben werden. Ich glaube, dieses links/rechts hat den Schalter im Breadboard gelockert und hatte dadurch keine richtige Verbindung mehr und der eine oder andere Pin des Schalters wurde floating, also mit keinem gut definierten High/Low-Signal. Beim neuen Umschalter sollte sich nichts mehr lockern.

Meine Messungen mit dem Oszilloskop und den neuen Schalter zeige ich euch in diesem Video:



Da das Ablesen (oder besser gesagt binärrechnen) der Adress- und Datenbus-LEDS doch ein wenig umständlich ist, entwickeln wir im nächsten Teil ein Sniffer-Modul mit LCD und STM32-Mikrocontroller, der uns das Leben erleichtern sollte.