cursos:ensamblador:interrupciones

Interrupciones del procesador Z80

En este capítulo vamos a ver qué son las interrupciones del microprocesador Z80 y cómo utilizarlas en nuestro beneficio para ejecutar rutinas de servicio “en paralelo” al flujo del programa. Aunque la introducción inicial será básicamente teórica, la aplicación práctica es bastante sencilla, pudiendo utilizar las rutinas de esta entrega directamente sin conocimiento total de la información teórica presentada.

Los microprocesadores suelen disponer como mínimo de una señal de Interrupción. Esta señal, normalmente es invocada externamente por dispositivos de I/O que requieren la atención del procesador, solicitando la atención del mismo por algún tipo de evento.

De esta forma, no es necesario que sea nuestro programa el encargado de comprobar continuamente si ha ocurrido un evento concreto.

Una señal de interrupción provoca que el procesador termine de ejecutar la instrucción en curso y ya no continúe con la ejecución de la siguiente instrucción apuntada por PC (el contador de programa). En lugar de esto, temporalmente, lanza una porción de código definida como ISR (Interrupt Service Routine o Rutina de Servicio de Interrupción), en la que podemos realizar determinadas tareas regulares, desde actualizar variables de ticks/segundos/minutos/horas con precisión, hasta enviar datos musicales al chip de música AY, por ejemplo.

Cuando se finaliza la ejecución de la rutina ISR, el procesador continúa la ejecución desde donde se detuvo al llegarle la señal de interrupción. Para nuestro programa la ejecución de la ISR es “transparente”. No obstante, es importante que estas rutinas ISR sean lo más reducidas y rápidas posibles para no afectar a la velocidad de ejecución del programa principal.

El Z80A (el corazón del ZX Spectrum) dispone de 2 tipos de señales de interrupción: una señal de alta prioridad (NMI, Non-mascarable-Interrupt), y otra señal enmascarable de menor prioridad (MI). El procesador, como hemos dicho, lee el estado de las señales /NMI e /INT al acabar la ejecución de cada instrucción (salvo en el caso de instrucciones repetitivas como lddr, por ejemplo, que lo realiza al acabar cada subinstrucción como ldd).


 Pinout del uP z80

Veamos a continuación los 2 tipos de interrupciones, en qué modos pueden operar, y qué podemos hacer con ellas.


Las interrupciones no enmascarables (NMI) permiten que dispositivos I/O ajenos al procesador le interrumpan solicitando atención por parte del mismo. El microprocesador Z80 verifica el estado de la señal de interrupción NMI en el correspondiente pin del procesador en el último T-estado del ciclo de ejecución actual (incluyendo las instrucciones con prefijo al opcode). En ese momento realiza un PUSH PC y salta a la dirección de la ISR (hacia $0066). Tras 11 ciclos de reloj, se ejecuta el código correspondiente, se recupera PC de la pila y se continúa la ejecución del programa original. En el caso de que ocurran 2 interrupciones simultáneamente (una de tipo NMI y otra de tipo MI), la interrupción NMI tiene prioridad.

En el caso del Sinclair ZX Spectrum, debido a un bug en la ROM (un JR NZ que debió haber sido un JR Z) las NMI simplemente provocan un RESET del ordenador, puesto que $0066 es una posición de memoria que cae dentro de la ROM y que no puede modificarse salvo hacia $0000, dirección de inicio del ciclo de ejecución y que provoca el mencionado reset.


Las interrupciones enmascarables (MI, INT o INTRQ) se denominan así porque, al contrario que las NMI, pueden ser ignoradas por el procesador cuando han sido deshabilitadas con la instrucción DI (Disable Interrupt).

Cuando el procesador recibe una de estas interrupciones actúa de 3 formas diferentes según el modo actual de interrupción en que esté. El Z80 puede estar en 3 modos de interrupción o IM (Interrupt Mode):

  • Modo 0: En este modo de interrupción, el dispositivo que desea interrumpir al procesador activa la pantilla /INTdel mismo y durante el ciclo de reconocimiento de interrupción del procesador coloca el opcode de una instrucción en el bus de datos del Z80. Normalmente será una instrucción de 1 sólo byte (normalmente un RST XX) ya que esto sólo hace necesario escribir y mantener un opcode en el bus de datos (aunque puede ser, en periféricos más complejos y con una correcta temporización, un jp o call seguido de la dirección de salto). Este modo de interrupción existe principalmente por compatibilidad con el procesador 8080.
  • Modo 1: Cuando se recibe una señal INT y el procesador está en im 1, el Z80 ejecuta un di (Disable Interrupts), se salva en la pila el valor actual de PC, y se realiza un salto a la ISR ubicada en la dirección $0038 (en la ROM). Es el modo de interrupción por defecto del Spectrum (por ejemplo, en el intérprete BASIC), y en este modo el Spectrum no sabe qué dispositivo ha causado la interrupción y es la rutina ISR la encargada de determinar qué dispositivo externo (o proceso interno) es el que requiere la atención del procesador.
  • Modo 2: Es el modo más utilizado en los programas comerciales e implica el uso del registro I y el bus de datos para generar un vector de salto. Los 8 bits superiores de la dirección de salto del ISR se cargan en el registro I. El dispositivo que desea interrumpir al procesador colocará los 8 bits bajos de la dirección (por convención, un número par, es decir, con el bit 0 a 0) en el bus de datos.

La lectura de la parte baja de la dirección de salto desde el bus de datos permite que cada dispositivo pueda tener su propia rutina ISR y solicitar una interrupción al procesador y que ésta sea atendida por la rutina especializada adecuada.

El vector resultante de combinar I (parte alta de la dirección) y el valor del bus de datos (parte baja de la dirección) no apunta a la ISR en sí misma, sino a una dirección de 2 bytes que es la que realmente contiene la dirección de la ISR. Esto nos permite también utilizar nuestras propias ISR por software, desde nuestros propios programas.

Concretamente I*256 apunta a una tabla de direcciones de ISR (Tabla de Vectores de Interrupción) que será indexada por el byte bajo de la dirección (lo que nos da un total de 128 ISR posibles de 2 bytes de dirección de inicio absoluta cada una). Es por esto que la dirección del byte bajo es, por convención, un número par, de forma que siempre accedamos a las direcciones de 16 bits correctas en la tabla (I*256+N y I*256+N+1 siendo N par) y no a media dirección de una ISR y media de otra (como veremos más adelante).

Así pues, se puede definir la dirección de salto de la interrupción en modo im2 como:

DIR_DE_SALTO = [ (I*256)+VALOR_EN_BUS_DE_DATOS ]

Así, si hemos puesto en I el valor $fe, quiere decir que hemos creado una tabla desde $fe00 a $feFF+1, la cual contiene 128 direcciones de salto de 2 bytes. Cada dirección de esta tabla, debería apuntar a una rutina en memoria que gestione las interrupciones de ese dispositivo. Por ejemplo, si se produce una interrupción por parte de la ULA que pone en el bus de datos el valor $ff, ocurre lo siguiente:

  • El Z80 deja de ejecutar el programa que estuviera ejecutando en PC para atender la interrupción.
  • El Z80 lee el bus de datos y encuentra el valor que ha puesto la ULA ($ff).
  • Combina el valor de I ($fe en nuestro ejemplo) con el “identificador de la interrupción” y genera el valor $feFF.
  • Va a la memoria, a la dirección $feff, y lee de ella 2 bytes. Esos 2 bytes son la rutina ISR.
  • El Z80 deshabilita las interrupciones y hace un salto a esa dirección.
  • Nosotros, antes de activar el im2, ya pusimos en $feff la dirección de la rutina de ISR que tenemos en nuestro código.
  • Nuestra ISR se ejecuta, y al final de ella, hace un ei (para volver a activar las interrupciones) y un reti. Además no debe de modificar ningún registro ni flag (debe de preservarlos todos con PUSH/POP), ni siquiera los registros alternativos (y debe de dejar como set de registros activo, el que había al entrar).
  • El Z80 continúa ejecutando el código por donde se quedó PC, hasta que llegue la siguiente interrupción.

Finalmente, este es el coste en T-estados de la aceptación de interrupciones en cada modo:

  • NMI: 11 t-estados
  • INT im 0: 13 t-estados (si la instrucción del bus es un RST)
  • INT im 1: 13 t-estados
  • INT im 2: 19 t-estados



Podemos cambiar el modo de interrupcion en el Spectrum con la instrucción del procesador IM:

im 0       ; Cambiar a modo im 0 (8 T-Estados).
im 1       ; Cambiar a modo im 1 (8 T-Estados).
im 2       ; Cambiar a modo im 2 (8 T-Estados).

Como ya hemos dicho, el Spectrum opera normalmente en im 1, donde se llama regularmente a una ISR que lee el estado del teclado y actualiza ciertas variables del sistema (LAST_K, FRAMES, etc.) para la conveniencia del intérprete de BASIC (y, en algunos casos, de nuestros propios programas). Esta ISR (la rst $38, en $0038) pretende hacer uso exclusivo del registro IY por lo que si nuestro programa necesita hacer uso de este registro es importante hacerlo entre un di y un ei para evitar que pueda ocurrir una interrupción con su valor modificado por nosotros y provocar un reset en el Spectrum. También tenemos que tener en cuenta esto si estando en modo im 2 llamamos manualmente a la rst $38 para actualizar variables del sistema (aunque no es habitual que necesitemos ejecutar la ISR que usa el intérprete de BASIC).

En el caso de aplicaciones y juegos, lo normal es cambiar a im 2 con una rutina propia de ISR que realice las tareas que nosotros necesitemos, especialmente temporización, actualización del buffer del chip AY de audio para reproducir melodías, etc.


Existen también 2 instrucciones especiales para DESACTIVAR las interrupciones (DI, Disable Interrupts), y ACTIVARLAS (EI, Enable Interrupts), manipulando IFF, el flip-flop del procesador.

di         ; Disable Interrupts (4 T-estados) -> IFF=0
ei         ; Enable Interrupts (4 T-estados). -> IFF=1

Nótese el hecho importantísimo de que las interrupciones no se habilitan de nuevo al final de la ejecución del EI, sino tras la ejecución de la instrucción que lo sigue en el flujo del programa. Más adelante veremos por qué.


La instrucción HALT es una instrucción muy útil que detiene el proceso de ejecución de la CPU. Al llamarla, la CPU realiza de forma continuada el mismo procedimiento que cuando se ejecutan instrucciones NOP de 4 t-estados (pero sin incrementar el contador de programa, e incrementando el registro R), hasta que se vea interrumpido por una NMI o una MI (INT), en cuyo momento se incrementa PC y se procesa la interrupción. Al volver de la ISR, el procesador continúa la ejecución del programa en la instrucción siguiente al HALT.

halt       ; Halt computer and wait for INT (4 T-Estados).

Básicamente, la ejecución de nuestro programa se detiene en el HALT hasta que ocurra una interrupción.

Como veremos con detalle a continuación, la ULA genera una interrupción 50 veces por segundo (cada vez que se sincroniza con el haz de electrones de la pantalla para su redibujado, en la parte superior izquierda de la pantalla, antes de empezar a dibujar el actual BORDER). Al colocar un HALT en nuestro programa, la ejecución del mismo se detendrá en ese punto y lo dejaremos en espera hasta que se produzca el siguiente retrazo vertical y la ULA genere la interrupción correspondiente. Es una manera de limitar la velocidad del programa y de sincronizarse con el haz de electrones para hacer efectos concretos con temporizaciones controladas.

Como veremos más adelante, la instrucción HALT nos será especialmente útil en determinadas ocasiones al trabajar con la manipulación del área de datos de la videomemoria.


