Parte 4: QFTASM e Cogol
Panoramica sull'architettura
In breve, il nostro computer ha un'architettura RISC Harvard asincrona a 16 bit. Quando si costruisce un processore a mano, un'architettura RISC ( computer con set di istruzioni ridotto ) è praticamente un requisito. Nel nostro caso, ciò significa che il numero di codici operativi è ridotto e, cosa ancora più importante, che tutte le istruzioni vengono elaborate in modo molto simile.
Per riferimento, il computer Wireworld utilizzava un'architettura innescata dal trasporto , in cui l'unica istruzione era MOV
e i calcoli venivano eseguiti scrivendo / leggendo registri speciali. Sebbene questo paradigma porti a un'architettura molto facile da implementare, il risultato è anche al limite inutilizzabile: tutte le operazioni aritmetiche / logiche / condizionali richiedono tre istruzioni. Per noi era chiaro che volevamo creare un'architettura molto meno esoterica.
Al fine di semplificare il nostro processore aumentando al contempo l'usabilità, abbiamo preso diverse importanti decisioni di progettazione:
- Nessun registro Ogni indirizzo nella RAM viene trattato allo stesso modo e può essere utilizzato come argomento per qualsiasi operazione. In un certo senso, ciò significa che tutta la RAM potrebbe essere trattata come un registro. Ciò significa che non ci sono istruzioni speciali per il caricamento / archiviazione.
- Allo stesso modo, mappatura della memoria. Tutto ciò che potrebbe essere scritto o letto da condivide uno schema di indirizzamento unificato. Ciò significa che il contatore del programma (PC) è l'indirizzo 0 e l'unica differenza tra le istruzioni normali e le istruzioni del flusso di controllo è che le istruzioni del flusso di controllo utilizzano l'indirizzo 0.
- I dati sono seriali in trasmissione, paralleli in memoria. A causa della natura basata su "elettroni" del nostro computer, l'aggiunta e la sottrazione sono significativamente più facili da implementare quando i dati vengono trasmessi in forma seriale little-endian (bit meno significativo prima). Inoltre, i dati seriali eliminano la necessità di ingombranti bus di dati, che sono sia larghi che ingombranti per un corretto tempo (affinché i dati rimangano uniti, tutte le "corsie" del bus devono presentare lo stesso ritardo di viaggio).
- Architettura di Harvard, che significa una divisione tra memoria di programma (ROM) e memoria di dati (RAM). Sebbene ciò riduca la flessibilità del processore, questo aiuta con l'ottimizzazione delle dimensioni: la lunghezza del programma è molto più grande della quantità di RAM di cui avremo bisogno, quindi possiamo dividere il programma in ROM e quindi concentrarci sulla compressione della ROM , che è molto più semplice quando è di sola lettura.
- Larghezza dati a 16 bit. Questa è la più piccola potenza di due che è più ampia di una scheda Tetris standard (10 blocchi). Questo ci dà un intervallo di dati da -32768 a +32767 e una lunghezza massima del programma di 65536 istruzioni. (2 ^ 8 = 256 istruzioni sono sufficienti per la maggior parte delle cose che potremmo volere fare un processore giocattolo, ma non Tetris.)
- Design asincrono. Invece di avere un orologio centrale (o, equivalentemente, diversi orologi) che detta i tempi del computer, tutti i dati sono accompagnati da un "segnale di orologio" che viaggia in parallelo con i dati mentre scorre attorno al computer. Alcuni percorsi possono essere più brevi di altri e, sebbene ciò crei difficoltà per un progetto con clock centrale, un design asincrono può facilmente gestire operazioni a tempo variabile.
- Tutte le istruzioni hanno le stesse dimensioni. Abbiamo ritenuto che un'architettura in cui ogni istruzione avesse 1 codice operativo con 3 operandi (destinazione valore valore) fosse l'opzione più flessibile. Ciò comprende operazioni binarie di dati e spostamenti condizionali.
- Sistema di indirizzamento semplice. Avere una varietà di modalità di indirizzamento è molto utile per supportare cose come array o ricorsione. Siamo riusciti a implementare diverse importanti modalità di indirizzamento con un sistema relativamente semplice.
Un'illustrazione della nostra architettura è contenuta nel post generale.
Funzionalità e operazioni ALU
Da qui, si trattava di determinare quale funzionalità dovrebbe avere il nostro processore. Particolare attenzione è stata prestata alla facilità di implementazione e alla versatilità di ciascun comando.
Mosse condizionate
Le mosse condizionali sono molto importanti e servono sia come flusso di controllo su piccola che su larga scala. "Piccola scala" si riferisce alla sua capacità di controllare l'esecuzione di un particolare spostamento di dati, mentre "grande scala" si riferisce al suo uso come operazione di salto condizionale per trasferire il flusso di controllo a qualsiasi pezzo arbitrario di codice. Non ci sono operazioni di salto dedicate perché, grazie alla mappatura della memoria, uno spostamento condizionale può sia copiare i dati nella RAM normale sia copiare un indirizzo di destinazione sul PC. Abbiamo anche scelto di rinunciare sia alle mosse incondizionate che ai salti incondizionati per un motivo simile: entrambi possono essere implementati come mossa condizionale con una condizione codificata su VERO.
Abbiamo scelto di avere due diversi tipi di mosse condizionate: "sposta se non zero" ( MNZ
) e "sposta se meno di zero" ( MLZ
). Funzionalmente, MNZ
equivale a verificare se un bit nei dati è un 1, mentre MLZ
equivale a verificare se il bit di segno è 1. Sono utili rispettivamente per le uguaglianze e i confronti. Il motivo per cui abbiamo scelto questi due rispetto ad altri come "sposta se zero" ( MEZ
) o "sposta se maggiore di zero" ( MGZ
) era che MEZ
avrebbe richiesto la creazione di un segnale VERO da un segnale vuoto, mentre MGZ
è un controllo più complesso, che richiede il il bit del segno è 0 mentre almeno un altro bit è 1.
Aritmetica
Le successive istruzioni più importanti, in termini di guida alla progettazione del processore, sono le operazioni aritmetiche di base. Come ho detto prima, stiamo usando dati seriali little-endian, con la scelta dell'endianness determinata dalla facilità delle operazioni di addizione / sottrazione. Avendo prima il bit meno significativo, le unità aritmetiche possono facilmente tenere traccia del bit di riporto.
Abbiamo scelto di utilizzare la rappresentazione del complemento di 2 per numeri negativi, poiché ciò rende più coerenti l'aggiunta e la sottrazione. Vale la pena notare che il computer Wireworld ha usato il complemento di 1.
L'aggiunta e la sottrazione sono l'estensione del supporto aritmetico nativo del nostro computer (oltre ai bit shift che verranno discussi più avanti). Altre operazioni, come la moltiplicazione, sono troppo complesse per essere gestite dalla nostra architettura e devono essere implementate nel software.
Operazioni bit a bit
Il nostro processore ha AND
, OR
e XOR
istruzioni che fanno ciò che ti aspetteresti. Piuttosto che avere NOT
un'istruzione, abbiamo scelto di avere un'istruzione "e-non" ( ANT
). La difficoltà con l' NOT
istruzione è di nuovo che deve creare il segnale da una mancanza di segnale, che è difficile con gli automi cellulari. L' ANT
istruzione restituisce 1 solo se il primo bit dell'argomento è 1 e il secondo bit dell'argomento è 0. Pertanto, NOT x
equivale a ANT -1 x
(così come XOR -1 x
). Inoltre, ANT
è versatile e ha il suo principale vantaggio nel mascheramento: nel caso del programma Tetris lo utilizziamo per cancellare i tetromini.
Bit Shifting
Le operazioni di spostamento dei bit sono le operazioni più complesse gestite dall'ALU. Prendono due input di dati: un valore da spostare e un importo da spostare. Nonostante la loro complessità (a causa della quantità variabile di spostamento), queste operazioni sono cruciali per molti compiti importanti, comprese le molte operazioni "grafiche" coinvolte in Tetris. I bit shift servirebbero anche da base per efficienti algoritmi di moltiplicazione / divisione.
Il nostro processore ha operazioni di shift a tre bit, "shift left" ( SL
), "shift right logic" ( SRL
) e "shift right arithmetic" ( SRA
). I primi due turni di bit ( SL
e SRL
) riempiono i nuovi bit con tutti gli zeri (il che significa che un numero negativo spostato a destra non sarà più negativo). Se il secondo argomento dello spostamento è al di fuori dell'intervallo compreso tra 0 e 15, il risultato è tutto zero, come ci si potrebbe aspettare. Per l'ultimo bit shift, SRA
il bit shift conserva il segno dell'ingresso e agisce quindi come una vera divisione per due.
Pipeline di istruzioni
Ora è il momento di parlare di alcuni dei dettagli grintosi dell'architettura. Ogni ciclo della CPU consiste nei seguenti cinque passaggi:
1. Recupera le istruzioni correnti dalla ROM
Il valore corrente del PC viene utilizzato per recuperare le istruzioni corrispondenti dalla ROM. Ogni istruzione ha un codice operativo e tre operandi. Ogni operando è costituito da una parola dati e una modalità di indirizzamento. Queste parti sono divise l'una dall'altra mentre vengono lette dalla ROM.
Il codice operativo è di 4 bit per supportare 16 codici operativi univoci, di cui 11 assegnati:
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. Scrivere il risultato (se necessario) dell'istruzione precedente nella RAM
A seconda della condizione dell'istruzione precedente (come il valore del primo argomento per uno spostamento condizionale), viene eseguita una scrittura. L'indirizzo della scrittura è determinato dal terzo operando dell'istruzione precedente.
È importante notare che la scrittura avviene dopo il recupero delle istruzioni. Ciò porta alla creazione di uno slot di ritardo del ramo in cui l'istruzione immediatamente dopo un'istruzione di ramo (qualsiasi operazione che scrive sul PC) viene eseguita al posto della prima istruzione sul bersaglio del ramo.
In alcuni casi (come i salti incondizionati), lo slot di ritardo del ramo può essere ottimizzato. In altri casi non può e le istruzioni dopo un ramo devono essere lasciate vuote. Inoltre, questo tipo di slot di ritardo significa che i rami devono usare un bersaglio di ramo che è 1 indirizzo in meno dell'effettiva istruzione di bersaglio, per tenere conto dell'incremento del PC che si verifica.
In breve, poiché l'output dell'istruzione precedente viene scritto su RAM dopo che è stata recuperata l'istruzione successiva, i salti condizionali devono avere un'istruzione vuota dopo di essi, altrimenti il PC non verrà aggiornato correttamente per il salto.
3. Leggere i dati per gli argomenti dell'istruzione corrente dalla RAM
Come accennato in precedenza, ciascuno dei tre operandi è costituito sia da una parola di dati che da una modalità di indirizzamento. La parola dati è di 16 bit, la stessa larghezza della RAM. La modalità di indirizzamento è di 2 bit.
Le modalità di indirizzamento possono essere una fonte di significativa complessità per un processore come questo, poiché molte modalità di indirizzamento del mondo reale implicano calcoli in più passaggi (come l'aggiunta di offset). Allo stesso tempo, le versatili modalità di indirizzamento svolgono un ruolo importante nell'usabilità del processore.
Abbiamo cercato di unificare i concetti di utilizzo di numeri hard-coded come operandi e utilizzo di indirizzi di dati come operandi. Ciò ha portato alla creazione di modalità di indirizzamento contro-basate: la modalità di indirizzamento di un operando è semplicemente un numero che rappresenta quante volte i dati dovrebbero essere inviati attorno a un ciclo di lettura RAM. Ciò comprende l'indirizzamento immediato, diretto, indiretto e doppio indiretto.
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)
Dopo aver eseguito questa dereferenziazione, i tre operandi dell'istruzione hanno ruoli diversi. Il primo operando è in genere il primo argomento per un operatore binario, ma funge anche da condizione quando l'istruzione corrente è una mossa condizionale. Il secondo operando funge da secondo argomento per un operatore binario. Il terzo operando funge da indirizzo di destinazione per il risultato dell'istruzione.
Poiché le prime due istruzioni fungono da dati mentre la terza funge da indirizzo, le modalità di indirizzamento hanno interpretazioni leggermente diverse a seconda della posizione in cui vengono utilizzate. Ad esempio, la modalità diretta viene utilizzata per leggere i dati da un indirizzo RAM fisso (poiché è necessaria una lettura RAM), ma la modalità immediata viene utilizzata per scrivere i dati su un indirizzo RAM fisso (poiché non sono necessarie letture RAM).
4. Calcola il risultato
Il codice operativo e i primi due operandi vengono inviati all'ALU per eseguire un'operazione binaria. Per le operazioni aritmetiche, bit a bit e di spostamento, ciò significa eseguire l'operazione pertinente. Per le mosse condizionate, ciò significa semplicemente restituire il secondo operando.
Il codice operativo e il primo operando vengono utilizzati per calcolare la condizione, che determina se scrivere o meno il risultato in memoria. Nel caso di spostamenti condizionali, ciò significa determinare se un bit nell'operando è 1 (per MNZ
) o determinare se il bit di segno è 1 (per MLZ
). Se il codice operativo non è una mossa condizionale, la scrittura viene sempre eseguita (la condizione è sempre vera).
5. Aumentare il contatore del programma
Infine, il contatore del programma viene letto, incrementato e scritto.
A causa della posizione dell'incremento del PC tra l'istruzione letta e la scrittura dell'istruzione, ciò significa che un'istruzione che incrementa il PC di 1 non è operativa. Un'istruzione che copia il PC su se stessa fa eseguire l'istruzione successiva due volte di seguito. Ma attenzione, più istruzioni per PC in una riga possono causare effetti complessi, incluso il loop infinito, se non si presta attenzione alla pipeline di istruzioni.
Quest for Tetris Assembly
Abbiamo creato un nuovo linguaggio assembly chiamato QFTASM per il nostro processore. Questo linguaggio assembly corrisponde 1 a 1 con il codice macchina nella ROM del computer.
Qualsiasi programma QFTASM è scritto come una serie di istruzioni, una per riga. Ogni riga è formattata in questo modo:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Elenco codici op
Come discusso in precedenza, ci sono undici codici operativi supportati dal computer, ognuno dei quali ha tre operandi:
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.
Modalità di indirizzamento
Ciascuno degli operandi contiene sia un valore di dati che uno spostamento di indirizzamento. Il valore dei dati è descritto da un numero decimale compreso tra -32768 e 32767. La modalità di indirizzamento è descritta da un prefisso di una lettera al valore dei dati.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Codice di esempio
Sequenza di Fibonacci in cinque righe:
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
Questo codice calcola la sequenza di Fibonacci, con l'indirizzo RAM 1 contenente il termine corrente. Trabocca rapidamente dopo il 28657.
Codice grigio:
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
Questo programma calcola il codice Gray e memorizza il codice in indirizzi successivi a partire dall'indirizzo 5. Questo programma utilizza diverse funzionalità importanti come l'indirizzamento indiretto e un salto condizionale. Si interrompe una volta ottenuto il codice Gray risultante 101010
, che si verifica per l'ingresso 51 all'indirizzo 56.
Interprete online
El'endia Starman ha creato un interprete online molto utile qui . È possibile scorrere il codice, impostare i punti di interruzione, eseguire scritture manuali su RAM e visualizzare la RAM come display.
Cogol
Una volta definiti il linguaggio di architettura e assembly, il passo successivo sul lato "software" del progetto è stato la creazione di un linguaggio di livello superiore, qualcosa di adatto a Tetris. Così ho creato Cogol . Il nome è sia un gioco di parole su "COBOL" sia un acronimo di "C of Game of Life", anche se vale la pena notare che Cogol è in C ciò che il nostro computer è un computer reale.
Cogol esiste a un livello appena sopra il linguaggio assembly. Generalmente, la maggior parte delle linee in un programma Cogol corrispondono ciascuna a una singola linea di assemblaggio, ma ci sono alcune caratteristiche importanti del linguaggio:
- Le funzionalità di base includono variabili denominate con assegnazioni e operatori con sintassi più leggibile. Ad esempio,
ADD A1 A2 3
diventa z = x + y;
, con il compilatore che associa le variabili agli indirizzi.
- Looping costrutti quali
if(){}
, while(){}
e do{}while();
quindi il compilatore maniglie ramificazione.
- Matrici unidimensionali (con puntatore aritmetico), utilizzate per la scheda Tetris.
- Subroutine e stack di chiamate. Questi sono utili per prevenire la duplicazione di grossi blocchi di codice e per supportare la ricorsione.
Il compilatore (che ho scritto da zero) è molto semplice / ingenuo, ma ho tentato di ottimizzare a mano diversi costrutti del linguaggio per ottenere una breve durata del programma compilato.
Ecco alcune brevi rassegne di come funzionano le varie funzionalità linguistiche:
tokenizzazione
Il codice sorgente è tokenizzato linearmente (single-pass), usando semplici regole su quali caratteri possono essere adiacenti all'interno di un token. Quando viene incontrato un personaggio che non può essere adiacente all'ultimo carattere del token corrente, il token corrente viene considerato completo e il nuovo personaggio inizia un nuovo token. Alcuni personaggi (come {
o ,
) non possono essere adiacenti ad altri personaggi e sono quindi i loro token. Altri (come >
o =
) possono solo essere adiacente ad altri caratteri all'interno loro classe, e possono quindi formare token come >>>
, ==
o >=
, ma non come =2
. I personaggi degli spazi bianchi forza un confine tra i token ma non sono essi stessi inclusi nel risultato. Il personaggio più difficile da tokenizzare è-
perché può rappresentare sia la sottrazione che la negazione unaria e quindi richiede un involucro speciale.
parsing
L'analisi viene eseguita anche in un solo passaggio. Il compilatore ha metodi per gestire ciascuno dei diversi costrutti del linguaggio e i token vengono estratti dall'elenco globale di token mentre vengono utilizzati dai vari metodi del compilatore. Se il compilatore vede mai un token che non si aspetta, genera un errore di sintassi.
Allocazione di memoria globale
Il compilatore assegna a ciascuna variabile globale (parola o matrice) i propri indirizzi RAM designati. È necessario dichiarare tutte le variabili utilizzando la parola chiave in my
modo che il compilatore sappia allocare spazio per essa. Molto più interessante delle variabili globali nominate è la gestione della memoria dell'indirizzo scratch. Molte istruzioni (in particolare condizionali e molti accessi all'array) richiedono indirizzi temporanei "scratch" per memorizzare calcoli intermedi. Durante il processo di compilazione, il compilatore alloca e disalloca gli indirizzi scratch, se necessario. Se il compilatore necessita di più indirizzi scratch, dedicherà più RAM come indirizzi scratch. Credo che sia tipico per un programma richiedere solo pochi indirizzi scratch, sebbene ogni indirizzo scratch verrà utilizzato più volte.
IF-ELSE
dichiarazioni
La sintassi per le if-else
istruzioni è il modulo C standard:
other code
if (cond) {
first body
} else {
second body
}
other code
Quando convertito in QFTASM, il codice è organizzato in questo modo:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Se il primo corpo viene eseguito, il secondo corpo viene ignorato. Se il primo corpo viene ignorato, viene eseguito il secondo corpo.
Nell'assemblaggio, un test di condizione è in genere solo una sottrazione e il segno del risultato determina se eseguire il salto o eseguire il corpo. Un'istruzione MLZ
viene utilizzata per gestire le disuguaglianze come >
o <=
. Un'istruzione MNZ
viene utilizzata per gestire ==
, poiché salta sul corpo quando la differenza non è zero (e quindi quando gli argomenti non sono uguali). I condizionali multi-espressione non sono attualmente supportati.
Se l' else
istruzione viene omessa, viene omesso anche il salto incondizionato e il codice QFTASM è simile al seguente:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
dichiarazioni
La sintassi per le while
istruzioni è anche il modulo C standard:
other code
while (cond) {
body
}
other code
Quando convertito in QFTASM, il codice è organizzato in questo modo:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
Il test delle condizioni e il salto condizionale si trovano alla fine del blocco, il che significa che vengono rieseguiti dopo ogni esecuzione del blocco. Quando la condizione viene restituita falsa, il corpo non viene ripetuto e il ciclo termina. Durante l'avvio dell'esecuzione del ciclo, il flusso di controllo passa sul corpo del ciclo al codice della condizione, quindi il corpo non viene mai eseguito se la condizione è falsa la prima volta.
Un'istruzione MLZ
viene utilizzata per gestire le disuguaglianze come >
o <=
. Diversamente dalle if
istruzioni, MNZ
viene utilizzata un'istruzione per gestire !=
, poiché salta al corpo quando la differenza non è zero (e quindi quando gli argomenti non sono uguali).
DO-WHILE
dichiarazioni
L'unica differenza tra while
e do-while
è che il do-while
corpo di un loop non viene inizialmente ignorato, quindi viene sempre eseguito almeno una volta. In genere utilizzo le do-while
istruzioni per salvare un paio di righe di codice assembly quando so che il loop non dovrà mai essere ignorato del tutto.
Array
Le matrici unidimensionali sono implementate come blocchi contigui di memoria. Tutti gli array sono a lunghezza fissa in base alla loro dichiarazione. Le matrici sono dichiarate così:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
Per l'array, questa è una possibile mappatura RAM, che mostra come gli indirizzi 15-18 sono riservati all'array:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
L'indirizzo etichettato alpha
viene riempito con un puntatore alla posizione di alpha[0]
, quindi in questo caso l'indirizzo 15 contiene il valore 16. La alpha
variabile può essere utilizzata all'interno del codice Cogol, possibilmente come puntatore di stack se si desidera utilizzare questo array come stack .
L'accesso agli elementi di un array avviene con la array[index]
notazione standard . Se il valore di index
è una costante, questo riferimento viene automaticamente inserito con l'indirizzo assoluto di quell'elemento. Altrimenti esegue qualche aritmetica del puntatore (solo aggiunta) per trovare l'indirizzo assoluto desiderato. È anche possibile nidificare l'indicizzazione, ad esempio alpha[beta[1]]
.
Subroutine e chiamate
Le subroutine sono blocchi di codice che possono essere richiamati da più contesti, impedendo la duplicazione del codice e consentendo la creazione di programmi ricorsivi. Ecco un programma con una subroutine ricorsiva per generare numeri di Fibonacci (sostanzialmente l'algoritmo più lento):
# 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;
}
Una subroutine viene dichiarata con la parola chiave sub
e una subroutine può essere posizionata ovunque all'interno del programma. Ogni subroutine può avere più variabili locali, che sono dichiarate come parte del suo elenco di argomenti. A questi argomenti possono anche essere dati valori predefiniti.
Per gestire le chiamate ricorsive, le variabili locali di una subroutine sono memorizzate nello stack. L'ultima variabile statica nella RAM è il puntatore dello stack di chiamate e tutta la memoria successiva funge da stack di chiamate. Quando viene chiamata una subroutine, viene creato un nuovo frame nello stack di chiamate, che include tutte le variabili locali e l'indirizzo di ritorno (ROM). A ogni subroutine nel programma viene assegnato un singolo indirizzo RAM statico che funge da puntatore. Questo puntatore indica la posizione della chiamata "corrente" della subroutine nello stack di chiamate. Il riferimento a una variabile locale viene eseguito utilizzando il valore di questo puntatore statico più un offset per fornire l'indirizzo di quella particolare variabile locale. Anche nello stack di chiamate è contenuto il valore precedente del puntatore statico. Qui'
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
Una cosa interessante delle subroutine è che non restituiscono alcun valore particolare. Piuttosto, tutte le variabili locali della subroutine possono essere lette dopo l'esecuzione della subroutine, quindi una varietà di dati può essere estratta da una chiamata di subroutine. Ciò si ottiene memorizzando il puntatore per quella chiamata specifica della subroutine, che può quindi essere utilizzata per recuperare qualsiasi variabile locale all'interno del frame dello stack (recentemente deallocato).
Esistono diversi modi per chiamare una subroutine, tutti utilizzando la call
parola chiave:
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
Qualsiasi numero di valori può essere fornito come argomento per una chiamata di subroutine. Qualsiasi argomento non fornito verrà compilato con l'eventuale valore predefinito. Un argomento non fornito e privo di valore predefinito non viene cancellato (per risparmiare istruzioni / tempo), pertanto potrebbe potenzialmente assumere qualsiasi valore all'inizio della subroutine.
I puntatori sono un modo per accedere a più variabili locali della subroutine, anche se è importante notare che il puntatore è solo temporaneo: i dati a cui punta il puntatore verranno distrutti quando viene effettuata un'altra chiamata di subroutine.
Etichette di debug
Qualsiasi {...}
blocco di codice in un programma Cogol può essere preceduto da un'etichetta descrittiva composta da più parole. Questa etichetta è allegata come commento nel codice assembly compilato e può essere molto utile per il debug in quanto semplifica l'individuazione di blocchi di codice specifici.
Ottimizzazione degli slot per ritardo di derivazione
Al fine di migliorare la velocità del codice compilato, il compilatore Cogol esegue alcune ottimizzazioni di slot di ritardo davvero basilari come passaggio finale sul codice QFTASM. Per qualsiasi salto incondizionato con uno slot di ritardo ramo vuoto, lo slot di ritardo può essere riempito dalla prima istruzione nella destinazione di salto e la destinazione di salto viene incrementata di uno per indicare l'istruzione successiva. Questo generalmente salva un ciclo ogni volta che viene eseguito un salto incondizionato.
Scrivere il codice Tetris in Cogol
Il programma finale di Tetris è stato scritto in Cogol e il codice sorgente è disponibile qui . Il codice QFTASM compilato è disponibile qui . Per comodità, un collegamento permanente è disponibile qui: Tetris in QFTASM . Poiché l'obiettivo era golfare il codice assembly (non il codice Cogol), il codice Cogol risultante è ingombrante. Molte parti del programma si troverebbero normalmente nelle subroutine, ma quelle subroutine erano effettivamente abbastanza brevi da duplicare il codice salvando le istruzioni sulcall
dichiarazioni. Il codice finale ha solo una subroutine oltre al codice principale. Inoltre, molti array sono stati rimossi e sostituiti con un elenco equivalentemente lungo di singole variabili o con molti numeri hardcoded nel programma. Il codice QFTASM compilato finale ha meno di 300 istruzioni, sebbene sia solo leggermente più lungo della fonte Cogol stessa.