8-Bit-Breadboard-Computer auf Basis einer 6502-CPU - Assemblerprogrammierung Laufschrift auf 3 fach 7 Segment Anzeige

Bisherige Artikel dieser Serie - hier könnt ihr nochmal alle Grundlagen nachlesen, falls ihr jetzt erst einsteigt: Mit der neuen Architektur funktionieren die 7-Segment-Anzeige fabelhaft. Zeit, sie ein bisschen zu fordern und etwas Anspruchsvolleres zu programmieren... wie wäre es mit einer Laufschrift?

Heute geht es also wieder um die Programmierung des W65C02 mit Assembler.

Was soll das Programm können?

Wir haben nur drei 7-Segment-Anzeigen. Das reicht nicht gerade für viel Text. Aber man kann sich ja dem alten Prinzip der LAufschrift bedienen.

Dabei laufen die Buchstaben von rechts nach links durch. Und in unserem Fall sind dann immer drei sichtbar. Eine Laufschrift mit nur drei Buchstaben ist zwar nicht supertoll zu lesen, aber da die meisten Silben auch nicht mehr als 3 Buchstaben haben, sollte das schon noch lesbar sein. Wir haben halt nicht mehr.

Hinzu kommt, dass eine 7-Segment-Anzeige eigentlich nur zur Anzeige von Ziffern gemacht ist und nicht von Text. Manchmal muss man bei der Darstellung der Buchstaben ein wenig kreativ seien - so dann später auch beim Interpretieren. Aber mit ein bisschen Kombinationsgabe und Übung hat auch der Leser den Dreh bald raus und kann Texte hierauf flüssig lesen.

Hello World

Hauptziel soll sein, den Text "Hello World" auszugeben. Das ist eine beliebte Übung und sie existiert eigentlich für jede Programmiersprache und jeden Computer. Die Aufgabe ist einfach: Der Computer soll mithilfe der verwendeten Programmiersprache eben den Text "Hello World" (oder auch "Hallo Welt") ausgeben. Anhand des Source-Codes, die diese Aufgabe erledigt, kann ein Programmierer dann abschätzen, wie aufwändig Programme in einer Programmiersprache zu schreiben sind.

In einer Hochsprache wie Basic reicht zum Beispiel der Befehl PRINT "HELLO WORLD" In C würde der einfachste Source-Code void main() { printf ("Hello World"); } lauten. In einer so verrückten (bzw. "esoterischen") Programmiersprache wie Brainfuck hieße der Source-Code ++++++++[->+++++++++<]>.<+++++[->+++++<]>++++.+++++++..+++.>+++++[->++++++<]>++.<+++++++[->+++++++<]>++++++.<+++++[->++++<]>++++.+++.------.--------. Es gibt eigentlich für jede noch so exotische Programmiersprache ein Hello-World-Beispiel. Viele Beispiele finden sich auf Wikipedia.

Aber wir befinden uns auf Assembler-Ebene (Beispiele dazu auf Wikipedia) und haben es nicht so einfach, einen hochsprachigen Befehl wie Print benutzen zu können. Und der 6502 hat einen extrem reduzierten Satz an OpCodes und nur wenige Register. Das macht es noch einmal aufwendiger. Noch dazu haben wir ein proprietäres Ausgabegerät, nämlich unsere Segmentanzeigen.

Ganz so trivial ist die Aufgabe mit unserem Breadboard-Computer also nicht.

1. Schritt: ASCII benutzen