Una instrucción de uso infrecuente con una peculiar utilidad es ld a, r. Con esta instrucción cargamos el valor del registro interno del procesador R (utilizado para el refresco de la DRAM) en el acumulador. Comunmente se utiliza para obtener algún tipo de valor “variable” como semilla o parte del proceso de generación de números aleatorios.

No obstante, esta instrucción de 2 bytes ($ed $5f) y 9 t-estados de ejecución tiene la particular utilidad de copiar en el flag P/V el contenido del flip-flop IFF2, por lo que podemos utilizarla para conocer el estado de las interrupciones enmascarables.

Así, una vez ejecuado un ld a, r, sabemos que si la bandera está a 1 es que las interrupciones están habilitadas, mientras que si están a cero, es porque han sido deshabilitadas.

Como curiosidad, la instrucción ld a, i produce la misma afectación de P/V que ld a, r. Otros flags afectados por ambas instrucciones son S, C (reseteado) y Z.


Como ya hemos dicho, las interrupciones están diseñadas para que los dispositivos externos puedan interrumpir al procesador Z80. En el caso del Spectrum, existe un dispositivo externo común a todos los modelos y que tiene funciones críticas para el sistema. Hablamos de la ULA, que en un Spectrum sin dispositivos conectados al puerto de expansión es el único periférico que provoca señales de interrupción al procesador.

La ULA, como encargada de gestionar la I/O, el teclado, y de refrescar el contenido de la pantalla usando los datos almacenados en el área de videoram del Spectrum, interrumpe al procesador de forma constante, a razón de 50 veces por segundo en sistemas de televisión PAL (Europa y Australia) y 60 veces por segundo en sistemas NTSC (USA).

Esto quiere decir que cada 1/50 (o 1/60) segundos, la ULA produce una señal INT (interrupción enmascarable), que provoca la ejecución de la ISR de turno (rst $38 en modo im 1 ó la ISR que hayamos definido en modo im 2).

En el modo im 1 (el modo en que arranca el Spectrum), el salto a rst $38 provocado por las interrupciones generadas por la ULA produce la ejecución regular y continua cada 1/50 segundos de las rutinas de lectura del teclado, actualización de variables del sistema de BASIC y del reloj del sistema (FRAMES) requeridas por el intérprete de BASIC para funcionar.

En cuanto al modo im 2, el que nos interesa principalmente para la realización de programas y juegos, la dirección de salto del ISR se compone como un valor de 16 bits que se utiliza como “vector de salto”. Como parte alta del mismo se utiliza el valor del registro I, y como parte baja el identificador de dispositivo presente en el bus de datos.

Como ya hemos visto en la definición del modo im 2, la dirección resultante ((I*256)+ID_DE_DISPOSITIVO_EN_BUS_DATOS) se utiliza para consultar una tabla de vectores de interrupción para saltar. A partir de la dirección I*256, debe de haber una tabla de 256 bytes con 128 direcciones de salto absolutas de 2 bytes cada una.

De esta forma, cada dispositivo de hasta un total de 128 puede colocar su ID en el bus de datos y tener su propia ISR en la tabla:

  • Un dispositivo con ID 0 tendría su dirección de salto a la ISR en (I*256+0 e I*256+1).
  • Un dispositivo con ID 2 tendría su dirección de salto a la ISR en (I*256+2 e I*256+3).
  • Un dispositivo con ID 4 tendría su dirección de salto a la ISR en (I*256+4 e I*256+5).
  • (…)
  • Un dispositivo con ID 254 tendría su dirección de salto a la ISR en (I*256+254 e I*256+255).

Debido a que la tabla de saltos requiere 2 bytes por cada dirección y que existen 256 posibles valores en el bus de datos, el identificador de dispositivo tiene que ser un valor PAR, ya que si un dispositivo introdujera un valor IMPAR en el bus de datos, el procesador podría realizar un salto a una dirección compuesta a partir de los datos de salto de 2 dispositivos diferentes. Comprenderemos este problema con un ejemplo muy sencillo:

  • Un dispositivo con ID 1 tendría su dirección de salto a la ISR en (I*256+1 e I*256+2) (que forman parte de las direcciones de salto de los dispositivos con ID 0 e ID 2).
  • Un dispositivo con ID 255 tendría su dirección de salto a la ISR en (I*256+255 e I*256+256), lo que implicaría tratar de utilizar parte de la dirección de salto del dispositivo con ID 254 además de un byte de fuera de la tabla de vectores de interrupción.

Así pues, por convención, todos los dispositivos que se conectan a un Z80 tienen que colocar como ID de dispositivo en el bus de datos un identificador único par, que asegure que los vectores de salto de 2 dispositivos nunca puedan solaparse.

Existe una excepción notable a esta regla, y no es otra que la propia ULA. La ULA no está diseñada para funcionar en modo im 2, ya que no coloca ningún identificador de dispositivo en el bus de datos cuando genera interrupciones. Está diseñada para funcionar en modo 1, donde no se espera este identificador y siempre se produce el salto a $0038, sea cual sea el dispositivo que solicita la interrupción.

Por suerte, cuando no se coloca ningún valor en el bus de datos del Spectrum, éste adquiere el valor de 8 señales uno (11111111b, 255d o FFh), debido a las resistencias de pull-up al que están conectadas las líneas de dicho bus. Por lo tanto, nuestro procesador Z80A obtendrá como device-id del dispositivo que interrumpe un valor FFh (con cierta particularidad que veremos en la sección sobre Compatibilidad).

Este valor, impar, produce el siguiente valor dentro de la tabla de vectores de interrupción: (I*256+255) e (I*256+255+1), lo que produce la lectura dentro de la tabla de vectores del campo 255 y del 256, provocando la necesidad de que nuestra tabla de vectores requiera 257 en lugar de 256 bytes.


La ULA provee al Spectrum en modo im 1 de un mecanismo para, regularmente, escanear el teclado y evitar así que sean los propios programas quienes tengan que realizar esa tarea por software.

La interrupción generada por la ULA debe de ser de una regularidad tal que se ejecute suficientes veces por segundo para que el escaneo del teclado no pierda posibles pulsaciones de teclas del usuario, pero no tan frecuente como para que requiera gran cantidad de tiempo de procesador ejecutando una y otra vez la ISR asociada.

Como nos cuenta el libro sobre la ULA de Chris Smith, el ingenierio de Sinclair, Richard Altwasser, aprovechó la señal de VSYNC que la ULA genera como señal de sincronización para el televisor como lanzador de la señal de interrupción. Esta señal se genera 50 veces por segundo para televisiones PAL y 60 para televisiones NTSC, y tiene la duración adecuada para que el procesador detecte la interrupción en su patilla INTrq.

Este es el precisamente el motivo por el cual la interrupción generada por la ULA se genera 50 (ó 60 veces por segundo): un aprovechamiento de la señal de VSYNC para los televisores, con el consiguiente ahorro de electrónica adicional que supondría generar otra señal adicional para INTrq.

Por otra parte, para los programadores es una enorme ventaja el saber que la interrupción del procesador por parte de la ULA coincide con el VSYNC, ya que nos permite el uso de la instrucción halt en nuestro programa para forzar al mismo a esperar a dicha interrupción y poder ejecutar código después del halt que trabaje sobre la pantalla sabiendo que el haz de electrones no la está redibujando.

Como veremos en el capítulo dedicado a la memoria de vídeo, la ULA lee regularmente un área de aprox. 7 KB que empieza en la dirección de memoria $4000 y con los datos que hay en ese área alimenta al haz de electrones del monitor para que forme la imagen que aparece en pantalla. Como parte del proceso de generación de la imagen, 50 veces por segundo (una vez por cada “cuadro de imagen”) la ULA debe de generar un pulso de VSYNC durante 256 microsegundos para el monitor que asegure que el inicio de la generación de la imagen está sincronizado con las líneas de vídeo que se le envían. Durante ese período, la ULA no está generando señal de vídeo sobre el televisor y podemos alterar el contenido de la videoram con seguridad.

¿Por qué es necesario tener esta certeza acerca de la ubicación del haz de electrones? La respuesta es que si alteramos el contenido de la videoram durante la generación de la imagen, es posible que se muestren en pantalla datos de la imagen del cuadro de vídeo anterior (datos de videoram ya trazados por el haz de electrones) junto a datos de la imagen del cuadro de vídeo que estamos generando, mostrando un efecto “cortinilla” o “efecto nieve”.

Por mostrarlo de una manera gráfica (y con un ejemplo “teórico”), supongamos que tenemos una pantalla de color totalmente azul y queremos cambiarla a una pantalla de color totalmente verde. Imaginemos que cuando el haz de electrones ha mostrado la mitad de la pantalla nosotros cambiamos el contenido de la zona de atributos para que la pantalla completa sea verde. Con el haz en el centro de la pantalla, todavía recorriendo la videoram y “trazando” los colores en pantalla, todos los datos mostrados a partir de ese momento serán píxeles verdes, por lo que durante ese cuadro de imagen tendremos el 50% inicial de la pantalla en color azul y el 50% restante en verde, y no toda verde como era nuestra intención. Será en el próximo cuadro de retrazado de la pantalla cuando se leerán los valores de color verde de la VRAM de la zona superior de la pantalla y se retrazarán dichos píxeles en verde, dejándonos la pantalla totalmente de dicho color.

Este efecto es lo que se conoce como tearing, y es lo que hace que a veces veamos un sprite como “cortado”, ya que estamos viendo un trozo del Sprite con los píxeles del fotograma de animación o de posición anterior, y otro trozos con los del siguiente.

¿Cómo podemos evitar este efecto? Mediante la instrucción HALT.

El haz de electrones del monitor barre la pantalla empezando en la esquina superior izquierda de la misma, recorriendola de derecha a izquierda, trazando líneas horizontales con el contenido de la videomemoria. Cuando el haz llega a la derecha del televisor, baja a la siguiente línea de pantalla retrocediendo a la izquierda de la misma y se sincroniza con la ULA mediante una señal de HSYNC. De nuevo el haz de electrones traza una nueva línea horizontal hacia la derecha, repitiendo el proceso una y otra vez hasta llegar a la esquina inferor derecha. El haz de electrones debe entonces volver a la parte superior izquierda de la pantalla (mediante una diagonal directa) y sincronizarse con la ULA mediante un pulso VSYNC.


 Recorrido del haz de electrones del monitor/TV

Sabemos que la interrupción generada por la ULA llega al procesador cuando se realiza el VSYNC con el monitor (cuando el haz de electrones está en el punto superior de su retroceso a la esquina superior izquierda de la pantalla), así que podemos utilizar halt en nuestro programa para forzar al mismo a esperar una interrupción, es decir, a que se finalice el trazado del cuadro actual, asegurándonos que no se está escribiendo en pantalla. Esto nos deja un valioso pero limitado tiempo para realizar actualizaciones de la misma antes de que el haz de electrones comience el retrazado o incluso alcance el punto que queremos modificar.

En nuestro ejemplo anterior de la pantalla azul y verde, un halt antes de llamar a la rutina que pinta la pantalla de verde aseguraría que la pantalla se mostrara completamente en verde (y no parcialmente) al haber realizado el cambio de los atributos tras el halt (durante el VSYNC) y no durante el retrazado de la pantalla en sí misma.

Hay que tener en cuenta un detalle para temporizaciones precisas: aunque el microprocesador siempre recibe la señal de interrupción en el mismo instante de retorno del haz, la señal sólo es leída por el microprocesador al acabar la ejecución de la instrucción en curso, por lo que dependiendo del estado actual de ejecución y del tipo de instrucción (su tamaño y tiempo de ejecución) puede haber una variación de hasta 23 t-estados en el tiempo de procesado de la INT.

