cursos:ensamblador:lenguaje_1

Lenguaje Ensamblador del Z80 (I)

Arquitectura del Z80 e Instrucciones básicas

Empezaremos nuestro periplo por el ensamblador del Z80 viendo en detalle los registros del procesador: qué registros hay disponibles, cómo se agrupan, y el registro especial de Flags, enlazando el uso de estos registros con las instrucciones de carga, de operaciones aritméticas, y de manejo de bits, que serán las que trataremos hoy.

El lenguaje ensamblador tiene disponibles muchas instrucciones diferentes, y resultaría imposible explicarlas todas en un mismo capítulo, lo que nos fuerza a explicar las instrucciones del microprocesador en varias entregas.

Comencemos con los conceptos más importantes y las instrucciones más básicas, como las instrucciones de carga, incremento, decremento, operaciones matemáticas básicas e instrucciones de intercambio.


Como ya vimos en el capítulo dedicado a la Arquitectura del Spectrum y del Z80, todo el “trabajo de campo” lo haremos con los registros de la CPU, que no son más que variables de 8 y 16 bits integradas dentro del Z80 y que por tanto son muy rápidos para realizar operaciones con ellos.

El Z80 tiene una serie de registros de 8 bits con nombres específicos:

  • A: El Registro A (de 8 bits) es el acumulador. Es un registro que se utiliza generalmente como destino de muchas operaciones aritméticas y de comparaciones y testeos.
  • B, C, D, E, H, L: Registros de propósito general, utilizables para gran cantidad de operaciones, almacenamiento de valores, etc.
  • I: Registro de interrupción, no lo utilizaremos en nuestros primeros programas. No debemos modificar su valor, aunque en el futuro veremos su uso en las interrupciones del Spectrum.
  • R: Registro de Refresco de memoria: lo utiliza internamente la CPU para saber cuándo debe refrescar la RAM. Su valor cambia sólo conforme el Z80 va ejecutando instrucciones, de modo que podemos utilizarlo (leerlo) para obtener valores pseudo-aleatorios entre 0 y 127 (el Z80 no cambia el bit de mayor peso de R, sólo los bits del 0 al 6).


Además, podemos agrupar algunos de estos registros en pares de 16 bits para determinadas operaciones:

  • AF: Formado por el registro A como byte más significativo (Byte alto) y por F como byte menos significativo (Byte bajo). Si A vale $ff y F vale $00, AF valdrá automáticamente $ff00.
  • BC: Agrupación de los registros B y C que se puede utilizar en bucles y para acceder a puertos. También se utiliza como “repetidor” o “contador” en las operaciones de acceso a memoria (LDIR, LDDR, etc.).
  • DE, HL: Registros de 16 bits formados por D y E por un lado y H y L por otro. Utilizaremos generalmente estos registros para leer y escribir en memoria en una operación única, así como para las operaciones de acceso a memoria como LDIR, LDDR, etc.


Aparte de estos registros, existen otra serie de registros de 16 bits:

  • IX, IY: Dos registros de 16 bits pensados para acceder a memoria de forma indexada. Gracias a estos registros podemos realizar operaciones como: LD (IX+desplazamiento), VALOR. Este tipo de registros se suele utilizar pues para hacer de índices dentro de tablas o vectores. El desplazamiento es un valor numérico de 8 bits en complemento a 2, lo que nos permite un rango desde -128 a +127 (puede ser negativo para acceder a posiciones de memoria anteriores a IX).
  • SP: Puntero de pila, como veremos en su momento apunta a la posición actual de la “cabeza” de la pila.
  • PC: Program Counter o Contador de Programa. Como ya vimos en la anterior entrega, contiene la dirección de la instrucción actual a ejecutar. No modificaremos PC directamente moviendo valores a este registro, sino que lo haremos mediante instrucciones de salto (JP, JR, CALL…).


Por último, tenemos disponible un banco alternativo de registros, conocidos como Shadow Registers o Registros Alternativos, que se llaman igual que sus equivalentes principales pero con una comilla simple detrás: A', F', B', C', D'. E', H' y L'.

En cualquier momento podemos intercambiar el valor de los registros A, B, C, D, E, F, H y L con el valor de los registros A', B', C', D', E', F', H' y L' mediante las instrucciones de ensamblador EX AF, AF' y exx. La utilidad de estos Shadow Registers es almacenar valores temporales y proporcionarnos más registros para operar: podremos intercambiar el valor de los registros actuales con los temporales, realizar operaciones con los registros sin perder los valores originales (que al hacer el intercambio se quedarán en los registros Shadow), y después recuperar los valores originales volviendo a ejecutar un intercambio.

Ya conocemos los registros disponibles, veamos ahora ejemplos de operaciones típicas que podemos realizar con ellos:

  • Meter valores en registros (ya sean valores numéricos directos, de memoria, o de otros registros).
  • Incrementar o decrementar los valores de los registros.
  • Realizar operaciones (tanto aritméticas como lógicas) entre los registros.
  • Acceder a memoria para escribir o leer.

Por ejemplo, las siguientes instrucciones en ensamblador serían válidas:

    ld c, $00            ; C vale 0
    ld b, $01            ; B vale 1
                         ; con esto, BC = $0100
    ld a, b              ; A ahora vale 1
    ld hl, $1234         ; HL vale $1234 o 4660d
    ld a, (hl)           ; A contiene el valor de (4660)
    ld a, (16384)        ; A contiene el valor de (16384)
    ld (16385), a        ; Escribimos en (16385) el valor de A
    add a, b             ; Suma: A = A + B
    inc b                ; Incrementamos B (B = 1+1 =2)
                         ; Ahora BC vale $0200
    inc  bc              ; Incrementamos BC
                         ; (BC = $0200+1 = $0201)

Dentro del ejemplo anterior queremos destacar el operador “()”, que significa “el contenido de la memoria apuntado por”. Así, ld a, (16384) no quiere decir “mete en A el valor 16384” (cosa que además no se puede hacer porque A es un registro de 8 bits), sino “mete en A el valor de 8 bits que contiene la celdilla de memoria 16384” (equivalente a utilizar en BASIC las funciones PEEK y POKE, como en LET A=PEEK 16384).

Cabe destacar un gran inconveniente del juego de instrucciones del Z80, y es que no es ortogonal. Se dice que el juego de instrucciones de un microprocesador es ortogonal cuando puedes realizar todas las operaciones sobre todos los registros, sin presentar excepciones. En el caso del Z80 no es así, ya que hay determinadas operaciones que podremos realizar sobre unos registros pero no sobre otros.