Da wir auf dem PC entwickeln, benutzen wir dort den ASCII-Code. Das ist der Standard, welcher Bytecode (von 0 bis 255) welchem Zeichen zugeordnet ist. So entspricht z. B. die Null (0) dem ASCII-Code 48 (dez.), das große A etwa dem ASCII-Code 65 und das kleine a dem ASCII-Code 97. +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 0- 15 NUL SOH STX ETX EOT ENQ ACK BEL BS HT LF VT FF CR SO SI 16- 31 DLE DC1 DC2 DC3 DC4 NAK SYN ETB CAN EM SUB ESC FS GS RS US 32- 47 SP ! " # $ % & ' ( ) * + , - . / 48- 63 0 1 2 3 4 5 6 7 8 9 : ; < = > ? 64- 79 @ A B C D E F G H I J K L M N O 80- 95 P Q R S T U V W X Y Z [ \ ] ^ _ 96-111 ` a b c d e f g h i j k l m n o 112-127 p q r s t u v w x y z { | } ~ DEL Warum das so ist? Das hat eine lange Geschichte und geht auf das frühe Fernschreiber zurück, bei den man noch 7 Löcher in einen Paperstreifen gestanzt hat, um Daten zu speichern. Der Ursprungs-ASCII-Code hat darum auch nur 27, gleich 128 Zeichen. Aus historischen Gründen sind die ersten 32 Positionen auch mit nicht druckbaren Steuerzeichen belegt. So gibt es etwa auf Position 7 den Steuercode für die Glocke in einem Fernschreiber. Wurde dieser Code 7 übermittelt, dann klingelte das Glöckchen im Fernschreiber, z. B. um anzuzeigen, dass das Fernschreiben jetzt komplett übertragen ist.

Fun Fact: Öffnet doch einmal eine Eingabeaufforderun und gebt dort echo ^G^G^G ein (die ^G nicht direkt so eingeben, sondern dazu STRG+G drücken) und drückt Return. Es erklingt dreimal ein Piepton. Wie kommt das? Nun, das Piepen steht für die Glocke und G ist der 7. Buchstabe im Alphabet. STRG steht übrigens für Steuerung (auf englisch CTRL = Control). STRG-A bedeutet also: das erste Steuerzeichen und STRG-G ist eben das 7. Steuerzeichen und das ist die Glocke. Ist doch witzig, wie es das Glockenzeichen vom Fernschreiber auf den modernen Windows-PC geschafft hat. Eigentlich benutzt das niemand mehr, es ist aus Kompatibilitätsgründen aber immer noch vorhanden.

Moderne Computer rechnen mit 8 Bits das Byte und so wurden auf die 128 oberen Positionen besetzt. Das hat man für jedes Land bzw. Tastatur anders gemacht. Die Deutschen haben hier ihre Umlaute platziert, die Polen oder Tschechen ihre vielen "Buchstaben-Dekorationen" und so hat jedes Land irgendwie seine eigene Definition, Codepage genannt. Die wird gewechselt, wenn man die Ländereinstellung bzw. die Tastaturbelegung im Bestriebssystem ändert. Den Aufwand betreiben wir nicht und lassen die oberen 128 Bit einfach leer, sprich wir schreieben dort $FF für "alle Segmente aus" hinein.