Una vez se produce la interrupción, tenemos un tiempo finito para trabajar sobre la pantalla antes de que el haz de electrones llegue a la primera línea (incluyendo borde) de la misma, y comience el “redibujado” de la imagen:

Modelo de Spectrum t-estados disponibles (+-1 t-estado)
16K 14336 t-estados
48K 14336 t-estados
128K 14364 t-estados
+2 14364 t-estados
+2A 14364 t-estados
+3 14364 t-estados

Este es el tiempo del que disponemos para borrar los sprites del “fotograma anterior” y dibujar los nuevos, mientras el haz de electrones dibuja el borde de la pantalla, evitando así que sufran de tearing y aparezcan “partidos”. Es más, si ubicamos en la parte superior del juego un área de estado (con las vidas, el tiempo, etc), tendremos todavía más tiempo antes de que el haz de electrones alcance el área donde estamos dibujando todos los elementos “animados” del juego (scroll, personaje, enemigos, etc).

Además, tener en cuenta los tiempos exactos que tarda el haz de electrones en dibujar la parte superior del borde, los trozos laterales de borde, o un scanline de la pantalla son los que permiten a algunos juegos (con precisos procesos de temporización) realizar auténticas virguerías con el borde (como Aquaplane), o generar rutinas que permitan varios colores por carácter controlando la posición exacta del haz de electrones y cambiando los atributos mientras el haz está trazando un determinado scanline.

A continuación podemos ver un resumen de los tiempos (en t-estados, t-states o ciclos, como queramos llamarlos) que necesita el haz de electrones para realizar un determinado recorrido en un Spectrum 48K, resumiendos por nuestro compañero del canal de “Ensamblador de Z80”, Xor_A [Fer]:

Tiempo (en t-estados) que tarda el haz en… Modelos 16K y 48K Modelos 128K, +2, +2A y +3
Renderizar uno de los bordes laterales en un scanline (izquierdo o derecho) 24 26
Renderizar un scanline (la parte gráfica “central” de la imagen) 128 128
Volver desde el extremo derecho al inicio del borde del siguiente scanline 48 48
Renderizar un scanline completo con borde y retorno 224 (24+128+24+48) 228
Renderizar toda la pantalla visible (192 scanlines) 43008 (192*224) 43776 (192*228)
Renderizar la parte superior del borde 14336 (224*64) 14364 (228*63)
Alcanzar el pixel (0,0) de la imagen 14408 ((224*64)+48+24) 14666 ((228*64)+48+26)
Renderizar el borde inferior (56 scanlines) 12544 (224*56) 12768 (228*56)
Volver desde el parte inferior a la parte superior izquierda (8 scans)
(por eso sólo dibuja 56 y no 64/63 scanlines en la parte inferior)
1792 (224*8) 1824 (228*8)
Número de scanlines dibujados (contando bordes) 312 311
Renderizar un frame completo (bordes, scanlines y retornos) 69888 (14336+12544+43008) 70908

Como puede verse, los modelos 128K tienen tiempos ligeramente diferentes, como por ejemplo el tiempo total en renderizar un scan, que son 228 t-estados, 311 líneas de scan en total en lugar de 312 y 63 líneas de borde superior en lugar de 64. Además, la frecuencia de reloj de los modelos de 16 y 48K es de 3.50000 MHz mientras que la de los modelos 128K es de 3.54690 MHz.

Como indica el documento 128K ZX Spectrum Reference de World Of Spectrum:

The ZX Spectrum 128K / +2 - timing differences:

- The main processor runs at 3.54690 MHz, as opposed to 3.50000 MHz.
- There are 228 T-states per scanline, as opposed to 224.
- There are 311 scanlines per frame, as opposed to 312.
- There are 63 scanlines before the television picture, as opposed to 64.
- To modify the border at the position of the first byte of the screen,
  the OUT must finish after 14365, 14366, 14367 or 14368 T states have passed
  since interrupt. As with the 48K machine, on some machines all timings
  (including contended memory timings) are one T state later.

Note that this means that there are 70908 T states per frame, and the '50 Hz'
interrupt occurs at 50.01 Hz, as compared with 50.08 Hz on the 48K machine.
The ULA bug which causes snow when I is set to point to contended memory still
occurs, and also appears to crash the machine shortly after I is set
to point to contended memory.

Por si fuera poco, en los modelos INVES los tiempos son diferentes, ya que la ULA es diferente y no genera la interrupción justo cuando se produce el inicio del recorrido del haz sino un poco más tarde, lo que causaba tearing como explican Miguel Ángel Rodríguez Jódar y César Hernández Bañó en su excelentísimo documento El Inves Spectrum+, a fondo:

Juegos que presentan sprites parpadeantes, o movimiento no fluido, o incluso sprites que no llegan a verse del todo. Como acertadamente se dice, esto es causado por la (gran) diferencia de tiempo que hay entre el momento en que ocurre el pulso de interrupción del retrazo vertical y el momento en que se comienza a pintar el “paper” en la pantalla. En el Inves ese tiempo es mucho más pequeño, pero el software no lo sabe, y algunos programas realizan el redibujado de los sprites cuando el haz de rayos catódicos ya ha pasado por esa parte de la pantalla, haciendo que en algunos casos, el sprite se vea con un ostensible parpadeo, o directamente no se vea. La otra razón que se arguye, que la CPU trabaja a 3.54MHz en lugar de a 3.5MHz en la práctica no es problema, siendo la diferencia de velocidad practicamente despreciable para la inmensa mayoría del software.

Es decir, en el caso del INVES, como en los anteriores modelos se producen 50 interrupciones por segundo igualmente, generadas por la ULA. Pero en los modelos no INVES, la interrupción se lanza cuando el haz de electrones está en la parte superior izquierda del monitor, mientras que en los INVES se lanza un poco más tarde (prácticamente, cuando va a empezar a dibujar datos de “paper” o área de pantalla). Esto hace que las “sincronizaciones” con la ULA para controlar la velocidad del programa (mediante HALT) funcione, pero que el tiempo para dibujar los sprites sea mucho menor por lo que se producían los parpadeos.

Hablamos de controlar la velocidad de los programas porque al sincronizarnos con las interrupciones de la ULA, establecemos un “límite” de veces por segundo que se ejecuta una rutina.

Por ejemplo, si ejecutamos el siguiente bucle:

bucle:
    ; hacer algo
    jr bucle

Se ejecutará cientos o miles de veces por segundo, pero si hacemos:

bucle:
    halt
 
    ; hacer algo
 
    jr bucle

Sabemos que, como máximo, se ejecutará 50 veces por segundo (en Europa, 60 veces en máquinas Timex Sinclair) ya que antes de cada iteración del bucle pararemos el procesador con HALT hasta que se produzca una interrupción. Por tanto, ese bucle como máximo se ejecutará 50 veces por segundo (dependiendo del tiempo que cueste ejecutar el código que ubiquemos entre el halt y el salto).

Esto funciona sin importar si estamos en un INVES o en un modelo normal, lo que varía es el tiempo del que disponemos para dibujar los sprites de nuestro juego antes de que el haz de electrones alcance el pixel (0,0).

En cualquier caso, esto sólo nos afectará si estamos haciendo rutinas de temporización muy avanzadas para, por ejemplo, dibujar en el borde de la pantalla, conseguir más colores en una celdilla de los permitidos por el Spectrum o efectos similares, lo cual no es muy habitual. Además existen otras técnicas para temporizar (como la del “Bus flotante”) que quedan lejos de los objetivos de este curso.


Hemos hablado ya de las rutinas de ISR y de cómo son llamadas 50 (o 60) veces por segundo. Lo normal en el desarrollo de un juego o programa medianamente complejo es que utilicemos el modo im 2 y desarrollemos nuestra propia rutina ISR para que cumpla nuestras necesidades.

Las ISR deben optimizarse lo máximo posible, tratando de que sean lo más rápidas y óptimas posibles, ya que nuestro programa se ha visto interrumpido y no se continuará su ejecución hasta la salida de la ISR. Si tenemos en cuenta que normalmente nuestras ISR se ejecutarán 50 veces por segundo, es importante no ralentizar la ejecución del programa principal llenando de código innecesario la ISR.

Es crítico también que en la salida de la ISR no hayamos modificado los valores de los registros con respecto a su entrada. Para eso, podemos utilizar la pila y hacer PUSH + POP de los registros utilizados o incluso utilizar los Shadow Registers si sabemos a ciencia cierta que nuestro programa no los utiliza (con un EXX y un EX AF, AF' al principio y al final de nuestra ISR).

Al principio de nuestra ISR no es necesario desactivar las interrupciones con di, ya que el Z80 las deshabilita al aceptar la interrupción. Debido a este DI automático realizado por el procesador, las rutinas de ISR deben incluir un EI antes del RET/RETI.

Así pues, de las rutinas ISR llamadas en las interrupciones se debe de volver con una instrucción RETN en las interrupciones no enmascarables y un EI + RETI en las enmascarables (aunque en algunos casos, según el periférico que provoca la interrupción, también se puede utilizar EI + RET, que es ligeramente más rápido y que tiene el mismo efecto en sistemas como el Spectrum).

reti        ; Return from interrupt (14 T-Estados).
retn        ; Return from non-maskable interrupt (14 T-Estados).

Existe un motivo por el cual existe RETI y no se utiliza simplemente RET, y es que existen unos flip-flops internos en el procesador que le marcan ciertos estados al procesador y que en el caso de salida de una interrupción deben resetearse.

Citando el documento z80undoc3.txt de Z80.info (por Sean Young):

3.1) Non-maskable interrupts (NMI)

When a NMI is accepted, IFF1 is reset. At the end of the routine, IFF1 must
be restored (so the running program is not affected). That's why IFF2 is
there; to keep a copy of IFF1.

An NMI is accepted when the NMI pin on the Z80 is made low. The Z80 responds
to the /change/ of the line from +5 to 0. When this happens, a call is done
to address 0066h and IFF1 is reset so the routine isn't bothered by maskable
interrupts. The routine should end with an retn (RETurn from Nmi) which is
just a usual ret, but also copies IFF2 to IFF1, so the IFFs are the same as
before the interrupt.


3.2) Maskable interrupts (INT)

At the end of a maskable interrupt, the interrupts should be enabled again.
You can assume that was the state of the IFFs because otherwise the interrupt
wasn't accepted. So, an INT routine always ends with an ei and a ret
(reti according to the official documentation, more about that later):

INT:	.
	.
	.
	ei
	reti (or ret)

Note a fact about ei: a maskable interrupt isn't accepted directly after it,
so the next opportunity for an INT is after the reti. This is very useful;
if the INT is still low, an INT is generated again. If this happens a lot and
the interrupt is generated before the reti, the stack could overflow (since
the routine is called again and again). But this property of ei prevents this.

You can use ret in stead of reti too, it depends on hardware setup. reti
is only useful if you have something like a Z80 PIO to support daisy-chaining:
queueing interrupts. The PIO can detect that the routine has ended by the
opcode of reti, and let another device generate an interrupt. That is why
I called all the undocumented EDxx ret instructions retn: All of them
operate like retn, the only difference to reti is its specific opcode.
(Which the Z80 PIO recognises.)

Es decir, para aquellos sistemas basados en Z80 con hardware PIO que soporte múltiples dispositivos I/O encadenando sus interrupciones, se define un opcode especial reti distinto de ret de forma que el PIO pueda detectar el fin de la ISR y pueda permitir a otros dispositivos generar una interrupción.