Así, si el Z80 fuera ortogonal, podríamos ejecutar cualquiera de estas operaciones:

    ld bc, $1234
    ld hl, bc
    ld sp, bc
    ex de, hl
    ex bc, de
    add hl, bc
    add de, bc

Sin embargo, como el Z80 no tiene un juego de instrucciones (J.I. desde este momento) ortogonal, hay instrucciones del ejemplo anterior que no son válidas, es decir, que no tienen dentro de la CPU un microcódigo para que el Z80 sepa qué hacer con ellas:

   ld sp, bc      ; NO: No se puede cargar el valor un registro en SP,
                  ; sólo se puede cargar un valor inmediato NN
 
   ex bc, de      ; NO: Existe ex de, hl, pero no EX BC, DE
 
   add de, bc     ; NO: Sólo se puede usar HL como operando destino
                  ; en las sumas de 16 bytes con registros de propósito
                  ; general. Una alternativa sería:
                  ;
                  ; ld hl, 0        ; HL = 0
                  ; add hl, bc      ; HL = HL + BC
                  ; ex de, hl       ; Intercambiamos el valor de HL y DE
 
   ld bc, de      ; NO:, pero se pueden tomar alternativas, como por ej:
                  ;
                  ; push de
                  ; pop bc
                  :
                  ; o también:
                  ;
                  ; ld b, d
                  ; ld c, e
 
   ld de, hl      ; NO: mismo caso anterior.
 
   ld sp, bc      ; NO: no existe como instrucción.

La única solución para programar sin tratar de utilizar instrucciones no permitidas es la práctica: con ella acabaremos conociendo qué operaciones podemos realizar y sobre qué registros se pueden aplicar, y realizaremos nuestros programas con estas limitaciones en mente. Iremos viendo las diferentes excepciones caso a caso, pero podemos encontrar las nuestras propias gracias a los errores que nos dará el ensamblador al intentar ensamblar un programa con una instrucción que no existe para el Z80.

No os preocupéis: es sólo una cuestión de práctica. Tras haber realizado varios programas en ensamblador ya conoceréis, prácticamente de memoria, qué instrucciones son válidas para el microprocesador y cuáles no.


Aunque IY es un registro de 16 bits como los demás, en el caso del Spectrum no está disponible para su uso a menos que se cumplan ciertas condiciones.

La ROM del Spectrum (el “sistema operativo” que ejecuta BASIC) realiza ciertas tareas 50 veces por segundo. Estas tareas son transparentes por nuestro programa, ya que el Spectrum “genera” lo que se conocen como “interrupciones” de forma periódica.

Estas “interrupciones” detienen el programa durante un brevísimo período de tiempo para realizar tareas como leer el teclado y actualizar ciertas variables del sistema cuyos valores residen en memoria. El acceso a esas variables del sistema se realiza con el registro IY, que la ROM del Spectrum espera que no sea modificado por nadie ya que lo usa como un “puntero base” para acceder a las variables, referenciándolas no por su dirección de memoria sino por (IY+$30), por ejemplo.

Debido a esto, no debemos modificar el registro IY, a menos que cumplamos estas 2 condiciones:

  1. Cambiamos el modo de funcionamiento del Spectrum de im1 (su modo por defecto) a im2 (un modo “personalizado”).
  2. No llamamos ni utilizamos ninguna rutina de la ROM para ninguna tarea.

Ambas cosas son el procedimiento habitual en un juego o programa, por lo que podremos utilizar el registro IY en nuestros programas, pero no inicialmente. A lo largo del curso no lo usaremos ya que vamos a utilizar rutinas de la ROM de apoyo para la mayoría de nuestros ejemplos.


Hemos hablado del registro de 8 bits F como un registro especial. La particularidad de F es que no es un registro de propósito general donde podamos introducir valores a voluntad, sino que los diferentes bits del registro F tienen un significado propio que cambia automáticamente según el resultado de operaciones anteriores.

Por ejemplo, uno de los bits del registro F, el bit nº 6, es conocido como “Zero Flag”, y nos indica si el resultado de la última operación (para determinadas operaciones, como las aritméticas o las de comparación) es cero o no es cero. Si el resultado de la anterior operación resultó cero, este FLAG se pone a uno. Si no resultó cero, el flag se pone a cero.

¿Para qué sirve pues un flag así? Para gran cantidad de tareas, por ejemplo para bucles (repetir X veces una misma tarea poniendo el registro BC al valor X, ejecutando el mismo código hasta que BC sea cero), o para comparaciones (mayor que, menor que, igual que).

Veamos los diferentes registros de flags (bits del registro F) y su utilidad:


Los indicadores de flag del registro F


  • Flag S (sign o signo): Este flag se pone a uno si el resultado de la operación realizada en complemento a dos es negativo (es una copia del bit más significativo del resultado). Si por ejemplo realizamos una suma entre 2 números en complemento a dos y el resultado es negativo, este bit se pondrá a uno. Si el resultado es positivo, se pondrá a cero. Es útil para realizar operaciones matemáticas entre múltiples registros: por ejemplo, si nos hacemos una rutina de multiplicación o división de números que permita números negativos, este bit nos puede ser útil en alguna parte de la rutina.
  • Flag Z (zero o cero): Este flag se pone a uno si el resultado de la última operación que afecte a los flags es cero. Por ejemplo, si realizamos una operación matemática y el resultado es cero, se pondrá a uno. Este flag es uno de los más útiles, ya que podemos utilizarlo para múltiples tareas. La primera es para los bucles, ya que podremos programar código como:
            ; Repetir algo 100 veces
            ld b, 100
    bucle:
            (...)              ; código
 
            dec b              ; Decrementamos B (B=B-1)
            jr nz, bucle
 
            ; Si el resultado de la operación anterior no es cero (NZ = Non Zero),
            ; saltar a la etiqueta bucle y continuar. dec b hará que el flag Z
            ; se ponga a 1 cuando B llegue a cero, lo que afectará al jr NZ.
            ; Como resultado, este trozo de código (...) se ejecutará 100 veces.

Como veremos en su momento, existe una instrucción equivalente a dec b + JR NZ que es más cómoda de utilizar y más rápida que estas 2 instrucciones juntas (DJNZ), pero se ha elegido el ejemplo que tenéis arriba para que veáis cómo muchas operaciones (en este caso DEC) afectan a los flags, y la utilidad que estos tienen a la hora de programar.

Además de para bucles, también podemos utilizarlo para comparaciones. Supongamos que queremos hacer en ensamblador una comparación de igualdad, algo como:

   IF C = B THEN GOTO 1000
   ELSE          GOTO 2000

