cursos:ensamblador:codigo_maquina

Código Máquina y Lenguaje Ensamblador

Este capítulo está diseñado para contestar a la siguiente pregunta: ¿Qué es el código máquina y en qué se diferencia del ensamblador?.

Veremos cómo los programas ensambladores convierten nuestro código fuente en lenguaje ensamblador, en un código máquina utilizable directamente por el Spectrum, y cómo podemos cargar dicho código en el Spectrum y “ejecutarlo”.

Veremos también la sintaxis utilizada en los programas en ensamblador. Para ello comenzaremos con una definición general de la sintaxis para el ensamblador Pasmo, que será el “traductor” que usaremos entre el lenguaje ensamblador y el código máquina del Z80.

Esta entrega del curso es delicada y complicada: por un lado, tenemos que explicar las normas y sintaxis del ensamblador cruzado Pasmo antes de que conozcamos la sintaxis del lenguaje ensamblador en sí, y por el otro, no podremos utilizar Pasmo hasta que conozcamos la sintaxis del lenguaje. Esto implica que hablaremos de Pasmo comentando reglas, opciones de instrucciones y directivas que todavía no conocemos. Por eso, en ocasiones volver atrás a releer algún concepto ayudará a rellenar huecos y lagunas en la compresión global de la programación en ensamblador.


El microprocesador Z80 (Z80A en el caso del Spectrum) es un pequeño chip de 40 pines de conexión, cada uno de los cuales está conectada a diferentes señales. Uno de los pines es la alimentación eléctrica, otro la conexión al reloj/cristal de 3.50Mhz, 8 pines suponen el bus de datos y 16 el bus de direcciones, etc.


| Z80A

Estas “patillas” de datos y direcciones están físicamente conectadas a través de pistas eléctricas a la memoria, el teclado, el cassette, etc. Utilizando las patillas de direcciones el procesador selecciona “posiciones de memoria” en la memoria, y recibe las instrucciones de los programas a través de las 8 señales del bus de datos.

Una señal (el estado de cada una de las patillas del micro en un instante concreto) puede tener 2 estados: sin tensión eléctrica (0 Voltios físicos, o señal lógica “0”), o con tensión eléctrica (5 Voltios físicos, o señal lógica “1”). El procesador recibe a través de las 8 patillas del bus de datos 8 señales que conforman una ristra de unos y ceros como puedan serlo 01000100 o 11001100, por ejemplo.

Los diseñadores del Z80 le otorgaron mediante circuitos en su interior una serie de registros de almacenamiento (A, B, C, D, E, F, H, L, etc.) que pueden alojar números, y la capacidad de ejecutar una serie de instrucciones (sumar, restar, comparar, etc.) entre ellos (y también entre ellos y otras posiciones de memoria).

Cada posible conjunto de señales entre 00000000 y 11111111 se corresponde con una de estas posibles operaciones mediante un “diccionario interno” que le dice al Z80 qué debe de hacer según la instrucción que se le está solicitando.

Cuando el microprocesador obtiene de la memoria la siguiente instrucción del programa a ejecutar y obtiene, por ejemplo, un conjunto de señales “01010000”, el Z80 sabe que tiene que sumar el contenido de su registro interno A con el del registro interno B, y dejar el resultado en A.

Es decir, entiende un número binario de 8 digítos que recibe en forma de señales binarias como una instrucción concreta a ejecutar. Este valor numérico es lo que se conoce como un Opcode o código de operación, ya que un código (01010000) le indica al procesador qué operación ejecutar (A = A + B).

Un programa en código máquina no es más que una ristra de código binarios de 8 dígitos (de instrucciones) que le indican al Z80 qué operaciones ejecutar en un orden concreto. El procesador leerá una a una la ristra de códigos binarios que forman el programa y ejecutará cada una de las instrucciones con que se corresponde cada código.

El Z80 utiliza un registro interno especial llamado PC (Program Counter o Contador de Programa) para saber cuál es la dirección de la instrucción actual con la que está trabajando y lo incrementa tras cada instrucción para poder seguir el flujo del programa.

Cuando arrancamos nuestro Spectrum, todos los registros del Z80 (A, B, C, PC, etc) valen 0, por lo que el Spectrum empieza a leer desde la memoria en la posición 0, instrucción tras instrucción, incrementando el valor de PC tras ejecutar cada una de ellas. Este programa “inicial” que ejecuta nuestro Spectrum es nada más y nada menos que el intérprete de BASIC, escrito para Sinclair por ingenierios de Nine Tiles Information Handlind Ltd.

Este código máquina con todo el programa que supone el intérprete BASIC está almacenado como ristra de instrucciones en un chip del Spectrum llamado ROM cuyo contenido no se borra al apagar el ordenador.

Programar en código máquina no es fácil, puesto que no es inmediata la correspondencia entre una ristra de unos y ceros y la instrucción que ejecutará el procesador. Una vez escrito un programa, es también muy complicado de depurar en busca de errores, puesto que todo lo que tenemos son miles o decenas de miles de ristras de 8 dígitos binarios.

Veamos algunas instrucciones en código máquina y el efecto que tienen en el procesador cuando le pedimos ejecutarlas:


Instrucción en hexadecimal Señales en bus de datos (binario) Instrucción ejecutada
$09 00001001 HL = HL + BC
$50 01010000 A = A + B
$3c 00111100 Incrementar A → A = A + 1
$3d 00111101 Decrementar A → A = A - 1


El conjunto completo de operaciones que puede realizar el procesador representado por los opcodes asociados a los mismos se conoce como juego de instrucciones del procesador.

Recordar todos los códigos de operación del juego de instrucciones es muy complejo y la programación en base a utilizar ristras de números es prácticamente inmanejable.

Veamos un ejemplo de programa en lenguaje máquina que rellena toda la pantalla del Spectrum con un patrón de colores:

00100001b 00000000b 01000000b 00111110b 10100010b 01110111b 00010001b 00000001b 01000000b 00000001b 11111111b 00011010b 11101101b 10110000b 11001001b

El mismo programa, representado en hexadecimal, sería:

$21, $00, $40, $3e, $a2, $77, $11, $01, $40, $01, $ff, $1a, $ed, $b0, $c9

Incomprensible en ambos casos, ¿verdad?

Este sería el resultado de ejecutar el programa anterior en un Spectrum:


Salida del programa de ejemplo

Por ejecutar, nos referimos a copiar la ristra de bytes anterior a memoria uno detrás de otro, así:

Dirección de memoria Valor en esa celdilla
40000 $21
40001 $00
40002 $40
40003 $3e
40014 $c9

Y una vez el programa está en memoria, pedirle al procesador que empiece a ejecutar instrucciones a partir del inicio del programa con un call 40000 (equivalente al GO SUB de BASIC, pero con una dirección de memoria en lugar de un número de línea).

Es evidente que, para un humano, recordar todos los “opcodes” (códigos de instrucción) y escribir los programa así es una tarea imposible. Debido a esta complejidad y dificultad, nunca se programa directamente en código máquina sino que se realiza en lenguaje ensamblador.


El lenguaje ensamblador es una “versión humana” del lenguaje máquina en la que asociamos un “nombre” (técnicamente conocido como mnenónico) a cada instrucción de 8 bits del procesador.

Así, en nuestro programa, en lugar de representar la operación A = A + B como 001010000, la representamos como add a, b, lo cual es mucho más legible e intuitivo a la hora de programar y depurar y sigue siendo igual de compacto, existiendo una correspondencia exacta de una instrucción ASM = una instrucción en código máquina.

De esta forma, podemos programar utilizando un conjunto de instrucciones en lenguaje “humano”, que no llegan a ser tan especializadas y de tanto alto nivel como en BASIC ya que el objetivo del lenguaje ensamblador es dotar de un nombre “legible” a cada microinstrucción disponible en el procesador.

Al programar en lenguaje ensamblador, lo hacemos pues en este lenguaje humano con instrucciones como add a, b, ld a, 20 o call subrutina.

Recordemos el ejemplo del apartado anterior para rellenar la pantalla con un patrón de gráficos y colores:

$21, $00, $40, $3e, $a2, $77, $11, $01, $40, $01, $ff, $1a, $ed, $b0, $c9

¿Cómo se escribiría este programa en lenguaje ensamblador? Así:

    ld hl, $4000
    ld a, $a2
    ld (hl), a
    ld de, $4001
    ld bc, $1aff
    ldir
    ret

El problema es que el microprocesador no entiende este lenguaje ensamblador, ya que él sólo entiende las señales de 8 dígitos binarios que lee de la memoria. En realidad, sólo entiende el lenguaje máquina que hemos visto en el apartado anterior, ya que lo que hace es leer valores numéricos de memoria, ver a qué opcode corresponde ese valor numérico (qué operación) y ejecutarla. No entiende lo que es el texto LD ni lo que es RET.

Para solucionar esto se necesita un programa llamado programa ensamblador o simplemente ensamblador o assembler, que lee nuestros programas en texto escritos en lenguaje ensamblador y convierte cada instrucción a su correspondiente instrucción en código máquina. El resultado de la conversión de cada instrucción se va almacenando de forma consecutiva para acabar obteniendo un bloque de datos que contiene la traducción a código máquina de todo el programa que hemos solicitado ensamblar.

Para realizar este proceso, el programa ensamblador se vale de una tabla de ensamblado que relaciona cada instrucción en ensamblador con la instrucción en código máquina que realiza la misma acción. Así, cuando lee en nuestro programa add a, b, lo traduce por un 001010000b que es lo que realmente almacena en el programa en código máquina resultante.

