Creación de una máquina arcade emulador. Parte 3

imagen

Partes uno y dos .

Emulador de procesador 8080


Cáscara del emulador


Ahora debe tener todos los conocimientos necesarios para comenzar a crear un emulador de procesador 8080.

Intentaré que mi código sea lo más claro posible, cada código de operación se implementa por separado. Cuando se sienta cómodo con él, es posible que desee volver a escribirlo para optimizar el rendimiento o reutilizar el código.

Para empezar, crearé una estructura de memoria que contendrá campos para todo lo que me pareció necesario al escribir un desensamblador. También habrá un lugar para un búfer de memoria, que será RAM.

typedef struct ConditionCodes { uint8_t z:1; uint8_t s:1; uint8_t p:1; uint8_t cy:1; uint8_t ac:1; uint8_t pad:3; } ConditionCodes; typedef struct State8080 { uint8_t a; uint8_t b; uint8_t c; uint8_t d; uint8_t e; uint8_t h; uint8_t l; uint16_t sp; uint16_t pc; uint8_t *memory; struct ConditionCodes cc; uint8_t int_enable; } State8080; 

Ahora cree un procedimiento con una llamada de error que finalizará el programa con un error. Se verá más o menos así:

  void UnimplementedInstruction(State8080* state) { // pc    ,     printf ("Error: Unimplemented instruction\n"); exit(1); } int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: UnimplementedInstruction(state); break; case 0x01: UnimplementedInstruction(state); break; case 0x02: UnimplementedInstruction(state); break; case 0x03: UnimplementedInstruction(state); break; case 0x04: UnimplementedInstruction(state); break; /*....*/ case 0xfe: UnimplementedInstruction(state); break; case 0xff: UnimplementedInstruction(state); break; } state->pc+=1; //  } 

Implementemos algunos códigos de operación.

  void Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; switch(*opcode) { case 0x00: break; //NOP -  ! case 0x01: //LXI B, state->c = opcode[1]; state->b = opcode[2]; state->pc += 2; //   2  break; /*....*/ case 0x41: state->b = state->c; break; //MOV B,C case 0x42: state->b = state->d; break; //MOV B,D case 0x43: state->b = state->e; break; //MOV B,E } state->pc+=1; } 

Ahí tienes. Para cada código de operación, cambiamos el estado y la memoria, como lo haría un comando ejecutado en un 8080 real.

El 8080 tiene aproximadamente 7 tipos, dependiendo de cómo los clasifique:

  • Transferencia de datos
  • Aritmética
  • Lógico
  • Ramas
  • Pila
  • Entrada-salida
  • Especial

Miremos cada uno de ellos individualmente.

Grupo aritmético


Las instrucciones aritméticas son muchos de los 256 códigos de operación del procesador 8080, que incluyen varios tipos de suma y resta. La mayoría de las instrucciones aritméticas funcionan con el registro A y guardan el resultado en A. (El registro A también se llama acumulador).

Es interesante notar que estos comandos afectan los códigos de condición. Los códigos de estado (también llamados indicadores) se establecen según el resultado del comando ejecutado. No todos los comandos afectan a las banderas, y no todos los equipos que afectan las banderas afectan a todas las banderas a la vez.

Banderas 8080


En un procesador 8080, los indicadores se denominan Z, S, P, CY y AC.

  • Z (cero, cero) toma el valor 1 cuando el resultado es cero
  • S (signo) toma el valor 1 cuando se da el bit 7 (el bit más significativo, el más significativo, MSB) del comando matemático
  • P (paridad, paridad) se establece cuando el resultado es par y se restablece cuando es impar
  • CY (carry) toma el valor 1 cuando, como resultado del comando, se realiza una transferencia o préstamo en un bit de orden superior
  • AC (transporte auxiliar) se utiliza principalmente para las matemáticas BCD (decimal codificado en binario). Para obtener más detalles, consulte el manual, en Space Invaders este indicador no se utiliza.

Los códigos de estado se usan en comandos de ramificación condicionales, por ejemplo, JZ realiza ramificaciones solo si se establece la bandera Z.

La mayoría de las instrucciones tienen tres formas: para registros, para valores inmediatos y para memoria. Implementemos algunas instrucciones para comprender sus formularios y ver cómo es trabajar con códigos de estado. (Tenga en cuenta que no implemento el indicador de transferencia auxiliar porque no se usa. Si lo implementé, no podría probarlo).

Formulario de registro


Aquí hay un ejemplo de implementación de dos instrucciones con un formulario de registro; en el primero, implementé el código para que su trabajo sea más fácil de entender, y en el segundo, se presenta una forma más compacta que hace lo mismo.

  case 0x80: //ADD B { //      , //      uint16_t answer = (uint16_t) state->a + (uint16_t) state->b; //  :    , //    , //      if ((answer & 0xff) == 0) state->cc.z = 1; else state->cc.z = 0; //  :   7 , //    , //      if (answer & 0x80) state->cc.s = 1; else state->cc.s = 0; //   if (answer > 0xff) state->cc.cy = 1; else state->cc.cy = 0; //    state->cc.p = Parity( answer & 0xff); state->a = answer & 0xff; } //  ADD     case 0x81: //ADD C { uint16_t answer = (uint16_t) state->a + (uint16_t) state->c; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Emulo comandos matemáticos de 8 bits con un número de 16 bits. Esto facilita el seguimiento de los casos en que los cálculos generan un acarreo.

Formulario para valores inmediatos


La forma de los valores inmediatos es casi la misma, excepto que el byte después del comando es la fuente del agregado. Como "opcode" es un puntero al comando actual en la memoria, opcode [1] será inmediatamente el siguiente byte.

  case 0xC6: //ADI  { uint16_t answer = (uint16_t) state->a + (uint16_t) opcode[1]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Forma para la memoria


En el formulario para memoria, se agregará un byte al que indica la dirección almacenada en un par de registros HL.

  case 0x86: //ADD M { uint16_t offset = (state->h<<8) | (state->l); uint16_t answer = (uint16_t) state->a + state->memory[offset]; state->cc.z = ((answer & 0xff) == 0); state->cc.s = ((answer & 0x80) != 0); state->cc.cy = (answer > 0xff); state->cc.p = Parity(answer&0xff); state->a = answer & 0xff; } 

Notas


Las instrucciones aritméticas restantes se implementan de manera similar. Adiciones:

  • En diferentes versiones con carry (ADC, ACI, SBB, SUI), de acuerdo con el manual de referencia, utilizamos bits de carry en los cálculos.
  • INX y DCX afectan a los pares de registros; estos comandos no afectan a las banderas.
  • DAD es otro comando de un par de registros, solo afecta a la bandera de acarreo
  • INR y DCR no afectan la bandera de acarreo

Grupo de sucursal


Después de tratar con los códigos de estado, el grupo de sucursales se volverá lo suficientemente claro para usted. Hay dos tipos de ramificación: transiciones (JMP) y llamadas (CALL). JMP simplemente establece la PC en el valor del destino del salto. CALL se usa para rutinas, escribe la dirección de retorno en la pila y luego asigna a la PC la dirección de destino. RET regresa de CALL, recibe la dirección de la pila y la escribe en la PC.

Tanto JMP como CALL solo van a direcciones absolutas que están codificadas en bytes después del código de operación.

Jmp


El comando JMP se ramifica incondicionalmente a la dirección de destino. También hay comandos de ramificación condicionales para todos los códigos de estado (excepto para AC):

  • JNZ y JZ para cero
  • JNC y JC para migración
  • JPO y JPE por paridad
  • JP (más) y JM (menos) para el signo

Aquí hay una implementación de algunos de ellos:

  case 0xc2: //JNZ  if (0 == state->cc.z) state->pc = (opcode[2] << 8) | opcode[1]; else //    state->pc += 2; break; case 0xc3: //JMP  state->pc = (opcode[2] << 8) | opcode[1]; break; 

LLAME Y RETIRE


CALL empuja la dirección de la instrucción en la pila después de la llamada y luego salta a la dirección de destino. RET recibe la dirección de la pila y la guarda en la PC. Existen versiones condicionales de CALL y RET para todos los estados.

  • CZ, CNZ, RZ, RNZ para cero
  • CNC, CC, RNC, RC para transferencia
  • CPO, CPE, RPO, RPE para paridad
  • CP, CM, RP, RM para firmar

  case 0xcd: //CALL  { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; case 0xc9: //RET state->pc = state->memory[state->sp] | (state->memory[state->sp+1] << 8); state->sp += 2; break; 

Notas


  • El comando PCHL salta incondicionalmente a una dirección en un par de registros HL.
  • No incluí el RST discutido anteriormente en este grupo. Escribe la dirección de retorno en la pila y luego salta a la dirección predefinida en la parte inferior de la memoria.

Grupo lógico


Este grupo realiza operaciones lógicas (consulte la primera publicación del tutorial). Por su naturaleza, son similares a un grupo aritmético en que la mayoría de las operaciones funcionan con el registro A (unidad), y la mayoría de las operaciones afectan a las banderas. Todas las operaciones se realizan en valores de 8 bits, en este grupo no hay comandos que afecten a pares de registros.

Operaciones booleanas


AND, OR, NOT (CMP) y "exclusivo o" (XOR) se denominan operaciones booleanas. OR y AND expliqué anteriormente. El comando NOT (para el procesador 8080 se llama CMA, o acumulador de complemento) simplemente cambia los valores de bits: todas las unidades se convierten en ceros y los ceros se convierten en unos.

Percibo a XOR como un "reconocedor de diferencia". Su tabla de verdad se ve así:

xyResultado
0 00 00 0
0 011
10 01
110 0

AND, OR y XOR tienen una forma para registros, memoria y valores inmediatos. (CMP solo tiene un comando que distingue entre mayúsculas y minúsculas). Aquí hay una implementación de un par de códigos de operación:

  case 0x2F: //CMA (not) state->a = ~state->a //  ,  CMA     break; case 0xe6: //ANI  { uint8_t x = state->a & opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); state->cc.p = parity(x, 8); state->cc.cy = 0; //  ,  ANI  CY state->a = x; state->pc++; //   } break; 

Comandos de desplazamiento cíclico


Estos comandos cambian el orden de los bits en los registros. Un desplazamiento hacia la derecha los mueve un bit hacia la derecha y un desplazamiento hacia la izquierda, un bit hacia la izquierda:

(0b00010000) = 0b00001000

(0b00000001) = 0b00000010

Parecen no tener valor, pero en realidad esto no es así. Se pueden usar para multiplicar y dividir por potencias de dos. Tome el desplazamiento a la izquierda como ejemplo. 0b00000001 es decimal 1, y desplazarlo hacia la izquierda lo convierte en 0b00000010 , es decir, decimal 2. Si realizamos otro desplazamiento hacia la izquierda, obtenemos 0b00000100 , que es 4. Otro desplazamiento hacia la izquierda, y lo multiplicamos por 8. Esto funcionará con cualquier por números: 5 ( 0b00000101 ) cuando se desplaza hacia la izquierda da 10 ( 0b00001010 ). Otro desplazamiento a la izquierda da 20 ( 0b00010100 ). Un cambio a la derecha hace lo mismo, pero para la división.

El 8080 no tiene un comando de multiplicación, pero se puede implementar con estos comandos. Si comprende cómo hacer esto, recibirá puntos de bonificación. Una vez me hicieron esa pregunta en una entrevista. (Lo hice, aunque me tomó unos minutos).

Estos comandos rotan el disco cíclicamente y solo afectan la bandera de acarreo. Aquí hay un par de comandos:

  case 0x0f: //RRC { uint8_t x = state->a; state->a = ((x & 1) << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; case 0x1f: //RAR { uint8_t x = state->a; state->a = (state->cc.cy << 7) | (x >> 1); state->cc.cy = (1 == (x&1)); } break; 

Comparación


La tarea de CMP y CPI es solo establecer marcas (para ramificación). Lo hacen restando banderas, pero no almacenando el resultado.

  • Igualmente: si dos números son iguales, entonces se establece la bandera Z, ya que su resta entre sí da cero.
  • Mayor que: si A es mayor que el valor que se está comparando, entonces el indicador CY se borra (ya que la sustracción se puede hacer sin pedir prestado).
  • Menor: si A es menor que el valor comparado, se establece el indicador CY (porque A debe completar el préstamo para completar la resta).

Hay versiones de estos comandos para registros, memoria y valores inmediatos. La implementación es una simple resta sin guardar el resultado:

  case 0xfe: //CPI  { uint8_t x = state->a - opcode[1]; state->cc.z = (x == 0); state->cc.s = (0x80 == (x & 0x80)); //  ,    p -   state->cc.p = parity(x, 8); state->cc.cy = (state->a < opcode[1]); state->pc++; } break; 

CMC y STC


Completan el grupo lógico. Se utilizan para establecer y borrar la bandera de transporte.

Grupo de entrada-salida y comandos especiales.


Estos comandos no pueden asignarse a ninguna otra categoría. Los mencionaré para completar, pero me parece que tendremos que volver a ellos cuando comencemos a emular el hardware de Space Invaders.

  • EI y DI habilitan o deshabilitan la capacidad del procesador para manejar las interrupciones. Agregué el indicador interrupt_enabled a la estructura de estado del procesador y lo configuré / reinicié usando estos comandos.
  • Parece que RIM y SIM se utilizan principalmente para E / S en serie. Si está interesado, puede leer el manual, pero estos comandos no se utilizan en Space Invaders. No los emularé.
  • HLT es una parada. No creo que debamos emularlo, pero puede llamar a su código para salir (o salir (0)) cuando vea este comando.
  • IN y OUT son comandos que el equipo procesador 8080 usa para comunicarse con equipos externos. Mientras los estamos implementando, no harán nada más que saltear su byte de datos. (Más tarde volveremos a ellos).
  • NOP es "sin operación". Una aplicación de NOP es controlar la sincronización del panel (se requieren cuatro ciclos de CPU para ejecutarse).

Otra aplicación de NOP es la modificación del código. Digamos que necesitamos cambiar el código ROM del juego. No podemos simplemente eliminar códigos de operación innecesarios, porque no queremos cambiar todos los comandos CALL y JMP (serán incorrectos si al menos una parte del código se mueve). Con NOP podemos deshacernos del código. ¡Agregar código es mucho más difícil! Puede agregarlo buscando espacio en algún lugar de la ROM y cambiando el comando a JMP.

Grupo de pila


Ya hemos completado la mecánica para la mayoría de los equipos en el grupo de pila. Si hiciste el trabajo conmigo, entonces estos comandos serán fáciles de implementar.

EMPUJE y POP


PUSH y POP solo funcionan con pares de registros. PUSH escribe un par de registros en la pila, y POP toma 2 bytes de la parte superior de la pila y los escribe en un par de registros.

Hay cuatro códigos de operación para PUSH y POP, uno para cada uno de los pares: BC, DE, HL y PSW. PSW es ​​un par especial de registros de banderas de unidades y códigos de estado. Aquí está mi implementación de PUSH y POP para BC y PSW. No hay comentarios en él, no creo que haya nada particularmente complicado aquí.

  case 0xc1: //POP B { state->c = state->memory[state->sp]; state->b = state->memory[state->sp+1]; state->sp += 2; } break; case 0xc5: //PUSH B { state->memory[state->sp-1] = state->b; state->memory[state->sp-2] = state->c; state->sp = state->sp - 2; } break; case 0xf1: //POP PSW { state->a = state->memory[state->sp+1]; uint8_t psw = state->memory[state->sp]; state->cc.z = (0x01 == (psw & 0x01)); state->cc.s = (0x02 == (psw & 0x02)); state->cc.p = (0x04 == (psw & 0x04)); state->cc.cy = (0x05 == (psw & 0x08)); state->cc.ac = (0x10 == (psw & 0x10)); state->sp += 2; } break; case 0xf5: //PUSH PSW { state->memory[state->sp-1] = state->a; uint8_t psw = (state->cc.z | state->cc.s << 1 | state->cc.p << 2 | state->cc.cy << 3 | state->cc.ac << 4 ); state->memory[state->sp-2] = psw; state->sp = state->sp - 2; } break; 

SPHL y XTHL


Hay dos equipos más en el grupo de pila: SPHL y XTHL.

  • SPHL mueve HL a SP (obligando a SP a obtener una nueva dirección).
  • XTHL intercambia lo que está encima de la pila con lo que hay en un par de registros HL. ¿Por qué necesitarías hacer esto? No lo se

Un poco más sobre los números binarios.


Al escribir un programa de computadora, una de las decisiones que debe tomar es elegir el tipo de datos utilizados para los números, si desea que sean negativos y cuál debería ser su tamaño máximo. Para el emulador de CPU, necesitamos que el tipo de datos coincida con el tipo de datos de la CPU de destino.

Firmado y sin firmar


Cuando comenzamos a hablar de números hexadecimales, los consideramos sin signo, es decir, cada dígito binario del número hexadecimal tenía un valor positivo, y cada uno se consideraba una potencia de dos (unidades, dos, cuatro, etc.).

Nos ocupamos del problema del almacenamiento en la computadora de números negativos. Si sabe que los datos en cuestión tienen un signo, es decir, pueden ser negativos, entonces puede reconocer un número negativo por el bit más significativo del número (bit más significativo, MSB). Si el tamaño de los datos es de un byte, cada número con un valor de bit MSB dado es negativo, y cada uno con un MSB cero es positivo.

El valor de un número negativo se almacena como un código adicional. Si tenemos un número con signo, y el MSB es igual a uno, y queremos saber cuál es este número, entonces podemos convertirlo de la siguiente manera: realice el "NO" binario para los números hexadecimales y luego agregue uno.

Por ejemplo, para un número hexadecimal 0x80, se establece el bit MSB, es decir, es negativo. El "NO" binario del número 0x80 es 0x7f, o decimal 127. 127 + 1 = 128. Es decir, 0x80 en decimal es -128. Segundo ejemplo: 0xC5. No (0xC5) = 0x3A = decimal 58 +1 = decimal 59. Es decir, 0xC5 es decimal -59.

Lo sorprendente de los números con código adicional es que podemos realizar cálculos con ellos como con números sin signo, y seguirán funcionando . La computadora no necesita hacer nada especial con signos. Mostraré un par de ejemplos que prueban esto.

  Ejemplo 1

      decimal hexadecimal binario    
       -3 0xFD 1111 1101    
    + 10 0x0A +0000 1010    
    ----- -----------    
        7 0x07 1 0000 0111    
                        ^ Esto se registra en el bit de acarreo

    Ejemplo 2    

      decimal hexadecimal binario    
      -59 0xC5 1100 0101    
    + 33 0x21 +0010 0001    
    ----- -----------    
      -26 0xE6 1110 0110 


En el Ejemplo 1, vemos que sumar 10 y -3 da como resultado 7. El resultado de la suma se transfirió, por lo que se puede establecer el indicador C. En el Ejemplo 2, el resultado de la suma fue negativo, por lo que decodificamos esto: No (0xE6) = 0x19 = 25 + 1 = 26. 0xE6 = -26 Explosión del cerebro!

Si lo desea, lea más sobre el código adicional en Wikipedia .

Tipos de datos


En C, existe una relación entre los tipos de datos y el número de bytes utilizados para este tipo. De hecho, solo nos interesan los enteros. Los tipos de datos C estándar / de la vieja escuela son char, int y long, así como sus amigos unsigned char, unsigned int y unsigned long. El problema es que en diferentes plataformas y en diferentes compiladores, estos tipos pueden tener diferentes tamaños.

Por lo tanto, es mejor seleccionar un tipo de datos para nuestra plataforma que declare explícitamente el tamaño de los datos. Si su plataforma tiene stdint.h, puede usar int8_t, uint8_t, etc.

El tamaño de un número entero determina el número máximo que se puede almacenar en él. En el caso de enteros sin signo, puede almacenar números de 0 a 255 en 8 bits. Si traduce a hexadecimal, entonces es de 0x00 a 0xFF. Dado que 0xFF tiene "todos los bits establecidos", y corresponde al decimal 255, es completamente lógico que el intervalo de un entero sin signo de un solo byte sea 0-255. Los intervalos nos dicen que todos los tamaños de enteros funcionarán exactamente igual: los números corresponden al número que se obtiene cuando se establecen todos los bits.

TipoIntervaloMaleficio
8 bits sin signo0-2550x0-0xFF
8 bits firmado-128-1270x80-0x7F
16 bits sin signo0-655350x0-0xFFFF
16 bits firmado-32768-327670x8000-0x7FFF
32 bits sin signo0-42949672950x0-0xFFFFFFFFFF
32 bits firmado-2147483648-21474836470x80000000-0x7FFFFFFF

Aún más interesante es que -1 en cada tipo de datos con signo es un número que tiene todos los bits establecidos (0xFF para el byte con signo, 0xFFFF para el número de 16 bits con signo y 0xFFFFFFFF para el número de 32 bits con signo). Si los datos se consideran sin signo, entonces para todos los bits dados, se obtiene el número máximo posible para este tipo de datos.

Para emular registros de procesador, seleccionamos el tipo de datos correspondiente al tamaño de este registro. Probablemente valga la pena seleccionar tipos sin firmar de forma predeterminada y convertirlos cuando necesite considerarlos firmados. Por ejemplo, usamos el tipo de datos uint8_t para representar un registro de 8 bits.

Sugerencia: use un depurador para convertir tipos de datos


Si gdb está instalado en su plataforma, entonces es muy conveniente usarlo para trabajar con números binarios. A continuación, mostraré un ejemplo: en la sesión que se muestra a continuación, las líneas que comienzan con # son comentarios que agregué más adelante.

# /c, gdb
(gdb) print /c 0xFD
$1 = -3 '?'

# /x, gdb hex
# "p" "print"
(gdb) p /c 0xA
$2 = 10 '\n'

# 2 " "
(gdb) p /c 0xC5
$3 = -59 '?'
(gdb) p /c 0xC5+0x21
$4 = -26 '?'

# print , gdb
(gdb) p 0x21
$9 = 33

# , gdb,
# ,
(gdb) p 0xc5
$5 = 197 #
(gdb) p /c 0xc5
$3 = -59 '?' #
(gdb) p 0xfd
$6 = 253

# ( 32- )
(gdb) p /x -3
$7 = 0xfffffffd

# 1
(gdb) print (char) 0xff
$1 = -1 '?'
# 1
(gdb) print (unsigned char) 0xff
$2 = 255 '?'


Cuando trabajo con números hexadecimales, siempre lo hago en gdb, y sucede casi todos los días. Mucho más fácil que abrir la calculadora de un programador con una GUI. En máquinas Linux (y Mac OS X), para iniciar una sesión de gdb, simplemente abra un terminal e ingrese "gdb". Si usa Xcode en OS X, luego de iniciar el programa, puede usar la consola dentro de Xcode (en la que se imprime la salida de printf). En Windows, el depurador gdb está disponible en Cygwin.

Terminación del emulador de CPU


Habiendo recibido toda esta información, estás listo para un largo viaje. Debes decidir cómo implementar el emulador: crear una emulación 8080 completa o implementar solo los comandos necesarios para completar el juego.

Si decide hacer una emulación completa, necesitará algunas herramientas más. Hablaré de ellos en la siguiente sección.

Otra forma es emular solo las instrucciones utilizadas por el juego. Continuaremos completando esa construcción de interruptor enorme que creamos en la sección Shell del emulador. Repetiremos el siguiente proceso hasta que tengamos un solo comando no realizado:

  1. Inicie el emulador con ROM Space Invaders
  2. La llamada se UnimplementedInstruction()cierra si el comando no está listo
  3. Emula esta instrucción
  4. Goto 1

Lo primero que hice al comenzar a escribir mi emulador fue agregar código de mi desensamblador. Entonces pude generar un comando que debería ejecutarse de la siguiente manera:

  int Emulate8080Op(State8080* state) { unsigned char *opcode = &state->memory[state->pc]; Disassemble8080Op(state->memory, state->pc); switch (*opcode) { case 0x00: //NOP /* ... */ } /*    */ printf("\tC=%d,P=%d,S=%d,Z=%d\n", state->cc.cy, state->cc.p, state->cc.s, state->cc.z); printf("\tA $%02x B $%02x C $%02x D $%02x E $%02x H $%02x L $%02x SP %04x\n", state->a, state->b, state->c, state->d, state->e, state->h, state->l, state->sp); } 

También agregué código al final para mostrar todos los registros y banderas de estado.

Buenas noticias: para profundizar en el programa para 50 mil equipos, solo necesitamos un subconjunto de los códigos de operación 8080. Incluso daré una lista de códigos de operación que deben implementarse:

OpcodeEl equipo
0x00Nop
0x01LXI B, D16
0x05DCR B
0x06MVI B, D8
0x09Papá b
0x0dDCR C
0x0eMVI C, D8
0x0fRrc
0x11LXI D, D16
0x13Inx d
0x19Papá d
0x1aLDAX D
0x21LXI H, D16
0x23Inx h
0x26MVI H, D8
0x29Papá h
0x31LXI SP, D16
0x32STA adr
0x36MVI M, D8
0x3aLda adr
0x3eMVI A, D8
0x56MOV D, M
0x5eMOV E, M
0x66MOV H, M
0x6fMOV L, A
0x77MOV M, A
0x7aMOV A, D
0x7bMOV A, E
0x7cMOV A, H
0x7eMOV A, M
0xa7ANA A
0xafXRA A
0xc1Pop b
0xc2Jnz adr
0xc3Jmp adr
0xc5PULSE B
0xc6ADI D8
0xc9Ret
0xcdLlamar adr
0xd1Pop d
0xd3OUT D8
0xd5PULSAR D
0xe1Pop h
0xe5EMPUJE H
0xe6ANI D8
0xebXchg
0xf1POP PSW
0xf5EMPUJE PSW
0xfbEi
0xfeIPC D8

Estas son solo 50 instrucciones, y 10 de ellas son movimientos que se implementan trivialmente.

Depuración


Pero tengo algunas malas noticias. Su emulador seguramente no funcionará correctamente, y los errores en dicho código son muy difíciles de encontrar. Si sabe qué comando se está comportando mal (por ejemplo, una transición o una llamada que va a un código sin sentido), puede intentar corregir el error examinando su código.

Además de examinar el código, hay otra forma de solucionar el problema: comparando su emulador con uno que funcione con seguridad. Suponemos que otro emulador siempre funciona correctamente, y todas las diferencias son errores en su emulador. Por ejemplo, puedes usar mi emulador. Puede ejecutarlos manualmente en paralelo. Puede ahorrar tiempo si integra mi código en su proyecto para obtener el siguiente proceso:

  1. Crea un estado para tu emulador
  2. Crea un estado para el mío
  3. Para el proximo equipo
  4. Llamar a tu emulador con tu estado
  5. Llamando a los míos con mi fortuna
  6. Compara nuestros dos estados
  7. Buscando errores en cualquier diferencia
  8. ir a 3

Otra forma es usar este sitio manualmente . Este es un emulador de procesador Javascript 8080 que incluso incluye ROM Space Invaders. Aquí está el proceso:

  1. Reinicie la emulación de Space Invaders haciendo clic en el botón Space Invaders
  2. Presione el botón "Ejecutar 1" para ejecutar el comando.
  3. Ejecutamos el siguiente comando en nuestro emulador
  4. Compare el estado del procesador con el suyo
  5. Si las condiciones coinciden, pase a 2
  6. Si las condiciones no coinciden, entonces su emulación de instrucciones es errónea. Corríjalo y luego comience nuevamente desde el paso 1.

Al principio utilicé este método para depurar mi emulador 8080. No mentiré, el proceso puede ser largo. Como resultado, muchos de mis problemas resultaron ser errores tipográficos y de copiar y pegar, que después de la detección fueron muy fáciles de solucionar.

Si ejecuta su código paso a paso, la mayoría de las primeras 30 mil instrucciones se ejecutan en un ciclo de aproximadamente $ 1a5f. Si observa javascript en el emulador , puede ver que este código copia datos en la pantalla. Estoy seguro de que este código se llama con frecuencia.

Después de la primera representación de la pantalla, después de 50 mil comandos, el programa se atasca en este ciclo sin fin:

  0ada LDA $20c0 0add ANA A 0ade JNZ $0ada 

Espera hasta que el valor en la memoria a $ 20c0 cambie a cero. Dado que el código en este bucle no cambia exactamente $ 20c0, debe ser una señal de otro lugar. Es hora de hablar sobre emular el "hierro" de una máquina arcade.

Antes de pasar a la siguiente sección, asegúrese de que su emulador de CPU se encuentre en este ciclo sin fin.

Para referencia, vea mis fuentes .

Emulación 8080 completa


, : , . . , . , .

, 8080 . 8080 cpudiag.asm, 8080.

:

  1. , . , cpudiag.asm .
  2. Como puede ver, el proceso es bastante laborioso. Creo que un novato en la depuración del código del ensamblador experimentará grandes dificultades si estos pasos no se enumeran.

Así es como usé esta prueba con mi emulador. Puede usarlo o encontrar una mejor manera de integrarlo.

Conjunto de prueba


, . cpudiag.asm , . , , , «Make Beautiful Code» , test.bin, 8080. , .

cpudiag.asm .

cpudiag.bin ( 8080) .


invaders.* .

. -, ORG 00100H , , , 0x100 hex. 8080, , . , , , 0x100.

-, , . hex- JMP $0100 , . ( PC 0x100.)

En tercer lugar, encontré un error en el código compilado. Creo que la razón es el procesamiento incorrecto de la última línea de código STACK EQU TEMPP+256, pero no estoy seguro. Sea como fuere, la pila durante la compilación se ubicó en $ 6ad, y los primeros PUSH comenzaron a reescribir el código. Sugerí que la variable también debería estar compensada por 0x100, como el resto del código, así que lo arreglé insertando "0x7" en la línea de código que inicializa el puntero de la pila.

Finalmente, dado que no implementé DAA o la migración auxiliar en mi emulador, modifico el código para omitir esta verificación (simplemente la omitimos usando JMP).

  ReadFileIntoMemoryAt(state, "/Users/kpmiller/Desktop/invaders/cpudiag.bin", 0x100); //  ,   JMP 0x100 state->memory[0]=0xc3; state->memory[1]=0; state->memory[2]=0x01; //Fix the stack pointer from 0x6ad to 0x7ad // this 0x06 byte 112 in the code, which is // byte 112 + 0x100 = 368 in memory state->memory[368] = 0x7; //  DAA state->memory[0x59c] = 0xc3; //JMP state->memory[0x59d] = 0xc2; state->memory[0x59e] = 0x05; 

La prueba está tratando de llegar a una conclusión.


Obviamente, esta prueba se basa en la ayuda del sistema operativo CP / M. Descubrí que CP / M tiene un código de $ 0005 que imprime mensajes en la consola, y cambié mi emulación CALL para manejar este comportamiento. No estoy seguro de si todo salió bien, pero funcionó para los dos mensajes que el programa está tratando de imprimir. Mi emulación CALL para ejecutar esta prueba se ve así:

  case 0xcd: //CALL  #ifdef FOR_CPUDIAG if (5 == ((opcode[2] << 8) | opcode[1])) { if (state->c == 9) { uint16_t offset = (state->d<<8) | (state->e); char *str = &state->memory[offset+3]; // - while (*str != '$') printf("%c", *str++); printf("\n"); } else if (state->c == 2) { //    ,   ,    printf ("print char routine called\n"); } } else if (0 == ((opcode[2] << 8) | opcode[1])) { exit(0); } else #endif { uint16_t ret = state->pc+2; state->memory[state->sp-1] = (ret >> 8) & 0xff; state->memory[state->sp-2] = (ret & 0xff); state->sp = state->sp - 2; state->pc = (opcode[2] << 8) | opcode[1]; } break; 

Con esta prueba, encontré varios problemas en mi emulador. No estoy seguro de cuál de ellos estaría involucrado en el juego, pero si lo estuvieran, sería muy difícil encontrarlos.

Seguí adelante e implementé todos los códigos de operación (con la excepción de DAA y sus amigos). Me llevó 3-4 horas solucionar problemas en mis desafíos e implementar otros nuevos. Definitivamente fue más rápido que el proceso manual que describí anteriormente: antes de encontrar esta prueba, pasé más de 4 horas en el proceso manual. Si puede encontrar esta explicación, le recomiendo usar este método en lugar de compararlo manualmente. Sin embargo, conocer el proceso manual también es una gran habilidad, y si desea emular otro procesador, entonces debe volver a él.

Si no puede realizar este proceso o parece demasiado complicado, definitivamente vale la pena elegir el enfoque descrito anteriormente con dos emuladores diferentes que se ejecutan dentro de su programa. Cuando aparecen varios millones de comandos en el programa y se agregan interrupciones, será imposible comparar manualmente dos emuladores.

Source: https://habr.com/ru/post/es418699/


All Articles