YASS-Yet Another Spectrum Simulator

Hazte un Spectrum - Capítulo 1

Publicado originalmente: 27 de Agosto de 2012

Últimamente me ha dado (no sé por qué) por el tema “retrocomputing”. Quizá es que me estoy haciendo mayor y me ataca la nostalgia, o que simplemente la forma de hacer ahora las cosas es aburrida. Como en mi día fui “de los del Commodore 64”, me decidí por aprender cómo era el enemigo de por entonces: el ZX Spectrum. Y a hacerme uno.

ZXSpectrumDebo decir que si alguien piensa que los ordenadores de los ‘80 están olvidados, nada más lejos de la realidad: hay ingentes cantidades de información ahí afuera, incluso mucho mejores que durante la época dorada de estos aparatos. Para mi proceso de aprendizaje he encontrado absolutamente fundamental la información que se puede encontrar en WorldOfSpectrum.org principalmente.

Además me di cuenta de que iba a emplear varios lenguajes de programación, cada uno perfecto para una parte del desarrollo: desde C++ para los componentes de bajo nivel del emulador (donde la velocidad es primordial) a .NET y WPF para un “frontend” de usuario. Con esta idea en mente me propuse desarrollar todo esto de la forma más clara posible, para mostraros cómo mezclar herramientas, lenguajes y conceptos para aprovechar lo máximo de cada uno de ellos. Comenzaremos con una emulación de una cpu Z80 en C++, junto con los componentes hardware que componen un Spectrum, para convertirlo todo en un componente .NET reutilizable usando C++ gestionado, para terminar con programación C# y WPF para el programa final. Manos a la obra.

Necesito una CPU…

Más que nada, porque sin un procesador Z80 no vamos a ningún sitio. Afortunadamente también hay ingentes cantidades de información sobre este componente. No tuve que rebuscar mucho para encontrar el datasheet de Zilog y empezar a echarle un vistazo.

Acostumbrado como estaba a los procesadores 6502 y similares del Commodore Vic20 y 64, para mi el Z80 era simplemente un “conocido”, así que tras una primera lectura de la documentación, me puse manos a la obra para desarrollar un emulador de ésta CPU.

Antes de empezar con la parte “dura” de la emulación, pensé en cómo accedería la CPU a los elementos externos a la misma, como por ejemplo la memoria, la ROM y los dispositivos de entrada/salida. Tras darle algunas vuelas, decidí que los buses del procesador (de datos y de entrada/salida) serían realmente instancias de clases especializadas, así que para la emulación podría olvidarme de cómo leer la RAM o los dispositivos y centrarme únicamente en la emulación del procesador en sí. También tuve que decidir qué lenguaje utilizar y, para variar, me decanté por C++.

El primer paso es definir “algo” que me permita leer y escribir bytes en un bus, y a ésta clase la llamé (original que es uno) BusComponentBase:

class BusComponentBase
{
public:
  virtual unsigned int GetStartAddress() = 0;
  virtual unsigned int GetRegionSize() = 0;
  virtual void Write(unsigned int address,unsigned char value) = 0;
  virtual unsigned char Read(unsigned int address) = 0;
};

La clase no provee ninguna implementación para las funciones, así que cada componente puede hacerlo de la forma que mejor le venga. GetStartAddress() devuelve la dirección base dentro del bus del componente, y GetRegionSize() devuelve el tamaño del bloque dentro del bus. Las funciones Read() y Write() se encargan de las operaciones de lectura y escritura.

El siguiente paso es hacer una versión un poco más especializada de la función, aprovechando los templates de C++ e implementar en tiempo de compilación los tamaños y las direcciones base de los componentes:

template<unsigned int B,unsigned int S> class BusComponent : public BusComponentBase
{
public:
  virtual unsigned int GetStartAddress() { return(B); }
  virtual unsigned int GetRegionSize() { return(S); }
};

Y continuando con la programación con templates, definimos nuestro primer componente: un bloque de memoria RAM:

template<unsigned int B,unsigned int S> class RAM : public BusComponent<B,S>
{
public:
  RAM()
  {
    ZeroMemory(data,S);
  }
  void Write(unsigned int address,unsigned char value)
  {
    data[address-B] = value;
  }
  unsigned char Read(unsigned int address)
  {
    return(data[address-B]);
  }

protected:
  unsigned char data[S];
};