En resumen: como resultado de un proceso de ensamblado, el ensamblador convierte un programa en este “lenguaje ensamblador” a una ristra de dígitos binarios en memoria que se corresponden, en código máquina, con las instrucciones que nosotros hemos solicitado realizar al procesador en ensamblador. Ensamblando nuestro programa de ejemplo obtendremos un bloque de datos (en un fichero binario) con la ristra de números que hemos visto ya varias veces en los últimos ejemplos.

Una vez el programa está totalmente acabado (asumiendo que no tenga fallos y no sea necesario depurarlo) sólo es necesario realizar una vez el proceso de ensamblado. Por ejemplo, los programadores de un juego ensamblarán el listado del mismo, obtendrán una ristra de dígitos binarios en memoria, y la salvarán en cinta. Lo que se distribuye a los usuarios es el programa en código máquina que el Spectrum cargará en memoria y ejecutará.

El proceso de ensamblado puede ser manual: nosotros podemos utilizar una tabla de traducción instrucciones → opcodes y traducir manualmente cada instrucción en el opcode correspondiente. No obstante, lo más normal es utilizar un programa ensamblador, que automatiza este proceso por nosotros.

En este curso, programaremos nuestras rutinas o programas en lenguaje ensamblador en un fichero de texto con extensión .asm, y con un programa ensamblador cruzado ejecutándose en un PC o MAC lo traduciremos al código binario que entiende la CPU del Spectrum. Ese código binario puede ser ejecutado, instrucción a instrucción, por el Z80, realizando las tareas que nosotros le encomendemos en nuestro programa.

En este capítulo no vamos a ver todavía la sintaxis e instrucciones disponibles en el ensamblador del microprocesador Z80. Por ahora nos debe bastar conocer que el lenguaje ensamblador es mucho más limitado en cuanto a instrucciones que BASIC (no hay PRINT, no hay FOR, e instrucciones similares), y que, a base de pequeñas piezas, debemos montar nuestro programa entero, que será sin duda mucho más rápido en cuanto a ejecución.

Como las piezas de construcción son tan pequeñas, para hacer tareas que son muy sencillas en BASIC, en ensamblador necesitaremos muchas líneas de programa, es por eso que los programas en ensamblador en general requieren más tiempo de desarrollo y se vuelven más complicados de mantener (de realizar cambios, modificaciones) y de leer conforme crecen. Debido a esto cobra especial importancia hacer un diseño en papel de los bloques del programa (y seguirlo) antes de programar una sóla línea del mismo. También se hacen especialmente importantes los comentarios que introduzcamos en nuestro código, ya que clarificarán su lectura en el futuro. El diseño es CLAVE y VITAL a la hora de programar: sólo se debe implementar lo que está diseñado previamente, y cualquier modificación de las especificaciones debe resultar en una modificación del diseño.

Así pues, resumiendo, lo que haremos a lo largo de este curso será aprender la arquitectura interna del Spectrum, su funcionamiento a nivel de CPU, y los fundamentos de su lenguaje ensamblador, con el objetivo de programar rutinas que integraremos en nuestros programas BASIC, o bien programas completos en ensamblador que serán totalmente independientes del lenguaje BASIC.


Supongamos que sabemos ensamblador y queremos mejorar la velocidad de un programa BASIC utilizando una rutina en código máquina. El lector se preguntará: “¿cómo podemos hacer esto?”.

La integración de rutinas en código máquina dentro de programas BASIC se realiza a grandes rasgos de la siguiente forma:

Primero escribimos nuestra rutina en ensamblador, por ejemplo una rutina que realiza un borrado de la pantalla mucho más rápidamente que realizarlo en BASIC, o una rutina de impresión de Sprites o gráficos, etc.

Una vez escrito el programa o la rutina, la ensamblamos (de la manera que sea: manualmente o mediante un programa ensamblador) y obtenemos en lugar del código ASM una serie de valores numéricos que representan los códigos de instrucción en código máquina que se corresponden con nuestro listado ASM.

La siguiente figura muestra a título de ejemplo parte de una tabla de ensamblado manual, como la que utilizaban en la década de los 80 y 90 los programadores que no podían comprar un software ensamblador:


Parte de una tabla de ensamblado manual

Utilizando la anterior tabla, o bien un programa ensamblador, transformamos nuestro programa ensamblador en código máquina.

Tras el proceso de ensamblado y la obtención del código máquina, nuestro programa en BASIC debe cargar esos valores en memoria (mediante LOAD “” CODE o mediante instrucciones POKE) y después saltar a la dirección donde hemos POKEADO la rutina para ejecutarla.

Veamos un ejemplo de todo esto. Supongamos el siguiente programa en BASIC, que hace lo mismo que el ejemplo que hemos visto en los 2 apartados anteriores, es decir, rellenar toda la pantalla con un patrón de píxeles determinado:

10 FOR n=16384 TO 23295
20 POKE n, 162
30 NEXT n

Si ejecutamos el programa en BASIC veremos (de nuevo) el patrón siguiente:


Salida del programa BASIC de ejemplo

Una vez tecleado y ejecutado el programa, si medimos el tiempo necesario para “dibujar” toda la pantalla obtendremos que tarda aproximadamente 1 minuto y 15 segundos.

A continuación tomemos el mismo programa escrito en lenguaje ensamblador:

; Listado 2: Rellenado de pantalla
    ORG 40000
 
    ld hl, $4000
    ld a, $a2
    ld (hl), a
    ld de, $4001
    ld bc, $1aff
    ldir
    ret

Si ensamblamos este programa con un programa ensamblador y lo ejecutamos, veremos que tarda menos de 1 segundo en ejecutar la misma tarea. El tiempo de ejecución es prácticamente inapreciable. Es en ejemplos tan sencillos como este donde podemos ver la diferencia de velocidad entre BASIC y ASM.

Supongamos que ensamblamos a mano el listado anterior, mediante una tabla de conversión de Instrucciones ASM a Códigos de Operación (opcodes) del Z80, ensamblando manualmente (tenemos una tabla de conversión en el mismo manual del +2, por ejemplo).

Ensamblar a mano, como ya hemos dicho, consiste en escribir el programa y después traducirlo a códigos de operación consultando una tabla que nos dé el código correspondiente a cada instrucción en ensamblador. Utilizamos cualquier tabla de ensamblado manual (ya sea online, o la que incluye el propio manual del +2A, por ejemplo) y obtendremos el siguiente código máquina (una rutina de 15 bytes de tamaño):

$21, $00, $40, $3e, $a2, $77, $11, $01, $40, $01, $ff, $1a, $ed, $b0, $c9

Si no queremos ensamblar a mano podemos ensamblar el programa con un ensamblador como pasmo (pasmo --bin ejemplo.asm ejemplo.bin) y después obtener esos números abriendo el fichero .bin resultante con un editor hexadecimal (que no de texto), para poder obtener los valores numéricos de cada byte del fichero.

Como ya hemos visto en la definición de “código máquina”, esta extraña ristra de bytes para nosotros incomprensible tiene un total significado para nuestro Spectrum: cuando él encuentra, por ejemplo, los bytes $3e $a2, sabe que eso quiere decir ld a, $a2; cuando encuentra el byte $c9, sabe que tiene que ejecutar un RET, y así con todas las demás instrucciones.

A continuación vamos a BASIC y tecleamos el siguiente programa:

10 CLEAR 39999
20 DATA 33, 0, 64, 62, 162, 119, 17, 1, 64, 1, 255, 26, 237, 176, 201
30 FOR n=0 TO 14
40 READ I
50 POKE (40000+n), I
60 NEXT n

El objetivo de este programa es guardar a partir de la dirección 40000 los diferentes bytes del DATA (usando POKE), almacenando así nuestra rutina pre-ensamblada, en memoria.

Una vez tecleado el programa, ejecutamos:

RUN

Tras ejecutar el programa, si examinamos la memoria del Spectrum a partir de la dirección 40000 veremos los siguientes valores:

Dirección Valor Significado
40000 $21 ld hl,
40001 $00 parte baja de $4000
40002 $40 parte alta de $4000
40003 $3e ld a,
40004 $a2 $a2
40014 $c9 ret

Ya tenemos nuestro programa en memoria, a partir de la dirección 40000. Nótese la pecularidad de que los números de 16 bits se escriben en memoria al revés, es decir, para almacenar $4000, el Z80 guarda primero la parte baja ($00) y luego la alta ($40). Más adelante hablaremos sobre esta característica del Z80.

Lo importante es que en este momento, nuestro programa BASIC ha cargado el código máquina en memoria y podemos ejecutarlo con:

RANDOMIZE USR 40000

El comando RUN ejecutó el código BASIC (el cual POKEa en memoria el código ensamblador a partir de la dirección 40000) y el RANDOMIZE USR 40000 lo que provoca es la ejecución de la rutina posicionada en dicha dirección, que justo es la rutina que hemos ensamblado a mano y pokeado mediante el programa en BASIC.

Lo que hemos hecho en este ejercicio es:


  • Con el CLEAR nos aseguramos de que tenemos libre la memoria desde 40000 hacia arriba (hacemos que BASIC se situe por debajo de esa memoria).
  • La línea DATA contiene el código máquina de nuestra rutina.
  • Con el bucle FOR hemos POKE-ado la rutina en memoria a partir de la dirección 40000 (desde 40000 a 40015).
  • El RANDOMIZE USR 40000 salta la ejecución del Z80 a la dirección 40000, donde está nuestra rutina. Recordad que nuestra rutina acaba con un RET, que es una instrucción de retorno que finaliza la rutina y realiza una “vuelta” al BASIC.