Klar ist: unsere alten Definition der Segment-Muster SEG: BYTE $88, $BE, $C4, $94, $B2, $91, $81, $BC, $80, $90 ; 0 bis 9 = Ziffern 0 bis 9 BYTE $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E ; 11 bis 20 = Buchst. A bis J BYTE $A1, $CB, $A5, $A8, $88, $E0, $B0, $E9, $91, $BC ; 21 bis 30 = Buchst. K bis T BYTE $8A, $DA, $D2, $D5, $92, $C4 ; 31 bis 36 = Buchst. U bis Z können wir nicht mehr verwenden, wenn wir ASCII-Definitionen mit dem Definitionsbefehl HELLO: ASCII HELLO WORLD benutzen wollen. Sicher könnten wir uns jetzt die Mühe machen, unseren Text als HELLO: BYTE 18, 15, ... ; 18 für H, 15 für E etc. zu definieren. Aber Texte werden wir noch viele benutzen. Darum ist es einfacher eine zweite SEG-Tabelle zu erstellen, die wir SEG_ASC nennen wollen, und die die Muster bereits an der richtigen ASCII-Position hat: SEG_ASC: ; ersten 32 Bytes leer, weil Steuerzeichen BLKB 32, $FF ; SPC ! " # $ % & ' ( ) * + , - . / BYTE $FF, $3E, $FA, $AA, $91, $E6, $C0, $FE, $C9, $9C, $B6, $E3, $BF, $F7, $7F, $E6 ; 0 1 2 3 4 5 6 7 8 9 : ; < = > ? BYTE $88, $BE, $C4, $94, $B2, $91, $81, $BC, $80, $90, $3F, $3F, $C7, $D7, $97, $44 ; @ A B C D E F G H I J K L M N O BYTE $84, $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E,$A1, $CB, $A5, $A8, $88 ; P Q R S T U V W X Y Z [ \ ] ^ _ BYTE $E0, $B0, $E9, $91, $BC, $8A, $DA, $D2, $D5, $92, $C4, $C9, $B3, $9C, $F8, $DF ; ` a b c d e f g h i j k l m n o BYTE $FB, $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E,$A1, $CB, $A5, $A8, $88 ; p q r s t u v w x y z { | } ~ DEL BYTE $E0, $B0, $E9, $91, $BC, $8A, $DA, $D2, $D5, $92, $C4, $C9, $B3, $9C, $F8, $FF ; ASCII 128-255 = 128 Bytes leer BLKB 128, $FF BLKB ist ein neuer Befehl und wird wohl für "Block of Bytes" stehen. Es gibt übrigens auch BLKW für Block of Words, aber hier geht es ja um Bytes. Mit BLKB 32, $FF sparen wir uns, 32 mal hintereinander $FF zu schreiben. Das macht der Assembler für uns. Das Ergebnis ist das selbe: 32 $FFs hintereinander.

Mit meinem selbstprogrammierten Segment-Rechner, den ich euch ja schon vorgestellt habe, habe ich die Zeichen, die wir noch nicht hatten, flugs noch "gemalt" und natürlich die bereits vorhandenen Definitionen (Ziffern, Buchstaben) an die richtigen Positionen kopiert.

Definieren wir jetzt also einen Text mit ASCII HELLO WORLD so übersetzt der Assembler das anhand der ASCII-Tabelle intern zu einem BYTE 72, 69, 76, 76, 79, 32, 87, 79, 82, 76, 68 und wir haben jetzt mit unseren neuen Tabelle SEG_ASCI an Position 72 eben das erforderliche H, an 69 das E usw. usf.

Das heißt, wir können mit SEG_ASC direkt das richtige Muster für einen ASCII-Text holen und dann auf der Segment-Anzeige ausgeben.

2. Schritt: Anzeigen

Der Anfang ist einfach: Das erste Zeichen des ASCII-Textes gehört auf die linke Anzeige, das 2. Zeichen auf die mittlere und das 3. Zeichen auf die rechte. ldy HELLO+0 ; holt das 1. Zeichen, also das H (bzw. 72) in das Y-Regsiter lda SEG_ASC,y ; holt die 72. (steht so in Y) Muster-Definition in den Akkumulator sta SEGL ; und schreibt das Muster auf das linke Segment ldy HELLO+1 ; holt das 2. Zeichen, also das E (bzw. 69) in das Y-Regsiter lda SEG_ASC,y ; holt die 69. (steht so in Y) Muster-Definition in den Akkumulator sta SEGM ; und schreibt das Muster auf das mittlere Segment ldy HELLO+2 ; holt das 3. Zeichen, also das L (bzw. 76) in das Y-Regsiter lda SEG_ASC,y ; holt die 76. (steht so in Y) Muster-Definition in den Akkumulator sta SEGL ; und schreibt das Muster auf das rechte Segment Damit sind die ersten drei Zeichen HEL geschrieben. Im nächsten Schritt soll das H nach links rausscrollen, das E an die 1. Position, das L an die zweite Position und das neue L aus HELL an die dritte Position geschrieben werden, so dass dann ELL stehenbleibt. Angezeigt werden also immer drei Zeichen gleichzeitig, nur die Position, ab der der Text angezeigt wird, verschiebt sich nach rechts bzw. erhöht jeweils um eins: HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD HELLO WORLD Im Grunde heißt das: immer drei Zeichen anzeigen ab der aktuelen Position, die wir von 0 bis Textlänge minus 1 hochzählen.