Si restamos C y B y el resultado es cero, es que ambos registros contienen el mismo valor:

     ld a, c                   ; A = C
     ; Tenemos que hacer esto porque no existe
     ; una instruccion sub b, c . Sólo se puede
     ; restar un registro al registro A.
 
     sub b                     ; A = A-B
     jp z, Es_Igual            ; Si A=B la resta es cero y Z=1
     jp nz, No_Es_Igual        ; Si A<>B la resta no es cero y Z=0
     (...)
 
    Es_Igual:
     (...)
    No_Es_Igual:
     (...)

Existe una instrucción específica para realizar comparaciones: CP, que es similar a SUB pero que no altera el valor de A. Hablaremos de CP con más detalle en su momento.

  • Flag H (Half-carry o Acarreo-BCD): Se pone a uno cuando en operaciones BCD existe un acarreo del bit 3 al bit 4.
  • Flag P/V (Parity/Overflow o Paridad/Desbordamiento): En las operaciones que modifican el bit de paridad, este bit vale 1 si el número de unos del resultado de la operación es par, y 0 si es impar. Si, por contra, el resultado de la operación realizada necesita más bits para ser representado de los que nos provee el registro, tendremos un desbordamiento, con este flag a 1. Este mismo bit sirve pues para 2 tareas, y nos indicará una u otra (paridad o desbordamiento) según sea el tipo de operación que hayamos realizado. Por ejemplo, tras una suma, su utilidad será la de indicar el desbordamiento.
    El flag de desbordamiento se activará cuando en determinadas operaciones pasemos de valores 11111111b a 00000000b, por “falta de bits” para representar el resultado o viceversa . Por ejemplo, en el caso de INC y DEC con registros de 8 bits, si pasamos de 0 a 255 o de 255 a 0.
  • Flag N (Substract o Resta): Se pone a 1 si la última operación realizada fue una resta. Se utiliza en operaciones aritméticas.
  • Flag C (Carry o Acarreo): Este flag se pone a uno si el resultado de la operación anterior no cupo en el registro y necesita un bit extra para ser representado. Este bit es ese bit extra. Veremos su uso cuando tratemos las operaciones aritméticas, en esta misma entrega.

Así pues, resumiendo:

  • El registro F es un registro cuyo valor no manejamos directamente, sino que cada uno de sus bits tiene un valor especial y está a 1 o a 0 según ciertas condiciones de la última operación realizada que afecte a dicho registro.
  • Por ejemplo, si realizamos una operación y el resultado de la misma es cero, se pondrá a 1 el flag de Zero (Z) del registro F, que no es más que su bit número 6.
  • No todas las operaciones afectan a los flags, iremos viendo qué operaciones afectan a qué flags conforme avancemos en el curso, en el momento en que se estudia cada instrucción.
  • Existen operaciones que se pueden ejecutar con el estado de los flags como condición. Por ejemplo, realizar un salto a una dirección de memoria si un determinado flag está activo, o si no lo está.


Las operaciones que más utilizaremos en nuestros programas en ensamblador serán sin duda las operaciones de carga o instrucciones LD. Estas operaciones sirven para:

  • Meter un valor en un registro.
  • Copiar el valor de un registro en otro registro.
  • Escribir en memoria (en una dirección determinada) un valor.
  • Escribir en memoria (en una dirección determinada) el contenido de un registro.
  • Asignarle a un registro el contenido de una dirección de memoria.

La sintaxis de LD en lenguaje ensamblador es:

ld DESTINO, ORIGEN

Así, gracias a las operaciones LD podemos:

  • Asignar a un registro un valor numérico directo de 8 o 16 bits.
ld a, 10              ; A = 10
ld b, 200             ; B = 200
ld bc, 12345          ; BC = 12345
  • Copiar el contenido de un registro a otro registro:
ld a, b               ; A = B
ld bc, de             ; BC = DE
  • Escribir en posiciones de memoria:
ld (12345), a         ; Memoria[12345] = valor en A
ld (hl), 10           ; Memoria[valor de HL] = 10
  • Leer el contenido de posiciones de memoria:
ld a, (12345)         ; A = valor en Memoria[12345]
ld b, (hl)            ; B = valor en Memoria[valor de HL]

Nótese cómo el operador () nos permite acceder a la memoria del Spectrum. En nuestros ejemplos, ld a, (12345) no significa meter en A el valor 12345 (cosa imposible al ser un registro de 16 bits) sino almacenar en el registro A el valor que hay almacenado en la celdilla número 12345 de la memoria del Spectrum.

Este operador indica que se hace referencia a una posición de memoria referenciada por el valor que hay dentro de los paréntesis. Dicho valor referencia a una celdilla de memoria de 8 bits.

Es decir, si escribiéramos en BASIC del Spectrum (con PEEK y POKE) las instrucciones de carga de 8 bits que referencian a la memoria, veríamos lo siguiente:

ld a, (16384)     =>    LET A = PEEK 16384
ld (16384), a     =>    POKE 16384, a
 
ld hl, 16384      =>    HL = 16384
ld a, (hl)        =>    LET A = PEEK HL  =>    LET A = PEEK 16384
ld (hl), a        =>    POKE HL, a       =>    POKE (16384), a

En el segundo ejemplo hemos utilizado ld hl, 16384, que significa “carga en HL el valor 16384”. Como no hay paréntesis en la instrucción, no estamos haciendo una referencia a memoria sino al valor inmediato 16384, el cual metemos en HL. Después, al utilizar los paréntesis en ld a, (hl), sí que hacemos una referencia a memoria, con la dirección absoluta contenida en HL.

No sólo podemos leer de o escribir en una dirección de memoria valores de 8 bits, también podemos leer y escribir valores de 16 bits. Evidentemente, como la memoria es un conjunto de “celdillas de 8 bits”, para leer o escribir valores de 16 bits lo haremos en 2 celdillas: la celdilla apuntada por la dirección, y la siguiente.

De nuevo, viéndolo “en instrucciones BASIC”, podemos ver la diferencia entre asignar un valor de 16 bits inmediato, o referenciar a una posición de memoria para leer 16 bits:

ld hl, 16384      =>    HL = 16384
 
ld hl, (16384)    =>    HL = (PEEK 16384) + 256*(PEEK 16385)
                           => L = PEEK 16384
                              H = PEEK 16385

En ld hl, (16384), metemos en HL el dato de 16 bits en 16384 y 16385, un valor de 8 bits para cada uno de los 2 registrow de 8 bits de HL (concretamente, H será el valor contenido en 16385 y L el valor en 16384, posteriormente veremos por qué se leen en orden inverso).

De la misma forma, si hablamos de escribir en memoria un valor de 16 bits:

ld (16384), hl    =>    POKE 16384, L
                        POKE 16385, H