En el caso del Spectrum con la ULA como (habitualmente) único dispositivo que interrumpe, se utiliza normalmente RET en lugar de RETI por ser ligeramente más rápida en ejecución. No obstante, en nuestros ejemplos hemos utilizado RETI para acomodarlos a la teoría mostrada.

Como ya hemos comentado antes, ei no activa las interrupciones al acabar su ejecución, sino al acabar la ejecución de la siguiente instrucción. El motivo de esto es evitar que se pueda recibir una interrupción estando dentro de una ISR entre el EI y el RET:

Nuestra_ISR:
    ; Hay que preservar todos los registros que se modifiquen, incluido AF
    push XX
 
    (código de la ISR)
 
    ; Y recuperar su valor antes de finalizar la ISR
    pop XX
    ei
    reti

Si EI habilitara las interrupciones de forma instantánea y se recibiera una interrupción entre la instrucción EI y el RETI, se volvería a entrar en la ISR, y por lo tanto se volvería a realizar el PUSH de PC y el PUSH de HL, rompiendo el flujo correcto del programa. Por contra, tal y como funciona ei sólo se habilitarán de nuevo las interrupciones tras la ejecución de RETI y la recuperación de PC de la pila, permitiendo así la ejecución de una nueva interrupción sin corromper el contenido del stack.

Finalmente, es importantísimo en los modelos de más de 16K (48K y 128K paginados) utilizar una tabla de vector de interrupciones ubicada en la página superior de la RAM (memoria por encima de los 32K), ya que la utilización del bloque inferior de memoria (dejando de lado la ROM, el bloque desde 16K a 32K) provocaría un efecto nieve en la pantalla. La elección estándar de la dirección de la tabla de vector de interrupciones recae en direcciones a partir de $fe00, por motivos que veremos al hablar sobre la ULA. Por otra parte, en la sección de Consideraciones y Curiosidades veremos cómo solucionar este problema en sistemas de 16K de memoria RAM.

Ninguna de las instrucciones que hemos visto (RST XX, EI, DI, RETI, RETN, HALT o IM XX) produce afectación alguna en los flags, salvo ld a, r, que altera el flag P/V con la utilidad que ya hemos visto.


A modo de curiosidad, vamos a ver el código de la ISR que se ejecuta en modo 1 (rst $38), tomado del documento The Complete Spectrum ROM Disassembly con alguna modificación en los comentarios:

; THE 'MASKABLE INTERRUPT' ROUTINE
; The real time clock (FRAMES) is incremented and the keyboard
; scanned whenever a maskable interrupt occurs.
;
; FRAMES = 3 bytes variable.
 
; BYTES 1 & 2 FRAMES -> $5c78 and $5c79
; BYTE 3 FRAMES      -> IY+40h = $5c7a
;
0038 MASK-INT:
                  push  af                  ; Save the current values held in
                  push  hl                  ; these registers.
                  ld    hl,($5c78)          ; The lower two bytes of the
 
                  inc   hl                  ; frame counter are incremented (FRAMES)
                  ld    ($5c78),hl          ; every 20 ms. (UK) -> inc bYTE_1_2(FRAMES)
                  ld    a,h                 ; The highest byte of the frame counter is
                  or    l                   ; only incremented when the value
                  jr    nz,KEY-INT          ; of the lower two bytes is zero
                  inc   (iy+$40)            ; inc bYTE_3(FRAMES) ($5c7a)
0048 KEY-INT:
                  push  bc                  ; Save the current values held
                  push  de                  ; in these registers.
                  call  KEYBOARD            ; Now scan the keyboard. (call $02bf)
                  pop   de                  ; Restore the values.
                  pop   bc
                  pop   hl
                  pop   af
                  ei                        ; The maskable interrupt is en-
                  ret                       ; abled before returning.

Nótese cómo la ISR del modo 1 se ajusta a lo visto hasta ahora: se preserva cualquier registro que pueda utilizarse dentro de la misma, se reduce el tamaño y tiempo de ejecución de la ISR en la medida de lo posible, y se vuelve con un EI+RET.

La rutina actualiza el valor de la variable del sistema FRAMES (que viene a ser el equivalente de la variable “abs_ticks” del ejemplo que veremos en el siguiente apartado) y llama a la rutina de la ROM KEYBOARD ($02bf) que es la encargada de chequear el estado del teclado y actualizar ciertas variables del sistema para que el intérprete BASIC (o nuestros programas si corren en im 1) pueda gestionar las pulsaciones de teclado realizadas por el usuario. Si bien la rutina KEYBOARD a la que se llama desde la ISR no es todo lo “pequeña” que se podría esperar de algo que se va a ejecutar en una ISR, sí que es cierto que es una de las partes primordiales del intérprete BASIC y que es más óptimo y rápido obtener el estado del teclado en la ISR (aunque la rutina sea larga e interrumpa 50 veces por segundo a nuestro programa con su ejecución) que tener que realizar la lectura del teclado dentro del propio intérprete de forma continuada.

Nótese, como nos apunta metalbrain en los foros de Speccy.org, que FRAMES es una variable de 3 bytes y que esta rutina ISR utiliza IY para acceder al tercer byte de esta variable cuando el incremento de los 2 bytes más bajos requieren el incremente del tercer byte. Lo hace a través de IY+40h y esto explica porqué desde BASIC, bajo im1, no debemos utilizar código ASM que haga uso de IY, bajo riesgo de que este INC pueda realizarse sobre un valor de IY que no sea el esperado y por tanto “corromper” un byte de código de nuestro programa o de datos, pantalla, etc.


La clave de este capítulo, y la principal utilidad del uso de interrupciones en nuestros programas es la de aprovechar las interrupciones que la ULA genera 50 veces por segundo (60 en América) en el modo im 2.

Como hemos explicado anteriormente, antes de pasar al modo im 2, nosotros somos los responsables de generar la tabla de vectores de interrupción con los valores a los que el procesador debe de saltar en caso de recibir una interrupción de un dispositivo externo. Para eso, generamos en memoria una tabla de 257 bytes y en ella introducimos las direcciones de salto de las ISR.

En un Spectrum estándar sin dispositivos conectados al bus de expansión sólo recibiremos interrupciones generadas por la ULA, con un device_id teórico de valor $ff (este device-id es también el causante de que la tabla sea de 257 bytes y no de 256).

En realidad, técnicamente hablando, la ULA no pone ningún valor en el bus de datos como sí haría un dispositivo hardware conectado para “identificarse”. Lo que ocurre es que las resistencias de “pull-up” en la circuitería del Spectrum hacen que por defecto ese valor sea $ff.

Si sólo vamos a recibir un $ff en el bus de datos cada vez que se ejecute una interrupción, el Z80 irá a buscar la dirección de la ISR a (I*256) + $ff, por lo que si queremos nuestra tabla de vectores de salto en $fe (I=$fe), en realidad no necesitamos una tabla, sino que sólo necesitamos poner la dirección de nuestra ISR en $feFF y $feFF+1.

Como veremos luego, esto es en realidad un error y funciona en prácticamente la totalidad de los Spectrums europeos, pero no en modelos Timex Sinclair, en algunos clones o en Spectrums con algún dispositivo conectado que modifique este valor $ff por parte de la ULA. Lo normal es que con este método todo funcione correctamente y vamos a verlo, porque se ha utilizado tal cual en muchísimo software de Spectrum.

Veamos pues cómo instalar una rutina ISR que se ejecute cada vez que se recibe una interrupción de la ULA asumiendo que pone en el bus de datos el valor $ff. En teoría deberíamos generar nuestra tabla de vectores de interrupción a partir de una posición de memoria como, por ejemplo, $fe00. Pero sabiendo que la segunda parte de la dirección va a ser $ff, sólo necesitamos modificar $fe00 y $feff+1 ($ff00).

Para atender a las interrupciones generadas por la ULA (device_id $ff), tendremos que realizar los siguientes pasos:


  • Crear una rutina de ISR correcta (preservar registros, salir con EI+RETI o EI+RET, etc.).
  • Desactivar las interrupciones con DI.
  • Colocar la dirección de nuestra ISR en las posiciones de memoria $feff y $feff+1 (que es de donde leerá el Z80 la dirección de salto cuando reciba la interrupción con ID $ff).
  • Asignar a I el valor $fe y saltar a im 2 (de esta forma, le decimos al Z80 que la tabla de vectores de interrupción empieza en $fe00).
  • Activar las interrupciones con EI.


El código resultante sería el siguiente:

 ; Instalando una ISR de atención a la ULA.
 
    ORG 50000
 
    ; Instalamos la ISR:
    ld hl, ISR_ASM_ROUTINE        ; HL = direccion de la rutina de ISR
    di                            ; Deshabilitamos las interrupciones
    ld ($feff), hl                ; Guardamos en (65279 = $feff) la direccion
    ld a, $fe                     ; de la rutina ISR_ASM_ROUTINE
    ld i, a                       ; Colocamos en I el valor $fe
    im 2                          ; Saltamos al modo de interrupciones 2
    ei
 
    (resto programa)
 
;--- Rutina de ISR. ---------------------------------------------
ISR_ASM_ROUTINE:
    push af
    push hl
    push ...
 
    (código de la ISR)
 
    pop ...
    pop hl
    pop af
 
    ei
    reti

De esta forma, saltamos a im 2 y el procesador se encargará de ejecutar la rutina ISR_ASM_ROUTINE 50 veces por segundo, por el siguiente proceso:


  • La ULA provoca una señal de interrupción enmascarable INT.
  • La ULA no coloca ningún device ID en el bus de datos; debido a las resistencias de pull-up, el bus de datos toma el valor $ff.
  • El procesador termina de ejecutar la instrucción en curso y, si las interrupciones están actualmente habilitadas procesa la interrupción.
  • El procesador lee del bus de datos el valor $fe y, al estar en modo 2, compone junto al registro una dirección “$feFF” (campo $ff dentro de la tabla de vectores de interrupción que empieza en $fe*256 = $fe00).
  • El procesador lee la dirección de 16 bits que se compone con el contenido de las celdillas de memoria $feff y $feff+1 ($ff00). Esta dirección de 16 bits es la que hemos cargado nosotros con ld ($feff), hl y que apunta a nuestra rutina ISR.
  • El procesador salta a la dirección de 16 bits compuesta, que es la dirección de nuestra ISR.
  • Se ejecuta la ISR, de la cual salimos con ei+reti, provocando la continuación de la ejecución del programa original hasta la siguiente interrupción.


Como hemos dicho en el apartado sobre las ISR, es crítico que la tabla de vectores de interrupción se ubique en una página alta de la RAM, es decir; que no esté dentro del área comprendida entre los 16K y los 32K que el procesador y la ULA comparten regularmente para que la ULA pueda actualizar la pantalla. En todos los ejemplos que hemos visto y veremos, la dirección de la tabla de vectores de interrupción comienza a partir de $fe00. Ubicarla a partir de $ff00 (que es la única página más alta que $fe00) no sería una elección apropiada puesto que al necesitar una tabla de 257 bytes para la ULA (device ID=$ff), parte de la dirección de salto se compondría con “$ffFF +1 = $0000” (la ROM).

Finalmente destacar que (aunque no es lo habitual) desde dentro de nuestra rutina ISR en im2 podemos llamar a rst $38 (la rutina que sería ejecutada por im1) si queremos que ésta actualice determinadas variables del sistema, siempre y cuando no usemos el registro IY en nuestro programa. En ese caso, recordemos que hay que preservar todos los registros:

Rutina_ISR:
    push af             ; Preservamos todos los registros
    push bc
    push hl
    push de
    push ix             ; IX tambien.
                        ; IY no lo podemos tocar si usamos rst $38.
 
    ; Aqui hacemos nuestras cosas, como actualizar contadores
    ; o llamar al reproductor de musica en modelos 128K
    (...)
 
    rst $38             ; Lectura del teclado, actualización de FRAMES
 
    pop ix
    pop de
    pop hl
    pop bc
    pop af              ; restauramos todos los registros
    ei
    reti

Es posible que no queramos llamar a im1 para evitar tareas superflúas para nosotros como la lectura del teclado por parte de la ROM, pero sí que queramos que se siga actualizando la variable FRAMES.

Ese ese caso, podemos actualizarla nosotros copiando parte del código de la im1 dentro de nuestra ISR. Así es como lo incrementa la rutina real de la ROM ($0038):

    ld    hl,($5c78)          ; The lower two bytes of the
    inc   hl                  ; frame counter are incremented (FRAMES)
    ld    ($5c78),hl          ; every 20 ms. (UK) -> inc bYTE_1_2(FRAMES)
    ld    a,h                 ; The highest byte of the frame counter is
    or    l                   ; only incremented when the value
    jr    nz,frames_no_inc    ; of the lower two bytes is zero
    INC   (IY+40h)            ; inc bYTE_3(FRAMES) ($5c7a)
frames_no_inc:

En nuestro caso no tendremos IY apuntando al valor correcto así que tenemos 2 opciones. O bien simplemente incrementamos la parte de 16 bits de FRAMES (suficiente para implementar temporización y control de velocidad):

    ld hl, $5c78              ; The lower two bytes of the
    inc (hl)                  ; frame counter are incremented (FRAMES)

O bien no usamos IY y simulamos el mismo código pero incrementando el valor de $5c7a con HL:

    ld hl, ($5c78)
    inc hl
    ld ($5c78), hl
    ld a, h
    or l
    jr nz, frames_no_inc
    ld hl, $5c7a           ; O "inc hl" + "inc hl" (+2 t-states -1 byte)
    inc (hl)               ; inc bYTE_3(FRAMES) ($5c7a)
frames_no_inc:

Con la teoría descrita hasta ahora ya tenemos los mecanismos para realizar programas que dispongan de sus propias ISR de servicio, como los ejemplos que veremos a continuación.


A continuación se muestra un ejemplo completo de ISR que implementa un contador de segundos.

Al principio del programa, se escribe en $feff la dirección de nuestra ISR y se cambia a im2 con A en $fe para que se lea (en un Spectrum estándar) el valor $feff como “vector único de ISR”.

En la ISR incrementamos el valor de la variable ticks y cuando esta llega a 50, incrementamos el número de segundos y a ticks lo hacemos de nuevo 0 (en realidad, le restamos 50, por si hubiese sido incrementado externamente, pero podríamos simplemente haber hecho un xor a).

Ese código de ISR está corriendo de forma automática 50 veces por segundo, por lo que ticks y seconds se están modificando por la ISR. El código principal de nuestro programa simplemente, en un bucle, cambia el cursor a 0,0 y muestra el valor de seconds.

; Ejemplo de ISR que gestiona un contador de ticks y segundos.
    ORG 50000
 
    call CLS
 
    ; Instalamos la ISR:
    ld hl, CLOCK_ISR_ASM_ROUTINE
    di                            ; Desactivar interrupciones
    ld ($feff), hl                ; Guardamos en ($feff) la direccion
    ld a, $fe                     ; de la rutina CLOCK_ISR_ASM_ROUTINE
    ld i, a                       ; I = $fe => direccion de ISR en $feFF
    im 2                          ; Cambiar a im 2
    ei                            ; Activar interrupciones
 
    jr ImprimirNumero             ; Hacemos la 1a impresion: "00"
 
Bucle:
    halt
 
ImprimirNumero:
    ld de, 0
    call CursorAt                 ; Cursor en (0,0)
 
    ld a, (seconds)
    call PrintNum2digits
 
    jr Bucle                      ; Repetir indefinidamente
 
;-----------------------------------------------------------------------
; Rutina de ISR : incrementa FRAMES 50 veces por segundo.
;-----------------------------------------------------------------------
INTS_PER_SECOND    EQU  50
 
CLOCK_ISR_ASM_ROUTINE:
    push af
    push hl
    ld a, (ticks)
    inc a
    ld (ticks), a
    cp INTS_PER_SECOND
    jr c, frames_menor_que_50
 
    sub INTS_PER_SECOND       ; Restamos 50 a A (no lo hago 0 directamente
                              ; por si alguien incremento su valor externamente)
 
    ld hl, seconds
    inc (hl)                  ; Incrementamos segundos
 
frames_menor_que_50:
    ld (ticks), a
    pop hl
    pop af
    ei
    reti
 
ticks              DB    0
seconds            DB    0
 
    ;---------------------------------------------------------
    INCLUDE "utils.asm"
 
    END 50000



La captura anterior está tomada 8 segundos después de iniciar el programa.


Como ya hemos dicho, la ULA no está preparada para funcionar en im 2 y por lo tanto no coloca ningún valor en el bus de datos antes de generar una señal de interrupción. Debido a las resistencias pull-up, el valor “por defecto” de este bus es $ffh (11111111b), que es el valor que hemos utilizado en nuestros anteriores ejemplos para diseñar una ISR que se ejecute las 50 (ó 60) veces por segundo que interrumpe la ULA al Z80A. Es por eso que en los ejemplos anteriores estamos escribiendo la dirección de nuestra ISR en ($feff y $feff+1).

Nuestro pequeño truco de escribir la dirección de nuestra ISR en $feff es sencillo y funciona, pero no es la manera correcta de hacer las cosas. Algunos clones de Spectrum, con diferente circuitería electrónica, no tienen por qué dejar como “valor por defecto” $ff en el bus de datos. Es más, un Spectrum estándar, europeo, con algún dispositivo hardware conectado al Slot de expansión trasero, podría modificar ese valor.

El mismo problema sucede en ciertos modelos de Timex Sinclair, como por ejemplo el TS2068, el cual no tiene las resistencias de pull-up conectadas a las líneas del bus de datos por lo que el valor que aparezca en dicho bus puede ser totalmente arbitrario o aleatorio. Los programas anteriores de ejemplo, que ubicaba la ISR en $feff (asumiendo el device-id de $ff), no tendría asegurada la compatibilidad con este modelo.

Con un valor aleatorio en el bus de datos, el procesador podría saltar a cualquiera de las direcciones de la tabla de vectores de interrupción.

Una primera aproximación a solucionar este problema podría ser la de introducir la misma dirección de salto (la de nuestra rutina ISR) en las 128 direcciones de salto de la tabla de vectores. De esta forma, fuera cual fuera el valor en el bus de datos, el procesador siempre saltaría a nuestra ISR.

El problema es que tampoco podemos determinar si este valor aleatorio en el bus es par o impar, de forma que si fuera par saltaría a la dirección correcta de uno de los vectores ($fe00), mientras que si fuera impar saltaría a una dirección incorrecta compuesta por parte de la dirección de un device-id, y parte de la dirección del otro ($00fe), como ya vimos en un apartado anterior.

La forma de solucionar esta problemática es bastante curiosa y original: basta con ubicar nuestra ISR en una dirección donde coincidan la parte alta y la parte baja de la misma, y rellenar la tabla de vectores de interrupción con este valor. Por ejemplo, en el compilador de C z88dk y la librería SPlib se utiliza para su ISR la dirección $f1f1. De esta forma, la tabla de vectores de interrupción se llena con 257 valores “$f1”. Así, sea cual sea el valor que tome el bus de datos cuando se recibe la interrupción (y sea par o impar), el procesador siempre saltará a $f1f1, donde estará nuestra ISR.

TABLA DE VECTORES DE INTERRUPCION

Posición   -  Valor
--------------------
($fe00)    -   $f1
($fe01)    -   $f1
($fe02)    -   $f1
($fe03)    -   $f1
(...)      -   $f1
($feff)    -   $f1
($ff00)    -   $f1

La desventaja de este sistema es que convertimos al versátil modo im 2 con posibilidad de ejecutar hasta 128 ISR diferentes que atiendan cada una a su periférico correspondiente en una evolución del im 1 (donde siempre se saltaba a $0038), pero en la cual la dirección de salto está fuera de la ROM y es personalizable. Es decir, perdemos la capacidad del im2 de que cada dispositivo pueda disponer de código específico para atender sus interrupciones. Este “im 1 mejorado” lo que nos permite es funcionar como en im1 pero con nuestra propia ISR única. De hecho, esta es la forma más habitual de utilizar im 2.

La única desventaja es que en esta ISR deberemos gestionar todas las interrupciones de cualquier periférico basado en interrupciones que queramos que interactúe con nuestro programa, aunque en el 99% de los programas o juegos (a excepción del uso del AMX mouse o similares) no se suele interactuar con los periféricos mediante este sistema.

Resumamos lo que acabamos de ver y comprender de forma esquemática las ventajas de una tabla de 257 bytes con el valor $f1 en cada elemento de la misma:

  • Por lo que hemos visto hasta ahora, en un Spectrum estándar sin dispositivos en el bus de expansión la ULA se identifica al interrumpir el procesador como $ff, aunque no intencionadamente pues es el resultado del valor por defecto que hay en el bus de datos cuando no se coloca ningún dato debido a las resistencias de pull-up.
  • En nuestras anteriores rutinas guardábamos en FEFFh la dirección de la rutina de ISR que queríamos que se ejecutara cuando la interrupcion se identificaba con ID $ff (la ULA).
  • Cargábamos I con 256 (FE) antes de saltar a im2, de esta forma cuando la ULA producía una interrupción con id $ffh se saltaba a la direccion que había en ($feff), que era la de nuestra ISR.
  • Por desgracia, en ciertos modelos Timex Sinclair o si tenemos dispositivos conectados al bus de expansion puede que no encontremos $ff en el bus de datos, sino un valor arbitrario. Esto puede producir que la interrupcion no llegue como “ID=FF” y que, por lo tanto, no se produzca el salto a ($feff) sino a otro de los elementos de la tabla de vectores de interrupcion.
  • Para evitar que esto ocurra, podemos generar una tabla de 257 bytes y llenarla con el valor “$f1”. De esta forma, sea cual sea el valor leído en el bus de datos, se saltará a $f1F1 (ya sea par o impar el valor del bus, las 2 partes de la dirección de salto en la tabla siempre sería $f1 + $f1).
  • Nuestra ISR estará pues en $f1f1 (en este ejemplo) y ser la responsable de gestionar cualquier periferico que pueda haber generado la interrupción.
  • La Tabla de Vectores de Interrupción se debe mantener en memoria (son 257 bytes de memoria “perdidos” para nuestro código o datos) ya que será usada en cada Interrupción del Z80 para buscar la ISR a la cual saltar.


A continuación podemos ver cómo sería el esqueleto de un programa utilizando una ISR diseñada para funcionar aunque existan dispositivos conectados al bus de expansión que modifiquen el valor del mismo cuando no haya datos en él.

    ORG 50000
 
    ; Generamos una tabla de 257 valores "$f1" desde $fe00 a $ff00
    ld hl, $fe00
    ld a, $f1
    ld (hl), a                    ; Cargamos $f1 en $fe00
    ld de, $fe01                  ; Apuntamos DE a $fe01
    ld bc, 256                    ; Realizamos 256 ldi para copiar $f1
    ldir                          ; en toda la tabla de vectores de int.
 
    ; Arrancamos las interrupciones con todas las direcciones
    ; del vector apuntando a nuestra ISR:
    di
    ld a, $fe                     ; Tenemos la tabla a partir de $fe00.
    ld i, a
    im 2                          ; Saltamos a im2
    ei
 
