cursos:ensamblador:lenguaje_3

Action disabled: source

Lenguaje Ensamblador del Z80 (III)

Una vez hemos visto la mayoría de instrucciones aritméticas y lógicas, es el momento de utilizarlas como condicionales para realizar cambios en el flujo lineal de nuestro programa. En esta entrega aprenderemos a usar etiquetas y saltos mediante instrucciones condicionales (CP, JR + condición, JP + condición, etc.), lo que nos permitirá implementar en ensamblador las típicas instrucciones IF/THEN/ELSE, los GOTO de BASIC y por tanto realizar bucles.


Las etiquetas son unas útiles directivas de los ensambladores que nos permitirán hacer referencia a posiciones concretas de memoria por medio de nombres, en lugar de tener que utilizar valores numéricos.

Las etiquetas aparecen al principio de una línea de assembler y pueden tener (o no) tener dos puntos para finalizarlas. Los dos puntos son opcionales, y hay programadores que prefieren ponerlos, y otros que no. Como veremos en muchos ejemplos, es habitual ponerlas en las etiquetas de salto y no ponerlas en las referencias a variables salvo que ocupen varias líneas.

Veamos un ejemplo de etiqueta en un programa ensamblador:

    ORG 50000
 
    nop
    ld b, 10
 
bucle:
    ld a, 20
    nop
    (...)
    jp bucle
    ret

Este es el código binario que genera el listado anterior al ser ensamblado:

00 06 0a 3e 14 00 (...) c3 53 c3 c9

Concretamente:

DIRECCION OPCODE INSTRUCCION
50000 00 nop
50001 06 0a ld b, 10
50003 3e 14 ld a, 20
50004 00 nop
c3 53 c3 jp $c353 (53000)
…+1 c9 ret

Si mostramos las direcciones de memoria en que se ensambla cada instrucción, veremos:

50000   nop              ; (opcode = 1 byte)
50001   ld b, 10         ; (opcode = 2 bytes)
50003   ld a, 20         ; (opcode = 2 bytes)
50005   nop              ; (opcode = 1 byte)
50005   (más código)
50006   (más código)
.....
50020   jp bucle
50023   ret

¿Dónde está en ese listado de instrucciones nuestra etiqueta bucle? Sencillo: no está. No es ninguna instrucción, sino, para el ensamblador, una referencia a la celdilla de memoria 50003, donde está la instrucción que sigue a la etiqueta.

En nuestro ejemplo anterior, le decimos al programa ensamblador mediante ORG 50000 que nuestro código, una vez ensamblado, debe quedar situado a partir de la dirección 50000, con lo cual cuando calcule las direcciones de las etiquetas deberá hacerlo en relación a esta dirección de origen. Así, en nuestro ejemplo anterior la instrucción NOP, que se ensambla con el opcode $00, será “pokeada” (por nuestro cargador BASIC) en la dirección 50000. La instrucción ld b, 10, cuyo opcode tiene 2 bytes, será “pokeada” en 50001 y 50002, y así con todas las instrucciones del programa.

Cuando el ensamblador se encuentra la etiqueta bucle después del ld b, 10, ¿cómo la ensambla? Supuestamente le corresponde la posición 50003, pero recordemos que esto no es una instrucción, sino una etiqueta: no tiene ningún significado para el microprocesador, sólo para el programa ensamblador. Por eso, cuando el ensamblador encuentra la etiqueta bucle, asocia internamente esta etiqueta (el texto “bucle”) a la dirección 50003, que es la dirección donde hemos puesto la etiqueta.

Si la etiqueta fuera una instrucción, se ensamblaría en la dirección 50003, pero como no lo es, el programa ensamblador simplemente la agrega a una tabla interna de referencias, donde lo que anota es:


  • La etiqueta “bucle” apunta a la dirección 50003


Lo que realmente ensamblará en la dirección 50003 (y en la 50004) es la instrucción siguiente: ld a, 20.

Pero, entonces, ¿para qué nos sirve la etiqueta? Sencillo: para poder hacer referencia en cualquier momento a esa posición de memoria (del programa, en este caso), mediante una cadena fácil de recordar en lugar de mediante un número. Es más sencillo recordar “bucle” que recordar “50003”, y si nuestro programa es largo y tenemos muchos saltos, funciones o variables, acabaremos utilizando decenas y centenares de números para saltos, con lo que el programa sería inmanejable.

El siguiente programa es equivalente al anterior, pero sin usar etiquetas:

    ORG 50000
 
    nop
    ld b, 10
    ld a, 20
    nop
    (...)
    jp 50003
    ret
 
    END

En este caso, jp 50003 no permite distinguir rápidamente a qué instrucción vamos a saltar, mientras que la etiqueta “bucle” que utilizamos en el anterior ejemplo marcaba de forma indiscutible el destino del salto.

Las etiquetas son muy útiles no sólo por motivos de legibilidad del código.

Imaginemos que una vez acabado nuestro programa sin etiquetas (utilizando sólo direcciones numéricas), con muchos saltos (jp, call, jr, DJNZ…) a diferentes partes del mismo, tenemos que modificarlo para corregir alguna parte del mismo. Al añadir o quitar instrucciones del programa, estamos variando las posiciones donde se ensambla todo el programa. Si por ejemplo, añadiéramos un NOP extra al principio del mismo, ya no habría que saltar a 50003 sino a 50004:

    ORG 50000
 
    nop
    nop        ; Un nop extra
    ld b, 10
    ld a, 20
    nop
    (...)
    jp 50004   ; La dirección de salto cambia
    ret
 
    END

Para que nuestro programa funcione, tendríamos que cambiar TODAS las direcciones numéricas de salto del programa, a mano (recalculandolas todas). Las etiquetas evitan esto, ya que es el programa ensamblador quien, en tiempo de ensamblado, cuando está convirtiendo el programa a código objeto, cambia todas las referencias a la etiqueta por el valor numérico correcto (por la posición donde aparece la etiqueta). Un jp bucle siempre saltaría a la dirección correcta (la de la posición de la etiqueta) aunque cambiemos la cantidad de instrucciones del programa.

Como veremos posteriormente, la instrucción jp realiza un salto de ejecución de código a una posición de memoria dada. Literalmente, un jp NNNN hace el registro PC = NNNN, de forma que alteramos el orden de ejecución del programa. Las etiquetas nos permiten establecer posiciones donde saltar en nuestro programa para utilizarlas luego fácilmente:

    ORG 50000
 
; Al salir de esta rutina, A=tecla pulsada
RutinaLeerTeclado:
   (instrucciones)    ; Aquí código
   ret
 
; Saltar (jp) a esta rutina con:
;  HL = Sprite a dibujar
;  DE = Direccion en pantalla donde dibujar
RutinaDibujarSprite:
   (...)
bucle1:
    (instrucciones)
bucle2:
    (instrucciones)
pintar:
    (instrucciones)
    jp bucle1
    (...)
 salir:
    ret
 
(etc...)
    END

Así, podremos especificar múltiples etiquetas para hacer referencia a todas las posiciones que necesitemos dentro de nuestro programa.

Lo que nos tiene que quedar claro de este apartado son dos conceptos: cuando el ensamblador encuentra la definición de una etiqueta, guarda en una tabla interna la dirección de ensamblado de la siguiente instrucción a dicha etiqueta. Después, cada vez que hay una aparición de esa etiqueta en el código, sustituye la etiqueta por dicha dirección de memoria. Además, podemos utilizar la etiqueta incluso aunque la definamos después (más adelante) del código, ya que el ensamblador hace varias pasadas en la compilación: no es necesario primero definir la etiqueta y después hacer referencia a ella, podemos hacerlo también a la inversa.

Es decir, es válido tanto:

etiqueta:
    ;;; (más código)
    jp etiqueta

Como:

    jp etiqueta
    ;;; (más código)
etiqueta:

Como vamos a ver, también podemos utilizar etiquetas para referenciar a bloques de datos, cadenas de texto, gráficos y en general cualquier tipo de dato en crudo que queramos insertar dentro de nuestro programa.



Podemos insertar en cualquier posición de la memoria y de nuestro programa datos en formato numérico o de texto con directivas como DB (o DEFB, de “define byte”), DW (o DEFW, de “define word”) o DS (DEFS de “define space”).

    DB 0
    DB "Esto es una cadena"
    DB "Cadena con fin", $ff
    DW 12345