En un microprocesador con un juego de instrucciones ortogonal, se podría usar cualquier origen y cualquier destino sin distinción. En el caso del Z80 no es así. El listado completo de operaciones válidas con LD es el siguiente:

Leyenda:

 N  = valor numérico directo de 8 bits (0-255)
 NN = valor numérico directo de 16 bits (0-65535)
 r  = registro de 8 bits (A, B, C, D, E, H, L)
 rr = registro de 16 bits (BC, DE, HL, SP)
 ri = registro índice (IX o IY).
 d  = desplazamiento respecto a un registro índice.

Listado:

; Carga de valores en registros
ld r, N
ld rr, NN
ld ri, NN
 
; Copia de un registro a otro
ld r, r
ld rr, rr
 
; Acceso a memoria
ld r, (hl)
ld (NN), a
ld (hl), N
ld a, (rr)      ; (excepto rr=SP)
ld (rr), a      ; (excepto rr=SP)
ld a, (NN)
ld rr, (NN)
ld ri, (NN)
ld (NN), rr
ld (NN), ri
 
; Acceso indexado a memoria
ld (ri+N), r
ld r, (ri+N)
ld (ri+N), N

Además, tenemos una serie de casos “especiales”:

; Manipulación del puntero de pila (SP)
ld sp, ri
ld sp, hl
 
; Para manipular el registro I
ld a, i
ld i, a
 
; Para manipular el registro R
ld a, r
ld r, a

Veamos ejemplos válidos y cuál sería el resultado de su ejecución:

; Carga de valores en registros
; registro_destino = valor
ld a, 100               ; ld r, N
ld bc, 12345            ; ld rr, NN
 
; Copia de registros en registros
; registro_destino = registro_origen
ld b, c                 ; ld r, r
ld a, b                 ; ld r, r
ld bc, de               ; ld rr, rr
 
; Acceso a memoria
; (Posicion_memoria) = VALOR o bien
;  Registro = VALOR en (Posicion de memoria)
ld a, (hl)              ; ld r, (rr)
ld (bc), a              ; ld (rr), r
ld (12345), a           ; ld (NN), a
ld a, (hl)              ; ld r, (rr)
ld (de), a              ; ld (rr), r
ld (bc), 1234h          ; ld (bc), NN
ld (12345), de          ; ld (NN), rr
ld ix, (12345)          ; LD ri, (NN)
ld (34567), iy          ; ld (NN), ri
 
; Acceso indexado a memoria
; (Posicion_memoria) = VALOR o VALOR = (Posicion_memoria)
; Donde la posicion es IX+N o IY+N:
ld (ix+10), a           ; LD (ri+N), r
ld a, (iy+100)          ; ld r, (ri+N)
ld (ix-30), 100         ; LD (ri+N), N

Hagamos hincapié de nuevo en el mismo detalle: debido a que el juego de instrucciones del Z80 no es ortogonal, en ocasiones no podemos ejecutar ciertas operaciones que podrían sernos útiles con determinados registros. En ese caso tendremos que buscar una solución mediante los registros y operaciones válidas de que disponemos.

Un detalle muy importante respecto a las instrucciones de carga: en el caso de las operaciones LD, el registro F no ve afectado ninguno de sus indicadores o flags en relación al resultado de la ejecución de las mismas (salvo en el caso de ld a, i y ld a, r).

                         Flags
   Instrucción       |S Z H P N C|
 ---------------------------------
   ld r, r           |- - - - - -|
   ld r, N           |- - - - - -|
   ld rr, rr         |- - - - - -|
   ld (rr), n        |- - - - - -|
   ld (rr), n        |- - - - - -|
   ld ri, (NN)       |- - - - - -|
   ld (NN), ri       |- - - - - -|
   ld (ri+d), N      |- - - - - -|
   ld (ri+d), r      |- - - - - -|
   ld r, (ri+d)      |- - - - - -|
   ld a, i           |* * 0 * 1 0|
   ld a, r           |* * 0 * 1 0|


Esto quiere decir, y es muy importante, que una operación como ld a, 0, por ejemplo, no activará el flag de Zero del registro F.


Hay otros dos datos que, como la afectación de flags, son muy importantes sobre las diferentes instrucciones que iremos viendo.

Uno es el tamaño en bytes de cada instrucción, que viene determinado por los opcodes que ocupa. Así, un ld a, b ocupa un sólo byte ($78), ld a, $ff ocupa 2 bytes (al opcode $3E le sigue el operando $ff) y ld bc, $1234 ocupa 3 bytes (al opcode 01 le siguen los 2 bytes de $1234).

Del mismo modo, tenemos el tiempo que tarda en ejecutarse cada una de ellas, lo que se conoce como el número de ciclos (o t-estados / t-states). Este es el tiempo que tarda el procesador Z80 en leer de memoria, decodificar y ejecutar cada instrucción. Cada lectura de byte de memoria requiere en general de 3 t-estados extra, así que no lo es lo mismo una instrucción sencilla de un sólo byte de opcode como ld a, b que una con 3 bytes como ld bc, $1234.

En nuestro ejemplo anterior, el ld a, b de un sólo byte se ejecuta en 4 ciclos de reloj (3 para leer de memoria el opcode y 1 para ejecutarlo), el ld a, $ff son 7 ciclos de reloj o t-estados (los 3 de leer de memoria el primer byte, 3 de leer el segundo byte, y uno más para la ejecución) y ld bc, $1234 que ocupa 3 bytes requiere 3+3+3+1 = 10 ciclos de reloj.

En estos capítulos iniciales del curso no nos deben de preocupar tanto lo que ocupan las instrucciones y cuánto tardan en ejecutarse como el saber qué instrucciones existen, la manera en que operan y cómo se utilizan. No obstante, sí que necesitaremos más adelante tener un buen conocimiento de tamaño y tiempo de ejecución de cada instrucción para desarrollar programas.

En el último capítulo dedicado a las diferentes instrucciones veremos una tabla donde se detallan todos los tamaños y tiempos de las diferentes instrucciones.

Un apunte sobre ld bc, $1234: Al respecto de escritura y lectura de valores de 16 bits utilizando instrucciones que trabajan con 8 bits, queremos recordar en este punto que el Z80 es una CPU LITTLE-ENDIAN por lo que los valores de 16 bits aparecerán en memoria “invertidos”, es decir, primero el byte menos significativo y en la celdilla siguiente el byte más significativo. Es decir, que el opcode correspondiente a ld bc, $1234 en memoria no es “$01 $12 $34” sino “$01 $34 $12”;


Entre las operaciones disponibles, tenemos la posibilidad de incrementar (INC) y decrementar (DEC) en 1 unidad el contenido de determinados registros de 8 y 16 bits, así como de posiciones de memoria apuntadas por HL o por IX/IY más un offset (desplazamiento de 8 bits).

