[Diario de Desarrollo] Creación de un Emulador de Chip-8

Buenas, hoy le vengo a traer un pequeño diario/guía de desarrollo sobre el desarrollo de un emulador de Chip-8 que llevo tiempo haciendolo (lo empece por alrededor de 2024) y despues de idas y venidas ya logre poder ejecutar 2 ROM de prueba que veremos mas tarde, siendo replicarle en cualquier lenguaje si se entiende los conceptos bien, en este caso yo use Rust.

Es un interprete lanzado a mediados de los 70s desarrollado por Joseph Weisbecker para el microprocesador 1802 y usado en microcomputadoras como la COSMAC VIP como el interprete predeterminado, siendo un interprete de 8-bit programable en base hexadecimal.

Requisitos/Conceptos Generales

Para empezar vamos a explicar conceptos necesarios que deberiamos saber para entender todo sin mucha dificultad.

RAM/Memoria

Empezando por unas de las piezas mas importante, la Memoria, siendo en la que vamos a cargar las ROM y donde vamos a mantener todas las operaciones que hagamos; Para este caso sera de 4KB de capacidad, aunque jamas ninguna ROM va a poder llenar esos 4KB asi que es lo justo que necesitamos. El Chip-8 al momento de empezar a leer una ROM lo hace desde la dirección 0x200 (que serian unos 512 bytes en decimal) en adelante, asi que desde ese punto es donde vamos a cargar la ROM para ser ejecutada. La Memoria puede ser un Array de 4096 posiciones (que es su capacidad en bytes).

Contador de Programa/Program Counter(PC)

El Contador de Programa (mejor lo llamamos PC) es el “apuntador” de las instrucciones en memoria actual, es el que se encarga de saber que instrucción necesitamos leer para ser ejecutada. Es una variable de 16 bits y ya veremos porque.

Pantalla

Esta es facil, para el Chip-8 original la pantalla era de 64px de ancho y 32px de alto, pero para el SUPER-CHIP es de 128px de ancho y 32px de alto; Es una pantalla monocromática (osea, blanco y negro) haciendo asi que nos facilita bastante para no tener que estresarnos para representar colores. Para representar los graficos en pantalla puede usar cualquier cosa en la que puedas representar y mover pixeles, en mi caso use directamente la terminal pero si gustas de usar OpenGl o SDL adelante.

Registro de Indice/I

Es un registro de 16 bits usado para apuntar a direcciones de memoria, usado para apuntar a un sprite en memoria o algun otro elemento. De igual manera es una variable de 16 bits.

Pila/Stack

Este es uno de los conceptos mas complicados de explicar, la Pila conforma parte de la memoria y se basa en un estricto sistema de El ultimo que entra, el primero que sale usado para ejecutar funciones o subrutinas, siendo que lo usan las funciones para guardar datos y devolver esos mismo datos. Es un Array de 16 bits con 16 bytes de posiciones.

Por lo general el stack siempre es un cacho de la memoria (por ejemplo, si la memoria tiene 4KB un 1KB es destinado a el Stack) pero para este caso no haremos eso si no que el stack ira aparte.

Registro General/V

Este son Registro que usaremos para guardar datos o argumentos para funciones, llendo del 0 al F (si, la longitud del codigo hexadecimal). Este sera un Array de 16 bytes de posiciones pero sus datos seran de 8 bits.

El registro VF por lo general es usado como una flag, que pronto veremos para que se usa.

Stack Pointer

Para no hacerlo tan largo, el Stack Pointer es lo mismo que PC pero para el Stack, es que apunta al ultimo dato que entro para poder ser liberado cuando el programador lo pida. Es una variable de 16 bits.

Fuentes

Recuerdas los primeros 512 bytes (la posición 0x200) que dejamos libre? ya es hora de ser utilizado, tenemos desde la dirección 000-1FF para pegar las fuentes en RAM, mas abajo te voy a dejar la fuente para que solo tengas que copiar y pegar en el codigo, la fuente es imprencidible para chip-8 asi que es estritamente necesaria, la fuente va desde el 0 al F, caracter de la fuente debe ser de 4px de ancho por 5px de altura, son como sprites normales de personaje. Hay una instrucción especifica que estable la dirección de la fuente, pronto la veremos.

0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80  // F

Y ya, con eso tenemos los imprencidible para poder ejecutar 2 ROM de prueba, una es la Chip-8 splash screen y la otra es la IBM, estas 2 sacadas de este repositorio de github en cual hay muchas ROM de pruebas que podemos ejecutar. Hay mas cosas como el Temporizador o la función de ejecutar Sonido que hay que explicar pero estas ROM no hace falta y en otra entrada sobre este emulador profundizare en ello, asi que si, esto todavia no ha terminado.

Una pequeña aclaración que doy que a mi me dio mucha confunsión, cuando se utiliza un numero hexadecimal (como el 0x200) no hace falta hacer la conversión nosotros mismo, ya por general los lenguajes hacen esas conversiones por nosotros, asi que puedes poner 0x200 como posición y te va dar la posición 512.

Ciclo del Interprete

Como se puede ver en la imagen de arriba, es un ciclo de lectura(fetch), identificar(opcode) y ejecución(execute) siendo el bucle interminable para poder ejecutar la ROM.

Lectura/Fetch

Esta es una de las fases mas importante, aquí es donde leeremos la instrucción que consta de 2 bytes que utilizaremos, con ayuda de PC podremos leer la instrucción.