Um das zu bewerkstelligen können wir noch einen weiteren Index benutzen, X haben wir ja noch übrig. Statt ldy HELLO+0/+1/+2 schreiben wir einfach ldy HELLO,x und erhöhen das X-Regsiter mit INX nach jeder Runde. Wie der binäre OpCode für INX ist, dass ist uns inzwischen egal, dass soll mal schön der Assembler für uns raussuchen. Wichtig nur zu wissen, dass es den Befehl gibt und wie seine dreistellige Abkürzung ist.

Apropos Assembler. Leider habe ich nicht herausfinden können, wie die Assembler-Anweisung lautet, um die Länge eines mit ASCII definierten Textes abzurufen. Das hätte ich eigentlich vom Assembler erwartet. Aber wahrscheinlich gibt es diesen Befehl einfach nicht. Also müssen wir HELLO WORLD selbst abzählen und kommen auf 11 Zeichen. Mit dem CPX-Befehl (ComPare X) können wir schauen, ob das X-Register einen bestimmten Wert hat, hier 11. Ist dies der Fall, weil wir am Textende angekommen sind, dann springen wir aus der Schleife heraus. start: ldx #0 print: ldy HELLO,x ; X. Buchstaben aus "HELLO WORLD" in Y-Register laden lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGL ldy HELLO+1,x ; X.+1 Buchstaben aus "HELLO WORLD" in Y-Register laden lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGM ldy HELLO+2,x ; X.+2 Buchstaben aus "HELLO WORLD" in Y-Register laden lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGR inx cpx #11 ; HELLO WORLD hat 11 Zeichen bne fertig bra print fertig: bra start ; noch eine Runde Wir testen das Programm (siehe Video am Ende). Mir gefällt noch nicht, dass das HEL sofort da steht. Das soll auch hereinscrollen. Einfachste Lösung: zwei Leerzeichen davor setzen. Da Leerzeichen vor einem mit ASCII definierten Text aber als kosmetische Zeichen zur Einrückung gewertet sind, erledigen wir das mit einem BYTE 32,32 davor.

3. Test und Optimierung des Programmes

Außerdem möchte ich mir das Abzählen der Zeichen des Textes sparen. Das müsste ich jedesmal machen, wenn ich den Text ändere. Einfacher wäre es doch, ein nicht verwendetes Zeichen - wir nehmen hier mal das Zeichen an Position 0 - zu "missbrauchen", um anzuzeigen, dass das Ende des Strings (Textes) erreicht ist. Nicht einfacher als das: Sezten wir ein BYTE 0 hinter die ASCII-Definition.

Anstatt mit CPX #nn zu überprüfen, ob wir nn Durchläufen gemacht haben, prüfen wir einfach, ob das mit ldy HELLO,x in das Y-Register geladene Zeiche eine 0 ist. Und springen dann mit beq fertig aus der Print-Schleife heraus. Zur Erinnerung: BEQ steht für "Branch if EQual zero", also "Verzweige, wenn im Statusregsiter das Zero-Bit gesetzt ist". Und das Zero-Bit wird gesetzt, wenn wir eine 0 ins Y-Register laden. Wichtig ist halt, dass wir den BEQ-Befehl direkt nach dem LDY benutzen, nicht dass ein anderer Befehl das Zero-Bit wieder beeinflusst.

Beim Testen fällt mir dann auf, dass das Scrolling nicht ganz flüssig aussieht. Es ist zwar so schnell als nur irgend geht, aber trotzdem nicht schön, weil die alten Buchstaben noch kurz stehenbleiben (siehe Video am Ende).

Man müsste das Aufbauen der jeweiligen drei Buchstaben so schnell wie möglich machen, aber dann jeweils eine kurze Zeit warten, damit das Ergebnis länger stehen bleibt. Bei höheren Taktgeschwindigkeiten sieht man dann nur noch den richtigen Text und die Laufschrift sieht sauberer aus.