Por ejemplo:

ld a, 0                ; A = 0
inc a                  ; A = A+1 = 1
ld b, a                ; B = A = 1
inc b                  ; B = B+1 = 2
inc b                  ; B = B+1 = 3
ld  bc, 0
inc bc                 ; BC = $0001
inc b                  ; BC = $0101 (ya que B=B+1 y es la parte alta)
dec a                  ; A = A-1 = 0

Veamos las operaciones INC y DEC permitidas:

inc r
dec r
inc rr
dec rr

Donde r puede ser A, B, C, D, E, H o L, y 'rr' puede ser BC, DE, HL, SP, IX o IY. Esta instrucción incrementa o decrementa el valor contenido en el registro especificado.

inc (hl)
dec (hl)

Incrementa o decrementa el byte que contiene la dirección de memoria apuntada por HL.

inc (ix+n)
dec (ix+n)
inc (iy+n)
dec (iy+n)

Incrementa o decrementa el byte que contiene la dirección de memoria resultante de sumar el valor del registro IX o el registro IY con un valor numérico de 8 bits en complemento a dos.

Por ejemplo, las siguientes instrucciones serían válidas:

inc a               ; A = A+1
dec b               ; B = B-1
inc de              ; DE = DE+1
dec ix              ; IX = IX-1
inc (hl)            ; (HL) = (HL)+1
inc (ix-5)          ; (IX-5) = (IX-5)+1
dec (iy+100)        ; (IY+100) = (IY+100)+1

Unos apuntes sobre la afectación de los flags ante el uso de INC y DEC:

  • Si un registro de 8 bits vale 255 ($ff) y lo incrementamos, pasará a valer 0.
  • Si un registro de 16 bits vale 65535 ($ffff) y lo incrementamos, pasará a valer 0.
  • Si un registro de 8 bits vale 0 y lo decrementamos, pasará a valer 255 ($ff).
  • Si un registro de 16 bits vale 0 ($0) y lo decrementamos, pasará a valer 65535 ($ff).
  • En estos desbordamientos no se tomará en cuenta para nada el bit de Carry (acarreo) de los flags (registro F), ni tampoco lo afectarán tras ejecutarse.
  • Las operaciones INC y DEC sobre registros de 16 bits (BC, DE, HL, IX, IY, SP) no afectan a los flags. Esto implica que no podemos usar como condición de flag zero para un salto el resultado de instrucciones como “dec bc”, por ejemplo.
  • Las operaciones INC y DEC sobre registros de 8 bits y sobre la memoria no afectan al flag de acarreo, pero sí que pueden afectar al flag de Zero (Z), al de Paridad/Overflow (P/V), al de Signo (S) y al de Half-Carry (H).

Lo siguiente que vamos a ver es una tabla de afectación de flags (que encontraremos en muchas tablas de instrucciones del Z80, y a las que conviene ir acostumbrandose). Esta tabla indica cómo afecta cada instrucción a cada uno de los flags:

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
   inc r             |* * * V 0 -|
   inc (hl)          |* * * V 0 -|
   inc (ri+N)        |* * * V 0 -|
   inc rr            |- - - - - -|
   dec r             |* * * V 1 -|
   dec rr            |- - - - - -|

Donde:

 r = registro de 8 bits
 rr = registro de 16 bits (BC, DE, HL, IX, IY)
 ri = registro índice (IX, IY)
 N = desplazamiento de 8 bits (entre -128 y +127).

Y respecto a los flags:

 - = El flag NO se ve afectado por la operación.
 * = El flag se ve afectado por la operación acorde al resultado.
 0 = El flag se pone a cero.
 1 = El flag se pone a uno.
 V = El flag se comporta como un flag de Overflow acorde al resultado.
 ? = El flag toma un valor indeterminado.


Las operaciones aritméticas básicas para nuestro Spectrum son la suma y la resta, tanto con acarreo como sin él. A partir de ellas deberemos crearnos nuestras propias rutinas para multiplicar, dividir, etc.


Nuestro microprocesador Z80 puede realizar sumas de 8 y 16 bits internamente. La instrucción utilizada para ello es ADD y el formato es:

add DESTINO, ORIGEN

Las instrucciones disponibles para realizar sumas se reducen a:

add a, s
add hl, ss
add ri, rr

Donde:

 s:  Cualquier registro de 8 bits (A, B, C, D, E, H, L),
     cualquier valor inmediato de 8 bits (en el rango 0-255 o -128+127
     en complemento a dos), cualquier dirección de memoria apuntada por
     HL, y cualquier dirección de memoria apuntada por un registro
     índice con desplazamiento de 8 bits.
 ss: Cualquier registro de 16 bits de entre los siguientes: BC, DE, HL, SP.
 ri: Uno de los 2 registros índices (IX o IY).
 rr: Cualquier registro de 16 bits de entre los siguientes excepto el mismo
     registro índice origen: BC, DE, HL, IX, IY, SP.

Esto daría la posibilidad de ejecutar cualquiera de las siguientes instrucciones:

; add a, s
add a, b             ; A = A + B
add a, 100           ; A = A + 100
add a, (hl)          ; A = A + (HL)
add a, (ix+10)       ; A = A + (IX+10)
 
; add hl, ss
add hl, bc           ; HL = HL + BC
add hl, sp           ; HL = HL + SP
 
; addri, rr
add ix, bc           ; IX = IX + BC
add iy, de           ; IY = IY + DE
add iy, ix           ; IY = IY + IX
add ix, iy           ; IX = IX + IY

Por contra, estas instrucciones no serían válidas:

add b, c             ; Sólo A puede ser destino
add bc, de           ; Sólo puede ser destino HL
add ix, ix           ; No podemos sumar un registro índice a él mismo

La afectación de los flags ante las operaciones de sumas es la siguiente:

  • Para add a, s, el registro N (Substraction) se pone a 0 (lógicamente, ya que sólo se pone a uno cuando se ha realizado una resta). El registro P/V se comporta como un registro de Overflow e indica si ha habido overflow (desbordamiento) en la operación. El resto de flags (Sign, Zero, Half-Carry y Carry) se verán afectados de acuerdo al resultado de la operación de suma.
  • Para add hl, ss y add ri, rr, se pone a 0 el flag N, y sólo se verá afectado el flag de acarreo (C) de acuerdo al resultado de la operación.

O, en forma de tabla de afectación:

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
 add a, s            |* * * V 0 *|
 add hl, ss          |- - ? - 0 *|
 add ri, rr          |- - ? - 0 *|

Las sumas realizadas por el Spectrum se hacen a nivel de bits, empezando por el bit de más a la derecha y yendo hacia la izquierda, según las siguientes reglas:

0 + 0 = 0
0 + 1 = 1
1 + 0 = 1
1 + 1 = 10 (=0 con acarreo)

Al sumar el último bit, se actualizará el flag de acarreo si es necesario.

Por ejemplo:

       *
   00000100
 + 00000101
  -----------
   00001001

  (* = acarreo de la suma del bit anterior, 1+1=10)

Si la suma del último bit (bit 7) requiere un bit extra, se utilizará el Carry Flag del registro F para almacenarlo. Supongamos que ejecutamos el siguiente código:

ld a, %10000000
ld b, %10000000
add a, b

El resultado de la ejecución de esta suma sería: A=128+128=256. Como 256 (100000000b) tiene 9 bits, no podemos representar el resultado con los 8 bits del registro A, de modo que el resultado de la suma sería realmente: A = 00000000 y CarryFlag = 1.


En el caso de las restas, sólo es posible realizar (de nuevo gracias a la no ortogonalidad del J.I. del Z80) la operación “A=A-origen”, donde “origen” puede ser cualquier registro de 8 bits, valor inmediato de 8 bits, contenido de la memoria apuntada por (HL), o contenido de la memoria apuntada por un registro índice más un desplazamiento. El formato de la instrucción SUB no requiere 2 operandos, ya que el registro destino sólo puede ser A:

sub ORIGEN

Concretamente:

sub r                ; A = A - r
sub N                ; A = A - N
sub (hl)             ; A = A - (HL)
sub (rr+d)           ; A = A - (rr+d)

Por ejemplo:

sub b           ; A = A - B
sub 100         ; A = A - 100
sub (hl)        ; A = A - (HL)
sub (ix+10)     ; A = A - (IX+10)

Es importante recordar que en una operación SUB X, la operación realizada es “A=A-X” y no “A=X-A”.

Por otra parte, con respecto a la afectación de flags, es la siguiente:

 Flags:      S Z H P N C
 -----------------------
 Afectación: * * * V 1 *

Es decir, el flag de N (substraction) se pone a 1, para indicar que hemos realizado una resta. El flag de P/V (Parity/Overflow) se convierte en indicar de Overflow y queda afectado por el resultado de la resta. El resto de flags (Sign, Zero, Half-Carry y Carry) quedarán afectados de acuerdo al resultado de la misma (por ejemplo, si el resultado es Cero, se activará el Flag Z).


Sumar con acarreo dos elementos (ADC) significa realizar la suma de uno con el otro y, posteriormente, sumarle el estado del flag de Carry. Es decir:

"adc a, s"    equivale a    "A = A + s + CarryFlag"
"adc hl, ss"  equivale a    "HL = HL + ss + CarryFlag"

(“s” y “ss” tienen el mismo significado que en ADD y SUB).

La tabla de afectación de flags sería la siguiente:

                        Flags
  Instrucción       |S Z H P N C|
 ----------------------------------
 adc a,s            |* * * V 0 *|
 adc hl,ss          |* * ? V 0 *|

La suma con acarreo se utiliza normalmente para sumar las partes altas de elementos de 16 bits. Se suma la parte baja con ADD y luego la parte alta con ADC para tener en cuenta el acarreo de la suma de la parte baja.


Al igual que en el caso de la suma con acarreo, podemos realizar restas con acarreo (SBC), que no son más que realizar una resta de los 2 operandos, tras lo cual restamos además el valor del bit de Carry Flag:

"sbc a, s"    equivale a    "A = A - s - CarryFlag"
"sbc hl, ss"  equivale a    "HL = HL - ss - CarryFlag"

La tabla de afectación de flags (en este caso con N=1, ya que es una resta):

                        Flags
  Instrucción       |S Z H P N C|
 ----------------------------------
 sbc a,s            |* * * V 1 *|
 sbc hl,ss          |* * ? V 1 *|


A lo largo del presente texto hemos hablado de números en complemento a dos. Complemento a dos es una manera de representar números negativos en nuestros registros de 8 bits, utilizando para ello como signo el bit más significativo (bit 7) del byte.

Si dicho bit está a 0, el número es positivo, y si está a 1 es negativo. Así:

 01111111    (+127)
 01111110    (+126)
 01111101    (+125)
 01111100    (+124)
 (...)
 00000100    (+4)
 00000011    (+3)
 00000010    (+2)
 00000001    (+1)
 00000000    (0)
 11111111    (-1)
 11111110    (-2)
 11111101    (-3)
 11111100    (-4)
 (...)
 10000011    (-125)
 10000010    (-126)
 10000001    (-127)
 10000000    (-128)

Podemos averiguar cuál es la versión negativa de cualquier número positivo (y viceversa), invirtiendo el estado de los bits y sumando uno:

 +17  = 00010001

 -17 =  11101110   (Invertimos unos y ceros)
     =        +1   (Sumamos 1)
     =  11101111   (-17 en complemento a dos)

Se eligió este sistema para representar los números negativos para que las operaciones matemáticas estándar funcionaran directamente sobre los números positivos y negativos. ¿Por qué no utilizamos directamente la inversión de los bits para representar los números negativos y estamos sumando además 1 para obtenerlos? Sencillo: si no sumáramos uno y simplemente invirtiéramos los bits, tendríamos 2 ceros (00000000 y 11111111) y además las operaciones matemáticas no cuadrarían (por culpa de los dos ceros). La gracia del complemento a dos es que las sumas y restas binarias lógicas (ADD, ADC, SUB y SBC) funcionan:

Sumemos -17 y 32:

  -17 = 11101111
+ +32 = 00100000
 -----------------
      1 00001111

El resultado es 00001111, es decir, 15, ya que 32-17=15. El flag de carry se pone a 1, pero lo podemos ignorar, porque el flag que nos indica realmente el desbordamiento (como veremos a continuación) en operaciones de complemento a dos es el flag de Overflow.

Sumemos ahora +17 y -17:

  +17 = 00010001
+ -17 = 11101111
 ----------------------
      1 00000000

Como podéis ver, al sumar +17 y -17 el resultado es 0. Si representáramos los números negativos simplemente como la inversa de los positivos, esto no se podría hacer:

  +17 = 00010001
+ -17 = 11101110    <--- (solo bits invertidos)
 ----------------------
      1 11111111    <--- Nos da todo unos, el "cero" alternativo.

En complemento a dos, las sumas y restas de números se pueden realizar a nivel lógico mediante las operaciones estándar del Z80. En realidad para el Z80 no hay más que simples operaciones de unos y ceros, y somos nosotros los que interpretamos la información de los operandos y del resultado de una forma que nos permite representar números negativos.