; ---------------------------------------------------------------
; (aqui insertamos el codigo del programa, incluidas subrutinas)
; ---------------------------------------------------------------
 
; A continuación la rutina ISR, ensamblada en $f1f1:
;
; Con el ORG $f1f1 nos aseguramos de que la ISR sera ensamblada
; por el ensamblador a partir de esta direccion, que es donde queremos
; que este ubicada para que el salto del procesador sea a la ISR.
; Asi pues, todo lo que siga a este ORG se ensamblara para cargarse
; a partir de la direccion $f1f1 de la RAM.
 
    ORG $f1f1
 
;-----------------------------------------------------------------------
; Rutina de ISR : incrementa ticks 50 veces por segundo, y el resto
; de las variables de acuerdo al valor de ticks.
;-----------------------------------------------------------------------
CLOCK_ISR_ASM_ROUTINE:
    push af
    push hl
 
   ; (... aqui insertamos el resto de la ISR...)
 
    pop hl
    pop af
 
    ei
    reti
 
; Si vamos a colocar mas codigo en el fichero ASM detrás de la ISR; este
; sera ensamblado en direcciones a partir del final de la ISR en memoria
; (siguiendo a la misma). Como seguramente no queremos esto, es mejor ubicar
; la ISR con su ORG $f1f1 al final del listado, o bien enlazarla como
; un binario aparte junto al resto del programa, o bien colocar otro ORG
; tras las ISR y antes de la siguiente rutina a ensamblar.
 
    END 50000

El ORG $f1f1 indica al ensamblador que debe ensamblar todo lo que va detrás de esta directiva a partir de la dirección de memoria indicada. En el ejemplo anterior hemos ubicado la rutina de ISR al final del programa, puesto que si seguimos añadiendo código tras la ISR, esté será ensamblado en ($f1f1 + TAMAÑO_ISR). Si no queremos que la ISR esté al final del listado fuente, podemos utilizar las siguientes directivas de pasmo para continuar el ensamblado de más rutinas a partir de la dirección inmediatamente anterior al ORG:

; Recuperando una posicion de ensamblado
    ORG 50000
 
    ; Nuestro programa
    ; (...)
 
    ; Guardamos en una variable de preprocesador la posicion
    ; de este punto en el proceso de ensamblado ($)
    PUNTO_ENSAMBLADO EQU $
 
    ;----------------------------------------------------
    ; Nuestra rutina de ISR ensamblada en $f1f1 debido
    ; a la directiva ORG $f1f1
    ;----------------------------------------------------
    ORG $f1f1
 
 Rutina_ISR:
    ; La rutina ISR
 
 
    ORG PUNTO_ENSAMBLADO
    ;----------------------------------------------------
    ; El codigo continua pero no ensamblado tras $f1f1
    ; sino en la direccion anterior al ORG
    ;----------------------------------------------------
 
Mas_Rutinas:
    ; Resto del programa

El listado completo del ejemplo está disponible para su descarga al final del capítulo, pero es esencialmente igual al primer ejemplo de reloj interno basado en ISR con ciertas excepciones:

  • La rutina de ISR se ensambla en $f1f1 mediante una directiva del ensamblador ORG $f1f1 antes de la misma, lo cual hace que en el programa resultante, dicho código se ubique a partir de $f1f1.
  • Se genera en $fe00 una tabla de 257 bytes conteniendo el valor $f1, para que la dirección de salto de una interrupción sea siempre $f1F1 independientemente de cuál sea el valor del device_id en el bus de datos (sea también par o impar).

De esta forma nuestra ISR será compatible con los diferentes modelos de Sinclair Spectrum con o sin periféricos conectados al bus de expansión.

Otros autores de libros sobre programación (y a su vez programadores), como David Webb, proponen la utilización de $fdfd como vector de salto, y colocar en esta dirección un jp a la dirección de la rutina real. Nótese que $fdfd está 3 bytes en memoria antes que $fe00, por lo que de esta forma se puede tener el salto a la ISR junto a la tabla de vectores de interrupción, consecutivos en memoria. No obstante, esto añade 10 t-estados adicionales a la ejecución de la ISR, los relativos al salto, y no nos es de especial utilidad dada la posibilidad de los ensambladores cruzados de ubicar nuestra ISR en $f1f1 mediante la directiva ORG.


A continuación se muestra un ejemplo completo de ISR implementada con Vectores de Interrupción que gestiona una serie de variables en memoria:


  • abs_ticks : Esta variable se incrementa en cada ejecución de la interrupción (es decir, 50 veces por segundo), y al ser de 16 bits se resetea a 0 al superar el valor 65535. Puede ser muy útil como controlador de tiempo entre 2 sucesos que duren menos de 21 minutos (65535/50/60).
  • timer: Esta variable es igual que abs_ticks pero se decrementa en lugar de incrementarse. Existe para ser utilizada por ciertas funciones útiles que veremos más adelante en este mismo capítulo.
  • ticks : Esta variable se incrementa igual que abs_ticks en cada ejecución de la ISR (50 veces por segundo), pero cuando su valor llega a 50 la restablecemos a 0 y aprovechamos este cambio para incrementar la variable seconds.
  • seconds : Esta variable almacena los segundos transcurridos. Sólo se incrementa cuando ticks vale 50, es decir, cuando han pasado 50 ticks que son 1 segundo. Cuando la variable llega a 60, se restablece a cero y se incrementa la variable minutes.
  • pause : Esta variable nos permite que la ISR no incremente el tiempo cuando estamos en “modo pausa”.
  • clock_changed : Esta variable cambia de 0 a 1 cuando nuestra ISR ha modificado el “reloj” interno formado por las variables minutos y segundos. La utiliza el bucle principal del programa para saber cuándo actualizar el reloj en pantalla.


Para ello generamos una ISR y la enlazamos con el modo 2 de interrupciones. En este caso, en vez de ubicarla en $f1f1, la hemos ubicado en $a1a1, para que sea compatible con utilizar paginación, como explicaremos en el próximo apartado.

Tras esto, nos mantenemos en un bucle de programa infinito que detecta cuándo la variable clock_changed cambia de 0 a 1 y que actualiza el valor en pantalla del reloj, volviendo a setear dicha variable a 0 hasta que la ISR modifique de nuevo el reloj. Cuando clock_changed vale 0, el programa se mantiene en un simple bucle que no realiza acciones salvo comprobar el estado de clock_changed continuamente. La ISR se ejecuta, por tanto, “en paralelo” a nuestro programa cuando las interrupciones solicitan la atención del procesador, 50 veces por segundo.

Podríamos simplemente, como en el primer ejemplo del contador de segundos, habernos quedado en un bucle que imprimiera constantemente el valor, pero no tiene sentido hacerlo si no ha cambiado, así que simplemente nos quedamos en un bucle que no imprime nada hasta que la ISR ha avisado de que ha habido un cambio de segundo (algo que sólo ocurre en 1 de cada 50 interrupciones).

; Ejemplo de ISR con tabla de vectores que gestiona un contador de ticks, minutos y segundos.
    ORG 40000
 
    call CLS
 
    ; Generamos una tabla de 257 valores "$a1" desde $fe00 a $ff00
    ld hl, $fe00
    ld a, $a1                     ; A = $a1
    ld (hl), a                    ; Cargamos $a1 en $fe00
    ld de, $fe01                  ; Apuntamos DE a $fe01
    ld bc, 256                    ; Realizamos 256 ldi para copiar $a1
    ldir                          ; en toda la tabla de vectores de int.
 
    ; Activamos im2 con nuestra ISR
    di
    ld a, $fe                     ; Definimos la tabla a partir de $fe00.
    ld i, a
    im 2                          ; Saltamos a im2
    ei
 
    jr ImprimirNumero             ; Hacemos la 1a impresion: "00:00"
 
Bucle:
    ld a, (clock_changed)
    and a
    jr z, Bucle                   ; Si clock_changed no vale 1, no hay
                                  ; que imprimir el mensaje
 
    ; Si estamos aqui es que clock_changed = 1... lo reseteamos
    ; e imprimimos por pantalla la información como MM:SS
    xor a
    ld (clock_changed), a         ; clock_changed = 0
 
ImprimirNumero:
    ld de, 0
    call CursorAt
 
    ld a, (minutes)               ; Imprimimos minutos + ":" + segundos
    call PrintNum2digits
    ld a, ":"
    rst 16
    ld a, (seconds)
    call PrintNum2digits
 
    jr Bucle                      ; Repetir indefinidamente
 
clock_changed DB 0
ticks         DB 0
seconds       DB 0
minutes       DB 0
pause         DB 0
abs_ticks     DW 0
timer         DW 0
 
POSICION_DETRAS_DEL_MAIN  EQU $
 
 
;-----------------------------------------------------------------------
; Con este ORG $a1a1 nos aseguramos de que la ISR sera ensamblada
; por el ensamblador a partir de esta direccion, que es donde queremos
; que este ubicada para que el salto del procesador sea a la ISR.
; Asi pues, todo lo que siga a este ORG se ensamblara para cargarse
; a partir de la direccion $a1a1 de la RAM.
;
; Se ha elegido $a1a1 para que esté en el bloque de 16K que empieza
; en 32768, es decir, fuera de la contented memory y fuera del bloque
; de paginación 128K.
 
    ORG $a1a1
 
;-----------------------------------------------------------------------
; Rutina de ISR : incrementa ticks 50 veces por segundo, y el resto
; de las variables de acuerdo al valor de ticks.
;-----------------------------------------------------------------------
CLOCK_ISR_ASM_ROUTINE:
    push af
    push hl
 
    ld a, (pause)
    or a
    jr nz, clock_isr_fin          ; Si pause==1, no continuamos la ISR
 
    ld hl, (abs_ticks)
    inc hl
    ld (abs_ticks), hl            ; Incrementamos abs_ticks (absolutos)
 
    ld hl, (timer)
    dec hl
    ld (timer), hl                ; Decrementamos timer (ticks absolutos)
 
    ld a, (ticks)
    inc a
    ld (ticks), a                 ; Incrementamos ticks (50 veces/seg)
 
    cp 50
    jr c, clock_isr_fin           ; if ticks < 50,  fin de la ISR
                                  ; si ticks >= 50, cambiar seg:min
    xor a
    ld (ticks), a                 ; ticks = 0
 
    ld a, 1
    ld (clock_changed), a         ; ha cambiado el numero de segundos
 
    ld a, (seconds)
    inc a
    ld (seconds), a               ; seconds = segundos +1
 
    cp 60
    jr c, clock_isr_fin           ; si segundos < 60 -> salir de la ISR
 
    xor a                         ; si segundos == 60 -> inc minutos
    ld (seconds), a               ; seconds = 0
 
    ld a, (minutes)
    inc a
    ld (minutes), a               ; minutes = minutos + 1
 
    cp 60
    jr c, clock_isr_fin           ; si minutos >= 60 -> resetear minutos
    xor a
    ld (minutes), a               ; minutes = 0
 
clock_isr_fin:
    pop hl
    pop af
 
    ei
    reti
 
; Si vamos a colocar mas codigo en el fichero ASM detra de la ISR; este sera
; ensamblado en direcciones a partir de $a1a1 a partir del final de la ISR en
; memoria. Como seguramente no queremos esto, es mejor ubicar la ISR con su
; ORG $a1a1 al final del listado o bien colocar otro ORG antes de la siguiente
; rutina a ensamblar, usando un EQU $ previo para poder continuar
; Con el "POSICION_DETRAS_DEL_MAIN  EQU $" de antes de la ISR, nos guardamos
; la posición de ese punto de ensamblado, y ahora continuamos a partir de ahí:
 
    ORG POSICION_DETRAS_DEL_MAIN
 
    INCLUDE "utils.asm"
 
    END 40000

