Mapa de memoria y secuencia de arranque
El mapa de memoria de la SMS es muy sencillo, ya que las primeras posiciones de memoria (el vector de reset del Z80 es 0x0000) están en el cartucho, por lo que el cartucho de cada juego es responsable del arranque correcto de la CPU (vector de reset, puntero de pila e interrupciones).
$10000 ----------------------------------------------------------- Paging registers $FFFC ----------------------------------------------------------- Mirror of RAM at $C000-$DFFF $E000 ----------------------------------------------------------- 8k of on-board RAM (mirrored at $E000-$FFFF) $C000 ----------------------------------------------------------- 16k ROM Page 2, or one of two pages of Cartridge RAM $8000 ----------------------------------------------------------- 16k ROM Page 1 $4000 ----------------------------------------------------------- 15k ROM Page 0 $0400 ----------------------------------------------------------- First 1k of ROM Bank 0, never paged out with rest of Page 0 $0000 -----------------------------------------------------------
Aunque la SMS permite paginación de memoria, para ROMs de hasta 32Kb no es necesario y no será tenido en cuenta para este ejemplo. Los puntos clave en una ROM de la SMS son los siguientes:
- Vector de reset en 0x0000 (aquí empieza la ejecución del código)
- En 0x0038 está el vector de interrupción enmascarable: Las interrupciones en la SMS se gestionan con el modo 1 de la CPU, por lo que todas las interrupciones enmascarables (las "normales") van al vector 0x0038. En la SMS se puede configurar el chip gráfico (VDP) para que genere interrupciones cuando se produce un retrazo vertical o cuando se alcance determinada línea horizontal en el pintado.
- En 0x0066 está el vector de interrupción no enmastarable: en el caso de la SMS la interrupción no enmascarable está conectada al botón "pause" de la carcasa de la consola.
- La RAM de la consola empieza en 0xC000 y termina en 0xDFF0.
- En nuestro caso particular, para guardar las partidas, lo habitual es que en cartucho disponga de una RAM no volátil propia a partir de la dirección 0x8000 que se puede mapear en el espacio de direcciones de la CPU configurando unos registros especiales.
- Información sobre el cartucho (su cabecera) se encuentra alojada a partir de la dirección 0x7FF0 de la ROM del cartucho y consta de 16 bytes que indican tipo de ROM, tamaño y una pequeña firma ASCII para identificarla como ROM de SMS.
Aunque formalmente, la RAM termina en 0xDFFF, como dicha RAM tiene un espejo en 0xE000-0xFFFB y las últimas direcciones de memoria 0xFFFC-0xFFFF se usan para el control de la paginación de memoria, se considera que la RAM "termina" antes, en 0xDFF0. Por tanto, como la pila del Z80 crece hacia direcciones bajas de memoria (SP disminuye cuando se hace PUSH y aumenta cuando se hace POP), lo habitual es inicializar el puntero de pila SP a 0xDFF0 y que el compilador coloque las variables globales a partir de 0xC000.
Para la prueba de concepto se utilizará el compilador SDCC que se puede instalar siguiendo las instrucciones indicadas en este post, y que viene acompañado de todos los compiladores y enlazadores necesarios. A continuación se puede ver un típico esqueleto de código de arranque de ROM de Sega Master System en ensamblador:
.module crt0sms .globl _main .globl _vblankISR .globl _nmISR .area _HEADER (ABS) .org 0x0000 di im 1 ; SMS runs in z80 interrupt mode 1 jp init .org 0x0038 jp _vblankISR .org 0x0066 jp _nmISR init: ; init stack pointer ld sp, #0xDFF0 ; at end of onboard RAM ; init memory mapper ld hl,#0x0008 ; first page of cartridge RAM at 0x8000 ld (#0xfffc),hl ; [0xfffc] = 0x08, [0xfffd] = 0x00 ld hl,#0x0201 ld (#0xfffe),hl ; [0xfffe] = 0x01, [0xffff] = 0x02 ; init global variables call gsinit ; main function ei call _main inf_loop: jp inf_loop ; ordering of segments for the linker. .area _HOME .area _CODE .area _INITIALIZER .area _GSINIT .area _GSFINAL .area _CODE .area _GSINIT gsinit:: .area _GSFINAL ret .area _DATA .area _INITIALIZED .area _BSEG .area _BSS .area _HEAP .area _TAIL (ABS) .org 0x7FF0 ; cartridge "header" .ascii "TMR SEGA" ; https://www.smspower.org/Development/ROMHeader .db #0x00, #0x00 ; reserved .db #0x00, #0x00 ; checksum .db #0x26, #0x70, #0xA0 ; product code 107026, version 0 .db #0x4C ; SMS export, rom size = 32 Kb
Los pasos para la inicialización son los siguientes:
- Deshabilitar interrupciones
- Poner el modo 1 de interrupciones (vector único en 0x0038).
- Poner el puntero de pila en 0xDFF0
- Configuramos en los registros de paginación (0xFFFC y 0xFFFE) que los primeros 8K de la RAM del cartucho (para guardar partidas) estarán mapeados a partir de la dirección 0x8000.
- Invocamos la función "gsinit", que es proporcionada por el compilador (no la tenemos que escribir nosotros) y es la encargada de inicializar las variables globales que tengamos..
- Invocamos la función "main".
El código anterior asume que se deben definir como mínimo 3 funciones:
void vblankISR(void) __critical __interrupt(0): rutina de servicio de interrupción sólo interrumpible por una interrupción no enmascarable (NMI). En la práctica es una rutina "normal" de interrupción que lo primero que hacer nada más entrar es deshabilitar las interrupciones y volver a habilitarlas antes de salir.
void nmISR(void) __critical __interrupt: rutina de servicio de interrupción no interrumpible y no enmascarable (NMI). Se ejecuta cuando se pulsa en botón "pause" de la consola.
void main(void): el punto de entrada de nuestro código.
Chip gráfico (VDP)
El chip gráfico de la Sega Master System es una versión simplificada del chip gráfico del MSX y sólo soporta un único modo de vídeo. El chip tiene una VRAM (RAM de vídeo) dedicada y separada de la RAM de la CPU. A dicha VRAM se accede mediante operaciones de puerto (instrucciones "in", "out" del Z80). La VRAM está organizada como sigue:
$4000 --------------------------------------------------------------- Sprite info table: contains x,y and tile number for each sprite $3F00 --------------------------------------------------------------- Screen display: 32x28 table of tile numbers/attributes $3800 --------------------------------------------------------------- Sprite/tile patterns, 256..447 $2000 --------------------------------------------------------------- Sprite/tile patterns, 0..255 $0000 ---------------------------------------------------------------
Esta forma de organizar la VRAM no es fija y puede cambiarse escribiendo en registros especiales del VDP aunque es la más habitual en todos los juegos de SMS y la que usaremos para la prueba de concepto.
Lo habitual para asegurarnos de que el VDP está correctamente inicializado es:
- Inicializar sus registros a unos valores por defecto.
- Inicializar toda la VRAM a 0.
A continuación el código utilizado para inicializar el VDP y que se coloca justo antes de llamar "gsinit" (que inicializa las variables globales):
init_vdp_data: .db #0x04, #0x80, #0x00, #0x81, #0xff, #0x82, #0xff, #0x85, #0xff, #0x86, #0xff, #0x87, #0x00, #0x88, #0x00, #0x89, #0xff, #0x8a init_vdp_data_end: ... ; init vdp ld hl, #init_vdp_data ld b, #init_vdp_data_end - #init_vdp_data ld c, #0xbf otir ; clear vram ld a, #0x00 out (#0xbf), a ld a, #0x40 out (#0xbf), a ld bc, #0x4000 ; 16384 bytes of vram clear_vram_loop: ld a, #0x00 out (#0xbe), a dec bc ld a, b or c jp nz, clear_vram_loop ...
Para comprender el significado detallado de los datos "init_vdp_data" es mejor recurrir a la documentación del VDP, por ahora nos basta saber que con esos comandos, el VDP queda correctamente inicializado.
Fondo de la pantalla
La imagen en la pantalla de la Sega Master System está compuesta por 32 x 24 baldosas de 8 x 8 pixels cada bandolsa. Cada pixel está definido por 4 bits (máximo 15 colores simultáneos más el color 0 que siempre es transparente o negro). Cada color está definido por una paleta con dos bits por cada componente ("rrggbb").
La forma de definir los colores en las baldosas es algo críptica por la forma en que se definen los bits así que lo mejor es poner un ejemplo. Imaginemos que queremos que una baldosa sea un corazón rojo de bordes blancos y fondo transparente:
color | símbolo | índice en la paleta -------+---------+---------------------- transp | - | 0b0000 (0) rojo | * | 0b0001 (1) blanco | + | 0b0010 (2) -------- -++-++-- +**+**+- +*****+- -+***+-- --+*+--- ---+---- --------
Para este caso, la baldosa quedaría definida de la siguiente manera:
const uint8_t HEART_PATTERN[32] = { // bit // 0 1 2 3 cada byte: msb=pixel más izq, lsb=pixel más derecha 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x6C, 0x92, 0x00, 0x00, 0x7C, 0x82, 0x00, 0x00, 0x38, 0x44, 0x00, 0x00, 0x10, 0x28, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
Cada fila de la baldosa está definida por 4 bytes (total 4 x 8 = 32 bytes por baldosa). Y en cada fila el byte 0 tiene los bits 0 de cada pixel, el byte 1 tiene los bits 1 de cada pixel, el tercer byte 2 los bits 2 de cada pixel y el byte 3 los bits 3 de cada pixel. En el ejemplo anterior, el dibujo sólo utiliza 3 colores (0, 1 y 2), por eso los bytes 2 y 3 están a 0 siempre.
En nuestro caso, a partir de una foto y utilizando el Gimp para aplicar el algoritmo de reducción de paleta Floyd-Steinberg, generamos una imagen de 128 x 120 pixels (16 x 15 baldosas) junto con una paleta reducida de 16 colores.
#include <stdint.h> #include "ana.h" __sfr __at (0xBE) VDP_DATA; __sfr __at (0xBF) VDP_ADDRESS; #define PALETTE_OFFSET_TILES 0 #define PALETTE_OFFSET_SPRITES 16 void write_palette(uint8_t offset, const uint8_t *palette) { VDP_ADDRESS = offset; // start at color 0 VDP_ADDRESS = 0b11000000; uint8_t n = 16; while (n > 0) { VDP_DATA = *palette; palette++; n--; } } void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; src++; size--; } } void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; VDP_DATA = 0; src++; size--; } } void vblankISR(void) __critical __interrupt(0) { uint8_t vdp_status = VDP_ADDRESS; } void nmISR(void) __critical __interrupt { } void main(void) { __asm__ ("di"); // draw background image write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE); write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0); // 240 + 1 tile patterns --> vram pattern address (0x0000) write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800); // 32 * 24 tiles --> vram tile map address (0x3800) // enable display VDP_ADDRESS = 0b01100000; VDP_ADDRESS = 0x81; __asm__ ("ei"); while (1) ; }
Compilamos:
$ /opt/sdcc/bin/sdasz80 -o crt0sms.rel crt0sms.s $ /opt/sdcc/bin/sdcc -mz80 -c -o main.rel main.c $ /opt/sdcc/bin/sdcc -mz80 --data-loc 0xC000 --no-std-crt0 -o main.ihx crt0sms.rel main.rel $ objcopy -I ihex -O binary main.ihx main.sms
Y el fichero "main.sms" podemos ahora ejecutarlo con un simulador o incluso pasarlo a un cartucho flash para ejecutarlo en una Sega Master System real:

Sprites
El formato de baldosas descritp es el que se utiliza tanto para el fondo como para los sprites, con el añadido de que los sprites tienen su propia paleta, que puede ser diferente de la paleta de las baldosas del fondo.
La Sega Master System puede manejar hasta 64 sprites por hardware. La tabla de información de sprites ("sprite info table"), alojada a partir de la dirección 0x3F00 de la VRAM es una tabla de 256 bytes que almacena las coordenadas (x, y) de cada sprite y el número de baldosa que se usará para el sprite. Si asumimos que n = 0 ... 63 (0x00 ... 0x3F) tenemos que, dentro de la VRAM:
- 0x3F00 + n: es la coordenada "y" del sprite (un valor de 208 aquí significa que este es el final de la tabla de sprites y ya no hay más sprites que pintar).
- 0x3F80 + (n x 2): es la coordenada "x" del sprite.
- 0x3F81 + (n x 2): índice de la baldosa (0 a 255).
La VRAM permite definir hasta 512 baldosas distintas y mediante el registro 6 del VDP se configura si el índice del sprite hace referencia a las primeras 256 baldosas o a las últimas 256 baldosas de las 512. En nuestro caso (a través de "init_vdp_data") configuramos este registro para que las baldosas de los sprites sean las últimas 256 de todas las 512.
Interrupción vblank
La interrupción vblank es la parte del código donde habitualmente se realiza la escritura o modificación de los sprites, las baldosas, etc. ya que es en ese momento cuando se está produciendo una pausa entre un cuadra y el siguiente en la pantalla .
En nuestro caso colocaremos ahí un sencillo código que hace moverse al sprite a través de la pantalla rebotando en los bordes.
Código de la parte gráfica
A continuación se ve cómo quedaría todo el código de la parte gráfica (acceso al VDP, a la VRAM y la interrupción vblank):
#include <stdint.h> #include "ana.h" __sfr __at (0xBE) VDP_DATA; __sfr __at (0xBF) VDP_ADDRESS; const uint8_t SPRITE_PALETTE[16] = { // red white 0x00, 0x03, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; const uint8_t SPRITE_PATTERN[32] = { // bit // 0 1 2 3 each byte: msb=leftmost pixel, lsb=rightmost pixel 0x00, 0x00, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x6C, 0x92, 0x00, 0x00, 0x7C, 0x82, 0x00, 0x00, 0x38, 0x44, 0x00, 0x00, 0x10, 0x28, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; #define PALETTE_OFFSET_TILES 0 #define PALETTE_OFFSET_SPRITES 16 void write_palette(uint8_t offset, const uint8_t *palette) { VDP_ADDRESS = offset; // start at color 0 VDP_ADDRESS = 0b11000000; uint8_t n = 16; while (n > 0) { VDP_DATA = *palette; palette++; n--; } } void write_vram(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; src++; size--; } } void write_vram_2(const uint8_t *src, uint16_t size, uint16_t vram_addr) { VDP_ADDRESS = (uint8_t) (vram_addr & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((vram_addr >> 8) & 0x3F)); while (size > 0) { VDP_DATA = *src; VDP_DATA = 0; src++; size--; } } uint8_t sprite_x; uint8_t sprite_y; int8_t sprite_x_inc; int8_t sprite_y_inc; void vblankISR(void) __critical __interrupt(0) { uint8_t vdp_status = VDP_ADDRESS; // apply (sprite_x, sprite_y) coordinates to sprite vram VDP_ADDRESS = 0; VDP_ADDRESS = 0b01000000 | 0x3F; VDP_DATA = sprite_y; VDP_ADDRESS = 0x80; VDP_ADDRESS = 0b01000000 | 0x3F; VDP_DATA = sprite_x; // update (sprite_x, sprite_y) sprite_x += sprite_x_inc; if (sprite_x == (256 - 8)) sprite_x_inc = -1; else if (sprite_x == 0) sprite_x_inc = 1; sprite_y += sprite_y_inc; if (sprite_y == (192 - 8)) sprite_y_inc = -1; else if (sprite_y == 0) sprite_y_inc = 1; } void nmISR(void) __critical __interrupt { } void main(void) { __asm__ ("di"); // draw background image write_palette(PALETTE_OFFSET_TILES, ANA_PALETTE); write_vram(ANA_TILE_PATTERNS, 241 * 4 * 8, 0); // 240 + 1 tile patterns --> vram pattern address (0x0000) write_vram_2(ANA_TILE_MAP, 32 * 24, 0x3800); // 32 * 24 tiles --> vram tile map address (0x3800) // draw sprite write_palette(PALETTE_OFFSET_SPRITES, SPRITE_PALETTE); VDP_ADDRESS = 0; // border color = color 0 of sprite palette VDP_ADDRESS = 0x87; write_vram(SPRITE_PATTERN, 4 * 8, 0x2000); // locate sprite sprite_x = 80; sprite_y = 80; sprite_x_inc = 1; sprite_y_inc = 1; VDP_ADDRESS = (uint8_t) (0x3F00 & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F00 >> 8) & 0x3F)); VDP_DATA = sprite_y; VDP_DATA = 208; // end of sprite list (sprite_y = 208) VDP_ADDRESS = (uint8_t) (0x3F80 & 0x00FF); VDP_ADDRESS = 0b01000000 | ((uint8_t) ((0x3F80 >> 8) & 0x3F)); VDP_DATA = sprite_x; VDP_DATA = 0; // tile 0 of sprite tiles // enable display VDP_ADDRESS = 0b01100000; VDP_ADDRESS = 0x81; __asm__ ("ei"); while (1) ; }
Chip de sonido (PSG)
El chip de sonido SN76489 es un chip muy sencillo al que se accede mediante escrituras sucesivas en el registro 0x7F (es de sólo escritura) y en la documentación se explicmuy bien cómo se programa. En nuestro caso, para evitar estar "componiendo" o haciendo música desde cero para la prueba de concepto, se ha optado por recurrir al formato de música VGM ("Video Game Music"), documentado aquí y que es muy fácil de leer y de procesar pues es simplemente un volcado de los valores que deben escribirse en el registro del chip junto con comandos de espera.
Se definen dos funciones:
- void vgm_init(vgm_info *vgm, const uint8_t *file_data): que inicializa una estructura "vgm_info" a partir de los datos de una canción en formato VGM.
- void vgm_tick(vgm_info *vgm): que se encarga de escribir los datos en el chip de sonido con la cadencia indicada por la canción VGM. Esta función debe ser invocada en cada vblank del VDP.
typedef struct { const uint8_t *first_byte; const uint8_t *next_byte; uint16_t wait_counter; } vgm_info; void vgm_init(vgm_info *vgm, const uint8_t *file_data) { uint32_t version = *((uint32_t *) (file_data + 0x08)); if (version < 0x00000150) vgm->next_byte = file_data + 0x40; else { uint32_t data_offset = *((uint32_t *) (file_data + 0x34)); if (data_offset == 0x0000000C) vgm->next_byte = file_data + 0x40; else vgm->next_byte = file_data + data_offset + 0x34; } vgm->first_byte = vgm->next_byte; vgm->wait_counter = 0; } void vgm_tick(vgm_info *vgm) { if (vgm->wait_counter > 0) { vgm->wait_counter--; return; } const uint8_t *p = vgm->next_byte; if (*p == 0x50) { vgm->wait_counter = 0; while (*p == 0x50) { p++; PSG = *p; p++; } } while ((*p == 0x61) || (*p == 0x62) || (*p == 0x63)) { if ((*p == 0x62) || (*p == 0x63)) { vgm->wait_counter++; p++; } else { p++; uint16_t num_samples = *((uint16_t *) p); p += 2; //vgm->wait_counter += num_samples / 882; // convert samples to ticks (requires stdlib because of integer division) // // aproximate num_samples / 882 with num_samples / 768 = num_samples / (256 * 3) // (1 / 3) * 65536 = 21845, so: // num_samples / 768 = ((num_samples / 256) * 21845) / 65536 // num_samples / 768 = ((num_samples >> 8) * 21845) >> 16 // num_samples / 768 = (ns * 21845) >> 24 // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24 // num_samples / 768 = (ns * (16384 + 4096 + 512 + 256 + 32 + 4)) >> 24 // num_samples / 768 = ((ns << 14) + (ns << 12) + (ns << 8) + (ns << 5) + (ns << 2) + ns) >> 24 uint32_t aux = num_samples; aux = ((aux << 14) + (aux << 12) + (aux << 8) + (aux << 5) + (aux << 2)) >> 24; vgm->wait_counter = aux; } } while ((*p & 0x70) == 0x70) // wait n + 1 samples, 1 tick = 882 samples, so ignore 0x7X commands p++; if (*p == 0x66) { vgm->wait_counter = 0; vgm->next_byte = vgm->first_byte; } else vgm->next_byte = p; }
Como puede verse, desde la función main se invoca "vgm_init" indicando un puntero a los datos VGM a procesar y se invoca "vgm_tick" en cada interrupción vblank. La única parte algo críptica del código de "vgm_tick" es cuando hay que calcular la cantidad de ticks de espera a partir de los samples, pues la fórmula oficial es $\frac{samples}{882}$ para el caso PAL (50 Hz) y $\frac{samples}{735}$ para el caso NTSC (60 Hz).
En nuestro caso se ha optado por buscar un término medio y calcular los ticks de espera mediante la fórmula $\frac{samples}{768}$ (independientemente de que la consola se sea PAL o NTSC). Esta división entera se ha realizado mediante desplazamientos y sumas para evitar tener que usar la división proporcionada por la librería de SDCC (el Z80 no tiene instrucción de división).
Desde vgmrips.net pueden descargarse cientos de canciones en formato VGM para el chip SN76489.
Conclusiones
La Sega Master System es un sistema potente para su época y muy sencillo de programar comparado con la NES y otras consolas de su generación. El formato de baldosas es quizá lo más críptico que tiene pero en cuanto se le coge el truco, no es tan complicado aunque es mejor recurrir a herramientas externas para ayudarnos en las conversiones de datos. Todo el código puede descargarse de la sección soft.
[ añadir comentario ] ( 137 visualizaciones ) | [ 0 trackbacks ] | enlace permanente |





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 ] ( 1928 visualizaciones ) | [ 0 trackbacks ] | enlace permanente |




