Este proyecto es educativo y Open Source. No se copia código de otros emuladores. Implementación basada únicamente en documentación técnica y tests permitidas.
Implementación de Stack y Subrutinas en C++
Resumen
Se implementó el Stack (Pila) y las operaciones de subrutinas en C++, añadiendo los helpers de pila (push_byte, pop_byte, push_word, pop_word) y 4 opcodes críticos: PUSH BC (0xC5), POP BC (0xC1), CALL nn (0xCD) y RET (0xC9). La implementación respeta el crecimiento hacia abajo de la pila (SP decrece en PUSH) y el orden Little-Endian correcto. Todos los tests pasan, validando operaciones básicas, CALL/RET anidados y el comportamiento correcto de la pila.
Concepto de Hardware
El Stack (Pila) es una estructura de datos LIFO (Last In First Out) que permite a la CPU "recordar" direcciones de retorno cuando se ejecutan subrutinas. En la Game Boy, la pila crece hacia abajo (direcciones de memoria decrecientes), lo que significa que el Stack Pointer (SP) se decrementa cuando se hace PUSH y se incrementa cuando se hace POP.
Stack Growth (Crecimiento de la Pila): La pila crece hacia abajo porque el espacio de memoria de la pila está típicamente en la región alta de la RAM (0xFFFE es el valor inicial típico). Al decrementar SP, la pila se expande hacia direcciones más bajas, evitando colisiones con el código y datos del programa.
Little-Endian en PUSH/POP: Cuando se hace PUSH de una palabra de 16 bits (ej: PC), se escribe primero el byte ALTO (MSB) en SP-1 y luego el byte BAJO (LSB) en SP-2. Al hacer POP, se lee primero el byte BAJO de SP y luego el byte ALTO de SP+1, combinándolos en formato Little-Endian. Este orden es crítico para la correcta restauración de direcciones.
CALL y RET: CALL nn guarda la dirección de retorno (PC actual) en la pila y luego salta a la dirección nn. RET recupera la dirección de retorno de la pila y restaura PC. Sin estas operaciones, la CPU no puede ejecutar código estructurado con subrutinas, limitándose a "espagueti de saltos" sin capacidad de retorno.
Optimización C++: Las operaciones de pila son extremadamente frecuentes en el código de la Game Boy (cada CALL/RET, cada interrupción). En C++, estas operaciones se compilan a simples movimientos de punteros y asignaciones directas de memoria, ofreciendo rendimiento brutal comparado con Python donde cada operación implica múltiples llamadas a función y gestión de objetos.
Implementación
Se añadieron 4 métodos privados inline en la clase CPU para operaciones de pila:
push_byte(), pop_byte(), push_word() y
pop_word(). Estos métodos manejan la aritmética del Stack Pointer y
el orden correcto de bytes (Little-Endian).
Componentes creados/modificados
- CPU.hpp: Añadidas declaraciones de métodos de stack inline.
- CPU.cpp: Implementación de helpers de stack y 4 nuevos opcodes (0xC5, 0xC1, 0xCD, 0xC9).
- tests/test_core_cpu_stack.py: Suite completa de 4 tests para validar stack nativo.
Decisiones de diseño
- Métodos inline: Los helpers de stack son métodos privados inline para máximo rendimiento. El compilador los incrusta directamente en los opcodes, eliminando el coste de llamada a función.
- Wrap-around en 16 bits: Todas las operaciones de SP usan
& 0xFFFFpara garantizar que SP siempre esté en el rango válido de 16 bits, incluso en casos edge (aunque en hardware real esto no debería ocurrir). - Orden de bytes en PUSH/POP: PUSH escribe high byte primero (SP-1), luego low byte (SP-2). POP lee low byte primero (SP), luego high byte (SP+1). Este orden es consistente con el formato Little-Endian de la Game Boy.
- CALL guarda PC después de fetch: CALL guarda el valor de PC después de leer toda la instrucción (incluyendo la dirección destino), que es la dirección de la siguiente instrucción. Esto permite que RET retorne correctamente al código que sigue al CALL.
Código clave
// Helper para PUSH de palabra (16 bits)
void CPU::push_word(uint16_t val) {
push_byte((val >> 8) & 0xFF); // High byte primero
push_byte(val & 0xFF); // Low byte segundo
}
// Helper para POP de palabra (16 bits)
uint16_t CPU::pop_word() {
uint8_t low = pop_byte(); // Low byte primero
uint8_t high = pop_byte(); // High byte segundo
return (static_cast<uint16_t>(high) << 8) | static_cast<uint16_t>(low);
}
// CALL nn: Guarda dirección de retorno y salta
case 0xCD: {
uint16_t target = fetch_word();
uint16_t return_addr = regs_->pc;
push_word(return_addr);
regs_->pc = target;
cycles_ += 6;
return 6;
}
Archivos Afectados
src/core/cpp/CPU.hpp- Añadidas declaraciones de métodos de stack (push_byte, pop_byte, push_word, pop_word)src/core/cpp/CPU.cpp- Implementación de helpers de stack y opcodes (0xC5, 0xC1, 0xCD, 0xC9)tests/test_core_cpu_stack.py- Suite de 4 tests para validar operaciones de stack
Tests y Verificación
Se creó una suite completa de tests en test_core_cpu_stack.py que valida:
- test_push_pop_bc: Verifica PUSH BC y POP BC básico, validando que los datos se guardan y restauran correctamente, y que SP se decrementa/incrementa apropiadamente.
- test_stack_grows_downwards: Test crítico que verifica que la pila crece hacia abajo (SP decrece en PUSH). Si la pila creciera hacia arriba, los juegos se corromperían.
- test_call_ret_basic: Verifica CALL nn y RET básico, validando que la dirección de retorno se guarda correctamente en la pila y que RET restaura PC correctamente.
- test_call_nested: Verifica CALL anidado (subrutina que llama a otra subrutina), validando que múltiples niveles de llamadas funcionan correctamente.
Resultado: Todos los 4 tests pasan correctamente (0.06s de ejecución). La implementación C++ es funcionalmente correcta y lista para uso en emulación.
Fuentes Consultadas
- Pan Docs: CPU Instruction Set - Secciones sobre PUSH, POP, CALL y RET
- Pan Docs: Memory Map - Stack Pointer y región de pila
Nota: La implementación sigue estrictamente la especificación de Pan Docs sobre el orden de bytes en PUSH/POP y el comportamiento del Stack Pointer.
Integridad Educativa
Lo que Entiendo Ahora
- Stack Growth: La pila crece hacia abajo (SP decrece) porque el espacio de pila está en la región alta de RAM. Esto evita colisiones con código y datos.
- Little-Endian en PUSH/POP: PUSH escribe high byte primero (SP-1), luego low byte (SP-2). POP lee low byte primero (SP), luego high byte (SP+1). Este orden es crítico para la correcta restauración de direcciones.
- CALL/RET: CALL guarda PC (dirección de retorno) en la pila y salta a la subrutina. RET recupera PC de la pila y restaura la ejecución. Sin esto, no hay código estructurado.
- Rendimiento C++: Las operaciones de pila son extremadamente frecuentes y en C++ se compilan a simples movimientos de punteros, ofreciendo rendimiento brutal comparado con Python.
Lo que Falta Confirmar
- PUSH/POP de otros pares: Actualmente solo se implementó PUSH/POP BC. Falta implementar PUSH/POP DE, HL y AF (este último requiere máscara especial para F).
- CALL/RET condicionales: Falta implementar CALL/RET condicionales (CALL NZ, CALL Z, RET NZ, RET Z, etc.) que verifican flags antes de ejecutar.
- Interrupciones: Las interrupciones también usan la pila para guardar PC. Falta validar que el comportamiento sea correcto cuando se implementen interrupciones.
Hipótesis y Suposiciones
Se asume que el orden de bytes en PUSH/POP es consistente con el formato Little-Endian de la Game Boy. Esto está respaldado por Pan Docs y los tests confirman que funciona correctamente.
Próximos Pasos
- [ ] Implementar PUSH/POP para otros pares de registros (DE, HL, AF)
- [ ] Implementar CALL/RET condicionales (CALL NZ, CALL Z, RET NZ, RET Z, etc.)
- [ ] Implementar más opcodes de carga y almacenamiento (LD)
- [ ] Continuar migrando más opcodes de la CPU a C++