Para hacer referencia a ellos, podemos precederlos en el programa con etiquetas que marquen su ubicación.

Como ya hemos visto, la aparición de una etiqueta en el código marca para el programa ensamblador “la posición del programa en este punto”, o mejor dicho, “la dirección de memoria en la que este punto del programa quedaría en memoria al ensamblarse”.

Como simplemente marcan una determinada posición de memoria, no importa si lo que viene después de una etiqueta es código, datos, o múltiples datos. La etiqueta simplemente referencia ese punto, esa dirección de ensamblado, y podemos usarla para referirnos a esa posición de memoria.

MiRutina:
    ...
 
bucle:
    ...
    ret
 
 
variable1    DB  0
 
variable2:
            DB 0, 0, 0, 0, 0, 0, 0
 
variable3:
            DB 0, 1, 1, 1, 1, 1, 0
            DB 1, 0, 0, 0, 0, 0, 1

Veamos el siguiente ejemplo:

; Demostracion de datos y variables en memoria
    ORG 50000
 
    ; Primero vamos a copiar unos datos a la videomemoria.
    ld hl, datos                ; origen
    ld de, 16384                ; destino
    ld bc, (longitud_datos)     ; longitud (BC son 2 bytes, var es DW)
    ldir
 
    ld de, 10                   ; D = 0, E = 10
    call CursorAt
 
    ld de, texto
    call PrintString            ; Imprimimos cadena
 
    ; Ahora vamos a sumar 1 a cada caracter de la cadena,
    ; un total de "repeticiones" veces:
    ld hl, texto                ; Origen
    ld a, (repeticiones)        ; A
    ld b, a                     ; B = A (para djnz)
 
bucle:
    inc (hl)                    ; Incrementamos el valor apuntado por HL
    inc hl                      ; Siguiente posicion de la cadena
 
    djnz bucle                  ; Repetir "variable (27)" veces
 
    ld de, texto
    call PrintString            ; Imprimimos cadena transformada
 
    ld a, 1
    ld (fin), a                 ; Cambiamos valor de variable
 
    ld bc, (variable_16bit)     ; Ahora BC vale 12345
    call PrintNum               ; imprimir su valor
 
    call PrintSpace
    ld a, (fin)                 ; Ahora BC debería valer 1
    ld b, 0
    ld c, a                     ; BC = A (B=0 y C=A)
    call PrintNum
 
    ret
 
datos           DB 0, $ff, $ff, 0, $ff, 12, 0, 0, 0, 10, 255
longitud_datos  DW 10
 
variable_16bit  DW 12345
fin             DB 0
 
repeticiones    DB 27
texto           DB "Esto es una cadena de texto", _CR, _CR, _EOS
 
    ;; Incluimos nuestra libreria aqui
    INCLUDE "utils.asm"
 
    END 50000


Resultado de ejecutar el programa

Como puede verse, con DB hemos “insertado” datos directamente dentro de nuestro programa. Estos datos se cargarán en memoria también como parte del programa, y podremos acceder a ellos posteriormente utilizando las etiquetas que referencian su posición en el programa (y por tanto en memoria).

También hemos insertado una etiqueta llamada “bucle” que referencia una posición concreta que usaremos para saltos, igual que las etiquetas que hemos puesto delante de las “variables” o “datos” nos permiten en el código obtener una referencia absoluta a esas “celdillas de memoria” cuando el programa esté cargado y en ejecución.

El programa ensamblador sabe en qué posición de la memoria estarán todas las etiquetas (variables, nombres de funciones, etiquetas para saltos) cuando lo ejecutemos, porque le hemos definido una dirección ORG al principio. A partir de ella, en el proceso de ensamblado, se van convirtiendo instrucciones en opcodes, e insertando datos, y el ensamblador puede ser en qué dirección exacta acabará en memoria cada instrucción, variable, etiqueta o rutina.

Por eso, si por ejemplo utilizamos una dirección ORG 50000, pero luego cargamos el programa en 40000 y saltamos a 40000, las referencias que hay en el código apuntarán a direcciones de memoria incorrectas (por ejemplo direcciones en el rango 50XXX). El código comenzará a ejecutarse bien empezando en 40000, se irá ejecutando nuestro programa opcode a opcode, pero cuando la ejecución del programa llegue a cualquier salto a rutina (call) o etiqueta (jp, jr, DJNZ, etc), la dirección destino del salto que habrá en nuestro programa será la que introdujo el programa ensamblador con ORG 40000, es decir, serán direcciones 40xxx, y el salto se realizará a una zona de memoria donde no está nuestro código y el programa se colgará. De la misma forma, referencias a “variables” definidas con DB/DW apuntarán a zonas de la memoria donde no está realmente nuestra variable.

Los datos, en nuestro programa de ejemplo, están situados en la memoria, justo después de las instrucciones ensambladas (tras el ret posterior al PrintNum final). Podemos verlo si ensamblamos el programa y examinamos el resultado del ensamblado:

$ pasmo --bin db.asm db.bin