La siguiente captura muestra la salida del anterior programa de ejemplo transcurridos dos minutos y trece segundos desde el inicio de su ejecución:



El programa anterior nos muestra algunos detalles interesantes:


  • call CLS y rst 16 (CLS y PRINT-A): Podemos aprovechar las rutinas de la ROM dentro de nuestros programas, evitando escribir más código del necesario cuando la ROM ya provee de alguna rutina para ello. Por contra, esto hace nuestros programas no portables, ya que las rutinas de la ROM del Spectrum no están presentes (al menos no en las mismas direcciones y con los mismos parámetros de entrada y salida) en otros sistemas basados en Z80 como el Amstrad o el MSX, y menos eficientes que si usamos nuestras propias rutinas. Y tampoco podemos usar el registro IY pese a que ahora estamos en im2.
  • EQU $ : Mediante $, podemos acceder al valor de la posición exacta del punto de ensamblado (por tanto, una dirección de memoria absoluta calculable por el programa ensamblador al conocer el ORG del programa) en el momento en que lo usamos. Si lo asociamos a una constante, podemos referenciar ese punto del programa en cualquier momento, por ejemplo para un ORG posterior. Podemos asignar múltiples veces $ a diferentes constantes, ya que su valor variará según el punto en que la definamos.
  • En el programa tenemos 3 bloques diferentes de código: ORG 40000 (el inicio del programa), ORG POSICION_DETRAS_DEL_MAIN (que va inmediatamente después del código ensamblado en 40000) y ORG $a1a1 (41377). Lo normal es que el programa ensamblador, ante diferentes directivas ORG, generase varios bloques de código máquina (dos en este caso: uno en 40000 y otro en 41377 con los 77 bytes que ocupa el código de la ISR) y hacer un cargador BASIC que cargue ambos, pero Pasmo lo que hace es generar un único bloque que empieza en 40000 y acaba en 41454. Todo el hueco entre el final del código principal y la ISR, lo rellena con ceros y ocupa espacio en el BIN (y en el TAP).

    De hecho, si bajamos el ORG de 40000 a 35000, veremos que el TAP resultante ocupa 5KB más (5KB más de “blancos”). Otros ensambladores como sjasmplus saben resolver esto con múltiples bloques, y también lo podemos resolver nosotros ensamblando las 2 cosas por separado, y generando un cargador TAP a mano, y no automáticamente con pasmo --tapbas.




Algo tan básico como disponer de un reloj interno de ticks, segundos y minutos es sumamente importante para los juegos, puesto que podemos:

  • Temporizar el juego para proporcionar al jugador un tiempo límite o informarle de cuánto tiempo lleva transcurrido. La variable “pause” permite que la ISR no cuente el tiempo cuando a nosotros nos interese detener el contaje (juego pausado por el jugador, al mostrar escenas entre fase y fase o mensajes modales en pantalla, etc.).
  • Utilizar la información de ticks (como timer o abs_ticks y/o otras variables “temporales” que podemos agregar a la ISR) para que el tiempo afecte al juego. Esto permite, por ejemplo, para reducir el nivel de vida de un personaje con el tiempo, etc.
  • Actualizar regularmente el buffer de “notas” del chip AY de los modelos de 128K para reproducir melodías AY en paralelo a la ejecución de nuestro programa.
  • Llevar un control exacto de ticks para procesos de retardos con valores precisos. Es decir, si necesitamos hacer una espera de N ticks, o de N segundos (sabiendo que 50 ticks son 1 segundo), podemos utilizar la variable de 16 bits “timer” que se decrementa en cada ejecución de la ISR. Podemos así generar una rutina WaitNTicks en la que establecer “timer” a un valor concreto y esperar a que valga 0 (ya que será decrementado por la ISR).

    En este ejemplo en lugar de esperar a que timer valga 0, esperamos a que su byte alto valga FFh, en previsión de utilizarla en otros bloques de código más largos en el que se nos pueda pasar el ciclo exacto en que timer sea igual a cero. Comprobando que el byte alto de timer sea FFh, tenemos una rutina que nos permite tiempos de espera desde 1 tick hasta 49151/50/60=16 minutos. Veamos el siguiente ejemplo (el cual, por simplificar el código, no usa el sistema de tabla de vectores de interrupción sino simplemente $feff para apuntar a la ISR):


; Ejemplo de WaitNticks
 
    ORG 50000
 
    ; Instalamos la ISR:
    ld hl, CLOCK_ISR_ASM_ROUTINE
    di
    ld ($feff), hl                ; Guardamos en ($feff) la direccion
    ld a, 254                     ; de la rutina CLOCK_ISR_ASM_ROUTINE
    ld i, a
    im 2
    ei
 
Bucle_entrada:
    ld a, "."
    rst 16
    ld a, "5"
    rst 16
    ld a, " "
    rst 16                        ; Imprimimos por pantalla ".5 "
    ld hl, 25                     ; Esperamos 25 ticks (0.5 segundos)
    call WaitNTicks
 
    ld a, "3"
    rst 16
    ld a, " "
    rst 16                        ; Imprimimos por pantalla "3 "
    ld hl, 3*50                   ; Esperamos 150 ticks (3 segundos)
    call WaitNTicks
 
    jp Bucle_entrada
 
ticks         DB 0
timer         DW 0
 
;-----------------------------------------------------------------------
; WaitNTicks: Esperamos N ticks de procesador (1/50th) en un bucle.
;-----------------------------------------------------------------------
WaitNTicks:
    ld (timer), hl          ; seteamos "timer" con el tiempo de espera
 
waitnticks_loop:            ; bucle de espera, la ISR lo ira decrementando
    ld hl, (timer)
    ld a, h                 ; cuando (timer) valga 0 y lo decrementen, su
    cp $ff                  ; byte alto pasara a valer FFh, lo que quiere
                            ; decir que ha pasado el tiempo a esperar.
    jr nz, waitnticks_loop  ; si no, al bucle de nuevo.
    ret
 
;-----------------------------------------------------------------------
; Rutina de ISR : incrementa ticks y decrementa timer 50 veces por seg.
;-----------------------------------------------------------------------
CLOCK_ISR_ASM_ROUTINE:
    push af
    push hl
 
    ld hl, (timer)
    dec hl
    ld (timer), hl                ; Decrementamos timer (absolutos)
 
    ld a, (ticks)
    inc a
    ld (ticks), a                 ; Incrementamos ticks (50 veces/seg)
 
    cp 50
    jr c, clock_isr_fin           ; if ticks < 50,  fin de la ISR
    xor a                         ; si ticks >= 50, cambiar seg:min
    ld (ticks), a                 ; y ticks = 0
 
clock_isr_fin:
    pop hl
    pop af
    ei
    reti
 
    END 50000

Este ejemplo muestra por pantalla la cadena de texto “.5 ” (con un espacio al final) y después espera 25 ticks (0.5 segundos). A continuación muestra la cadena “3 ” y espera 150 ticks (3 segundos). Este proceso se repite en un bucle infinito.


 Ejemplo de espera con ISR

  • Aprovechando las rutinas anteriores, y que tenemos disponible al procesador para, por ejemplo, chequear el teclado, podemos agregar a nuestro programa funciones como la siguiente, la cual mantiene al procesador dentro de un bucle durante N segundos o bien hasta el usuario pulse una tecla, permitiendo pantallas de presentación o de créditos donde no es necesario obligar al usuario a pulsar una tecla para avanzar, aunque siga existiendo esta posibilidad.
;-----------------------------------------------------------------------
; WaitKeyOrTime: Esperamos N ticks de procesador o una tecla.
;-----------------------------------------------------------------------
WaitKeyOrTime:
    ld (timer), hl            ; seteamos "timer" con el tiempo de espera
 
waitkeyticks_loop:            ; bucle de espera, la ISR lo ira decrementando
    xor a                     ; A = 0 => leer todas las semifilas del teclado
    in a, ($fe)               ; Leer teclado
    or %11100000              ; Poner a 1 los 3 bits más altos
    inc a                     ; A=A+1. Comprobamos el estado del teclado
    ret nz                    ; Si A=0  => ZF = 1 => no hay tecla pulsada
                              ; Si A!=0 => ZF = 0 => hay alguna tecla => salimos
 
    ld hl, (timer)
    ld a, h                   ; cuando (timer) valga 0 y lo decrementen, su
    cp $ff                    ; byte alto pasara a valer FFh, lo que quiere
                              ; decir que ha pasado el tiempo a esperar.
    jr nz, waitkeyticks_loop  ; si no, al bucle de nuevo.
    ret


En muchos juegos comerciales y homebrew, la rutina de ISR no es una rutina en sí misma sino un jp a la rutina real, de modo que podamos tenerla junto al resto de nuestro código, y no en una ubicación específica de memoria:

; Rutina de ISR ubicada junto a nuestro programa, y no en dirección tipo $XYXY
    ORG 40000
 
    ; Generamos una tabla de 257 valores "$a2" desde $a000 a $a101
    ld hl, $a000
    ld a, $a2                     ; A = $a2
    ld (hl), a                    ; Cargamos $a2 en $fe00
    ld de, $fe01                  ; Apuntamos DE a $fe01
    ld bc, 256                    ; Realizamos 256 ldi para copiar $a2
    ldir                          ; en toda la tabla de vectores de int.
 
    ; Activamos im2 con nuestra ISR
    di
    ld a, $fe                     ; Definimos la tabla a partir de $fe00.
    ld i, a
    im 2                          ; Saltamos a im2
    ei
 
    ; Nuestro programa
    ; (...)
 
Rutina_ISR:
    ; La rutina ISR
    (...)
    ei
    reti
 
    ; Guardamos en una variable de preprocesador la posicion
    ; de este punto en el proceso de ensamblado ($)
    PUNTO_ENSAMBLADO EQU $
 
    ;-------------------------------------------------------------
    ; Nuestra rutina de ISR ensamblada en $A2A2: JP a rutina real
    ;-------------------------------------------------------------
    ORG $A2A2
 
PUNTO_ENTRADA_ISR:
    jp Rutina_ISR

De esta forma, la tabla de vectores de interrupción y la rutina ISR (un simple jp a la rutina real) están prácticamente juntas en memoria y sabemos que acaba 3 bytes después de $a2a2, por lo que podemos poner código a continuación sin tener que calcular cuánto ocupa la rutina de ISR. De esta forma también podemos tener la rutina de ISR junto al resto del código, con la única penalización de 3 bytes y 10 ciclos de reloj del jp NN.


Al respecto de la ubicación de la tabla de salto o “vectores de interrupción” es necesaria una consideración importante: La dirección que hemos tomado para el ejemplo ($feff para el ejemplo de la ULA, o $fe00-$feff+1 para el ejemplo de la tabla de vectores): es válida tanto para modelos 48K como para 128K, siempre que en estos últimos no usemos paginación de memoria.

Como veremos en el siguiente capítulo, la paginación del Spectrum consiste en que tenemos acceso a bloques adicionales de 16KB de RAM del Spectrum usando la “ventana” de 16KB final de la memoria (el bloque entre 49152 y 65535).

Si ponemos la tabla de vectores de interrupción en $fexx (al final de la memoria) o directamente en $feff (asumiendo ULA = $ff), todo funcionará correctamente hasta que hagamos un cambio de página. En el momento en que hagamos un cambio de página, la siguiente interrupción se irá al final de la memoria a buscar la dirección de salto de la ISR (en $feff) y no encontrará en ella la dirección de nuestra rutina sino el valor que haya en $feff en ese banco de memoria. Saltará a ella, y lo más probable será un cuelgue del Spectrum.