En otras palabras: cuando vemos un uno en el bit más significativo de un resultado, somos nosotros los que tenemos que interpretar si ese bit representa un signo negativo o no: si sabemos que estamos operando con números 0-255, podemos tratarlo como un resultado positivo. Si estábamos operando con números en complemento a dos, podemos tratarlo como un resultado en complemento a dos. Para el microprocesador, en cambio, no hay más que unos y ceros.

Para acabar, veamos cuál es la diferencia entre el Flag de Carry (C) y el de Overflow (V) a la hora de realizar sumas y restas. El primero (C) se activará cuando se produzca un desbordamiento físico a la hora de sumar o restar 2 números binarios (cuando necesitemos un bit extra para representarlo). El segundo (V), se utilizará cuando se produzca cualquier sobrepasamiento operando con 2 números en complemento a dos.

Como acabamos de ver, en complemento a dos el último bit (el bit 7) nos indica el signo, y cuando operamos con 2 números binarios que nosotros interpretamos como números en complemento a dos no nos basta con el bit de Carry. Es el bit de Overflow el que nos dará información sobre el desbordamiento a un nivel lógico.

El bit de Carry se activará si pasamos de 255 a 0 o de 0 a 255 (comportándose como un bit de valor 2 elevado a 8, o 256), y el bit de overflow lo hará si el resultado de una operación en complemento a dos requiere más de 7 bits para ser representado.

Mediante ejemplos:

255+1:

   11111111
+  00000001
  -----------
 1 00000000

   C=1 (porque hace falta un bit extra)
   V=0

127+1:

   01111111
+  00000001
  -----------
   10000000

   C=0 (no es necesario un bit extra en el registro)
   V=1 (en complemento a dos, no podemos representar +128)

En el ejemplo anterior, V se activa porque no ha habido desbordamiento físico (no es necesario un bit extra para representar la operación), pero sí lógico: no podemos representar +128 con 7 bits+signo en complemento a dos.


Como ya se ha explicado, disponemos de un banco de registros alternativos (los Shadow Registers), y podemos conmutar los valores entre los registros estándar y los alternativos mediante unas determinadas instrucciones del Z80.

El Z80 nos proporciona una serie de registros de propósito general (así como un registro de flags), de nombres A, B, C, D, E, F, H y L. El micro dispone también de unos registros extra (set alternativo conocido como Shadow Registers) de nombre A', B', C', D', E', F', H' y L', que aprovecharemos en cualquier momento de nuestro programa. No obstante, no podremos hacer uso directo de estos registros en instrucciones en ensamblador. No es posible, por ejemplo, usar ninguna de las siguientes instrucciones (porque no existen):

ld b', $10
inc a'
ld hl', $1234
ld a', ($1234)

La manera de utilizar estos registros alternativos es conmutar sus valores con los registros estándar mediante la instrucción EXX, cuyo resultado es el intercambio de B por B', C por C', D por D', E por E', H por H' y L por L'. Supongamos que tenemos los siguientes valores en los registros:

Registro Valor Registro Valor
B $a0 B' $00
C $55 C' $00
D $01 D' $00
E $ff E' $00
H $00 H' $00
L $31 L' $00

En el momento en que realicemos un EXX, los registros cambiarán de valor por la “conmutación” de bancos:

Registro Valor Registro Valor
B $00 B' $a0
C $00 C' $55
D $00 D' $01
E $00 E' $ff
H $00 H' $00
L $00 L' $31

Si realizamos de nuevo EXX, volveremos a dejar los valores de los registros en sus “posiciones” originales. EXX (mnemónico ensamblador derivado de EXchange), simplemente intercambia los valores entre ambos bancos.

Aparte de la instrucción EXX, disponemos de una instrucción ex af, af' , que, como el lector imagina, intercambia los valores de los registros AF y AF'. Así, pasaríamos de:

Registro Valor Registro Valor
A $01 A' $00
F $10 F' $00

a:

Registro Valor Registro Valor
A $00 A' $01
F $00 F' $10

Realizando de nuevo un ex af, af' volveríamos a los valores originales en ambos registros.

De esta forma podemos disponer de un set de registros extra Acumulador/Flags con los que trabajar. Por ejemplo, supongamos que programamos una porción de código donde queremos hacer una serie de cálculos entre registros y después dejar el resultado en una posición de memoria, pero no queremos perder los valores actuales de los registros (ni tampoco hacer uso de la pila, que veremos en su momento). En ese caso, podemos hacer:

; Una rutina a la que saltaremos gracias a la
; etiqueta que definimos aquí:
MiRutina:
 
    ; Cambiamos de banco de registros:
    exx
    ex af, af'                       ; Intercambiamos AF con AF'
 
    ; Hacemos nuestras operaciones
    ld a, ($1234)
    ld b, a
    ld a, ($1235)
    inc a
    add a, b
    ; (...etc...)
    ; (...aquí más operaciones...)
 
    ; Grabamos el resultado en memoria
    ld ($1236), a
 
    ; Recuperamos los registros:
    ex af, af'                       ; Intercambiamos AF con AF'
    exx
 
    ; Volvemos al lugar de llamada de la rutina
    ret

También podríamos utilizar la pila para almacenar valores como resultado de un cálculo, antes de hacer EXX:

    exx
 
    ; ... Realizamos una serie de operaciones complejas ...
 
    ; Guardamos en la pila el valor de HL
    push hl
 
    ; Recuperamos el juego de registros original
    exx
 
    ; Obtenemos de la pila el valor calculado
    pop hl
    ret

Además de EXX y EX AF, AF' tenemos disponibles 3 instrucciones de intercambio más que no trabajan con los registros alternativos, sino entre la memoria y registros, y la pila (o memoria en general) y los registros HL, IX e IY.

Instrucción Resultado
ex de, hl Intercambiar los valores de DE y HL.
ex (sp), hl Intercambiar el valor de HL con el valor de 16 bits
de la posición de memoria apuntada por el registro SP
(por ejemplo, para intercambiar el valor de HL con el
del último registro que hayamos introducido en la pila).
ex (sp), ix Igual que el anterior, pero con IX.
ex (sp), iy Igual que el anterior, pero con IY.

La primera de estas instrucciones nos resultará muy útil en nuestros programas en ensamblador, ya que nos permite intercambiar los valores de los registros DE y HL. Las 3 instrucciones restantes permiten intercambiar el valor apuntado por SP (en memoria) por el valor de los registros HL, IX o IY.

Como ya hemos comentado cuando hablamos del carácter Low-Endian de nuestra CPU, al escribir en memoria (también en la pila) primero se escribe el Byte Bajo y luego el Byte Alto. Posteriormente lo leeremos de la misma forma, de tal modo que si los bytes apuntados en la pila (en memoria) son $ff $00, al hacer el ex (sp), hl, el registro HL valdrá $00ff.

