Parte 4: QFTASM y Cogol
Descripción de la arquitectura
En resumen, nuestra computadora tiene una arquitectura RISC Harvard asincrónica de 16 bits. Cuando se construye un procesador a mano, una arquitectura RISC ( computadora con conjunto de instrucciones reducido ) es prácticamente un requisito. En nuestro caso, esto significa que el número de códigos de operación es pequeño y, mucho más importante, que todas las instrucciones se procesan de manera muy similar.
Como referencia, la computadora Wireworld utilizó una arquitectura activada por transporte , en la que la única instrucción era MOV
y los cálculos se realizaban escribiendo / leyendo registros especiales. Aunque este paradigma conduce a una arquitectura muy fácil de implementar, el resultado también es inutilizable: todas las operaciones aritméticas / lógicas / condicionales requieren tres instrucciones. Para nosotros estaba claro que queríamos crear una arquitectura mucho menos esotérica.
Para mantener nuestro procesador simple y aumentar la usabilidad, tomamos varias decisiones de diseño importantes:
- No hay registros Cada dirección en RAM se trata por igual y se puede usar como cualquier argumento para cualquier operación. En cierto sentido, esto significa que toda la RAM podría tratarse como registros. Esto significa que no hay instrucciones especiales de carga / almacenamiento.
- En una línea similar, mapeo de memoria. Todo lo que podría escribirse o leerse comparte un esquema de direccionamiento unificado. Esto significa que el contador del programa (PC) es la dirección 0, y la única diferencia entre las instrucciones regulares y las instrucciones del flujo de control es que las instrucciones del flujo de control usan la dirección 0.
- Los datos son seriales en transmisión, paralelos en almacenamiento. Debido a la naturaleza basada en "electrones" de nuestra computadora, las sumas y restas son significativamente más fáciles de implementar cuando los datos se transmiten en forma serial little-endian (primer bit menos significativo). Además, los datos en serie eliminan la necesidad de buses de datos engorrosos, que son realmente anchos y engorrosos a la hora adecuada (para que los datos permanezcan juntos, todos los "carriles" del bus deben experimentar el mismo retraso de viaje).
- Arquitectura de Harvard, que significa una división entre la memoria del programa (ROM) y la memoria de datos (RAM). Aunque esto reduce la flexibilidad del procesador, esto ayuda con la optimización del tamaño: la longitud del programa es mucho mayor que la cantidad de RAM que necesitaremos, por lo que podemos dividir el programa en ROM y luego centrarnos en comprimir la ROM , que es mucho más fácil cuando es de solo lectura.
- Ancho de datos de 16 bits. Esta es la potencia más pequeña de dos que es más ancha que una placa Tetris estándar (10 bloques). Esto nos da un rango de datos de -32768 a +32767 y una longitud máxima del programa de 65536 instrucciones. (2 ^ 8 = 256 instrucciones son suficientes para la mayoría de las cosas simples que podríamos querer que haga un procesador de juguetes, pero no Tetris).
- Diseño asincrónico. En lugar de tener un reloj central (o, de manera equivalente, varios relojes) que dicta la sincronización de la computadora, todos los datos van acompañados de una "señal de reloj" que viaja en paralelo con los datos a medida que fluye alrededor de la computadora. Ciertas rutas pueden ser más cortas que otras, y si bien esto plantearía dificultades para un diseño con reloj centralizado, un diseño asincrónico puede manejar fácilmente operaciones de tiempo variable.
- Todas las instrucciones son de igual tamaño. Consideramos que una arquitectura en la que cada instrucción tiene 1 código de operación con 3 operandos (valor valor destino) era la opción más flexible. Esto abarca operaciones de datos binarios, así como movimientos condicionales.
- Sistema de modo de direccionamiento simple. Tener una variedad de modos de direccionamiento es muy útil para admitir cosas como matrices o recursividad. Logramos implementar varios modos de direccionamiento importantes con un sistema relativamente simple.
Una ilustración de nuestra arquitectura está contenida en la publicación general.
Funcionalidad y Operaciones ALU
A partir de aquí, se trataba de determinar qué funcionalidad debería tener nuestro procesador. Se prestó especial atención a la facilidad de implementación, así como a la versatilidad de cada comando.
Movimientos condicionales
Los movimientos condicionales son muy importantes y sirven como flujo de control tanto a pequeña como a gran escala. "Pequeña escala" se refiere a su capacidad para controlar la ejecución de un movimiento de datos en particular, mientras que "a gran escala" se refiere a su uso como una operación de salto condicional para transferir el flujo de control a cualquier fragmento de código arbitrario. No hay operaciones de salto dedicadas porque, debido al mapeo de memoria, un movimiento condicional puede copiar datos a la RAM normal y copiar una dirección de destino a la PC. También elegimos renunciar a movimientos incondicionales y saltos incondicionales por una razón similar: ambos pueden implementarse como un movimiento condicional con una condición que está codificada como VERDADERA.
Elegimos tener dos tipos diferentes de movimientos condicionales: "mover si no es cero" ( MNZ
) y "mover si es menor que cero" ( MLZ
). Funcionalmente, MNZ
equivale a verificar si algún bit en los datos es un 1, mientras que MLZ
equivale a verificar si el bit de signo es 1. Son útiles para las igualdades y las comparaciones, respectivamente. La razón por la que elegimos estos dos sobre otros como "mover si es cero" ( MEZ
) o "mover si es mayor que cero" ( MGZ
) fue que MEZ
requeriría crear una señal VERDADERA a partir de una señal vacía, mientras que MGZ
es una verificación más compleja, que requiere el el bit de signo será 0 mientras que al menos otro bit será 1.
Aritmética
Las siguientes instrucciones más importantes, en términos de guiar el diseño del procesador, son las operaciones aritméticas básicas. Como mencioné anteriormente, estamos utilizando datos en serie little-endian, con la elección de endianness determinada por la facilidad de las operaciones de suma / resta. Al hacer que el bit menos significativo llegue primero, las unidades aritméticas pueden seguir fácilmente el bit de acarreo.
Elegimos usar la representación del complemento 2 para números negativos, ya que esto hace que la suma y la resta sean más consistentes. Vale la pena señalar que la computadora Wireworld usó el complemento de 1.
La suma y la resta son el alcance del soporte aritmético nativo de nuestra computadora (además de los cambios de bits que se analizan más adelante). Otras operaciones, como la multiplicación, son demasiado complejas para ser manejadas por nuestra arquitectura y deben implementarse en software.
Operaciones bit a bit
Nuestro procesador tiene AND
, OR
e XOR
instrucciones que hacen lo que usted esperaría. En lugar de tener una NOT
instrucción, elegimos tener una instrucción "and-not" ( ANT
). La dificultad con la NOT
instrucción es nuevamente que debe crear señal a partir de la falta de señal, lo cual es difícil con un autómata celular. La ANT
instrucción devuelve 1 solo si el primer bit de argumento es 1 y el segundo bit de argumento es 0. Por lo tanto, NOT x
es equivalente a ANT -1 x
(así como XOR -1 x
). Además, ANT
es versátil y tiene su principal ventaja en el enmascaramiento: en el caso del programa Tetris lo usamos para borrar tetrominoes.
Bit Shifting
Las operaciones de desplazamiento de bits son las operaciones más complejas manejadas por la ALU. Toman dos entradas de datos: un valor para cambiar y una cantidad para cambiarlo. A pesar de su complejidad (debido a la cantidad variable de desplazamiento), estas operaciones son cruciales para muchas tareas importantes, incluidas las muchas operaciones "gráficas" involucradas en Tetris. Los cambios de bits también servirían como base para algoritmos eficientes de multiplicación / división.
Nuestro procesador tiene operaciones de desplazamiento de tres bits, "desplazamiento a la izquierda" ( SL
), "desplazamiento a la derecha lógica" ( SRL
) y "desplazamiento a la derecha aritmética" ( SRA
). Los primeros dos cambios de bit ( SL
y SRL
) completan los nuevos bits con todos los ceros (lo que significa que un número negativo desplazado a la derecha ya no será negativo). Si el segundo argumento del cambio está fuera del rango de 0 a 15, el resultado son todos ceros, como es de esperar. Para el último desplazamiento de bit SRA
, el desplazamiento de bit conserva el signo de la entrada y, por lo tanto, actúa como una verdadera división entre dos.
Tubería de instrucciones
Ahora es el momento de hablar sobre algunos de los detalles arenosos de la arquitectura. Cada ciclo de CPU consta de los siguientes cinco pasos:
1. Obtenga las instrucciones actuales de la ROM
El valor actual de la PC se utiliza para obtener la instrucción correspondiente de la ROM. Cada instrucción tiene un código de operación y tres operandos. Cada operando consta de una palabra de datos y un modo de direccionamiento. Estas partes se dividen entre sí a medida que se leen desde la ROM.
El código de operación es de 4 bits para admitir 16 códigos de operación únicos, de los cuales se asignan 11:
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. Escriba el resultado (si es necesario) de la instrucción anterior en la RAM
Dependiendo de la condición de la instrucción anterior (como el valor del primer argumento para un movimiento condicional), se realiza una escritura. La dirección de la escritura está determinada por el tercer operando de la instrucción anterior.
Es importante tener en cuenta que la escritura se produce después de buscar instrucciones. Esto lleva a la creación de una ranura de retardo de rama en la que la instrucción inmediatamente después de una instrucción de rama (cualquier operación que escribe en la PC) se ejecuta en lugar de la primera instrucción en el destino de la rama.
En ciertos casos (como saltos incondicionales), el intervalo de retardo de ramificación se puede optimizar. En otros casos no puede, y las instrucciones después de una rama deben dejarse vacías. Además, este tipo de intervalo de retraso significa que las sucursales deben usar un objetivo de sucursal que sea 1 dirección menos que la instrucción de destino real, para dar cuenta del incremento de PC que ocurre.
En resumen, debido a que la salida de la instrucción anterior se escribe en la RAM después de obtener la siguiente instrucción, los saltos condicionales deben tener una instrucción en blanco después de ellos, de lo contrario, la PC no se actualizará correctamente para el salto.
3. Lea los datos para los argumentos de la instrucción actual de RAM
Como se mencionó anteriormente, cada uno de los tres operandos consta de una palabra de datos y un modo de direccionamiento. La palabra de datos es de 16 bits, el mismo ancho que la RAM. El modo de direccionamiento es de 2 bits.
Los modos de direccionamiento pueden ser una fuente de complejidad significativa para un procesador como este, ya que muchos modos de direccionamiento del mundo real implican cálculos de varios pasos (como agregar compensaciones). Al mismo tiempo, los modos de direccionamiento versátiles juegan un papel importante en la usabilidad del procesador.
Intentamos unificar los conceptos de usar números codificados como operandos y usar direcciones de datos como operandos. Esto condujo a la creación de modos de direccionamiento basados en contador: el modo de direccionamiento de un operando es simplemente un número que representa cuántas veces se deben enviar los datos alrededor de un bucle de lectura de RAM. Esto abarca el direccionamiento inmediato, directo, indirecto y doble indirecto.
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)
Después de realizar esta desreferenciación, los tres operandos de la instrucción tienen roles diferentes. El primer operando suele ser el primer argumento para un operador binario, pero también sirve como condición cuando la instrucción actual es un movimiento condicional. El segundo operando sirve como segundo argumento para un operador binario. El tercer operando sirve como la dirección de destino para el resultado de la instrucción.
Dado que las dos primeras instrucciones sirven como datos mientras que la tercera sirve como una dirección, los modos de direccionamiento tienen interpretaciones ligeramente diferentes según la posición en la que se usen. Por ejemplo, el modo directo se usa para leer datos de una dirección RAM fija (ya que se necesita una lectura de RAM), pero el modo inmediato se usa para escribir datos en una dirección de RAM fija (ya que no se necesitan lecturas de RAM).
4. Calcular el resultado
El código de operación y los dos primeros operandos se envían a la ALU para realizar una operación binaria. Para las operaciones aritméticas, bit a bit y de desplazamiento, esto significa realizar la operación relevante. Para los movimientos condicionales, esto significa simplemente devolver el segundo operando.
El código de operación y el primer operando se usan para calcular la condición, que determina si se escribe o no el resultado en la memoria. En el caso de movimientos condicionales, esto significa determinar si algún bit en el operando es 1 (para MNZ
) o determinar si el bit de signo es 1 (para MLZ
). Si el código de operación no es un movimiento condicional, entonces la escritura siempre se realiza (la condición siempre es verdadera).
5. Incremente el contador del programa
Finalmente, el contador del programa se lee, se incrementa y se escribe.
Debido a la posición del incremento de la PC entre la lectura de la instrucción y la escritura de la instrucción, esto significa que una instrucción que incrementa la PC en 1 no es operativa. Una instrucción que copia la PC en sí misma hace que la siguiente instrucción se ejecute dos veces seguidas. Pero, tenga en cuenta que varias instrucciones de PC seguidas pueden causar efectos complejos, que incluyen bucles infinitos, si no presta atención a la canalización de instrucciones.
Búsqueda de la Asamblea Tetris
Creamos un nuevo lenguaje ensamblador llamado QFTASM para nuestro procesador. Este lenguaje ensamblador corresponde 1 a 1 con el código de máquina en la ROM de la computadora.
Cualquier programa QFTASM se escribe como una serie de instrucciones, una por línea. Cada línea tiene el siguiente formato:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
Lista de códigos de operación
Como se discutió anteriormente, hay once códigos de operación compatibles con la computadora, cada uno de los cuales tiene tres 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 direccionamiento
Cada uno de los operandos contiene tanto un valor de datos como un movimiento de direccionamiento. El valor de los datos se describe mediante un número decimal en el rango de -32768 a 32767. El modo de direccionamiento se describe mediante un prefijo de una letra al valor de los datos.
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
Código de ejemplo
Secuencia de Fibonacci en cinco líneas:
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
Este código calcula la secuencia de Fibonacci, con la dirección RAM 1 que contiene el término actual. Se desborda rápidamente después de 28657.
Código 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
Este programa calcula el código Gray y almacena el código en direcciones sucesivas que comienzan en la dirección 5. Este programa utiliza varias características importantes, como el direccionamiento indirecto y un salto condicional. Se detiene una vez que el código Gray resultante es 101010
, lo que sucede para la entrada 51 en la dirección 56.
Intérprete en línea
El'endia Starman ha creado un intérprete en línea muy útil aquí . Puede recorrer el código, establecer puntos de interrupción, realizar escrituras manuales en la RAM y visualizar la RAM como una pantalla.
Cogol
Una vez que se definió la arquitectura y el lenguaje ensamblador, el siguiente paso en el lado del "software" del proyecto fue la creación de un lenguaje de nivel superior, algo adecuado para Tetris. Así creé Cogol . El nombre es un juego de palabras con "COBOL" y un acrónimo de "C of Game of Life", aunque vale la pena señalar que Cogol es para C lo que nuestra computadora es para una computadora real.
Cogol existe en un nivel justo por encima del lenguaje ensamblador. Generalmente, la mayoría de las líneas en un programa Cogol corresponden a una sola línea de ensamblaje, pero hay algunas características importantes del lenguaje:
- Las características básicas incluyen variables con nombre con asignaciones y operadores que tienen una sintaxis más legible. Por ejemplo, se
ADD A1 A2 3
convierte z = x + y;
, con el compilador, en el mapeo de variables en direcciones.
- Looping construcciones tales como
if(){}
, while(){}
y do{}while();
por lo que el compilador de mangos de ramificación.
- Matrices unidimensionales (con aritmética de puntero), que se utilizan para la placa Tetris.
- Subrutinas y una pila de llamadas. Son útiles para evitar la duplicación de grandes fragmentos de código y para admitir la recursividad.
El compilador (que escribí desde cero) es muy básico / ingenuo, pero he intentado optimizar a mano varias de las construcciones del lenguaje para lograr una corta longitud del programa compilado.
Estas son algunas breves descripciones generales sobre cómo funcionan las distintas características del lenguaje:
Tokenización
El código fuente se tokeniza linealmente (paso único), usando reglas simples sobre qué caracteres pueden estar adyacentes dentro de un token. Cuando se encuentra un personaje que no puede ser adyacente al último personaje de la ficha actual, la ficha actual se considera completa y el nuevo personaje comienza una nueva ficha. Algunos caracteres (como {
o ,
) no pueden ser adyacentes a ningún otro personaje y, por lo tanto, son su propia ficha. Otros (como >
o =
) sólo se les permite ser adyacente a otros caracteres dentro de su clase, y por lo tanto pueden formar tokens como >>>
, ==
, o >=
, pero no como =2
. Los caracteres de espacio en blanco fuerzan un límite entre los tokens pero no se incluyen en el resultado. El personaje más difícil de tokenizar es-
porque puede representar sustracción y negación unaria, y por lo tanto requiere un revestimiento especial.
Analizando
El análisis también se realiza de una sola pasada. El compilador tiene métodos para manejar cada una de las diferentes construcciones de lenguaje, y los tokens se sacan de la lista global de tokens a medida que los diversos métodos del compilador los consumen. Si el compilador alguna vez ve un token que no espera, genera un error de sintaxis.
Asignación de memoria global
El compilador asigna a cada variable global (palabra o matriz) sus propias direcciones RAM designadas. Es necesario declarar todas las variables usando la palabra clave my
para que el compilador sepa asignarle espacio. Mucho más genial que las variables globales nombradas es la gestión de memoria de la dirección de memoria virtual. Muchas instrucciones (especialmente condicionales y muchos accesos a arreglos) requieren direcciones temporales "temporales" para almacenar cálculos intermedios. Durante el proceso de compilación, el compilador asigna y desasigna direcciones reutilizables según sea necesario. Si el compilador necesita más direcciones reutilizables, dedicará más RAM como direcciones reutilizables. Creo que es típico que un programa solo requiera unas pocas direcciones reutilizables, aunque cada dirección reutilizable se usará muchas veces.
IF-ELSE
Declaraciones
La sintaxis para las if-else
declaraciones es la forma estándar de C:
other code
if (cond) {
first body
} else {
second body
}
other code
Cuando se convierte a QFTASM, el código se organiza así:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
Si se ejecuta el primer cuerpo, se salta el segundo cuerpo. Si se salta el primer cuerpo, se ejecuta el segundo cuerpo.
En el ensamblaje, una prueba de condición generalmente es solo una resta, y el signo del resultado determina si se debe dar el salto o ejecutar el cuerpo. Una MLZ
instrucción se utiliza para manejar desigualdades como >
o <=
. Se MNZ
utiliza una instrucción para manejar ==
, ya que salta sobre el cuerpo cuando la diferencia no es cero (y, por lo tanto, cuando los argumentos no son iguales). Los condicionales de múltiples expresiones no son compatibles actualmente.
Si else
se omite la declaración, también se omite el salto incondicional y el código QFTASM se ve así:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
Declaraciones
La sintaxis de las while
declaraciones también es la forma estándar de C:
other code
while (cond) {
body
}
other code
Cuando se convierte a QFTASM, el código se organiza así:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
La prueba de condición y el salto condicional se encuentran al final del bloque, lo que significa que se vuelven a ejecutar después de cada ejecución del bloque. Cuando la condición es falsa, el cuerpo no se repite y el ciclo termina. Durante el inicio de la ejecución del bucle, el flujo de control salta sobre el cuerpo del bucle al código de condición, por lo que el cuerpo nunca se ejecuta si la condición es falsa la primera vez.
Una MLZ
instrucción se utiliza para manejar desigualdades como >
o <=
. A diferencia de durante las if
declaraciones, MNZ
se usa una instrucción para manejar !=
, ya que salta al cuerpo cuando la diferencia no es cero (y por lo tanto cuando los argumentos no son iguales).
DO-WHILE
Declaraciones
La única diferencia entre while
y do-while
es que el do-while
cuerpo de un bucle no se omite inicialmente, por lo que siempre se ejecuta al menos una vez. Generalmente uso do-while
declaraciones para guardar un par de líneas de código de ensamblado cuando sé que el bucle nunca tendrá que omitirse por completo.
Matrices
Las matrices unidimensionales se implementan como bloques contiguos de memoria. Todos los arreglos son de longitud fija según su declaración. Las matrices se declaran así:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
Para la matriz, esta es una posible asignación de RAM, que muestra cómo las direcciones 15-18 están reservadas para la matriz:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
La dirección etiquetada alpha
se llena con un puntero a la ubicación de alpha[0]
, por lo que en este caso la dirección 15 contiene el valor 16. La alpha
variable se puede usar dentro del código de Cogol, posiblemente como un puntero de pila si desea usar esta matriz como una pila .
El acceso a los elementos de una matriz se realiza con la array[index]
notación estándar . Si el valor de index
es una constante, esta referencia se completa automáticamente con la dirección absoluta de ese elemento. De lo contrario, realiza una aritmética de puntero (solo suma) para encontrar la dirección absoluta deseada. También es posible anidar la indexación, como alpha[beta[1]]
.
Subrutinas y Llamadas
Las subrutinas son bloques de código que pueden llamarse desde múltiples contextos, evitando la duplicación de código y permitiendo la creación de programas recursivos. Aquí hay un programa con una subrutina recursiva para generar números de Fibonacci (básicamente el algoritmo más 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;
}
Se declara una subrutina con la palabra clave sub
y se puede colocar una subrutina en cualquier lugar dentro del programa. Cada subrutina puede tener múltiples variables locales, que se declaran como parte de su lista de argumentos. Estos argumentos también pueden tener valores predeterminados.
Para manejar llamadas recursivas, las variables locales de una subrutina se almacenan en la pila. La última variable estática en RAM es el puntero de la pila de llamadas, y toda la memoria posterior sirve como pila de llamadas. Cuando se llama a una subrutina, se crea un nuevo marco en la pila de llamadas, que incluye todas las variables locales, así como la dirección de retorno (ROM). Cada subrutina del programa recibe una única dirección RAM estática para que sirva como puntero. Este puntero proporciona la ubicación de la llamada "actual" de la subrutina en la pila de llamadas. La referencia a una variable local se realiza utilizando el valor de este puntero estático más un desplazamiento para proporcionar la dirección de esa variable local en particular. También se incluye en la pila de llamadas el valor anterior del puntero estático. Aquí'
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 que es interesante sobre las subrutinas es que no devuelven ningún valor en particular. Por el contrario, todas las variables locales de la subrutina se pueden leer después de que se realiza la subrutina, por lo que se puede extraer una variedad de datos de una llamada de subrutina. Esto se logra almacenando el puntero para esa llamada específica de la subrutina, que luego se puede utilizar para recuperar cualquiera de las variables locales desde el marco de la pila (recientemente desasignado).
Hay varias formas de llamar a una subrutina, todas utilizando la call
palabra clave:
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
Se puede dar cualquier número de valores como argumentos para una llamada de subrutina. Cualquier argumento no proporcionado se completará con su valor predeterminado, si lo hay. Un argumento que no se proporciona y que no tiene un valor predeterminado no se borra (para guardar instrucciones / tiempo), por lo que podría tomar cualquier valor al comienzo de la subrutina.
Los punteros son una forma de acceder a múltiples variables locales de subrutina, aunque es importante tener en cuenta que el puntero es solo temporal: los datos a los que apunta el puntero se destruirán cuando se realice otra llamada de subrutina.
Etiquetas de depuración
Cualquier {...}
bloque de código en un programa Cogol puede estar precedido por una etiqueta descriptiva de varias palabras. Esta etiqueta se adjunta como un comentario en el código ensamblado compilado, y puede ser muy útil para la depuración, ya que facilita la localización de fragmentos específicos de código.
Optimización de ranura de retardo de ramificación
Para mejorar la velocidad del código compilado, el compilador Cogol realiza una optimización de ranura de retardo realmente básica como un paso final sobre el código QFTASM. Para cualquier salto incondicional con una ranura de retardo de rama vacía, la primera instrucción en el destino de salto puede llenar el espacio de retardo, y el destino de salto se incrementa en uno para apuntar a la siguiente instrucción. Esto generalmente ahorra un ciclo cada vez que se realiza un salto incondicional.
Escribir el código Tetris en Cogol
El programa final de Tetris fue escrito en Cogol, y el código fuente está disponible aquí . El código QFTASM compilado está disponible aquí . Para mayor comodidad, se proporciona un enlace permanente aquí: Tetris en QFTASM . Dado que el objetivo era desarrollar el código de ensamblaje (no el código de Cogol), el código de Cogol resultante es difícil de manejar. Muchas partes del programa normalmente se ubicarían en subrutinas, pero esas subrutinas en realidad eran lo suficientemente cortas como para duplicar las instrucciones guardadas del código sobre elcall
declaraciones. El código final solo tiene una subrutina además del código principal. Además, se eliminaron muchas matrices y se reemplazaron por una lista de variables individuales de longitud equivalente o por una gran cantidad de números codificados en el programa. El código QFTASM compilado final está bajo 300 instrucciones, aunque es solo un poco más largo que la fuente de Cogol.