YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 2

Publicado originalmente: 27 de Agosto de 2012

En la primera parte de este tutorial establecimos una arquitectura básica para nuestro emulador de ZX Spectrum: teníamos un procesador y unos buses, incluso tenemos un componente que emula memoria RAM. Ahora toca ejecutar las instrucciones del procesador.

Y debo decir que tras la primera lectura del datasheet del procesador Z80 (que podéis leer directamente aquí en formato PDF) la cosa no pintaba tan bien como podía esperar en un principio.

Acostumbrado como estaba al sencillísimo código máquina de los procesadores 6502 que conocía de la época (Commodore Vic 20 y Commodore 64), la estructura del Z80 era extraordinariamente compleja. El 6502 tiene un juego de instrucciones codificado en 8 bits, e incluso no todas las combinaciones (de 0 a 255) eran instrucciones válidas. En cambio, el Z80 no solo emplea instrucciones de 8 bits, sino que reconoce hasta cuatro “prefijos” para ejecutar instrucciones adicionales. Los prefijos 0xDD y 0xFD hacen que el procesador cambie la interpretación de las instrucciones para emplear un direccionamiento basado en los registros IX e IY, mientras que los prefijos 0xCB y 0xED abren dos nuevas páginas completas de instrucciones. Para colmo, las instrucciones con prefijo 0xDD y 0xFD también permiten el uso del prefijo 0xCB, complicando más si cabe la interpretación de las instrucciones del Z80.

Necesito una plantilla

Como soy consciente de que ni seré el primero ni el último que va a escribir un emulador de un procesador Z80 (los hay a montones), me puse a buscar si ya había algún código que me sirviese de “plantilla” para mi emulador. Empezando por el del archiconocido MAME (“Multi Arcade Machine Emulator”), emulador de videojuegos que soporta montones de plataformas, rápidamente lo dejé de lado al estar fuertemente basado en macros que harían complicado reescribir el código y, sobre todo, lo harían difícilmente portable a otros lenguajes. Eché un vistazo a FUSE, un emulador de Spectrum, pero tampoco me convenció su estructura, pero entre ambas opciones tenía una lista, una por una, de todas las instrucciones del procesador Z80… incluso de las no documentadas.

Hay más de lo que dice Zilog

De la lectura de montones de código fuente de emulación del Z80, me maravilla la precisión con la que emulan las instrucciones no documentadas del mismo. Si, éstas instrucciones son “accidentes” en el desarrollo del procesador.

En un lenguaje de programación, las instrucciones están perfectamente documentadas, son esas y no hay más. Luego hay gente con más o menos ingenio para combinarlas buscando efectos concretos, pero no hay nada oculto. En cambio, cuando hablamos de procesadores, cada instrucción lo que realmente hace es activar o desactivar ciertas partes de la electrónica interna, y los fabricantes documentan las combinaciones diseñadas a propósito. Pero hay muchas combinaciones que no están documentadas, y hay gente con un extraordinario talento dispuesta a descubrirlos, uno a uno, estudiando todos los comportamientos del procesador tras ejecutar todas y cada una de las instrucciones que no aparecen en la documentación oficial. Algunos ejemplos? Sólo hay una instrucción NOP oficial, pero los opcodes 0x77 y 0x7F también son NOPs con la curiosa diferencia que consumen 8 ciclos de reloj en lugar de los 4 habituales. O la instrucción NEG, que oficialmente es 0x44. Las instrucciones 0x4C, 0x54, 0x5C, 0x64, 0x6C, 0x74 y 0x7C hacen exactamente lo mismo.

Pero, e instrucciones nuevas? También las hay. La instrucción SLI no existe en el datasheet de Zilog, pero contiene todas las variantes de direccionamiento de cualquier instrucción oficial, y es una mutación de la instrucción oficial SLA. La instrucción SLA desplaza los contenidos del acumulador (registro A) a la izquierda, el bit que sobra se envía al flag de acarreo (carry) e inserta un cero por la derecha. La función SLI hace exactamente lo mismo, pero insertando un 1 a la derecha. Imagino que cuando Zilog estaba desarrollando el procesador, pensaron que ésta instrucción no tendría utilidad práctica alguna, y no se molestaron en documentarla… O ni siquiera se dieron cuenta de su existencia. Pero es realmente útil para “scrolls” horizontales en videojuegos…