Al usar templates para el tamaño y la base, ya no es necesario reservar memoria dinámicamente. Simplemente usamos los argumentos del template para crear el bloque de memoria (unsigned char [S]) y acceder a los contenidos (data[address-B] = value).

Ya tenemos los bloques básicos que se pueden conectar a un bus, pero nos falta definir el “bus” mismo… Y siguiendo con la analogía de cómo funcionan los SoCs (“System On Chip”) que incluyen buses internos que a su vez agrupan diversos dispositivos, los buses tambien derivan de BusComponent, solo que incluyen funciones para “añadir” y “eliminar” componentes del bus:

template<unsigned int B,unsigned int S> class Bus : public BusComponent<B,S>
{
public:
  HRESULT AddBusComponent(BusComponentBase *newComponent)
  {
    for (unsigned int dd=0;dd<m_components.size();dd++)
    {
      if (m_components.at(dd) == newComponent)
        return(ERROR_ALREADY_EXISTS);
    }
    m_components.push_back(newComponent);
    OnComponentsUpdated();
    return(S_OK);
  }

  HRESULT RemoveBusComponent(BusComponentBase *component)
  {
    for (unsigned int dd=0;dd<m_components.size();dd++)
    {
      if (m_components.at(dd) == component)
      {
        m_components.erase(m_components.begin()+dd);
        OnComponentsUpdated();
        return(S_OK);
      }
    }
    return(ERROR_NOT_FOUND);
  }

protected:
  virtual void OnComponentsUpdated()
  {
  };

protected:
  vector<BusComponentBase*> m_components;
};

Sólo queda hacer una clase que implemente un bus de 16 bits para acceder a los 64KB de direccionamiento del Z80. Para optimizar la cosa, la implementación crea una tabla de 64 entradas, cada una de ellas apuntando al componente que gestiona “ese kilobyte” de almacenamiento:

class Bus16 : public Bus<0x0000,0x10000>
{
public:
  Bus16()
  {
    ZeroMemory(m_pagedComponents,sizeof(m_pagedComponents));
  }

public:
  void Write(unsigned int address,unsigned char value)
  {
    BusComponentBase *pComp = m_pagedComponents[(address & 0xFFFF) / 1024];
    if (pComp)
      pComp->Write(address,value);
  }
  unsigned char Read(unsigned int address)
  {
    BusComponentBase *pComp = m_pagedComponents[(address & 0xFFFF) / 1024];
    return(pComp ? pComp->Read(address) : 0xFF);
  }

protected:
  virtual void OnComponentsUpdated()
  {
    // Build a quick lookup table por each component. Address
    // space is split in 1 KiloByte segment (64 entries).
    for (unsigned int dd=0;dd<m_components.size();dd++)
    {
      BusComponentBase *pComp = m_components.at(dd);
      if (pComp)
      {
        unsigned int start = pComp->GetStartAddress() / 1024;
        unsigned int end = start + (pComp->GetRegionSize() / 1024);
        for (unsigned int zz = start;zz<end;zz++)
          m_pagedComponents[zz] = pComp;
      }
    }
  };

protected:
    BusComponentBase* m_pagedComponents[64];
}

La clase deriva del template Bus<0x0000,0x10000>, esto es, un bus que empieza en la dirección “0” y termina en “0xFFFF”. El array m_pagedComponents se actualiza cada vez que se añade o elimina un componente del bus, y cada entrada apunta al bloque asignado a cada kilobyte. Decidir a qué componente enviar la petición de lectura o escritura es tan simple como

BusComponentBase *pComp = m_pagedComponents[(address & 0xFFFF) / 1024];

Con nuestros buses y bloques de RAM en el arsenal, ya podemos empezar a implementar nuestra flamante CPU…

Cómo es un Z80 por dentro

Z80RegistersEl procesador Z80 era extraordinariamente complejo comparado con los 6502 de la época. Incluye la escandalosa (para la época) cantidad de 16 registros de 16 bits cada uno, y para muchos de dichos registros permite accederlos por “mitades”, pudiendo trabajar con la parte superior o inferior de cada uno de ellos de forma individual. Por ejemplo, el registro de 16 bits HL puede usarse “entero” (por ejemplo para leer una posición de memoria con “LD A,(HL)”) o bien su mitad superior o inferior individualmente (por ejemplo “INC H” ó “DEC L”). El registro AF es incluso “peor”: el byte alto del registro (“A”) es el acumulador del procesador, esto es, la unidad en la que se efectúan las operaciones matemáticas. El byte menos significativo (“F”) contiene los “flags” o “banderas” del procesador, que indican el estado en que se encuentra en cada momento. Concretamente, el registro F contiene los siguientes campos, cada uno de ellos un bit:

Z80Flags

El bit ‘S’ indica si la última operación realizada por el procesador generó un resultado positivo o negativo. El bit ‘Z’ indica que la última operación dió resultado cero. El bit ‘H’ (“Half Carry”) indica si la última operación rebasó la capacidad de los cuatro bits inferiores del registro y se usa para operaciones aritméticas en “Base 10”. ‘P’ indica bien la paridad (número de bits a “1”) de la última operación o si la operación excedió los límites (overflow). Y finalmente, el bit ‘C’ indica si la última operación generó acarreo (“Carry” en la jerga).

Y vista esta estructura, me alegré de haber escogido C++ para implementar el procesador. Para los programadores en C#, implementar cualquiera de los registros del Z80 implicaría el uso de montones de “getters” y “setters”, para acceder a las mitades superior e inferior de cada registro. En C++ se usan “unions”.

Un “union” es una forma especial de estructura, donde los miembros no están “uno detrás de otro”, sino “uno encima de otro”. De esta forma se puede leer y escribir datos en formatos diferentes. Por ejemplo, el registro HL del Z80 puede codificarse como sigue:

struct Z80Registers
{
  ....
  // HL register pair
  union
  {
    unsigned short HL;
    struct
    {
      unsigned char L;    // LSB
      unsigned char H;    // MSB
    };
  };
  ....
};

al definir el registro HL empleamos un union sobre un “unsigned short” (16 bits) y una estructura que contiene dos “unsigned char” (bytes), que ocupan (“union”) las MISMAS posiciones de memoria. De esta forma se puede escribir “Z80Registers.HL”, o “Z80Registers.H” o “Z80Registers.L”. No hay getters, no hay setters, no hay ciclos de cpu desperdiciados para desmontar y montar variables: el compilador lo hace todo.

El registro AF tiene más gracia todavía:

struct Z80Registers
{
  // AF register pair
  union
  {
    unsigned short AF;
    struct
    {
      union
      {
        unsigned char F;    // LSB
        struct
        {
          unsigned char CF : 1;
          unsigned char NF : 1;
          unsigned char PF : 1;
          unsigned char XF : 1;
          unsigned char HF : 1;
          unsigned char YF : 1;
          unsigned char ZF : 1;
          unsigned char SF : 1;
        };
      };
      unsigned char A;    // MSB
    };
  };
};

Gracias a esta estructura y el uso de bitfields de C++, podemos acceder individualmente al registro AF completo (“Z80Registers.AF”), al acumulador del procesador (“Z80Registers.A”), al conjunto de flags (“Z80Registers.F”) o a cualquier flag individual sin necesidad de recurrir a ninguna operación de máscara de bits (“Z80Registers.CF”).

Con nuestra declaración de los registros de un procesador Z80 y nuestros buses, podemos empezar a construir el procesador:

class Z80
{
public:
  Z80()
  {
    ZeroMemory(&regs,sizeof(regs));
    DataBus = NULL;
    IOBus = NULL;
    tStates = 0;
  }

public:
  void EmulateOne();

public:
  BusComponent<0x0000,0x10000>* DataBus;
  BusComponent<0x0000,0x10000>* IOBus;
  Z80Registers regs;
  unsigned int tStates;
};

El procesador tiene dos buses, uno para la memoria (DataBus) y otro para I/O (apropiadamente denominado IOBus). Incluye un juego de registros (regs) y una variable que contará cuantos ciclos de reloj lleva emular cada instrucción (y no es coña). La función EmulateOne() se encargará de emular una instrucción en nuestro procesador.

El siguiente paso

En el próximo capítulo de éste tutorial comenzaremos a implementar el emulador de instrucciones de nuestro procesador Z80. Iremos instrucción por instrucción y tendremos una clase que encapsula, más o menos con precisión, el procesador que dará vida a nuestro Spectrum.