Część 4: QFTASM i Cogol
Przegląd architektury
Krótko mówiąc, nasz komputer ma 16-bitową asynchroniczną architekturę RISC Harvard. Podczas ręcznego budowania procesora architektura RISC ( komputer z ograniczoną liczbą instrukcji ) jest praktycznie wymagana. W naszym przypadku oznacza to, że liczba kodów jest niewielka i, co ważniejsze, wszystkie instrukcje są przetwarzane w bardzo podobny sposób.
Dla porównania, komputer Wireworld zastosował architekturę wyzwalaną przez transport , w której była jedyna instrukcja MOV
i obliczenia były wykonywane przez zapis / odczyt specjalnych rejestrów. Chociaż ten paradygmat prowadzi do bardzo łatwej do wdrożenia architektury, wynik jest również bezużyteczny na granicy: wszystkie operacje arytmetyczne / logiczne / warunkowe wymagają trzech instrukcji. Było dla nas jasne, że chcemy stworzyć znacznie mniej ezoteryczną architekturę.
Aby uprościć procesor i zwiększyć użyteczność, podjęliśmy kilka ważnych decyzji projektowych:
- Brak rejestrów. Każdy adres w pamięci RAM jest traktowany jednakowo i może być wykorzystany jako dowolny argument dla dowolnej operacji. W pewnym sensie oznacza to, że całą pamięć RAM można traktować jak rejestry. Oznacza to, że nie ma specjalnych instrukcji ładowania / przechowywania.
- W podobny sposób mapowanie pamięci. Wszystko, co można zapisać lub odczytać, udostępnia wspólny schemat adresowania. Oznacza to, że licznik programu (PC) ma adres 0, a jedyną różnicą między zwykłymi instrukcjami a instrukcjami przepływu sterowania jest to, że instrukcje przepływu sterowania używają adresu 0.
- Dane są przesyłane szeregowo, równolegle w pamięci. Ze względu na „komputerowy” charakter naszego komputera, dodawanie i odejmowanie jest znacznie łatwiejsze do wdrożenia, gdy dane są przesyłane w postaci szeregowej little-endian (najpierw najmniej znaczący bit). Ponadto dane szeregowe eliminują potrzebę uciążliwych magistral danych, które są zarówno naprawdę szerokie, jak i kłopotliwe w odpowiednim czasie (aby dane pozostały razem, wszystkie „pasy” autobusu muszą doświadczyć tego samego opóźnienia podróży).
- Architektura Harvarda, co oznacza podział między pamięcią programu (ROM) a pamięcią danych (RAM). Chociaż zmniejsza to elastyczność procesora, pomaga to w optymalizacji rozmiaru: długość programu jest znacznie większa niż ilość potrzebnej pamięci RAM, dzięki czemu możemy podzielić program na ROM, a następnie skoncentrować się na kompresji ROM , co jest znacznie łatwiejsze, gdy jest tylko do odczytu.
- 16-bitowa szerokość danych. Jest to najmniejsza z dwóch potęg, która jest szersza niż standardowa tablica Tetris (10 bloków). To daje nam zakres danych od -32768 do +32767 i maksymalną długość programu 65536 instrukcji. (2 ^ 8 = 256 instrukcji wystarcza na najprostsze rzeczy, które chcielibyśmy zrobić z procesorem zabawki, ale nie Tetris.)
- Projekt asynchroniczny. Zamiast centralnego zegara (lub równoważnie kilku zegarów) dyktujących taktowanie komputera, wszystkim danym towarzyszy „sygnał zegarowy”, który przemieszcza się równolegle z danymi przepływającymi wokół komputera. Niektóre ścieżki mogą być krótsze niż inne, i chociaż nastręczałoby to trudności projektowi z centralnym taktowaniem, projekt asynchroniczny może łatwo poradzić sobie z operacjami w czasie zmiennym.
- Wszystkie instrukcje są jednakowej wielkości. Uważaliśmy, że najbardziej elastyczną opcją jest architektura, w której każda instrukcja ma 1 kod operacji z 3 operandami (miejsce docelowe wartości wartości). Obejmuje to operacje na danych binarnych, a także ruchy warunkowe.
- Prosty system trybu adresowania. Posiadanie różnych trybów adresowania jest bardzo przydatne do obsługi takich rzeczy, jak tablice lub rekurencja. Udało nam się wdrożyć kilka ważnych trybów adresowania za pomocą stosunkowo prostego systemu.
Ilustracja naszej architektury znajduje się w poście przeglądowym.
Funkcjonalność i operacje ALU
Odtąd chodziło o określenie, jaką funkcjonalność powinien mieć nasz procesor. Szczególną uwagę zwrócono na łatwość wdrożenia oraz wszechstronność każdego polecenia.
Ruchy warunkowe
Ruchy warunkowe są bardzo ważne i służą zarówno jako przepływ kontrolny na małą skalę, jak i na dużą skalę. „Mała skala” odnosi się do jego zdolności do kontrolowania wykonania określonego przenoszenia danych, podczas gdy „duża skala” odnosi się do jej zastosowania jako operacji skoku warunkowego do przeniesienia przepływu sterowania do dowolnego dowolnego kodu. Nie ma dedykowanych operacji przeskoku, ponieważ z powodu mapowania pamięci ruch warunkowy może zarówno skopiować dane do zwykłej pamięci RAM, jak i skopiować adres docelowy na komputer. Postanowiliśmy również zrezygnować zarówno z bezwarunkowych ruchów, jak i bezwarunkowych skoków z podobnego powodu: oba mogą być realizowane jako ruch warunkowy z warunkiem zakodowanym na TRUE.
Zdecydowaliśmy się na dwa różne typy ruchów warunkowych: „ruch, jeśli nie zero” ( MNZ
) i „ruch, jeśli mniej niż zero” ( MLZ
). Funkcjonalnie MNZ
sprowadza się do sprawdzenia, czy dowolny bit w danych ma wartość 1, a MLZ
sprowadza się do sprawdzenia, czy bit znaku ma wartość 1. Są one przydatne odpowiednio do równości i porównań. Powodem, dla którego wybraliśmy te dwa spośród innych, takich jak „ruch, jeśli zero” ( MEZ
) lub „ruch, jeśli jest większy od zera” ( MGZ
), było to, MEZ
że wymagałoby utworzenia PRAWDZIWEGO sygnału z pustego sygnału, podczas gdy MGZ
jest to bardziej skomplikowana kontrola, wymagająca bit znaku ma wartość 0, a co najmniej jeden inny bit ma wartość 1.
Arytmetyka
Kolejnymi najważniejszymi instrukcjami, pod względem prowadzenia projektu procesora, są podstawowe operacje arytmetyczne. Jak wspomniałem wcześniej, używamy danych szeregowych little-endian, a wybór endianizmu zależy od łatwości operacji dodawania / odejmowania. Dzięki pierwszemu najmniej znaczącemu bitowi, jednostki arytmetyczne mogą łatwo śledzić bit przenoszący.
Zdecydowaliśmy się zastosować reprezentację uzupełnienia 2 dla liczb ujemnych, ponieważ dzięki temu dodawanie i odejmowanie jest bardziej spójne. Warto zauważyć, że komputer Wireworld używał uzupełnienia 1.
Dodawanie i odejmowanie to zakres natywnej obsługi arytmetycznej naszego komputera (oprócz przesunięć bitów, które zostaną omówione później). Inne operacje, takie jak mnożenie, są zbyt skomplikowane, aby mogły być obsługiwane przez naszą architekturę i muszą zostać zaimplementowane w oprogramowaniu.
Operacje bitowe
Nasz procesor ma AND
, OR
i XOR
instrukcje, które mają to, czego można się spodziewać. Zamiast NOT
instrukcji, zdecydowaliśmy się na instrukcję „i nie” ( ANT
). Trudność z NOT
instrukcją polega na tym, że musi ona wytwarzać sygnał z braku sygnału, co jest trudne w przypadku automatów komórkowych. ANT
Instrukcja zwraca 1 tylko wtedy, gdy pierwszy bit argument jest 1, a drugi bit argument jest 0. W ten sposób, NOT x
jest równoważna ANT -1 x
(także XOR -1 x
). Co więcej, ANT
jest wszechstronny i ma swoją główną zaletę w maskowaniu: w przypadku programu Tetris używamy go do usuwania tetromino.
Przesunięcie bitów
Operacje przesuwania bitów są najbardziej złożonymi operacjami obsługiwanymi przez ALU. Pobierają dwa dane wejściowe: wartość do przesunięcia i kwotę do przesunięcia o. Pomimo złożoności (ze względu na zmienną liczbę przesunięć) operacje te są kluczowe dla wielu ważnych zadań, w tym wielu operacji „graficznych” związanych z Tetris. Przesunięcia bitów służyłyby również jako podstawa wydajnych algorytmów mnożenia / dzielenia.
Nasz procesor ma trzy bitowe operacje przesunięcia, „shift left” ( SL
), „shift right logical” ( SRL
) i „shift right arytmetic” ( SRA
). Pierwsze dwa przesunięcia bitowe ( SL
i SRL
) wypełniają nowe bity wszystkimi zerami (co oznacza, że liczba ujemna przesunięta w prawo nie będzie już ujemna). Jeśli drugi argument zmiany jest poza zakresem od 0 do 15, wynik jest zerowy, jak można się spodziewać. Dla ostatniego przesunięcia bitowego przesunięcie SRA
bitowe zachowuje znak wejścia, a zatem działa jak prawdziwy podział na dwa.
Instrukcja Rurociągi
Czas porozmawiać o niektórych drobnych szczegółach architektury. Każdy cykl procesora składa się z następujących pięciu kroków:
1. Pobierz bieżącą instrukcję z ROM
Bieżąca wartość komputera służy do pobrania odpowiedniej instrukcji z pamięci ROM. Każda instrukcja ma jeden kod operacji i trzy operandy. Każdy operand składa się z jednego słowa danych i jednego trybu adresowania. Części te są od siebie oddzielone, ponieważ są odczytywane z pamięci ROM.
Kod operacji to 4 bity, które obsługują 16 unikalnych kodów, z których 11 ma przypisane:
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. Zapisz wynik (jeśli to konieczne) poprzedniej instrukcji w pamięci RAM
W zależności od stanu poprzedniej instrukcji (np. Wartości pierwszego argumentu dla ruchu warunkowego), wykonywane jest zapisywanie. Adres zapisu jest określany przez trzeci argument poprzedniej instrukcji.
Należy zauważyć, że pisanie następuje po pobraniu instrukcji. Prowadzi to do utworzenia szczeliny opóźnienia rozgałęzienia, w której instrukcja natychmiast po instrukcji rozgałęzienia (każda operacja zapisująca na PC) jest wykonywana zamiast pierwszej instrukcji w miejscu docelowym rozgałęzienia.
W niektórych przypadkach (jak bezwarunkowe skoki) szczelinę opóźnienia gałęzi można zoptymalizować. W innych przypadkach nie jest to możliwe, a instrukcja po gałęzi musi pozostać pusta. Co więcej, ten typ szczeliny opóźniającej oznacza, że gałęzie muszą używać celu gałęzi, który jest o 1 adres mniejszy niż rzeczywista instrukcja celu, aby uwzględnić przyrost PC.
Krótko mówiąc, ponieważ dane wyjściowe poprzedniej instrukcji są zapisywane w pamięci RAM po pobraniu kolejnej instrukcji, skoki warunkowe muszą mieć po nich pustą instrukcję, w przeciwnym razie komputer nie zostanie poprawnie zaktualizowany do skoku.
3. Przeczytaj dane dla argumentów bieżącej instrukcji z pamięci RAM
Jak wspomniano wcześniej, każdy z trzech operandów składa się zarówno ze słowa danych, jak i trybu adresowania. Słowo danych ma 16 bitów, taką samą szerokość jak pamięć RAM. Tryb adresowania to 2 bity.
Tryby adresowania mogą być źródłem znacznej złożoności dla takiego procesora, ponieważ wiele rzeczywistych trybów adresowania wymaga obliczeń wieloetapowych (takich jak dodawanie przesunięć). Jednocześnie wszechstronne tryby adresowania odgrywają ważną rolę w użyteczności procesora.
Staraliśmy się ujednolicić koncepcje używania liczb zakodowanych na stałe jako operandów i używania adresów danych jako operandów. Doprowadziło to do stworzenia opartych na licznikach trybów adresowania: tryb adresowania operandu jest po prostu liczbą reprezentującą ile razy dane powinny być wysyłane wokół pętli odczytu pamięci RAM. Obejmuje to adresowanie bezpośrednie, bezpośrednie, pośrednie i podwójnie pośrednie.
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)
Po wykonaniu tego dereferencji trzy operandy instrukcji mają różne role. Pierwszy argument jest zwykle pierwszym argumentem dla operatora binarnego, ale służy również jako warunek, gdy bieżąca instrukcja jest ruchem warunkowym. Drugi operand służy jako drugi argument dla operatora binarnego. Trzeci operand służy jako adres docelowy dla wyniku instrukcji.
Ponieważ dwie pierwsze instrukcje służą jako dane, a trzecia służy jako adres, tryby adresowania mają nieco inne interpretacje w zależności od miejsca, w którym są używane. Na przykład tryb bezpośredni służy do odczytu danych ze stałego adresu RAM (ponieważ potrzebny jest jeden odczyt pamięci RAM), ale tryb natychmiastowy służy do zapisywania danych na ustalonym adresie pamięci RAM (ponieważ nie są konieczne żadne odczyty pamięci RAM).
4. Oblicz wynik
Kod operacji i pierwsze dwa operandy są wysyłane do ALU w celu wykonania operacji binarnej. W przypadku operacji arytmetycznych, bitowych i shiftowych oznacza to wykonanie odpowiedniej operacji. W przypadku ruchów warunkowych oznacza to po prostu zwrócenie drugiego operandu.
Kod operacji i pierwszy operand są używane do obliczania warunku, który określa, czy zapisać wynik w pamięci. W przypadku ruchów warunkowych oznacza to albo ustalenie, czy dowolny bit w operandzie ma wartość 1 (dla MNZ
), czy ustalenie, czy bit znaku ma wartość 1 (dla MLZ
). Jeśli kod operacji nie jest ruchem warunkowym, zapis jest zawsze wykonywany (warunek jest zawsze spełniony).
5. Zwiększ licznik programu
Na koniec licznik programu jest odczytywany, zwiększany i zapisywany.
Ze względu na położenie przyrostu PC między odczytem instrukcji a zapisem instrukcji, oznacza to, że instrukcja, która zwiększa PC o 1, nie działa. Instrukcja, która kopiuje komputer do siebie, powoduje wykonanie następnej instrukcji dwa razy z rzędu. Ale ostrzegamy, że wiele instrukcji na PC z rzędu może powodować złożone efekty, w tym nieskończoną pętlę, jeśli nie zwrócisz uwagi na potok instrukcji.
Wyprawa po zgromadzenie Tetris
Dla naszego procesora stworzyliśmy nowy język asemblera o nazwie QFTASM. Ten język asemblera odpowiada 1 do 1 kodowi maszynowemu w pamięci ROM komputera.
Każdy program QFTASM jest zapisywany jako seria instrukcji, po jednej w wierszu. Każda linia jest sformatowana w następujący sposób:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Lista kodów operacyjnych
Jak wspomniano wcześniej, istnieje 11 kodów operacyjnych obsługiwanych przez komputer, z których każdy ma trzy argumenty:
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.
Tryby adresowania
Każdy z operandów zawiera zarówno wartość danych, jak i ruch adresowania. Wartość danych jest opisana liczbą dziesiętną z zakresu od -32768 do 32767. Tryb adresowania jest opisany jednuliterowym przedrostkiem wartości danych.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Przykładowy kod
Sekwencja Fibonacciego w pięciu wierszach:
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
Ten kod oblicza sekwencję Fibonacciego z adresem RAM 1 zawierającym bieżący termin. Szybko przelewa się po 28657.
Szary kod:
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
Ten program oblicza kod Graya i przechowuje kod pod kolejnymi adresami, zaczynając od adresu 5. Ten program wykorzystuje kilka ważnych funkcji, takich jak adresowanie pośrednie i skok warunkowy. Zatrzymuje się, gdy powstanie wynikowy kod Graya 101010
, co dzieje się dla wejścia 51 pod adresem 56.
Tłumacz online
El'endia Starman stworzył bardzo przydatnych tłumacza online, tutaj . Jesteś w stanie przechodzić przez kod, ustawiać punkty przerwania, wykonywać ręczne zapisy w pamięci RAM i wizualizować pamięć RAM jako wyświetlacz.
Cogol
Po zdefiniowaniu architektury i języka asemblera, następnym krokiem po stronie „oprogramowania” projektu było stworzenie języka wyższego poziomu, coś odpowiedniego dla Tetris. W ten sposób stworzyłem Cogola . Nazwa ta jest zarówno słowem „COBOL”, jak i akronimem „C of Game of Life”, chociaż warto zauważyć, że Cogol jest dla C tym, czym jest nasz komputer dla rzeczywistego komputera.
Cogol istnieje na poziomie nieco powyżej języka asemblera. Zasadniczo większość linii w programie Cogol odpowiada jednej linii asemblera, ale istnieją pewne ważne cechy języka:
- Podstawowe funkcje obejmują nazwane zmienne z przypisaniami i operatory, które mają bardziej czytelną składnię. Na przykład
ADD A1 A2 3
staje się z = x + y;
ze zmiennymi mapowania kompilatora na adresy.
- Pętle konstrukcje, takie jak
if(){}
, while(){}
i do{}while();
kompilator obsługuje rozgałęzienia.
- Tablice jednowymiarowe (ze wskaźnikiem arytmetycznym), które są używane na płycie Tetris.
- Podprogramy i stos wywołań. Są one przydatne do zapobiegania powielaniu dużych fragmentów kodu oraz do obsługi rekurencji.
Kompilator (który napisałem od zera) jest bardzo prosty / naiwny, ale próbowałem ręcznie zoptymalizować niektóre konstrukcje językowe, aby uzyskać krótką długość kompilowanego programu.
Oto kilka krótkich omówień działania różnych funkcji językowych:
Tokenizacja
Kod źródłowy jest tokenizowany liniowo (jednoprzebiegowy), przy użyciu prostych reguł, które znaki mogą przylegać do tokena. Kiedy napotyka się postać, która nie może przylegać do ostatniego znaku bieżącego żetonu, aktualny żeton uznaje się za ukończony, a nowa postać rozpoczyna nowy żeton. Niektóre postacie (takie jak {
lub ,
) nie mogą sąsiadować z żadnymi innymi postaciami i dlatego są ich własnymi żetonami. Inne (jak >
lub =
) są dozwolone tylko być obok innych znaków w swojej klasie, a może zatem stanowić znaki takie jak >>>
, ==
lub >=
, ale nie podoba =2
. Znaki białych znaków wymuszają granicę między tokenami, ale same nie są uwzględniane w wyniku. Najtrudniejszą postacią do tokenizacji jest-
ponieważ może zarówno reprezentować odejmowanie, jak i jednoargumentową negację, a zatem wymaga specjalnej obudowy.
Rozbiór gramatyczny zdania
Parsowanie odbywa się również w trybie jednoprzebiegowym. Kompilator ma metody obsługi każdej z różnych konstrukcji języka, a tokeny są usuwane z globalnej listy tokenów, ponieważ są wykorzystywane przez różne metody kompilatora. Jeśli kompilator kiedykolwiek zobaczy token, którego się nie spodziewa, zgłosi błąd składniowy.
Globalna alokacja pamięci
Kompilator przypisuje każdej zmiennej globalnej (słowu lub tablicy) swój własny wyznaczony adres (adresy) RAM. Konieczne jest zadeklarowanie wszystkich zmiennych za pomocą słowa kluczowego, my
aby kompilator wiedział, aby przydzielić dla niego miejsce. Znacznie fajniejsze niż nazwane zmienne globalne jest zarządzanie pamięcią adresu zera. Wiele instrukcji (zwłaszcza warunki warunkowe i wiele dostępów do tablicy) wymaga tymczasowych adresów „scratch” do przechowywania pośrednich obliczeń. Podczas procesu kompilacji kompilator przydziela i przydziela adresy scratch w razie potrzeby. Jeśli kompilator potrzebuje więcej adresów scratch, poświęci więcej pamięci RAM jako adresy scratch. Uważam, że to typowe, że program wymaga tylko kilku adresów scratch, chociaż każdy adres scratch będzie używany wiele razy.
IF-ELSE
Sprawozdania
Składnia if-else
instrukcji jest standardową formą C:
other code
if (cond) {
first body
} else {
second body
}
other code
Po przekonwertowaniu na QFTASM kod jest zorganizowany w następujący sposób:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Jeśli pierwsze ciało zostanie wykonane, drugie ciało zostanie pominięte. Jeśli pierwsze ciało zostanie pominięte, drugie ciało zostanie wykonane.
W złożeniu test warunków jest zwykle tylko odejmowaniem, a znak wyniku określa, czy wykonać skok, czy wykonać ciało. MLZ
Instrukcja jest używana do obsługi takich jak nierówności >
lub <=
. Do MNZ
obsługi używana jest instrukcja ==
, ponieważ przeskakuje ona nad ciałem, gdy różnica nie jest równa zero (a zatem gdy argumenty nie są równe). Warunki warunkowe z wieloma wyrażeniami nie są obecnie obsługiwane.
Jeśli else
instrukcja zostanie pominięta, pominięty zostanie również skok bezwarunkowy, a kod QFTASM wygląda następująco:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
Sprawozdania
Składnia while
instrukcji jest również standardową formą C:
other code
while (cond) {
body
}
other code
Po przekonwertowaniu na QFTASM kod jest zorganizowany w następujący sposób:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
Testowanie warunków i skok warunkowy znajdują się na końcu bloku, co oznacza, że są one ponownie wykonywane po każdym wykonaniu bloku. Gdy warunek jest zwracany jako false, ciało nie jest powtarzane, a pętla kończy się. Na początku wykonywania pętli przepływ sterowania przeskakuje nad korpusem pętli do kodu warunku, więc ciało nigdy nie jest wykonywane, jeśli warunek jest za pierwszym razem fałszywy.
MLZ
Instrukcja jest używana do obsługi takich jak nierówności >
lub <=
. W przeciwieństwie do if
instrukcji, do MNZ
obsługi używana jest instrukcja !=
, ponieważ przeskakuje ona do ciała, gdy różnica nie jest równa zero (a zatem gdy argumenty nie są równe).
DO-WHILE
Sprawozdania
Jedyna różnica między while
i do-while
polega na tym, że do-while
ciało pętli nie jest początkowo pomijane, więc zawsze jest wykonywane przynajmniej raz. Zazwyczaj używam do-while
instrukcji, aby zapisać kilka wierszy kodu asemblera, gdy wiem, że pętla nigdy nie będzie musiała zostać całkowicie pominięta.
Tablice
Tablice jednowymiarowe są implementowane jako ciągłe bloki pamięci. Wszystkie tablice mają stałą długość na podstawie deklaracji. Tablice są deklarowane w następujący sposób:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
W przypadku tablicy jest to możliwe mapowanie pamięci RAM, pokazujące, w jaki sposób adresy 15-18 są zarezerwowane dla tablicy:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
Adres oznaczony alpha
jest wypełniony wskaźnikiem położenia alpha[0]
, więc w tym przypadku adres 15 zawiera wartość 16. alpha
Zmiennej można użyć wewnątrz kodu Cogola, być może jako wskaźnik stosu, jeśli chcesz użyć tej tablicy jako stosu .
Dostęp do elementów tablicy odbywa się za pomocą standardowej array[index]
notacji. Jeśli wartość index
jest stała, to odwołanie jest automatycznie wypełniane bezwzględnym adresem tego elementu. W przeciwnym razie wykonuje pewną arytmetykę wskaźnika (tylko dodanie), aby znaleźć pożądany adres bezwzględny. Można również zagnieżdżać indeksowanie, takie jak alpha[beta[1]]
.
Podprogramy i połączenia
Podprogramy to bloki kodu, które można wywoływać z wielu kontekstów, zapobiegając powielaniu kodu i umożliwiając tworzenie programów rekurencyjnych. Oto program z rekurencyjnym podprogramem do generowania liczb Fibonacciego (w zasadzie najwolniejszy algorytm):
# 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;
}
Podprogram jest deklarowany za pomocą słowa kluczowego sub
, a podprogram można umieścić w dowolnym miejscu w programie. Każdy podprogram może mieć wiele zmiennych lokalnych, które są zadeklarowane jako część listy argumentów. Argumentom tym można również nadać wartości domyślne.
Aby obsłużyć wywołania rekurencyjne, lokalne zmienne podprogramu są przechowywane na stosie. Ostatnią zmienną statyczną w pamięci RAM jest wskaźnik stosu wywołań, a cała pamięć później służy jako stos wywołań. Wywołanie podprogramu powoduje utworzenie nowej ramki na stosie wywołań, która zawiera wszystkie zmienne lokalne, a także adres zwrotny (ROM). Każda podprogram w programie ma jeden statyczny adres RAM, który służy jako wskaźnik. Ten wskaźnik podaje lokalizację „bieżącego” wywołania podprogramu na stosie wywołań. Odwołanie do zmiennej lokalnej odbywa się za pomocą wartości tego statycznego wskaźnika plus przesunięcia, aby podać adres tej konkretnej zmiennej lokalnej. W stosie wywołań znajduje się również poprzednia wartość wskaźnika statycznego. Tutaj'
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
Ciekawą rzeczą w podprogramach jest to, że nie zwracają one żadnej konkretnej wartości. Zamiast tego wszystkie lokalne zmienne podprogramu można odczytać po wykonaniu podprogramu, dzięki czemu można wywołać różnorodne dane z wywołania podprogramu. Odbywa się to poprzez przechowywanie wskaźnika dla tego konkretnego wywołania podprogramu, którego można następnie użyć do odzyskania dowolnych zmiennych lokalnych z ramki (ostatnio zwolnionego) stosu.
Istnieje wiele sposobów wywoływania podprogramu, wszystkie za pomocą call
słowa kluczowego:
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
Dowolną liczbę wartości można podać jako argumenty wywołania podprogramu. Każdy niedostarczony argument zostanie wypełniony wartością domyślną, jeśli taka istnieje. Argument, który nie został podany i nie ma wartości domyślnej, nie jest usuwany (aby zaoszczędzić instrukcje / czas), więc może potencjalnie przyjąć dowolną wartość na początku podprogramu.
Wskaźniki są sposobem na uzyskanie dostępu do wielu lokalnych zmiennych podprogramu, chociaż należy zauważyć, że wskaźnik jest tylko tymczasowy: dane, na które wskazuje wskaźnik, zostaną zniszczone, gdy zostanie wykonane inne wywołanie podprogramu.
Debugowanie etykiet
Każdy {...}
blok kodu w programie Cogol może być poprzedzony wielowątkową etykietą opisową. Ta etykieta jest dołączona jako komentarz do skompilowanego kodu zestawu i może być bardzo przydatna do debugowania, ponieważ ułatwia zlokalizowanie określonych fragmentów kodu.
Optymalizacja czasu opóźnienia gałęzi
Aby poprawić szybkość skompilowanego kodu, kompilator Cogol wykonuje naprawdę podstawową optymalizację szczeliny opóźnienia jako końcowe przejście przez kod QFTASM. W przypadku dowolnego bezwarunkowego skoku z pustym miejscem na opóźnienie gałęzi, miejsce na opóźnienie można wypełnić pierwszą instrukcją w miejscu docelowym skoku, a miejsce docelowe skoku jest zwiększane o jeden, aby wskazać kolejną instrukcję. Generalnie oszczędza to jeden cykl za każdym razem, gdy wykonywany jest bezwarunkowy skok.
Pisanie kodu Tetris w Cogol
Ostateczny program Tetris został napisany w Cogolu, a kod źródłowy jest dostępny tutaj . Skompilowany kod QFTASM jest dostępny tutaj . Dla wygody dostępny jest bezpośredni link : Tetris w QFTASM . Ponieważ celem było sprawdzenie kodu asemblera (nie kodu Cogola), wynikowy kod Cogola jest nieporęczny. Wiele części programu normalnie znajdowałoby się w podprogramach, ale te podprogramy były w rzeczywistości na tyle krótkie, że powielenie kodu zachowało instrukcje przezcall
sprawozdania. Kod końcowy ma tylko jeden podprogram oprócz kodu głównego. Dodatkowo wiele tablic zostało usuniętych i zastąpionych albo równo długą listą poszczególnych zmiennych, albo wieloma zakodowanymi liczbami w programie. Ostateczny skompilowany kod QFTASM zawiera mniej niż 300 instrukcji, chociaż jest tylko nieznacznie dłuższy niż samo źródło Cogol.