Para leer cualquier instrucción necesitaremos de 2 variables de 8 bits (1 byte), en una llevara la primera mitad de la instrucción y la otra variable la otra mitad, ¿Por qué hacemos esto y no directamente leemos los 2 bytes de la instrucción? como bien sabemos PC es un apuntador, mas no el que almacena los datos de la instrucción y como la memoria de chip-8 lee en 1 solo byte a la vez es lo que nos impide leer la instrucción de 16 bits de una vez.

Ya que tenemos cada mitad de la instrucción vamos a combinar en un solo opcode o en una sola para que sea mas facil de utilizar y leer.

Para entender esta parte primero tenemos que entender como se ve una variable de 16 bits de manera “grafica”:

00000000 00000000

así se ve una variable de 16 bits sin datos en memoria, si por ejemplo tengo este valor en hexadecimal 0xAB (que seria en este valor en binario 10101011) y hago que mi variable de 16 bits copie ese contenido la variable se veria así:

00000000 10101011

y ahora construir este valor de 2 bytes (0xABCD), ¿cómo hariamos esto? Para copiar los el bytes que nos falta (0xCD) lo que tenemos que hacer es mover 8 posiciones a la izquierda el valor que ya tenemos copiado, haciendo que se vea así:

10101011 00000000

Para cualquier lenguaje hacer este proceso (el mover X posiciones un valor) se hace con este “<<” operador dependiendo en que dirección lo quieres mover

y con el operador Bitwise OR podemos combinar los valores para construir el valor final (0xABCD):

10101011 11001101

Y para terminar con la explicación de Fetch tenemos que incrementar PC en 2 para hacer que este listo para la siguiente lectura de instrucción.

Identificación/Opcode

Esta es la parte en la que vamos a identificar la instrucción que construimos en la anterior fase, Chip-8 consta de 35 instrucciones que tenemos que implementar pero hoy solo implementaremos 6 de ellas.

Una forma sencilla de hacer todo esto es si tu lenguaje admite switch pero si no lo admite con if/elseif también lo puede hacer.

Ahora, una forma de separar todos los argumentos los argumentos es en forma de nibble (4 bits) para poder ejecutar cada instrucción. Tienes que separar cada nibble de la instrucción que construimos:

  • X: que es el segundo nibble se saca moviendo 8 bits y haciendo una operación AND (bit a bit) con una mascara (0xF).

  • Y: tercer nibble se saca moviendo 4 bits y igualmente haciendo una operación AND (bit a bit) con una mascara (0xF).

  • N: que es cuarto nibble, solo haces la operación AND con la mascara (0xF).

  • NN: el segundo byte (que es el tercer y cuarto nibble) de igual manera se hace la operación AND con la mascara (0xFF).

  • NNN: siendo el segundo, tercero y cuarto nibble formado un valor de 12 bits se hace la operación AND y con la mascara (0xFFF).

esta van hacer las instrucciónes que haremos hoy:

  1. 0x00E0: limpia la pantalla

  2. 0x1NNN: PC salta a la dirección NNN

  3. 0x6XNN: copia el valor de NN al registro VX

  4. 0x7XNN: se le añade el valor de NN al registro VX

  5. 0xANNN: se establece a el registro I el valor de NNN

  6. 0xDXYN: este es la instrucción de dibujo en la pantalla (que es la mas complicada)

Ejecución/Execute

No me dedicare a explicar cada instrucción porque creo que se bastante descriptivas con lo que tienes que hacer pero si me centrare en la de dibujado (0xDXYN) que es la mas dificil de implementar, y la explicare paso a paso:

  • Se establece X en la coordenada VX en modulo del ancho de la pantalla (o también se puede hacer con el operador AND).

  • Se establece Y en la coordenada VY en modulo del alto de la pantalla.

  • Se establece VF en 0 (la flag que habiamos ya mencionado).

  • Se empieza un ciclo FOR con N ciclos

  • Se obtiene el enésimo bytes empezando a contar desde el registro I hasta N.

  • Se empieza otro ciclo FOR desde 0 a 8 (eso es la longitud de sprite, 8px)

  • Para averguar si el pixel de la pantalla esta prendido se hace una operación AND con el sprite y se hace un desplazamiento de bit a 128 (0x80) a la derecha con el valor del ciclo actual y si da un numero diferente a 0 significa que ese pixel esta prendido.

  • Si se llega al borde la pantalla deja de dibujar

  • Y para terminar, se marcar VF en 1 o 0 dependiendo si el pixel esta apagado o no (puedo hacer con un facil if)

Si no entiendes absolutamente nada tranquilo, es normal, es instrucción bastante complicada que a mi todavia me sigue constando entender.

El fin?..

Y ya con eso tenemos todo para poder ejecutar las ROMS que mencione arriba, pronto seguire ampliando el emulador añadiendo las 29 instrucciones que nos falta y ampliando esta guia en español con tema del sonido y temporizador, por lo pronto dejo el repositorio por si necesitas leer codigo para poder entenderlo (si, hablo sobre todo de la maldita instrucción DXYN).

Bibliografía

Aquí le dejo las guías que use para hacer este emulador, NADA DE IA, asi pierde la diversión, si no entiendes la dichosa instrucción DXYN con la guía de Tobias V. I. Langhoff seguro que te ayuda.

1 me gusta

porque no hacer un isa for scratch?