Parte 4: QFTASM e Cogol
Visão geral da arquitetura
Em resumo, nosso computador possui uma arquitetura RISC Harvard assíncrona de 16 bits. Ao construir um processador manualmente, uma arquitetura RISC ( computador com conjunto de instruções reduzido ) é praticamente um requisito. No nosso caso, isso significa que o número de códigos de operação é pequeno e, muito mais importante, que todas as instruções são processadas de maneira muito semelhante.
Para referência, o computador Wireworld usou uma arquitetura acionada por transporte , na qual a única instrução era MOV
e os cálculos eram executados escrevendo / lendo registros especiais. Embora esse paradigma leve a uma arquitetura muito fácil de implementar, o resultado também é inutilizável: todas as operações aritméticas / lógicas / condicionais requerem três instruções. Ficou claro para nós que queríamos criar uma arquitetura muito menos esotérica.
Para manter nosso processador simples e aumentar a usabilidade, tomamos várias decisões importantes de design:
- Sem registros. Cada endereço na RAM é tratado da mesma forma e pode ser usado como qualquer argumento para qualquer operação. De certa forma, isso significa que toda a RAM pode ser tratada como registradora. Isso significa que não há instruções especiais de carregamento / armazenamento.
- Na mesma linha, mapeamento de memória. Tudo o que poderia ser gravado ou lido em compartilha um esquema de endereçamento unificado. Isso significa que o contador de programa (PC) é o endereço 0, e a única diferença entre instruções regulares e instruções de controle de fluxo é que as instruções de controle de fluxo usam o endereço 0.
- Os dados são de série na transmissão, paralelos no armazenamento. Devido à natureza baseada em "elétrons" do nosso computador, a adição e subtração são significativamente mais fáceis de implementar quando os dados são transmitidos na forma serial little-endian (bit menos significativo primeiro). Além disso, os dados seriais eliminam a necessidade de barramentos de dados complicados, que são realmente amplos e complicados de tempo adequadamente (para que os dados permaneçam juntos, todas as "faixas" do barramento devem experimentar o mesmo atraso de viagem).
- Arquitetura de Harvard, significando uma divisão entre memória de programa (ROM) e memória de dados (RAM). Embora isso reduza a flexibilidade do processador, isso ajuda na otimização do tamanho: a duração do programa é muito maior que a quantidade de RAM necessária, para que possamos dividir o programa em ROM e depois nos concentrar na compactação. , o que é muito mais fácil quando é somente leitura.
- Largura de dados de 16 bits. Essa é a menor potência de duas que é mais larga que uma placa Tetris padrão (10 blocos). Isso nos fornece um intervalo de dados de -32768 a +32767 e um comprimento máximo de programa de 65536 instruções. (2 ^ 8 = 256 instruções é suficiente para a maioria das coisas simples que podemos querer que um processador de brinquedo faça, mas não o Tetris.)
- Design assíncrono. Em vez de ter um relógio central (ou, equivalentemente, vários relógios) ditando o tempo do computador, todos os dados são acompanhados por um "sinal de relógio" que viaja em paralelo com os dados enquanto circula pelo computador. Certos caminhos podem ser mais curtos que outros e, embora isso represente dificuldades para um design com relógio central, um design assíncrono pode lidar facilmente com operações de tempo variável.
- Todas as instruções são do mesmo tamanho. Consideramos que uma arquitetura na qual cada instrução possui 1 código de operação com 3 operandos (destino do valor-valor) foi a opção mais flexível. Isso inclui operações de dados binários e movimentos condicionais.
- Sistema de modo de endereçamento simples. Ter uma variedade de modos de endereçamento é muito útil para dar suporte a itens como matrizes ou recursão. Conseguimos implementar vários modos importantes de endereçamento com um sistema relativamente simples.
Uma ilustração da nossa arquitetura está contida no post de visão geral.
Funcionalidade e operações da ULA
A partir daqui, era uma questão de determinar qual funcionalidade nosso processador deveria ter. Foi dada atenção especial à facilidade de implementação, bem como à versatilidade de cada comando.
Movimentos Condicionais
Movimentos condicionais são muito importantes e servem como fluxo de controle em pequena e grande escala. "Pequena escala" refere-se à sua capacidade de controlar a execução de uma movimentação de dados específica, enquanto "grande escala" refere-se ao seu uso como uma operação de salto condicional para transferir o fluxo de controle para qualquer trecho de código arbitrário. Não há operações de salto dedicadas porque, devido ao mapeamento de memória, uma movimentação condicional pode copiar dados para a RAM comum e copiar um endereço de destino para o PC. Também optamos por renunciar a movimentos incondicionais e saltos incondicionais por um motivo semelhante: ambos podem ser implementados como um movimento condicional com uma condição codificada como TRUE.
Optamos por ter dois tipos diferentes de movimentos condicionais: "mova se não for zero" ( MNZ
) e "mova se for menor que zero" ( MLZ
). Funcionalmente, MNZ
equivale a verificar se algum bit nos dados é 1, enquanto MLZ
equivale a verificar se o bit de sinal é 1. Eles são úteis para igualdades e comparações, respectivamente. A razão pela qual escolhemos esses dois em detrimento de outros, como "mover se zero" ( MEZ
) ou "mover se maior que zero" ( MGZ
), é que MEZ
exigiria a criação de um sinal VERDADEIRO a partir de um sinal vazio, enquanto MGZ
é uma verificação mais complexa, exigindo o bit de sinal seja 0 enquanto pelo menos um outro bit seja 1.
Aritmética
As próximas instruções mais importantes, em termos de orientação do design do processador, são as operações aritméticas básicas. Como mencionei anteriormente, estamos usando dados seriais little-endian, com a escolha do endianness determinada pela facilidade das operações de adição / subtração. Ao fazer com que o bit menos significativo chegue primeiro, as unidades aritméticas podem acompanhar facilmente o bit de transporte.
Optamos por usar a representação do complemento de 2 para números negativos, pois isso torna a adição e a subtração mais consistentes. Vale a pena notar que o computador Wireworld usou o complemento de 1.
Adição e subtração são a extensão do suporte aritmético nativo do nosso computador (além das mudanças de bits que serão discutidas mais adiante). Outras operações, como multiplicação, são complexas demais para serem tratadas por nossa arquitetura e devem ser implementadas em software.
Operações bit a bit
Nosso processador tem AND
, OR
e XOR
instruções que não o que você esperaria. Em vez de ter uma NOT
instrução, optamos por ter uma instrução "e-não" ( ANT
). A dificuldade com a NOT
instrução é novamente que ela deve criar sinal a partir da falta de sinal, o que é difícil com os autômatos celulares. A ANT
instrução retornará 1 apenas se o primeiro bit de argumento for 1 e o segundo bit de argumento for 0. Portanto, NOT x
é equivalente a ANT -1 x
(assim como XOR -1 x
). Além disso, ANT
é versátil e tem sua principal vantagem em mascarar: no caso do programa Tetris, o usamos para apagar tetrominoes.
Mudança de bits
As operações de troca de bits são as operações mais complexas manipuladas pela ALU. Eles recebem duas entradas de dados: um valor para mudar e uma quantidade para alterá-lo. Apesar de sua complexidade (devido à quantidade variável de mudança), essas operações são cruciais para muitas tarefas importantes, incluindo as muitas operações "gráficas" envolvidas no Tetris. Mudanças de bits também serviriam de base para algoritmos eficientes de multiplicação / divisão.
Nosso processador possui operações de deslocamento de três bits, "shift left" ( SL
), "shift right logic" ( SRL
) e "shift right aritmetic" ( SRA
). Os dois primeiros turnos de bits ( SL
e SRL
) preenchem os novos bits com todos os zeros (o que significa que um número negativo deslocado para a direita não será mais negativo). Se o segundo argumento da mudança estiver fora do intervalo de 0 a 15, o resultado será todos os zeros, como seria de esperar. Para o último deslocamento de bits SRA
, o deslocamento de bits preserva o sinal da entrada e, portanto, atua como uma verdadeira divisão por dois.
Pipelining de instruções
Agora é a hora de falar sobre alguns dos detalhes da arquitetura. Cada ciclo da CPU consiste nas cinco etapas a seguir:
1. Busque a instrução atual na ROM
O valor atual do PC é usado para buscar a instrução correspondente na ROM. Cada instrução possui um opcode e três operandos. Cada operando consiste em uma palavra de dados e um modo de endereçamento. Essas partes são divididas uma da outra à medida que são lidas na ROM.
O código de operação é de 4 bits para suportar 16 códigos de operação únicos, dos quais 11 são atribuídos:
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. Escreva o resultado (se necessário) da instrução anterior na RAM
Dependendo da condição da instrução anterior (como o valor do primeiro argumento para uma movimentação condicional), uma gravação é executada. O endereço da gravação é determinado pelo terceiro operando da instrução anterior.
É importante observar que a gravação ocorre após a busca das instruções. Isso leva à criação de um slot de atraso de ramificação no qual a instrução imediatamente após uma instrução de ramificação (qualquer operação que grava no PC) é executada em vez da primeira instrução no destino da ramificação.
Em certos casos (como saltos incondicionais), o slot de atraso da ramificação pode ser otimizado. Em outros casos, não pode, e a instrução após uma ramificação deve ser deixada vazia. Além disso, esse tipo de slot de atraso significa que as ramificações devem usar um destino de ramificação 1 endereço menor que a instrução de destino real, para contabilizar o incremento do PC que ocorre.
Em resumo, como a saída da instrução anterior é gravada na RAM após a busca da próxima instrução, os saltos condicionais precisam ter uma instrução em branco após eles, ou o PC não será atualizado corretamente para o salto.
3. Leia os dados para os argumentos da instrução atual da RAM
Como mencionado anteriormente, cada um dos três operandos consiste em uma palavra de dados e um modo de endereçamento. A palavra de dados é 16 bits, a mesma largura que a RAM. O modo de endereçamento é de 2 bits.
Os modos de endereçamento podem ser uma fonte de complexidade significativa para um processador como esse, pois muitos modos de endereçamento no mundo real envolvem cálculos em várias etapas (como adicionar compensações). Ao mesmo tempo, modos de endereçamento versáteis desempenham um papel importante na usabilidade do processador.
Procuramos unificar os conceitos de uso de números codificados como operandos e de endereços de dados como operandos. Isso levou à criação de modos de endereçamento baseados em contador: o modo de endereçamento de um operando é simplesmente um número que representa quantas vezes os dados devem ser enviados em torno de um loop de leitura de RAM. Isso abrange endereçamento imediato, direto, indireto e indireto duplo.
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)
Após essa desreferenciação ser realizada, os três operandos da instrução têm funções diferentes. O primeiro operando é geralmente o primeiro argumento para um operador binário, mas também serve como condição quando a instrução atual é uma movimentação condicional. O segundo operando serve como o segundo argumento para um operador binário. O terceiro operando serve como o endereço de destino para o resultado da instrução.
Como as duas primeiras instruções servem como dados, enquanto a terceira serve como endereço, os modos de endereçamento têm interpretações ligeiramente diferentes, dependendo da posição em que são usadas. Por exemplo, o modo direto é usado para ler dados de um endereço RAM fixo (desde é necessária uma leitura de RAM), mas o modo imediato é usado para gravar dados em um endereço de RAM fixo (já que não são necessárias leituras de RAM).
4. Calcule o resultado
O opcode e os dois primeiros operandos são enviados à ALU para executar uma operação binária. Para as operações aritmética, bit a bit e shift, isso significa executar a operação relevante. Para os movimentos condicionais, isso significa simplesmente retornar o segundo operando.
O opcode e o primeiro operando são usados para calcular a condição, que determina se deve ou não gravar o resultado na memória. No caso de movimentos condicionais, isso significa determinar se algum bit no operando é 1 (para MNZ
) ou determinar se o bit de sinal é 1 (para MLZ
). Se o opcode não for uma movimentação condicional, a gravação será sempre realizada (a condição é sempre verdadeira).
5. Incremente o contador do programa
Finalmente, o contador do programa é lido, incrementado e gravado.
Devido à posição do incremento do PC entre a leitura da instrução e a gravação da instrução, isso significa que uma instrução que incrementa o PC em 1 é não operacional. Uma instrução que copia o PC para si mesma faz com que a próxima instrução seja executada duas vezes seguidas. Porém, lembre-se de que várias instruções consecutivas do PC podem causar efeitos complexos, incluindo loop infinito, se você não prestar atenção ao pipeline de instruções.
Quest for Tetris Assembly
Criamos uma nova linguagem assembly chamada QFTASM para o nosso processador. Essa linguagem de montagem corresponde 1 a 1 com o código da máquina na ROM do computador.
Qualquer programa QFTASM é escrito como uma série de instruções, uma por linha. Cada linha é formatada assim:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Opcode List
Como discutido anteriormente, existem onze códigos de operação suportados pelo computador, cada um dos quais com três operandos:
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.
Modos de endereçamento
Cada um dos operandos contém um valor de dados e uma movimentação de endereçamento. O valor dos dados é descrito por um número decimal no intervalo de -32768 a 32767. O modo de endereçamento é descrito por um prefixo de uma letra ao valor dos dados.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Código de exemplo
Sequência de Fibonacci em cinco linhas:
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
Esse código calcula a sequência de Fibonacci, com o endereço de RAM 1 contendo o termo atual. Ele transborda rapidamente após 28657.
Código cinza:
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
Este programa calcula o código Gray e armazena o código em endereços sucessivos começando no endereço 5. Este programa utiliza vários recursos importantes, como endereçamento indireto e um salto condicional. Ele para quando o código Gray resultante é o 101010
que ocorre na entrada 51 no endereço 56.
Intérprete Online
El'endia Starman criou um intérprete online muito útil aqui . Você é capaz de percorrer o código, definir pontos de interrupção, executar gravações manuais na RAM e visualizar a RAM como uma exibição.
Cogol
Uma vez definidas a arquitetura e a linguagem assembly, o próximo passo no lado "software" do projeto foi a criação de uma linguagem de nível superior, algo adequado para o Tetris. Assim, eu criei a Cogol . O nome é um trocadilho com "COBOL" e um acrônimo para "C of Game of Life", embora seja interessante notar que Cogol é para C o que nosso computador é para um computador real.
O Cogol existe em um nível logo acima da linguagem assembly. Geralmente, a maioria das linhas de um programa Cogol corresponde a uma única linha de montagem, mas existem alguns recursos importantes da linguagem:
- Os recursos básicos incluem variáveis nomeadas com atribuições e operadores que possuem sintaxe mais legível. Por exemplo,
ADD A1 A2 3
torna-se z = x + y;
, com o compilador, mapeando variáveis para endereços.
- Looping construções, tais como
if(){}
, while(){}
e do{}while();
de modo que o compilador alças ramificação.
- Matrizes unidimensionais (com aritmética de ponteiro), que são usadas para a placa Tetris.
- Sub-rotinas e uma pilha de chamadas. Eles são úteis para impedir a duplicação de grandes blocos de código e para oferecer suporte à recursão.
O compilador (que escrevi do zero) é muito básico / ingênuo, mas tentei otimizar manualmente várias construções de linguagem para obter um curto comprimento de programa compilado.
Aqui estão algumas breves visões gerais de como os vários recursos de idioma funcionam:
Tokenização
O código-fonte é tokenizado linearmente (passagem única), usando regras simples sobre quais caracteres podem ficar adjacentes a um token. Quando é encontrado um personagem que não pode ser adjacente ao último caractere do token atual, o token atual é considerado completo e o novo personagem inicia um novo token. Alguns caracteres (como {
ou ,
) não podem ser adjacentes a outros caracteres e, portanto, são seus próprios tokens. Outros (como >
ou =
) só são permitidos a ficar adjacente a outros caracteres dentro da sua classe, e podem, assim, formar fichas como >>>
, ==
ou >=
, mas não gosta =2
. Os caracteres de espaço em branco forçam um limite entre os tokens, mas não são incluídos no resultado. O personagem mais difícil de tokenizar é-
porque pode representar subtração e negação unária e, portanto, requer um revestimento especial.
Análise
A análise também é feita de uma única maneira. O compilador possui métodos para lidar com cada uma das diferentes construções de idioma, e os tokens são retirados da lista de tokens global à medida que são consumidos pelos vários métodos do compilador. Se o compilador vir um token que não espera, ele gera um erro de sintaxe.
Alocação Global de Memória
O compilador atribui a cada variável global (palavra ou matriz) seu próprio endereço de RAM designado. É necessário declarar todas as variáveis usando a palavra-chave my
para que o compilador saiba alocar espaço para ela. Muito mais legal do que as variáveis globais nomeadas é o gerenciamento da memória do endereço de rascunho. Muitas instruções (principalmente condicionais e muitos acessos à matriz) requerem endereços temporários temporários para armazenar cálculos intermediários. Durante o processo de compilação, o compilador aloca e desaloca endereços temporários, conforme necessário. Se o compilador precisar de mais endereços temporários, ele dedicará mais RAM como endereços temporários. Eu acredito que é típico para um programa exigir apenas alguns endereços temporários, embora cada endereço temporário seja usado muitas vezes.
IF-ELSE
Afirmações
A sintaxe para if-else
instruções é o formulário C padrão:
other code
if (cond) {
first body
} else {
second body
}
other code
Quando convertido para QFTASM, o código é organizado da seguinte maneira:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Se o primeiro corpo for executado, o segundo corpo será pulado. Se o primeiro corpo for pulado, o segundo corpo será executado.
Na montagem, um teste de condição geralmente é apenas uma subtração, e o sinal do resultado determina se é necessário dar um salto ou executar o corpo. Uma MLZ
instrução é usada para lidar com desigualdades como >
ou <=
. Uma MNZ
instrução é usada para manipular ==
, uma vez que salta sobre o corpo quando a diferença não é zero (e, portanto, quando os argumentos não são iguais). Condicionais de múltiplas expressões não são suportadas no momento.
Se a else
instrução for omitida, o salto incondicional também será omitido e o código QFTASM se parecerá com o seguinte:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
Afirmações
A sintaxe para while
instruções também é o formulário C padrão:
other code
while (cond) {
body
}
other code
Quando convertido para QFTASM, o código é organizado da seguinte maneira:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
O teste da condição e o salto condicional estão no final do bloco, o que significa que são reexecutados após cada execução do bloco. Quando a condição retorna false, o corpo não é repetido e o loop termina. Durante o início da execução do loop, o fluxo de controle salta sobre o corpo do loop para o código de condição, para que o corpo nunca seja executado se a condição for falsa na primeira vez.
Uma MLZ
instrução é usada para lidar com desigualdades como >
ou <=
. Diferentemente das if
instruções, uma MNZ
instrução é usada para manipular !=
, uma vez que salta para o corpo quando a diferença não é zero (e, portanto, quando os argumentos não são iguais).
DO-WHILE
Afirmações
A única diferença entre while
e do-while
é que o do-while
corpo do loop a não é pulado inicialmente, por isso é sempre executado pelo menos uma vez. Geralmente uso do-while
instruções para salvar algumas linhas de código de montagem quando sei que o loop nunca precisará ser ignorado completamente.
Matrizes
Matrizes unidimensionais são implementadas como blocos contíguos de memória. Todas as matrizes são de tamanho fixo com base em suas declarações. As matrizes são declaradas da seguinte forma:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
Para a matriz, este é um possível mapeamento de RAM, mostrando como os endereços 15-18 são reservados para a matriz:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
O endereço rotulado alpha
é preenchido com um ponteiro para o local de alpha[0]
, portanto, no caso em que o endereço 15 contém o valor 16. A alpha
variável pode ser usada dentro do código Cogol, possivelmente como ponteiro de pilha, se você desejar usar esse array como pilha. .
O acesso aos elementos de uma matriz é feito com a array[index]
notação padrão . Se o valor de index
for uma constante, essa referência será automaticamente preenchida com o endereço absoluto desse elemento. Caso contrário, ele executa alguma aritmética do ponteiro (apenas adição) para encontrar o endereço absoluto desejado. Também é possível aninhar a indexação, como alpha[beta[1]]
.
Sub-rotinas e chamadas
Sub-rotinas são blocos de código que podem ser chamados de vários contextos, impedindo a duplicação de código e permitindo a criação de programas recursivos. Aqui está um programa com uma sub-rotina recursiva para gerar números de Fibonacci (basicamente o algoritmo mais 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;
}
Uma sub-rotina é declarada com a palavra-chave sub
e uma sub-rotina pode ser colocada em qualquer lugar dentro do programa. Cada sub-rotina pode ter várias variáveis locais, que são declaradas como parte de sua lista de argumentos. Esses argumentos também podem receber valores padrão.
Para lidar com chamadas recursivas, as variáveis locais de uma sub-rotina são armazenadas na pilha. A última variável estática na RAM é o ponteiro da pilha de chamadas e toda a memória depois serve como a pilha de chamadas. Quando uma sub-rotina é chamada, ele cria um novo quadro na pilha de chamadas, que inclui todas as variáveis locais e o endereço de retorno (ROM). Cada sub-rotina no programa recebe um único endereço de RAM estático para servir como ponteiro. Esse ponteiro fornece o local da chamada "atual" da sub-rotina na pilha de chamadas. A referência a uma variável local é feita usando o valor desse ponteiro estático mais um deslocamento para fornecer o endereço dessa variável local específica. Também está contido na pilha de chamadas o valor anterior do ponteiro estático. Aqui'
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
Uma coisa interessante sobre as sub-rotinas é que elas não retornam nenhum valor específico. Em vez disso, todas as variáveis locais da sub-rotina podem ser lidas após a execução da sub-rotina, portanto, uma variedade de dados pode ser extraída de uma chamada de sub-rotina. Isso é feito armazenando o ponteiro para a chamada específica da sub-rotina, que pode ser usada para recuperar qualquer uma das variáveis locais do quadro de pilha (recentemente desalocado).
Existem várias maneiras de chamar uma sub-rotina, todas usando a call
palavra-chave:
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
Qualquer número de valores pode ser fornecido como argumento para uma chamada de sub-rotina. Qualquer argumento não fornecido será preenchido com seu valor padrão, se houver. Um argumento que não é fornecido e não possui valor padrão não é limpo (para salvar instruções / tempo), portanto, pode assumir qualquer valor no início da sub-rotina.
Os ponteiros são uma maneira de acessar várias variáveis locais da sub-rotina, embora seja importante observar que o ponteiro é apenas temporário: os dados para os quais o ponteiro aponta serão destruídos quando outra chamada de sub-rotina for feita.
Etiquetas de depuração
Qualquer {...}
bloco de código em um programa Cogol pode ser precedido por um rótulo descritivo de várias palavras. Esse rótulo é anexado como um comentário no código de montagem compilado e pode ser muito útil para depuração, pois facilita a localização de partes específicas do código.
Otimização do slot de atraso de ramificação
Para melhorar a velocidade do código compilado, o compilador Cogol realiza uma otimização realmente básica do slot de atraso como uma passagem final sobre o código QFTASM. Para qualquer salto incondicional com um slot de atraso de ramificação vazio, o slot de atraso pode ser preenchido pela primeira instrução no destino do salto e o destino do salto é incrementado em um para apontar para a próxima instrução. Isso geralmente salva um ciclo cada vez que um salto incondicional é realizado.
Escrevendo o código Tetris em Cogol
O programa final do Tetris foi escrito em Cogol, e o código fonte está disponível aqui . O código QFTASM compilado está disponível aqui . Por conveniência, é fornecido um link permanente aqui: Tetris no QFTASM . Como o objetivo era jogar golfe no código de montagem (não no código Cogol), o código Cogol resultante é pesado. Muitas partes do programa normalmente seriam localizadas em sub-rotinas, mas essas sub-rotinas eram realmente curtas o suficiente para duplicar as instruções salvas do código nascall
afirmações. O código final possui apenas uma sub-rotina, além do código principal. Além disso, muitas matrizes foram removidas e substituídas por uma lista equivalentemente longa de variáveis individuais ou por muitos números codificados no programa. O código QFTASM final compilado tem menos de 300 instruções, embora seja apenas um pouco mais longo que a própria fonte Cogol.