Creando un Emulador de Chip8 en C
Esta entrada no va a ser una guia completa para desarrollar un emulador de chip8 desde cero, con solamente leer esta entrada no vas a tener un emulador completamente funcional, que podras compilar y ejecutar, sino que mas bien te podras hacer una idea general de como funciona el chip8, y de como sus instrucciones podrian ser implementadas en C.
Importante: Antes de continuar con esta entrada, es importante tener sdl2 instalado, y tener los headers para usarlo en programas de C.
Chip8
Primero, vamos a ver los detalles tecnicos de como funciona el Chip8, antes de que miremos su implementacion, para asi saber que es lo que estamos haciendo y por que.
Pueden encontrar una referencia mas completa (en ingles) aqui.
Distribucion de Memoria ATTACH
El Chip8 tiene 4095 bytes de memoria, que estan representados de la siguiente manera:
La direccion 0x00 es donde se marca el inicio de la RAM del Chip8.
Desde las direcciones 0x00 a 0x1FF (511 bytes) es un espacio reservado para que
Chip8 almacene algunos datos, como el conjunto de caracteres. Todos los
programas del Chip8 se cargan en la direccion de memoria 0x200, y tienen desde
0x200 y 0xfff para almacenar todas sus instrucciones y sus propios sprites.
El Chip8 tiene un stack que es un array de 16 diferentes valores de 16 bits, estos se utilizan para almacenar las direcciones en las que el Chip8 debe volver cuando se termine una subrutina, lo que quiere decir que pueden 16 niveles de subrutinas anidadas. Puesto en palabras mas simples, cuando una subrutina se llama, la direccion de memoria de la instruccion donde esta fue llamada debe ser pusheada al stack, entonces si tenemos 3 llamadas de subrutinas anidadas, van a haber 3 diferentes entradas en nuestro stack, cuando alguna de estas subrutinas finalice su ejecucion, se le va a hacer pop a la ultima entrada, y volveremos a esa instruccion.
El Conjunto de Caracteres
El Chip8 contiene algunos sprites por defectos que se llaman “conjunto de caracteres”, ellos son un conjunto de sprites que representan los digitos hexadecimales desde 0 a F. Estos ocupan 5 bytes (son sprites 8x5) y son almacenados en el area reservada para el chip8 (0x00 - 0x1ff).
Los siguientes son ejemplos de algunos caracteres:
+----+--------+----+ +----+--------+----+
|"0" |Binario |Hex | |"1" |Binario |Hex |
+----+--------+----| +----+--------+----+
|****|11110000|0xF0| | 1 |00100000|0x20|
|* *|10010000|0x90| | 11 |01100000|0x60|
|* *|10010000|0x90| | 1 |00100000|0x20|
|* *|10010000|0x90| | 1 |00100000|0x20|
|****|11110000|0xF0| | 111|01110000|0x70|
+----+--------+----+ +----+--------+----+
Registros
El Chip8 tiene 16 registros que pueden almacenar valores de 8 bits, es decir, unicamente pueden almacenar un byte de informacion, los registros son los siguientes:
- V0
- V1
- V2
- V3
- V4
- V5
- V6
- V7
- V8
- V9
- VA
- VB
- VC
- VD
- VE
- VF
Es importante tener en cuenta, que el registro VF no debe ser usado por un
programa, ya que algunas instrucciones van a manipular este registro a manera de
asignar flags cuando ciertas condiciones se cumplan, pero los demas 15 registros
pueden ser utilizados.
El Chip8 tambien tiene algunos registros adicionales:
- I: Usado para almacenar direcciones en memoria.
- PC: Apunta a la direccion de la instruccion que se esta ejecutando actualmente. Todas las instrucciones del Chip8 tienen 2 bytes de tamano, entonces este debe ser incrementado por 2 cada vez que una instruccion es ejecutada.
- SP: Este es el puntero al stack.
- Sound Timer (ST): este registro le permite al Chip8 reproducir el sonido de un beep, este sonara cuando este registro sea diferente a cero, va a ir disminuyendo a un ritmo de 70hz. Si el registro es 0, ningun sonido sera reproducido.
- Delay Timer (DT): Este timer se utiliza cuando queremos detener la ejecucion de un programa, funciona de forma bastante similar al ST, cuando este registro es diferente de 0, el Chip8 va a detener todas sus instrucciones, y disminuira a un ritmo de 60hz. Cuando el DT sea 0 de nuevo, la ejecucion se reanudara.
El Display
La resolucion del display del Chip8 es de 64x32 pixeles y es monocromatico, significa que puede mostrar unicamente los colores blanco y negro, pueden ser representados como booleanos: 0 para negro, 1 para blanco.
Cuando dibujamos algo en la pantalla, estamos dibujando sprites y no pixeles individuales. Si un sprite le hace overflow a uno de los lados de la pantalla, sera dibujado en el otro lado.
Los sprites tienen un maximo de longitud de 8 bits de ancho, y 15 bits de alto.
El Teclado
El teclado del Chip8 tiene 16 teclas: desde los numeros 0 a F.
Asi es como se veria:
| 1 | 2 | 3 | C |
|---|---|---|---|
| 4 | 5 | 6 | D |
| 7 | 8 | 9 | E |
| A | 0 | B | F |
Set de Instrucciones
El conjunto de instrucciones del Chip8 es reducido, unicamente tiene 16 instrucciones diferentes. Estas instrucciones son para operaciones matematicas, dibujar en pantalla, y manipulacion de registros.
La Implementacion
Como referencia, vamos a tomar uno de mis codigos antiguos (de cuando usaba github) aqui.
Estructura Principal
El nucleo del emulador es una estructura que se llama struct chip8 ubicada en
include/chip8, esa estructura es bastante simple, tiene los siguientes campos:
struct chip8
{
struct chip8_memory memory;
struct chip8_registers registers;
struct chip8_stack stack;
struct chip8_keyboard keyboard;
struct chip8_screen screen;
};
Esa estructura contiene todos los elementos que previamente fueron mencionados, la memoria, los registros, el stack, el teclado y la pantalla.
La estructura chip8_memory (definida en include/mem.h) es una estructura que
tiene una variable de 4095 bytes unsigned char memory, y tiene ciertas funciones
como la de obtener un valor en cierto indice, insertar un valor en cierto
indice, y obtener un short (dos bytes) en cierto indice.
La estructura chip8_registers (definida en include/registers.h) contiene todos
los registros que fueron mecionados anteriormente, tiene un array de unsigned char llamado V con un tamano de 16, tambien tiene otros unsigned char como el
delay timer, el stack pointer, y otros registros como I y PC que son del tipo
unsigned short.
La structura chip8_stack (definida en include/stack.h) tiene una variable de
tipo unsigned short llamada stack, con un tamano de 16, y algunas funciones para
hacer push y pop.
chip8_keyboard y chip8_screen son estructuras simples, que unicamente contienen
arrays.
Tambien hay un archivo adicional include/config.h que se usa unicamente para
usar macros y no magic numbers a lo largo del codigo.
La Funcion Principal
La funcion principal del emulador (definida en src/main.c) se hacen varias
cosas:
- Los argumentos que se pasan al programa se usan para cargar la ROM. El
emulador necesita ser ejecutado como:
./chip rom.ch8. - Abrimos la ROM, la leemos, y la guardamos en una variable temporal
buf. - Llamamos una funcion
chip8_initque pone toda la estructurastruct chip8en cero, y copia el conjunto de caracteres por defecto achip8->memory.memory. - La ROM que leimos antes se carga en la memoria del chip8 usando
chip8_load, que le va a hacermemcpyachip8->memory.memory + 0x200, y pone el registro PC en 0x200 tambien. - Se inicializa SDL, y se crea la ventana.
- Entramos al bucle principal:
- Revisamos si se presiona alguna tecla con la ayuda de funciones de SDL y
actualizamos nuestro teclado virtual usando funciones definidas en
src/keyboard.c, hacemos un mapeo de la tecla fisica a nuestro teclado virtual. - Limpiamos la pantalla y ponemos el color de renderizado como blanco.
- Pasamos por cada uno de los pixeles en
chip8.screen.screeny lo dibujamos en el display de SDL. - Revisamos los registros DT y ST, si DT esta marcado, hacemos
sleeppor 100 milisegundos y le disminuimos 1 al registro DT. - Obtenemos el opcode en la memoria del Chip8 que esta en la posicion
chip8.registers.PC, incrementamosPCpor 2 y finalmente la ejecutamos.
- Revisamos si se presiona alguna tecla con la ayuda de funciones de SDL y
actualizamos nuestro teclado virtual usando funciones definidas en
- Cuando estamos fuera del bucle destruimos la pantalla de SDL.
Ejecutando Instrucciones
Hay una funcion que se llama en cada iteracion del bucle principal llamada
chip8_exec (definida en src/chip8.c) que recibe como argumento la estructura del
chip8 y el opcode que esta en la posicion 0x200 + PC.
Esta funcion es una coleccion inmensa de switches anidados que prueban todas las posibles instrucciones que pueden ser pasadas al emulador, las instrucciones se pueden ver la siguiente manera:
0xnnn3xkk5xy0Dxyn
Las letras n, nnn, kk, x y y significan cosas distintas:
nnn: un valor de 12 bits, por ejemplo0x0fff.n(nibble): un valor de 4 bits, por ejemplo0x000fx: un valor de 4 bits.y: un valor de 4 bits.kk(byte): un valor de 8 bits, por ejemplo0x00ff.
Ahora, usando como ejemplo las instrucciones mostradas antes, pueden ser transformadas de la siguiente manera:
0nnn->0fffdondennnesfff.3xkk->30acdondexes 0 ykkesac.5xy0->5100dondexes 1 yyes 0.Dxyn->Dae0dondexesa,yeseynes0.
Sabiendo eso, debemos extraer 5 cosas de cada opcode que se pasa a la funcion para ejecutarlas para poder interpretarlas, necesitamos saber la operacion (los 4 bits mas significantes del opcode), nnn, x, y, kk, y n. podemos hacer esto usando operaciones con bits, vamos a revisar cada una de ellas (consideremos que tenemos una variable opcode con una instruccion de 2 bytes.)
unsigned short nnn = opcode & 0x0fff;
unsigned char x = (opcode >> 8) & 0x000f;
unsigned char y = (opcode >> 4) & 0x000f;
unsigned char kk = opcode & 0x00ff;
unsigned char n = opcode & 0x000f;
Podemos hacerlo de esa manera ya que estos elementos siempre van a estar en la
misma posicion, por eso es que shifteamos x por 8 bits, y y por 4, y asi con los
demas.
Ahora, tenemos tres funciones en src/chip8.c que ejecutan opcodes:
chip8_exec: ejecuta instrucciones tan simples que no se necesita extraer ninguno de los elementos mostrados antes.chip8_exec_extended: ejecuta instrucciones que necesita extraer los elementos anteriormente mencionados.chip8_exec_extended_F: esta existe porque hay demasiadas instrucciones que empiezan conFy su comportamient es definido por su ultimo byte, por ejemplo:Fx07,Fx01,Fx15, etc.
La primera funcion chip8_exec tiene switch similar al siguiente:
switch (opcode)
{
/* los casos vienen aqui. */
default:
chip8_exec_extended (chip8, opcode);
break;
}
Esta va a ejecutar algunas instrucciones simples (unicamente 0x00E0 y 0x00EE) ,
y si el opcode que se paso no es ninguna de esas, ahora se va a llamar
chip8_exec_extended, que tiene un switch similar a este:
switch (opcode & 0x0f000)
{
/* los casos vienen aqui */
case 0xF000:
chip8_exec_extended_F (chip8, opcode);
break;
}
En ese bloque de codigo estamos haciendo el switch por los 4 bits mas
significantes (diciendo que pueden ser valores entre 0 - F), y los compararemos
como: 0x1000, 0x2000, 0x3000, y asi suscesivamente.
Finalmente, chip8_exec_extended_F, como ya sabemos que empieza con F, unicamente
necesitamos comparar los 4 bits menos significantes:
switch (opcode)
{
/* los casos vienen aqui */
}
Sabiendo como se comparan todas las instrucciones, podemos implementar el conjunto de instrucciones de chip8.
Conjunto de Instrucciones
-
0x00E0
Esta instruccion limpia la pantalla, asi que literalmente es solo hacer esto:
case 0x00E0: chip8_screen_clear (&chip8->screen); break;
-
0x00EE
Esta instruccion se ejecuta cuando terminamos con una subrutina y queremos volver a la posicion donde la llamamos, entonces le asignamos a
PCel valor del ultimo elemento del stack.case 0x00EE: chip8->registers.PC = chip8_stack_pop (chip8); break;
-
0x1nnn
Esta instruccion ira a la ubicacion
nnn.case 0x1000: chip8->registers.PC = nnn; break;
-
0x2nnn
Esta instruccion llama la subrutina en la ubicacion
nnn.chip8_stack_push (chip8, chip8->registers.PC); chip8->registers.PC = nnn;
-
0x3xkk
Esta instruccion se va a saltar la siguiente instruccion si el registro
Vx = kk.if (chip8->registers.V[x] == kk) chip8->registers.PC += 2;
-
0x4xkk
La siguiente instruccion se va a saltar si el registro
Vx != kk.if (chip8->registers.V[x] != kk) chip8->registers.PC += 2;
-
0x5xy0
La siguiente instruccion se saltara si el registro
Vx = Vy.if (chip8->registers.V[x] == chip8->registers.V[y]) chip8->registers.PC += 2;
-
0x6xkk
El registro
Vx = kk.chip8->registers.V[x] = kk;
-
0x7xkk
Le suma
kkal registroVx.chip8->registers.V[x] += kk;
-
0x8xy0
Asigna el valor de
Vx =Vy.chip8->registers.V[x] = chip8->registers.V[y];
-
0x8xy1
Hace OR a
VxyVy, almacena el resultado enVx.chip8->registers.V[x] |= chip8->registers.V[y];
-
0x8xy2
Hace AND a
VxyVy, almacena el resultado enVx.chip8->registers.V[x] &= chip8->registers.V[y];
-
0x8xy3
Hace XOR a
VxyVy, almacena el resultado enVx.chip8->registers.V[x] ^= chip8->registers.V[y];
-
0x8xy4
Sumara
VxyVy, pondraVFsi el resultado es superior a 1 byte.chip8->registers.V[0x0f] = chip8->registers.V[x] + chip8->registers.V[y] > 0xff; chip8->registers.V[x] += chip8->registers.V[y];
-
0x8xy5
Restara
VxyVy, y pondraVFsi es necesario prestar.chip8->registers.V[0x0f] = chip8->registers.V[x] > chip8->registers.V[y]; chip8->registers.V[x] -= chip8->registers.V[y];
-
0x8xy6
Si el bit menos significa de
Vxes 1,VFsera asignada,Vxsera dividida por 2.chip8->registers.V[0x0f] = chip8->registers.V[x] & 0x01; chip8->registers.V[x] = chip8->registers.V[x] / 2;
-
0x8xy7
Pone
Vx = Vy - Vx, asignaraVFsi es necesario prestar.chip8->registers.V[0x0f] = chip8->registers.V[y] > chip8->registers.V[x]; chip8->registers.V[x] = chip8->registers.V[y] - chip8->registers.V[x];
-
0x8xyE
Pondra
VFsi el bit mas significante deVxes 1, y multiplicaraVxpor 2.chip8->registers.V[0x0f] = chip8->registers.V[x] & 0b10000000; chip8->registers.V[x] = chip8->registers.V[x] * 2;
-
0x9xy0
Se salta la siguiente instruccion, si
Vx != Vy.if (chip8->registers.V[x] != chip8->registers.V[y]) chip8->registers.PC += 2;
-
0xAnnn
Pone el registro
Iannn.chip8->registers.I = nnn;
-
0xBnnn
Salta la la ubicacion
nnn + V0chip8->registers.PC = nnn + chip8->registers.V[0x00];
-
0xCxkk:
Le asignara
Vxun byte aleratorio y le hara AND con kk.srand (clock ()); chip8->registers.V[x] = (rand () % 255) & kk;
-
0xDxyn
Dibujara el sprite al que I esta apuntando, en la posicion
Vx,Vy, con una altura den.VFse asignara si hay una colision, basicamente, si dibujamos sobre un pixel que ya ha sido marcado con 1.const char *sprite = (const char *)&chip8->memory.memory[chip8->registers.I]; chip8->registers.V[0x0f] = chip8_screen_draw_sprite (&chip8->screen, chip8->registers.V[x], chip8->registers.V[y], sprite, n);
-
0xEx9e
Se salta la siguiente instruccion si la tecla Vx es presionada:
if (chip8_keyboard_is_down (&chip8->keyboard, chip8->registers.V[x])) { chip8->registers.PC += 2; }
-
0xExa1
Se salta la siguiente instruccion si la tecla Vx no es presionada.
if (!chip8_keyboard_is_down (&chip8->keyboard, chip8->registers.V[x])) { chip8->registers.PC += 2; }
-
0xFx07
El valor de Vx sera asignado el valor del DT.
chip8->registers.V[x] = chip8->registers.DT;
-
0xFx0a
El programa parara completamente hasta que se haya presionado una tecla, y almacenaremos el valor de esas tecla en Vx.
char key = chip8_wait_for_keypress (chip8); chip8->registers.V[x] = key;
-
0xFx18
Asignara el ST a Vx.
chip8->registers.ST = chip8->registers.V[x];
-
0xFx1e
Sumara Vx con I.
chip8->registers.I = chip8->registers.I + chip8->registers.V[x];
-
0xF29
Asignara I con el valor de Vx.
chip8->registers.I = chip8->registers.V[x] * CHIP8_SPRITE_DEFAULT_HEIGHT;
-
0xFx33
Tomara un numero almacenado en Vx, y le va a asignar el valor de I a las centenas, I + 1 a las decenas, y 1 + 2 a las unidades de ese numero.
unsigned char hundreds = chip8->registers.V[x] / 100; unsigned char tens = (chip8->registers.V[x] / 10) % 10; unsigned char units = chip8->registers.V[x] % 10; chip8_memory_set (&chip8->memory, chip8->registers.I, hundreds); chip8_memory_set (&chip8->memory, chip8->registers.I + 1, tens); chip8_memory_set (&chip8->memory, chip8->registers.I + 2, units);
-
0xFx55
Almacena desde los registros V0 a Vx en memoria, iniciando en la posicion I.
for (int i = 0; i <= x; i++) { chip8_memory_set (&chip8->memory, chip8->registers.I + i, chip8->registers.V[x]); }
-
0xFx65
Lee los registros desde V0 hasta Vx en la posicion de memoria iniciando en I.
for (int i = 0; i <= x; i++) { chip8->registers.V[i] = chip8_memory_get (&chip8->memory, chip8->registers.I + i); }Si se implementan todas esas instrucciones tal como fueron descritar arriba, ya deberias tener un emulador de chip8 funcional.