$ hexdump -C 05_db.bin
00000000  21 92 c3 11 00 40 ed 4b  9d c3 ed b0 11 0a 00 cd  |!....@.K........|
00000010  d0 c3 11 a3 c3 cd da c3  21 a3 c3 3a a2 c3 47 34  |........!..:..G4|
00000020  23 10 fc 11 a3 c3 cd da  c3 3e 01 32 a1 c3 ed 4b  |#........>.2...K|
00000030  9f c3 cd c1 c3 cd c8 c3  3a a1 c3 06 00 4f cd c1  |........:....O..|
00000040  c3 c9 00 ff ff 00 ff 0c  00 00 00 0a ff 0a 00 39  |...............9|
00000050  30 00 1b 45 73 74 6f 20  65 73 20 75 6e 61 20 63  |0..Esto es una c|
00000060  61 64 65 6e 61 20 64 65  20 74 65 78 74 6f 0d 0d  |adena de texto..|
00000070  ff cd 2b 2d cd e3 2d c9  3e 20 d7 c9 3e 0d d7 c9  |..+-..-.> ..>...|
00000080  f5 3e 16 d7 7b d7 7a d7  f1 c9 1a fe ff c8 d7 13  |.>..{.z.........|
00000090  18 f8 f5 c5 4f 06 08 3e  25 d7 3e 31 cb 79 20 02  |....O..>%.>1.y .|
000000a0  3e 30 d7 cb 01 10 f3 c1  f1 c9 e5 67 3e 24 d7 7c  |>0.........g>$.||
000000b0  f5 d5 cd 31 c4 21 54 c4  7e d7 23 7e d7 d1 f1 e1  |...1.!T.~.#~....|
000000c0  c9 e5 f5 3e 24 d7 60 cd  31 c4 21 54 c4 7e d7 23  |...>$.`.1.!T.~.#|
000000d0  7e d7 61 cd 31 c4 21 54  c4 7e d7 23 7e d7 f1 e1  |~.a.1.!T.~.#~...|
000000e0  c9 f5 d5 e5 11 54 c4 7c  cd 42 c4 7c cd 46 c4 c3  |.....T.|.B.|.F..|
000000f0  50 c4 1f 1f 1f 1f f6 f0  27 c6 a0 ce 40 12 13 c9  |P.......'...@...|
00000100  e1 d1 f1 c9 00 00 f5 c5  f5 c1 79 cd e2 c3 c1 f1  |..........y.....|
00000110  c9 f5 c5 f5 c1 79 cb 07  10 fc e6 01 c6 30 d7 c1  |.....y.......0..|
00000120  f1 c9 3a 05 5c fe 05 28  f9 3a 05 5c fe 05 20 f9  |..:.\..(.:.\.. .|
00000130  3a 08 5c c9                                       |:.\.|

El contenido del binario que estamos viendo arriba es lo que el cargador BASIC “pokearía” en memoria (o cargaría con LOAD “” CODE).

El primer opcode es “$21”, que es el opcode correspondiente a ld hl, NNNN (nuestro programa empieza por ld hl, datos).

Los 2 siguientes bytes son pues la dirección de la etiqueta “datos”. En este caso, estos 2 bytes son $92 $c3, siendo el primer número el byte bajo y el segundo el byte alto del valor NNNN, es decir, $c392 o 50066. Esa dirección de memoria, 50666, es donde están alojados los bytes 0, $ff, $ff, 0, $ff, 12, 0, 0, 0, 10, 255 que hay tras la etiqueta datos.

A continuación podemos ver el opcode $11 $00 $40 que se corresponde con ld de, 16384 (16384 es $4000).

Así, el ensamblador va avanzando en el ensamblado del programa e insertando los opcodes y sus operandos en el binario resultante.

Cuando durante el proceso de ensamblado, el ensamblador se encuentra una etiqueta, la reemplaza en el binario resultante por la dirección de memoria donde está dicha etiqueta. Si la etiqueta ya se definió anteriormente, el ensamblador sabe en qué dirección de memoria estaba, y si es una etiqueta que aparece más adelante en el código, deja 2 bytes (por ejemplo, dos ceros) y al acabar el ensamblado (cuando la etiqueta ya ha aparecido) los reemplaza por la dirección de la misma.

Pero sigamos viendo el resultado del ensamblado: podemos ver en el “binario” los datos de nuestro programa mezclados con el código del mismo, hasta el opcode de RET (201, o $c9) justo antes del bloque de datos en la línea “00000040” del listado de arriba (“00000040 c3 c9 00 ff ff 00 ff 0c”).

Después de ese RET vienen los datos (podemos ver la cadena de texto) hasta el final de la línea 00000060 y principio de 00000070, donde vemos $0d $0d $ff. Estos son los _CR, _CR, _EOS (13, 13, 255) que acaban nuestra cadena en el código:

texto          DB "Esto es una cadena de texto", _CR, _CR, _EOS

Todo el código desde después de dicho $ff hasta el final del binario es el código ensamblado de nuestra librería utils.asm que hemos añadido en ese punto con INCLUDE. Efectivamente, al incluir nuestra librería estamos engordando el binario resultante con todo el código de la misma, por lo que lo habitual cuando se va a compilar la versión final y definitiva de un juego o programa es eliminar de las librerías todas aquellas funciones y funcionalidades que no se usan en el programa principal, haciendo que ocupe menos al ensamblarlo (y tarde menos en cargar desde cinta), o utilizar otros métodos del ensamblador, como IFUSED para que sólo se ensamble aquello que haya sido utilizado.

Volvamos de nuevo a las etiquetas y a DB: Cuando en el programa hacemos ld hl, datos, el ensamblador transforma esa instrucción en realidad en ld hl, 50066. Gracias a esto podemos manipular los datos (que están en memoria) y leerlos y cambiarlos, utilizando un nombre como referencia a la celdilla de memoria de inicio de los mismos.

Lo mismo ocurre con el texto que se ha definido entre dobles comillas. A partir de la dirección definida por texto se colocan todos los bytes que forman la cadena Esto es una cadena de texto. Cada byte en memoria es una letra de la cadena, en formato ASCII (La “E” es $45, la “s” es $73“, etc.).

Con DB (o DEFB, que es un equivalente por compatibilidad con otros ensambladores) podremos definir:


  • Cadenas de texto (todos los mensajes de texto de nuestros programas/juegos).
  • Datos numéricos con los que trabajar (bytes, words, caracteres…).
  • Tablas precalculadas para optimizar. Por ejemplo, podemos tener un listado como el siguiente:


numeros_primos  DB  1, 3, 5, 7, 11, 13, (etc...)
  • Variables en memoria para trabajar en nuestro programa:
    vidas  DB   3
    x      DB   0
    y      DB   0
    ancho  DB  16
    alto   DB  16
    (...)
 
    ld a, (vidas)
    (...)
 
muerte:
    dec a
    ld (vidas), a
  • Datos gráficos de nuestros sprites (creados con utilidades como SevenuP o ZXPaintBrush, por ejemplo):
Enemigo:
    DB 12, 13, 25, 123, 210 (etc...)

Ahora bien, es muy importante tener clara una consideración: los datos que introducimos con DB (o DW, o cualquier otra directiva de inclusión) no se ensamblan, pero se insertan dentro del código resultante tal cual. Y el Z80 no puede distinguir un 201 introducido con DB de un opcode 201 (RET), con lo cual tenemos que asegurarnos de que dicho código no se ejecute, como en el siguiente programa:

    ORG 50000
 
    ; Cuidado, al situar los datos aquí, cuando saltemos a 50000 con
    ; RANDOMIZE USR, ejecutaremos estos datos como si fueran opcodes.
 
    datos DB 00, 201, 100, 12, 255, 11
 
    ld b, a
    (más instrucciones)
    ret
 
    END 50000

Lo correcto sería:

    ORG 50000
 
    ; Ahora el salto a 50000 ejecutará el ld b, a, no los
    ; datos que habíamos introducido antes.
    ld b, a
 
    (más instrucciones)
 
    ret
 
; Aquí nunca serán ejecutados, el ret está antes.
datos DB 00, 201, 100, 12, 255, 11
 
    END 50000

Los microprocesadores como el Z80 no saben distinguir entre datos e instrucciones, y es por eso que tenemos que tener cuidado de no ejecutar datos como si fueran códigos de instrucción del Z80. De hecho, si hacemos un RANDOMIZE USR XX (siendo XX cualquier valor de la memoria fuera de la ROM), lo más probable es que ejecutemos datos como si fueran instrucciones y el Spectrum se cuelgue, ya que los datos no son parte de un programa, y la ejecución resultante de interpretar esos datos no tendría ningún sentido.


Ya sabemos definir etiquetas en nuestros programas y referenciarlas. Ahora la pregunta es: ¿para qué sirven estas etiquetas? Aparte de referencias para usarlas como variables o datos, su principal uso será saltar a ellas con las instrucciones de salto.

Para empezar vamos a ver 2 instrucciones de salto incondicionales, es decir, cuando lleguemos a una de esas 2 instrucciones, se modificará el registro PC para cambiar la ejecución del programa. De esta forma podremos realizar bucles, saltos a rutinas o funciones, etc.

Empecemos con jp (abreviatura de JumP):

    ; Ejemplo de un programa con un bucle infinito
    ORG 50000
 
    xor a               ; A = 0
bucle:
    inc a               ; A = A + 1
    ld (16384), a       ; Escribir valor de A en (16384)
    jp bucle
 
    ret                 ; Esto nunca se ejecutará
 
    END 50000

¿Qué hace el ejemplo anterior? Ensamblémoslo con pasmo --tapbas bucle.asm bucle.tap y carguémoslo en BASIC.

Nada más entrar en 50000, se ejecuta un inc a. Después se hace un ld (16384), a, es decir, escribimos en la celdilla (16384) de la memoria el valor que contiene A. Esta celdilla se corresponde con los primeros 8 píxeles de la pantalla, con lo cual estaremos cambiando el contenido de la misma.

Tras esta escritura, encontramos un jp bucle, que lo que hace es cambiar el valor de PC y hacerlo, de nuevo, PC=50000. El código se volverá a repetir, y de nuevo al llegar a jp volveremos a saltar a la dirección definida por la etiqueta bucle. Es un bucle infinito, realizado gracias a este salto incondicional (podemos reiniciar el Spectrum para retomar el control). Estaremos repitiendo una y otra vez la misma porción de código, que cambia el contenido de los 8 primeros píxeles de pantalla poniendo en ellos el valor de A (que varía desde 0 a 255 continuadamente).

Utilizaremos pues jp para cambiar el rumbo del programa y cambiar PC para ejecutar otras porciones de código (anteriores o posteriores a la posición actual) del mismo. jp realiza pues lo que se conoce como “SALTO INCONDICIONAL ABSOLUTO”, es decir, saltar a una posición absoluta de memoria (una celdilla de 0 a 65535), mediante la asignación de dicho valor al registro PC.

Existen 3 maneras de usar jp:


a.- jp NN:

Saltar a la dirección NN. Literalmente: PC = NN



b.- jp (hl)

Saltar a la dirección contenida en el registro HL (ojo, no a la dirección apuntada por el registro HL, sino directamente a su valor). Literalmente: PC = HL



c.- jp (registro_indice)

Saltar a la dirección contenida en IX o IY. Literalmente: PC = IX o PC = IY


Ninguna de estas instrucciones afecta a los flags:

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
 jp NN               |- - - - - -|
 jp (hl)             |- - - - - -|
 jp (ix)             |- - - - - -|
 jp (iy)             |- - - - - -|

Recordemos que nuestra CPU almacena primero en memoria los bytes bajos de los números de 16 bits, por lo que a la hora de ensamblar un salto como jp 50000 (jp $c350), dicha instrucción será traducida como:

C3 50 C3

que quiere decir:

jp 50 C3  ->  jp $c350  ->  jp 50000

Como podéis ver, aparte del código de instrucción (C3) almacenamos un valor numérico, absoluto, de la posición a la que saltar. Es pues una instrucción de 3 bytes.

Literalmente, jp NN se traduce por PC=NN .


Además de jp, tenemos otra instrucción para realizar saltos incondicionales: jr. jr trabaja exactamente igual que jp: realiza un salto (cambiando el valor del registro PC), pero lo hace de forma diferente.

jr son las siglas de “Jump Relative”, y es que esta instrucción en lugar de realizar un salto absoluto (a una posición de memoria 0-65535), lo hace de forma relativa, es decir, a una posición de memoria alrededor de la posición actual (una vez decodificada la instrucción jr).

El argumento de jr no es pues un valor numérico de 16 bits (0-65535) sino un valor de 8 bits en complemento a dos que nos permite saltar desde la posición actual (referenciada en el ensamblador como “$”) hasta 127 bytes hacia adelante y 127 bytes hacia atrás:

Ejemplos de instrucciones jr:

jr $+25      ; Saltar adelante 25 bytes: PC = PC+25
jr $-100     ; Saltar atrás 100 bytes:   PC = PC-100

Nosotros, gracias a las etiquetas, podemos olvidarnos de calcular posiciones y hacer referencia de una forma más sencilla a posiciones en nuestro programa:

Veamos el mismo ejemplo anterior, pero con un salto relativo:

    ; Ejemplo de un programa con un bucle infinito
    ORG 50000
 
bucle:
    inc a
    ld (16384), a
    jr bucle
 
    ret ; Esto nunca se ejecutará
 
    END

Como puede verse, el ejemplo es exactamente igual que en el caso anterior. No tenemos que utilizar el carácter $ (posición actual de ensamblado) porque al hacer uso de etiquetas es el ensamblador quien se encarga de traducir la etiqueta a un desplazamiento de 8 bits y ensamblarlo.

¿Qué diferencia tiene jp con jr? Pues bien: para empezar en lugar de ocupar 3 bytes (jp + la dirección de 16 bits), ocupa sólo 2 (jr + el desplazamiento de 8 bits) con lo cual se decodifica y ejecuta más rápido.

Además, como la dirección del salto no es absoluta, sino relativa, y de 8 bits en complemento a dos, no podemos saltar a cualquier punto del programa, sino que sólo podremos saltar a código que esté cerca de la línea actual: como máximo 127 bytes por encima o por debajo de la posición actual en memoria.

Si tratamos de ensamblar un salto a una etiqueta que está más allá del alcance de un salto relativo, obtendremos un error como el siguiente:

ERROR: Relative jump out of range

En ese caso, tendremos que cambiar la instrucción jr etiqueta por un jp etiqueta, de forma que el ensamblador utilice un salto absoluto que le permita llegar a la posición de memoria que queremos saltar y que está más alejada de que la capacidad de salto de jr. Se podría decir que “hay demasiado código” entre el punto de salto y el destino del salto, más de 127 bytes, y por tanto necesitamos hacer un salto absoluto.

¿Cuál es la utilidad o ventaja de los saltos relativos aparte de ocupar 2 bytes en lugar de 3? Pues que los saltos realizados en rutinas que usen jr y no jp son todos relativos a la posición actual, con lo cual la rutina es REUBICABLE. Es decir, si cambiamos nuestra rutina de 50000 a 60000 (por ejemplo), funcionará, porque los saltos son relativos a “$”. En una rutina programada con jp, si la pokeamos en 60000 en lugar de en 50000 y la hemos ensanmblado con ORG 50000, cuando hagamos saltos (jp 50003, por ejemplo), saltaremos a lugares donde no está el código (ahora está en 60003) y el programa no hará lo que esperamos. En resumen: jr permite programar rutinas reubicables y jp no.

Se dice que una rutina es reubicable cuando estando programada a partir de una determinada dirección de memoria, podemos copiar una rutina ya ensamblada a otra dirección y sus saltos funcionarán correctamente por no ser absolutos.

¿Recordáis en los cursos y rutinas de Microhobby cuando se decía ”Esta rutina es reubicable“? Pues quería decir exactamente eso, que podías copiar la rutina en cualquier lugar de la memoria y llamarla, dado que el autor de la misma había utilizado sólo saltos relativos y no absolutos, por lo que daría igual la posición de memoria en que la POKEaramos.

En nuestro caso, al usar un programa ensamblador en lugar de simplemente disponer de las rutinas en código máquina (ya ensambladas) que nos mostraba microhobby, no se nos plantearán esos problemas, dado que nosotros podemos usar etiquetas y copiar cualquier porción del código a dónde queramos de nuestro programa. Aquellas rutinas etiquetadas como “reubicables” o “no reubicables” estaban ensambladas manualmente y utilizaban direcciones de memoria numéricas o saltos absolutos.

Nuestro ensamblador (Pasmo, z80asm, sjasmplus, etc) nos permite utilizar etiquetas, que serán reemplazadas por sus direcciones de memoria durante el proceso de ensamblado. Nosotros podemos modificar las posibles de nuestras rutinas en el código, y dejar que el ensamblador las “reubique” por nosotros, ya que al ensamblará cambiará todas las referencias a las etiquetas que usamos.

Esta facilidad de trabajo contrasta con las dificultades que tenían los programadores de la época que no disponían de ensambladores profesionales. Imaginad la cantidad de usuarios que ensamblaban sus programas a mano, usando saltos relativos y absolutos (y como veremos, llamadas a subrutinas), que en lugar de sencillos nombres (jp A_mayor_que_B) utilizaban directamente direcciones en memoria.

E imaginad el trabajo que suponía mantener un listado en papel todas los direcciones de saltos, subrutinas y variables, referenciados por direcciones de memoria y no por nombres, y tener que cambiar muchos de ellos cada vez que tenían que arreglar un fallo en una subrutina y cambiaban los destinos de los saltos por crecer el código que había entre ellos.

Dejando ese tema aparte, la tabla de afectación de flags de jr es la misma que para jp: nula.

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
  jr d               |- - - - - -|

 (Donde "d" es un desplazamiento de 8 bits)

Literalmente, jr d se traduce por PC=PC+d.


Ya hemos visto la forma de realizar saltos incondicionales. A continuación veremos cómo realizar los saltos (ya sean absolutos con JP o relativos con JR) de acuerdo a unas determinadas condiciones.

Las instrucciones condicionales disponibles trabajan con el estado de los flags del registro F, y son:


jp nz, direccion : Salta si el indicador de cero (Z) está a cero (resultado no cero).
jp z, direccion : Salta si el indicador de cero (Z) está a uno (resultado cero).
jp nc, direccion : Salta si el indicador de carry (C) está a cero.
jp c, direccion : Salta si el indicador de carry (C) está a uno.
jp po, direccion : Salta si el indicador de paridad/desbordamiento (P/O) está a cero.
jp pe, direccion : Salta si el indicador de paridad/desbordamiento (P/O) está a uno.
jp p, direccion : Salta si el indicador de signo S está a cero (resultado positivo).
jp m, direccion : Salta si el indicador de signo S está a uno (resultado negativo).

jr nz, relativo : Salta si el indicador de cero (Z) está a cero (resultado no cero).
jr z, relativo : Salta si el indicador de cero (Z) está a uno (resultado cero).
jr nc, relativo : Salta si el indicador de carry (C) está a cero.
jr c, relativo : Salta si el indicador de carry (C) está a uno.

Donde “dirección” es un valor absoluto 0-65535, y “relativo” es un desplazamiento de 8 bits con signo -127 a +127.

(Nota: en el listado de instrucciones, positivo o negativo se refiere a considerando el resultado de la operación anterior en complemento a dos).

Así, supongamos el siguiente programa:

    jp z, destino
    ld a, 10
destino:
    nop

(donde “destino” es una etiqueta definida en algún lugar de nuestro programa, aunque también habríamos podido especificar directamente una dirección como por ejemplo 50004).

Cuando el procesador lee el jp z, destino, lo que hace es lo siguiente:

  • Si el flag Z está activado (a uno), saltamos a “destino” (con lo cual no se ejecuta el ld a, 10), ejecutándose el código a partir del NOP.
  • Si no está activo (a cero) no se realiza ningún salto, con lo que se ejecutaría el ld a, 10, y seguiría después con el NOP.

En BASIC, jp z, destino sería algo como:

 IF FLAG_ZERO = 1 THEN GOTO destino

Y jp nz, destino sería:

 IF FLAG_ZERO = 0 THEN GOTO destino

Con estas instrucciones podemos realizar saltos condicionales en función del estado de los flags o indicadores del registro F: podemos saltar si el resultado de una operación es cero, si no es cero, si hubo acarreo, si no lo hubo…

Y el lector se preguntará: ¿y tiene utilidad realizar saltos en función de los flags? Pues la respuesta es: bien usados, lo tiene para todo tipo de tareas:

    ; Repetir 100 veces la instruccion nop
    ld a, 100
bucle:
    nop
 
    dec a               ; Decrementamos A.
                        ; Cuando A sea cero, Z se pondrá a 1
 
    jr nz, bucle        ; Mientras Z=0, repetir el bucle
 
    ld a, 200           ; Aquí llegaremos cuando Z sea 1 (A valga 0)
 
    ; resto del programa

Es decir: cargamos en A el valor 100, y tras ejecutar la instrucción NOP hacemos un dec a que decrementa su valor (a 99). Como el resultado de dec a es 99 y no cero, el flag de Z (de cero) se queda a 0, (recordemos que sólo se pone a uno cuando la última operación resultó ser cero).

Y como el flag Z es cero (NON ZERO = no activado el flag zero) la instrucción jr nz, bucle realiza un salto a la etiqueta “bucle”. Allí se ejecuta el nop y de nuevo el dec a, dejando ahora A en 98.

Tras repetirse 100 veces el proceso, llegará un momento en que A valga cero tras el dec a. En ese momento se activará el flag de ZERO con lo que la instrucción jr nz, bucle no realizará el salto y continuará con el ld a, 20.

Así pues, acabamos implementar un bucle gracias a los flags y las instrucciones condicionales.

Veamos otro ejemplo más gráfico: vamos a implementar en ASM una comparación de igualdad:

IF A=B THEN GOTO iguales ELSE GOTO distintos

En ensamblador:

    sub b                ; A = A-B
    jr z, iguales        ; Si Z=1 saltar a iguales
    jr nz, distintos     ; Si Z=0 saltar a distintos
 
iguales:
    ;;; (código)
    jr seguir
 
distintos:
    ;;; (código)
    ;jr seguir           ; Este salto no es necesario,
                         ; ya continuamos en "seguir"
 
seguir:

O, invirtiendo el orden de la comparación, ahorrarse un salto:

    sub b                ; A = A-B
    jr nz, distintos     ; Si Z=0 saltar a distintos
                         ; Si Z=1, seguimos aqui, ya en "iguales"
 
iguales:
    ;;; (código)
    jr seguir
 
distintos:
    ;;; (código)         ; No es necesario el salto
 
seguir:

(Nota: se podría haber usado jp en vez de jr).

Para comparar A con B los restamos (A=A-B). Si el resultado de la resta es cero, es porque A era igual a B. Si no es cero, es que eran distintos. Y utilizando el flag de Zero con jp Z y jp NZ podemos detectar esa diferencia.

Pronto veremos más a fondo otras instrucciones de comparación para evitar hacer la resta y perder el valor de A, pero este ejemplo debe bastar para demostrar la importancia de los flags y de su uso en instrucciones de salto condicionales. Bien utilizadas podemos alterar el flujo del programa a voluntad. Es cierto que no es tan inmediato ni cómodo como los >, <, = y <> de BASIC, pero el resultado es el mismo, y es fácil acostumbrarse a este tipo de comparaciones mediante el estado de los flags.

Para finalizar, un detalle sobre DEC+jr: La combinación dec b / jr NZ se puede sustituir (es más eficiente, y más sencillo) por el comando djnz, que literalmente significa “Decrementa B y si no es cero, salta a <direccion>”.


djnz direccion

Equivale a decrementar B y a la dirección indicada en caso de que B no valga cero tras el decremento.

bucle:
     ; (pasos a repertir)
 
    djnz bucle                 ; Decrementar B, salta si distinto de 0

Esta instrucción se usa habitualmente en bucles (usando B como iterador del mismo) y, al igual que jp y jr, no afecta al estado de los flags:

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
  |jp COND, NN       |- - - - - -|
  |jr COND, d        |- - - - - -|
  |djnz d            |- - - - - -|

El argumento de salto de DJNZ es de 1 byte, por lo que para saltos relativos de más de 127 bytes hacia atrás o hacia adelante (-127 a +127), djnz se tiene que sustituir por la siguiente combinación de instrucciones:

bucle:
     ; (pasos a repertir)
 
    dec b                      ; Decrementar B, afecta a los flags
    jp nz, direccion           ; Salto absoluto: permite cualquier distancia

DJNZ trabaja con el registro B como contador de repeticiones, lo que implica que podemos realizar de 0 a 255 iteraciones. En caso de necesitar realizar hasta 65535 iteraciones tendremos que utilizar un registro de 16 bits como BC o DE de la siguiente forma:

bucle:
    ; (pasos a repetir)
 
    dec bc                    ; Decrementamos BC -> no afecta a los flags
    ld a, b                   ; Cargamos B en A
    or c                      ; Hacemos OR a de A y C (de B y C)
    jr nz, bucle              ; Si (B or c) no es cero, BC != 0, saltar



Para realizar comparaciones (especialmente de igualdad, mayor que y menor que) utilizaremos la instrucción CP. Su formato es:

cp origen

Donde “origen” puede ser A, F, B, C, D, E, H, L, un valor numérico de 8 bits directo, (HL), (IX+d) o (IY+d).

Al realizar una instrucción CP origen, el microprocesador ejecuta la operación “A-origen”, pero no almacena el resultado en ningún sitio. Lo que sí que hace es alterar el estado de los flags de acuerdo al resultado de la operación.

Recordemos el ejemplo de comparación anterior donde realizábamos una resta, perdiendo por tanto el valor de A:

    sub b                    ; A = A-B
    jr z, iguales            ; Si Z=1 saltar a iguales
    jr nz, distintos         ; Si Z=0 saltar a distintos

Gracias a CP, podemos hacer la misma operación pero sin perder el valor de A (por la resta):

    cp b                     ; Flags = estado(A-B)
    jr z, iguales            ; Si Z=1 saltar a iguales
    jr nz, distintos         ; Si Z=0 saltar a distintos

¿Qué nos permite esto? Aprovechando todos los flags del registro F (flag de acarreo, flag de zero, etc), realizar comparaciones como las siguientes:

    ; Comparación entre A Y B (=, > y <)
    ld b, 5
    ld a, 3
 
    cp b                            ; Flags = estado(A-B)
    jp z, A_Igual_que_B             ; IF(a-b)=0 THEN a=b
    jp nc, A_Mayor_o_igual_que_B    ; IF(a-b)>0 THEN a>=b
    jp c, A_Menor_que_B             ; IF(a-b)<0 THEN a<b
 
A_Mayor_que_B:
    ;;; (instrucciones)
    jp fin
 
A_Menor_que_B:
    ;;; (instrucciones)
    jp fin
 
A_Igual_que_B:
    ;;; (instrucciones)
 
fin:
    ;;; (continúa el programa)

Finalmente, destacar que nada nos impide el hacer comparaciones multiples o anidadas:

    ld b, 5
    ld a, 3
    ld c, 6
 
    cp b                    ; IF A==B
    jr z, A_Igual_a_B       ; THEN goto A_Igual_a_B
    cp c                    ; IF A==C
    jr z, A_Igual_a_C       ; THEN goto A_Igual_a_C
    jp Fin                  ; si no, salimos
 
A_Igual_a_B:
    ;;; (...)
    jr Fin
 
A_Igual_a_C:
    ;;; (...)
 
Fin:
    (resto del programa)

La instrucción CP afecta a todos los flags:

                         Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
 |cp s               |* * * V 1 *|

El flag “N” se pone a uno porque, aunque se ignore el resultado, la operación efectuada es una resta.


Aunque la instrucción CP sólo permite comparar un valor de 8 bits con el valor contenido en el registro A, podemos realizar 2 comparaciones CP para verificar si un valor de 16 bits es menor, igual o mayor que otro.

Si lo que queremos comparar es un registro con otro, podemos hacerlo mediante un CP de su parte alta y su parte baja. Por ejemplo, para comparar HL con DE:

    ;;; Comparacion 16 bits de HL y DE
    ld a, h
    cp d
    jr nz, no_iguales
    ld a, l
    cp e
    jr nz, no_iguales
 
iguales:
    ;;; (...)
 
no_iguales:
    ;;; (...)

Para comparar si el valor de un registro es igual a un valor numérico inmediato (introducido directamente en el código de programa), utilizaríamos el siguiente código:

    ;;; Comparacion 16 bits de HL y VALOR_NUMERICO (inmediato)
    ;;; VALOR_NUMERICO puede ser cualquier valor de 0 a 65535
    ld a, h
    cp VALOR_NUMERICO / 256         ; Parte alta (VALOR/256)
    jr nz, no_iguales
    ld a, l
    cp VALOR_NUMERICO % 256         ; Parte baja (Resto de VALOR/256)
    jr nz, no_iguales
iguales:
    ;;; (...)
 
no_iguales:
    ;;; (...)


A la hora de utilizar instrucciones condicionales hay que tener en cuenta que no todas las instrucciones afectan a los flags. Por ejemplo, la instrucción dec bc no pondrá el flag Z a uno cuando BC sea cero. Si intentamos montar un bucle mediante dec bc + jr nz, nunca saldremos del mismo, ya que dec bc no afecta al flag de zero.

    ld bc, 1000        ; BC = 1000
bucle:
    (...)
 
    dec bc             ; BC = BC-1 (pero NO ALTERA el Carry Flag)
    jr nz, bucle       ; Nunca se pondrá a uno el ZF, siempre salta

Para evitar estas situaciones necesitamos conocer la afectación de los flags ante cada instrucción, que podéis consultar en todas las tablas que os hemos proporcionado.

Podemos realizar algo similar al ejemplo anterior aprovechándonos (de nuevo) de los flags y de los resultados de las operaciones lógicas (y sus efectos sobre el registro F). Como ya vimos al tratar la instrucción DJNZ, podemos comprobar si un registro de 16 bits vale 0 realizando un OR entre la parte alta y la parte baja del mismo. Esto sí afectará a los flags y permitirá realizar el salto condicional:

    ld bc, 1000        ; BC = 1000
 
bucle:
    (...)
    dec bc             ; Decrementamos BC. No afecta a F.
    ld a, b            ; A = B
    or c               ; A = A or c
                       ; Esto sí que afecta a los flags.
                       ; Si B==C y ambos son cero, el resultado
                       ; del OR será cero y el ZF se pondrá a 1.
    jr nz, bucle       ; ahora sí que funcionará el salto si BC=0

Más detalles sobre los saltos condicionales: esta vez respecto al signo. Las condiciones P y M (jp p, jp m) nos permitirán realizar saltos según el estado del bit de signo. Resultará especialmente útil después de operaciones aritméticas.

Los saltos por Paridad/Overflow (jp po, jp PE) permitirán realizar saltos en función de la paridad cuando la última operación realizada modifique ese bit de F según la paridad del resultado. La misma condición nos servirá para desbordamientos si la última operación que afecta a flags realizada modifica este bit con respecto a dicha condición.

¿Qué quiere decir esto? Que si, por ejemplo, realizamos una suma o resta, jp po y jp pe responderán en función de si ha habido un desbordamiento o no y no en función de la paridad, porque las sumas y restas actualizan dicho flag según los desbordamientos, no según la paridad.


No estamos obligados a utilizar los flags para realizar un salto condicional justo después de utilizar CP.

CP seteará los bits de flags a los valores que corresponda, y los bits del registro de Flags mantendrán sus estados mientras no ejecutemos instrucciones que los varíen.

Por ejemplo, LD no altera los flags, por lo que podríamos usarlo después de un CP para establecer algún valor por defecto en un registro que después modifiquemos con un salto.

Supongamos que queremos poner en A el valor 'x' si A vale 0, y poner el valor 'y' si no vale 0.

No es necesario que hagamos:

    cp 0
    jr z, es_cero
    ld a, 'y'
    jr continuar         ; Podemos evitarnos este salto
 
es_cero:
    ld a, 'x'
 
continuar:
    ;;; Aqui A vale 'x' o 'y' segun el valor de 0

Directamente, podemos establecer uno de los valores por defecto con LD (lo cual no alterará los flags resultantes del CP anterior), y saltar si no queremos que se modifique:

    cp 0
    ld a, 'x'
    jr z, es_cero
    ld a, 'y'
 
es_cero:
    ;;; Aqui A vale 'x' o 'y' segun el valor de 0


Ante una instrucción condicional, el microprocesador tendrá 2 opciones, según los valores que comparemos y el tipo de comparación que hagamos (si es cero, si no es cero, si es mayor o menor, etc.). Al final, sólo habrá 2 caminos posibles: saltar a una dirección de destino, o no saltar y continuar en la dirección de memoria siguiente al salto condicional.

Aunque pueda parecer una pérdida de tiempo, en rutinas críticas es muy interesante el pararse a pensar cuál puede ser el caso con más probabilidades de ejecución, ya que el tiempo empleado en la opción ”CONDICION CIERTA, Por lO QUE SE PRODUCE EL SALTO“ es mayor que el empleado en ”CONDICION FALSA, NO SALTO Y SIGO“.

Por ejemplo, ante un jp z, direccion, el microprocesador tardará 10 ciclos de reloj en ejecutar un salto si la condición se cumple, y sólo 1 si no se cumple (ya que entonces no tiene que realizar salto alguno).

Supongamos que tenemos una rutina crítica donde la velocidad es importante. Vamos a utilizar, como ejemplo, la siguiente rutina que devuelve 1 si el parámetro que le pasamos es mayor que 250 y devuelve 0 si es menor:

; Comparar A con 250:
;
; Devuelve A = 0 si A < 250
;          A = 1 si A > 250
 
Valor_Mayor_Que_250:
    cp 250                      ; Comparamos A con 250
    jp c, A_menor_que_250       ; Si es menor, saltamos
    ld a, 1                     ; si es mayor, devolvemos 1
    ret
 
A_menor_que_250:
    ld a, 0
    ret

En el ejemplo anterior se produce el salto si A es menor que 250 (10 t-estados) y no se produce si A es mayor que 250 (1 t-estado).

Supongamos que llamamos a esta rutina con 1000 valores de 8 bits diferentes (todos ellos entre 0 y 255). En ese caso, existen más probabilidades de que el valor esté entre 0 y 250 a que esté entre 250 y 255, por lo que sería más óptimo que el salto que hay dentro de la rutina se haga no cuando A sea menor que 250 sino cuando A sea mayor, de forma que se produzcan menos saltos.

Lo normal es que, ante datos aleatorios, haya más probabilidad de encontrar datos del segundo caso (0-250) que del primero (250-255), simplemente por el hecho de que del primer caso hay 250 probabilides de 255, mientras que del segundo hay 5 probabilidades de 255.

En tal caso, la rutina debería organizarse de forma que la comparación realice el salto cuando encuentre un dato mayor de 250, dado que ese supuesto se dará menos veces. Si lo hicieramos a la inversa, se saltaría más veces y la rutina tardaría más en realizar el mismo trabajo.

; Comparar A con 250:
;
; Devuelve A = 0 si A < 250
;          A = 1 si A > 250
 
Valor_Mayor_Que_250:
    cp 250                      ; Comparamos A con 250
    jp nc, A_mayor_que_250      ; Si es mayor, saltamos
    ld a, 0                     ; si es menor, devolvemos 1
    ret
 
A_mayor_que_250:
    ld a, 1
    ret

Eso hace que haya más posibilidades de no saltar que de saltar, es decir, de emplear un ciclo de procesador y no 10 para la mayoría de las ejecuciones.


Gracias a la instrucción CP, podemos ejecutar una tarea un número determinado de veces, como en la siguiente línea de BASIC:

For i=1 TO 20
   (código a repetir 20 veces)
NEXT I

De la siguiente forma:

    ld a, 1              ; Valor Inicial
bucle:
 
    ; Codigo a repetir 20 veces
 
    inc a
    cp 20                ; Valor final
    jr nz, bucle

Dado que el registro A es vital en muchas operaciones en el Z80 (es el operando único en algunos opcodes), no es una buena idea utilizarlo como contador de un bucle, ya que en ese caso tendremos que hacer haciendo copias (en otros registros, o en la pila) para recuperarlo antes de hacer el CP.

Además, sólo nos permitiría realizar bucles de 8 bits.

Para eso, el registro utilizando habitualmente como contador de bucles es B para cuentas de 8 bits, y BC para cuentas de 16 bits.

Eso convertiría nuestro bucle anterior en:

    ld b, 1
bucle:
 
    ; Codigo a repetir 20 veces
 
    inc b
    ld a, b
    cp 20
    jr NZ bucle

No obstante, nos obliga a copiar B en A para poder hacer el CP.

Gracias a los flags del Z80 hay una forma mucho más eficiente de repetir N veces una porción de código. Esta forma es, en lugar de contar desde 1 hasta N, hacerlo desde N hasta 0, usando DEC/JR o DJNZ. La cuenta atrás hasta 0 activará el ZF (Flag de Zero) sin necesidad de usar CP:

Usando DEC/JR o DEC/JP:

    ld b, 20
bucle:
 
    ; Codigo a repetir 20 veces
 
    dec b
    jr nz, bucle

Usando DJNZ:

    ld b, 20
bucle:
 
    ; Codigo a repetir 20 veces
 
    djnz, bucle

Esta forma de realizar bucles es más rápida en tiempo de ejecución y ocupa menos espacio en el programa que contar desde 1 hasta N.

Esto permite también implementar bucles de 16 bits de una forma mucho más eficiente, decrementando BC con dec bc y comprobando mediante operaciones lógicas si tanto B como C son cero para acabar el bucle:

     ld bc, 10000       ; veces a iterar
 
bucle:
 
    ; ... código de nuestro bucle ...
 
    dec bc              ; Decrementamos el contador
    ld a, b             ; A = B
    or c                ; A = A or c = B or c
    jp nz, bucle        ; Si B or c == 0 => es que BC = 0

En el ejemplo anterior, decrementamos el valor de BC y hacemos un OR de B y C (como OR sólo actúa con A como destino, copiamos previamente B en A). Si el OR de B y C es cero, es porque ambos son cero, y debemos finalizar el bucle. Si no está activo el flag de Zero, es porque alguno de los 2 registros tiene algún bit a 1, de modo que en ese caso (JR NZ) repetimos otra iteración del bucle.


Hemos visto cómo podemos ejecutar código un número determinado de veces en base a contar desde un determinado valor hasta 0, utilizando los flags para saber cuándo debe de finalizar el bucle y continuar la ejecución del programa.

En ciertas ocasiones concretas los desarrolladores evitan utilizar bucles y lo que hacen es repetir el mismo bloque de instrucciones N veces, para evitar el salto (DJNZ, o CP/DEC + JR o JP) y su coste en t-estados.

Este proceso se llama desenrollar el bucle y consiste en, básicamente, no usar un bucle, sino sustituirlo por N repeticiones del código en el fichero de texto que después ensamblaremos. Para hacer algo así evidentemente necesitamos conocer el número de repeticiones del código.

El desenrollar el bucle en instrucciones repetidas evita los saltos y por tanto hace que el código tarde menos en ejecutarse que si lo hiciéramos en un bucle, pero a costa de ocupar mucho más espacio, ya que estamos incluyendo los opcodes compilados N veces.

Por ejemplo, supongamos el siguiente código para escribir 8 valores consecutivos $ff en la videomemoria:

    ORG 50000
 
    call ROM_CLS
 
    ld hl, $4000
    ld a, $ff
    ld b, 8             ; Repetir 8 veces
 
bucle:
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
    djnz bucle
 
    ret
 
ROM_CLS EQU $0daf
 
    END 50000

En pantalla aparecerá lo siguiente:


Resultado del bucle para escribir

El programa resultante ocupa 15 bytes hasta el RET (opcode $c9):


$ hexdump -C bucle.bin
00000000  cd af 0d 21 00 40 3e ff  06 08 77 23 10 fc c9     |...!.@>...w#...|


Pero dado que el bucle tiene un número de iteraciones fijo, también podríamos haber desenrollado el bucle y haber escrito lo siguiente:

    ORG 50000
 
    call ROM_CLS
 
    ld hl, $4000
    ld a, $ff
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
 
    ld (hl), a          ; Escribir valor en pantalla
 
    ; El ultimo inc hl no es necesario ya que no vamos
    ; a escribir en el siguiente bloque de pixeles.
 
    ret
 
ROM_CLS EQU $0daf
 
    END 50000

Este código produce el mismo resultado en pantalla, pero tiene varias ventajas:

  • Ya no necesitamos usar el registro B para el bucle, así que tenemos un registro más disponible para trabajar.
  • La instrucción para cargar B con un valor ya no es necesaria (-2 bytes y 7 ciclos de reloj menos de ejecución del programa).
  • El djnz tampoco es necesario ya (-2 bytes y 13 ciclos de reloj menos en cada iteración del bucle y 7 menos al acabar el bucle, es decir, ¡98 t-estados menos!).
  • El último inc hl ya no es necesario, mientras que en el caso del bucle se habría ejecutado (-1 byte y 6 ciclos de reloj menos).

En total nuestro código es ahora 111 ciclos de reloj más rápido. Esto nos puede parecer de poca importancia en un ejemplo como este, pero si lo hacemos dentro de una rutina para dibujar los gráficos del juego, o para hacer cálculos importantes en el bucle principal del programa, puede suponer una gran diferencia.

Sin embargo, el haber desenrollado el bucle tiene una desventaja, un coste:

  • Nuestro programa de 15 bytes ocupa ahora 24 bytes, ya que hemos tenido que repetir código 8 veces:


$ hexdump -C bucle_desenrollado.bin
00000000  cd af 0d 21 00 40 3e ff  77 23 77 23 77 23 77 23  |...!.@>.w#w#w#w#|
00000010  77 23 77 23 77 23 77 c9                           |w#w#w#w.|


Como puede apreciarse, ahora tenemos repetidas varias veces las instrucciones ld (hl), a ($77) y inc hl ($23) en el binario resultante.

En este caso sólo pasamos de 15 a 24 bytes, pero si dentro del bucle tuviéramos no 2 instrucciones sino 20 ó 30, podríamos estar consumiendo muchos cientos de bytes sólo para desenrollar el bucle, y recordemos que la memoria es limitada, con unos 25-35KB libres en un Spectrum 48K para la totalidad del programa.

Así, lo normal es “desenrollar” sólo aquellos bucles que sean críticos en velocidad para el programa. No tiene sentido desenrollar un bucle en un sitio donde la velocidad no es crucial, a costa de que el programa ocupe más, tarde más en cargar, y tengamos el riesgo de quedarnos sin espacio para desarrollar el resto del programa.

Cabe destacar que los programas ensambladores suelen disponer de directivas para repetir código sin tener que escribirlo varias veces.

En pasmo tenemos las directivas REPT n (de “repeat/repetir”) como inicio y ENDM, que repetirá el bloque de código entre ellas n veces.

En sjasmplus se utiliza REPT n y ENDR, aunque también tiene como sinónimo DUP (de “duplicate”) y EDUP.

    ; En pasmo: REPT - ENDM
    ld a, $ff
 
    REPT 8
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
    ENDM
 
    ld (hl), a          ; Escribir valor en pantalla
    ; En sjasmplus: REPT - ENDR
    ld a, $ff
 
    REPT 8
    ld (hl), a          ; Escribir valor en pantalla
    inc hl              ; Siguiente bloque de pixeles
    ENDR
 
    ld (hl), a          ; Escribir valor en pantalla

Es una lástima que ambas directivas no sean la misma y podamos escribir código compatible con ambos ensambladores, por lo que lo normal si vamos a usar “directivas y macros” de los ensambladores es asumir que el código que escribiremos estará ligado al programa ensamblador que elijamos al empezar un proyecto (a menos que para cambiar de programa modifiquemos todo el código para adaptar, por ejemplo, las directivas ENDR como ENDM o viceversa).


Para acabar con las instrucciones de comparación vamos a ver las instrucciones de comparación repetitivas. Son parecidas a CP, pero trabajan (igual que ldi, ldir, ldd y lddr) con HL y BC para realizar las comparaciones con la memoria: son CPI, CPD, CPIR y CPDR.

Comencemos con cpi (ComPare and Increment):



cpi:

  • Al registro A se le resta el byte contenido en la posición de memoria apuntada por HL.
  • El resultado de la resta no se almacena en ningún sitio.
  • Los flags resultan afectados por la comparación:
    • Si A==(HL), se pone a 1 el flag de Zero (si no es igual se pone a 0).
    • Si BC==0000, se pone a 0 el flag Parity/Overflow (a 1 en caso contrario).
  • Se incrementa HL.
  • Se decrementa BC.

Técnicamente (con un pequeño matiz que veremos ahora), cpi equivale a:

cpi =     cp (hl)
          inc hl
          dec bc


cpd:
Su instrucción “hermana” cpd (ComPare and Decrement) funciona de idéntica forma, pero decrementando HL:

cpd =     cp (hl)
          dec hl
          dec bc

Y el pequeño matiz: así como cp (hl) afecta al indicador C de Carry, cpi y cpd, aunque realizan esa operación intermedia, no lo afectan.

Las instrucciones CPIR y CPDR son equivalentes a CPI y CPD, pero ejecutándose múltiples veces: hasta que BC sea cero o bien se encuentre en la posición de memoria apuntada por HL un valor numérico igual al que contiene el registro A. Literalmente, es una instrucción de búsqueda: buscamos hacia adelante (CPIR) o hacia atrás (CPDR), desde una posición de memoria inicial (HL), un valor (A), entre dicha posición inicial (HL) y una posición final (HL+BC o HL-BC para CPIR y CPDR).



cpir:

  • Al registro A se le resta el byte contenido en la posición de memoria apuntada por HL.
  • El resultado de la resta no se almacena en ningún sitio.
  • Los flags resultan afectados por la comparación:
    • Si A==(HL), se pone a 1 el flag de Zero (si no es igual se pone a 0).
    • Si BC==0000, se pone a 0 el flag Parity/Overflow (a 1 en caso contrario).
  • Se incrementa HL.
  • Se decrementa BC.
  • Si BC===0 o A=(HL), se finaliza la instrucción. Si no, repetimos el proceso.



cpdr:
CPDR es, como podéis imaginar, el equivalente a CPIR pero decrementando HL, para buscar hacia atrás en la memoria.

Como ya hemos comentado, muchos flags se ven afectados:

                        Flags
   Instrucción       |S Z H P N C|
 ----------------------------------
 |cpi                |* * * * 1 -|
 |cpd                |* * * * 1 -|
 |cpir               |* * * * 1 -|
 |cpdr               |* * * * 1 -|

Un ejemplo de uso de un CP repetitivo es realizar búsquedas de un determinado valor en memoria. Supongamos que deseamos buscar la primera aparición del valor “123” en la memoria a partir de la dirección 20000, y hasta la dirección 30000, es decir, encontrar la dirección de la primera celdilla de memoria entre 20000 y 30000 que contenga el valor 123.

Podemos hacerlo mediante el siguiente ejemplo con CPIR:

    ld hl, 20000      ; Origen de la busqueda
    ld bc, 10000      ; Número de bytes a buscar (20000-30000)
    ld a, 123         ; Valor a buscar
    cpir

Este código realizará lo siguiente:

HL = 20000
 BC = 10000
 A  = 123

cpir =
Repetir:
  Leer el contenido de (HL)
  Si A==(HL) -> Fin_de_cpir
  Si BC==0   -> Fin_de_cpir
  HL = HL+1
  BC = BC-1
Fin_de_cpir:

Con esto, si la celdilla 15000 contiene el valor “123”, la instrucción CPIR del ejemplo anterior acabará su ejecución, dejando en HL el valor 15001 (tendremos que decrementar HL para obtener la posición exacta). Dejará además el flag “P/O” (paridad/desbordamiento) y el flag Z a uno. En BC tendremos restado el número de iteraciones del “bucle” realizadas.

Si no se encuentra ninguna aparición de “123”, BC llegará a valer cero, porque el “bucle cpi” se ejecutará 10000 veces. El flag P/O estará a cero, al igual que Z, indicando que se finalizó el CPIR y no se encontró nada.

Nótese que si en vez de utilizar CPIR hubiéramos utilizado CPDR, podríamos haber buscado hacia atrás, desde 20000 a 10000, decrementando HL. Incluso haciendo HL=0 y usando CPDR, podemos encontrar la última aparición del valor de A en la memoria (ya que 0000 - 1 = $ffff, es decir: 0-1=65535 en nuestros 16 bits).


Veamos un ejemplo práctico con CPIR. El código que veremos a continuación realiza una búsqueda de un determinado carácter ASCII en una cadena de texto:

 ; Principio del programa
    ORG 50000
 
    ld hl, texto        ; Inicio de la busqueda
    ld a, 'X'           ; Carácter (byte) a buscar
    ld bc, 100          ; Número de bytes donde buscar
    cpir                ; Realizamos la búsqueda
 
    jp nz, No_Hay       ; Si no encontramos el caracter buscado
                        ; el flag de Z estará a cero.
 
                        ; Si seguimos por aquí es que se encontró
    dec hl              ; Decrementamos HL para apuntar al byte
                        ; encontrado en memoria.
 
    ld bc, texto
    scf
    ccf                 ; Ponemos el carry flag a 0 (scf+ccf)
    sbc hl, bc          ; HL = HL - BC
                        ;    = (posicion encontrada) - (inicio cadena)
                        ;    = posición de 'X' dentro de la cadena.
 
    ld b, h
    ld c, l             ; BC = HL
 
    ret                 ; Volvemos a basic con el resultado en BC
 
No_Hay:
    ld bc, $ffff
    ret
 
texto DB "Esto es una X cadena de texto."
 
   ; Fin del programa
   END

Lo compilamos con pasmo --tapbas buscatxt.asm buscatxt.tap (en este caso no tiene sentencia END para que no se autoejecute), lo cargamos en el emulador y tras un RUN ejecutamos nuestra rutina como PRINT AT 10,10 ; USR 50000.

En pantalla aparecerá el valor “12”:


Salida del programa buscatxt.asm

¿Qué significa este “12”? Es la posición del carácter 'X' dentro de la cadena de texto. La hemos obtenido de la siguiente forma:

  • Hacemos HL = posición de memoria donde empieza la cadena.
  • Hacemos A = 'X'.
  • Ejecutamos un cpir
  • En HL obtendremos la posición absoluta + 1 donde se encuentra el carácter 'X' encontrado (o FFFFh si no se encuentra). Exactamente 50041.
  • Decrementamos HL para que apunte a la 'X' (50040).
  • Realizamos la resta de Posicion('X') - PrincipioCadena para obtener la posición del carácter dentro de la cadena. De esta forma, si la 'E' de la cadena está en 50028, y la X encontrada en 50040, eso quiere decir que la 'X' está dentro del array en la posición 50040-50028 = 12.
  • Volvemos al BASIC con el resultado en BC. El PRINT USR 50000 imprimirá dicho valor de retorno.

Nótese que el bloque desde scf hasta ld c, l tiene como objetivo ser el equivalente a HL = HL - BC, y se tiene que hacer de esta forma porque no existe sub hl, bc ni ld bc, hl:

sub hl, bc =  scf
              ccf              ; Ponemos el carry flag a 0 (scf+ccf)
              sbc hl, bc       ; HL = HL - BC

ld bc, hl  =  ld b, h
              ld c, l          ; BC = HL

(Podemos dar las gracias por estas extrañas operaciones a la no ortogonalidad del juego de instrucciones del Z80).


En este capítulo hemos aprendido a utilizar todas las funciones condicionales y de salto de que nos provee el Z80. Será necesario comprender perfectamente el funcionamiento de los flags para poder desarrollar código ensamblador. Para ello recordemos que nuestra librería utils.asm contiene las rutinas PrintFlags y PrintFlag que serán muy útiles cuando desarrollemos ejemplos y queramos ver la afectación a los flags de un código determinado.

En el próximo trataremos la PILA (Stack) del Spectrum, gracias a la cual podremos implementar en ensamblador el equivalente a GOSUB/RETURN de BASIC, es decir, subrutinas.


  • cursos/ensamblador/lenguaje_3.txt
  • Última modificación: 19-01-2024 07:14
  • por sromero