Siguiendo este mismo procedimiento podemos generar todas las rutinas que necesitemos y ensamblarlas, obteniendo ristras de código máquina que meteremos en un o varias sentencias DATA y pokearemos en memoria.

Otra opción, para evitar DATA y POKE, es grabar en cinta el fichero BIN resultante del ensamblado (convertido a TAP) tras nuestro programa en BASIC, y realizar en nuestro programa un LOAD “” CODE DIRECCION_DESTINO de forma que carguemos todo el código binario ensamblado en memoria.

Podemos así realizar muchas rutinas en un mismo fichero ASM y ensamblarlas y cargarlas en memoria de una sola vez. Tras tenerlas en memoria, tan sólo necesitaremos saber la dirección de inicio de cada una de las rutinas para llamarlas con el RANDOMIZE USR DIRECCION_RUTINA correspondiente en cualquier momento de nuestro programa BASIC.

Para hacer esto, ese fichero ASM podría tener una forma como la siguiente:

    ; La rutina 1
    ORG 40000
 
rutina1:
    (...)
    ret
 
    ; La rutina 2
    ORG 41000
 
rutina2:
    (...)
    ret

También podemos ensamblarlas por separado y después cargarlas con varios LOAD “” CODE en las diferentes direcciones.

Hay que tener mucho cuidado a la hora de teclear los DATA (y de ensamblar) si lo hacemos a mano, porque equivocarnos en un sólo número cambiaría totalmente el significado del programa, por lo que este no hará lo que debería haber hecho el programa correctamente pokeado en memoria.

Un detalle más avanzado sobre ejecutar rutinas desde BASIC es el hecho de que podamos necesitar pasar parámetros a una rutina, o recibir un valor de retorno desde una rutina.

Pasar parámetros a una rutina significa indicarle a la rutina uno o más valores para que haga algo con ellos. Por ejemplo, si tenemos una rutina que borra la pantalla con un determinado patrón o color, podría ser interesante poder pasarle a la rutina el valor a escribir en memoria (el patrón). Esto se puede hacer de muchas formas: la más sencilla sería utilizar una posición libre de memoria para escribir el patrón, y que la rutina lea de ella. Por ejemplo, si cargamos nuestro código máquina en la dirección 40000 y consecutivas, podemos por ejemplo usar la dirección 50000 para escribir uno (o más) parámetros para las rutinas:

; Listado 3: Rellenado de pantalla
; recibiendo el patron como parametro.
 
    ORG 40000
 
    ; En vez de 162, ponemos en A lo que hay en la
    ; dirección de memoria 50000
    ld a, (50000)
 
    ; El resto del programa es igual:
    ld hl, $4000
    ld (hl), a
    ld de, $4001
    ld bc, 6911
    ldir
    ret

Nuestro programa en BASIC a la hora de llamar a esta rutina (una vez ensamblada y pokeada en memoria) haría:

    POKE 50000, 162
    RANDOMIZE USR 40000

Este código produciría la misma ejecución que el ejemplo anterior, porque como parámetro estamos pasando el valor 162, pero podríamos llamar de nuevo a la misma función en cualquier otro punto de nuestro programa pasando otro parámetro diferente a la misma, cambiando el valor de la dirección 50000 de la memoria. Esto rellenaría la pantalla con un patrón que deseemos, pudiendo ser éste diferente del utilizado en el anterior ejemplo, simplemente variando el valor pokeado en la dirección 50000 (el parámetro de la rutina).

En el caso de necesitar más de un parámetro, podemos usar direcciones consecutivas de memoria: en una rutina de dibujado de sprites, podemos pasar la X en la dirección 50000, la Y en la 50001, y en la 50002 y 50003 la dirección en memoria (2 bytes porque las direcciones de memoria son de 16 bits) donde tenemos el Sprite a dibujar, por ejemplo. Todo eso lo veremos con más detalle en posteriores capítulos. En este ejemplo hemos utilizado la dirección 50000, pero lo normal es utilizar direcciones concretas y reservadas dentro del propio programa ensamblado para asegurar que no hay colisión con otras rutinas que pueda haber o podamos necesitar instalar en la dirección 50000.

Además de recibir parámetros, puede sernos interesante la posibilidad de devolver a BASIC el resultado de la ejecución de nuestro programa. Por ejemplo, supongamos que realizamos una rutina en ensamblador que hace un determinado cálculo y debe devolver, tras todo el proceso, un valor. Ese valor lo queremos asignar a una variable de nuestro programa BASIC para continuar trabajando con él.

Un ejemplo: imaginemos que realizamos una rutina que calcula el factorial de un número de una manera mucho más rapida que su equivalente en BASIC. Para devolver el valor a BASIC en nuestra rutina ASM, una vez realizados los cálculos, debemos dejarlo dentro del registro BC justo antes de hacer el ret. Una vez programada la rutina y pokeada, la llamamos mediante:

LET VALOR=USR 40000

Con esto la variable de BASIC “VALOR” contendrá la salida de nuestra rutina (concretamente, el valor del registro BC antes de ejecutar el ret). Las rutinas sólo pueden devolver un valor (el registro BC), aunque siempre podemos (dentro de nuestra rutina BASIC) escribir valores en direcciones de memoria y leerlos después con PEEK dentro de BASIC (al igual que hacemos para pasar parámetros).


Lo que hemos visto hasta ahora es que podemos programar pequeñas rutinas y llamarlas desde programas en BASIC fácilmente. Todavía no hemos aprendido nada del lenguaje en sí mismo, pero se han asentado algunos de los conceptos necesarios para los próximos capítulos.

En realidad, muchos de nosotros seguramente hemos introducido código máquina en nuestros Spectrums sin saberlo, cuando tecleabamos los listados de programa que venían en la fabulosa revista Microhobby. Muchos de los programas BASIC publicados en la revista tenían rutinas en código máquina que después eran llamadas desde el BASIC.

Algunas veces introducíamos el código máquina en forma de DATA, integrados en el programa BASIC que estábamos tecleando, pero otras lo hacíamos mediante el famoso Cargador Universal de Código Máquina (CUCM).

Para que os hagáis una idea de qué era el CUCM de Microhobby, no era más que un programa en el cual tecleabamos los códigos binarios de rutinas ASM ensambladas previamente. Se tecleaba una larga línea de números en hexadecimal agrupados juntos, y cada 10 bytes o pares de dígitos se debía introducir un número a modo de CRC que aseguraba que los 10 dígitos (20 caracteres) anteriores habían sido introducidos correctamente. Este CRC podía no ser más que la suma de todos los valores anteriores, para asegurarse de que no habíamos tecleado incorrectamente el listado.


Ejemplo de listado para el CUCM de MH

Al acabar la introducción en todo el listado en el CUCM, se nos daba la opción de grabarlo. Al grabarlo indicábamos el tamaño de la rutina en bytes y la dirección donde la ibamos a alojar en memoria (en el ejemplo de la captura, la rutina se alojaría en la dirección 53000 y tenía 115 bytes de tamaño). El CUCM todo lo que hacía era un simple:

SAVE "DATOS.BIN" CODE 53000, 115