Unterprogramme (Subroutinen) und der Stack

So eine Warte-Funktion braucht man sicher öfters. Darum will ich ein Unterprogramm daruas machen. Unterprogramme haben auch eine Sprungmarke (Label) als Start-Kennzeichnung und ein RTS (ReTurn from Sub routine) am Ende. Beim Aufruf mit JSR (Jump Sub Routine) passiert folgendes: Das Prinzip ist so einfach wie genial. Den Stack (der ja bei $100 bis $1FF im RAM liegt) muss man sich wie einen Kartenstapel vorstellen: Etwas auf den Stack packen (push) ist so, wie auf eine Spielkarte die Rücksprungadresse zu schreiben und offen auf den Kartenstapel (oder wenn leer auf den dafür vorgesehenen Platz zu legen). Dann geht es im Unterprogramm weiter. Das könnte jetzt in ein Unterunterprogramm verzweigen und das Prinzip bleibt das Gleiche: wieder wird eine Karte beschriftet, jetzt mit der zweiten Adresse und oben auf den Kartenstapel gelegt. Jetzt liegen schon zwei Karten auf dem Stapel.

An die erste Karte kommen wir jetzt nicht mehr so einfach dran. Das macht aber gar nichts, weil die CPU nach dem LIFO (Last In, First Out, logischerweise das Gleiche wie FILO First in, Last out) Prinzip arbeitet: Was zuletzt auf den Stapel gelegt wurde, wird als erstes wieder verwendet. Das bedeutet: wenn jetzt ein RTS kommt, holt sich die CPU die oberste Karte vom Stapel, setzt den Program Counter drauf und wirft die Karte weg (technisch: ändert die Adresse des Stackcounters).

Damit liegt beim ersten Rücksprung aus dem Unterunterprogramm nur noch eine Karte auf dem Stack. Und beim nächsten RTS wird diese letzte Karte benutzt. Und magischerweise (naja, eigentlich logischerweise) steht auf der Karte wieder die richtige Rücksprungadresse. Wieder wird die Karte weggeworfen und der PC angepasst. Der Kartenstapel ist jetzt leer. Würde die CPU jetzt dennoch wieder auf einen RTS treffen, dann weiß sie nicht mehr wohin. Es geschieht ein Stack Error und die CPU hält an oder startet neu. Es muss ein Programmierfehler vorliegen.

Mit PHA (PusH Accu), PHX (PusH X) und PHY (PusH y) kann man übrigens selbst etwas auf den Stack packen (pushen) und mit PLA (PulL Accu), PLX (PulL X) und PLY (PulL Y) vom Stack holen (pullen).

Würde unsere Subroutine z. B. über mehrere Schleifen zählen und dafür auch X und Y-Register einbeziehen, würde unser Delay diese Register verändern. Dann müsste sie Routine zu Beginn diese Register mit PHX und PHY auf dem Stack sichern und am Ende mit PLX und PLY wiederherstelle, damit für das aufrufende Programm alles beim Alten ist. Immer dran denken: alle Routinen und Programmteile benutzen die selben Register.

Lange Rede, kurzer Sinn: wir packen unsere Warteroutine einfach in eine Subroutine names delay. Die soll den Akku-Wert auswerten und von dort aus hinunterzählen. Und wenn sie bei Null angekommen ist, springt sie zurück. Der Akku-Wert ist also unser Aufrufparameter und bestimmt, wie lange gewartet werden soll (das ist natürlich wieder von der Taktgeschwindigkeit abhängig, wir haben ja keine Uhr im 6502).

Mit den Verbesserungen sieht der fertige Source-Code wie folgt aus. Und auch das Ergebnis sieht jetzt gut aus (siehe Video am Ende).

Fertiger Source-Code

