Mecánica de los bloques deslizantes
Disponemos de un tablero de juego dividido en cuadrícula (en nuestro caso de 9x9 huecos), la mayoría de ellas transitables y otras no transitables. En el tablero de juego se disponen entre 1 y 4 bloques (siempre en huecos transitables), cada uno de ellos de un color. Por cada bloque habrá un hueco en el tablero de juego que será el sitio destino donde debe llevarse a dicho bloque, es decir, si en nuestro tablero tenemos 3 bloques, uno rojo, otro verde y otro azul, en el tablero existirán también 3 huecos marcados como destino del bloque rojo, destino del bloque verde y destino del bloque azul, respectivamente.
El jugador mueve los bloques por turnos con la restricciones siguientes:
- Un bloque sólo puede moverse en una dirección en la que el tablero sea transitable y no haya otro bloque impidiendo su movimiento.
- La longitud del recorrido de un bloque en una dirección será siempre la máxima posible hasta que el bloque se encuentre con un obstáculo (hueco no transitable u otro bloque). dicho de otra forma, los bloques se mueven hasta que "chocan" con el borde del tablero (hueco no transitable) o con otro bloque, no pueden frenarse a mitad de recorrido.
El objetivo del juego es llevar cada bloque hasta su respectivo hueco (el bloque rojo hacia el hueco rojo, el bloque verde hacia el hueco verde y así con todos) antes de que se cumpla el tiempo de juego.
Esta mecánica es la que se puede encontrar en algunos mini juegos de puzzle de "Mario + Rabbids Kingdom Battle", para Nintendo Switch.
(Imagen extraida de Neoseeker)
Experiencia previa
Tengo experiencia cero en desarrollar juegos y esta es, por tanto, la primera vez que lo hago. Me lo he planteado como un reto de "puedo hacerlo" y, para el lector experimentado en el desarrollo de juegos, es muy probable que haya hecho algunas (o muchas) cosas bastante mal, usando de forma incorrecta algún patrón de diseño o incluso haber implementado pésimamente algunos aspectos. Vayan por delante mis disculpas a esos desarrolladores.
Pantallas y diseño
Las pantallas del juego las he representado como un grafo en el que los nodos son las pantallas en sí y los arcos son las transiciones entre pantallas. El grafo del juego sería el siguiente:
Modelo de máquina de estados
A partir de este grafo se desarrolló un modelo sencillo en el que cada nodo es un "objeto pantalla". Se definió la clase "Screen" y la clase "ScreenManager". Cada pantalla debe heredar de la clase "Screen" y será un objeto, mientras que la clase "ScreenManager" se instancia una sola vez y se encarga de gestionar la navegación entre las pantallas del juego.
Como vimos en el anterior post relativo al desarrollo para Game Boy Advance, la forma ideal de acceder a la memoria de vídeo (VRAM, paleta, etc.) para que no se produzca ruido o errores de pintado es durante el tiempo de retrazo vertical así que lo que haremos será orientar toda la arquitectura en ese sentido. Al principio se inicializan todas las pantallas (objetos de algún tipo que herede de "Screen") que va a tener el juego (ya sea de forma global o de forma local a la función main) y se inicializa el objeto de tipo "ScreenManager" pasándole la pantalla inicial.
#include "Screen.H" #include "ScreenTitle.H" #include "ScreenMenu.H" #include "ScreenFileSelectionMenu.H" #include "ScreenBoardSelectionMenu.H" #include "GameStatus.H" #include "ScreenDeleteFileMenu.H" #include "ScreenConfirmStartBoardMenu.H" #include "ScreenBoard.H" #include "ScreenPauseMenu.H" using namespace std; using namespace avelino; typedef void (*fptr)(void); #define ISR_PTR *((fptr *) 0x03007FFC) #define IME *((volatile uint32_t *) 0x04000208) #define IE *((volatile uint16_t *) 0x04000200) #define IF *((volatile uint16_t *) 0x04000202) #define IFBIOS *((volatile uint16_t *) 0x03007FF8) #define DISPSTAT *((volatile uint16_t *) 0x04000004) void isr() __attribute__((target("arm"))); void isr() { // mark interrupt as served IF = 1; IFBIOS |= 1; } void enableInterrupts() { IME = 0; ISR_PTR = isr; DISPSTAT = ((uint16_t) 1) << 3; IE = 1; // v-blank IME = 1; } int main() { enableInterrupts(); GameStatus status; // define screens ScreenTitle st; ScreenFileSelectionMenu sfsm; ScreenBoardSelectionMenu sbsm; ScreenDeleteFileMenu sdfm; ScreenConfirmStartBoardMenu scsbm; ScreenBoard sb; ScreenPauseMenu spm; // link screens st.nextScreen = &sfsm; sfsm.backScreen = &st; sfsm.nextScreen = &sbsm; sbsm.backScreen = &sfsm; sbsm.deleteFileScreen = &sdfm; sbsm.boardSelectedScreen = &scsbm; sdfm.backScreen = &sbsm; sdfm.justDeletedScreen = &sfsm; scsbm.backScreen = &sbsm; scsbm.yesScreen = &sb; sb.pauseScreen = &spm; sb.afterFinishScreen = &sbsm; spm.continueScreen = &sb; spm.finishScreen = &sbsm; // start screen manager ScreenManager m; m.init(st, status); while (true) { asm volatile ("swi 0x05"); // wait for v-blank m.onVBlank(); } }
En el bucle principal del juego lo único que se hace es esperar a que se produzca la interrupción de retrazo vertical y, en cuanto se produce, se invoca la función miembro "ScreenManager::onVBlank()".
// Screen.H class InterScreenData { // any data }; class Screen { public: virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0; virtual Screen *onVBlank(Keypad &keypad) = 0; // return "this" to stay on same screen or other screen to switch to virtual InterScreenData *onUnload() = 0; }; class ScreenManager { public: Screen *currentScreen; Keypad keypad; ScreenManager() : currentScreen(NULL) { }; void init(Screen &initialScreen, InterScreenData &initialInterScreenData); void onVBlank(); }; // Screen.cc void ScreenManager::init(Screen &initialScreen, InterScreenData &initialInterScreenData) { this->keypad.init(); this->currentScreen = &initialScreen; // start with first screen this->currentScreen->onLoad(&initialInterScreenData); } void ScreenManager::onVBlank() { this->keypad.onVBlank(); Screen *next = this->currentScreen->onVBlank(this->keypad); if (next != this->currentScreen) { // switch to another screen InterScreenData *isd = this->currentScreen->onUnload(); // get data from outgoing screen next->onLoad(isd); // and passing it to incomming screen this->currentScreen = next; } }
La función miembro "init" del objeto de clase "ScreenManager" inicializa el teclado, asigna la pantalla actual a la pantalla que le llega por parámetros, invoca la función miembro "onLoad" de dicha pantalla inicial y regresa. A partir de entonces, cada vez que haya un retrazo vertical, se invocará la función miembro "onVBlank()" del objeto de tipo "ScreenManager" que, a su vez, invocará la función miembro "onVBlank()" del objeto de tipo "Screen" que tiene como pantalla actual. Esta función miembro "Screen::onVBlank()" devuelve "this" en caso de que queramos continuar en esta pantalla para el siguiente retrazo o un puntero a otro objeto "Screen" en caso de que queramos cambiar de pantalla para el siguiente retrazo. En caso de que devuelva diferente de la pantalla actual ("this"), se invoca a la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Con esta mecánica podemos implementar la lógica de navegación entre pantallas.
Funciones miembro de la clase Screen
class InterScreenData { // any data }; class Screen { public: virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0; virtual Screen *onVBlank(Keypad &keypad) = 0; // return "this" to stay on same screen or other screen to switch to virtual InterScreenData *onUnload() = 0; };
La clase Screen define 3 funciones miembro virtuales puras que deberán ser implementadas por las subclases que hereden de ella:
void Screen::onLoad(InterScreenData *dataFromPreviousScreen)
Esta función miembro se invoca en el momento de cargar una pantalla y antes de invocar por primera vez a su función miembro "onVBlank". En esta función miembro se inicializarán las variables y los datos de la pantalla así como la configuración del controlador LCD, los backgrounds, los sprites y demás, de tal manera que todo quede "listo para pintar" esta pantalla. Lo recomentable es, al principio de esta función miembro, poner a 1 el bit 7 del registro DISPCNT para deshabilitar el acceso a la VRAM desde el controlador LCD y permitir que la CPU acceda a la VRAM de forma rápida y, justo antes de salir de esta función miembro, configurar el mismo registro DISPCNT con el modo gráfico, los backgrounds, sprites y demás opciones que se necesiten para esta pantalla.
Screen *Screen:onVBlank(Keypad &keypad)
Esta función miembro se llamará en cada retrazo vertical mientras ésta pantalla sea la pantalla activa. Recibe por parámetro una referencia a un objeto de clase Keypad (donde puede leerse el estado de las teclas) y devuelve un puntero a una pantalla válida: si devuelve "this" se permanece en la pantalla (objeto Screen) actual y en el siguiente retrazo vertical se volverá a invocar de nuevo a la misma función miembro "onVBlank" del mismo objeto Screen. En caso de que se devuelva un objeto (una pantalla, de tipo Screen) diferente a "this", el ScreenManager interpretará que se desea cambiar de pantalla por lo que invocará la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Para el siguiente retrazo vertical se invocará la función miembro "onVBlank" de la pantalla entrante y así sucesivamente.
InterScreenData *Screen::onUnload()
Esta función miembro es la que invoca el objecto de tipo ScreenManager cuando la función miembro "onVBlank" de pantalla actual devuelve una pantalla distinta a ella misma, porque entiende que se quiere pasar a otra pantalla. Como se puede ver, "onUnload" devuelve un puntero a un objeto de tipo "InterScreenData" que es pasado en el "onLoad" de la pantalla entrante. La intención de este objeto de tipo "InterScreenData" es de servir como estado del juego, de tal manera que todas las pantallas (objetos de tipo base Screen) vean los mismos datos del juego (en otras palabras, una forma elegante de tener "variables globales"). Nótese que la clase InterScreenData es una clase vacía, cuando hacemos el juego decidimos qué queremos que sea InterScreenData, ya que puede ser cualquier cosa.
Toda la mecánica del juego se define mediante estas tres funciones miembro en cada pantalla.
Ejemplo de implementación de un pantalla: ScreenTitle
Ésta es la pantalla de arranque. Muestra un fondo prediseñado en modo 3 (bitmap de 15 bits de color por pixel) y muestra el texto "- Press START-" haciendo que parpadee de forma suave hasta que el usuario pulsa "START" para continuar a la siguiente pantalla.
- En la función miembro "onLoad" se pone a 1 el bit 7 de DISPCNT para deshabilitar el LCD y acelerar el acceso a VRAM desde la CPU, luego se copia la imagen de fondo de la ROM a una parte de la VRAM, se inicializa la paleta para los sprites, se pintan las letras como sprites, se configura el alpha blending (parpadeo suave) para los sprites y, antes de salir, se configura el controlador LCD: modo 3, background layer 2 y objetos (sprites) habilitados.
void ScreenTitle::print(const char *text) { char *p = (char *) text; // calculate string length uint16_t n = 0; while (*p != 0) { p++; n++; } this->objUsed = n; // draw sprites as text const int16_t Y = 100; int16_t x = 120 - ((n << 3) >> 1); uint16_t i = 0; p = (char *) text; while (*p != 0) { OAM[i++] = Y | (((uint16_t) 1) << 13); OAM[i++] = x & 0x01FF; OAM[i++] = 512 + ((*p - ' ') << 1); i++; x += 8; p++; } } void ScreenTitle::onLoad(InterScreenData *dataFromPreviousScreen) { this->interScreenData = dataFromPreviousScreen; this->alpha = 0; this->objUsed = 0; this->timer = 5 * 60; // 5 seconds // force blank to access VRAM DISPCNT = ((uint16_t) 1) << 7; // draw on framebuffer //memcpy16(MODE3_VRAM_FB, ANA, 240 * 160); memcpy16(MODE3_VRAM_FB, TITLE_RGB_IMAGE, 240 * 160); // configure OBJ 256 color palette (all colors white) memcpy16(OBJ_PALETTE, PALETTE, 256); // load OBJ tiles from FONT_1 memcpy16((uint16_t *) OBJ_TILES_VRAM, (uint16_t *) FONT_1, 3072); // configure OBJs to print a text (this call updates this->objUsed to free the sprites on unload this->print("- Press START -"); // 1st target is OBJ, 2nd target is BG2, fx = alpha blending BLDCNT = (((uint16_t) 1) << 4) | (((uint16_t) 1) << 6) | (((uint16_t) 1) << 10); // transparency at 50% (8/16 for both OBJ and BG2 intensities) BLDALPHA = (((uint16_t) this->alpha) << 8) | ((uint16_t) (16 - this->alpha)); // enable mode 3 with background layer 2, enable obj, enable obj 1-d mapping DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10) | (((uint16_t) 1) << 12) | (((uint16_t) 1) << 6); }
- En la función miembro "onVBlank()" se actualiza el registro de alpha blending para producir el efecto de parpadeo suave. Si se detecta que se pulsa el botón START se devuelve "nextScreen" (que será la pantalla de selección de fichero), en caso contrario se devuelve siempre "this" para indicar que seguimos en esta pantalla.
Screen *ScreenTitle::onVBlank(Keypad &keypad) { // recalculate sprite alpha blending this->alpha += ALPHA_INC; uint16_t alphaIntegerPart = this->alpha >> 8; if (alphaIntegerPart > 16) { this->alpha = 0; alphaIntegerPart = 0; } BLDALPHA = (((uint16_t) alphaIntegerPart) << 8) | ((uint16_t) (16 - alphaIntegerPart)); // next screen when pressing START, else keep screen if (keypad.mask & KEY_START) return this->nextScreen; else return this; }
- En la función miembro "onUnload", se reinicia el registro de alpha blending y los sprites usados.
InterScreenData *ScreenTitle::onUnload() { // free the sprites (OBJs) used uint16_t n = this->objUsed; uint16_t j = 0; while (n > 0) { OAM[j] = SCREEN_HEIGHT | (((uint16_t) 1) << 13); OAM[j + 1] = SCREEN_WIDTH & 0x01FF; j += 4; n--; } // disable alpha blending BLDCNT = 0; return this->interScreenData; }
Esta forma de escribir el código facilita mucho poder concentrarse en cada pantalla del juego por separado, pues cada pantalla tiene su terna onLoad/onVBlank/onUnload. Hay que tener en cuenta algunas cosas:
- Hay que garantizar que las cosas que inicialicemos y configuremos en onLoad luego las dejemos con su valor por defecto o desactivadas en el onUnload, para que, si la siguiente pantalla no las vaya a usar, no le afecten.
- En la función miembro "onVBlank" hay que tratar de hacer la menor cantidad de cosas posible ya que se trata de una función miembro que se invoca en el retrazo vertical, y, por tanto, se dispone de una pequeña ventana de ejecución hasta el siguiente retrazo. De la misma manera, dentro de la propia función lo ideal es hacer los cambios en la VRAM o en los registro del LCD lo antes posible, por si es inevitable que su ejecución se prolongue más ciclos de los que dura el retrazo. Cuantas más cosas dejemos preparadas y precalculadas en la función miembro "onLoad", mejor.
Teniendo esta filosofía en mente ya podemos ir diseñando las diferentes pantallas del juego.
Diagrama de clases
La herencia y el polimorfismo nos permiten factorizar muy fácilmente algunas pantallas que son muy similares unas a otras. Por ejemplo ScreenFileSelectionMenu, ScreenPauseMenu, ScreenYesNoMenu, ScreenConfirmStartBoardMenu y ScreenDeleteFileMenu, se aprovechan de esta característica de C++ y la única clase que debe lidiar con el hardware (LCD, teclas, etc.) es ScreenMenu, ya que el resto heredan de ella y permiten cambiar los mensajes, las opciones y las acciones del menú de forma muy elegante, tan sólo redefiniendo las funciones miembro virtuales puras con otras adecuadas.
La pantalla de juego principal: ScreenBoard
Vamos a centrarnos en la clase ScreenBoard, que es la que implementa la pantalla de juego principal, que gestiona el tablero de juego y la mecánica del mismo. A continuación podemos ver un grafo con la máquina de estados de esta pantalla:
Durante el estado "MARK" se muestra un marcador que parpadea sobre un bloque y dándole a las flechas cambiamos de bloque.
Si, estando en el estado "MARK" pulsamos A, nos ponemos en modo "STEER" ("girar las ruedas") que lo que hace es calcular los posibles movimientos del bloque actual y marcarlos con flechas. Una vez en el estado "STEER" las flechas sirven para empujar el bloque seleccionado en la dirección deseada pasando al modo "MOVE" o podemos pasarnos al modo "MARK" si no queremos mover el bloque, pulsando B de nuevo.
El estado "MOVE" es el estado en el que la pantalla realiza la animación de movimiento de un bloque hasta su nueva posición. Cuando el bloque que se está moviendo llega a su posición final se "consolida" en el tablero y se evalúa el tablero para saber si el usuario ha ganado. Si el usuario ha ganado se va al estado "END_WIN", si no ha ganado aún, se pasa al estado "MARK".
En caso de que el cronómetro de la fase llegue a cero se pierde (estado "END_LOSE") mientras que si logramos ubicar a todos los bloques en sus correspondientes rectángulos antes de que el cronómetro de la fase llegue a cero, ganamos (estado "END_WIN").
"ScreenBoard::onVBlank" puede devolver tres valores:
- this: Mientras el tablero esté en juego de forma normal.
- this->pauseScreen: Puntero a pantalla de pausa, en caso de pulsar Start estando en el estado "MARK".
- this->afterFinishScreen: Puntero a pantalla después de finalización (tanto si gana como si pierde, desde el estado "FINISHED").
Mecanismo de guardado de partidas
En la Game Boy Advance es posible utilizar diferentes mecanismos de guardado de datos de forma no volátil (que no se pierdan al apagar la consola) tanto basados en SRAM no volátil como basados en Flash o en EEPROM. En nuestro caso hemos optado por usar el mecanismo de SRAM no volátil, que es el más sencillo. Sólo hay que tener en cuenta las siguientes restricciones:
- Tenemos 64 Kbytes de memoria SRAM no volátil a partir de 0x0E000000 (hasta 0x0E00FFFF).
- Es una zona de memoria direccionable a nivel de byte (debe escribirse y leerse en esta zona de memoria mediante escrituras y lecturas de un byte de anchura, no de 16 ni de 32 bits).
- Para acceder a dicha zona de memoria es necesario escribir previamente un 3 en el registro WAITCNT para indicar al controlador del bus que dicha memoria es lenta y requiere de 8 ciclos de espera. Lo ideal es guardar el valor actual de WAITCNT antes de acceder, hacer WAITCNT = 3, acceder al espacio de memoria y al final restaurar el valor de WAITCNT anterior.
Para que los cartuchos flash y los emuladores detecten correctamente el tipo de backup que vamos a utilizar en nuestro juego es necesario situar una cadena de caracteres en algún sitio de la rom del juego para que sea reconocible el tipo de backup que se usará. En nuestro caso, al usar SRAM colocaremos la cadena "SRAM_V113" en una posición de memoria cualquiera de la ROM que sea múltiplo de 2 bytes. La cadena debe ocupar una cantidad múltiplo de 4 bytes de longitud y los bytes restantes deberán contener ceros. En nuestro caso lo más sencillo es incluir dicha cadena en el array "CARTRIDGE_HEADER", justo después de la cabecera de Nintendo:
__attribute__((section(".cartridge_header"))) const uint8_t CARTRIDGE_HEADER[256] = { 0x3E, 0x00, 0x00, 0xEA, // branch to +0x100 0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, // nintendo logo 0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, 'E', 'J', 'E', 'M', 'P', 'L', 'O', 0x00, 0x00, 0x00, 0x00, 0x00, // game title 'A', 'E', 'J', 'S', // game code 'A' + two characters + 'S' (for spanish) '0', '1', // maker code 0x96, // fixed value 0x00, // GBA 0x00, // device type 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved 0x00, // software version 0xC1, // complement check 0x00, 0x00, // reserved 'S', 'R', 'A', 'M', '_', 'V', '1', '1', '3', 0x00, 0x00, 0x00 // backup id string after official header to indicate the backup method (SRAM at 0x0E000000) };
De esta forma, usando las clases SaveFile y SaveFileManager, podemos cargar y guardar partidas ya que internamente utilizan las funciones "memset8" y "memcpy8" que aseguran un acceso a nivel de byte a la zona de memoria entre 0x0E000000 y 0x0E00FFFF y, además, controlan que el registro WAITCNT=3 cuando se accede dicha zona.
Diseño de niveles
Los niveles se definen en Level.H y Level.cc como un array de constantes que se aloja en el mismo espacio de memoria que el resto de la ROM del cartucho. Cada nivel incluye:
- Nombre corto (2 letras).
- Nombre largo (9 letras).
- Configuración de tablero en sí (un valor de 16 bits por cada baldosa, con 9x9 baldosas).
- Tiempo en minutos y segundos para resolverlo.
- Vector con niveles que se desbloquean cuando este nivel se termina.
El diseño de niveles para este juego creo que excede mi capacidad como desarrollador por varias razones:
- Los niveles deberían ser de complejidad creciente, eligiendo una cantidad de bloques y un tablero acordes al progreso actual.
- También hay que elegir bien el tiempo máximo de resolución y buscar un equilibrio entre que el juego no sea muy fácil (tableros sencillos y/o tiempo largo) y que no sea muy difícil (tableros complejos y/o tiempos de resolución cortos).
Para este caso, y por pura "curiosidad teórico-computacional", desarrollé un pequeño programa (también en C++ pero no para compilar de forma cruzada, sino para compilar y ejecutar en un ordenador) que permite generar niveles jugables aleatorios a partir de ciertos parámetros iniciales. El programa parte de una situación final de ganar (todos los bloques en sus correspondientes baldosas destino) y va generando movimientos hacia atrás hasta obtener un tablero en el que lo bloques estén dispersos y se pueda usar como tablero de juego inicial para un nivel.
# primero generamos un fichero binario con la forma del nivel, # indicando las celdas transitables con 0x0000 y las celdas no transitables con 0x0080 (nótese que los valores son little-endian) # 9x9 valores de 16 bits = 162 bytes echo “0000 0000 8000 8000 0000 0000 0000 0000 0000” | xxd -r -ps > board1.bin echo “0000 0000 8000 8000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin echo “0000 0000 0000 0000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin echo “0000 0000 0000 0000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin echo “0000 0000 0000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin echo “0000 0000 8000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin echo “0000 0000 8000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin echo “0000 0000 8000 8000 8000 0000 0000 8000 8000” | xxd -r -ps >> board1.bin echo “0000 0000 8000 8000 8000 0000 0000 8000 8000” | xxd -r -ps >> board1.bin # luego decidimos los bloques que queremos generar con una máscara de bits: el bloque rojo vale 1, el verde 2, el azul 4 y el amarillo 8 # pasando un 7 significa que queremos generar un nivel que use los bloques rojo, verde y azul # el tercer parámetro es el número de iteraciones máximas ./level_generator board1.bin 7 2000
Mediante este algoritmo se han generado los niveles A4 al C1. Los niveles A1 al A3 se han generado a mano y del C2 en adelante los niveles están vacíos, no son "ganables" y por ahora no se pueden desbloquear. El código fuente está disponible para quien quiera completar niveles o rediseñarlos.
Todo, el código fuente junto con una ROM precompilada, puede descargarse de la sección soft.
[ 2 comentarios ] ( 31142 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 979 )
Durante los años 2004 a 2006 hice varios desarrollos para la Game Boy Advance (GBA). En aquella época utilicé varios recursos muy útiles y que aún hay diponibles, como el proyecto DevkitPro, que permite instalar una toolchain completa y librerías para programar diferentes consolas (NintendoDS, GBA, Gamecube, Wii, Switch y otras). Sin embargo tras estos años he querido abordar de nuevo el desarrollo para Game Boy Advance pero desde cero, partiendo de un compilador y una toolchain bare-metal estándar (arm-none-eabi).
De cara a realizar un proyecto de desarrollo para Game Boy Advance u otras consolas de forma más "profesional" recomiendo siempre acudir a DevkitPro. Este artículo debe leerse como una prueba de concepto y una forma de entender mejor cómo funcionan las interioridades de la Game Boy Advance ya que definiremos en detalle y desde cero la cabecera de la ROM, el linker script, el código de arranque, etc. El código que generemos será igual de válido que el generado por DevkitPro con la diferencia de que DevkitPro dispone de multitud de librerías y herramientas que facilitan el desarrollo y lo de nosotros es "masoquismo".
Prerrequisitos
El único prerrequisito será tener instalada la toolchain bare-metal para ARM (arm-none-eabi). La construcción de dicha toolchain a partir de las fuentes está descrita en esta entrada de este mismo blog.
Características técnicas de la Game Boy Advance
La Game Boy Advance fue una consola portátil muy exitosa que sacó Nintendo en 2001, sucesora y compatible hacia atrás con las Game Boy y Game Boy Color. Es la primera de su clase provista de un procesador de 32 bits (un ARM7TDMI, con repertorio de instrucciones ARMv4T e instrucciones de multiplicación y división entera implementadas en hardware). No me detendré aquí a enumerar todas las características técnicas de la Game Boy Advance, para ello es mejor dirigirse a sitios como GBATEK o blogs como el de Jamie Stewart. Hay muchos recursos en internet disponibles. Me centraré en lo que necesitamos para construir un programa, una ROM mínima que se pueda ejecutar en una Game Boy Advance y utilizando sólo una toolchain bare-metal, por lo que nos veremos obligados a escribir el linker script y el código de startup desde cero.
Formato de una ROM de GBA
La Gameboy Advance utiliza un formato de ROM parecido al formato de ROM de sus precedesoras:
Offset (hex) Longitud (dec) Descripción 0000 4 Código ARM de 32 bits que se ejecuta al arrancar el cartucho. 0004 156 156 bytes fijos que contienen en logo de Nintendo comprimido. Siempre son los mismos bytes. 00A0 12 Nombre del juego (ASCII). 00AC 4 Código del juego (ASCII). 00B0 2 Código del fabricante (ASCII). 00B2 1 Valor fijo (96h). 00B3 1 Código del dispositivo (00h). 00B4 1 Tipo de dispositivo (00h). 00B5 7 Reservado (todo a 00h). 00BC 1 Versión del software (lo que queramos aquí). 00BD 1 Checksum calculado a partir de los bytes A0 a BC (ambos inclusive). 00BE 2 Reservado (todo a 00h) 00C0 n Resto de la ROM (el código y los datos)
Para contextualizar este mapa (que es el mapa de memoria de un cartucho de Game Boy Advance), veamos el mapa de memoria de la consola en sí:
Inicio Fin Tamaño Descipción 00000000 00003FFF 16 Kbytes ROM BIOS 02000000 0203FFFF 256 Kbytes WRAM (lenta) 03000000 03007FFF 32 Kbytes WRAM (rápida) 04000000 040003FE Registros E/S 05000000 050003FF 1 Kbyte Paletas LCD 06000000 06017FFF 96 Kbytes VRAM LCD 07000000 070003FF 1 Kbyte Sprites LCD 08000000 09FFFFFF 32 Mbytes ROM/Flash cartucho 0A000000 0BFFFFFF 32 Mbytes ROM/Flash cartucho 0C000000 0DFFFFFF 32 Mbytes ROM/Flash cartucho 0E000000 0E00FFFF 64 Kbytes RAM cartucho
La RAM de cartucho es opcional (no todos los cartuchos la tienen), mientras que las tres zonas de ROM/Flash de cartucho se diferencian por la cantidad de estados de espera que necesitan para ser accedidas desde la CPU. La primera zona (0x08000000 a 0x09FFFFFF) es la más rápida y en la que se asume que se encuentra el cartucho en sí, las otras dos zonas suelen ser utilizadas como memorias flash para almacenar datos no volátiles (partidas guardadas, por ejemplo), aunque algunos fabricantes de cartuchos optan por utilizar los 64 Kbytes de RAM de cartucho y utilizar una NVRAM en su lugar (mantenida con una pila o cualquier otra tecnología no volátil).
Teniendo en cuenta este mapa de memoria de la consola, las ROM de Game Boy Advance no empiezan en 0, sino en 0x08000000 (hay que sumar 0x08000000 a los offsets de la primera tabla).
El proceso de arranque
En el proceso de arranque, la consola comprueba la cabecera del cartucho insertado (accediendo a 0x08000000) y, si la cabecera es válida (logo de Nintendo y checksum), la consola hace un salto incondicional a la dirección de memoria 0x08000000. Como en esa posición de memoria sólo disponemos de 4 bytes, ya que en el offset 0x08000004 del cartucho está el logo de Nintendo y el resto de la cabecera, lo que se suele colocar aquí es una instrucción de salto a una dirección de memoria que esté más allá de la cabecera. En nuestro caso, por comodidad, hacemos un salto siempre a 0x08000100:
instrucción: "b 0x08000100" $\rightarrow$ opcode: 0xEA00003E $\rightarrow$ bytes en little endian: 0x3E, 0x00, 0x00, 0xEA
El byte de checksum se calcula realizando el siguiente cálculo con los bytes situados entre los offsets 0x080000A0 y 0x080000BC (ambos inclusive):
int32_t chk = 0; for (int n = 0xA0; n <= 0xBC; n++) chk = chk - headerData[n]; chk = (chk - 0x19) & 0xFF;
Para el resto de detalles de la cabecera del cartucho es mejor consultar la sección correspondiente en GBATEK. En nuestro caso, que hemos titulado a nuestro juego "EJEMPLO" y le hemos puesto el código de juego "AEJS" y el código de desarrollador "01", nos salen los siguientes datos de cabecera.
const uint8_t CARTRIDGE_HEADER[256] = { 0x3E, 0x00, 0x00, 0xEA, // branch to +0x100 0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, // nintendo logo 0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, 'E', 'J', 'E', 'M', 'P', 'L', 'O', 0x00, 0x00, 0x00, 0x00, 0x00, // game title 'A', 'E', 'J', 'S', // game code 'A' + two characters + 'S' (for spanish) '0', '1', // maker code 0x96, // fixed value 0x00, // GBA 0x00, // device type 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved 0x00, // software version 0xC1, // complement check 0x00, 0x00 // reserved };
Linker script y código de arranque
El linker script es el script de "ld" (la herramienta de enlazado de GCC) que utilizaremos para decidir cómo se distribuyen el código y los datos en el espacio de memoria del cartucho:
SECTIONS { . = 0x08000000 ; .cartridge_header : { startup.o (.cartridge_header) } . = 0x08000100 ; .text : { _linker_code = . ; startup.o (.startup) *(.text) *(.text.*) *(.rodata*) *(.gnu.linkonce.t*) *(.gnu.linkonce.r*) } .preinit_array : { __preinit_array_start = . ; *(.preinit_array) __preinit_array_end = . ; } .init_array : { __init_array_start = . ; *(.init_array) __init_array_end = . ; } .fini_array : { __fini_array_start = . ; *(.fini_array) __fini_array_end = . ; } .ctors : { __CTOR_LIST__ = . ; LONG((__CTOR_END__ - __CTOR_LIST__) / 4 - 2) *(.ctors) LONG(0) __CTOR_END__ = . ; } .dtors : { __DTOR_LIST__ = . ; LONG((__DTOR_END__ - __DTOR_LIST__) / 4 - 2) *(.dtors) LONG(0) __DTOR_END__ = . ; } flash_sdata = . ; . = 0x03000000 ; ram_sdata = . ; .data : AT (flash_sdata) { _linker_data = . ; *(.data) *(.data.*) *(.gnu.linkonce.d*) } ram_edata = . ; data_size = ram_edata - ram_sdata; ram_sbssdata = . ; .bss : AT (LOADADDR(.data) + SIZEOF(.data)) { _linker_bss = . ; *(.bss) *(.bss.*) *(.gnu.linkonce.b.*) *(.COMMON) } ram_ebssdata = . ; bssdata_size = ram_ebssdata - ram_sbssdata; _linker_end = . ; end = . ; }
Como se puede apreciar, al principio (zona marcada de verde) se fija el offset en 0x08000000 y, a partir de ese punto, lo primero que se hace es insertar una sección que hemos llamado ".cartridge_header" (el nombre es arbitrario) y que el linker script buscará en "startup.o", a continuación se fija el offset en 0x08000100 y a partir de ahí se coloca todo el código compilado, teniendo cuidado de que lo primero que se coloca a partir de 0x08000100 es la sección que hemos llamado ".startup" (el nombre también es arbitrario) y a continuación el resto del código (secciones ".text", ".rodata", etc. Estos nombres no son arbitrarios, son estándar de GCC).
Nótese que después de colocar todo el código, el linker script fija el offset en 0x03000000 (como se vio antes, esta sería la zona WRAM rápida de 32 Kbytes, zona marcada de rojo) para definir ahí las variables globales, tanto las inicializadas (".bss") como las sin inicializar (".data"). Mediante la palabra reservada "AT" le decimos al linker script que los datos "estarán" (futuro) en la WRAM (0x03000000) pero físicamente se encuentran ("por ahora") en "flash_sdata" (después del código). Esta "rareza" se explica porque los valores con los que se inicializa la RAM están en la ROM del cartucho y, antes de invocar a la función "main" tenemos que copiar dichos datos de la ROM a la RAM.
El código de arranque lo tenemos definido en "startup.cc". En este fichero tenemos definido un array constante de 256 bytes al que le asociamos la sección ".cartridge_header" (para que el linker script meta esos 256 bytes al principio de la ROM del cartucho, dirección 0x08000000) y también varias funciones que son las que realizarán todo el proceso de arranque.
__attribute__((section(".cartridge_header"))) const uint8_t CARTRIDGE_HEADER[256] = { 0x3E, 0x00, 0x00, 0xEA, // branch to +0x100 0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, // nintendo logo 0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, ... };
Código ARM y código THUMB
El procesador ARM7TDMI de la Game Boy Advance, al igual que otros procesadores ARMv4 y superiores, tiene dos repertorios de instrucciones:
1.- Un repertorio de instrucciones de 32 bits, con datos de 32 bits (modo ARM).
2.- Otro repertorio de instrucciones de 16 bits, con datos de 32 bits (modo THUMB).
El modo THUMB es igual de potente que el modo ARM, con el inconveniente de que las instrucciones no son igual de ortogonales y que para realizar la funcionalidad de algunas instrucciones ARM son necesarias varias instrucciones THUMB, pero con la ventaja de una mayor velocidad de ejecución y una mayor densidad de código. En el caso de la Game Boy Advance, el ARM7TDMI puede ejecutar código en ambos modos e incluso se puede pasar de modo ARM a modo THUMB y viceversa en cualquier momento de la ejecución de un programa.
A nivel hardware la Game Boy Advance posee un bus de 16 bits de anchura para la ROM del cartucho por lo que la opción recomendable para ejecutar el grueso del código es usar modo THUMB (el modo ARM funciona sin problemas sobre el espacio de direcciones de la ROM del cartucho, pero obliga al procesador a realizar dos lecturas de la ROM por cada instrucción y eso se nota en el rendimiento y el consumo). Por tanto el "coste" de tener un repertorio de instrucciones "recortado" (THUMB) se compensa por la velocidad de ejecución y por el menor consumo de energía.
El compilador GCC permite definir mediante opciones ("-marm" y "-mthumb") el tipo de código que generará por defecto y, de forma adicional, para introducir excepciones, es posible especificar que determinadas funciones sean compiladas en modo "arm" o en modo "thumb".
Teniendo en cuenta lo dicho y que los cartuchos ROM son arrancados siempre en modo ARM (la instrucción alojada al principio del cartucho, en 0x08000000, se lee en modo ARM, es una instrucción de 32 bits), lo que haremos será lo siguiente:
1.- Se compila por defecto todo el código del proyecto en modo THUMB (opción "-mthumb" del compilador).
2.- En el fichero "startup.cc", la función "_startup()" (que está en la sección ".startup" y, por tanto, su código se aloja justo a partir de 0x08000100, como se vio en el linker script), se marca como de tipo "arm", para que el compilador genere dicho código en modo "arm" (instrucciones de 32 bits) y, dentro de esta función, sólo hacemos un salto a la función "_startup_thumb()".
3.- En el mismo fichero "startup.cc" la función "_startup_thumb()", al no tener ningún atributo especial, se compila en modo THUMB (debido a la opción "-mthumb" que le pasamos al compilador) y el compilador genera, además, el código necesario para pasar de modo ARM a modo THUMB (por la llamada desde "_startup()" que está en modo ARM a "_startup_thumb()" que está en modo THUMB y gracias a la opción "-mthumb-interwork" que también se le pasa al compilador).
4.- Es la función "_startup_thumb()" (que ya se ejecuta en modo THUMB) la que realiza la inicialización de todo y la encargada de invocar la función "main()".
void _startup() __attribute__((section(".startup"), naked, target("arm"))); void _thumb_startup(); void _startup() { _thumb_startup(); } void _thumb_startup() { _initDataRAM(); _initBssRAM(); _callConstructors(); _callInitArray(); main(); _callFiniArray(); _callDestructors(); while (true) ; }
De esta forma ya todo el código que generemos en nuestro proyecto estará en modo THUMB (aunque nada nos impide escribir funciones que se compilen en código ARM añadiendo el atributo "target("arm")" a las mismas).
Compilar y generar la ROM
Ahora que tenemos nuestro código de arranque y el linker script, podemos compilar y enlazar todo nuestro código con la toolchain bare-metal de GCC:
# con esta línea compilamos "startup.cc", el código de arranque RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -o startup.o startup.cc # con esta línea compilamos el resto de ficheros fuente que necesitemos RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -o main.o main.cc # enlazamos (generamos un fichero .elf) indicando al enlazador # que use nuestro script de enlazado personalizado "gba.ld" RUTA_BARE_METAL_ARM/bin/arm-none-eabi-g++ -std=c++11 -march=armv4t -mcpu=arm7tdmi -mtune=arm7tdmi -fno-exceptions -fno-rtti -nostartfiles -mthumb -mthumb-interwork -Wl,-Tgba.ld -o main.elf startup.o main.o # generamos el binario (.gba) a partir del .elf RUTA_BARE_METAL_ARM/bin/arm-none-eabi-objcopy -O binary main.elf main.gba
El binario generado ("main.gba") es una imagen válida de un cartucho de Game Boy Advance, por lo que puede ser ejecutada tanto en un simulador de Game Boy Advance como en una Game Boy Advance real (mediante un cartucho flash o similar).
Primera prueba de concepto
Con el linker script anterior más el código de arranque que hemos definido ya podemos hacer una primera prueba de concepto.
#include <stdint.h> #include "Ana.H" // .H con datos generados a partir de una imagen de 240x160 (RGB555) using namespace std; using namespace avelino; #define DISPCNT *((volatile uint16_t *) 0x04000000) #define MODE3_VRAM_FB ((uint16_t *) 0x06000000) void memcpy16(uint16_t *dest, const uint16_t *source, uint32_t size) { uint16_t *s = (uint16_t *) source; while (size > 0) { *dest = *s; s++; dest++; size--; } } int main() { // force blank to access VRAM DISPCNT = ((uint16_t) 1) << 7; // draw on framebuffer memcpy16(MODE3_VRAM_FB, ANA, 240 * 160); // enable mode 3 with background layer 2 DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10); while (true) ; }
En este código lo único que hacemos es deshabilitar el LCD (para facilitar el acceso a la VRAM), copiar una imagen predefinida al framebuffer del LCD, habilitar el LCD en el modo gráfico 3 (que permite poner imágenes de color RGB555 (15 bits) en un frame buffer) y quedarnos en un blucle infinito a modo de "parada". En GBATEK puede verse cómo funcionan los diferentes modo gráficos de la Game Boy Advance.
Gestión interrupciones
El manejo de interrupciones es clave para sacar el máximo partido a cualquier plataforma embebida y en el caso de la Game Boy Advance, es importante tenerlas controladas tanto de cara a la gestión adecuada del controlador de LCD como de cara a minimizar el consumo de energía (recordemos que se trata de una consola portátil alimentada a baterías).
Para la gestión de interrupciones hay que tener en cuenta varias cosas:
- El registro IME (Interrupt Master Enable) permite habilitar y deshabilitar las interrupciones de forma global.
- El registro IE (Interrupt Enable) permite habilitar determinadas interrupciones de forma específica.
- Cuando de produce una interrupción se ejecuta un código específico de la BIOS de la Game Boy Advance que, a su vez, hace una llamada en modo ARM (no THUMB) a la dirección almacenada en 0x03007FFC (parte alta de la WRAM). Por tanto en esta posición de memoria escribiremos el puntero a nuestra rutina de interrupción, que deberá estar compilada en modo ARM (no THUMB).
- Una vez dentro de la interrupción hay que leer el registro IF (Interrupt Request Flags) para saber el origen de cada interrupción y escribir en él al final para marcar la interrupción como "servida".
Consideremos la interrupción que probablemente más vamos a necesitar: la interrupción V-Blank. Mediante interrupción sabremos cuando la CPU puede acceder de forma rápida a los registros de control de la pantalla y a la VRAM, de tal manera que no se produzcan "glitches" en la visualización de la imagen y podremos "prepararlo" todo para el siguiente frame (la Game Boy Advance pinta 60 imágenes por segundo, se producen 60 interrupciones de V-Blank por segundo).
Para configurar y usar la interrupción V-Blank podremos escribir el siguiente código:
typedef void (*fptr)(void); #define ISR_PTR *((fptr *) 0x03007FFC) #define IME *((volatile uint32_t *) 0x04000208) #define IE *((volatile uint16_t *) 0x04000200) #define IF *((volatile uint16_t *) 0x04000202) #define IFBIOS *((volatile uint16_t *) 0x03007FF8) void isr() __attribute__((target("arm"))); void isr() { // // ... // // mark v-blank interrupt as served IF = 1; IFBIOS |= 1; } void enableInterrupts() { IME = 0; ISR_PTR = isr; DISPSTAT = ((uint16_t) 1) << 3; IE = 1; // enable v-blank IME = 1; } int main() { enableInterrupts(); // // ... // while (true) { asm volatile ("swi 0x05"); // wait for v-blank } }
La función "isr()" la tendríamos definida como de tipo ARM (no THUMB) y, nada más empezar la función "main()", invocaríamos a la función "enableInterrupts()" que se encargaría de configurar las interrupciones y de escribir la dirección de memoria de la función "isr()" en 0x03007FFC. Una vez tenemos habilitadas las interrupciones, en el bucle infinito de espera incovamos una rutina de la BIOS de la Game Boy Advance ("swi 0x05") que pone a la CPU en modo de bajo consumo hasta que se produce una interrupción, con lo que conseguimos una ejecución más eficiente (gastamos menos batería). Para más detalles sobre el funcionamiento de las interrupciones u otras fuentes de interrupción, ver el apartado correspondiente en GBATEK.
Nótese que se produce una especie de contradicción en el manejo de interrupciones ya que, si bien dijimos antes que la forma más rápida y eficiente de ejecutar código en la Game Boy Advance, es en modo THUMB, ahora resulta que el código de interrupción se invoca en modo ARM. En una interrupción es cuando mas "prisa" tengo, ya que necesito despacharla rápido antes de que llegue la siguiente. Esta contradicción se suele resolver de varias formas:
A.- Dejando que el código de interrupción sea ARM, asegurarnos de que hacemos pocas cosas en la rutina de interrupción "isr()" y dejar el grueso de operaciones que debemos hacer entre cada V-Blank para justo después de la instrucción asm volatile ("swi 0x05"). Esta es la forma más sencilla de ejecutar código THUMB entre retrazos verticales, ya que el código que se ejecuta es THUMB (estaría en la función "main()").
B.- No podemos hacer que la función "isr()" sea THUMB pero, como compilamos con el flag "-mthumb-interwork" sí que podemos llamar desde "isr()" a otra función que sí sea THUMB (el compilador genera un pequeño overhead de 2 o 3 instrucciones adicionales) y de esta forma manejamos la interrupción mediante código THUMB.
C.- La forma más avanzada: ejecutar la función "isr()" en WRAM. En "startup.cc", antes de invocar "main()", se copia el código de la función "isr()" (que habremos compilado en modo ARM) al principio de la WRAM y las variables globales se ponen después. El puntero a "isr()" que se usa no es el del código de "isr()" que está en la ROM del cartucho, sino el puntero al código copiado a WRAM que, aunque se ejecuta en modo ARM, va mucho más rápido, puesto que la WRAM está dentro del procesador. El mayor inconveniente de esta técnica es que, si ya de por sí tenemos sólo 32 Kbytes de WRAM, podemos mermar aún mas dicha memoria (necesaria para variables globales y para la pila) si no hacemos una función "isr()" pequeña. Por otro lado tenemos la ventaja de que es la forma más rápida de ejecutar código ARM.
Para nuestra siguiente prueba de concepto optamos por el mecanismo más sencillo: dejar que la función "isr()" sea de tipo ARM y "dejarla" en la ROM del cartucho (no copiarla a WRAM), ya que no vamos a ejecutar muchas instrucciones en ella y así se mantiene el código más fácil de entender.
Segunda prueba de concepto
Partiendo de la primera prueba de concepto, vamos a mover un sprite por la pantalla, actualizando su posición sólo durante el retrazo vertical (V-Blank). Para utilizar el motor de sprites por hardware de la Game Boy Advance debemos acceder a las zona de memoria OAM (Object Attribute Memory), la paleta y una parte de la VRAM, según indica el apartado correspondiente en la documentación GBATEK.
int main() { enableInterrupts(); // force blank to access VRAM DISPCNT = ((uint16_t) 1) << 7; // draw on framebuffer memcpy16(MODE3_VRAM_FB, ANA, 240 * 160); // configure OBJ 256 color palette (all colors white) OBJ_PALETTE[0] = 0; // transparent (color don't care) OBJ_PALETTE[1] = 0; // black OBJ_PALETTE[2] = 0x7FFF; // white // OBJ tile 0 memcpy16((uint16_t *) OBJ_TILES_VRAM, BALL_TILE, 32); // MUST copy 16 bit words // configure OBJ 0 ballX = 120 - 4; ballY = 80 - 4; // (y, no rotation/scaling, enabled, no mosaic, 256 colors with 1 palette, shape = square) OAM[0] = (ballY & 0x00FF) | ((uint16_t) 1) << 13; // (x, no horizontal flip, no vertical flip, size = 8x8) OAM[1] = (ballX & 0x01FF); // (tile number = 512 (first obj tile with mode 3), priority = 0) OAM[2] = 512; // enable mode 3 with background layer 2, enable obj, enable obj 1-d mapping DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10) | (((uint16_t) 1) << 12) | (((uint16_t) 1) << 6); ballDX = 1; ballDY = 1; while (true) { asm volatile ("swi 0x05"); // wait for v-blank } }
Asignamos una paleta muy sencilla en la posición de memoria OBJ_PALETTE (color 0 transparente, color 1 negro y color 2 blanco), inicializamos una baldosa de 8x8 con el dibujo de una pequeña pelota en la posición de memoria OBJ_TILES_VRAM (baldosa 0) e inicializamos los atributos del sprite 0 (posición de memoria OAM) para que el sprite empiece en el centro de la pantalla.
Definimos 4 variables globales para la posición y la velocidad del sprite en X y en Y e implementamos una sencilla mecánica de movimiento y detección de colisiones en la rutina de interrupción ("isr()").
int32_t ballX, ballY, ballDX, ballDY; void isr() __attribute__((target("arm"))); void isr() { // calculate next location ballX += ballDX; ballY += ballDY; // check collisions if (ballX < 0) { ballX = 0; ballDX = 1; } else if (ballX > (240 - 8)) { ballX = 240 - 8; ballDX = -1; } if (ballY < 0) { ballY = 0; ballDY = 1; } else if (ballY > (160 - 8)) { ballY = 160 - 8; ballDY = -1; } // locate sprite OAM[0] = (ballY & 0x00FF) | ((uint16_t) 1) << 13; OAM[1] = (ballX & 0x01FF); // mark interrupt as served IF = 1; IFBIOS |= 1; }
Ahora tenemos a nuestro sprite "pelotita" danzando por la pantalla. Todo el código está disponible en la sección soft.
[ añadir comentario ] ( 1138 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 948 )
A lo largo de este post desgranaremos, paso a paso y desde cero, cómo programar en C para esta mítica consola de mano. Usaremos como base el compilador cruzado SDCC y la documentación que hay disponible.
Instalación del SDCC
Lo primero que hay que hacer es instalar o compilar el compilador SDCC. En caso de que vayamos a compilarlo para Linux o para algún Unix, nos descargaremos el código fuente de http://sdcc.sourceforge.net y compilaremos con:
./configure --prefix=/opt/sdcc --disable-pic14-port --disable-pic16-port
make
make install
De esta forma tendremos instalado el compilador cruzado SDCC (se deshabilitan los targets pic14 y pic16 para evitar la dependencia con "gputils"). En nuestro caso sólo necesitamos los target "z80" y "sm83" (el chip SM83 es la CPU de 8 bits que trae la GameBoy, una variante del Z80 en la que cambian sólo algunas instrucciones).
Documentación sobre el estándar
No es mi intención detallar en este post todos los entresijos de la arquitectura de la GameBoy ni de su modelo de programación. Toda la información al respecto la he extraido básicamente de aquí y de aquí, así como de otras fuentes de fácil acceso en internet. Me centraré en lo principal y necesario para obtener una ROM funcional para GameBoy.
Estructura de la ROM de un cartucho y del resto de memoria
Para empezar nos centraremos en el uso de una ROM básica de 32 Kbytes sin conmutación de bancos de memoria (hay otros tipos de ROM, pero este es el más básico):
Después de arrancar, la ROM de arranque comprueba el logo y el checksum del cartucho insertado y, si es correcto, salta a la dirección de memoria 0x0100, que es donde debe residir la primera instrucción del cartucho. Como se puede apreciar en la estructura del cartucho, en esta zona apenas caben 4 bytes (a partir de 0x0104 vienen los datos del logo de Nintendo, el nombre del juego, la licencia, el checksum, etc.), por lo que lo habitual es colocar ahí una instrucción de salto hacia una dirección donde esté el resto del código: por ejemplo 0x0150 u otra. Aquí puede verse con detalle todo el mapa de memoria de la GameBoy. La zona de los vectores de interrupción, que está antes de 0x0100, la veremos más adelante, cuando usemos las interrupciones.
Código de arranque
Lo que haremos será definir nuestro propio código de arranque "crt0gb.s" que genere los bytes específicos de la cabecera de un cartucho GameBoy (de 0x0100 a 0x014F) y que, a continuación, defina las áreas donde deben estar alojado tanto el código y los datos de sólo lectura (de 0x0150 a 0x7FFF, 32 KBytes de ROM) como los datos de lectura y escritura (0xC000 a 0xDFFF, 8 KBytes de RAM).
.module crt0gb .globl _main .area _HEADER (ABS) .org 0x0100 nop jp init ; nintendo logo .db 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E ; game title (16 bytes) .ascii "PRUEBA" .db 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ; licensee code (0 for non super gameboy games) .db 0x00, 0x00, 0x00 ; cartridge type (0 for ROM only) .db 0x00 ; ROM size (0 for 32 KBytes) .db 0x00 ; RAM size (0 for none) .db 0x00 ; country code (1 for non japanese) .db 0x01 ; licensee code (0xA4 for Konami) .db 0xA4 ; version number .db 0x00 ; header checksum (run "header_checksum_calc < main.gb" to get this value) .db 0x83 ; PUT HERE THE VALUE CALCULATED BY header_checksum_calc AND RECOMPILE ; checksum of all cartridge (GameBoy ignores this) .db 0x00, 0x00 .org 0x0150 init: call gsinit call _main exit_loop:: jp exit_loop .area _HOME .area _CODE .area _GSINIT gsinit:: .area _GSFINAL ret .area _DATA .area _BSS .area _HEAP
Los diferentes campos de la cabecera de un cartucho de GameBoy (entre las direcciones 0x0100 y 0x014F) se detallan aquí. Como se puede ver en la posición 0x0100 se ponen las instrucciones "nop" y "jp init", a continuación se definen todos los campos de la cabecera del cartucho y es a partir de la posición de memoria 0x0150 donde se pone el resto del código y se aloja la etiqueta "init". "call gsinit" es la parte encargada de inicializar variables globales y a continuación se hace un "call _main" para que se invoque la función "main" que se haya definido.
El trabajo con el SDCC es más artesanal que con el GCC ya que en SDCC no existe el concepto de "linker script" como en GCC y los processos de compilación y enlazado están algo más entremezclados. Ahora podemos definir un "main.c" que simplemente pinte la valdosa (tile) 0 en el extremo superior izquierdo de la pantalla:
#include <stdint.h> #define IE *((uint8_t *) 0xFFFF) #define LCDC *((uint8_t *) 0xFF40) #define STAT *((uint8_t *) 0xFF41) #define BGP *((uint8_t *) 0xFF47) #define BG_TILES ((uint8_t *) 0x8800) #define BG_DATA ((uint8_t *) 0x9800) const uint8_t TILE0[16] = { 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011 }; void memcpy(void *dest, void *src, uint16_t n) { uint8_t *d = (uint8_t *) dest; uint8_t *s = (uint8_t *) src; while (n > 0) { *d = *s; d++; s++; n--; } } void main() { // disable LCD before accessing VRAM while ((STAT & 0x03) != 0x01) ; LCDC = 0x00; // copy tile data to tile data VRAM memcpy(BG_TILES, TILE0, 16); // disable all LCD interrupts STAT = 0x00; // disable interrupts IE = 0x00; // configure palette BGP = 0b11100100; // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on LCDC = 0x81; // with LCD enabled, wait vblank before accessing VRAM while ((STAT & 0x03) != 0x01) ; BG_DATA[0] = 0x80; // halt while (1) ; }
Para hacer este código sí hay que echar más mano de la documentación sobre el controlador gráfico de la GameBoy. En este caso se ha optado por un ejemplo muy simple:
1. Se deshabilita el controlador gráfico en el registro LCDC. Para hacerlo es necesario esperar a un retrazo vertical (vblank). Nintendo recomienda encarecidamente hacer esta espera previa antes de apagar el controlador LCD ya que, de lo contrario, puede dañarse la pantalla LCD (!).
2. Copiamos los 16 bytes de la baldosa (tile) 0 desde el array TILE0 a la VRAM que alberga los datos de las baldosas. La baldosa son simplemente 8 rayas verticales con las diferentes tonalidades de gris.
3. Se deshabilitan todas las interrupciones (registros STAT e IE a 0x00). Por ahora no estamos usando el mecanismo de interrupciones.
4. Configuramos la paleta (registro BGP).
5. Habilitamos de nuevo el controlador gráfico LCD.
6. Esperamos al siguiente retrazo vertical (vblank).
7. Escribimos en la VRAM encargada de indexar las valdosas que queremos mostrar, la baldosa 0 en la esquina superior izquierda.
8. Terminamos: bucle infinito.
Como se puede ver, se trata de un ejemplo muy simple y minimalista, pero que funciona. Ya tenemos preparado nuestro entorno de desarrollo para GameBoy.
Como emulador recomiendo el VisuaBoyAdvance-M, que es open source y muy estable. Aunque también nos podemos llevar la ROM a cualquier otro emulador o a un cartucho Flash que tengamos conectado a nuestra GameBoy física.
Interrupciones
De cara a hacer desarrollos más serios se hace necesario el uso y el aprovechamiento de las interrupciones en el Z80. El sistema de interrupciones en el Z80 depende de la implementación y, en el caso concreto del procesador SM83 y de la GameBoy, disponemos de las siguientes fuentes de interrupción (detalladas aquí):
- Int 0x40: VBlank.
- Int 0x48: Interrupción asociada al registro STAT del LCD.
- Int 0x50: Interrupción de timer.
- Int 0x58: Interrupción de puerto serie.
- Int 0x60: Interrupción de botones.
El valor "int 0xNN" hace referencia a la dirección de memoria del vector. En este caso, la dirección a la que salta el procesador cuando se produce una interrupción de retrazo vertical (VBlank) es la dirección 0x0040. Recordemos en la imagen del mapa de memoria, que las posiciones de memoria que están entre 0x0000 y 0x00FF se corresponden con los vectores de interrupción.
Modificaremos el ejemplo anterior para que el pintado de las baldosas se realice en la interrupción de retrazo vertical (VBlank). Para habilitar y trabajar con la interrupción VBlank hacemos lo siguiente:
1. Definimos en "crt0gb.s" el vector de interrupciones en la ubicación correcta (0x0040).
2. Habilitamos las interrupciones a nivel de sistema mediante la escritura en el registro IE (Interrupt Enable, dirección 0xFFFF).
3. Habilitamos las interrupciones a nivel de CPU mediante la instrucción ensamblador "ei".
En nuestro caso, para que la generación de código sea más cómoda, se define el nombre "vblankISR" como nombre que debe tener la función encargada de ejecutarse cada vez que haya un retrazo vertical. Dicho nombre se declara en "crt0gb.s" y se pone una instrucción "call _vblankISR" seguida de una instrucción "reti" en la posición de memoria 0x0040.
.module crt0gb .globl _main .globl _vblankISR .area _HEADER (ABS) .org 0x0040 call _vblankISR reti .org 0x0100 nop jp init ...
Esto nos obliga a que tenemos que tener una función llamada "vblankISR" en C (con el atributo "__interrupt" para que guarde todos los registros al entrar y los restaure al salir) que se encargue de servir la interrupción de retrazo vertical (VBlank):
void vblankISR() __interrupt { // access VRAM here // ... }
La interrupción de retrazo vertical nos proporciona, además, una base de tiempo razonable, puesto que se ejecuta 60 veces por segundo. Aquí puede verse un ejemplo final de pintado mediante interrupción VBlank que cambia la ubicación de las valdosas una vez por segundo (cada 60 interrupciones):
#include <stdint.h> #define IE *((uint8_t *) 0xFFFF) #define LCDC *((uint8_t *) 0xFF40) #define STAT *((uint8_t *) 0xFF41) #define BGP *((uint8_t *) 0xFF47) #define BG_TILES ((uint8_t *) 0x8800) #define BG_DATA ((uint8_t *) 0x9800) const uint8_t TILE0[16] = { 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011, 0b01010101, 0b00110011 }; const uint8_t TILE1[16] = { 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000 }; const uint8_t TILE2[16] = { 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111, 0b00000000, 0b11111111 }; const uint8_t TILE3[16] = { 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111, 0b11111111 }; void memcpy(void *dest, void *src, uint16_t n) { uint8_t *d = (uint8_t *) dest; uint8_t *s = (uint8_t *) src; while (n > 0) { *d = *s; d++; s++; n--; } } volatile uint8_t frameCounter; volatile uint8_t frameIndex; void vblankISR() __interrupt { if (frameCounter == 0) { // show tiles BG_DATA[0] = 0x80 + frameIndex; BG_DATA[1] = 0x80 + ((frameIndex + 1) & 0x03); BG_DATA[2] = 0x80 + ((frameIndex + 2) & 0x03); BG_DATA[3] = 0x80 + ((frameIndex + 3) & 0x03); frameIndex = (frameIndex + 1) & 0x03; frameCounter = 60; } else frameCounter--; } void main() { // disable LCD before accessing VRAM while ((STAT & 0x03) != 0x01) ; LCDC = 0x00; // copy tile data to tile data VRAM memcpy(BG_TILES, TILE0, 16); memcpy(BG_TILES + 16, TILE1, 16); memcpy(BG_TILES + 32, TILE2, 16); memcpy(BG_TILES + 48, TILE3, 16); // disable all LCD interrupts STAT = 0x00; // enable only vblank interrupt frameCounter = 0; frameIndex = 0; IE = 0x01; __asm__ ("ei"); // configure palette BGP = 0b11100100; // 0b11 for full black, 0b10 for dark gray, 0b01 for light gray and 0b00 for white // enable LCD, disable window, background tile data at 0x8800, background tile indices at 0x9800, obj not displayed, background display on LCDC = 0x81; // halt while (1) ; }
Checksum de la cabecera
El valor del byte de la posición de memoria 0x014D de la ROM del cartucho se calcula mediante un algoritmo de suma de control entre los bytes 0x0134 y 0x14C (ambos inclusive) de la misma ROM. El código para calcular este valor es el siguiente:
#include <iostream> #include <iomanip> using namespace std; int main() { cin.ignore(0x0134); uint8_t x = 0; for (int i = 0x0134; i <= 0x014C; i++) x = x - ((uint8_t) cin.get()) - 1; cout << "0x" << hex << setw(2) << setfill('0') << ((int) x) << endl; return 0; }
Una vez generada una ROM se realiza este cálculo y el valor generado debe ser colocado en la posición 0x014D de la propia ROM. Como se puede ver, los campos que afectan al valor de esta suma de control no dependen del código principal de la ROM con lo que no será un valor que se modifique con frecuencia.
Proceso de compilación
A modo de resumen, el proceso de compilación sería entonces el siguiente:
# ensamblamos crt0gb.s /opt/sdcc/bin/sdasgb -o crt0gb.rel crt0gb.s # compilamos main.c /opt/sdcc/bin/sdcc -msm83 -c -o main.rel main.c # enlazamos para generar la ROM “casi-final” indicando que la RAM está en 0xC000 /opt/sdcc/bin/sdcc -msm83 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0gb.rel main.rel /opt/src/Hex2bin-2.5/hex2bin -s 0 -e gb main.ihx # ahora tenemos la ROM “casi-final” en el fichero “main.gb” # compilamos en el host el generador de suma de control g++ -std=c++11 -o header_checksum_calc header_checksum_calc.cc # lo ejecutamos con el "main.gb" para que nos diga el valor de la suma de control ./header_checksum_calc < main.gb 0x83 # este valor 0x83 es el que debo poner en el fichero crt0gb.s como checksum (dirección 0x014D de la ROM) # si al poner este valor, se modifica el fichero crt0gb.s, hay que recompilar desde el principio # Nótese que este valor se calcula a partir de la cabecera, no a partir de toda la ROM, por lo que en circunstancias normales apenas cambia
Como se puede apreciar, uso la utilidad Hex2bin (http://hex2bin.sourceforge.net) para generar el fichero .gb (la ROM) a partir del fichero Inte Hex que genera el compilador SDCC.
Todo el código fuente puede descargarse de la sección soft.
[ añadir comentario ] ( 1365 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 1009 )
A lo largo de este post se abordará el diseño y la implementación desde cero de un procesador RISC-V básico (repertorio de instrucciones RV32I, sin extensiones), sintetizable en una FPGA de rango medio-bajo y capaz de ejecutar código generado por un compilador.
Introducción
Una arquitectura de juego de instrucciones (ISA) es una especificación de un juegos de instrucciones tanto a nivel técnico (el repertorio en sí) como a nivel funcional (que hace cada instrucción, los registros afectados, etc.). Existen varias ISAs abiertas y libres, pero pocas con un crecimiento tan grande en los últimos años como RISC-V. De hecho, ya hay fabricantes ofreciendo SoCs RISC-V de varios núcleos y con MMUs que permiten ejecutar Linux de forma segura. La ISA base de RISC-V consta de un repertorio de instrucciones muy pequeño, relativamente fácil de implementar y con muy buen soporte tanto en GCC como en LLVM. El principal atractivo de esta ISA es el hecho de que cualquiera puede fabricar un SoC, una MCU o un procesador RISC-V sin tener que pagar regalías a ninguna empresa u organización.
Objetivo
El objetivo, que se plantea como una "prueba de concepto", es la implementación de un procesador RISC-V básico desde cero, en VHDL y usando código sintetizable de tipo RTL. Una vez esté la implementación terminada, se probará la CPU con código generado por el compilador GCC.
Una CPU muy sencilla
Se plantea el repertorio de instrucciones básico (el denominado "rv32i"), sin ningún tipo de extensión. Dicho repertorio consta de 39 instrucciones "de usuario" y varias instrucciones adicionales denominadas "privilegiadas" o "protegidas", destinadas principalmente al manejo de CSRs. En la implementación realizada no se han definido CSRs, ya que se trata de una CPU sin interrupciones, sin MMU y cuyo único "periférico" será un simple pin de salida (GPIO) para encender un led.
Controlador de memoria
En el RISC-V el bus de direcciones es de 32 bits y, aunque el bus de datos es de 32 bits, se pueden realizar accesos a memoria de palabra (32 bits), media palabra (16 bits) y byte (8 bits). Se plantea la realización de un pequeño controlador de memoria que abstraiga los detalles de implementación de la RAM usada, de tal manera que cuando el procesador desee leer desde la RAM (ya sea para leer una instrucción o para leer un dato) o escribir en la RAM (escribir un dato), se lo pida al controlador de memoria y éste avise cuando la operación se haya terminado.
El procedimiento para leer un dato desde la RAM es el siguiente:
1. En la entrada AddresIn (32 bits) se pone la dirección a la que se desea acceder.
2. En la entrada DataIn (32 bits) se pone el dato en caso de que se quiera escribir. Si se quiere escribir media palabra sólo se tendrán en cuenta los 16 bits menos significativos y si se quiere escribir un byte sólo se tendrán en cuenta los 8 bits menos significativos. Esta entrada se ignora en caso de que la operación sea de lectura.
3. En la entrada WidthIn se indica la anchura de trabajo: 0 para un byte, 1 para media palabra (16 bits) y 2 o 3 para una palabra de 32 bits.
4. En la entrada ExtendSignIn se indica si, a la hora de leer datos de 16 bits (media palabra) o de 8 bits (1 byte) se debe extender el signo en los bits no leidos desde la memoria. Esta entrada se ignora en caso de que la operación sea de escritura.
5. En la entrada WEIn se indica si queremos leer (0) o escribir (1).
6. En la entrada StartIn se pone un 1 para iniciar el proceso de lectura o escritura. El circuito es síncrono, por lo que el proceso empezará en el siguiente ciclo de reloj.
Tras configurar todos los pines de entrada del controlador de memoria y poner StartIn a 1, hay que esperar a que el pin ReadyOut se ponga a 1. Cuando ReadyOut se pone a 1 significa que la operación ha terminado, esto es:
- En caso de que haya sido una operación de lectura (WEIn = 0), significará que el dato que queríamos leer estará disponible en los pines DataOut del controlador de memoria (si hemos pedido leer datos de 16 o de 8 bits en DataOut aparecerá el signo extendido en caso de que lo hayamos indicado en el pin de entrada ExtendSignIn).
- En caso de que haya sido una operación de escritura (WEIn = 1), significará que el dato que queríamos escribir (DataIn) ya está alojado en la RAM.
El controlador de memoria actúa como interfaz entre el núcleo RISC-V y cualquier memoria que queramos ponerle. En este caso se ha usado la propia memoria de la FPGA pero con esta interfaz nada impide usar una SDRAM, alguna PSRAM externa o cualquier otro tipo de memoria que queramos: sólo hay que cambiar el controlador de memoria, el núcleo de la CPU no cambia.
Se definen tres ficheros VHDL:
ROM.vhd, que alberga una imagen de una ROM simulada de 4 Kbytes mapeada en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Esta ROM contiene el código a ejecutar.
RAM.vhd, que alberga una definición estándar de RAM (https://www.doulos.com/knowhow/vhdl/simple-ram-model/) de 4 Kbytes mapeada también en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Mediante esta implementación, todos los entornos de desarrollo de fabricantes de FPGAs infieren que quieres usar la RAM interna de la FPGA y la habilitan para ello (no gastan unidades lógicas).
Memory.vhd, que alberga la ROM y la RAM en un único bloque de memoria de 8 Kbytes (bus de direcciones de 13 bits y bus de datos de 8 bits). Los primeros 4 Kbytes son la ROM y los siguientes 4 KBytes con la RAM. En este módulo se define también un byte de GPIO que se solapa con la primera dirección de la RAM (posición 0 de la RAM, posición 4096 de la memoria total). El bit 0 de este byte GPIO está conectado directamente a un pin de salida.
MemoryController.vhd: El controlador de memoria en sí, que alberga un componente Memory (que a su vez alberga la ROM y la RAM).
Repertorio de instrucciones RV32I
Al conjunto de instrucciones básicas que debe tener cualquier procesador RISC-V de 32 bits se le denomina repertorio "base" o repertorio "RV32I". Son 39 instrucciones altamente ortogonales, muy sencillas de implementar y que siguen el paradigma RISC: instrucciones de manipulación de datos separadas de las instrucciones de acceso a la memoria.
En el documento denominado "green card" se especifica tanto el repertorio base RV32I como las extensiones estándar (M de multiplicación, A de atómicas, C de comprimidas). Nosotros sólo implementaremos el repertorio base RV32I.
Como se puede apreciar, las instrucciones de manejo de registros incluyen campos de 5 bits ($2^5 = 32$) para indicar los registros origen y destino de cada operación.
Registros de la CPU
La CPU RISC-V consta de 32 registros de 32 bits del x0 al x31. El registro x0 no es escribible y cuando se lee siempre alberga un 0.
De cara a escribir el código VHDL que sea RTL y para evitar estar definiendo 32 registros con sus correspondientes multiplexores uno por uno, usamos tipos array y bloques de tipo "generate" en VHDL. Definimos un tipo array para los 32 registros de 32 bits y definimos dos señales RegisterD y RegisterQ de este tipo:
... type WordArray32 is array(0 to 31) of std_logic_vector(31 downto 0); signal RegisterD : WordArray32; signal RegisterQ : WordArray32; ...
Implementamos los biestables de forma normal:
... process (Clk) begin if ((Clk = '1') and Clk'event) then for i in 0 to 31 loop RegisterQ(i) <= RegisterD(i); end loop; IRQ <= IRD; PCQ <= PCD; FSMQ <= FSMD; CounterQ <= CounterD; end if; end process; ...
Y definimos los multiplexores y la lógica que los activa mediante bloques "generate":
... -- RegSelForALUOut: 1..31 (R1..R31) RegisterD(0) <= (others => '0'); -- r0 cannot be altered, always zero value Gen1: for I in 1 to 31 generate RegisterD(I) <= ALUOut when (DecodedMuxReg(I) = '1') else RegisterQ(I); end generate; Gen1X: for I in 0 to 31 generate DecodedMuxReg(I) <= '1' when (I = to_integer(unsigned(RegSelForALUOut))) else '0'; end generate; ...
Este código genera un circuito combinacional compuesto por un decodificador (5 bits de entrada y 32 bits de salida) combinado con 31 multiplexores asociados a sus registros correspondientes:
Las entradas D (RegisterD) de los biestables de los registros provienen, o bien de la salida de la ALU (ALUOut), o bien de la salida Q (RegisterQ) de los mismos biestables (para mantener el valor). Se define un bus RegSelForALUOut de 5 bits que permite definir qué registro de los 31 disponibles recibe el dato de la ALU (el resto mantienen los datos que ya albergan). Como se puede apreciar en caso de que RegSelForALUOut valga "00000" no se hace nada, esto es compatible con el comportamiento deseado, puesto que el registro x0 no es escribible y siempre alberga un 0.
Además de los 32 registros indicados, se encuentran los registros de contador de programa (PC), registro de instrucción (IR) y "Contador". Este último registro es de 5 bits y se utiliza para llevar la cuenta en las operaciones de desplazamiento de bits.
ALU
La ALU se define de forma tabular y puramente combinacional.
... ALUOut <= std_logic_vector(signed(ALUIn1) + signed(ALUIn2)) when (ALUSel = ALU_SEL_ADD) else std_logic_vector(signed(ALUIn1) - signed(ALUIn2)) when (ALUSel = ALU_SEL_SUB) else (ALUIn1 xor ALUIn2) when (ALUSel = ALU_SEL_XOR) else (ALUIn1 or ALUIn2) when (ALUSel = ALU_SEL_OR) else (ALUIn1 and ALUIn2) when (ALUSel = ALU_SEL_AND) else (ALUIn1(30 downto 0) & '0') when (ALUSel = ALU_SEL_SLL) else ('0' & ALUIn1(31 downto 1)) when (ALUSel = ALU_SEL_SRL) else (ALUIn1(31) & ALUIn1(31 downto 1)) when (ALUSel = ALU_SEL_SRA) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) < signed(ALUIn2)) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) >= signed(ALUIn2)) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_EQ) and (ALUIn1 = ALUIn2) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_EQ) and (ALUIn1 /= ALUIn2) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) >= signed(ALUIn2)) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) < signed(ALUIn2)) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else std_logic_vector(to_signed(1, 32)) when (ALUSel = ALU_SEL_NE) and (ALUIn1 /= ALUIn2) else std_logic_vector(to_signed(0, 32)) when (ALUSel = ALU_SEL_NE) and (ALUIn1 = ALUIn2) else ALUIn1; -- default operation, identity ...
Como se puede observar, las operaciones de desplazamiento de bits se implementan en la ALU en forma de desplazamiento de un bit. Lo que se ha hecho en esta implementación es, para evitar consumir demasiadas LUTs en la FPGA, hacer que, en las operaciones de desplazamiento de varios bits, sea la máquina de estados la que use el desplazamiento de 1 bit varias veces hasta conseguir el desplazamiento deseado: El código VHDL se vuelve algo más complejo y se usan tantos ciclos de reloj como bits a desplazar, pero se ahorran recursos LUT en la FPGA.
Multiplexores y registros especiales
Los multiplexores incluidos en esta implementación del RISC-V pueden dividirse en tres grupos:
- Los multiplexores que controlan las señales de entrada a la ALU (ALUIn1, ALUIn2 y ALUSel) y de salida a los registros (RegSelForALUOut).
- Los multiplexores que controlan las señales de entrada al controlador de memoria (MCWidthIn, MCAddressIn, MCStartIn, MCWEIn, MCExtendSignIn y MCDataIn).
- Y los multiplexores que controlan los registros especiales: PC, IR y Counter.
Los multiplexores que controlan las señales de entrada a la ALU serían los siguientes:
A continuación tenemos los multiplexores asociados a las entradas al controlador de memoria:
Y por último tenemos los tres multiplexores asociados a sus respectivos registros especiales: PC, IR y Counter (el registro usado para llevar la cuenta en las operaciones de desplazamiento de bits de más de 1 bit):
Como se verá a continuación, en el apartado "Máquina de estados", las entradas de selección de todos estos multiplexores están gobernadas por los diferentes estados de la máquina de estados del procesador.
Máquina de estados
Uno de los conceptos que siempre hay que tener presente a la hora de diseñar circuitos secuenciales es el concepto de "carga retrasada" ("delayed load") ya que, por la propia naturaleza de los circuitos secuenciales síncronos, los cambios en un estado no tienen lugar hasta el siguiente ciclo de reloj. Veámoslo con un ejemplo: consideremos que tenemos dos registros A y B, con sus respectivos multiplexores a su entrada. MUX_A permite seleccionar qué dato se carga en el registro A, mientras que MUX_B permite seleccionar qué dato se carga en el registro B. Consideremos, además, la siguiente máquina de estados:
Cuando la máquina de estados está en el estado 1, el multiplexor del registro A (MUX_A) está seleccionando la X, pero en el registro A aún no se habrá cargado dicho valor X, lo hará en el siguiente ciclo de reloj. Lo mismo ocurrirá con el registro B: si estamos en el estado 1 y se cumple la condición P=Q, en el siguiente ciclo de reloj pasaremos al estado 2, pero en este ciclo de reloj aún no se cargará la Y en B. Es, una vez en el estado 2 (donde se selecciona en el MUX_B la entrada Y) y después del siguiente ciclo de reloj (pasando del estado 2 al 3), cuando se cargue la Y en B.
Una vez refrescado el concepto de "carga retrasada" podemos ver la máquina de estados de nuestro RISC-V, que consta de 23 estados.
- Máquina de estados: Nodos iniciales: Son los nodos encargados del reset, de la búsqueda de la instrucción en la posición de memoria apuntada por el PC y de la carga de dicha instrucción en el registro IR.
En esta parte de la máquina de estados se puede ver que, al ponerse la entrada Reset en nivel alto, se pasa al estado 0 y en este estado se selecciona la entrada RESET_VECTOR del multiplexor MUX_PC. Esto hará que, en el siguiente ciclo de reloj, se cargue en el registro PC el valor RESET_VECTOR. Cuando la entrada Reset pasa a valor 0, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 1 y en dicho estado se selecciona la entrada PC del multiplexor MUX_MCAddrIn, se selecciona la entrada a 2 del multiplexor MUX_MCWidthIn y se selecciona la entrada a 1 del multiplexor MUX_MCStartIn. Esto provocará que a partir del siguiente ciclo de reloj se inicie un proceso de lectura de 4 bytes de memoria en el controlador de memoria a partir de la dirección apuntada por el PC. El proceso de lectura terminará cuando MCReadyOut valga 1, y cuando esto ocurra, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 2. En este estado 2 el multiplexor MUX_IR seleccionará como entrada MCDataOut (el dato recién leído), por lo que, con otro ciclo más de reloj (al pasar del estado 2 al 3), tendremos la instrucción recién leída de memoria cargada en el registro IR.
- Máquina de estados: Nodos de ejecución de instrucciones aritméticas y lógicas: Se encargan tanto de las instrucciones de un solo ciclo como de las instrucciones de N ciclos.
Aquí se puede ver como se discrimina entre operaciones de desplazamiento de bits y resto de operaciones ya que en el caso de desplazamiento de bits necesitaremos un ciclo de reloj adicional por cada bit que queramos desplazar. El resto de operaciones se ejecutan en un único ciclo de reloj.
- Máquina de estados: Nodos de ejecución de instrucciones de carga:
- Máquina de estados: Nodos de ejecución de instrucciones de almacenamiento:
- Máquina de estados: Nodos de ejecución de instrucciones de bifurcación:
- Máquina de estados: Nodos de ejecución de instrucciones de salto:
- Máquina de estados: Nodos de ejecución de instrucciones especiales:
Nótese que la instrucción "ebreak" se implementa de forma incorrecta a propósito. Es una instrucción que no es generada por el compilador (ni "ebreak" ni "ecall") y se usa para entornos con depuración y/o sistema operativo. En este caso decidí hacer que la instrucción "ebreak" provocase un paro en el procesador.
RISC-V final
Ya tenemos el procesador RISC-V operativo (sólo la implementación base RV32I, sin extensiones). Ahora falta probarlo con código real. Realizaremos el siguiente proceso de compilación en dos pasos:
Vamos a hacer un pequeño programa en C++ (un blinker), lo compilaremos con el compilador cruzado (target "riscv32-none-elf") y la salida binaria del código RISC-V del compilador la usaremos para generar el fichero ROM.vhd que servirá, a su vez, de entrada para el compilador VHDL.
Como compilador VHDL usaremos GHDL en una primera iteración, ya que nos permite hacer una simulación y generar un fichero con todas las señales que podemos visualizar mediante el software GtkWave. Una vez comprobemos que todo sale bien, en una segunda iteración, crearemos un proyecto en un entorno de programación VHDL de un fabricante de FPGAs (en nuestro caso Quartus 13, de Intel) y lo probaremos todo sobre una FPGA real (en nuestro caso una FPGA Intel Cyclone II).
Linker script personalizado
En un post anterior de este blog se describe cómo compilar e instalar el compilador GCC cruzado para arquitectura RISC-V. Partiendo de ese post, creamos un sencillo linker script que nos permitirá compilar código para nuestro nuevo procesador. Recordemos que tenemos un espacio total de 8 Kbytes (8192 bytes) repartidos en 4096 bytes de ROM más 4096 bytes de RAM. Además el bit 0 del primer byte de la RAM se comparte con un pin de salida a modo de GPIO, por lo que podemos establecer el siguiente mapa de memoria de nuestro procesador:
1. La ROM abarca 4096 bytes desde la dirección 0x0000 (la constante RESET_VECTOR definida en RISCV.vhd) hasta la dirección 0x0FFF.
2. El pin GPIO ocupa un solo byte en la dirección 0x1000 (sólo el bit 0).
3. La RAM abarca 4095 bytes desde la dirección 0x1001 hasta la dirección 0x1FFF.
Con esta premisa podemos hacer un sencillo linker script en el que, por ahora y por simplicidad, obviamos la parte de inicialización de variables globales:
/* * ROM from 0x00000000 to 0x00000FFF * GPIO from 0x00001000 to 0x00001003 * RAM from 0x00001004 to 0x00001FFF */ SECTIONS { . = 0x00000000 ; .text : { startup.o (.startup) *(.text) *(.text.*) *(.rodata*) } . = 0x00001004 ; .data : { *(.data) *(.data.*) } }
En este linker script obligamos a que el código de la sección ".startup" se aloje en el vector de reset (dirección 0, RESET_VECTOR al principio de la ROM), y el resto de código vaya a continuación. A continuación escribimos el fichero "startup.cc":
#include <stdint.h> using namespace std; extern int main(); void _startup() __attribute__((section(".startup"), naked)); // startup located at RESET_VECTOR void _startup() { asm volatile ( "la sp, 0x00001FFC" // point SP to the end of SRAM (4 Kb ROM + 4 Kb RAM = 8 Kb total) ); main(); while (true) ; }
En el que definimos una función "_startup", que alojamos en la sección ".startup" y declaramos "naked" para que el compilador no genere código preámbulo ni post-ámbulo. Dentro de esa función "_startup()" inicializamos el puntero de pila al final de la RAM e invocamos a la función "main" (declarada externa). Ahora ya podemos hacer nuestro "main.cc":
#include <stdint.h> using namespace std; #define GPIO *((uint8_t *) 0x00001000) int main() { GPIO = 0; while (true) { for (int i = 0; i < 5; i++) ; GPIO = GPIO ^ 1; } }
Donde definimos la función "main" y el resto del código, ahora sí, de la manera usual.
Se trata de un sencillo blinker en el que el bucle de espera es de sólo 5 iteraciones (lo hacemos así para poder depurarlo cómodamente con GHDL y GtkWave). A continuación compilamos el código con el compilador cruzado de RISC-V (hacemos "make"). Si desensamblamos el .elf generado mediante la utilidad "riscv32-none-elf-objdump" podremos ver el código ensamblador generado por el GCC:
# /opt/baremetalriscv/bin/riscv32-none-elf-objdump -M no-aliases,numeric -D main.elf main.elf: file format elf32-littleriscv Disassembly of section .text: 00000000 <_Z8_startupv>: 0: 00002137 lui x2,0x2 4: ffc10113 addi x2,x2,-4 # 1ffc8: 008000ef jal x1,10 c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010 : 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...
Como se puede apreciar, el código empieza en la dirección 0, que es nuestro vector de reset. El Makefile también genera un fichero "main.bin" con el volcado binario del contenido que debe tener la ROM de programa de nuestro RISC-V. En el código fuente del proyecto se suministra un script de bash que permite convertir ficheros .bin a código VHDL apto para ser insertado en ROM.vhd.
Una vez generado el ROM.vhd (a partir del "main.bin" que generó, a su vez, el compilador C++), compilamos todo el VHDL con GHDL y analizamos con GtkWave la salida de señales generada (RISCV_tb.ghw).
Análisis de la ejecución
Como se puede apreciar, el compilador ha alojado la variable "i" en la dirección de memoria 4072 de la RAM (dirección absoluta 4096 + 4072 = 8168). El procesador realiza el conteo de 0 a 5 sobre esa variable, al llegar a 5, la salida GPIO cambia de valor y vuelta a empezar.
Si hacemos zoom podemos ver el proceso de ejecución instrucción a instrucción, por ejemplo, veamos las primeras 5 instrucciones del código de ejemplo:
00000000 <_Z8_startupv>: 0: 00002137 lui x2,0x2 4: ffc10113 addi x2,x2,-4 # 1ffc8: 008000ef jal x1,10 c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010 : 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...
Cada vez que avanza el contador de programa (señal "pcq"), se inicia un proceso de lectura en el controlador de memoria ("mcstartin") que culmina con un "mcreadyout" = 1. En ese momento se carga en el registro de instrucción ("irq") la instrucción de 32 bits que se acaba de leer. Tras este proceso de lectura de la instrucción se ejecuta la instrucción en sí:
- Dirección de memoria 0x00000000, instrucción "lui x2, 2" (irq = 00002137), carga un 2 en los 20 bits más significativos del registro x2. Avanza el PC 4 bytes.
- Dirección de memoria 0x00000004, instrucción "addi x2,x2,-4" (irq = ffc10113), resta 4 al registro x2. Avanza el PC 4 bytes.
- Dirección de memoria 0x00000008, instrucción "jal x1, 10" (irq = 008000ef), guarda en x1 la dirección de la siguiente instrucción (0x0000000C) y salta a la dirección 0x00000010. Nótese como el contador de programa pasa de 0x00000008 a 0x00000010.
- Y así con el resto de instrucciones.
Implementación en una FPGA
A continuación, viendo que en un entorno simulado (GHDL), el RISC-V funciona bien, podemos pasar a la segunda iteración, que es implementarlo todo en una FPGA real. Como nos hemos molestado en generar un código totalmente RTL (con registros biestables y lógica combinacional bien diferenciados en cada entidad VHDL) y, además, para el código RAM.vhd hemos seguido la recomendación de todos los fabricantes para el uso de bloques RAM internos de la FPGA, lo más probable es que no tengamos problemas.
En nuestro caso, llevando el código directamente al Quartus 13 para implementarlo en una FPGA Intel Cyclone II (es una FPGA antigua) todo compila sin problemas, la RAM es inferida de forma correcta (el entorno asigna bloques de RAM de la FPGA para nuestra RAM) y se usa aproximadamente un 66% de los recursos de la FPGA, lo que no está nada mal para una FPGA lanzada en 2004, hoy considerada antigua y de rango bajo.
La única modificación que se hace es en el código C++, que, en lugar de iterar hasta 5, hay que iterar hasta 250000 o valores de esa magnitud para que el parpadeo del led sea perceptible:
Dependencias
Para compilar el código VHDL sólo es necesario GHDL y para la visualización de la simulación generada por GHDL recomiendo usar GtkWave. Para compilar el código C++ sirve cualquier GCC cruzado para el target "riscv32-none-elf" (puedes compilar el tuyo propio siguiendo las indicaciones que puse hace tiempo en este mismo blog).
Para implementar el RISC-V en una FPGA se necesita el entorno de desarrollo del fabricante o alguna toolchain libre que sea compatible con tu FPGA. El código VHDL es RTL por lo que no debería haber problemas con ningún entorno.
Todo el código fuente (tanto VHDL como C++) está disponible en la sección soft.
[ añadir comentario ] ( 661 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 850 )
Un receptor de radio superheterodino es un receptor de radio que realiza un proceso de mezcla de frecuencias para convertir la señal de la antena a una frecuencia fija fácilmente procesable por los circuitos de demodulación, siendo la gran mayoría de los receptores de radio actuales de este tipo. En este pequeño proyecto se aborda la implementación de un receptor de radio superheterodino pero implementando la mayor parte del proceso directamente en circuitos digitales dentro de una FPGA.
Heterodinización
El proceso de heterodinización consiste en trasladar la frecuencia de una emisora que queremos sintonizar a otra frecuencia que es más cómoda a nivel electrónico o de procesamiento, para demodular. Este proceso se consigue en circuitería analógica normalmente mediante lo que se denomina un mezclador (multiplicador) en combinación con un oscilador: Si multiplicamos la señal que llega de una antena por una señal sinusoidal de un oscilador local conseguimos realizar un desplazamiento de todas las frecuencias que llegan a la antena de tal manera que si tenemos una emisora en $f_1$ y nuestro oscilador local genera una señal en $f_2$, el resultado serán dos señales con las mismas características que $f_1$ pero desplazadas en frecuencia: una en $f_1 + f_2$ y otra en $f_1 - f_2$.
Si denominamos a $f_1 - f_2 = f_i$ frecuencia intermedia podemos dejar pasar sólo dicha frecuencia mediante un filtro paso-banda (con la ventaja añadida de que dicho filtro es de frecuencia fija) y realizar todo el proceso de demodulación basándonos sólo en esta frecuencia intermedia, independientemente de a qué frecuencia esté emitiendo la emisora (independientemente de $f_1$) puesto que con el mezclador y el oscilador local ya desplazamos la señal de la emisora como si emitiese en $f_i$. En los receptores superheterodinos lo que se hace normalmente es elegir una frecuencia $f_i$ relativamente cómoda (el estándar es 455 KHz para AM y 10.7 MHz para FM). De esta manera, por ejemplo, para un receptor AM comercial que deba recibir emisoras en la banda entre 530 y 1710 KHz, su oscilador local generará frecuencias en el rango de 985 a 2165 KHz; así, para recibir una emisora que emita a 576 KHz, el receptor generará una señal en su oscilador local de 1031 KHz que, al ser multiplicada por la señal de antena, proporcionará un par de frecuencias resultado de esa multiplicación estando una de dichas frecuencias en 455 KHz. Y así con cualquier emisora: basta con alterar la frecuencia del oscilador local para cambiar de emisora, el resto de la circuitería del receptor trabaja a 455 KHz.
Implementación en digital
Como objetivo inicial nos planteamos un receptor sencillo AM para la banda comercial, puesto que la demodulación en amplitud suele ser un proceso más sencillo que la demodulación en frecuencia (FM). Como se vio anteriormente el proceso de heterodinización consiste básicamente en multiplicar la señal de antena por otra señal procedente de un oscilador local. El primer escollo que nos encontramos es la lectura de la señal de la antena y su posterior conversión analógico-digital.
Amplificador analógico para la antena
El amplificador analógico de antena hace una amplificación de banda ancha (no sintonizada) pero necesaria para que el ADC pueda detectar señal. He utilizado una configuración estándar de amplificador en emisor común.
En las pruebas con el prototipo se optó por ajustar las dos resistencias de la base de forma empírica con un potenciómetro ajustable de 10 K en modo divisor de tensión hasta que la calidad fuera la mejor posible. Al usarse un transistor 2N3904 la resistencia del colector sí se calculó utilizando las curvas características:
Usamos el valor de $220 \Omega$ para $R_c$ puesto que con ese valor tenemos una recta de carga con mínima distorsión y ganancia razonable, que toca, en el eje X, al punto $V_{cc} = 3.3 V$ y, en el eje Y, al punto ${3.3 \over 220} = 0.015 A$.
ADC para la entrada de la antena
Tratamos de usar una conversión "barata" de tipo delta-sigma, de la que hablamos en una entrada anterior, usando un comparador LVDS interno de la FPGA (todas las FPGA vienen con entradas diferenciales incorporadas basadas en comparadores LVDS). Este tipo de conversión es muy eficiente, permite resolución arbitraria pero, a cambio, requiere mucho sobremuestreo (oversampling) para obtener lecturas fiables. Al tener nuestra FPGA un reloj a 50 MHz, el sobremuestreo nos puede resultar muy caro a efectos de ancho de banda: por ejemplo para obtener una resolución de 8 bits en el ADC ya no podríamos muestrear a 50 MHz, sino a ${50000000 \over {2^8}} = 195 \: KHz$ con lo cual el ancho de banda del ADC caería a los 97 KHz y ya nos iríamos fuera del rango de la banda AM que queríamos abarcar inicialmente.
¿Qué pasa si, manteniendo la frecuencia de reloj de 50 MHz, subimos la frecuencia de muestreo a costa de una pérdida de resolución en el ADC? Más aún ¿Qué pasa si nos vamos al caso extremo de poner la frecuencia de muestreo a 50 MHz y de considerar un ADC de 1 bit de resolución? Bueno, uno puede pensar, a priori que esa pérdida en los bits de resolución es inasumible, pero lo cierto es que, si el ADC es de tipo delta-sigma, aunque la resolución del ADC sea de 1 bit, la anchura de los pulsos será proporcional al nivel de la entrada y, a nivel espectral, la señal de entrada seguirá siendo fiel reflejo de lo que llega por la antena, al menos hasta cierta frecuencia. Bueno, probemos entonces con un ADC de 1 bit a ver qué tal.
Elección de la frecuencia intermedia
Como se vio al principio, en los circuitos electrónicos, lo usual es elegir frecuencias intermedias que sean cómodas de cara al cálculo de componentes, de cara a la minimización del ruido, precio, rendimiento, etc. Sin embargo si estamos realizando el mezclado (la multiplicación) de las señales y la posterior demodulación dentro de una FPGA, la elección de la frecuencia intermedia (los 455 KHz que elegimos para el receptor AM) se convierte en una elección totalmente arbitraria: podríamos elegir la frecuencia que quisiéramos. En el caso que nos ocupa, y siendo un receptor AM, nos convendrá una frecuencia intermedia que sea muy fácilmente demodulable con los recursos de los que disponemos dentro de una FPGA. Pongámonos en el lado del transmisor y analicemos cómo es una señal modulada en AM:
Cuando modulamos una señal senoidal de alta frecuencia (la frecuencia a la que emite la emisora o señal portadora) en amplitud usando una señal de baja frecuencia (música, voz, sonido, etc.), el resultado es una señal que sigue estando centrada en la portadora, pero que está acompañada de dos "lóbulos", uno hacia arriba y otro hacia abajo en el espectro: dichos lóbulos son la señal del sonido (señal moduladora) que modula a la señal portadora que se encuentra desplazada hasta esas zonas. Ambos "lóbulos" de modulación son simétricos.
Esto es, si, en el transmisor, yo emito a 576 KHz y modulo la señal en amplitud (AM) con un tono de 1 KHz estoy generando tres señales: una a 575 KHz, otra a 576 KHz (la portadora central de la banda, esta siempre estará) y otra a 577 KHz. Si al tono de 1 KHZ le añado otro tono de 2KHz se comenzarán a producir 5 señales en la antena: 574, 575, 576 (frecuencia central), 577 y 578 KHz. Como se puede apreciar el proceso de modulación AM es muy parecido al proceso de heterodinización, ya que se producen frecuencias sumas y resta (de hecho la modulación AM no deja de ser también una multiplicación de señales).
Cuando en el receptor desplazamos la señal al mezclarse (multiplicarse) con la señal del oscilador local, desplazamos todo por igual. Por ejemplo, supongamos que dentro de la FPGA queremos adoptar el mismo estándar que se utiliza en circuitería analógica y queremos desplazar hasta 455 KHz. Si queremos sintonizar una emisora que emite a 576 KHz podríamos hacer que un oscilador local (ya veremos cómo implementarlo) genere una señal a 1031 KHz, esto generará a la salida del multiplicador, dos señales, una a 455 KHz y otra a 1607 KHz (esta última habría que eliminarla mediante filtros digitales). Una vez aislada la señal de 455 KHz podremos realizar el proceso de demodulación.
Si esta emisora que emite a 576 KHz transmite en AM un tono a 1 KHz, tras ese proceso de mezcla y filtrado dentro de la FPGA tendremos dicho tono en 456 KHz, que habrá que extraerlo mediante alguna técnica DSP.
Zero-IF
¿No podríamos hacer algo para simplificar todo este proceso de mezclado a frecuencia intermedia seguido de demodulación de la frecuencia intermedia? Bueno, lo cierto es que, si estamos en AM, sí que se puede simplificar. Recordemos lo que comentamos antes de que cuando una emisora emite a 576 KHz y decide transmitir un tono a 1 KHz en AM, se radían tres señales: los 576 KHz de la frecuencia central y dos señales más y superpuestas a 575 y 577 KHz. La técnica Zero-IF (o "frecuencia intermedia cero") consiste en multiplicar la señal de la antena por una señal con EXACTAMENTE LA MISMA frecuencia que la emisora que transmite: por las propiedades de la multiplicación de las señales, si yo multiplico una señal con una frecuencia $f_1$ por otra señal con la misma frecuencia $f_1$, el resultado son dos señales: una con frecuencia $f_1 + f_1 = 2 \times f_1$ y otra con FRECUENCIA CERO ($f_1 - f_1$). Es decir que si nuestra emisora, que emite a 576 KHz, transmite un tono a 1 KHz y nosotros en el receptor multiplicamos la señal de la antena por otra señal a exactamente 576 KHz, desplazaremos al cero la frecuencia central de la señal recibida (576 KHz), por lo que el tono de 1 KHz que la emisora transmite y que, en la señal recibida en la antena, estaba en los lóbulos de 575 y 577 KHz, a la salida de nuestro multiplicador se convertirá en ¡Un tono de 1 KHz! Es decir, estaremos haciendo una demodulación de AM, sin necesidad de frecuencias intermedias (455 KHz) ni de complicados algoritmos de demodulación.
Simplificando el multiplicador
Lo habitual, y para garantizar una buena calidad de recepción, es que el oscilador local genere una onda senoidal (o lo más parecido a ésta) y, de hecho, es la implementación habitual que se realiza de osciladores locales en otros proyectos SDR basados en FPGA: un oscilador local que genera una onda senoidar de N bits de resolución que se multiplica por la señal que llega de la antena y luego se filtra y se demodula. Sin embargo incluso en sintonizadores analógicos o híbridos se utiliza a veces el concepto de "mezclador de conmutación", es decir un multiplicador que multiplica una señal por una onda cuadrada: siendo esto no más que dejar pasar tal cual o cambiada de signo la señal original al ritmo que marca la onda cuadrada (matemáticamente se traduce en que, cuando la señal del oscilador local está a nivel alto, multiplico la señal de entrada por 1 y, cuando está a nivel bajo, multiplico la señal de entrada por -1) . El uso de mezcladores de conmutación está muy extendido puesto que simplifican el diseño de los osciladores (un oscilador de onda cuadrada siempre es más barato de calibrar y de implementar en un circuito digital que un oscilador de onda senoidal) con la contrapartida de que el filtrado debe hacerse mejor (debido a las componentes de alta frecuencia que se generan por ser una señal cuadrada).
En nuestro caso he optado por simplificar el mezclador (multiplicador) hasta su mínima expresión. Como comentamos antes, la salida del ADC es una señal de 1 bit (que puede ser 0 o 1), si hacemos que la salida de nuestro oscilador local sea también de 1 bit, al usar la técnica de la mezcla mediante conmutación (0 o 1), la multiplicación de dichas señales podrá implementarse mediante un circuito combinacional simple de 2 bits de entrada y 1 bit de salida. Si consideramos que nuestras señales no tienen componente de continua podemos asumir que un valor binario de 0 se corresponde con un valor físico -1 mientras que un valor binario de 1 se corresponde con un valor físico de +1:
Entrada ADC | Entrada oscilador | Salida mezclador (multiplicador) |
---|---|---|
-1 | -1 | +1 |
-1 | +1 | -1 |
+1 | -1 | -1 |
+1 | +1 | +1 |
Si traducimos estos valores a binario de nuevo:
Entrada ADC | Entrada oscilador | Salida mezclador (multiplicador) |
---|---|---|
0 | 0 | 1 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
Lo que tenemos es que podemos modelar el mezclador mediante ¡Una simple puerta XNOR!
En la siguiente gráfica se puede ver como, incluso con una simplificación tan extrema como ésta (usando señales de 1 bit tanto para la señal delta-sigma como para la señal del oscilador local y "multiplicando" con una puerta XNOR), podemos conseguir un desplazamiento de frecuencia de la misma forma que si lo hiciésemos con un multiplicador "de verdad".
Se ha simulado, por simplicidad, que la señal de entrada (de antena) es de 2 Hz y que la señal del oscilador local es de 5 Hz. La primera columna se corresponde con el dominio del tiempo mientras que la segunda columna se corresponde con el dominio de la frecuencia:
1.- Al principio tenemos una señal senoidal normal de 2 Hz.
2.- A continuación calculamos una señal delta-sigma a partir de esa señal de entrada de 2 Hz (en la Wikipedia se explican ampliamente los principios de esta modulación pero podemos quedarnos con esta pequeña gráfica que resume en qué consiste esta modulación, que es lo que hace nuestro ADC).
3.- Por otro lado tenemos el oscilador local de onda cuadrada que, en este ejemplo, lo hemos puesto a 5 Hz.
4.- Multiplicamos ambas señales (XNOR) y el resultado, como se puede comprobar en las gráficas, es el deseado: se generan dos frecuencias, una suma (7 Hz) y otra resta (3 Hz), de las frecuencias de las dos señales de entrada (antena y oscilador local).
Implementación en la FPGA
Para el ADC delta-sigma se sigue una configuración estándar como la descrita en esta publicación anterior y se calcula el valor de la resistencia y del condensador de integración en función de la frecuencia de reloj de la FPGA (50 MHz) usando el criterio publicado por Lattice Semiconductor:
$$200 < R \times C \times f_{clk} < 1000$$
Para nuestro caso particular usamos los valores C = 10 nF y R = 1 K. Por otro lado tenemos el acumulador de fase que hace las veces de oscilador local y cuya constante de incremento se calcula a partir de la frecuencia queremos sintonizar (nótese que, como usamos la técnica Zero-IF, la frecuencia del oscilador local deberá ser exactamente la misma que la de la emisora que queremos sintonizar). Si queremos sintonizar 576 KHz (en mi caso es la frecuencia a la que emite Radio Nacional de España en Las Palmas de Gran Canaria) calcularemos la constante de incremento del acumulador de fase de la siguiente manera:
$$Inc = {576000 \over 50000000} \times 2^{64} = 212506491729134048$$
De esta manera en el bit 63 (el más significativo) del acumulador de fase tendremos una onda cuadrada con una frecuencia de 576 KHz. Como se comentó con anterioridad, la multiplicación la implementamos mediante una simple puerta XNOR entre el bit 63 del acumulador de fase (oscilador local de onda cuadrada) y el bit proveniente del ADC delta-sigma.
A la salida de la puerta XNOR (nuestro particular multiplicador) convertimos la señal de 1 bit en una señal de 11 bits apta para ser acumulada en el registro de diezmado (en algunos textos técnicos se hace referencia al "diezmado" como "decimación", a mi me gusta más el término "diezmado", ya que es la traducción más correcta del término "decimation" y creo que expresa mejor su cometido).
Lo que hace el acumulador de diezmado es ir sumando las muestras que llegan del multiplicador (la puerta XNOR) y cuando ha hecho 1024 sumas (o, lo que es lo mismo, cuando el contador de diezmado se desborda), se pasa el valor de la cuenta al latch de diezmado y se inicia el acumulador de diezmado de nuevo. ¿Cual es el resultado de esto? Lo que estamos haciendo es un "diezmado en tiempo" y convertir una señal con una frecuencia de muestreo de 50 MHz (los 0s y 1s que salen del multiplicador) en otra señal con una frecuencia de muestreo de ${50000000 \over {2^{10}}} = {50000000 \over 1024} = 48828.125 \simeq 49 \: KHz$. Con este diezmado en tiempo matamos dos pájaros de un tiro:
1.- Por un lado, hacemos un filtrado paso-bajo, ya que estamos "promediando" y generamos una muestra de salida por cada 1024 muestras de entrada.
2.- Por otro lado, ajustamos la frecuencia de muestreo de la señal a un valor aceptable para ser procesado por circuitos de audio.
La salida del latch de diezmado ya es apta para convertirla a PWM y sacarla por un altavoz.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity CycloneIIAMReceiver is port ( ClkIn : in std_logic; -- 50 MHz AntennaIn : in std_logic; DeltaSigmaOut : out std_logic; SpeakerOut : out std_logic ); end entity; architecture A of CycloneIIAMReceiver is -- 1-bit ADC signal DeltaSigmaADCD : std_logic; signal DeltaSigmaADCQ : std_logic; -- COPE AM Las Palmas: 837 KHz -- (837000 / 50000000) * (2 ^ 64) = 308798495793897920 (64 bit) -- upper 32 bit: 71897752 -- lower 32 bit: 2297979328 ----constant UPPER_LOCAL_OSC_INC : integer := 71897752; ----constant LOWER_LOCAL_OSC_INC : integer := 2297979328; -- RNE AM Las Palmas: 576 KHz -- (576000 / 50000000) * (2 ^ 64) = 212506491729134048 (64 bit) -- upper 32 bit: 49478023 -- lower 32 bit: 1073398240 constant UPPER_LOCAL_OSC_INC : integer := 49478023; constant LOWER_LOCAL_OSC_INC : integer := 1073398240; constant LOCAL_OSC_INC : std_logic_vector(63 downto 0) := std_logic_vector(to_unsigned(UPPER_LOCAL_OSC_INC, 32)) & std_logic_vector(to_unsigned(LOWER_LOCAL_OSC_INC, 32)); signal LocalOscAccD : std_logic_vector(63 downto 0); signal LocalOscAccQ : std_logic_vector(63 downto 0); signal LocalOscOut : std_logic; -- mixer signal MixerOut : std_logic; signal NumericMixerOut : std_logic_vector(10 downto 0); -- decimator (factor = 1024 = 2^10, so pass from 50 MHz to 48.8 KHz (50000000 / 1024) signal DecimatorCounterD : std_logic_vector(9 downto 0); signal DecimatorCounterQ : std_logic_vector(9 downto 0); signal DecimatorAccD : std_logic_vector(10 downto 0); signal DecimatorAccQ : std_logic_vector(10 downto 0); signal DecimatorLatchD : std_logic_vector(10 downto 0); signal DecimatorLatchQ : std_logic_vector(10 downto 0); signal DemodulatedOutput : std_logic_vector(9 downto 0); begin -- delta-sigma ADC for input process (ClkIn) begin if (ClkIn'event and (ClkIn = '1')) then DeltaSigmaADCQ <= DeltaSigmaADCD; end if; end process; DeltaSigmaADCD <= AntennaIn; DeltaSigmaOut <= DeltaSigmaADCQ; -- local oscillator process (ClkIn) begin if (ClkIn'event and (ClkIn = '1')) then LocalOscAccQ <= LocalOscAccD; end if; end process; LocalOscAccD <= std_logic_vector(unsigned(LocalOscAccQ) + unsigned(LOCAL_OSC_INC)); LocalOscOut <= LocalOscAccQ(63); -- mixer (multiplier) MixerOut <= LocalOscOut xnor DeltaSigmaADCQ; NumericMixerOut <= std_logic_vector(to_unsigned(1, 11)) when (MixerOut = '1') else std_logic_vector(to_unsigned(0, 11)); -- decimator process (ClkIn) begin if (ClkIn'event and (ClkIn = '1')) then DecimatorCounterQ <= DecimatorCounterD; end if; end process; DecimatorCounterD <= std_logic_vector(unsigned(DecimatorCounterQ) + to_unsigned(1, 10)); process (ClkIn) begin if (ClkIn'event and (ClkIn = '1')) then DecimatorAccQ <= DecimatorAccD; end if; end process; DecimatorAccD <= NumericMixerOut when (unsigned(DecimatorCounterQ) = 0) else std_logic_vector(unsigned(DecimatorAccQ) + unsigned(NumericMixerOut)); process (ClkIn) begin if (ClkIn'event and (ClkIn = '1')) then DecimatorLatchQ <= DecimatorLatchD; end if; end process; DecimatorLatchD <= DecimatorAccQ when (unsigned(DecimatorCounterQ) = 0) else DecimatorLatchQ; DemodulatedOutput <= DecimatorLatchQ(10 downto 1); -- PWM for speaker output SpeakerOut <= '1' when (unsigned(DecimatorCounterQ) > unsigned(DemodulatedOutput)) else '0'; end architecture;
Salida PWM
La parte de la salida PWM de la FPGA lo que hace es convertir la señal del latch de diezmado en un tren de pulsos PWM que se conecta directamente a un amplificador de audio externo. El bit de salida PWM se calcula comparando el valor del latch de diezmado con el contador de diezmado, lo que provoca que la anchura de los pulsos de salida (un único bit que va al amplificador) sea proporcional al valor del latch de diezmado. Este bit (modulado en PWM) puede atacar directamente a la entrada de cualquier amplificador de audio.
Esquema eléctrico final
Resultados
Los resultados distan mucho de considerarse de calidad, el ruido en la recepción es alto (sólo a mi se me ocurre montar un circuito de radio en una protoboard...), pero "se entiende" más o menos lo que dice :-). El amplificador de antena ha sido la parte que, de lejos, más trabajo me ha dado, ya que tengo que reconocer que mi fuerte no es la electrónica analógica y menos a estas frecuencias de trabajo.
El código fuente puede descargarse de la sección soft.
[ 2 comentarios ] ( 21775 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 987 )