Y precisamente por eso toca implementarlas: aunque no estén en la documentación oficial, los programadores se han acostumbrado a usarlas y, de hecho, muchas de ellas tienen una utilidad más que práctica.

Hay otra cosa que, la verdad, no he considerado para emular: los dos bits que “faltan” del registro F del procesador (los “flags”) que ya vimos en el primer capítulo:

Flags del Z80

Veis los dos “flags” con un guión en lugar de una letra? Bueno, pues también están documentados, gracias al trabajo de mucha gente que ha analizado con un detalle exquisito cómo se alteran tras diferentes operaciones en el procesador. Los llaman “Flag X” y “Flag Y” y en la mayoría de los casos son los bits colocados en esa posición del resultado de una operación aritmética.

Como mi intención es hacerme un Spectrum para, sobre todo, aprender cómo era por dentro, no voy a incluir está funcionalidad (por el momento). Como ya podréis comprobar, simplemente implementando las instrucciones no documentadas basta para que funcione la mayoría de los programas. Y creedme: hay muchísimas cosas más para emular mucho más importantes que esos dos flags para que algunos programas funcionen!! Lo que sí tenía por seguro cuando empecé, es que la ROM original del Spectrum no usa ninguna de éstas características… buen punto para comenzar.

Instrucciones primarias

Volviendo a nuestra implementación de Z80, el primer paso es decodificar las instrucciones “directas” (sin prefijos) del procesador Z80, y lo haremos en la función EmulateOne() que definimos en la primera parte de éste tutorial. La función tiene más o menos éste aspecto:

void Z80::EmulateOne()
{
  // Fetch next instruction
  unsigned char op = DataBus->Read(regs.PC++);
  // Increment instruction counter register
  regs.R60++;

  switch(op)
  {
    // nop
    case 0x00:
      tStates += 4;
      break;

    // ld bc,NN
    case 0x01:
      tStates += 10;
      regs.C = DataBus->Read(regs.PC++);
      regs.B = DataBus->Read(regs.PC++);
      break;

    // ld (bc),a
    case 0x02:
      tStates += 7;
      DataBus->Write(regs.BC,regs.A);
      break;

    // inc bc
    case 0x03:
      tStates += 6;
      regs.BC++;
      break;
    ...
    ...
  };

Lo primero que hace la función es leer (empleando el bus de datos del procesador) el siguiente opcode a interpretar. Para ello emplea el registro PC (“Program Counter”), que indica la posición de memoria de la siguiente instrucción a ejecutar e incrementa el contador:

unsigned char op = DataBus->Read(regs.PC++);

Una vez que tenemos el opcode a emular, incrementamos el registro R del procesador que consiste, básicamente, en un contador de instrucciones y que se emplea en el hardware real para refrescar memoria dinámica. El registro R realmente está dividido en dos secciones: el bit más significativo (“R7”) que siempre está a cero y los bits 6-0 que contienen el contador propiamente dicho. Gracias al uso de los bitfields de C++ incrementamos solamente la parte menos significativa del registro (“R60++”).

A continuación comenzamos el bloque switch que ejecuta cada una de las instrucciones. La primera es fácil: el opcode 0x00 del Z80 es la instrucción NOP:

    // nop
    case 0x00:
      tStates += 4;
      break;

La instrucción NOP en cualquier procesador no hace nada, simplemente consume tiempo. Y concretamente en el procesador Z80 consume cuatro ciclos de reloj, de ahí la instrucción tStates += 4.

Vamos a por el opcode 0x01, que equivale a la instrucción LD BC,#valor. Para emular esta instrucción usaremos el bus de datos del procesador para leer dos bytes que cargaremos en las mitades baja y alta del registro:

    ...
    // ld bc,NN
    case 0x01:
      tStates += 10;
      regs.C = DataBus->Read(regs.PC++);
      regs.B = DataBus->Read(regs.PC++);
      break;
    ...

El primer paso es acumular cuantos ciclos de reloj necesita la instrucción, para luego proceder a ejecutarla. la primera línea lee el byte siguiente en el flujo de instrucciones y lo asigna a la mitad C del registro BC. La siguiente línea hace lo mismo con la parte alta.

La primera ventaja obvia del uso de uniones a la hora de declarar la estructura de registros del procesador llega al emular el opcode 0x03, INC BC:

    ...
    // inc bc
    case 0x03:
        tStates += 6;
        regs.BC++;
        break;
    ...

Esta instrucción incrementa el valor de 16 bits contenido en el registro BC. Simplemente incrementando la variable regs.BC tenemos el trabajo hecho: las mitades B y C usan las mismas posiciones de memoria, así que su valor se verá instantáneamente actualizado.

Operaciones aritméticas

Aquí es donde la cosa se complica. Aunque pueda parecer sencillo que sumar dos valores no tiene la menor complicación, a nivel de una cpu sí lo tiene. La cpu debe mantener una serie de banderas (o flags) que afectan a la operación actual o que, una vez cambiadas por una operación, afectan a la operación siguiente. Pongamos el ejemplo del bit de acarreo (o “carry” en la jerga): el bit de acarreo es el equivalente en máquinas a nuestro tradición “y me llevo x”, aunque siendo máquinas y funcionando en binario, éste “x” sólo puede tener dos valores: 0 y 1.

Por ejemplo, si sumamos 5 y 7 el resultado (en base 10) sería algo así como “12” o “2 y me llevo 1”. Ese “1” se añade a la siguiente operación. En nuestro caso, el procesador Z80 puede sumar números de 8 bits. Si sumamos (por ejemplo) 170 (en hexadecimal 0xAA) y 131 (0x83) resulta en 301 (0x12D), pero que truncado a 8 bits (0x2D) resulta en 45 y el bit de acarreo en “1”.

Como hay montones de operaciones aritméticas en el Z80, prefería escribir funciones específicas para cada operación, como por ejemplo la de “suma con acumulador”:

void Z80::ADC_R8(unsigned char v)
{ 
  unsigned short aux = regs.A + v + (regs.CF ? 1 : 0);
  unsigned char idx = ((regs.A & 0x88) >> 3) | ((v & 0x88) >> 2) | ((aux & 0x88) >> 1);
  regs.A = (unsigned char)aux;
  regs.SF = (regs.A & 0x80) != 0;
  regs.ZF = (regs.A == 0);
  regs.HF = halfcarryTable[idx & 0x07];
  regs.PF = overflowTable[idx >> 4];
  regs.NF = 0;
  regs.CF = (aux & 0x100) != 0;
}

La función suma el valor v al acumulador del procesador, y añade “+1” si el acarreo estaba activo (regs.CF). A continuación actualiza el estado del resto de los flags del procesador con el resultado de la operación, como por ejemplo el flag “Z” (regs.ZF) que indica si el resultado de la operación ha sido cero.

Luego, desde la función EmulateOne() empleamos estas funciones para realizar las operaciones aritméticas. Por ejemplo, la instrucción ADC A,C (que suma el contenido del registro C al acumulador) la emulamos así:

    ...
    // adc a,c
    case 0x89:
      tStates += 4;
      ADC_R8(regs.C);
      break;
    ...

A la hora de implementar los bloques de instrucciones 0xDD y 0xFD, utilicé una sola función que emplea un registro “extra” inventado en nuestro procesador. Los prefijos en cuestión activan un modo de direccionamiento que emplea el registro IX (para 0xDD) ó IY (para 0xFD). La función EmulateOneXX() procesa los dos juegos de instrucciones, pero en lugar de hacerlo sobre los registros IX ó IY lo hace sobre un registro ficticio llamado “XX”. En la función EmulateOne() el código es sencillo:

    // IX register operations
    case 0xDD:
      regs.XX = regs.IX;
      EmulateOneXX();
      regs.IX = regs.XX;
      break;
    ...
    // IY register operation prefix
    case 0xFD:
      regs.XX = regs.IY;
      EmulateOneXX();
      regs.IY = regs.XX;
      break;

Para el resto de prefijos, el trabajo es similar. Hay una función para emular las instrucciones con prefijo 0xCB (EmulateOneCB()), otra para emular las instrucciones con prefijo 0xED (EmulateOneED()) y además de la ya conocida EmulateOneXX() existe otra para emular los prefijos 0xDD+0xCB y 0xFD+0xCB llamada EmulateOneXXCB(). Lo dicho: una risa.

Avanzamos en el tiempo y…

Los fundamentos para emular las instrucciones de un procesador son básicos, aunque algunas cpus puedan ser más simples que otras. En el caso del Z80 el trabajo necesario para emular las instrucciones es significativo, pero sin mucha mayor complicación. Instrucciones para emular las operaciones aritméticas y lógicas del procesador, alguna que otra función “helper” para acceder a la memoria y ya casi, casi lo tenemos. Pero me gustaría probarlo!!

Para ello nada más fácil (hombre, visto lo visto) que escribir un pequeño programa de pruebas, que construya un pequeño “ordenador virtual”, cargue algunas instrucciones en su memoria y las ejecute.

Lo primero, vamos a crear la máquina que ejecutará nuestro código:

int _tmain(int argc, _TCHAR* argv[])
{
  printf("Z80 cpu tests\n");

  // Components for tests
  Z80 cpu;          // processor
  RAM<0,1024> ram;  // 1KB of RAM
  Bus16 dataBus;    // Main data bus

  // attach components to bus
  dataBus.AddBusComponent(&ram);

  // attach bus to cpu
  cpu.DataBus = &dataBus;

Ya tenemos una máquina con un procesador Z80, 1 kilobyte de RAM (a que se hace raro?) en la dirección 0, todo ello unido por su bus de datos. De momento el bus de IO se queda sin inicializar, así que si queréis probar opcodes de entrada/salida, tendréis que instanciar un nuevo bus y asignarlo al miembro IOBus del procesador.

Ahora vamos a cargar un minúsculo programa en código máquina en nuestro ordenador virtual. Y, por cierto, si alguien es tan carca como yo, y esto le suena a los típicos “DATA”s de listados en código máquina de los ‘80, bienvenido al club.

  unsigned char testProg[] =
  {
    0x21, 0x00, 0x02,        // 0x0000 LD HL,#0200
    0x34,                    // 0x0003 INC (HL)
    0xC3, 0x03, 0x00         // 0x0004 JP 0003
  };

  // Load test program at RAM start
  // This test program simply updates contents of
  //  memory location 0x0200
  unsigned short ptr = 0;
  for (int dd=0;dd<sizeof(testProg);dd++)
    cpu.DataBus->Write(ptr++,testProg[dd]);

Ya puestos, y en un afán de seguir probando cosas, inicializamos el contenido de la posición de memoria 0x200 a “algo”:

cpu.DataBus->Write(0x0200,0x23);   // Set initial value

Si os fijáis, en lugar de escribir directamente en el bloque de memoria RAM (con “ram.Write()”), escribimos el programa usando el bus de datos del procesador directamente. Esto, en el mundo real, se llama DMA. La utilidad práctica es que no necesitamos saber “qué hay” en la posición de memoria, o cómo se usa, o si tiene hardware asociado que haya que modificar: la estructura de buses reenvía la información al dispositivo encargado de manejarla y éste puede optar por hacer cualquier cosa con él.

Volviendo al programa de pruebas, no hace gran cosa (normal con ese tamaño, no?). Simplemente carga el registro HL con 0x200 y luego comienza un bucle que incrementa esa posición de memoria. Para hacerlo más divertido, mostraremos por pantalla el estado de algunos registros (y el contenido de la dirección de memoria 0x200) a medida que el procesador ejecuta las instrucciones.

  cpu.regs.PC = 0x0000;
  int key = 0;
  do
  {
    printf("PC   AF   HL   Mem(0x200)\n");
    printf("%04X %04X %04X %02X\n",
               cpu.regs.PC,cpu.regs.AF,
               cpu.regs.HL,ram.Read(0x0200));

    key = _getch();
    if (key == 0x20)
      cpu.EmulateOne();
  } while(key == 0x20);		// repeat while Space key pressed

Cuando ejecutamos nuestro flamante “emulador” (ejem) veremos algo parecido a lo siguiente:

Z80Tests

La primera sección muestra el estado de la máquina antes de ejecutar nada: el contador de ciclos es cero, al igual que el contenido de los registros. La posición de memoria 0x200 contiene 0x23, que es el valor inicial que asignamos al inicializar la máquina.

Si vamos pulsado la barra espaciadora, la cpu irá ejecutando instrucciones con cada llamada a EmulateOne(). Con la primera de ellas veremos como el registro HL pasa a tener el valor 0x200 (LD HL,#0200), y en ello ha consumido 10 ciclos de reloj y el contador de programa (PC) ha avanzado hasta la posición de memoria 0x0003. La siguiente instrucción ejecuta INC (HL), y lo veremos reflejado en la columna donde aparece el contenido de la memoria, mientras que el contador de programa avanza a la dirección 0x0004. La siguiente instrucción ejecuta un salto de nuevo a la posición 0x0003 para repetir el proceso. El registro AF aparentemente siempre está a cero: no es el caso. Si seguimos simulando instrucciones y (eventualmente) el contenido de la posición de memoria 0x200 sigue aumentando, llegará a 0x2F y luego pasará a 0x30: el flag ‘H’ (HalfCarry) indica que ha habido acarreo en los cuatro bits menos significativos del valor (de ‘F’ hexadecimal ha pasado a ‘0’). Esto ocurre cada pasada de los cuatro bits inferiores de ‘F’ a ‘0’: de 0x2F a 0x30, de 0x3F a 0x40, etc.

Si continuamos incluso más, el contenido de la posición de memoria 0x200 llegará a 0x7F y se incrementará a 0x80. Realmente 0x80 puede interpretarse de dos modos: sin signo, como “128” o con signo, que equivale a “–128”. En la transición de 0x7F a 0x80 varios flags se activarán en el registro F (parte baja de AF), concretamente los bits 7 (flag ‘S’), 4 (flag ‘H’) y 2 ( flag ’P’ ) dando un resultado de 0x94. El flag ‘S’ indica que el número es negativo, el flag ‘H’ indica el ya conocido efecto de que los 4 bits menos significativos del valor han desbordado y el flag ‘P’ (o ‘P/V’ para ser mas exactos) indica overflow en la operación. Si avanzamos hasta que el valor de la posición de memoria avanza hasta 0x81 veremos como el flag ‘S’ sigue activo, pero los flags ‘H’ y ‘P’ se ponen a cero: ya no hay overflow, ni acarreo en los 4 bits menos significativos del valor.

Todo este baile de banderas es la base de la programación en código máquina, en cualquier procesador: en código máquina no se “comparan variables con valores”, sino que la comparación es una operación en sí, que afecta a los flags del procesador. Luego hay otras instrucciones que actúan (o no) dependiendo del valor de dichos flags. Y si, tambien se puede “comparar” (en caso del Z80 con la instrucción CP) pero lo que realmente hace es “restar un valor al acumulador” y activar el flag ‘Z’ si el resultado es cero.

Más ejemplos: para hacer un bucle de 100 a 0 (los bucles descendentes son más efectivos en código máquina), se escribiría como “LD B,100; (bucle) DEC B; JP NZ,(bucle)”. No hay una comparación expresa: la instrucción JP NZ sigue saltando mientras el flag ‘Z’ no indique que el resultado de la operación fue cero. Un bucle de 0 a 127 sería algo así como “LD B,0; (bucle) INC B; JP P,(bucle”)”, donde “JP P” significa “salta si el signo (flag ‘S’) es cero ” o lo que es lo mismo “salta si el número es positivo”.

Siguiente paso

Ya tenemos un procesador que funciona y los buses necesarios para conectar componentes al mismo. Empieza el trabajo de emular otra cosa: el hardware del Spectrum! Mientras tanto, el código fuente tanto de nuestro flamante procesador Z80 y del programa de pruebas lo podéis descargar desde aquí.