Die eine Sache, die Computer wirklich gut können, ist Rechnen. Du erwartest jetzt vielleicht, dass alle CPUs die vier Grundrechenarten beherrschen, aber das ist nicht der Fall. Die ersten 8-Bit-Prozessoren konnten Zahlen nur addieren und subtrahieren, und selbst die Subtraktion wurde durch Addition des negierten Subtrahenden durchgeführt. Multiplikations- und Divisionsbefehle tauchten erstmals bei 16-Bit-Prozessoren auf, obwohl sie in der ersten Generation noch sehr langsam waren.
Einfache Multiplikationen und Divisionen durch Zweierpotenzen können erreicht werden, indem man einen Wert bitweise nach links bzw. rechts verschiebt. Das heißt, das Verschieben eines Wertes um ein Bit nach links ist dasselbe wie die Multiplikation mit 2, während das Verschieben um zwei Bits nach links ihn mit 4 multipliziert, und so weiter.
Aber wie können wir zwei beliebige Zahlen multiplizieren? Das muss Schritt für Schritt gemacht werden, indem wir grundlegende Operationen wie Addition oder Bit-Rotation verwenden. Dieser Artikel wird erklären, wie das auf einer Z80-CPU funktioniert.
In der Schule haben wir gelernt, große Zahlen durch schriftliche Multiplikation zu multiplizieren. Im Grunde teilen wir das Problem auf, indem wir den Multiplikator mit jeder Ziffer des Multiplikanden multiplizieren und dann die Produkte summieren. Wenn wir zum Beispiel das Produkt von 27 und 12 berechnen wollen, berechnen wir 27×2 = 54 und 27×1 = 27, und summieren dann die Produkte 57+270 = 324.
27 × 12
———————————
54
+ 27∙
———————————
324
Wir können denselben Algorithmus auf einem Computer verwenden. Aber warte, müssten wir nicht immer noch multiplizieren, wenn auch mit kleineren Zahlen? Eigentlich nicht! Da Computer Binärziffern verwenden, müssen wir nur entweder mit 1 multiplizieren, was den Wert selbst ergibt, oder mit 0, was immer 0 ergibt.
Das ist dieselbe schriftliche Multiplikation von 27 (11011) und 12 (1100) mit Binärzahlen:
11011 × 1100
————————————————
00000
00000∙
11011∙∙
+ 11011∙∙∙
————————————————
101000100
Die Schritte können in einer Schleife ausgeführt werden. Zu Beginn wird ein Ergebnisregister mit Null initialisiert. Wenn das ganz rechte Bit des Multiplikanden 1 ist, wird der Multiplikator zum Ergebnisregister addiert. Danach wird der Multiplikand um ein Bit nach rechts rotiert und der Multiplikator um ein Bit nach links rotiert. Diese Schleife wird wiederholt, bis der Multiplikand null ist, da sich das Ergebnis danach nicht mehr ändert.
Das folgende Z80-Assemblercode-Beispiel multipliziert die Werte in den Registerpaaren BC und DE und gibt das Produkt im Registerpaar HL zurück. Wenn während der Multiplikation ein Überlauf aufgetreten ist, wird das Carry-Flag gesetzt.
Der Multiplikand wird im Registerpaar BC gehalten. Um ihn um ein Bit nach rechts zu rotieren, verwenden wir zuerst den Befehl srl b. Er rotiert das B-Register, verschiebt den Wert von Bit 0 in das Carry-Flag und fügt eine 0 an Bit 7 ein, sodass der Multiplikand bei jeder Rotation mit Nullen aufgefüllt wird. Danach rotiert rr c das C-Register und verschiebt den Inhalt des Carry-Flags an Bit 7. Beide Befehle kombiniert rotieren das Registerpaar BC um ein Bit nach rechts, fügen eine 0 in das höchste Bit ein. Das niedrigste Bit wird in das Carry-Flag verschoben, wo es getestet werden kann.
Wir machen im Wesentlichen dasselbe mit dem Multiplikator im Registerpaar DE, aber in der entgegengesetzten Richtung. Da eine Rotation nach links um ein Bit den Wert im Grunde nur verdoppelt, hätten wir auch add de,de verwenden können. Leider bietet der Z80 keinen solchen Befehl an.
multiply: ld hl, 0 ; lösche das Ergebnisregister
.loop: ld a, b ; ist BC == 0?
or c ; (setzt auch das Carry-Flag zurück)
ret z ; dann sind wir fertig!
srl b ; logischer Rechts-Shift von BC
rr c ; Bit 0 geht ins Carry-Flag
jr nc, .zerobit ; es sei denn, Bit 0 war 0
add hl, de ; addiere Multiplikator zum Ergebnis
ret c ; Rückkehr bei Überlauf
.zerobit: sla e ; verschiebe Multiplikator nach links
rl d ; oberstes Bit geht ins Carry-Flag
ret c ; Rückkehr bei Überlauf
jr .loop ; nächste Iteration
Das Beispiel multipliziert nur positive ganze Zahlen. Um negative ganze Zahlen zu multiplizieren, müssen wir zuerst alle Faktoren in positive Zahlen umwandeln und die Multiplikation durchführen. Das Ergebnis muss dann negiert werden, wenn einer der Faktoren negativ war, aber nicht beide.
Vor einigen Tagen habe ich meinen tzxtools einen Z80-Disassembler hinzugefügt. Da ich keinen für Python finden konnte, beschloss ich, meinen eigenen zu schreiben. Das Ergebnis passt in eine einzige Python-Quelldatei. Dieser Artikel ist das Making-of…
Der Zilog Z80 ist ein 8-Bit-Prozessor. Das bedeutet, dass (fast) alle Befehle nur 1 Byte verbrauchen. Zum Beispiel hat der Befehl ret (Rücksprung aus dem Unterprogramm) C9 als Byte-Darstellung. Einigen Befehlen folgt ein weiteres Byte (als zu verwendende Konstante oder als relativer Sprungabstand) oder zwei weitere Bytes (als 16-Bit-Konstante oder absolute Adresse). Einige Beispiele:
C9 | -- | -- | -- | ret | Rücksprung aus dem Unterprogramm |
3E | 23 | -- | -- | ld a,$23 | Lade Konstante $23 in das A-Register |
C3 | 34 | 12 | -- | jp $1234 | Springe zu Adresse $1234 |
Beachte, dass bei 16-Bit-Konstanten die Bytes im Speicher scheinbar vertauscht sind. Das liegt daran, dass der Z80 eine sogenannte Little-Endian-CPU ist, bei der das niederwertige Byte zuerst kommt. Einige andere Prozessorfamilien (wie der 68000) sind Big-Endian und speichern das höherwertige Wort zuerst.
Es gibt also nur 256 Befehle, was es ziemlich einfach macht, sie zu disassemblieren. Ich habe ein Array mit 256 Einträgen verwendet, wobei jeder Eintrag den Befehl des jeweiligen Bytes als String enthält. Für Konstanten habe ich Platzhalter wie “##” oder “$” verwendet. Wenn ein solcher Platzhalter nach der Dekodierung im Befehlsstring gefunden wird, wird die entsprechende Anzahl von Bytes abgerufen und der Platzhalter durch den gefundenen Wert ersetzt.
Wenn wir einen Disassembler für die 8080-CPU schreiben würden, wären wir jetzt fertig. Allerdings hat der Z80 einige Erweiterungen, die abgedeckt werden müssen, nämlich zwei erweiterte Befehlssätze und zwei Indexregister.
Ein Satz erweiterter Befehle wird durch ein $ED-Präfix ausgewählt und enthält selten verwendete Befehle. Der andere Befehlssatz wird durch ein $CB-Präfix ausgewählt und verfügt über Bit-Manipulationen und einige Rotationsbefehle.
ED | B0 | -- | -- | ldir | Kopiere BC Bytes von HL nach DE |
ED | 4B | 78 | 56 | ld bc,($5678) | Lädt den Wert von Adresse $5678 in das BC-Registerpaar |
CB | C7 | -- | -- | set 0,a | Setze Bit 0 im A-Register |
Für das $ED-Präfix habe ich ein separates Array zur Dekodierung der Befehle verwendet. Die $CB-Befehle folgen einem einfachen Bit-Schema, sodass die Befehle durch ein paar Zeilen Python-Code dekodiert werden konnten.
Der Z80 bietet zwei Indexregister namens IX und IY. Sie werden verwendet, wenn dem Befehl ein $DD- bzw. $FD-Byte vorangestellt ist. Diese Präfixe verwenden im Grunde das ausgewählte Indexregister anstelle des HL-Registerpaars für den aktuellen Befehl. Wenn jedoch der Adressierungsmodus (HL) verwendet wird, wird ein zusätzlicher Byte-großer Offset bereitgestellt. Die Indexregister können mit dem $CB-Präfix kombiniert werden, was die Dinge kompliziert machen kann.
E5 | -- | -- | -- | push hl | Lege HL auf den Stack |
DD | E5 | -- | -- | push ix | Lege IX auf den Stack (gleicher Opcode E5, aber jetzt mit DD-Präfix) |
FD | E5 | -- | -- | push iy | Lege IY auf den Stack (jetzt mit FD-Präfix) |
FD | 21 | 80 | FF | ld iy,$FF80 | Lade Konstante $FF80 in das IY-Register |
DD | 7E | 09 | -- | ld a,(ix+9) | Lade Wert an Adresse IX+9 in das A-Register (Offset ist nach dem Opcode) |
CB | C6 | -- | -- | set 0,(hl) | Setze Bit 0 an der Adresse in HL |
FD | CB | 03 | C6 | set 0,(iy+3) | Setze Bit 0 an der Adresse IY+3 (Offset ist vor dem Opcode) |
Wenn der Disassembler ein $DD- oder $FD-Präfix erkennt, setzt er ein entsprechendes ix- oder iy-Flag. Später, wenn der Befehl dekodiert wird, wird jedes Vorkommen von HL entweder durch IX oder IY ersetzt. Wenn (HL) gefunden wurde, wird ein weiteres Byte aus dem Bytestream geholt und als Index-Offset für (IX+dd) oder (IY+dd) verwendet.
Es gibt eine Ausnahme. Die obigen Beispiele zeigen, dass der Index-Offset immer beim dritten Byte zu finden ist. Das bedeutet, dass, wenn das Indexregister mit einem $CB-Präfix kombiniert wird, der eigentliche Befehl nach dem Index steht. Dies ist ein Fall, der in meinem Disassembler eine Sonderbehandlung benötigte. Wenn diese Kombination erkannt wird, wird der Index-Offset abgerufen und gespeichert, bevor der Befehl dekodiert wird.
Puh, das war kompliziert. Jetzt sind wir in der Lage, den offiziellen Befehlssatz der Z80-CPU zu disassemblieren. Aber wir sind noch nicht fertig. Es gibt eine Reihe von undokumentierten Befehlen. Der Hersteller Zilog hat sie nie dokumentiert, sie sind nicht sonderlich nützlich, aber sie funktionieren trotzdem auf fast jeder Z80-CPU und werden tatsächlich verwendet.
Die meisten von ihnen werden einfach durch die Erweiterung der Befehls-Arrays abgedeckt. Zusätzlich wirken sich die $DD- oder $FD-Präfixe nicht nur auf das HL-Registerpaar aus, sondern auch nur auf die H- und L-Register, was IXH/IYH- und IXL/IYL-Register ergibt. Dies wird durch die Nachbearbeitung der Befehle abgedeckt. Ein ganz besonderer Fall ist das $CB-Präfix in Kombination mit Indexregistern, was eine ganze Reihe neuer Befehle ergibt, die das Ergebnis einer Bit-Operation in einem anderen Register speichern. Dies erforderte tatsächlich eine Sonderbehandlung durch einen separaten $CB-Präfix-Befehlsdekodierer.
Schließlich wird der ZX Spectrum Next einige neue Befehle wie Multiplikation oder Hardware-bezogene Dinge für den ZX Spectrum bringen. Auch diese wurden durch die Erweiterung der Befehls-Arrays abgedeckt. Die einzigen Ausnahmen sind der Befehl push [const], bei dem die Konstante als Big-Endian gespeichert wird, und der Befehl nextreg [reg],[val], dem (als einzigem Befehl) zwei Konstanten folgen.
Und das war’s. 😄 So schreibt man an einem einzigen Nachmittag einen Z80-Disassembler.
Auf einem langsamen Prozessor wie dem Z80 ist es unerlässlich, über die Ausführungszeit nachzudenken. Oft ist ein sauberer Ansatz zu langsam, und man muss den Code optimieren, um ihn viel schneller zu machen.
Die ZX Spectrum Bildschirm-Bitmap ist nicht linear. Die 192 Pixelzeilen sind in drei Abschnitte von 64 Pixelzeilen unterteilt. In jedem dieser Abschnitte kommen zuerst alle 8 ersten Pixelzeilen, gefolgt von den zweiten Pixelzeilen und so weiter. Der Vorteil ist, dass man beim Schreiben von Zeichen in die Bitmap nur das H-Register inkrementieren muss, um die nächste Bitmap-Zeile zu erreichen. Der Nachteil ist, dass eine pixelgenaue Adressberechnung die Hölle ist.
So werden die Koordinaten eines Pixels auf die Adresse abgebildet:
| H | L | ||||||||||||||
| 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
| 0 | 1 | 0 | Y7 | Y6 | Y2 | Y1 | Y0 | Y5 | Y4 | Y3 | X7 | X6 | X5 | X4 | X3 |
X2, X1 und X0 repräsentieren die Bitnummer an der Adresse. Sie können als Zähler für Rechts-Shift-Operationen verwendet werden.
Mein erster Versuch war ein geradliniger Code, der die Bitgruppen verschob, maskierte und an die richtigen Stellen bewegte. Er benötigte 117 Zyklen. Das ist nett, aber wir können es besser machen.
Wir brauchen viele Rotationsoperationen, um die Bits an die richtige Position zu schieben. Die Rotation ist eine ziemlich teure Operation auf einem Z80, da es keine Befehle gibt, die um mehr als ein Bit auf einmal rotieren. Meine Idee war, die X-Koordinate durch 8 zu teilen (indem ich sie dreimal nach rechts rotiere) und gleichzeitig Y3 bis Y5 in das L-Register zu schieben. Mit einem ähnlichen Trick konnte ich Bit 14 während des Rotierens setzen, was mir eine weitere or-Operation mit einer Konstanten ersparte.
Dies ist der finale optimierte Code. Er nimmt die X-Koordinate im C-Register und die Y-Koordinate im B-Register. Die Bildschirmadresse wird im HL-Registerpaar zurückgegeben. BC und DE bleiben unverändert, also gibt es keinen Bedarf für teure push- und pop-Operationen.
pixelAddress: ld a, b
and %00000111
ld h, a ; h enthält Y2-Y0
ld a, b
rra
scf ; Bit 14 setzen
rra
rra
ld l, a ; l enthält Y5-Y3
and %01011000
or h
ld h, a ; h ist jetzt komplett
ld a, c ; X durch 8 teilen
rr l ; und Y5-Y3 hineinrotieren
rra
rr l
rra
rr l
rra
ld l, a ; l ist jetzt komplett
ret
Er benötigt nur 108 Zyklen, inklusive ret. Die Optimierung hat mir 9 Zyklen (oder etwa 8%) gespart. Das klingt nicht nach viel, aber wenn der Code in einer Schleife aufgerufen wird, werden diese 9 Zyklen mit der Anzahl der Schleifendurchläufe multipliziert.
Ich behaupte, dies ist die schnellste Lösung, ohne auf eine Lookup-Tabelle zurückzugreifen. Versuch mich zu schlagen! 😁
Wenn du ein Kind der 1980er Jahre bist, erinnerst du dich vielleicht an den Sinclair ZX Spectrum. Er war ein erschwinglicher Heimcomputer, der an ein Farbfernsehgerät angeschlossen werden konnte und Kompaktkassetten als Massenspeicher nutzte.
Mein erster Computer war ein Sinclair ZX-81. Ich habe darauf BASIC und auch Z80-Assembler gelernt. Bald wurde der ZX-81 durch einen ZX Spectrum ersetzt. Ich habe viel programmiert, alle möglichen Tools und ein paar Demos geschrieben. Ich wollte immer zusammen mit meinen Freunden ein Spiel schreiben, aber als Teenager fehlte uns die nötige Ausdauer, um so ein Projekt zu Ende zu bringen. Als ich dann meinen Amiga 500 bekam, verlor ich schnell jedes Interesse an meinem guten alten Spectrum.
Aber es ist nie zu spät… Ich habe gerade ein winzig kleines Spielprojekt namens Coredump gestartet, geschrieben in Z80-Assembler für den guten alten ZX Spectrum. Warum? Einfach weil ich es kann. Weil ich es immer wollte. Und weil Retro-Programmierung auch eine Menge Spaß bedeutet!
Dieser erste Artikel handelt von der Toolchain, die ich verwende. Ich werde weitere Artikel hinzufügen, während das Spiel wächst und (hoffentlich) eines Tages fertiggestellt wird.
Damals in den 80er Jahren war die Assembler-Programmierung auf dem ZX Spectrum eine sehr mühsame Aufgabe. Ich musste mich mit Kassettenbändern (und ihrem sehr langsamen Zugriff) herumschlagen, einem Assembler, der bereits etwas von dem knappen RAM verbrauchte, und ich hatte keine Tools, die den Entwicklungsprozess vereinfachten. Wenn ich einen Fehler machte und der Spectrum abstürzte, musste ich den Assembler, den Quellcode und die Ressourcen neu von der Kassette laden. Oft verlor ich auch einen Teil meiner Arbeit, denn im Umgang mit Kassetten ist das Speichern eines Quellcodes viel mehr Arbeit, als nur Strg-S zu drücken, also riskierte ich lieber, die Änderungen nach einem Absturz neu eintippen zu müssen.
Heute ist es viel einfacher, Retro-Software zu schreiben. Ich kann sie auf meinem Linux-Rechner entwickeln, der sehr schnell ist und viel Speicherplatz hat. Ich verwende einen modernen Texteditor und viele mächtige Tools. Zum Testen muss ich nur eine Snapshot-Datei assemblieren und sie auf einem Emulator ausführen, was weniger als eine Sekunde dauert. Wenn der Emulator abstürzt, geht keine Arbeit verloren.
Das sind die Tools, die ich zum Programmieren verwende. Alle davon sind für Linux und MacOS verfügbar, einige auch für Windows.
- Fuse ist ein exzellenter ZX-Spectrum-Emulator mit einem sehr präzisen Timing.
- Ein ordentlicher Editor. Ich habe mit Atom angefangen, aber jetzt benutze ich Eclipse, weil es besser zu meinem Workflow passt. Nimm einfach deinen Lieblingseditor.
- zasm ist ein netter Z80-Cross-Assembler, der auch in der Lage ist, SNA-Dateien zu generieren, die auf dem Emulator laufen.
- Multipaint ist ein Open-Source-Zeichenprogramm, das mit den Einschränkungen der ZX-Spectrum-Grafik umgehen kann (und glaub mir, es gibt Einschränkungen). Es hat sich für die Erstellung von Sprites und Tiles als nicht so nützlich erwiesen, weil es keine präzise Kontrolle über die Papier- und Tintenfarbe bietet, die in der generierten Bildschirmdatei verwendet wird.
- Daher verwende ich auch Gimp zum Pixeln von Sprites und Tiles. Vielleicht werde ich später auch Inkscape nutzen.
- Tiled ist ein exzellenter Map-Editor. Ich benutze ihn, um die Welt meines Spiels zu entwerfen.
- Einige selbstgeschriebene Hilfsprogramme konvertieren die Grafiken und die Welt in das Binärformat, das im Spiel verwendet wird. Ich verwende Java für diese Tools, einfach weil ich mich mit Java am besten auskenne. Es gibt keinen technischen Grund dafür, nimm einfach die Sprache, mit der du dich am wohlsten fühlst.
- Zu guter Letzt verwende ich ant, um alle Teile zusammenzufügen und die Snapshot-Datei auszuführen.
Die Hardware des ZX Spectrum ist sehr einfach und leicht zu verstehen (was auch bedeutet, dass du viele Dinge ohne Hardware-Unterstützung machen musst). Der Z80-Prozessor hat einen einfachen Befehlssatz. Retro-Programmierung ist also nicht nur etwas für die Älteren, sondern auch für die junge Generation, die sich für einen ersten Einstieg auf die Hardware-Ebene von Computern interessiert. Es macht auch Spaß, das Maximum aus einer begrenzten und langsamen Hardware herauszuholen.
Es gibt eine Menge Dokumentation im Netz:
- World of Spectrum hat viel Hardware-Dokumentation in der Rubrik references.
- Ein kurzer Überblick über alle Z80-Befehle und ihre Timings.
- Ein kommentiertes ROM-Disassembly bietet einen ersten Blick auf den Z80-Assembler und enthält auch einige nützliche Funktionen (wie Multiplikation; der Z80 selbst bietet keine Multiplikations- oder Divisionsbefehle).
Als ich anfing, nach Ressourcen für den ZX Spectrum zu suchen, war ich überrascht, wie aktiv die Retro-Szene ist. Es gibt viele Blogs, die Tutorials anbieten, die Hardware-Tricks erklären, und es gibt sogar eine Demoszene, die dir Dinge zeigt, von denen du nie gedacht hättest, dass sie auf dieser Maschine möglich sind. Immerhin ist der Speccy jetzt fast 35 Jahre alt und war selbst zu seiner Zeit nicht für eine leistungsstarke Hardware berühmt.