Para solucionar esto hay 2 opciones:

1. No usamos $fe00 sino una dirección de memoria más baja para construir la tabla. Por ejemplo, en nuestro programa podemos hacer un:

; Reservamos en $be00 257 bytes para usarlos de tabla.
    ORG $be00
 
tabla_vectores_isr:
    DS 257, 0

Y después usar I = $be, para que la tabla de vectores de interrupción vaya desde $be00 hasta $beFF+1.

En este caso, ubicaremos la dirección de nuestra ISR en $beff y $beff+1.

    ld hl, rutina_ISR
    ld ($beff), hl

2. Antes de cambiar a im 2, creamos la tabla de vectores de interrupción (o escribimos el valor único para $ff) en todos los bancos que vayamos a utilizar durante el programa.

Para eso, creamos una rutina que sea “Generar_Tabla_Vectores”, que genere la tabla en $feNN o escriba el valor en $feFF, y hacemos lo siguiente al principio del programa, antes de cambiar a im 2:

  • Llamar a Generar_Tabla_Vectores (generará la tabla en la memoria “por defecto”)
  • Detectar si estamos en modo 128K, y si lo estamos:
    • Por cada banco de memoria paginada que queramos usar:
      • Cambiar de banco al banco de memoria extra que vayamos a usar
      • Llamar a Generar_Tabla_Vectores (generará de nuevo la tabla, pero en el nuevo banco)
    • Cambiar de banco al banco original del programa.

De esta forma, tenemos la tabla de vectores de interrupción en todos los bancos, y da igual cuál esté mapeado, las interrupciones serán capaces de encontrar nuestra ISR en todos ellos, y el código será básicamente el mismo tanto para 48K como para 128K.


Lo mismo ocurre con la dirección de nuestra ISR con el “truco” de poner el mismo byte en todas las direcciones de la tabla de vectores. El valor $f1, es decir, la dirección $f1F1, está en la parte alta de la memoria y por tanto no podemos utilizarla para ubicar la ISR si vamos a utilizar paginación 128KB.

Necesitaremos buscar un hueco en nuestro programa, para colocar un ORG adecuado delante de la ISR, como por ejemplo $a1a1 (41377), o cualquier otra dirección donde el byte alto y el bajo coincidan, y que nos venga bien según el mapa de memoria de nuestro juego.

Por citar ejemplos de direcciones, podemos ver las obtenidas en el artículo “Disassemble Interrupt Mode on some popular ZX Spectrum 128k Games”, de Andy Dansby, quien estudió la ubicación de la rutina de ISR en diferentes juegos de Spectrum comerciales:


Juego Ubicación tabla vectores Ubicación ISR Tipo de ISR (rutina o
salto a rutina real)
Notas
La Familia Addams (The Addams Family) $b900-$ba00 $5b5b ISR es jp $ba6e -
Where Time Stood Still $8400-$8500 $bebe Rutina ISR -
Demo 7th Reality $6300-$6400 $6464 ISR es jp $624d -
La Abadia Del Crimen $be00-$bf00 $bfbf Rutina ISR -
Chase HQ 2 $9b00-$9c00 $fdfd ISR es jp $ba6e ISR en bloque paginable
Grand Prix Circuit $8200-$8300 $6363 ISR es jp $a07a -
Robocop 2 $7700-$7800 $5b5b ISR es jp $9cb4 -
Robocop 3 $7700-$7800 $7676 ISR es jp $8225 -
Spacegun $be00-$bf00 $bfbf ISR es jp $a07a -
Desafio Total (Total Recall) $9100-$9200 $5d5d ISR es jp $71ff -
Carrier Command $8300-$8400 $8585 Rutina ISR -
El Gran Halcón (Hudson Hawk) $9000-$8100 $8181 Rutina ISR -
NARC $be00-$bf00 $bfbf ISR es jp $de38 -
Navy Seals $9100-$9200 $5d5d ISR es jp $6dfd -
Pang $8000-$8101 $8181 ISR es jp $6286 -


Varias curiosidades:

  • Como se puede ver, no hay un estándar sobre dónde ubicar la tabla de vectores de interrupción, ni la ISR, es una cuestión de elegir las direcciones deseadas según diferentes criterios (como el mapa de memoria de nuestro programa).
  • Sólo 2 juegos de la lista (Grand Prix Circuit y Pang) tienen una tabla de 257 bytes, teniendo el resto de juegos una tabla de 1 página (256 bytes), con lo que técnicamente, había una posibilidad “baja” de que el juego se colgase al arrancar si IM2 cogía $FF y $FF+1. Estos dos juegos implementan IM2 correctamente técnicamente hablando.
  • Sólo 4 juegos de la lista implementan la rutina directamente en la dirección apuntada por el vector de interrupciones, mientras que el resto simplemente tienen un JP a la rutina de ISR real.


Como ya hemos comentado, la ULA y el procesador compiten en uso por la zona de memoria comprendida entre los 16K y los 32K, por lo que es crítico ubicar el vector de interrupciones en un banco de memoria por encima de los 32K (típicamente, en $fe00).

Lamentablemente, en los modelos de 16KB de memoria sólo tenemos disponible la famosa página de 16KB entre $4000 y $7fff que se ve afectada por las lecturas de memoria de la ULA.

Aunque no es habitual diseñar programas para los modelos de Spectrum de 16KB, Miguel A. Rodríguez Jódar nos aporta una solución basada en apuntar el registro I a la ROM de tal forma que (I*256)+$ff proporcione un valor de la ROM cuyo contenido sea una dirección de memoria física en RAM disponible para ubicar nuestra ISR. Para poder realizar este pequeño truco es importante saber que el valor del bus de datos durante la interrupción será $ff, es decir, saber que no hay dispositivos mal diseñados conectados al bus de expansión que puedan alterar el valor del bus de datos:

Citando a Miguel A.:


La idea es poner un valor a I que esté comprendido entre 00h y 3Fh. Esto, claro está, hace que I apunte a la ROM, y que por tanto la dirección final de salto tenga que estar en la ROM. ¿Y esto plantea alguna dificultad? No, si encontramos algún valor de I tal que la posición I*256+255 contenga un valor de 16 bits (la dirección final de la ISR) que esté entre 4000h y 7FFFh.

El siguiente programa en BASIC escanea la ROM probando todas las combinaciones de I posibles entre 0 y 63, y muestra en pantalla la dirección en la que debería comenzar la ISR para los valores válidos que encuentre:

 Búsqueda de direcciones en la ROM

El resultado, con la ROM estándar del Spectrum 16/48K, es éste:

 Búsqueda de direcciones en la ROM

Así, por ejemplo, se puede escribir una rutina im 2 para un programa de 16K, así:

    ld i, 40
    im 2
    ... resto del programa...
 
    ORG 32348
    ...aqui va la ISR...

Aunque se use este sistema, hay aún un “peligro” oculto, aunque considero que es de menor relevancia, al menos hoy día. Es el hecho de que los periféricos “copiones” tipo Transtape y similares, usados en un Spectrum real para crear un snapshot, no pueden saber si el micro está en modo im1 o im2. Pueden saber si las interrupciones estaban habilitadas o no consultando el valor del flip-flop IFF2 al que se accede mediante el bit de paridad del registro F tras ejecutar una instrucción ld a,i ó ld a,r , pero no se puede saber directamente si el micro está en modo im 0, im1 ó im 2.

En un emulador que cree snapshosts esto no es un problema, pero en un Spectrum real sí. Los “copiones” usan un método puramente heurístico que consiste en ver el valor de I: si dicho valor es mayor que 63, entonces asumen que el programa lo ha modificado con la intención de usar su propia ISR, por lo que graban el snapshot indicando que el micro estaba en modo im 2. En cualquier otro caso, asumen im 1.

Habría que ver el código de la ROM de los copiones más populares para ver qué condición chequean, ya que la otra opción es que asuman im 1 sólamente cuando I valga 63 (el valor que la ROM pone por defecto) y asuman im 2 en cualquier otro caso. Si es así como lo hacen, un snapshot de 16K con interrupciones se generará con la información correcta.



Estando conectados los puertos de teclado y de unidad de cassette y disco a la ULA, el lector podría preguntarse por qué las pulsaciones de teclado o la entrada de datos desde estos dispositivos de almacenamiento no se gestionan mediante interrupciones.

Lamentablemente, en el caso del ZX Spectrum no se gestiona la pulsación de teclas ni la I/O del cassette mediante interrupciones, sino que la ULA proporciona al procesador acceso al estado de estos componentes directamente mediante operaciones de I/O (instrucciones Z80 IN/OUT).

Las instrucciones de acceso al cassette, por ejemplo, requieren tanta cantidad de tiempo de ejecución del procesador que no sería factible tratarlo en una ISR de interrupción, sobre todo en un microprocesador con la “escasa” potencia del Z80 y a 3.50 Mhz.


Resulta especialmente curioso el motivo por el cual las interrupciones NMI son generalmente de nula utilidad en el Spectrum salvo para provocar un reset. Como ya hemos dicho, al recibirse una NMI se realiza un salto a $0066 donde hay un bloque de código “ISR” especial de la ROM el cual, en teoría, estaba preparado para permitir la ejecución de una subrutina propia cuya dirección ubicaríamos en la dirección de memoria $5cb0 (NMIADD), a menos que el contenido de $5cb0 fuera 0, que provocaría un retorno de la NMI.

Por desgracia, un bug en esta subrutina acabó dejándola inservible salvo en el caso de ubicar un cero en esta variable del sistema, con RESET como única consecuencia. Como puede verse, en lugar de JR Z para saltar si $5cb0 valía 0, el programador escribió JR NZ lo cual produce que si NMIADD es distinta de 0, se salte a NO-RESET (y no se haga nada), y si NMIADD es 0, se salte con jp (hl) a su contenido, que al ser 0 produce un reset.

La instrucción jr nz, $0070 debería haber sido un jr z, $0070“ para permitir un jp ($5cb0) al recibir una NMI.

; THE 'NON-MASKABLE INTERRUPT' ROUTINE
; This routine is not used in the standard Spectrum but the code
; allows for a system reset to occur following activation of the
; NMI line. The system variable at 5CB0, named here NMIADD, has
; to have the value zero for the reset to occur.
0066 RESET:       push  af                  ; Save the current values held
                  push  hl                  ; in these registers.
                  ld    hl,($5cb0)          ; The two bytes of NMIADD
                  ld    a,h                 ; must both be zero for the reset
                  or    l                   ; to occur.
                  jr    nz,$0070            ; Note: This should have been jr Z!
                  jp    (hl)                ; Jump to START.
0070 NO-RESET     pop   hl                  ; Restore the current values to
                  pop   af                  ; these registers and return.
                  retn

Este bug estaba presente en la primera versión de la ROM del Spectrum y no fue corregido en futuras versiones, entendemos que para preservar la compatibilidad hacia atrás.

Los dispositivos tipo DivIDE que tienen un botón NMI, lo que hacen es “parchear” el opcode de esta instrucción al vuelo (cuando el Z80 pide el byte de dicha dirección de memoria para decodificarlo y ejecutarlo) y así poder disponer de interrupciones NMI (que estos dispositivos usan para determinadas tareas, como por ejemplo mostrar el Navegador de Ficheros para elegir un TAP/SNA/Z80 para su carga).


  • cursos/ensamblador/interrupciones.txt
  • Última modificación: 21-01-2024 11:23
  • por sromero