En el marco cyberciruja, gzalo dictó un excelente taller de emulación de CHIP-8 el sábado 2023-07-22. Comparto mi experiencia y resultado.
Me apoyé en el ejemplo provisto, tanto el base como los fragmentos de código de la presentación, le refactoricé las globales a un struct, quité el boolean y renombré .cpp a .c, es que había una trampita de estar usando g++ para compilar código C.
|
Screen dump al finalizar la ejecución
|
Mi objetivo, además de la diversión, es repasar un poco C para no olvidar demasiado, probar algunos recursos que no he usado mucho y otros que sí pero no en C. Entonces ejercité:
- Makefile
- procesar argumentos con getopt
- FSM
- testing unitario sin framework
- valgrind
- gdb
Mi enfoque fue incrementar de modo testeable la menor funcionalidad posible en cada paso. En un comienzo, testeable era que ejecutara el programa hasta la próxima instrucción no implementada, luego agregué test unitario.
Antes de seguir, para no spoilearte, te dejo mi recomendación de cómo encararlo:
- Elegí e inicializá las estructuras u objetos de tu arquitectura.
- Hacé el loop de ejecución.
- Implementá los dumps de los registros y estructuras
- Cargá la ROM
- Armá el switch para los opcodes y los switches internos para los subcodes, poné en cada rama una llamada a una función unimplemented(), unknown() en el default del switch principal, invalid() en cada default de los switches internos:
void unimplemented(){
printf("UNIMPLEMENTED INSTRUCTION\n");
exit(1);
}
- Ejecutá el programa, tiene que tirar un unimplemented, implementá esa instrucción. Y así....
Ya sea a mano o con un framework de testing, respetá TDD.
- Ejecutá hasta el unimplemented: eso te genera el requerimiento
- Escribí el test ateniendote a la especificación, ojo que hay sutiles diferencias
- https://chip8.gulrak.net/ (marcá solo CHIP-8)
- https://github.com/Timendus/chip8-test-suite
- https://en.wikipedia.org/wiki/CHIP-8
- https://tobiasvl.github.io/blog/write-a-chip-8-emulator/
- Dejá que falle para comprobar que es efectivo en la falla
- Hardcodeá para comprobar que es efectivo en el éxito
- Implementá hasta que pase
- Refactorizá
- Again...
Spoiler Alert
Ahora te cuento lo que hice yo, que más o menos se parece, está en cpantel/CHIP-8
Implementé la carga del programa en memoria y comprobé visualmente que soc_dump_memory() y hexdump -C ROM/1-chip8-logo.ch8 correspondieran. Recordá que en el dump debe estar la fuente, luego todos ceros y la imagen del ROM recién en 0x200, 512 decimal)
|
Comparación de memory dump y hexdump de 3-corax+.ch8
|
Implementé avanzar un paso, que haga el fetch y la descomposición en opcode, nibbles, X, Y, keys, NN, NNN, el nombre que sea útil en cada contexto
Tomé el ROM 1-chip8-logo.ch8 que es el que menos instrucciones precisa. De cada instrucción que invoca, implementé el decode y execute. Obviamente para SPRITE me copié de lo que Gustavo proveyó.
Para la primera instrucción que es CLS, mi clear_screen toma un valor, así en init() pongo la pantalla en blanco y ante la primera llamada de CLS se pone en negro.
Implemente que varias medidas intrusivas se activen con left shift, tales como:
- avance paso a paso
- volcado de memoria, video, registros, teclas
- des/activación de debug
Esto es por que el teclado esta mapeado en:
1 2 3 4
q w r t
a s d f
z x c v
Y algunas de esas letras son buenas como atajos:
while (!quit) {
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
quit = 1;
} else if(event.type == SDL_KEYDOWN){
if (event.key.keysym.mod & KMOD_LSHIFT ) {
// para que se vea bien acá
// sym = event.key.keysym.sym;
if(sym == SDLK_r) run = ! run;
if(sym == SDLK_n) soc_step(&soc,1);
if(sym == SDLK_d) {debug = ! debug; run=0;}
if(sym == SDLK_t) {show_time = ! show_time; run=0;}
if(sym == SDLK_v) {soc_dump_registers(&soc);run=0;}
if(sym == SDLK_m) {soc_dump_memory(&soc); run=0;}
if(sym == SDLK_s) {soc_dump_screen(&soc); run=0;}
if(sym == SDLK_k) {soc_dump_key(&soc); run=0;}
if(sym == SDLK_h) {show_help(); run=0;}
if(sym == SDLK_q) quit = 1;
} else {
if(sym == SDLK_1) soc_press_key(&soc, 1);
....
}
} else if(event.type == SDL_KEYUP){
if (event.key.keysym.mod == KMOD_NONE ) {
if(sym == SDLK_1) soc_release_key(&soc, 1);
...
if (run) soc_step(&soc, debug);
}
...
Ahí entrás en un loop de ir implementando la siguiente instrucción que falle....
pc: 200 fetch: 00E0 opcode: 0 X: 0 Y: E opcode2: 0 NN: E0 NNN: 0E0
pc: 202 fetch: A22A opcode: A X: 2 Y: 2 opcode2: A NN: 2A NNN: 22A
pc: 204 fetch: 600C opcode: 6 X: 0 Y: 0 opcode2: C NN: 0C NNN: 00C
pc: 206 fetch: 6108 opcode: 6 X: 1 Y: 0 opcode2: 8 NN: 08 NNN: 108
pc: 208 fetch: D01F opcode: D X: 0 Y: 1 opcode2: F NN: 1F NNN: 01F
UNIMPLEMENTED INSTRUCTION
Implementé una opciones de línea de comando para determinar:
- Estado inicial variable
- Corriendo o pausado
- Imprimiendo debug o no
- Hacer o no dump de pantalla al finalizar
- Cantidad de instrucciones a ejecutar hasta detenerse
- Cantidad de instrucciones por frame, 8 parece ok
- Delay para completar el frame, medio en vano, todo ocurre en mucho menos de un milisegundo, el default de 16 parece ok
- ROM a cargar
Fui tomando cada ROM de https://github.com/Timendus/chip8-test-suite y no hubo problemas hasta la quinta, quirks. De paso ahí noté que muchas de las operaciones tenían una equis en lugar del check ok en los anteriores, entonces tuve que...
Test
Llegó el momento de agregar test unitario. Hasta este momento tenía algo parecido a:
void step(struct typeSOC* soc) {
struct typeInstruction ins;
fetch(soc,&ins);
soc->pc+=2;
switch (ins.opcode) {
case 0x3:
if (soc->v[ins->X] == ins->NN) soc->pc+=2;
break;
...
...
Lo cual es imposible de probar, la implementación de la instrucción debe estar en una función aislada para no estar probando a la vez el fetch y fundamentalmente ese soc->pc+=2;
Me llevé la implementación de cada operación a otro archivo
switch (ins.opcode) {
case 0x0: // CLS
switch(ins.NNN) {
case 0x0E0:
api(soc, &ins, OPCODE_CLS);
if (debug) printf(" CLS\n");
break;
case 0x0EE: // RET
api(soc,&ins, OPCODE_RET);
break;
Este es el test de XOR
void test_XOR(struct typeSOC* soc, struct typeInstruction* ins) {
soc->v[8] = 0xff;
soc->v[2] = 0x2a;
ins->fetch = 0x8823;
predecode(ins);
api(soc, ins, OPCODE_XOR);
assert_equal(0xd5, soc->v[8], "XOR: bad result");
}
siendo assert_equal:
void assert_equal(uint32_t expected, uint32_t got, char * msg) {
if (expected != got) {
printf("\n%s, expected: %x got: %x\n", msg, expected, got);
} else {
printf(".");
}
}
En lo versionado actual quedaron unos if's en lugar de assert_equal, algún dia...
Pude haber implementado una función para cada instrucción, tipo api_CLS(), api_RET() o como lo hice, que es pasarle a una sola función el código y ahí dentro otro switch(), lo cual puede ser mal visto por que parece haber repetición de código, pero, no estoy tan seguro. El primer switch tiene switches anidados y el segundo no, es plano.
Usar funciones distintas es mejor manera de autodocumentar y un mejor lugar para poner los unimplemented(). Los invalid() y unknown() quedarían en el switch de soc.c.
Tal como hice los test, sin framework, tiene una muy evidente desventaja, que es no poder imprimir un puntito tras cada test realizado de modo elegante. Ni poder llevar la cuenta de los tests y asserts ejecutados. Digo de manera elegante, siempre se puede llenar todo de printfs y globales...
Otra limitación, no es que no se pueda hacer con C, es que de haber usado un lenguaje orientado a objetos, quedaría muy prolijo encapsular todo con setters y getters en lugar de acceder directamente a los registros y memorias y así poder recopilar algunas estadísticas como:
- uso de registros
- uso de instrucciones
- uso del stack
- uso de botones
- área de memoria leída y escrita
Esto serviría para hacer una versión reducida, como para portar a FPGA chica o lo que me interesaría, el core RISC-V icicle en la EDU-FPGA.
Traza
Un recurso es analizar una traza prexistente, se puede obtener de los emuladores octo. Por ejemplo en (https://timendus.github.io/chip8-test-suite/3-corax+.html), poniendo algo como
console.log("op: " + op.toString(16) + " o: "
+ o.toString(16) + " pc: " + this.pc.toString(16));
cerca de la línea 470 cuando descompone el fetch, genera una traza. Si en tu emulador hacés algo compatible, con ./tools/compare comparás.
Esto sirve para las partes donde no hay uso de timer ni lectura de teclado.
Headless
Implementé una versión headless, más que todo porque en mi máquina no quería instalar SDL2, entonces hice todo en otra con ssh y una ventana de VNC, solo en esa ventana se puede ejecutar código con SDL. Medio en vano pues con ssh -X funciona casi ok.
Dump ascii
Motivado por la posibilidad de testear la imagen rederizada, implementé que se haga un dump por terminal. Para ser franco no es ascii, es unicode, pero bueno. Es la primera imagen de este post, se puede ejecutar en cualquier momento o al finalizar.
Autostop
Aunque ningún programa baremetal como son estos termina, quizás queremos que terminen al terminar de ejecutar. Esto tiene sentido en los tests y la manera de implementarlo es hacer un salto incondicional a esta misma instrucción, o sea al PC actual. Esto se detecta y finaliza la emulación.
if (ins.opcode == 1 && soc->pc == ins.NNN) {
return 1;
}
Mi implementación en C
Está en
cpantel/CHIP-8 y te cuento que no me maté mucho en que los componentes respetaran la jerarquía, fijate que debió haber sido así:
struct typeSOC
pc -> soc.cpu.pc
i -> soc.cpu.i
v[] -> soc.cpu.v[]
stack_pointer -> soc.cpu.stack_pointer
key[] -> soc.keyboard.keys[]
kb_state -> soc.keyboard.state
last_key -> soc.keyborad.last
stack[] -> soc.stack[]
memory[] -> soc.memory[]
screen[] -> soc.screen.memory[]
redraw -> soc.screen.redraw
delay_timer -> soc.timer.delay
sound_timer -> soc.timer.sound
y la parte de la instruction, quizás así:
count -> soc.cpu.instruction.count
fetch -> soc.cpu.instruction.fetch
opcode -> soc.cpu.instruction.opcode
X/key -> soc.cpu.instruction.X/key
Y/N/opcode2 -> soc.cpu.instruction.Y/N/opcode2
NN -> soc.cpu.instruction.NN
address/NNN -> soc.cpu.instruction.address/NNN
pero implica agregar archivos y un montón de refactorización, no vale la pena.
Valgrind lo usé para ./tools/compare, no para chip8, así que no sé si
Posibilidades de mejora
RND: proveerle una seed por línea de comando.
Comparar la memoria de pantalla con una imagen de referencia.
Poder cambiar mediante parámetros de línea los colores de background y foreground.
Que en debug imprima el código disassembly.
Que pase el test quirks.
Implementar las validaciones de uso del stack y algunas otras.
Que imprima algún help para las command line options.
Que valide que exista el archivo de ROM y su longitud.
Usar funciones para cada instrucción en lugar del segundo switch.
Conclusión
Muy buena la explicación de gzalo y su asistencia posterior, me destrabó al menos en un ínfimo pero mortífero error muy difícil de notar en la lectura del teclado.
La experiencia fue muy buena, sin presión, hacer algo que no sirve para nada por fuera de haberlo hecho. Es que los juegos son horribles, inusables, no probé ninguno más allá de cargarlo para ver que funcionara algo.
La mayor parte de las posibilidades de mejora serían aplicables en caso de querer desarrollar programas para esta plataforma, nada más lejos de mis intenciones, se lo dejo a la gente más retro.