Nótese que aprovechando la pila (como veremos en su momento) también podemos intercambiar los valores de los registros mediante:

push bc
push de
pop bc
pop de

O, con el siguiente código más rápido en ejecución:

push hl
ld l, c
ld h, b
pop bc

En su momento veremos cómo funciona la pila, por ahora basta con saber que tenemos la posibilidad de intercambiar registros mediante el uso de la misma. Podríamos haber optado por no explicar este pequeño truco hasta haber hablado de la pila, pero nos parece más conveniente el hecho de tener toda la información sobre ensamblador agrupada de forma al buscar información o referencias sobre instrucciones para intercambiar valores de registros, pueda encontrarse toda junta. Como hemos comentado al principio de este capítulo, resulta muy complicado explicar un lenguaje tan interrelacionado de forma que no se solapen diferentes áreas, de modo que la comprensión total de muchos de los conceptos se alcanzará con una segunda lectura del curso completo.


Como hemos visto, con los Shadow Registers tenemos un set de registros adicional donde hacer cálculos, algo que parece en principio maravilloso si necesitamos más registros para realizar operaciones y no queremos acceder ni a memoria ni a la pila para almacenar datos o valores intermedios.

Existen algunas restricciones para el uso de los Shadow Registers (pero que como veremos, no nos afectan en el Spectrum): Si la ROM de nuestro sistema, en su rutina de gestión de interrupciones (ISR) utiliza los Shadow Registers, necesitaremos deshabilitar las interrupciones con di (Disable Interrupts) antes de usar “exx” y volver a habilitarlas con ei (Enable Interrupts“) cuando hayamos finalizado de trabajar con ellos. Afortunadamente, en el ZX Spectrum, la ISR del modo estándar de interrupciones (im1, el modo en que funciona el Spectrum desde que lo arrancamos) no utiliza los Shadow Registers.

Pero los Shadow Registers tienen una desventaja muy grande y que ya hemos visto, y es que no podemos utilizarlos directamente (no existen instrucciones para operar con ellos) y tampoco podemos usarlos a la vez que los registros normales.

Lo único que podemos hacer es intercambiar los registros actuales con los alternativos y viceversa, con lo cual nuestra posibilidad de operar entre los registros normales y los Shadow es muy limitada. Sí, tenemos un set de registros extra para hacer operaciones, pero no podemos pasar directamente los operandos que tenemos actualmente en BC, DE o HL al otro set para hacer dichas operaciones.

Debemos almacenar estos valores en la pila, la memoria, o el registro AF (si ejecutamos EXX pero no ex af, af') para que después del intercambio podamos hacer operaciones con estos valores en los nuevos registros. Y para devolvernos el valor resultado de la operación a los registros convencionales tendremos el mismo problema.

Es decir, que usar los Shadow Registers implica utilizar la memoria o la pila, de modo que si ya tenemos que hacer esto… ¿por qué no usar la memoria o la pila directamente para salvaguardar datos de nuestros registros normales cuando lo necesitemos?

Salvo excepciones, lo habitual en lugar de utilizar los Shadow Registers como registros extra es utilizar los registros estándar salvaguardando sus valores cuando lo necesitemos en la pila, en la memoria, o (esto es un truco algo más avanzado) en el propio código que se va a ejecutar después.

Veamos los 3 ejemplos (sin usar exx):

Opción 1.- Usar la pila:

    ld b, 8
    push bc              ; Necesitamos salvaguardar BC
    ld b, 9              ; (porque vamos a usarlo para algo)
 
    ... hacer algo con BC ...
 
    pop bc               ; recuperar el valor de BC

Opción 2.- Usar la memoria:

    ld b, 8
    ld (1000), bc        ; Necesitamos salvaguardar BC
    ld b,9               ; (porque vamos a usarlo para algo)
 
    .....
 
    ld (1002), bc        ; guardamos el resultado
    ld bc, (1000)        ; restauramos el valor de BC
    ....

Opción 3.- Usar “automodifying opcodes”:

Esto es ligeramente más lento que usar PUSH y POP pero nos puede servir si no tenemos la pila disponible. En este caso, vamos a sobreescribir un instrucción de carga “futura” para poder recuperar el valor de un registro cuando se ejecute ese opcode modificado previamente:

    ld (save_bc+1), bc    ; Escribimos BC en la parte NN NN del
                          ; opcode "ld bc, NN NN" en memoria
 
    ... hacer cosas con BC, perdiendo su valor ...
 
save_bc:
    ld bc, $0000          ; En el LD anterior cambiamos $000
                          ; por el valor de BC, así que cuando
                          ; el z80 llegue aquí no es ya ld bc, 0
                          ; sino ld bc, valor_que_tenia_BC
                          ; así que recuperaremos BC aquí.

El ejemplo anterior es muy interesante. Cuando hacemos el ld (save_bc+1), bc, estamos sobreescribiendo nuestro propio programa. Concretamente, lo que hacemos es CAMBIAR el opcode que estaba ensamblado (ld bc, $0000, que en memoria sería “$01 $00 $00”) por $01 XX XX.

En este caso save_bc apuntaría al $01, y con save_bc+1 lo que hacemos es escribir después del opcode de ld bc,, en la parte del opcode que contiene el valor a cargar en el registro. Cuando se ejecuta el ld (save_bc+1), bc, estamos escribiendo el valor de BC en ese momento encima de “XX XX” de forma que cuando la ejecución del programa continúe y lleguemos a ese punto, el comando ld bc, XX se ejecutará y recuperará en BC el valor que tenía BC cuando se almacenó en esa posición de memoria.

Con esto, estamos preservando el valor del registro a cambio de una escritura en memoria (ld (nn), bc = 20 ciclos de reloj) y de su posterior asignación (10 ciclos), un total de 30 ciclos de reloj. La alternativa con PUSH/POP utilizaría 21 ciclos en total (11 el PUSH y 10 el POP).


Hemos visto la sintaxis de los programas en ensamblador (o, al menos, la sintaxis general de Pasmo, el ensamblador que recomendamos), así como una descripción completa del juego de registros del Z80, incluyendo entre ellos el registro de flags F.

Además, hemos comenzado a ver nuestras primeras instrucciones del lenguaje ensamblador, en especial las instrucciones de carga, incremento y decremento, y aritméticas.

En el próximo capítulo continuaremos detallando las diferentes instrucciones del Z80, ejemplos de uso y su efecto sobre los flags del registro F.


  • cursos/ensamblador/lenguaje_1.txt
  • Última modificación: 22-01-2024 07:51
  • por sromero