cursos:ensamblador:asmz88dk

Integración de ASM en Z88DK

En este capítulo vamos a ver cómo podemos usar ASM dentro del compilador de C más utilizado en Spectrum: el Z88DK (Z88 Development Kit).

La idea de hacer programas mixtos en C con pequeñas partes en ASM es la de escribir en ensamblador las partes más críticas o importantes del mismo para acelerar su ejecución.

Por ejemplo, en un juego podríamos escribir el esqueleto del programa en C, incluyendo menú del juego, presentación, bucle principal de la partida, etc, pero escribir en ensamblador las rutinas que leen el teclado, renderizan los mapas, dibujan los gráficos y reproducen el sonido, que es donde requerimos la mayor velocidad de ejecución.

Es importante destacar que cuando usamos ASM con Z88DK, éste se ensambla con z80asm, el ensamblador incluído en Z88DK, y no lo haremos con pasmo o sjasmplus. Z80ASM tiene sus propias peculiaridades (formato de las etiquetas, macros, directivas de ensamblador, etc), que podemos encontrar documentadas en la siguiente URL:


Z88DK permite utilizar ASM de diferentes formas. En nuestro caso vamos a ver cómo se embebe C en ASM con las directivas #asm y #endasm:

    // Codigo en C
 
    #asm
        ; codigo en ensamblador
    #endasm
 
    // Codigo en C


Ya hemos visto cómo embeber código ASM en cualquier lugar de nuestro programa.

Lo normal es que ese código ASM lo queramos llamar desde otras partes del programa en C, es decir, que queramos crear “funciones” (rutinas) íntegramente en ASM pero llamables como funciones de C. Para eso, simplemente creamos nuestra función y

void ClearScreen()
{
  #asm
    xor a
    ld hl, 16384          ; HL = Inicio de la videoram
    ld (hl), a            ; Escribimos el patron A en (HL)
    ld de, 16385          ; Apuntamos DE a 16385
    ld bc, 192*32-1       ; Copiaremos 192*32-1 veces (HL) en (DE)
    ldir                  
  #endasm
}

No es necesario hacer PUSH y POP de los registros para preservar sus valores porque Z88DK lo hace automáticamente por nosotros.

Podremos llamar a esta función desde nuestro código en C como a cualquier otra función:

    ClearScreen();

Este ejemplo es muy sencillo y no ha necesitado parámetros de entrada, pero en muchas ocasiones necesitaremos poder pasar parámetros en las llamadas de las funciones, y recibir valores desde las mismas.


En C (y en otros lenguajes de programación) los parámetros se insertan normalmente en la pila en el orden en que aparecen en el código del programa. La subrutina debe utilizar el registro SP (sin modificarlo, y sin desapilar los valores) para acceder a los valores apilados en orden inverso, leyendo sus valores de la memoria. Estos valores son siempre de 16 bits aunque las variables pasadas sean de 8 bits (en este caso ignoraremos el byte que no contiene datos, el segundo).

Veamos unos ejemplos:

//-----------------------------------------------------------------
// Sea parte de nuestro programa en C:
 
  int jugador_x, jugador_y;
 
  jugador_x = 10;
  jugador_y = 200;
  Funcion( jugador_x, jugador_y );
  (...)
 
 
//-----------------------------------------------------------------
int Funcion(int x, int y)
{
 
#asm
    ld hl, 2
    add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                        ; en la pila por el compilador (valor de Y)
 
    ld c, (hl)
    inc hl
    ld b, (hl)
    inc hl              ; Ahora BC = y
 
    ld e, (hl)
    inc hl
    ld d, (hl)
    inc hl              ; Ahora, DE = x
 
    ;;; (ahora hacemos lo que queramos en asm)
 
#endasm
}

Como hemos comentado en un apartado anterior, no tenemos que preocuparnos por hacer PUSH y POP de los registros para preservar su valor dado que Z88DK lo hace automáticamente antes y después de cada #asm y #endasm.

El problema es que conforme crece el número de parámetros apilados, es posible que tengamos que hacer malabarismos para almacenarlos, dado que no podemos usar HL (es nuestro puntero a la pila en las lecturas). Veamos el siguiente ejemplo con 3 parámetros, donde tenemos que usar PUSH para guardar el valor de DE y ex de, hl para acabar asociando el valor final a HL:

//-----------------------------------------------------------------
int Funcion(int x, int y, int z)
{
 
#asm
    ld hl, 2
    add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                        ; en la pila por el compilador (z)
    ld c, (hl)
    inc hl
    ld b, (hl)
    inc hl              ; Ahora BC = z
 
    ld e, (hl)
    inc hl
    ld d, (hl)
    inc hl              ; Ahora, DE = y
 
    push de             ; Guardamos DE
 
    ld e, (hl)
    inc hl
    ld d, (hl)
    inc hl              ; Usamos DE para leer el valor de x
 
    ex de, hl           ; Ahora cambiamos x a HL
    pop de              ; Y recuperamos el valor de y en DE
 
    ;;; (ahora hacemos lo que queramos en asm)
 
#endasm
}

La manera de leer bytes (variables de tipo char) pulsados en C es de la misma forma que leemos una palabra de 16 bits, pero ignorando la parte alta. En realidad, como la pila es de 16 bits, el compilador convierte el dato de 8 bits en uno de 16 (rellenando con ceros) y mete en la pila este valor:

//-----------------------------------------------------------------
int Funcion(char x, char y)
{
 
#asm
    ld hl, 2
    add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                        ; en la pila por el compilador (y)
 
    ld a, (hl)          ; Aquí tenemos nuestro dato de 8 bits (y)
    ld b, a
    inc hl
    inc hl              ; La parte alta del byte no nos interesa
 
    ld a, (hl)          ; Aquí tenemos nuestro dato de 8 bits (x)
    ld c, a
    ; Si hubiera más parámetros, necesitaríamos seguir haciendo dos
    ; "inc hl" para acceder a ellos. Como es el último, no es necesario.
 
    ;;; (ahora hacemos lo que queramos en asm)
#endasm
}

Por ejemplo, veamos nuestra rutina anterior de ClearScreen() permitiendo pasar un parámetro para indicar el carácter a utilizar para el rellenado de la pantalla:

void ClearScreenValue(char value)
{
  #asm
    ld hl, 2
    add hl, sp            ; Ahora SP apunta al ultimo parametro metido
                          ; en la pila por el compilador (value)
    ld a, (hl)            ; Aquí tenemos nuestro dato de 8 bits (value)
 
    ld hl, 16384          ; HL = Inicio de la videoram
    ld (hl), a            ; Escribimos el patron A en (HL)
    ld de, 16385          ; Apuntamos DE a 16385
    ld bc, 192*32-1       ; Copiaremos 192*32-1 veces (HL) en (DE)
    ldir                  
  #endasm
}

En ocasiones, es posible que incluso tengamos que utilizar variables auxiliares de memoria para guardar datos:

//-----------------------------------------------------------------
int Funcion(int x, int y, char z)
{
 
#asm
    ld hl, 2
    add hl, sp          ; Ahora SP apunta al ultimo parametro metido
                        ; en la pila por el compilador (z)
 
    ld c, (hl)
    inc hl
    ld b, (hl)
    inc hl              ; Ahora BC = y
    ld (valor_y), bc    ; nos lo guardamos, bc libre de nuevo
 
    ld c, (hl)
    inc hl
    ld b, (hl)
    inc hl
    ld (valor_x), bc    ; Nos lo guardamos, bc libre de nuevo
 
    ld a, (hl)
    ld (valor_z), a     ; Nos guardamos el byte
    inc hl
    inc hl              ; La parte alta del byte no nos interesa
 
    ;;; (ahora hacemos lo que queramos en asm)
 
    ret
 
valor_x   DW  0
valor_y   DW  0
valor_z   DB  0
 
#endasm
}


Por contra, para devolver valores no se utiliza la pila dado que no podemos tocarla, ya que el RET trataría de volver al valor pulsado y no a la dirección de retorno. Lo que se hace en Z88DK es utilizar un determinado registro. En el caso de Z88DK, se utiliza el registro HL. Si la función es de tipo INT o CHAR en cuanto a devolución, el valor que dejemos en HL al finalizar la función será el que se asignará en una llamada de este tipo:

valor = MiFuncion_ASM(x, y, z);



Para aprovechar esta introducción de “uso de ASM en Z88DK”, veamos el código de algunos ejemplos de funciones en C que usen ASM internamente y que muestren, entre otras cosas, la lectura de parámetros de la pila, el acceso a variables del código C, el uso de etiquetas, o la devolución de valores.

Como ejemplo de función sin parámetros, y que tampoco devuelve ningún valor, además del ClearScreen() que hemos visto al principio de este capítulo, podemos examinar la siguiente función de “Fundido de pantalla a negro”, la cual hace uso de etiquetas de Z88DK, que van precedidas de un carácter punto en lugar del sufijo de los dos puntos que usamos normalmente en programas ensambladores:

//
// Realización de un fundido de la pantalla hacia negro
// Con esta función se muestra el uso de etiquetas. Nótese
// como en lugar de escribirse como ":", se escriben sin
// ellos y con un punto "." delante.
//
void FadeScreen(void)
{
 
#asm
    ld b, 9                      ; Repetiremos el bucle 9 veces
 
.fadescreen_loop1
    ld hl, 16384+6144            ; Apuntamos HL a la zona de atributos
    ld de, 768                   ; Iteraciones bucle
 
    halt
    halt                         ; Ralentizamos el efecto
 
.fadescreen_loop2
    ld a, (hl)                   ; Cogemos el atributo
    and 127                      ; Eliminamos el bit de flash
    ld c, a
 
    and 7                        ; Extraemos la tinta (and 00000111b)
    jr z, fadescreen_ink_zero    ; Si la tinta ya es cero, no hacemos nada
 
    dec a                        ; Si no es cero, decrementamos su valor
 
.fadescreen_ink_zero
 
    ex af, af                    ; Nos hacemos una copia de la tinta en A
    ld a, c                      ; Recuperamos el atributo
    sra a
    sra a                        ; Pasamos los bits de paper a 0-2
    sra a                        ; con 3 instrucciones de desplazamiento >>
 
    and 7                        ; Eliminamos el resto de bits
    jr z, fadescreen_paper_zero  ; Si ya es cero, no lo decrementamos
 
    dec a                        ; Lo decrementamos
 
.fadescreen_paper_zero
    sla a
    sla a                        ; Volvemos a color paper en bits 3-5
    sla a                        ; Con 3 instrucciones de desplazamiento <<
 
    ld c, a                      ; Guardamos el papel decrementado en A
    ex af, af                    ; Recuperamos A
    or c                         ; A = A or c  =  PAPEL OR TINTA
 
    ld (hl), a                   ; Almacenamos el atributo modificado
    inc hl                       ; Avanzamos puntero de memoria
 
    dec de
    ld a, d
    or e
    jp nz, fadescreen_loop2      ; Hasta que DE == 0
 
    djnz fadescreen_loop1        ; Repeticion 9 veces
 
#endasm
}

Un detalle a tener en cuenta, para demostrar esas pequeñas diferencias entre z80asm y pasmo, es que z80asm utiliza el nmemónico ex af, af mientras que pasmo requiere poner la comilla del shadow-register: ex af, af'.


Captura durante el fade de la pantalla

En la anterior captura podéis ver el aspecto de uno de los pasos del fundido.

A continuación vamos a ver una rutina que calcula la dirección de un atributo de pantalla dadas las coordenadas X,Y en baja resolución (0-31, 0-23) de un “bloque” 8×8 de la pantalla. La función recibe dos parámetros de tipo char (x e y). Para recoger los valores de estos parámetros con el objetivo de operar con ellos, ignora la parte alta de cada uno de los 2 parámetros en la pila.

El cálculo resultante se almacena en HL para que este valor sea devuelto a C, de forma que se asigne como resultado de la llamada.

//
// Devuelve la direccion de memoria del atributo de un caracter
// de pantalla, de coordenadas (x,y). Usando la dirección que
// devuelve esta función (en HL, devuelto en la llamada), podemos
// leer o cambiar los atributos de dicho carácter.
//
// Llamada:   valor = Get_LOWRES_Attrib_Address(1, 3);
//
int Get_LOWRES_Attrib_Address(char x, char y)
{
#asm
 
    ld hl, 2
    add hl, sp                 ; Leemos x e y de la pila
    ld  d, (hl)  ; d = y
    inc hl                     ; Primero "y" y luego "x".
    inc hl                     ; Como son "char", ignoramos parte alta.
    ld  e, (hl)  ; e = x
 
    ld h, 0
    ld l, d
    add hl, hl                 ; HL = HL*2
    add hl, hl                 ; HL = HL*4
    add hl, hl                 ; HL = HL*8
    add hl, hl                 ; HL = HL*16
    add hl, hl                 ; HL = HL*32
    ld d, 0
    add hl, de                 ; Ahora HL = (32*y)+x
    ld bc, 16384+6144          ; Ahora BC = offset attrib (0,0)
    add hl, bc                 ; Sumamos y devolvemos en HL
#endasm
}

El siguiente ejemplo muestra cómo acceder a variables globales del programa en C desde nuestro código en ASM, utilizando un “subrayado” (“_”) delante del nombre de la variable que queremos acceder

//
// Set Border
// Ejemplo de modificación del borde, muestra cómo leer variables
// globales de C en ASM, añadiendo "_" delante.
//
unsigned char bordeactual;
 