Esto grababa el bloque de código máquina en cinta (justo tras nuestro programa en BASIC), de forma que el juego en algún momento cargaba esta rutina con LOAD “” CODE, y podía utilizarla mediante un RANDOMIZE USR 53000 (o, si el código contenía más de una rutina, saltando a la dirección adecuada de cada una de ellas, en este caso desde 53000 a 53114.


El lector se preguntará: “Ensamblar programas a mano es muy costoso y complejo, ¿cómo vamos a ensamblar los listados que veamos a lo largo del curso, o los que yo realice para ir practicando o para que sean mis propias rutinas o programas?”.

Sencillo: lo haremos con un programa ensamblador cruzado. Un ensamblador cruzado nos permitirá programar en un PC o MAC (utilizando nuestro editor de textos habitual), y después ensamblar ese fichero .asm que hemos realizado, obteniendo un fichero .BIN (o directamente un .TAP), que cargar en un emulador.

Los programadores “originales” en la época del Spectrum tenían que utilizar programas ensamblador nativos como MONS y GENS para todo el proceso de desarrollo. Estos programas (que corren sobre el Spectrum) implicaban teclear los programas en el teclado del Spectrum, grabarlos en cinta, ensamblar y grabar el resultado en cinta, etc. Actualmente es mucho más cómodo usar ensambladores cruzados como los que usaremos en nuestro curso.

Hoy en día lo normal es utilizar un ensamblador cruzado que se ejecute en un sistema moderno, para permitirnos usar herramientas de edición modernas (como Visual Studio Code, o Sublime Text Editor) pero a su vez obtener como resultado del ensamblado un “binario” para ZX Spectrum. Con un ensamblador cruzado podremos programar en nuestro PC con nuestro editor de texto favorito, grabar un fichero ASM y ensamblarlo cómodamente, probándolo en un emulador, sin cintas de por medio. Tras el proceso de desarrollo, podremos llevar el programa resultante a una cinta (o disco) y ejecutarlo por lo tanto en un Spectrum real, pero lo que es el proceso de desarrollo se realiza en un PC, con toda la comodidad que eso conlleva.

En el momento de escribir estas líneas disponemos de tres opciones recomendables como ensamblador cruzado:

  1. pasmo: ensamblador cruzado opensource y multiplataforma.

  2. sjasmplus: completísimo ensamblador cruzado, en desarrollo continúo con una gran cantidad de directivas, soporte de macros, etc.

  3. z80asm: z80asm (o, mejor dicho z88dk-z80asm) es el ensamblador que viene incluído con el compilador de C Z88DK, y será el que tendremos que utilizar (en cuanto a peculiaridades concretas de su sintaxis) cuando programemos ensamblador embebido en rutinas en C, o en rutinas externas que queramos que sean compiladas y llamables desde un programa en C.

El ensamblador cruzado que usaremos en este curso es Pasmo, debido a que es muy sencillo de instalar y de utilizar, y podemos ejecutarlo tanto en Windows, como en MAC OS, como en Linux. Pasmo en su versión para Windows/DOS es un simple ejecutable (pasmo.exe) acompañado de ficheros README de información. Podemos mover el fichero pasmo.exe a cualquier directorio que esté en el PATH o directamente ensamblar programas (siempre desde la línea de comandos o CMD, no directamente mediante “doble click” al ejecutable) en el directorio en el que lo tengamos copiado.

La versión para Linux viene en formato código fuente (y se compila con un simple make) y su binario “pasmo” lo podemos copiar, por ejemplo, en /usr/local/bin.

Aunque para proyectos completos prefiero utilizar sjasmplus (proyecto en constante desarrollo y en general con más funcionalidades que pasmo), la sencillez con la que se puede ensamblar cualquier programa en pasmo y su opción de crear un TAP con cargador integrado con un simple parámetro de línea de comandos lo hace la herramienta perfecta para seguir este curso.

El lector se preguntará que cuál es la importancia de elegir uno u otro programa ensamblador, si el lenguaje ensamblador es propio del Z80 y debería ser el mismo para los 3 programas. Esto es así para las instrucciones del procesador (LD, call, PUSH, POP…) y también para las directivas más básicas del ensamblador (EQU, DB, ORG…), pero por desgracia cada uno de estos programas puede tratar las cosas de forma diferente (etiquetas temporales, macros, directivas condicionales…).

Esto implica que si utilizamos funciones “avanzadas” de sjasmplus (como MACROs en lenguaje LUA) no podremos ensamblar el programa con pasmo, por ejemplo. Algunas de esas funciones avanzadas están disponibles en varios de ellos, como las directivas INCLUDE (para incluir otro fichero ASM dentro del fichero actual) o INCBIN (para incluir un binario como un sprite o un sonido en el punto en que invocamos esta directiva), pero otras directivas que suponen ayudas a la programación pueden estar sólo disponibles en uno de ellos.

Normalmente, la elección de un ensamblador cruzado suele ser por obligación (“z80asm” si vamos a trabajar con z88dk) o por preferencia (pura elección entre “pasmo” o “sjasmplus” según nos guste más lo que nos ofrece uno u otro), pero no hay que preocuparse mucho porque lo normal es que elijamos el que elijamos, no nos encontremos ninguna limitación para programar.

Mi sugerencia es que sigas el curso con Pasmo (por la facilidad para ensamblar rutinas) y que no decidas entre pasmo y sjasmplus hasta que te enfrentes a tu primer programa “grande”. En ese momento (con los conocimientos de ensamblador ya adquiridos) es cuando tendrás la capacidad de elegir uno u otro en base a ver la documentación en línea de ambos. Intentar tomar esa decisión ahora sería absurdo, por lo que lo mejor que se puede hacer en este punto es empezar a trabajar con pasmo.



Generando código binario para programas BASIC desde Pasmo

Veamos ahora cómo se ensamblaría en pasmo el programa que vimos en el apartado de integración de BASIC y ASM.

Primero tecleamos el programa en un fichero de texto y después utilizamos pasmo para ensamblarlo de forma que no nos genere un TAP para el emulador, sino sólo el código máquina resultante:

pasmo --bin ejemplo1.asm ejemplo1.bin

Como resultado del proceso de ensamblado obtendremos un fichero .bin que contiene el código máquina que podremos utilizar directamente en los DATAs de nuestro programa en BASIC.

El fichero .bin es binario, por lo que para obtener los valores numéricos que introducir en los datas debemos utilizar un editor hexadecimal o alguna utilidad como hexdump de Linux:

$ hexdump -C ejemplo1.bin
00000000  21 00 40 3e a2 77 11 01 40 01 ff 1a ed b0 c9

Ahí tenemos los datos listos para convertirlos a decimal y pasarlos a sentencias DATA. Si el código es largo y no queremos teclear en DATAs la rutina, podemos convertir el BIN en un fichero TAP ensamblando el programa mediante:

pasmo --tap ejemplo1.asm ejemplo1.tap

Este fichero tap contendrá ahora un tap con el código binario compilado tal y como si lo hubieras introducido en memoria y grabado con SAVE “” CODE, para ser cargado posteriormente en nuestro programa BASIC con LOAD “” CODE.

Esta segunda opción (LOAD “” CODE) es la más cómoda, pues nos evita el pokeado de valores en memoria, pero implica ubicar el bloque de datos a cargar con LOAD “” CODE a continuación del programa en BASIC dentro del fichero .tap.

Para realizar esta concatenación escribimos las rutinas en un fichero .ASM y las compilamos con pasmo --tap fichero.asm bloque_cm.tap. Después, escribimos nuestro programa en BASIC y lo salvamos en cinta, obteniendo otro fichero tap (programa_basic.tap).

Tras esto tenemos que crear un TAP o un TZX que contenga primero el bloque BASIC y después el bloque de código máquina, de forma que el bloque BASIC podrá cargar el bloque de código máquina con un LOAD “” CODE DIRECCION, LONGITUD_BLOQUE_CM.

Podemos realizar esto con herramientas de gestión de ficheros TAP/TZX o, sin necesidad de utilizar emuladores o herramientas adicionales, mediante concatenación de ficheros:

  • Linux: cat programa_basic.tap bloque_cm.tap > programa_completo.tap
  • Windows: copy /b programa_basic.tap +bloque_cm.tap programa_completo.tap



Generando un binario desde un programa íntegramente en ensamblador

Si estamos realizando un programa completo en ensamblador, sin ninguna parte del mismo en BASIC, puede interesarnos generar directamente un TAP con un cargador de autoejecución (que cargue nuestro código máquina en memoria). En este caso podemos utilizar el flag --tapbas con 2 posibilidades: que cargue el código máquina en memoria pero no lo ejecute, o que lo cargue y lo ejecute.

Para la primera opción basta con que nuestro programa tenga una directiva ORG y ensamblar el programa mediante pasmo --tapbas fichero.asm fichero.tap. La opción --tapbas nos añadirá una cabecera BASIC que cargará el bloque código máquina en la dirección indicada por las sentencia ORG del programa en ensamblador (por ejemplo, 40000).

; Pruebas de ensamblador. Ensamblar con:
; pasmo --tapbas fichero.asm fichero.tap
 
    ORG 40000
 
    ld hl, 16384
    ld a, 162
    ld (hl), a
    ld de, 16385
    ld bc, 6911
    ldir
    ret

El fichero resultante del ensamblado será un TAP sin autoejecución listo para cargar en el Spectrum (o en un emulador). Cuando la carga del TAP termine no ocurrirá nada, porque sólo se habrá hecho un CLEAR y un LOAD “” CODE. Si queremos ejecutar nuestro código en cualquier momento, lo haremos manualmente tecleando RANDOMIZE USR 40000. Esto puede ser útil cuando queremos cargar el código máquina en memoria pero no ejecutarlo directamente.

Si agregamos la directiva END a nuestro programa y le especificamos la dirección de ejecución del mismo, en ese caso pasmo --tapbas agregará el RANDOMIZE USR correspondiente al listado BASIC y el programa se autoejecutará al ser cargado.

; Pruebas de ensamblador
    ORG 40000
 
    ld hl, 16384
    ld a, 162
    ld (hl), a
    ld de, 16385
    ld bc, 6911
    ldir
    ret
 
    END 40000            ; Pasmo añade RANDOMIZE USR 40000 al cargador

La sentencia END marcará pues el punto de autoejecución del programa, el valor para el RANDOMIZE USR N que pasmo introducirá en el cargador BASIC. Como ya hemos dicho, sin el END pasmo nos generará un cargador también, pero sin el RANDOMIZE USR.

El resultado del ensamblado de este ejemplo con --tapbas será directamente ejecutable en un Spectrum con un simple LOAD “”.

¿Por qué existe END si ya tenemos ORG para iniciar el inicio de nuestro programa? Muy sencillo: ORG indica la dirección de inicio del programa, el lugar en memoria a partir del cual se cargan todos los datos binarios, mientras que END indica la dirección a la que hay que saltar desde BASIC para ejecutar el programa.

Y es que la dirección de ejecución del programa no tiene por qué ser la primera del mismo. Nuestro programa podría empezar por variables y rutinas justo debajo del ORG, pero el punto principal de entrada podría ser otro, como el menú del juego:

; Pruebas de ensamblador
    ORG 40000
 
    vidas DB 3
    tiempo DB 60
 
dibujar_nave:
    ; codigo para dibujar la nave aqui
    ret
 
menu:
    ; codigo principal del programa aqui
    ret
 
    END menu
 
; Pasmo añade RANDOMIZE USR X al cargador,
; siendo X la direccion donde se ha ensamblado
; el codigo tras la etiqueta "menu"

A continuación veremos cómo seleccionar un valor adecuado para la dirección de inicio ORG.


En este capítulo hemos visto qué aspecto tiene un programa en lenguaje ensamblador, cómo se convierte a código máquina y cómo podemos escribir dicho programa en memoria (ya sea con POKE*DATA o con un LOAD “” CODE).

En este punto, al lector le podrían surgir dos preguntas:

  1. ¿Dónde cargo el programa? ¿En qué lugar de la memoria? ¿Dónde POKEo los datos o dónde hago el LOAD “” CODE?
  2. ¿Cuál es la mejor forma de ejecutar el programa?

Veamos la respuesta a esas preguntas:


Sea la siguiente pregunta:

¿Dónde cargo mis programas en código máquina?” es decir “¿Qué dirección elijo para ORG? ¿32000? ¿35000? ¿40000? ¿50000?”.

La respuesta a esta pregunta sería: “En cualquier dirección de la memoria libre, la que no esté siendo utilizada por BASIC ni por el Sistema, donde quepa el programa”.

La memoria libre en un Spectrum empieza allá donde acaba el intérprete de BASIC, y acaba donde están definidos los gráficos definidos por el usuario (que son los últimos 168 bytes de la memoria RAM, es decir, en un Spectrum 48K desde 65368 hasta 65535).

Para determinar un valor seguro que establecer en ORG, tenemos que decirle a BASIC que no utilice memoria por encima de la dirección donde queremos cargar nuestros programa.

Existe para eso una variable de sistema llamada RAMTOP (los 2 bytes alojados en $5cb2 o 23730) la cual indica al sistema cuál es la dirección de memoria más alta que se le permite a BASIC utilizar.

Se puede modificar la dirección de RAMTOP desde BASIC mediante el comando CLEAR dirección.

Un CLEAR dirección hace lo siguiente:


  • Borra todas las variables de BASIC.
  • Borra la pantalla (o “fichero de imagen”).
  • Restaura la posición del cursor gráfico en (0,0).
  • Restaura el puntero de datos y borra la pila de GOSUB.
  • Establece el valor de RAMTOP de forma que BASIC nunca escriba más allá de la dirección indicada.


Incluso el comando NEW, que limpia la RAM del Spectrum, no tocará ni eliminará nada por encima de esa dirección, y por tanto ni eliminará nuestros programas en código máquina ni cambiará los UDG o Gráficos Definidos por el Usuario (que como hemos dicho, están ubicados al final de la RAM).

Así, cuando tengamos un programa en lenguaje ensamblador y lo queramos cargar en memoria (ya sea con POKE, o con LOAD “”), hay que empezar por decidir el lugar de la memoria en que queremos situarlo, porque nuestro programa tiene que empezar con un ORG dirección para que el programa ensamblador convierta las etiquetas de texto para los saltos o las variables “en memoria” a las direcciones correctas relativas a esa dirección de inicio. El programa deberá ser POKEado o cargado en la misma dirección de memoria que el ORG que se ha utilizado, para que las referencias coincidan. Así, si nuestro programa usa ORG 40000, deberemos cargar el binario compilado en 40000 (a menos que hayamos diseñado las rutinas para no utilizar ninguna dirección absoluta, lo que se conoce como “rutinas reubicables”).

Lo normal, como hemos visto, es que carguemos nuestro programa en una dirección de memoria X (que coincida con el ORG X del programa), y hagamos un CLEAR X-1 (siendo X el valor que hemos puesto como ORG en nuestro programa en ensamblador).

Si por ejemplo nuestro programa utiliza un ORG 40000, haremos un CLEAR 39999 en el cargador BASIC, y cargaremos el código máquina desde 40000 en adelante.

La respuesta básica e inicial para cualquiera de los ejemplos que usaremos en este curso, o las pruebas que desees realizar, es:


  • Pon el ORG de tus programas de prueba, por ejemplo, en 35000 ó 40000. Estas son una direcciones perfectas para un Spectrum 48K y 128K para todos aquellos programas que vamos a realizar en este curso. Tendremos unos 30KB de espacio disponible (con ORG 35000) o 25KB (con ORG 40000) para alojar nuestro programa, una cantidad más que suficiente para nuestras pruebas e incluso para hacer juegos de calidad comercial.
  • También podrías usar para tus pruebas un ORG 45000, 50000* o cualquier otra dirección por encima de 32768 siempre y cuando no intentes usar el final de la memoria (por ejemplo, no podrías cargar un programa de 1000 bytes a partir de 65000 ya que el último byte de la memoria es 65535). Tampoco vamos a utilizar direcciones por debajo de 32768 (aunque se podría) por motivos que se explicarán más adelante.
  • En un modelo 16KB, inevitablemente deberás utilizar una dirección más baja, como 26000, ya que no existe memoria por encima del byte 32767. En cualquier caso, asumiremos que el lector estará trabajando con un modelo de 48K para todos los programas y ejemplos del curso.
  • Pon en tu programa ASM un END dirección que coincida con el ORG dirección, y asegúrate de que lo primero que hay en tu programa debajo del ORG es código ensamblador y no datos.
  • Haz un CLEAR DIRECCION_ELEGIDA - 1 si no estás autogenerando el cargador BASIC con pasmo.
  • Carga tu código máquina a partir de la dirección elegida.


Nosotros, a lo largo del curso, utilizaremos diferentes direcciones de ORG/END en nuestros ejemplos, típicamente 33500, 35000, 40000 ó 50000. Prácticamente ninguno de los programas que mostraremos excederá el KiloByte de tamaño con lo que en realidad podríamos cargarlos en cualquier posición de inicio sin riesgo de que no quepan en la memoria.

No obstante, al final del curso, en el capítulo sobre Consideraciones Avanzadas, veremos cómo seleccionar el valor de ORG más adecuado para exprimir al máximo la memoria del Spectrum, colocando el programa lo más bajo posible en memoria para disponer de la mayor cantidad de memoria posible para datos y código. Esto sólo es necesario si estamos haciendo un juego o programa que sea muy exigente en cuanto a necesidades de memoria. En ese capítulo es donde se explica y justifica por qué una buena dirección mínima de ORG para un programa “final” (no para pruebas, sino para el programa o juego finalizado) sería 33500.

Por ahora, como hemos dicho, en este curso ensamblaremos y cargaremos la mayoría de nuestros programas de ejemplo (y recomendamos al lector hacerlo también para sus pruebas y programas) en cualquier posición por encima de 32768, como 33500, 35000, 40000 o 50000. Verás diferentes valores en diferentes ejemplos, sin que se haya elegido ese valor por ningún motivo en concreto, ya que el programa cabrá en memoria y se ejecutará correctamente en cualquiera de ellos.


Una vez hemos decidido dónde vamos a cargar el código máquina, veamos cómo podemos cargarlo y cómo podemos ejecutarlo.

Usaremos un ejemplo con un pequeño programa de 4 bytes que vamos a cargar a partir de 40000:

    ORG 40000
 
    ld bc, 99
    ret

Ensamblamos el programa y obtenemos los siguientes dígitos en decimal:

1, 99, 0, 201

Vamos a introducirlos en la memoria del Spectrum:

10 CLEAR 39999
20 DATA 1, 99, 0, 201
30 FOR n=0 TO 4
40 READ I
50 POKE (40000+n), I
60 NEXT n

La orden CLEAR 39999 reserva la memoria a partir de de la dirección siguiente (40000), donde podemos pokear nuestro programa con READ y POKE.

Para ejecutar el programa de código máquina vamos a utilizar la función USR seguida de la dirección a la que “saltar”. Como queremos imprimir el resultado de la rutina (el valor devuelto en bc), podemos hacer:

PRINT USR 40000

(lo cual devolverá por pantalla el valor 99).

El comando USR dirección causa que BASIC le ceda el control a la rutina C.M. en la dirección indicada. Cuando la rutina vuelve al BASIC (porque se ejecuta un ret) el número en el registro BC se devuelve al BASIC como un valor, y por eso se imprime 99 por pantalla (es lo que ha hecho nuestro ld bc, 99 + RET).

En nuestro ejemplo hemos combinado PRINT con USR, lo cual significa:

PRINT USR dirección

Saltamos a la rutina en código máquina en la dirección indicada, y al finalizarse esta con un RET, por pantalla aparecerá el valor que hayamos dejado en el registro BC antes de ejecutarse ese ret, es decir, el valor de retorno de la subrutina. Esto es válido para pruebas, ya que normalmente no querremos que aparezca por pantalla nada como resultado de llamar a rutinas.

Si no queremos imprimir el valor retornado por BC sino sólo ejecutarlo, también podemos usar:

RANDOMIZE USR dirección

RANDOMIZE es un comando que se utiliza para definir la semilla para los números aleatorios (una variable del sistema en BASIC). Combinarlo con USR produce que se ejecute la rutina de la dirección de memoria indicada y que el valor devuelto por USR se use como semilla para números aleatorios. Esta es la forma más habitual de llamarlo desde programas cargadores BASIC que lanzan programas totalmente escritos en código máquina y que no vuelven a BASIC.

Como veremos más adelante, si estamos haciendo un programa mixto BASIC con llamadas a rutinas código máquina, este tipo de llamadas altera el valor de la semilla de números aleatorios, por lo que para programas mixtos se suele utilizar una variable temporal de forma que no aparezca nada por pantalla (como con PRINT) ni se altere la semilla (como con RANDOMIZE):

LET Z=USR dirección

Lo normal, dado que a lo largo del curso no mezclaremos BASIC con Ensamblador, será ver llamadas RANDOMIZE USR dirección desde cargadores BASIC creados sólo para poner nuestro código máquina en memoria y saltar a él.


Podemos grabar el programa en Código Máquina que acabamos de “POKEar” de la siguiente forma:

SAVE "nombre" CODE 40000,4

Este programa puede ser cargado en cualquier momento con LOAD “” CODE, pero no se autoejecutará. Para eso, deberíamos haber guardado primero en la cinta un pequeño programa cargador como:

10 CLEAR 39999
20 LOAD "" CODE 40000,4
30 PRINT USR 40000

Este cargador BASIC se debería grabar en cinta inmediatamente antes del código máquina, con:

SAVE "cargador" LINE 0

Y después de haberlo grabado, grabar el código máquina en la cinta, después del cargador, con:

SAVE "cm" CODE 40000, 4

Ahora tenemos una cinta (o un tap) que tiene al principio de ella un cargador BASIC, y después un bloque de código máquina.

Si ejecutamos un LOAD “cargador, se cargará y lanzará el cargador BASIC, el cual carga el bloque código máquina en memoria y lo ejecuta con el PRINT USR.


Antes de ver cuáles son las normas que debemos seguir al escribir programas en lenguaje ensamblador, eamos un ejemplo de programa para familiarizarnos con el aspecto del código con el que trabajaremos. Se han añadido comentarios para señalar algunos de los conceptos que veremos después al describir las normas que tiene este lenguaje:

; Programa de ejemplo para un programa típico en ensamblador para Pasmo.
; Copia una serie de bytes a la videoRAM con instrucciones simples.
 
    ORG 40000
 
    ; Aqui empieza nuestro programa que copia los
    ; 7 bytes desde la etiqueta "datos" hasta la
    ; videomemoria ([16384] en adelante).
 
    ld hl, destino                 ; HL = destino (VRAM)
    ld de, datos                   ; DE = origen de los datos
    ld b, 6                        ; numero de datos a copiar
 
bucle:                             ; etiqueta que usaremos luego
 
    ld a, (de)                     ; Leemos un dato de [DE]
    add a, valor                   ; Le sumamos 1 al dato leído
    ld (hl), a                     ; Lo grabamos en el destino [HL]
    inc de                         ; Apuntamos al siguiente dato
    inc hl                         ; Apuntamos al siguiente destino
 
    djnz bucle                     ; Equivale a:
                                   ; B = B-1
                                   ; if (B>0) goto Bucle
    ret
 
valor     EQU  1
destino   EQU  18384
 
datos     DEFB 127, %10101010, 0, 128, $fe, %10000000, 00h
 
    END

Algunos detalles que podemos ver al observar el programa:


  • Se utiliza normalmente una instrucción por línea. Se puede poner más de una separándolas normalmente por ':' pero no se recomienda por temas de legibilidad del código.
  • Los comentarios pueden ir en sus propias líneas, o la derecha de las propias instrucciones.
  • Podemos definir “constantes” con EQU para hacer referencia a ellas luego en el código. Son constantes, no variables, es decir, se definen en tiempo de ensamblado y no se cambian con la ejecución del programa. Su uso está pensado para poder escribir código más legible y que podamos cambiar los valores asociados posteriormente de una forma sencilla (es más fácil cambiar el valor asignado en el EQU, en un único punto, que cambiar un valor en todas sus apariciones en el código).
  • Podemos poner etiquetas (como bucle y datos -con o sin dos puntos, son ignorados-) para referenciar a una posición de memoria. Así, la etiqueta bucle del programa anterior hace referencia a la posición de memoria donde se ensamblaría la siguiente instrucción que aparece tras ella. Las etiquetas se usan para poder saltar a ellas (en los bucles y condiciones) mediante un nombre en lugar de tener que calcular nosotros la dirección del salto a mano y poner direcciones de memoria. Es más fácil de entender y programar un jp bucle que un jp $4008, por ejemplo. En el caso de la etiqueta datos, nos permite referenciar la posición en la que empiezan los datos que vamos a copiar.
  • Podemos ubicar en cualquier posición de memoria (antes o después de las rutinas) datos (que no instrucciones) para utilizarlas como hacemos en el ejemplo anterior con la directiva DEFB (o su abreviatura DB) insertando después del RET una serie de bytes que estarán en memoria justo después (en nuestro ejemplo) donde esté la instrucción RET ensamblada. Y además, le podemos poner una etiqueta delante (como datos) para poder hacer referencia desde el código a la posición exacta de memoria donde está el primer byte de la ristra de datos definidos con DEFB.
  • Los datos definidos con DEFB pueden estar en cualquier formato numérico, como se ha mostrado en el ejemplo: decimal, binario (con prefijo '%', hexadecimal tanto con prefijo '$' como con sufijo 'h', etc.


Podéis ensamblar el ejemplo anterior con pasmo mediante:

pasmo --tapbas ejemplo.asm ejemplo.tap

Una vez cargado y ejecutado el TAP en el emulador de Spectrum, podréis ejecutar el código máquina en BASIC con un RANDOMIZE USR 40000, y deberéis ver una pantalla como la siguiente:


Salida del ejemplo 1

Los píxeles que aparecen en el centro de la pantalla (dirección de memoria 18384) se corresponden con los valores numéricos que hemos definido en “datos”, ya que los hemos copiado desde “datos” hasta la videomemoria. No os preocupéis por ahora si no entendéis alguna de las instrucciones utilizadas, las iremos viendo poco a poco y al final tendremos una visión global y concreta de todas ellas.

Si cambiáis el END al final del programa por END 40000, no tendréis la necesidad de ejecutar RANDOMIZE USR 40000 ya que pasmo lo introducirá en el listado BASIC de “arranque” que genera de forma automática con el flag –tapbas.


En este curso vamos a utilizar Pasmo como ensamblador cruzado para generar “ejecutables” para Spectrum (ficheros TAP) a partir de nuestro código en ensamblador. Este programa ensamblador (o simplemente ensamblador) traduce nuestros ficheros de texto .asm con el código fuente del programa (en lenguaje ensamblador) a ficheros .bin (o .tap) que contendrán el código máquina directamente ejecutable por el Spectrum.

Para seguir el curso deberéis instalar Pasmo (ya sea la versión Windows o la de UNIX/Linux) en vuestro sistema y saber utilizarlo de forma básica (bastará con saber realizar un simple ensamblado de programa con los comandos que iremos detallando en algunos de los ejemplos). También necesitaréis un emulador para ejecutar el fichero resultante.

El ciclo de desarrollo con Pasmo será el siguiente:


  • Con un editor de texto, tecleamos nuestro programa en un fichero .ASM con la sintaxis que veremos a continuación.
  • Salimos del editor de texto y ensamblamos el programa:
  • Si queremos generar un fichero .bin de código objeto cuyo contenido POKEar en memoria (o cargar con LOAD ”“ CODE) desde un cargador BASIC, lo ensamblamos con: pasmo --bin ejemplo1.asm ejemplo1.bin
  • Si queremos generar un fichero .TAP con el código máquina de nuestro programa, pero que no sea directamente ejecutable en un Spectrum (sino que se usa para poder concatenarlo con otros TAPs y hacer ficheros de cinta con varios bloques) lo ensamblamos con: pasmo --tap ejemplo1.asm ejemplo1.tap
  • Finalmente, como ocurrirá en la mayoría de los ejemplos, si queremos generar un fichero .tap con su cargador BASIC para poder usar LOAD ”“ (pasmo añadirá un cargador BASIC), lo ensamblamos con pasmo --tapbas ejemplo1.asm ejemplo1.tap.


Con esto, ya sabemos ensamblar programas creados adecuadamente, de modo que la pregunta es: ¿cómo debo escribir mi programa para que Pasmo pueda ensamblarlo?

Es sencillo: escribiremos nuestro programa en un fichero de texto con extensión .asm. En este fichero de texto se ignorarán las líneas en blanco y los comentarios, que en ASM de Z80 se introducen con el símbolo ”;“ (punto y coma), de forma que todo lo que el ensamblador encuentre a la derecha de un ; será ignorado (siempre que no forme parte de una cadena). Ese fichero de texto será ensamblado por Pasmo y convertido en código binario.

Lo que vamos a ver a continuación son las normas que debe cumplir un programa para poder ser ensamblado en Pasmo. Es necesario explicar estas reglas para que el lector pueda consultarlas en el futuro, cuando esté realizando sus propios programas. No te preocupes si no entiendes alguna de las reglas, cuando llegues al momento de implementar tus primeras rutinas, las siguientes normas te serán muy útiles:


  • Normas para las instrucciones:

    • Pondremos una sóla instrucción de ensamblador por línea.

    • Dado que cada editor renderiza los tabuladores con diferentes tamaños, recomendamos utilizar espacios para indentar. Para eso, utiliza la indentación de “tabulador inteligente” de tu editor, es decir, que el propio editor inserte espacios cuando utilices el tabulador. Es la única forma de que un listado se vea igual en cualquier editor y sistema operativo, algo que no ocurre con los tabuladores reales. Una tabulación inteligente de 4 u 8 espacios (al gusto visual del programador) suele ser lo más compacto y a la vez legible. Por contra, 2 espacios es poca indentación para leer el código.

    • Además de una instrucción, en una misma línea podremos añadir etiquetas (para referenciar a dicha línea, algo que veremos posteriormente) y también comentarios (prececidos de ;).

    • Todo lo que va después de un carácter ; es un comentario y es ignorado por el ensamblador. Podemos poner comentarios en sus propias líneas (se recomienda que para las rutinas estén al principio de la misma, y para bloques de instrucciones estén al mismo nivel que el bloque, es decir, a 4 espacios), o a la derecha de una instrucción. Si los colocamos a la derecha de la instrucción, se recomienda elegir una posición de inicio común para todos los comentarios del listado, como por ejemplo la columna 35 ó 40.

    • Minúsculas y mayúsculas: las instrucciones y los registros pueden estar tanto en mayúsculas como en minúsculas. Es igual de válido ld a, 10 que LD A, 10. Usar mayúsculas o minúsculas es una preferencia personal de cada programador, y podemos encontrar listados de las 2 formas. Hace varias décadas había una especie de consenso por el cual se usaban las mayúsculas (podemos verlo en webs, revistas, libros y listados de la época). Más que consenso, se podría decir que los ensambladores de la época para muchos microprocesadores obligaban a escribir el código en minúsculas. Pero en los últimos años se ha ido imponiendo el uso de minúsculas ya el código resultante resulta menos “impactante” a la vista y es mucho más cómodo escribir sin tener que utilizar regularmente Shift o Caps-Shift cuando alternas entre código y comentarios.

    • De la misma forma, se puede o no poner espacios tras las comas de los operandos. Es igual de válido ld a, 10 que ld a,10. De nuevo, es una cuestión de preferencia personal. De nuevo en la literatura de la época nos encontraremos que los libros y publicaciones, incluído el manual del Spectrum no ponía las comas, pero personalmente encuentro más legible el código sin ellas.

    • Otra costumbre en algunos programadores es usar un tab en el espacio entre el nmemónico (ld) y los operandos (a, 10), de forma que todos los operandos queden a la misma altura. La otra opción es usar un simple espacio (no quedando los operandos alineados).

      Personalmente, mi preferencia es: minúsculas, con espacio tras la coma, con un espacio como separador entre nmemónico y operandos, con 4 u 8 espacios de indentación y con los comentarios en 30, 35 ó 40 según el programa.

      Veamos un resumen de las diferentes “escuelas”:

          ; Todo mayúsculas, espacio tras la coma
          LD A, 10
      bucle:
          AND $F0
          CALL rutina
          LD HL, $1234              ; Hacemos HL = $1234
          CP $FF
          LDI
          JR NZ, bucle
       
          ; Todo minúsculas (excepto comentarios)
          ; Con espacio tras la coma
          ld a, 10
      bucle:
          and $f0
          call rutina
          ld hl, $1234              ; Hacemos HL = $1234
          cp $ff
          ldi
          jr nz, bucle
       
          ; Todo minúsculas (excepto comentarios)
          ; Sin espacio tras la coma
          ld a,10
      bucle:
          and $f0
          call rutina
          ld hl,$1234               ; Hacemos HL = $1234
          cp $ff
          ldi
          jr nz, bucle
       
          ; Todo mayúsculas, tab entre nmenónico y operandos
          LD       A, 10
      bucle:
          AND    $F0
          CALL   rutina
          LD     HL, $1234        ; Hacemos HL = $1234
          CP     $FF
          LDI
          JR     NZ, bucle
       
              ; Todo minúsculas (excepto comentarios)
              ; Tabulado a 8 espacios (muy visible)
              ld a, 10
      bucle:
              and $f0
              call rutina
              ld hl, $1234              ; Hacemos HL = $1234
              cp $ff
              ldi
              jr nz, bucle
       
              ; Todo minúsculas (excepto comentarios)
              ; Tabulado a 8 espacios (muy visible)
              ; Tab nmenónico y operandos
              ld a, 10
      bucle:
              and      $f0
              call     rutina
              ld hl,   $1234              ; Hacemos HL = $1234
              cp       $ff
              ldi
              jr       nz, bucle


  • Normas para los valores numéricos:

    • Todos los valores numéricos se considerarán, por defecto, escritos en decimal.

    • Para introducir valores números en hexadecimal los precederemos del carácter $, y para escribir valores numéricos en binario lo haremos mediante el carácter %.

    • Podremos también especificar la base del literal poniendoles como prefijo las cadenas &, &H, &0 ó 0x (para hexadecimal) o &O (para octal), aunque esos símbolos están actualmente bastante en desuso en favor de $

    • Podemos especificar también los números mediante sufijos: Usando una H o h para hexadecimal, D o d para decimal, B o b para binario y O u o para octal.
    • Cuando realicemos operaciones de bits (AND, OR, XOR, etc) se recomienda poner el número en binario (prefijo %) para que quede visualmente más clara la operación que estamos realizando.
    • Cuando estemos asignando un valor que se corresponda con un carácter, es mejor poner el símbolo con comillas simples. Por ejemplo, en lugar de escribir ld a, 40, es más legible escribir ld a, '*'.


  • Normas para cadenas de texto:

    • Podemos separar las cadenas de texto mediante comillas simples o dobles.

    • El texto encerrado entre comillas simples no recibe ninguna interpretación, excepto si se encuentran 2 comillas simples consecutivas, que sirven para introducir una comilla simple en la cadena.

    • El texto encerrado entre comillas dobles permite introducir caracteres especiales al estilo de C/C++ como \n, \r o \t (nueva línea, retorno de carro, tabulador…).

    • El texto encerrado entre comillas dobles también admite \xNN para introducir el carácter correspondiente a un número hexadecimal NN.

    • Una cadena de texto de longitud 1 (un carácter) puede usarse como una constante (valor ASCII del carácter) en expresiones como, por ejemplo, 'C'+$10.


  • Normas para los nombres de ficheros:

    • Si vemos que nuestro programa se hace muy largo y por lo tanto incómodo para editarlo, podemos partir el fichero en varios ficheros e incluirlos mediante directivas INCLUDE (para incluir ficheros ASM) o INCBIN (para incluir código máquina ya compilado). Al especificar nombres de ficheros, deberán estar entre dobles comillas o simples comillas.


  • Normas para los identificadores:

    • Los identificadores son los nombres usados para etiquetas y también los símbolos definidos mediante EQU y DEFB/DB y similares, como por ejemplo num_vidas en num_vidas DB 5.

    • Podemos utilizar cualquier cadena de texto formada por a-Z, A-Z, 0-9 y subrayado, excepto los nombres de las palabras reservadas de ensamblador.


  • Normas para las etiquetas:

    • Una etiqueta es un identificador de texto que ponemos poner al principio de cualquier línea de nuestro programa, por ejemplo: bucle:

    • Podemos añadir el tradicional sufijo : a las etiquetas, pero también es posible no incluirlo si queremos compatibilidad con otros ensambladores que no lo soporten (por si queremos ensamblar nuestro programa con otro ensamblador que no sea pasmo).

    • Para un programa ensamblador, cualquier referencia a una etiqueta a lo largo del programa se convierte en una referencia a la posición de memoria de la instrucción o dato siguiente a donde hemos colocado la etiqueta. Podemos utilizar así etiquetas para hacer referencia a nuestros gráficos, variables, datos, funciones, lugares a donde saltar, etc.

    • Debido a esto, no podemos usar 2 etiquetas iguales en el programa. Es decir, dentro de 2 rutinas diferentes donde hacemos algún bucle, no podemos usar una etiqueta de nombre por ejemplo bucle:. Los nombres de etiquetas tienen que ser únicos. No obstante, algunos programas ensambladores como pasmo o sjasmplus permiten una cosa que se llama “etiquetas locales” en las que el nombre de la etiqueta sólo existe dentro de la rutina que la contiene, por lo que podremos tener 2 etiquetas con el mismo nombre para ese tipo de casuística que poníamos como ejemplo.


  • Directivas:

    • Las directivas del ensamblador (ORG, EQU, DEFB/DB, etc.) se suelen escribir en mayúsculas.

      * Tenemos a nuestra disposición una serie de directivas para facilitarnos la programación, como DEFB o DB para introducir datos en crudo en nuestro programa, ORG para indicar una dirección de inicio de ensamblado, END para finalizar el programa e indicar una dirección de autoejecución, IF/ELSE/ENDIF en tiempo de compilación, INCLUDE e INCBIN, MACRO y REPT/ENDR.

    • Una de las directivas más importantes es ORG, que indica la posición origen donde almacenar el código que la sigue. Podemos utilizar diferentes directivas ORG en un mismo programa. Los datos o el código que siguen a una directiva ORG son ensamblados a partir de la dirección que indica éste. Esta directiva, como el resto de directivas del ensamblador (END, INCLUDE, etc) tiene que ir indentada en el código 4 espacios (no debe aparecer al principio de la línea) para que el código sea compatible con otros assemblers (no es algo obligatorio para sjasmplus).

    • La directiva END permite indicar un parámetro numérico (END XXXX) que pasmo --tapbas toma para añadir al listado BASIC de arranque el RANDOMIZE USR XXXX correspondiente. De esta forma, podemos hacer que nuestros programas arranquen en su posición correcta sin que el usuario tenga que teclear el RANDOMIZE USR DIRECCION_INICIO.

    • Iremos viendo el significado de las directivas conforme las vayamos usando, pero es aconsejable consultar el manual de Pasmo para conocer más sobre ellas.


  • Operadores

    • Podemos utilizar los operadores típicos +, -, *. /, así como otros operadores de desplazamiento de bits como » y «.

    • Tenemos disponibles operadores de comparación como =, !=, <, >, y >=. Algunos ensambladores disponen de sus equivalentes EQ, NE, LT, LE, GT, GE. Los primeros son más legibles y se recomienda usarlos sobre las versiones en texto.

    • Existen también operadores lógicos como AND, OR, NOT, o sus variantes &, |, !.

    • Los operadores sólo tienen aplicación en tiempo de ensamblado, es decir, no podemos multiplicar o dividir en tiempo real en nuestro programa usando * o /. Estos operadores están pensados para que podamos poner expresiones como 32*10+12, en lugar del valor numérico del resultado, por ejemplo.

    • A la hora de utilizar el paréntesis para poner un cálculo inmediato en una expresión, como por ejemplo ((32*10)+12), tenemos que tener cuidado ya que paréntesis rodeando la expresión pueden ser interpretados por el ensamblador como el operador de acceso a memoria, y no como paréntesis matemáticos.


Se recomienda adoptar una “guía de estilo” para escribir código en ensamblador. Por guía de estilo entendemos el elegir un estilo único para el código (indentación, mayúsculas/minúsculas, posición de los comentarios, formato de cabecera para las rutinas, etc) y mantenerlo durante todo nuestro programa, librerías incluídas.


Para los listados y ejemplos del curso hemos utilizado el siguiente estilo para el código (siempre y cuando las limitaciones de espacio y tamaño en los ejemplos lo han permitido, en otro caso se ha modificado algún aspecto del estilo en los ejemplos):


  • Minúsculas para los nmemónicos, con espacio tras la coma de los operandos (ld a, 10) y sin separar el nmemónico de los operandos con un tabulador (se usará simplemente un espacio).
  • 4 espacios de indentación para las instrucciones. 8 espacios suele ser la mejor opción, porque es muy legible y evita que se mezclen tanto las instrucciones con las etiqueta, pero en el curso por limitaciones de espacio usaremos 4. No se recomienda utilizar tabs ya que tienen diferentes tamaños según el editor. Entre 4 u 8 espacios (igual que entre mayúsculas y minúsculas), es una simple decisión personal a la hora de tratar con el código.
  • Comentarios en la columna 35. Aunque en realidad, a veces hay que elegir una “columna variable”: no siempre podrán comenzar en la misma columna debido a etiquetas largas. En general, se intentarán ubicar a partir de la columna 35.
  • Algunas personas colocan dos caracteres ; para el inicio de los comentarios que van sólos en una línea (y no a la derecha de una instrucción). Esas líneas de comentario comienzan por ;; y empiezan a 4 (u 8) espacios del inicio de la línea, igual que una instrucción. En mi preferencia personal, no suelo usar esta recomendación ya que reservo este tipo de prácticas para las cabeceras de las rutinas.
  • Mayúsculas para las directivas del ensamblador, como ORG, EQU, DB/DEFB, etc.
  • Mayúsculas también para las constantes definidas por EQU, tanto direcciones de salto como valores (colores, valores numéricos, etc), como por ejemplo COLOR_BLACK EQU 0.
  • Minúsculas para las variables, como vidas_jugador DB 3.
  • Si alguna variable es temporal (“local”), la precederemos por _, como _valor_temporal DEFB 0.
  • Una práctica que sigue gente que viene del mundo C es preceder los nombres de variables que referencian a direcciones de memoria con una p de “pointer”, como p_atributo, para indicar que no contiene un valor de atributo sino la dirección en memoria de un atributo.
  • Siempre que sea posible, etiquetas de puntos de salto (bucles, bifurcaciones) en “snakecase” (bucle o fin_rutina) excepto los puntos de entrada de las rutinas (los que se llamarían con un CALL), que se escribirán en “PascalCase” o “UpperCamelCase” (como MenuPrincipal o LeerTeclado. Es aceptable también el subrayado (_) para separar palabras en las rutinas si lo preferimos así: Leer_Teclado, en una especie de mezcla de “snake_case” y “PascalCase”.
  • Rutinas documentadas con una cabecera explicando qué hacen, qué parámetros de entrada tienen, qué salida producen, y qué registros/variables cambian.
  • Utilizar prefijos para los valores numéricos ($ para hexadecimal, % para binario) en lugar de sufijos (como h y b). Los valores en hexadecimal, en minúsculas.
  • Utilizar números en binario (%00000000) en las operaciones lógicas relacionadas con bits (como AND, OR o XOR) en lugar de valores decimales o hexadecimales, ya que ayuda a entender sobre qué bits queremos actuar.
  • Añadir comentarios para explicar el objetivo de un bloque de código, y no para explicar instrucciones. No tiene sentido para AND %00000001 decir ; Chequeamos bit 0 de A cuando en ese caso concreto lo más apropiado pueda ser ; Comprobamos si A es par o impar.


Lo importante es que el lector encuentre su propio “código de estilo” y que lo siga en todos los programas que realice, tanto en el código principal como en las librerías que se desarrolle.


Algunos programas ensambladores disponen de la utilísima opción de generar no sólo el resultado del ensamblado (un fichero .bin o .tap) sino también un fichero resumen que contiene información sobre dicho resultado.

En sjasmplus, disponemos de la opción –lst la cual genera un fichero de extensión .lst que muestra en paralelo el código del programa con la dirección resultante de cada instrucción y los opcodes que la conforman. Veamos un ejemplo:

El siguiente ejemplo de programa compara el valor de A y B e imprime en pantalla '=' si son iguales o '!' si son diferentes. Ahora mismo no debemos preocuparnos ni por las instrucciones ni por la sintaxis, símplemente lo vamos a usar como programa de ejemplo para ilustrar la generación de “listings”:

; Programa de ejemplo para comparar A y B
    ORG $8000
 
    call $0daf                    ; call CLS (borrar pantalla)
 
    ld a, (variable2)
    ld b, a                       ; B = 20
    ld a, (variable1)             ; A = 10
    cp b
    jr nz, es_diferente
 
es_igual:                         ; A = B
    ld a, '='
    rst 16
    jr fin
 
es_diferente:                     ; A != B
    ld a, '!'
    rst 16
 
fin:
    ret                           ; Fin del programa
 
variable1  DEFB  10
variable2  DEFB  20
 
    END $8000


Si ejecutamos sjasmplus –lst ejemplo_lst.asm obtenemos un fichero llamado ejemplo_lst.lst con el siguiente contenido:


# file opened: ejemplo_lst.asm
 1    0000              ; Programa de ejemplo para comparar A y B
 2    0000                  ORG $8000
 3    8000
 4    8000 CD AF 0D         call $0daf           ; call CLS (borrar pantalla)
 5    8003
 6    8003 3A 17 80         ld a, (variable2)
 7    8006 47               ld b, a              ; B = 20
 8    8007 3A 16 80         ld a, (variable1)    ; A = 10
 9    800A B8               cp b
10    800B 20 05            jr nz, es_diferente
11    800D
12    800D              es_igual:                ; A = B
13    800D 3E 3D            ld a, '='
14    800F D7               rst 16
15    8010 18 03            jr fin
16    8012
17    8012              es_diferente:            ; A != B
18    8012 3E 21            ld a, '!'
19    8014 D7               rst 16
20    8015
21    8015              fin:
22    8015 C9               ret                  ; Fin del programa
23    8016
24    8016 0A           variable1  DEFB  10
25    8017 14           variable2  DEFB  20
26    8018
27    8018                  END $8000
# file closed: ejemplo_lst.asm

En el fichero LST resultante tenemos un listado de todas las líneas del programa (columna uno), con las celdillas de memoria que ocupará cada una de esas líneas (columna 2). En la columna 3 se puede ver el opcode o dato que se ubicará en memoria y a la derecha el código ensamblador que lo ha generado.

Este tipo de ficheros es una herramienta muy útil no sólo cuando se está aprendiendo sino también para ver el “mapa de memoria” del programa.

El ensamblador pasmo tiene una opción similar con –listing (requiere la versión 0.6beta para utilizarla). Si no tenemos dicha versión, y queremos usar pasmo igualmente y no cambiar a sjasmplus, podemos usar este último simplemente para generar ficheros LST cuando necesitemos estudiarlos, y pasmo para el ensamblado “final” en sí.

El parámetro –listing nombre_de_fichero.lst debemos posicionarlo al principio del comando:

$ pasmo --listing ejemplo_lst.lst ejemplo_lst.asm ejemplo.bin

$ cat ejemplo_lst.lst
        pasmo 0.6.0.20070113.0  PAGE 1

                         Programa de ejemplo para comparar A y B
                            ORG $8000

  8000   CD 0DAF            call $0daf            call CLS (borrar pantalla)

  8003   3A 8017            ld a, (variable2)
  8006   47                 ld b, a               B = 20
  8007   3A 8016            ld a, (variable1)     A = 10
  800A   B8                 cp b
  800B   20 05              jr nz, es_diferente

                        es_igual:                 A = B
  800D   3E 3D              ld a, '='
  800F   D7                 rst 16
  8010   18 03              jr fin

                        es_diferente:             A != B
  8012   3E 21              ld a, '!'
  8014   D7                 rst 16

                        fin:
  8015   C9                 ret                   Fin del programa

  8016   0A             variable1  DEFB  10
  8017   14             variable2  DEFB  20

                            END $8000

        pasmo 0.6.0.20070113.0  PAGE S

Macros:

Symbols:
8012    es_diferente    800D    es_igual        8015    fin
8016    variable1       8017    variable2


En esta entrega hemos definido las bases del curso de ensamblador de Z80. Hemos visto qué aspecto tiene el código en ensamblador (aunque todavía no conozcamos la sintaxis) y, muy importante, hemos visto cómo se integra este código en ensamblador dentro de programas en BASIC.

Por último, hemos conocido una utilidad (pasmo) que nos permitirá, a lo largo del curso, ensamblar todos los programas que realicemos, así como probarlos en un emulador o integrar rutinas en nuestros programas BASIC.


  • cursos/ensamblador/codigo_maquina.txt
  • Última modificación: 21-01-2024 20:25
  • por sromero