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:
Además, podemos agrupar algunos de estos registros en pares de 16 bits para determinadas operaciones:
A
vale $ff y F
vale $00, AF
valdrá automáticamente $ff00.LDIR
, LDDR
, etc.).LDIR
, LDDR
, etc.
Aparte de estos registros, existen otra serie de registros de 16 bits:
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).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:
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:
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:
; 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.
Así pues, resumiendo:
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:
La sintaxis de LD
en lenguaje ensamblador es:
ld DESTINO, ORIGEN
Así, gracias a las operaciones LD podemos:
ld a, 10 ; A = 10 ld b, 200 ; B = 200 ld bc, 12345 ; BC = 12345
ld a, b ; A = B ld bc, de ; BC = DE
ld (12345), a ; Memoria[12345] = valor en A ld (hl), 10 ; Memoria[valor de HL] = 10
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:
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:
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.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.