Partie 4: QFTASM et Cogol
Vue d'ensemble de l'architecture
En bref, notre ordinateur a une architecture RISC Harvard asynchrone 16 bits. Lors de la construction manuelle d’un processeur, un RISC ( ordinateur à jeu d’instructions réduit) ) est pratiquement indispensable. Dans notre cas, cela signifie que le nombre d'opcodes est petit et, ce qui est beaucoup plus important, que toutes les instructions sont traitées de manière très similaire.
Pour référence, l'ordinateur Wireworld utilisait une architecture déclenchée par le transport , dans laquelle la seule instruction était MOV
et les calculs étaient effectués en écrivant / lisant des registres spéciaux. Bien que ce paradigme conduise à une architecture très facile à mettre en œuvre, le résultat est également inutilisable à la limite: toutes les opérations arithmétiques / logiques / conditionnelles nécessitent trois instructions. Il était clair pour nous que nous voulions créer une architecture beaucoup moins ésotérique.
Afin de garder notre processeur simple tout en augmentant la convivialité, nous avons pris plusieurs décisions de conception importantes:
- Pas de registres. Chaque adresse de la RAM est traitée de manière égale et peut être utilisée comme argument pour toute opération. Dans un sens, cela signifie que toute la mémoire vive peut être traitée comme un registre. Cela signifie qu'il n'y a pas d'instructions spéciales de chargement / stockage.
- Dans le même esprit, la cartographie de la mémoire. Tout ce qui pourrait être écrit ou lu à partir d’actions correspond à un schéma d’adressage unifié. Cela signifie que le compteur de programme (PC) est l'adresse 0 et que la seule différence entre les instructions normales et les instructions de flux de contrôle réside dans le fait que les instructions de flux de contrôle utilisent l'adresse 0.
- Les données sont en série en cours de transmission et parallèles en mémoire. En raison de la nature "électronique" de notre ordinateur, l'addition et la soustraction sont nettement plus faciles à mettre en œuvre lorsque les données sont transmises sous forme de petit-boutian en série (bit le moins significatif en premier). De plus, les données série suppriment le besoin de bus de données encombrants, qui sont à la fois très larges et fastidieux (pour que les données restent ensemble, toutes les "voies" du bus doivent subir le même temps de déplacement).
- Architecture de Harvard, ce qui signifie une division entre la mémoire programme (ROM) et la mémoire de données (RAM). Bien que cela réduise la flexibilité du processeur, cela facilite l'optimisation de la taille: la longueur du programme est beaucoup plus longue que la quantité de mémoire vive dont nous avons besoin. Nous pouvons donc scinder le programme en ROM et ensuite nous concentrer sur la compression de la ROM. , ce qui est beaucoup plus facile quand il est en lecture seule.
- Largeur de données 16 bits. C'est la plus petite puissance de deux qui est plus large qu'une carte Tetris standard (10 blocs). Cela nous donne une plage de données de -32768 à +32767 et une longueur de programme maximale de 65536 instructions. (2 ^ 8 = 256 instructions suffisent pour la plupart des choses simples que nous pourrions souhaiter faire par un processeur de jouet, mais pas pour Tetris.)
- Conception asynchrone. Plutôt que d'avoir une horloge centrale (ou, de manière équivalente, plusieurs horloges) dictant la synchronisation de l'ordinateur, toutes les données sont accompagnées d'un "signal d'horloge" qui voyage en parallèle avec les données lorsqu'elles circulent autour de l'ordinateur. Certains chemins peuvent être plus courts que d'autres, et bien que cela poserait des difficultés pour une conception à horloge centrale, une conception asynchrone peut facilement prendre en charge des opérations à temps variable.
- Toutes les instructions sont de taille égale. Nous avons estimé qu’une architecture dans laquelle chaque instruction comportait un opcode avec 3 opérandes (destination valeur) était l’option la plus flexible. Cela englobe les opérations de données binaires ainsi que les déplacements conditionnels.
- Système en mode d'adressage simple. Avoir une variété de modes d'adressage est très utile pour supporter des choses telles que les tableaux ou la récursion. Nous avons réussi à mettre en œuvre plusieurs modes d'adressage importants avec un système relativement simple.
Une illustration de notre architecture est contenue dans le post général.
Fonctionnalité et opérations ALU
À partir de là, il s’agissait de déterminer les fonctionnalités que devrait avoir notre processeur. Une attention particulière a été accordée à la facilité de mise en œuvre ainsi qu'à la polyvalence de chaque commande.
Mouvements conditionnels
Les mouvements conditionnels sont très importants et servent à la fois de flux de contrôle à petite et à grande échelle. "Petite échelle" désigne sa capacité à contrôler l'exécution d'un mouvement de données particulier, tandis que "grande échelle" désigne son utilisation en tant qu'opération de saut conditionnel pour transférer le flux de contrôle vers un élément de code quelconque. Il n'y a pas d'opération de saut dédiée car, en raison du mappage de la mémoire, un déplacement conditionnel peut à la fois copier des données dans la RAM normale et copier une adresse de destination sur le PC. Nous avons également choisi de renoncer aux mouvements inconditionnels et aux sauts inconditionnels pour une raison similaire: les deux peuvent être implémentés en tant que mouvement conditionnel avec une condition codée en dur en VRAI.
Nous avons choisi deux types de mouvements conditionnels: "move si non nul" ( MNZ
) et "move si inférieur à zéro" ( MLZ
). Fonctionnellement, cela MNZ
revient à vérifier si l'un des bits de la donnée est un 1, alors que MLZ
de vérifier si le bit de signe est à 1. Ils sont utiles pour les égalités et les comparaisons, respectivement. La raison pour laquelle nous avons choisi ces deux options, telles que "déplacer si zéro" ( MEZ
) ou "déplacer si supérieur à zéro" ( MGZ
) était MEZ
de créer un signal VRAI à partir d'un signal vide, alors qu'il MGZ
s'agit d'une vérification plus complexe nécessitant la le bit de signe soit à 0 tandis qu'au moins un autre bit est à 1.
Arithmétique
Les instructions suivantes les plus importantes pour guider la conception du processeur sont les opérations arithmétiques de base. Comme je l'ai mentionné précédemment, nous utilisons des données série little-endian, le choix de l'endianité étant déterminé par la facilité des opérations d'addition / soustraction. En faisant arriver le bit le moins significatif en premier, les unités arithmétiques peuvent facilement suivre le bit de retenue.
Nous avons choisi d'utiliser la représentation du complément à 2 pour les nombres négatifs, car cela rend l'addition et la soustraction plus cohérentes. Il est à noter que l'ordinateur Wireworld utilisait un complément à un.
L'addition et la soustraction sont l'étendue du support arithmétique natif de notre ordinateur (en plus des décalages de bits qui seront discutés plus tard). D'autres opérations, comme la multiplication, sont beaucoup trop complexes pour être gérées par notre architecture et doivent être implémentées dans un logiciel.
Opérations sur les bits
Notre processeur a AND
, OR
et des XOR
instructions qui font ce que vous attendez. Plutôt que d'avoir une NOT
instruction, nous avons choisi d'avoir une instruction "and-not" ( ANT
). La difficulté avec l' NOT
instruction est encore une fois qu'elle doit créer un signal à partir d'un manque de signal, ce qui est difficile avec un automate cellulaire. L' ANT
instruction ne renvoie 1 que si le bit du premier argument est 1 et que le second bit de l'argument vaut 0. Ainsi, NOT x
équivaut à ANT -1 x
(ainsi qu'à XOR -1 x
). De plus, il ANT
est polyvalent et présente le principal avantage de masquer: dans le cas du programme Tetris, nous l’utilisons pour effacer les tétrominos.
Bit Shifting
Les opérations de transfert de bits sont les opérations les plus complexes gérées par l'ALU. Ils prennent deux entrées de données: une valeur à déplacer et une quantité à déplacer. Malgré leur complexité (en raison du nombre variable de changements), ces opérations sont cruciales pour de nombreuses tâches importantes, y compris les nombreuses opérations "graphiques" impliquées dans Tetris. Les décalages de bits serviraient également de fondement à des algorithmes efficaces de multiplication / division.
Notre processeur effectue trois opérations de décalage de bits, "décalage gauche" ( SL
), "décalage droit logique" ( SRL
), et "décalage droit arithmétique" ( SRA
). Les deux premiers bits décalés ( SL
et SRL
) remplissent les nouveaux bits avec tous les zéros (ce qui signifie qu'un nombre négatif décalé à droite ne sera plus négatif). Si le deuxième argument du décalage est en dehors de la plage de 0 à 15, le résultat est tous les zéros, comme vous pouvez vous attendre. Pour le dernier changement de bit,SRA
, le décalage de bits préserve le signe de l'entrée et agit donc comme une véritable division par deux.
Instruction Pipelining
Le moment est venu de parler de certains des détails les plus difficiles de l’architecture. Chaque cycle de la CPU comprend les cinq étapes suivantes:
1. Récupérer l’instruction en cours de la ROM
La valeur actuelle du PC est utilisée pour extraire l’instruction correspondante de la ROM. Chaque instruction a un opcode et trois opérandes. Chaque opérande comprend un mot de données et un mode d'adressage. Ces parties sont séparées les unes des autres lors de leur lecture à partir de la ROM.
Le code opération est de 4 bits pour prendre en charge 16 codes opération uniques, dont 11 sont attribués:
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. Ecrivez le résultat (si nécessaire) de l' instruction précédente dans la RAM
Selon la condition de l'instruction précédente (telle que la valeur du premier argument d'un déplacement conditionnel), une écriture est effectuée. L'adresse de l'écriture est déterminée par le troisième opérande de l'instruction précédente.
Il est important de noter que l'écriture a lieu après l'extraction des instructions. Cela conduit à la création d'un intervalle de temps de branche dans lequel l'instruction est immédiatement exécutée après une instruction de branche (toute opération écrite sur le PC) à la place de la première instruction sur la cible de la branche.
Dans certains cas (comme des sauts inconditionnels), l'intervalle de retard de branche peut être optimisé. Dans d'autres cas, cela ne peut pas et l'instruction après une branche doit être laissée vide. En outre, ce type d'intervalle de retard signifie que les branches doivent utiliser une cible de branche dont l'adresse est inférieure de 1 à l'instruction cible réelle, afin de prendre en compte l'incrément PC qui se produit.
En bref, étant donné que la sortie de l'instruction précédente est écrite dans la RAM après l'extraction de l'instruction suivante, les sauts conditionnels doivent avoir une instruction vide après eux, sinon le PC ne sera pas mis à jour correctement pour le saut.
3. Lisez les données des arguments de l'instruction en cours à partir de la RAM.
Comme mentionné précédemment, chacun des trois opérandes comprend à la fois un mot de données et un mode d'adressage. Le mot de données est 16 bits, la même largeur que RAM. Le mode d'adressage est 2 bits.
Les modes d'adressage peuvent être une source de complexité importante pour un processeur comme celui-ci, car de nombreux modes d'adressage dans le monde réel impliquent des calculs en plusieurs étapes (comme l'ajout de décalages). Dans le même temps, les modes d'adressage polyvalents jouent un rôle important dans la convivialité du processeur.
Nous avons cherché à unifier les concepts d'utilisation de nombres codés en dur en tant qu'opérandes et d'utilisation d'adresses de données en tant qu'opérandes. Cela a conduit à la création de modes d'adressage basés sur des compteurs: le mode d'adressage d'un opérande est simplement un nombre représentant le nombre de fois que les données doivent être envoyées autour d'une boucle de lecture RAM. Cela englobe l'adressage immédiat, direct, indirect et double-indirect.
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)
Une fois ce déréférencement effectué, les trois opérandes de l'instruction ont des rôles différents. Le premier opérande est généralement le premier argument d'un opérateur binaire, mais sert également de condition lorsque l'instruction en cours est un déplacement conditionnel. Le deuxième opérande sert de deuxième argument pour un opérateur binaire. Le troisième opérande sert d'adresse de destination pour le résultat de l'instruction.
Comme les deux premières instructions servent de données, tandis que la troisième sert d’adresse, les modes d’adressage ont des interprétations légèrement différentes selon la position dans laquelle ils sont utilisés. Par exemple, le mode direct est utilisé pour lire des données à partir d’une adresse RAM fixe ( une lecture RAM est nécessaire), mais le mode immédiat est utilisé pour écrire des données sur une adresse RAM fixe (aucune lecture RAM n'étant nécessaire).
4. Calculer le résultat
Le code d'opération et les deux premiers opérandes sont envoyés à l'ALU pour effectuer une opération binaire. Pour les opérations arithmétiques, au niveau du bit et de décalage, cela signifie que vous effectuez l'opération appropriée. Pour les mouvements conditionnels, cela signifie simplement renvoyer le deuxième opérande.
L'opcode et le premier opérande sont utilisés pour calculer la condition, qui détermine si le résultat doit être écrit ou non en mémoire. Dans le cas de déplacements conditionnels, cela signifie soit de déterminer si un bit de l'opérande est égal à 1 (pour MNZ
), soit de déterminer si le bit de signe est égal à 1 (pour MLZ
). Si l'opcode n'est pas un mouvement conditionnel, l'écriture est toujours effectuée (la condition est toujours vraie).
5. Incrémenter le compteur de programme
Enfin, le compteur de programme est lu, incrémenté et écrit.
En raison de la position de l'incrément du PC entre l'instruction lue et l'écriture de l'instruction, cela signifie qu'une instruction qui incrémente le PC de 1 est un non-op. Une instruction qui copie le PC sur lui-même entraîne l'exécution de l'instruction suivante deux fois de suite. Mais, soyez averti, plusieurs instructions PC consécutives peuvent provoquer des effets complexes, y compris des boucles infinies, si vous ne faites pas attention au pipeline d'instructions.
Quest for Tetris Assembly
Nous avons créé un nouveau langage d'assemblage appelé QFTASM pour notre processeur. Ce langage d'assemblage correspond 1 pour 1 au code de la machine dans la ROM de l'ordinateur.
Tout programme QFTASM est écrit sous la forme d’une série d’instructions, une par ligne. Chaque ligne est formatée comme ceci:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Liste d'opcode
Comme indiqué plus haut, l’ordinateur prend en charge onze codes, chacun ayant trois opérandes:
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.
Modes d'adressage
Chacun des opérandes contient à la fois une valeur de données et un déplacement d'adressage. La valeur de données est décrite par un nombre décimal compris entre -32768 et 32767. Le mode d'adressage est décrit par un préfixe d'une lettre à la valeur de donnée.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Exemple de code
Séquence de Fibonacci en cinq lignes:
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
Ce code calcule la séquence de Fibonacci, l’adresse RAM 1 contenant le terme actuel. Il déborde rapidement après 28657.
Code gris:
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
Ce programme calcule le code Gray et le stocke dans des adresses successives commençant à l'adresse 5. Ce programme utilise plusieurs fonctionnalités importantes telles que l'adressage indirect et un saut conditionnel. Il s'arrête une fois que le code gris résultant est 101010
, ce qui se produit pour l'entrée 51 à l'adresse 56.
Interprète en ligne
El'endia Starman a créé un interprète en ligne très utile ici . Vous pouvez parcourir le code, définir des points d'arrêt, effectuer des écritures manuelles sur la RAM et visualiser la RAM sous forme d'affichage.
Cogol
Une fois que l'architecture et le langage d'assemblage ont été définis, l'étape suivante du côté "logiciel" du projet a été la création d'un langage de niveau supérieur, adapté à Tetris. C'est ainsi que j'ai créé Cogol . Le nom est à la fois un jeu de mots sur "COBOL" et un acronyme pour "C de Game of Life", bien qu'il soit intéressant de noter que Cogol est à C ce que notre ordinateur est à un ordinateur réel.
Cogol existe à un niveau juste au-dessus du langage d'assemblage. Généralement, la plupart des lignes d'un programme Cogol correspondent chacune à une seule ligne d'assemblage, mais le langage présente certaines caractéristiques importantes:
- Les fonctionnalités de base incluent des variables nommées avec des affectations et des opérateurs ayant une syntaxe plus lisible. Par exemple,
ADD A1 A2 3
devient z = x + y;
, avec le compilateur, mappant des variables sur des adresses.
- Des constructions en boucle telles que
if(){}
, while(){}
et do{}while();
ainsi, le compilateur gère les branches.
- Tableaux unidimensionnels (avec arithmétique de pointeur), utilisés pour le tableau Tetris.
- Des sous-programmes et une pile d'appels. Celles-ci sont utiles pour empêcher la duplication de gros morceaux de code et pour prendre en charge la récursivité.
Le compilateur (que j'ai écrit à partir de zéro) est très basique / naïf, mais j'ai essayé d'optimiser manuellement plusieurs constructions de langage pour obtenir une longueur de programme compilée courte.
Voici quelques aperçus du fonctionnement de diverses fonctionnalités linguistiques:
Tokenization
Le code source est segmenté linéairement (passe unique), à l'aide de règles simples permettant de déterminer quels caractères sont autorisés à être adjacents dans un jeton. Lorsqu'un caractère rencontré ne peut pas être adjacent au dernier caractère du jeton actuel, le jeton actuel est considéré comme terminé et le nouveau caractère commence un nouveau jeton. Certains caractères (tels que {
ou ,
) ne peuvent pas être adjacents à d'autres caractères et constituent donc leur propre jeton. D' autres (comme >
ou =
) ne sont autorisés à être adjacents à d' autres personnages dans leur classe, et peuvent ainsi former des jetons comme >>>
, ==
ou >=
, mais pas comme =2
. Les caractères d'espacement imposent une limite entre les jetons mais ne sont pas eux-mêmes inclus dans le résultat. Le personnage le plus difficile à tokenize est-
car il peut à la fois représenter une soustraction et une négation unaire, et nécessite donc une casse spéciale.
L'analyse
L'analyse est également effectuée en une seule passe. Le compilateur dispose de méthodes pour gérer chacune des différentes constructions de langage et les jetons sont extraits de la liste des jetons globaux au fur et à mesure de leur utilisation par les différentes méthodes du compilateur. Si le compilateur voit un jeton qu'il ne s'attend pas, il génère une erreur de syntaxe.
Allocation de mémoire globale
Le compilateur assigne à chaque variable globale (mot ou tableau) sa ou ses adresses RAM désignées. Il est nécessaire de déclarer toutes les variables à l'aide du mot clé my
afin que le compilateur sache lui allouer de l'espace. La gestion de la mémoire d’adresses de travail est beaucoup plus intéressante que les variables globales nommées. De nombreuses instructions (notamment les conditions et de nombreux accès au tableau) nécessitent des adresses temporaires de travail pour stocker les calculs intermédiaires. Pendant le processus de compilation, le compilateur alloue et désalloue des adresses de travail si nécessaire. Si le compilateur a besoin d’adresses de travail supplémentaires, il consacrera plus de RAM en tant qu’adresses de travail. Je pense qu’il est typique pour un programme de n’exiger que quelques adresses de travail, bien que chaque adresse de travail soit utilisée à plusieurs reprises.
IF-ELSE
Les déclarations
La syntaxe des if-else
instructions est la forme C standard:
other code
if (cond) {
first body
} else {
second body
}
other code
Une fois converti en QFTASM, le code est organisé comme suit:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Si le premier corps est exécuté, le second corps est ignoré. Si le premier corps est ignoré, le deuxième corps est exécuté.
Dans l'assemblage, un test de condition est généralement une simple soustraction, et le signe du résultat détermine s'il faut effectuer le saut ou exécuter le corps. Une MLZ
instruction est utilisée pour gérer des inégalités telles que >
ou <=
. Une MNZ
instruction est utilisée pour gérer ==
, car elle saute sur le corps lorsque la différence n'est pas nulle (et donc lorsque les arguments ne sont pas égaux). Les conditions de multi-expression ne sont actuellement pas prises en charge.
Si l' else
instruction est omise, le saut inconditionnel l'est aussi, et le code QFTASM ressemble à ceci:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
Les déclarations
La syntaxe des while
instructions est également la forme C standard:
other code
while (cond) {
body
}
other code
Une fois converti en QFTASM, le code est organisé comme suit:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
Le test de condition et le saut conditionnel sont à la fin du bloc, ce qui signifie qu'ils sont réexécutés après chaque exécution du bloc. Lorsque la condition est fausse, le corps n'est pas répété et la boucle se termine. Au début de l'exécution de la boucle, le flux de contrôle saute sur le corps de la boucle jusqu'au code de condition. Ainsi, le corps n'est jamais exécuté si la condition est fausse la première fois.
Une MLZ
instruction est utilisée pour gérer des inégalités telles que >
ou <=
. Contrairement aux if
instructions, une MNZ
instruction est utilisée pour manipuler !=
, car elle saute au corps lorsque la différence n'est pas égale à zéro (et donc lorsque les arguments ne sont pas égaux).
DO-WHILE
Les déclarations
La seule différence entre while
et do-while
est que le do-while
corps d' une boucle n'est pas initialement ignoré, il est donc toujours exécuté au moins une fois. J'utilise généralement des do-while
instructions pour enregistrer quelques lignes de code d'assemblage lorsque je sais que la boucle n'aura jamais besoin d'être ignorée.
Tableaux
Les tableaux unidimensionnels sont implémentés sous forme de blocs de mémoire contigus. Tous les tableaux ont une longueur fixe en fonction de leur déclaration. Les tableaux sont déclarés comme suit:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
Pour le tableau, il s'agit d'un mappage RAM possible, montrant comment les adresses 15-18 sont réservées pour le tableau:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
L'adresse étiquetée alpha
est remplie par un pointeur sur l'emplacement de alpha[0]
. Ainsi, dans ce cas, l'adresse 15 contient la valeur 16. La alpha
variable peut être utilisée à l'intérieur du code Cogol, éventuellement en tant que pointeur de pile si vous souhaitez utiliser ce tableau comme une pile. .
L'accès aux éléments d'un tableau se fait avec la array[index]
notation standard . Si la valeur de index
est une constante, cette référence est automatiquement renseignée avec l'adresse absolue de cet élément. Sinon, il effectue une arithmétique de pointeur (juste addition) pour trouver l'adresse absolue souhaitée. Il est également possible d'imbriquer une indexation, telle que alpha[beta[1]]
.
Sous-routines et appels
Les sous-routines sont des blocs de code pouvant être appelés à partir de plusieurs contextes, empêchant la duplication de code et permettant la création de programmes récursifs. Voici un programme avec un sous-programme récursif pour générer des nombres de Fibonacci (essentiellement l'algorithme le plus lent):
# 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;
}
Un sous-programme est déclaré avec le mot-clé sub
et un sous-programme peut être placé n'importe où dans le programme. Chaque sous-routine peut avoir plusieurs variables locales, qui sont déclarées dans la liste des arguments. Ces arguments peuvent également recevoir des valeurs par défaut.
Afin de gérer les appels récursifs, les variables locales d'un sous-programme sont stockées dans la pile. La dernière variable statique dans la RAM est le pointeur de la pile d'appels, et toute la mémoire qui suit sert de pile d'appels. Lorsqu’un sous-programme est appelé, il crée une nouvelle trame sur la pile d’appels, qui inclut toutes les variables locales ainsi que l’adresse de retour (ROM). Chaque sous-routine du programme reçoit une seule adresse RAM statique pour servir de pointeur. Ce pointeur indique l'emplacement de l'appel "en cours" du sous-programme dans la pile d'appels. Le référencement d'une variable locale s'effectue à l'aide de la valeur de ce pointeur statique et d'un décalage pour donner l'adresse de cette variable locale particulière. La pile d'appels contient également la valeur précédente du pointeur statique. Ici'
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
Une chose intéressante à propos des sous-programmes est qu’ils ne renvoient aucune valeur particulière. Au lieu de cela, toutes les variables locales du sous-programme peuvent être lues après l'exécution du sous-programme, de sorte qu'une variété de données peut être extraite d'un appel de sous-programme. Ceci est accompli en stockant le pointeur pour cet appel spécifique du sous-programme, qui peut ensuite être utilisé pour récupérer l'une des variables locales à partir du cadre de pile (récemment désalloué).
Il existe plusieurs façons d'appeler un sous-programme, toutes en utilisant le call
mot - clé:
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
N'importe quel nombre de valeurs peut être donné comme argument pour un appel de sous-routine. Tout argument non fourni sera renseigné avec sa valeur par défaut, le cas échéant. Un argument qui n'est pas fourni et qui n'a pas de valeur par défaut n'est pas effacé (pour enregistrer des instructions / du temps) et peut donc potentiellement prendre n'importe quelle valeur au début du sous-programme.
Les pointeurs sont un moyen d'accéder à plusieurs variables locales du sous-programme, bien qu'il soit important de noter que le pointeur n'est que temporaire: les données pointées sur le pointeur seront détruites lors d'un autre appel de sous-programme.
Débogage des étiquettes
Tout {...}
bloc de code dans un programme Cogol peut être précédé d’une étiquette descriptive comportant plusieurs mots. Cette étiquette est jointe en tant que commentaire dans le code d'assembly compilé et peut s'avérer très utile pour le débogage, car elle facilite la localisation de fragments de code spécifiques.
Optimisation d'emplacement de délai de branche
Afin d’accroître la rapidité du code compilé, le compilateur Cogol effectue une optimisation des créneaux de retard vraiment élémentaire lors du passage final sur le code QFTASM. Pour tout saut inconditionnel avec un intervalle de retard de branche vide, cet intervalle peut être rempli par la première instruction à la destination du saut, et la destination du saut est incrémentée de un à la prochaine instruction. Cela enregistre généralement un cycle à chaque fois qu'un saut inconditionnel est effectué.
Écrire le code Tetris dans Cogol
Le programme final de Tetris a été écrit en Cogol et le code source est disponible ici . Le code QFTASM compilé est disponible ici . Pour plus de commodité, un lien permanent est fourni ici: Tetris dans QFTASM . L'objectif étant de jouer au code d'assemblage (et non au code Cogol), le code Cogol résultant est difficile à manier. De nombreuses parties du programme se trouveraient normalement dans des sous-routines, mais ces sous-routines étaient en fait suffisamment courtes pour que les instructions enregistrées en code soient dupliquées par dessus.call
déclarations. Le code final n'a qu'un seul sous-programme en plus du code principal. De plus, de nombreux tableaux ont été supprimés et remplacés soit par une liste de variables individuelles de longueur équivalente, soit par un grand nombre de nombres codés en dur dans le programme. Le code QFTASM final compilé contient moins de 300 instructions, bien qu’il soit légèrement plus long que la source Cogol elle-même.