; Hello World auf 3-fach Sieben-Segment-Anzeige ausgeben ; last edit: Oliver Kuhlemann (www.cool-web.de), 2020-07-10 CHIP W65C02S ; (default) ORG $8000 ; Programmstartadresse (von der CPU aus gesehen) ; --- Konstanten: IO-Adressen --- LED: equ $7000 ; 8-LED-Ausgabe, hört aber auf alles von $7000 bis $7fff SEGL: equ $7001 ; linke 7-Segment-Anzeige SEGM: equ $7002 ; mittlere 7-Segment-Anzeige SEGR: equ $7003 ; rechte 7-Segment-Anzeige init: lda #0 sta LED ; LED-Ausgabe löschen start: ldx #0 print: ldy HELLO,x ; X. Buchstaben aus "HELLO WORLD" in Y-Register laden beq fertig lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGL ldy HELLO+1,x ; X.+1 Buchstaben aus "HELLO WORLD" in Y-Register laden lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGM ldy HELLO+2,x ; X.+2 Buchstaben aus "HELLO WORLD" in Y-Register laden lda SEG_ASC,y ; Y. Zeichen aus 7-Segment-Muster-Tabelle in Akku laden sta SEGR inx lda #10 jsr delay bra print fertig: bra start stop: bra stop ; --- Unterprogramme --- delay: NOP ; Zeit verschwenden NOP dec A bne delay rts ; --- im ROM abgelegte Konstanten HELLO: BYTE 32, 32 ASCII HELLO WORLD BYTE 0 ; Definitionen für 7-Segment-Anzeige 5611 B (common Anode) ; ; 2 ; __ ; 4 | | 1 ; -- 8 ; 10 |__| 40 .=80 ; ; 20 ; 0...9 Ziffern, 11...36 Buchstaben SEG: BYTE $88, $BE, $C4, $94, $B2, $91, $81, $BC, $80, $90 ; 0 bis 9 = Ziffern 0 bis 9 BYTE $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E ; 11 bis 20 = Buchst. A bis J BYTE $A1, $CB, $A5, $A8, $88, $E0, $B0, $E9, $91, $BC ; 21 bis 30 = Buchst. K bis T BYTE $8A, $DA, $D2, $D5, $92, $C4 ; 31 bis 36 = Buchst. U bis Z ; Abbildung der wichtigsten ASCII-Zeichen an der ASCII-Position SEG_ASC: ; ersten 32 Bytes leer, weil Steuerzeichen BLKB 32, $FF ; SPC ! " # $ % & ' ( ) * + , - . / BYTE $FF, $3E, $FA, $AA, $91, $E6, $C0, $FE, $C9, $9C, $B6, $E3, $BF, $F7, $7F, $E6 ; 0 1 2 3 4 5 6 7 8 9 : ; < = > ? BYTE $88, $BE, $C4, $94, $B2, $91, $81, $BC, $80, $90, $3F, $3F, $C7, $D7, $97, $44 ; @ A B C D E F G H I J K L M N O BYTE $84, $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E,$A1, $CB, $A5, $A8, $88 ; P Q R S T U V W X Y Z [ \ ] ^ _ BYTE $E0, $B0, $E9, $91, $BC, $8A, $DA, $D2, $D5, $92, $C4, $C9, $B3, $9C, $F8, $DF ; ` a b c d e f g h i j k l m n o BYTE $FB, $A0, $83, $C9, $86, $C1, $E1, $89, $A2, $BE, $9E,$A1, $CB, $A5, $A8, $88 ; p q r s t u v w x y z { | } ~ DEL BYTE $E0, $B0, $E9, $91, $BC, $8A, $DA, $D2, $D5, $92, $C4, $C9, $B3, $9C, $F8, $FF ; ASCII 128-255 = 128 Bytes leer BLKB 128, $FF

Video

Natürlich gibt es auch heute wieder ein Video, in dem ich abwechselnd die Programmierung in der Entwicklungsumgebung und den Test auf dem Breadboard-Computer zeige. Viel Spaß beim Anschauen.



Im nächsten Teil erweitern wir den Breadboard-Computer dann mit einem LC Display.