Teil 4: QFTASM und Cogol
Architekturübersicht
Kurz gesagt, unser Computer verfügt über eine asynchrone 16-Bit-RISC-Harvard-Architektur. Wenn ein Prozessor von Hand gebaut wird, ist eine RISC- Architektur ( Reduced Instruction Set Computer ) praktisch eine Voraussetzung. In unserem Fall bedeutet dies, dass die Anzahl der Opcodes gering ist und, was noch wichtiger ist, dass alle Anweisungen auf sehr ähnliche Weise verarbeitet werden.
Als Referenz verwendete der Wireworld-Computer eine transportgetriggerte Architektur , in der der einzige Befehl war MOV
und Berechnungen durch Schreiben / Lesen von Sonderregistern durchgeführt wurden. Obwohl dieses Paradigma zu einer sehr einfach zu implementierenden Architektur führt, ist das Ergebnis auch unbrauchbar: Alle arithmetischen / logischen / bedingten Operationen erfordern drei Befehle. Uns war klar, dass wir eine viel weniger esoterische Architektur schaffen wollten.
Um unseren Prozessor einfach zu halten und gleichzeitig die Benutzerfreundlichkeit zu erhöhen, haben wir einige wichtige Designentscheidungen getroffen:
- Keine Register. Jede Adresse im RAM wird gleich behandelt und kann als Argument für jede Operation verwendet werden. In gewissem Sinne bedeutet dies, dass der gesamte RAM wie Register behandelt werden könnte. Dies bedeutet, dass es keine speziellen Lade- / Speicheranweisungen gibt.
- In ähnlicher Weise Memory-Mapping. Alles, was geschrieben oder gelesen werden kann, hat ein einheitliches Adressierungsschema. Dies bedeutet, dass der Programmzähler (PC) die Adresse 0 hat und der einzige Unterschied zwischen regulären Befehlen und Steuerflussbefehlen darin besteht, dass Steuerflussbefehle die Adresse 0 verwenden.
- Daten werden seriell übertragen und parallel gespeichert. Aufgrund der "elektronenbasierten" Natur unseres Computers sind Addition und Subtraktion wesentlich einfacher zu implementieren, wenn die Daten in serieller Little-Endian-Form (niedrigstwertiges Bit zuerst) übertragen werden. Durch serielle Daten entfällt außerdem die Notwendigkeit umständlicher Datenbusse, die sowohl sehr umfangreich als auch zeitaufwendig sind (damit die Daten zusammen bleiben, müssen alle "Fahrspuren" des Busses dieselbe Fahrverzögerung aufweisen).
- Harvard-Architektur, dh eine Unterteilung zwischen Programmspeicher (ROM) und Datenspeicher (RAM). Obwohl dies die Flexibilität des Prozessors verringert, hilft dies bei der Größenoptimierung: Die Länge des Programms ist viel größer als die Menge an RAM, die wir benötigen, sodass wir das Programm in ROM aufteilen und uns dann auf das Komprimieren des ROM konzentrieren können , was viel einfacher ist, wenn es schreibgeschützt ist.
- 16-Bit-Datenbreite. Dies ist die kleinste Zweierpotenz, die breiter ist als eine Standard-Tetris-Karte (10 Blöcke). Dies gibt uns einen Datenbereich von -32768 bis +32767 und eine maximale Programmlänge von 65536 Anweisungen. (2 ^ 8 = 256 Anweisungen reichen für die meisten einfachen Dinge aus, die ein Spielzeugprozessor ausführen soll, aber nicht Tetris.)
- Asynchroner Entwurf. Anstatt dass eine zentrale Uhr (oder äquivalent mehrere Uhren) das Timing des Computers bestimmt, werden alle Daten von einem "Taktsignal" begleitet, das sich parallel zu den Daten bewegt, wenn sie den Computer umfließen. Bestimmte Pfade können kürzer sein als andere, und während dies für einen zentral getakteten Entwurf Schwierigkeiten bereiten würde, kann ein asynchroner Entwurf leicht mit Operationen mit variabler Zeit umgehen.
- Alle Anweisungen sind gleich groß. Wir waren der Meinung, dass eine Architektur, in der jeder Befehl 1 Opcode mit 3 Operanden (Wert, Wert, Ziel) enthält, die flexibelste Option ist. Dies umfasst sowohl binäre Datenoperationen als auch bedingte Verschiebungen.
- Einfaches Adressierungsmodus-System. Eine Vielzahl von Adressierungsmodi ist sehr nützlich, um beispielsweise Arrays oder Rekursionen zu unterstützen. Mit einem relativ einfachen System ist es uns gelungen, mehrere wichtige Adressierungsmodi zu implementieren.
Eine Illustration unserer Architektur finden Sie im Übersichtsbeitrag.
Funktionalität und ALU-Operationen
Von hier aus galt es zu bestimmen, welche Funktionalität unser Prozessor haben sollte. Besonderes Augenmerk wurde auf die einfache Implementierung sowie die Vielseitigkeit der einzelnen Befehle gelegt.
Bedingte Bewegungen
Bedingte Bewegungen sind sehr wichtig und dienen sowohl als Kontrollfluss im kleinen als auch im großen Maßstab. "Klein" bezieht sich auf seine Fähigkeit, die Ausführung einer bestimmten Datenverschiebung zu steuern, während sich "groß" auf seine Verwendung als bedingte Sprungoperation bezieht, um den Steuerfluss auf ein beliebiges Stück Code zu übertragen. Es gibt keine dedizierten Sprungoperationen, da durch eine bedingte Verschiebung aufgrund der Speicherzuordnung sowohl Daten in den regulären RAM kopiert als auch eine Zieladresse auf den PC kopiert werden kann. Aus einem ähnlichen Grund haben wir uns auch dafür entschieden, sowohl auf bedingungslose Züge als auch auf bedingungslose Sprünge zu verzichten: Beide können als bedingter Zug mit einer Bedingung implementiert werden, die fest auf WAHR codiert ist.
Wir haben uns für zwei verschiedene Arten von bedingten Zügen entschieden: "move if not zero" ( MNZ
) und "move if less than zero" ( MLZ
). Funktionell wird MNZ
geprüft, ob ein Bit in den Daten eine 1 ist, während MLZ
geprüft wird, ob das Vorzeichenbit 1 ist. Sie sind für Gleichheiten bzw. Vergleiche nützlich. Der Grund haben wir uns für diese beide über anderen, wie „bewegen , wenn Null“ ( MEZ
) oder „bewegen , wenn größer als Null“ ( MGZ
) war , dass MEZ
würde ein TRUE - Signal von einem leeren Signal erzeugt wird , während MGZ
eine komplexere Überprüfung ist, erfordert das die Vorzeichenbit ist 0, während mindestens ein anderes Bit 1 ist.
Arithmetik
Die nächstwichtigsten Anweisungen zur Steuerung des Prozessordesigns sind die Grundrechenarten. Wie bereits erwähnt, verwenden wir serielle Little-Endian-Daten, wobei die Wahl der Endianzahl von der Einfachheit der Additions- / Subtraktionsoperationen abhängt. Indem das niedrigstwertige Bit zuerst ankommt, können die Recheneinheiten das Übertragsbit leicht verfolgen.
Wir haben die 2-Komplement-Darstellung für negative Zahlen gewählt, da dies die Addition und Subtraktion konsistenter macht. Es ist erwähnenswert, dass der Wireworld-Computer die Ergänzung 1 verwendet hat.
Addition und Subtraktion sind das Ausmaß der systemeigenen Rechenunterstützung unseres Computers (abgesehen von Bitverschiebungen, die später erläutert werden). Andere Operationen wie die Multiplikation sind viel zu komplex, um von unserer Architektur verarbeitet zu werden, und müssen in Software implementiert werden.
Bitweise Operationen
Unser Prozessor verfügt über AND
, OR
und XOR
Anweisungen, die genau das tun, was Sie erwarten. Anstatt eine NOT
Anweisung zu haben, haben wir uns für eine "and-not" ( ANT
) Anweisung entschieden. Die Schwierigkeit bei der NOT
Anweisung besteht wiederum darin, dass sie aus einem Signalmangel ein Signal erzeugen muss, was bei zellularen Automaten schwierig ist. Der ANT
Befehl gibt nur dann 1 zurück, wenn das erste Argumentbit 1 und das zweite Argumentbit 0 ist. Dies NOT x
ist also gleichbedeutend mit ANT -1 x
(sowie XOR -1 x
). Darüber hinaus ANT
ist es vielseitig und hat seinen Hauptvorteil in der Maskierung: Im Falle des Tetris-Programms verwenden wir es, um Tetrominoes zu löschen.
Bitverschiebung
Die Bitverschiebungsoperationen sind die komplexesten Operationen, die von der ALU gehandhabt werden. Sie nehmen zwei Dateneingaben vor: einen Wert zum Verschieben und einen Betrag zum Verschieben. Trotz ihrer Komplexität (aufgrund des variablen Verschiebungsgrades) sind diese Vorgänge für viele wichtige Aufgaben von entscheidender Bedeutung, einschließlich der zahlreichen "grafischen" Vorgänge in Tetris. Bitverschiebungen würden auch als Grundlage für effiziente Multiplikations- / Divisionsalgorithmen dienen.
Unser Prozessor verfügt über drei Bitverschiebungsoperationen, "Verschieben nach links" ( SL
), "Verschieben nach rechts logisch" ( SRL
) und "Verschieben nach rechts arithmetisch" ( SRA
). Die ersten beiden Bitverschiebungen ( SL
und SRL
) füllen die neuen Bits mit allen Nullen (was bedeutet, dass eine nach rechts verschobene negative Zahl nicht länger negativ ist). Wenn das zweite Argument der Verschiebung außerhalb des Bereichs von 0 bis 15 liegt, enthält das Ergebnis erwartungsgemäß alle Nullen. Bei der letzten Bitverschiebung behält SRA
die Bitverschiebung das Vorzeichen der Eingabe bei und wirkt daher als echte Division durch zwei.
Anleitung Pipelining
Jetzt ist es an der Zeit, über einige der wichtigsten Details der Architektur zu sprechen. Jeder CPU-Zyklus besteht aus den folgenden fünf Schritten:
1. Holen Sie sich den aktuellen Befehl aus dem ROM
Der aktuelle Wert des PCs wird verwendet, um die entsprechende Anweisung aus dem ROM abzurufen. Jeder Befehl hat einen Operationscode und drei Operanden. Jeder Operand besteht aus einem Datenwort und einem Adressierungsmodus. Diese Teile werden voneinander getrennt, wenn sie aus dem ROM gelesen werden.
Der Opcode besteht aus 4 Bits, um 16 eindeutige Opcodes zu unterstützen, von denen 11 zugewiesen sind:
0000 MNZ Move if Not Zero
0001 MLZ Move if Less than Zero
0010 ADD ADDition
0011 SUB SUBtraction
0100 AND bitwise AND
0101 OR bitwise OR
0110 XOR bitwise eXclusive OR
0111 ANT bitwise And-NoT
1000 SL Shift Left
1001 SRL Shift Right Logical
1010 SRA Shift Right Arithmetic
1011 unassigned
1100 unassigned
1101 unassigned
1110 unassigned
1111 unassigned
2. Schreiben Sie das Ergebnis (falls erforderlich) der vorherigen Anweisung in den RAM
Abhängig von der Bedingung des vorherigen Befehls (z. B. dem Wert des ersten Arguments für eine bedingte Bewegung) wird ein Schreibvorgang ausgeführt. Die Adresse des Schreibvorgangs wird durch den dritten Operanden des vorherigen Befehls bestimmt.
Es ist wichtig zu beachten, dass das Schreiben nach dem Abrufen von Befehlen erfolgt. Dies führt zur Erzeugung eines Verzweigungsverzögerungsschlitzes, in dem der Befehl unmittelbar nach einem Verzweigungsbefehl (jede Operation, die auf den PC schreibt) anstelle des ersten Befehls am Verzweigungsziel ausgeführt wird.
In bestimmten Fällen (wie bei bedingungslosen Sprüngen) kann der Verzweigungsverzögerungsschlitz wegoptimiert werden. In anderen Fällen ist dies nicht möglich, und die Anweisung nach einer Verzweigung muss leer bleiben. Darüber hinaus bedeutet diese Art von Verzögerungsschlitz, dass Verzweigungen ein Verzweigungsziel verwenden müssen, das 1 Adresse unter dem tatsächlichen Zielbefehl liegt, um das auftretende PC-Inkrement zu berücksichtigen.
Kurz gesagt, da die Ausgabe des vorherigen Befehls nach dem Abrufen des nächsten Befehls in den RAM geschrieben wird, müssen bedingte Sprünge mit einem leeren Befehl versehen werden, sonst wird der PC für den Sprung nicht ordnungsgemäß aktualisiert.
3. Lesen Sie die Daten für die Argumente des aktuellen Befehls aus dem RAM
Wie bereits erwähnt, besteht jeder der drei Operanden aus einem Datenwort und einem Adressierungsmodus. Das Datenwort hat 16 Bit, die gleiche Breite wie der RAM. Der Adressierungsmodus beträgt 2 Bits.
Adressierungsmodi können für einen Prozessor wie diesen eine Quelle von erheblicher Komplexität sein, da viele reale Adressierungsmodi mehrstufige Berechnungen beinhalten (wie das Hinzufügen von Offsets). Gleichzeitig spielen vielseitige Adressierungsmodi eine wichtige Rolle für die Benutzerfreundlichkeit des Prozessors.
Wir wollten die Konzepte der Verwendung von fest codierten Zahlen als Operanden und der Verwendung von Datenadressen als Operanden vereinheitlichen. Dies führte zur Schaffung von zählerbasierten Adressierungsmodi: Der Adressierungsmodus eines Operanden ist einfach eine Zahl, die angibt, wie oft die Daten um eine RAM-Leseschleife gesendet werden sollen. Dies umfasst die sofortige, direkte, indirekte und doppelte indirekte Adressierung.
00 Immediate: A hard-coded value. (no RAM reads)
01 Direct: Read data from this RAM address. (one RAM read)
10 Indirect: Read data from the address given at this address. (two RAM reads)
11 Double-indirect: Read data from the address given at the address given by this address. (three RAM reads)
Nachdem diese Dereferenzierung durchgeführt wurde, haben die drei Operanden des Befehls unterschiedliche Rollen. Der erste Operand ist normalerweise das erste Argument für einen binären Operator, dient jedoch auch als Bedingung, wenn der aktuelle Befehl eine bedingte Bewegung ist. Der zweite Operand dient als zweites Argument für einen Binäroperator. Der dritte Operand dient als Zieladresse für das Ergebnis des Befehls.
Da die ersten beiden Befehle als Daten und der dritte als Adresse dienen, werden die Adressierungsmodi in Abhängigkeit von der Position, an der sie verwendet werden, geringfügig unterschiedlich interpretiert. Der Direktmodus wird beispielsweise zum Lesen von Daten aus einer festen RAM - Adresse verwendet ein RAM-Lesevorgang ist erforderlich), aber der Sofortmodus wird zum Schreiben von Daten an eine feste RAM-Adresse verwendet (da keine RAM-Lesevorgänge erforderlich sind).
4. Berechnen Sie das Ergebnis
Der Operationscode und die ersten beiden Operanden werden an die ALU gesendet, um eine Binäroperation auszuführen. Für die arithmetischen, bitweisen und Verschiebungsoperationen bedeutet dies, dass die relevante Operation ausgeführt wird. Für die bedingten Züge bedeutet dies einfach die Rückgabe des zweiten Operanden.
Der Operationscode und der erste Operand werden verwendet, um die Bedingung zu berechnen, die bestimmt, ob das Ergebnis in den Speicher geschrieben wird oder nicht. Im Fall von bedingten Bewegungen bedeutet dies, entweder zu bestimmen, ob irgendein Bit im Operanden 1 (für MNZ
) ist, oder zu bestimmen, ob das Vorzeichenbit 1 (für MLZ
) ist. Wenn der Opcode keine bedingte Bewegung ist, wird immer geschrieben (die Bedingung ist immer wahr).
5. Inkrementieren Sie den Programmzähler
Schließlich wird der Programmzähler gelesen, inkrementiert und geschrieben.
Aufgrund der Position des PC-Inkrements zwischen dem Befehlslesevorgang und dem Befehlsschreibvorgang bedeutet dies, dass ein Befehl, der den PC um 1 inkrementiert, ein No-Op ist. Eine Anweisung, die den PC auf sich selbst kopiert, bewirkt, dass die nächste Anweisung zweimal hintereinander ausgeführt wird. Seien Sie jedoch gewarnt, dass mehrere PC-Anweisungen in einer Reihe komplexe Auswirkungen haben können, einschließlich Endlosschleifen, wenn Sie die Anweisungs-Pipeline nicht beachten.
Suche nach der Tetris-Versammlung
Wir haben eine neue Assemblersprache namens QFTASM für unseren Prozessor erstellt. Diese Assemblersprache entspricht 1: 1 dem Maschinencode im ROM des Computers.
Jedes QFTASM-Programm besteht aus einer Reihe von Anweisungen, eine pro Zeile. Jede Zeile ist folgendermaßen formatiert:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Opcode-Liste
Wie bereits erwähnt, werden vom Computer elf Opcodes unterstützt, von denen jeder drei Operanden hat:
MNZ [test] [value] [dest] – Move if Not Zero; sets [dest] to [value] if [test] is not zero.
MLZ [test] [value] [dest] – Move if Less than Zero; sets [dest] to [value] if [test] is less than zero.
ADD [val1] [val2] [dest] – ADDition; store [val1] + [val2] in [dest].
SUB [val1] [val2] [dest] – SUBtraction; store [val1] - [val2] in [dest].
AND [val1] [val2] [dest] – bitwise AND; store [val1] & [val2] in [dest].
OR [val1] [val2] [dest] – bitwise OR; store [val1] | [val2] in [dest].
XOR [val1] [val2] [dest] – bitwise XOR; store [val1] ^ [val2] in [dest].
ANT [val1] [val2] [dest] – bitwise And-NoT; store [val1] & (![val2]) in [dest].
SL [val1] [val2] [dest] – Shift Left; store [val1] << [val2] in [dest].
SRL [val1] [val2] [dest] – Shift Right Logical; store [val1] >>> [val2] in [dest]. Doesn't preserve sign.
SRA [val1] [val2] [dest] – Shift Right Arithmetic; store [val1] >> [val2] in [dest], while preserving sign.
Adressierungsmodi
Jeder der Operanden enthält sowohl einen Datenwert als auch eine Adressierungsbewegung. Der Datenwert wird durch eine Dezimalzahl im Bereich von -32768 bis 32767 beschrieben. Der Adressierungsmodus wird durch ein Ein-Buchstaben-Präfix vor dem Datenwert beschrieben.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Beispielcode
Fibonacci-Sequenz in fünf Zeilen:
0. MLZ -1 1 1; initial value
1. MLZ -1 A2 3; start loop, shift data
2. MLZ -1 A1 2; shift data
3. MLZ -1 0 0; end loop
4. ADD A2 A3 1; branch delay slot, compute next term
Dieser Code berechnet die Fibonacci-Sequenz, wobei die RAM-Adresse 1 den aktuellen Term enthält. Es läuft nach 28657 schnell über.
Gray-Code:
0. MLZ -1 5 1; initial value for RAM address to write to
1. SUB A1 5 2; start loop, determine what binary number to covert to Gray code
2. SRL A2 1 3; shift right by 1
3. XOR A2 A3 A1; XOR and store Gray code in destination address
4. SUB B1 42 4; take the Gray code and subtract 42 (101010)
5. MNZ A4 0 0; if the result is not zero (Gray code != 101010) repeat loop
6. ADD A1 1 1; branch delay slot, increment destination address
Dieses Programm berechnet den Gray-Code und speichert den Code ab Adresse 5 in aufeinanderfolgenden Adressen. Dieses Programm verwendet mehrere wichtige Funktionen wie die indirekte Adressierung und einen bedingten Sprung. Es wird angehalten, sobald der resultierende Gray-Code ist 101010
, was für die Eingabe 51 an der Adresse 56 geschieht.
Online-Dolmetscher
El'endia Starman hat ein sehr nützliches Online - Interpreter erstellt hier . Sie können den Code schrittweise durchlaufen, Haltepunkte festlegen, manuell in den RAM schreiben und den RAM als Anzeige anzeigen.
Cogol
Nachdem die Architektur und die Assemblersprache definiert waren, war der nächste Schritt auf der "Software" -Seite des Projekts die Erstellung einer höheren Sprache, die für Tetris geeignet ist. So habe ich Cogol geschaffen . Der Name ist ein Wortspiel auf "COBOL" und eine Abkürzung für "C of Game of Life", obwohl es erwähnenswert ist, dass Cogol für C ist, was unser Computer für einen tatsächlichen Computer ist.
Cogol existiert auf einer Ebene knapp über der Assemblersprache. Im Allgemeinen entsprechen die meisten Zeilen in einem Cogol-Programm jeweils einer einzelnen Assemblierungszeile, es gibt jedoch einige wichtige Merkmale der Sprache:
- Zu den grundlegenden Funktionen gehören benannte Variablen mit Zuweisungen und Operatoren mit besser lesbarer Syntax. Wird zum Beispiel,
ADD A1 A2 3
wenn z = x + y;
der Compiler Variablen auf Adressen abbildet.
- Looping - Konstrukte wie
if(){}
, while(){}
und do{}while();
so die Compiler Verzweigung Griffe.
- Eindimensionale Arrays (mit Zeigerarithmetik), die für die Tetris-Karte verwendet werden.
- Unterprogramme und ein Aufrufstapel. Diese sind nützlich, um das Duplizieren großer Codestücke zu verhindern und die Rekursion zu unterstützen.
Der Compiler (den ich von Grund auf geschrieben habe) ist sehr einfach / naiv, aber ich habe versucht, einige der Sprachkonstrukte von Hand zu optimieren, um eine kurze kompilierte Programmlänge zu erreichen.
Hier einige kurze Übersichten über die Funktionsweise verschiedener Sprachfunktionen:
Tokenisierung
Der Quellcode wird linear getokenet (Single-Pass), wobei einfache Regeln verwendet werden, welche Zeichen innerhalb eines Tokens benachbart sein dürfen. Wenn ein Zeichen angetroffen wird, das nicht neben dem letzten Zeichen des aktuellen Tokens stehen kann, gilt das aktuelle Token als vollständig und das neue Zeichen beginnt mit einem neuen Token. Einige Zeichen (wie z. B. {
oder ,
) dürfen nicht neben anderen Zeichen stehen und sind daher ihre eigenen Zeichen. Andere (wie >
oder =
) nur in andere Zeichen innerhalb ihrer Klasse sein benachbarten erlaubt, und kann somit bilden Token wie >>>
, ==
, oder >=
, aber nicht wie =2
. Leerzeichen erzwingen eine Grenze zwischen Token, sind jedoch selbst nicht im Ergebnis enthalten. Das am schwierigsten zu tokenisierende Zeichen ist-
weil es sowohl Subtraktion als auch unäre Negation darstellen kann und daher eine spezielle Umhüllung erfordert.
Parsing
Das Parsen erfolgt ebenfalls in einem Durchgang. Der Compiler verfügt über Methoden zum Behandeln der verschiedenen Sprachkonstrukte, und Token werden aus der globalen Tokenliste entfernt, wenn sie von den verschiedenen Compilermethoden verwendet werden. Wenn der Compiler jemals ein Token sieht, das er nicht erwartet, wird ein Syntaxfehler ausgelöst.
Globale Speicherzuordnung
Der Compiler weist jeder globalen Variablen (Wort oder Array) eine eigene zugewiesene RAM-Adresse (n) zu. Es ist erforderlich, alle Variablen mit dem Schlüsselwort zu deklarieren my
, damit der Compiler Speicherplatz dafür reservieren kann. Viel cooler als die genannten globalen Variablen ist die Verwaltung des Arbeitsspeichers. Viele Befehle (insbesondere Bedingungen und viele Array-Zugriffe) erfordern temporäre "Scratch" -Adressen zum Speichern von Zwischenberechnungen. Während des Kompilierungsprozesses weist der Compiler nach Bedarf Arbeitsadressen zu und hebt die Zuordnung auf. Wenn der Compiler mehr Arbeitsspeicheradressen benötigt, wird mehr Arbeitsspeicher als Arbeitsspeicheradressen zugewiesen. Ich glaube, es ist typisch für ein Programm, nur ein paar Arbeitsadressen zu benötigen, obwohl jede Arbeitsadresse viele Male verwendet wird.
IF-ELSE
Aussagen
Die Syntax für if-else
Anweisungen ist die Standard-C-Form:
other code
if (cond) {
first body
} else {
second body
}
other code
Bei der Konvertierung in QFTASM sieht der Code folgendermaßen aus:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Wenn der erste Körper ausgeführt wird, wird der zweite Körper übersprungen. Wenn der erste Körper übersprungen wird, wird der zweite Körper ausgeführt.
In der Baugruppe ist ein Zustandstest normalerweise nur eine Subtraktion, und das Vorzeichen des Ergebnisses bestimmt, ob der Sprung ausgeführt oder der Körper ausgeführt werden soll. Eine MLZ
Anweisung wird verwendet, um Ungleichungen wie >
oder zu behandeln <=
. Eine MNZ
Anweisung wird zum Behandeln verwendet ==
, da sie über den Körper springt, wenn die Differenz nicht Null ist (und daher, wenn die Argumente nicht gleich sind). Bedingungen für mehrere Ausdrücke werden derzeit nicht unterstützt.
Wenn die else
Anweisung weggelassen wird, wird auch der unbedingte Sprung weggelassen, und der QFTASM-Code sieht folgendermaßen aus:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
Aussagen
Die Syntax für while
Anweisungen ist auch die Standard-C-Form:
other code
while (cond) {
body
}
other code
Bei der Konvertierung in QFTASM sieht der Code folgendermaßen aus:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
Die Bedingungsprüfung und der bedingte Sprung befinden sich am Ende des Blocks, dh sie werden nach jeder Ausführung des Blocks erneut ausgeführt. Wenn die Bedingung false zurückgibt, wird der Body nicht wiederholt und die Schleife endet. Während des Starts der Schleifenausführung springt der Steuerungsfluss über den Schleifenkörper zum Bedingungscode, sodass der Körper niemals ausgeführt wird, wenn die Bedingung das erste Mal falsch ist.
Eine MLZ
Anweisung wird verwendet, um Ungleichungen wie >
oder zu behandeln <=
. Anders als bei if
Anweisungen wird eine MNZ
Anweisung zum Behandeln verwendet !=
, da sie zum Hauptteil springt, wenn die Differenz nicht Null ist (und daher die Argumente nicht gleich sind).
DO-WHILE
Aussagen
Der einzige Unterschied zwischen while
und do-while
besteht darin, dass der do-while
Körper einer Schleife zunächst nicht übersprungen wird, sodass er immer mindestens einmal ausgeführt wird. Ich verwende im Allgemeinen do-while
Anweisungen, um ein paar Zeilen Assembler-Code zu speichern, wenn ich weiß, dass die Schleife niemals vollständig übersprungen werden muss.
Arrays
Eindimensionale Arrays werden als zusammenhängende Speicherblöcke implementiert. Alle Arrays haben aufgrund ihrer Deklaration eine feste Länge. Arrays werden folgendermaßen deklariert:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
Für das Array ist dies eine mögliche RAM-Zuordnung, die zeigt, wie die Adressen 15-18 für das Array reserviert sind:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
Die beschriftete Adresse alpha
ist mit einem Zeiger auf den Speicherort von gefüllt. alpha[0]
In diesem Fall enthält die Adresse 15 den Wert 16. Die alpha
Variable kann im Cogol-Code verwendet werden, möglicherweise als Stapelzeiger, wenn Sie dieses Array als Stapel verwenden möchten .
Der Zugriff auf die Elemente eines Arrays erfolgt mit der Standardnotation array[index]
. Wenn der Wert von index
eine Konstante ist, wird diese Referenz automatisch mit der absoluten Adresse dieses Elements gefüllt. Andernfalls führt es eine Zeigerarithmetik (nur Addition) durch, um die gewünschte absolute Adresse zu finden. Es ist auch möglich, Indizierungen zu verschachteln, wie z alpha[beta[1]]
.
Unterprogramme und Aufruf
Subroutinen sind Codeblöcke, die aus mehreren Kontexten aufgerufen werden können. Sie verhindern die Vervielfältigung von Code und ermöglichen die Erstellung rekursiver Programme. Hier ist ein Programm mit einer rekursiven Subroutine zum Generieren von Fibonacci-Zahlen (im Grunde der langsamste Algorithmus):
# recursively calculate the 10th Fibonacci number
call display = fib(10).sum;
sub fib(cur,sum) {
if (cur <= 2) {
sum = 1;
return;
}
cur--;
call sum = fib(cur).sum;
cur--;
call sum += fib(cur).sum;
}
Eine Unterroutine wird mit dem Schlüsselwort deklariert sub
, und eine Unterroutine kann an einer beliebigen Stelle im Programm platziert werden. Jedes Unterprogramm kann mehrere lokale Variablen haben, die als Teil seiner Argumentliste deklariert werden. Diesen Argumenten können auch Standardwerte zugewiesen werden.
Um rekursive Aufrufe zu verarbeiten, werden die lokalen Variablen eines Unterprogramms auf dem Stapel gespeichert. Die letzte statische Variable im RAM ist der Aufrufstapelzeiger, und der gesamte darauf folgende Speicher dient als Aufrufstapel. Wenn ein Unterprogramm aufgerufen wird, erstellt es einen neuen Frame auf dem Aufrufstapel, der alle lokalen Variablen sowie die Rücksprungadresse (ROM-Adresse) enthält. Jedes Unterprogramm im Programm erhält eine einzelne statische RAM-Adresse als Zeiger. Dieser Zeiger gibt die Position des "aktuellen" Aufrufs des Unterprogramms im Aufrufstapel an. Das Referenzieren einer lokalen Variablen erfolgt unter Verwendung des Werts dieses statischen Zeigers plus eines Offsets, um die Adresse dieser bestimmten lokalen Variablen anzugeben. Im Aufrufstapel ist auch der vorherige Wert des statischen Zeigers enthalten. Hier'
RAM map:
0: pc
1: display
2: scratch0
3: fib
4: scratch1
5: scratch2
6: scratch3
7: call
fib map:
0: return
1: previous_call
2: cur
3: sum
Das Interessante an Unterprogrammen ist, dass sie keinen bestimmten Wert zurückgeben. Vielmehr können alle lokalen Variablen des Unterprogramms gelesen werden, nachdem das Unterprogramm ausgeführt wurde, so dass eine Vielzahl von Daten aus einem Unterprogrammaufruf extrahiert werden kann. Dies wird erreicht, indem der Zeiger für diesen spezifischen Aufruf der Unterroutine gespeichert wird, der dann verwendet werden kann, um eine der lokalen Variablen innerhalb des (kürzlich freigegebenen) Stapelrahmens wiederherzustellen.
Es gibt mehrere Möglichkeiten, eine Unterroutine mit dem call
Schlüsselwort aufzurufen :
call fib(10); # subroutine is executed, no return vaue is stored
call pointer = fib(10); # execute subroutine and return a pointer
display = pointer.sum; # access a local variable and assign it to a global variable
call display = fib(10).sum; # immediately store a return value
call display += fib(10).sum; # other types of assignment operators can also be used with a return value
Es können beliebig viele Werte als Argumente für einen Unterprogrammaufruf angegeben werden. Jedes Argument, das nicht angegeben wird, wird mit seinem Standardwert (falls vorhanden) ausgefüllt. Ein Argument, das nicht angegeben wird und keinen Standardwert hat, wird nicht gelöscht (um Anweisungen / Zeit zu sparen), sodass es zu Beginn des Unterprogramms möglicherweise einen beliebigen Wert annehmen kann.
Zeiger sind eine Möglichkeit, auf mehrere lokale Variablen eines Unterprogramms zuzugreifen. Es ist jedoch zu beachten, dass der Zeiger nur temporär ist: Die Daten, auf die der Zeiger zeigt, werden zerstört, wenn ein anderer Unterprogrammaufruf erfolgt.
Etiketten debuggen
Jeder {...}
Codeblock in einem Cogol Programm kann durch ein Mehrwort beschreibende Bezeichnung vorangestellt werden. Diese Bezeichnung wird als Kommentar in den kompilierten Assemblycode eingefügt und kann beim Debuggen sehr hilfreich sein, da sie das Auffinden bestimmter Codestücke erleichtert.
Slot-Optimierung für Verzweigungsverzögerung
Um die Geschwindigkeit des kompilierten Codes zu verbessern, führt der Cogol-Compiler als letzten Durchlauf des QFTASM-Codes eine grundlegende Optimierung der Verzögerungszeiträume durch. Für jeden unbedingten Sprung mit einem leeren Verzweigungsverzögerungsschlitz kann der Verzögerungsschlitz durch den ersten Befehl am Sprungziel gefüllt werden, und das Sprungziel wird um eins erhöht, um auf den nächsten Befehl zu zeigen. Dies spart im Allgemeinen einen Zyklus jedes Mal, wenn ein bedingungsloser Sprung ausgeführt wird.
Schreiben des Tetris-Codes in Cogol
Das endgültige Tetris-Programm wurde in Cogol geschrieben. Den Quellcode finden Sie hier . Der kompilierte QFTASM-Code ist hier verfügbar . Der Einfachheit halber wird hier ein Permalink bereitgestellt: Tetris in QFTASM . Da das Ziel darin bestand, den Assembly-Code (nicht den Cogol-Code) zu verwenden, ist der resultierende Cogol-Code unhandlich. Viele Teile des Programms befanden sich normalerweise in Unterprogrammen, aber diese Unterprogramme waren tatsächlich kurz genug, um den Code zu duplizieren, der über die Befehle gespeichert wurdecall
Aussagen. Der endgültige Code enthält neben dem Hauptcode nur ein Unterprogramm. Darüber hinaus wurden viele Arrays entfernt und entweder durch eine entsprechend lange Liste einzelner Variablen oder durch viele fest codierte Nummern im Programm ersetzt. Der endgültig kompilierte QFTASM-Code steht unter 300 Anweisungen, obwohl er nur geringfügig länger als die Cogol-Quelle selbst ist.