void BORDER(unsigned char value)
{
#asm
    ld hl, 2
    add hl, sp
    ld a, (hl)             ; A = parametro de la pila (value)
 
    ld c, $FE
    out (c), a
    ld (_bordeactual), a   ; Guardamos el valor del borde en la variable
#endasm
}


A continuación se muestra la función de descompresión RLE escrita en ASM embebido en una función de C. El ejemplo demuestra de nuevo la lectura de parámetros (en este caso, direcciones de memoria origen, destino, y un tamaño en 16 bits), así como las etiquetas que usa Z88DK, precedidas por punto.

int RLE_decompress_ASM(unsigned char *, unsigned char *, int);
 
//---------------------------------------------------------------------------
// RLE_decompress_ASM( src, dst, longitud );
//---------------------------------------------------------------------------
int RLE_decompress_ASM( unsigned char *src, unsigned char *dst, int length )
{
 
#asm
    ld hl,2
    add hl,sp
 
    ld c, (hl)
    inc hl
    ld b, (hl)
    inc hl                              // BC = lenght
 
    ld e, (hl)
    inc hl
    ld d, (hl)
    inc hl                              // de = dst
    push de
 
    ld e, (hl)
    inc hl
    ld d, (hl)
    inc hl                              // de = src
 
    ex de, hl
    pop de                              // now de = dst and hl = src
 
    // After this:  HL = source, DE = destination, BC = lenght of RLE data
 
.RLE_dec_loop
    ld a,(hl)                          // leemos un byte
 
    cp 192
    jp nc, RLE_dec_compressed          // si byte > 192 = está comprimido
    ld (de), a                         // si no está comprimido, escribirlo
    inc de
    inc hl
    dec bc
 
.RLE_dec_loop2
    ld a,b
    or c
    jr nz, RLE_dec_loop
    ret                                 // miramos si hemos acabado
 
.RLE_dec_compressed                    // bucle para descompresión
    push bc
    and 63                              // cogemos el numero de repeticiones
    ld b, a                             // lo salvamos en B
    inc hl                              // y leemos otro byte (dato a repetir)
    ld a, (hl)
 
.RLE_dec_loop3
    ld (de),a                           // bucle de escritura del dato B veces
    inc de
    djnz RLE_dec_loop3
    inc hl
    pop bc                              // recuperamos BC
    dec bc                              // Este dec bc puede hacer BC=0 si los datos
                                        // RLE no correctos. Cuidado (mem-smashing).
    dec bc
    jr RLE_dec_loop2
    ret
 
#endasm
}

Podemos paginar memoria también desde C usando Z88DK mediante un código como el siguiente:

//--- SetRAMBank ------------------------------------------------------
//
// Se mapea el banco (0-7) indicado sobre $c000.
//
// Ojo: en esta función no se deshabilitan las interrupciones y además, 
// en lugar de usar el registro B como parámetro, se recibe por la pila.
//
void SetRAMBank(char banco)
{
  #asm
    ld hl, 2
    add hl, sp        ; SP apunta a la posicion de "banco" en la pila
    ld a, (hl)        ; Leemos parte baja de "banco" de la pila
                      ; Es 8 bits, la parte alta es 00, la ignoramos
 
    ld b, a
    ld a, ($5b5c)
    and f8h
    or b
    ld bc, $7ffd
    ld ($5b5c), a
    out (c), a        ; Realizamos cambio de banco
   #endasm
}

Con el anterior código podemos mapear uno de los bancos de memoria de 16KB sobre la página que va desde $c000 a $ffff, pero debido al uso de memoria, variables y estructuras internas que hace Z88DK, debemos seguir una serie de consideraciones.

  • Todo el código en ejecución debe estar por debajo de $c000, para lo cual es recomendable definir los gráficos al final del “binario”.
  • Es importantísimo colocar la pila en la memoria baja, mediante la siguiente instrucción (o similar, según la dirección en que queremos colocarla) al principio de nuestro programa:


/* Allocate space for the stack */
#pragma output STACKPTR=24500


La regla general es asegurarse de que no haya nada importante (para la ejecución de nuestro programa) en el bloque $c000 a $ffff cuando se haga el cambio: ni la pila, ni código al que debamos acceder. Tan sólo datos que puedan ser intercambiandos de un banco a otro sin riesgo para la ejecución del mismo (por ejemplo, los datos de un nivel de juego en el que ya no estamos).


  • cursos/ensamblador/asmz88dk.txt
  • Última modificación: 21-01-2024 16:52
  • por sromero