CH552
Este microcontrolador está basado en un núcleo compatible MCS51 (8051) y, además de los periféricos estándar del 8051, dispone de una interfaz USB full-speed (12 Mbps) que sirve, además. como interfaz para tostar el integrado. Para desarrollar este pequeño proyecto utilizaremos una de las muchas placas disponibles en AliExpress que contienen este microcontrolador. Son placas muy baratas (muchas de menos de 2 ¤) y, para nuestro caso, sólo será necesaria una que incluya, como mínimo:
- El puerto USB (para conectar al ordenador).
- Un botón de bootloader (manteniendo pulsado este botón mientras se conecta el CH551 por USB al ordenador, hace que el micro entre en modo de programación y podamos tostarlo desde el ordenador).
- Un led conectado a cualquiera de sus pines GPIO.
El CH552 incluye dentro ya un reloj a 24 MHz calibrado y los PLLs para llegar a los 48 MHz que son necesarios para el subsistema USB del microcontrolador.
Compilador
Desgraciadamente no hay implementación oficial del backend del GCC (y, por tanto del g++) para la arquitectura 8051 por lo que nos limitaremos a programarlo en C usando el compilador SDCC. El SDCC podemos instalarlo de repositorio o compilarlo de forma sencilla:
$ ./configure --prefix=/opt/sdcc --disable-pic14-port --disable-pic16-port $ make $ make install
De esta forma tendremos en /opt/sdcc un compilador SDCC totalmente funcional. El SDCC soporta de forma nativa la arquitectura 8051 por lo que apenas serán necesarios algunos retoques en la línea de comandos para compilar nuestros programas para CH552.
Particularidades de la arquitectura MCS51
La arquitectura MCS51 (que es como se llama a la arquitectura compartida por todos los microcontroladores 8051 y compatibles, como el CH552) es una arquitectura un tanto peculiar. Se puede resumir en los siguientes puntos:
1. Arquitectura de tipo harvard a nivel funcional (instrucciones y datos en memorias separadas, aunque sólo a nivel funcional, internamente es von newmann).
2. Dos espacios de memoria RAM:
2.1. La IRAM que es siempre de 256 bytes divididos en 128 bytes de datos y 128 bytes de registros de función especial (SFR) para acceder a los periféricos.
2.2. La XRAM que es opcional y puede ser de hasta 64 KBytes. El acceso a la XRAM sólo se puede realizar mediante la instrucción "movx".
Es una arquitectura muy limitada pero que aún se sigue usando en microcontroladores de gama baja como el CH552.
Primera prueba de concepto: un blinker
A todos los efectos, el CH552 es un 8051 y, por tanto comparte con él todos los registros estándar de la familia MCS51, incluidos los registros de configuración de timer. Los registros relacionados con la configuración del reloj y GPIO sí son específicos del CH552. El proceso para hacer parpadear un led se puede resumir en:
* En la rutina de interrupción del timer 0:
- Incluimos la lógica de parpadeo del led.
* En función main():
- Configuramos el PLL del CH552 para que vaya a la frecuencia que necesitamos. Al arrancar, el CH552 va a 6 MHz pero podemos subir la frecuencia hasta 24 MHz como máximo.
- Configuramos el pin GPIO donde está conectado el led como pin de salida.
- Configuramos el timer 0 (puede ser cualquiera, yo elegí el 0).
Habilitamos las interrupciones.
A continuación puede verse el código fuente de lo que sería un sencillo blinker basado en el timer 0 del 8051.
#include <stdint.h> __sfr __at (0xB0) P3; // led at P3.2 (https://github.com/wuxx/nanoCH55x/blob/master/hardware/nanoCH55x-v1.1.pdf) __sfr __at (0x96) P3_MOD_OC; __sfr __at (0x97) P3_DIR_PU; __sfr __at (0xB9) CLOCK_CFG; __sfr __at (0x88) TCON; __sfr __at (0x89) TMOD; __sfr __at (0x8A) TL0; __sfr __at (0x8C) TH0; __sfr __at (0xA8) IE; __sfr __at (0xA1) SAFE_MOD; __sfr __at (0xC9) T2MOD; uint16_t x; void timer0Init() { const uint16_t TIMER_0_INIT_VALUE = 65535 - 2000; T2MOD &= 0xEF; // T0_CLK=0 --> freq = Fsys / 12 = 24 MHz / 2 = 2 MHz TMOD = (TMOD & 0xF0) | 0x01; // mode 1 (16 bit timer), T0_GATE=0, T0_CT=0 TH0 = (uint8_t) (TIMER_0_INIT_VALUE >> 8); TL0 = (uint8_t) (TIMER_0_INIT_VALUE & 0x00FF); TCON = (TCON & 0xCF) | 0x10; // clear timer flag (TF0=0) and start timer (TR0=1) } void timerIsr() __interrupt (1) { // Timer 0 ISR x++; if (x == 1000) { // at 1 KHz, so each 1000 calls we toggle led to toggle it each second P3 ^= 0x04; // switch P3.2 x = 0; } timer0Init(); } int main() { // set Fsys = 24 MHz SAFE_MOD = 0x55; SAFE_MOD = 0xAA; CLOCK_CFG = (CLOCK_CFG & 0xF8) | 0x06; SAFE_MOD = 0; // configure P3.2 as push-pull output pin P3_MOD_OC &= 0xFB; P3_DIR_PU |= 0x04; // configure timer 0 at Fsys / 12 = 2 MHz: T0_CT=0, T0_CLK=0, T0_GATE=0 timer0Init(); // enable timer 0 interrupts IE |= 0x80; // global IE |= 0x02; // timer 0 x = 0; // sleep while (1) ; }
Configuramos el timer 0 en modo 1 (timer de 16 bits sin recarga automática, esto es, hay que recargaro cada vez que queramos esperar un intervalo de tiempo). La función "timer0Init" se encarga de inicializar el timer 0 que, en el caso del CH552 se incrementa con una frecuencia de ${F_{sys} \over 12} = {24 \over 12} = 2\ MHz$. Si queremos que el timer 0 se desborde una vez por segundo, habría que cargar el valor 65535 - 2000000 en el contador, pero eso no es posible, pues se trata de un contador de 16 bits y, por tanto los valores de carga deben estar entre 0 y 65535. En este caso lo que se ha hecho es buscar una frecuencia de desbordamiento de 1 KHz, para lo cual se carga el timer 0 con el valor 65535 - 2000. Como se incrementa a 2 MHz, después de 2000 incrementos el contador se desbordará, con lo que tendremos un tiempo de desbordamiento de 1 / 1000 segundos. Si cada ${1 \over 1000}$ segundos se dispara la interrupción de timer 0 y en esa interrupción, a su vez, cambiamos el valor del bit del led cada 1000 llamadas (la variable x que se usa como contador en el código), conseguiremos que el led cambie de estado una vez por segundo.
# compilamos $ /opt/sdcc/bin/sdcc -mmcs51 -c -o main.rel main.c # enlazamos $ /opt/sdcc/bin/sdcc -mmcs51 --xram-loc 0 --xram-size 1024 --code-loc 0 --code-size 14336 -o main.ihx main.rel # convertimos el fichero hex a binario (objcopy es utilidad estándar en Linux) $ objcopy -Iihex -Obinary main.ihx main.bin # tostamos el binario en el CH552 (previamente insertado mientras pulsamos el botón de bootloader) $ ch55xtool -f main.bin
El parámetro "-mmcs51" sirve para indicar que la arquitectura destino es 8051 y los parámetros adicionales que se pasan en la fase de enlazado sirven para indicar al compilador SDCC el tamaño y la disposición de la memoria en el CH552. "objcopy" es una utilidad estándar en sistemas Linux mientras que "ch55xtool" es una utilidad de Python (se instala con el "pip") encargada de tostar el integrado.
Una vez tostado el CH552 lo podemos desconectar del conector USB y volver a conectarlo (esta vez sin pulsar el botón de bootloader) para comprobar que el led cambia de estado una vez por segundo.
Protocolo USB
En este post, donde se realiza una implementación similar para un microcontrolador AVR (ATMega32u4), se explica de forma resumida el protocolo USB, al igual que en varios documentos disponibles de forma abierta en la web:
https://www.usbmadesimple.co.uk
https://www.beyondlogic.org/usbnutshell
https://www.engineersgarage.com/signal-and-encoding-of-usb-system-part-5-6
https://www.keil.com/pack/doc/mw/USB/html/_u_s_b__endpoints.html
En este caso se han implementado los mensajes más usuales y mínimos del protocolo USB:
- GET_DESCRIPTOR (para dispositivo y configuración)
- SET_ADDRESS
- SET_CONFIGURATION
- GET_STATUS
Para que un dispositivo sea 100% compatible USB debe implementar también otros mensajes adicionales, pero para que un Linux reconozca un dispositivo, es suficiente con que dicho dispositivo responda a los mensajes enumerados.
El objetivo será que el CH552 sea detectado como un dispositivo de caracteres básico (estilo puerto serie) por parte del kernel de Linux, que el kernel cree un dispositivo en /dev/loQueSea y que se pueda encender y apagar el led de la placa CH552 mediante comandos "echo" enviados a dicho dispositivo:
$ echo 0 > /dev/loquesea # apagar el led $ echo 1 > /dev/loquesea # encender el led
Dispositivo USB
Definiremos un dispositivo USB muy básico con un descriptor de dispositivo, otro de configuración y dos endpoints: uno de entrada y otro de salida, ambos de tipo "bulk". El descriptor del dispositivo será el siguiente:
__code const deviceDescriptor myDeviceDescriptor = { 18, // bLength 0x01, // bDescriptorType (device) 0x0110, // bcdUSB (USB 1.1) 0x00, // bDeviceClass 0x00, // bDeviceSubClass 0x00, // bDeviceProtocol 64, // bMaxPacketSize 0xF055, // idVendor 0x0001, // idProduct 0x0000, // bcdDevice 0x00, // iManufacturer 0x00, // iProduct 0x00, // iSerialNumber 0x01 // bNumConfigurations };
Mientras que el descriptor de configuración completo (incluyendo los descriptores de interfaz y endpoints) será el siguiente:
__code const myConfigurationDescriptorType myConfigurationDescriptor = { { // configuration descriptor 9, // bLength 0x02, // bDescriptorType sizeof(configurationDescriptor) + sizeof(interfaceDescriptor) + sizeof(endpointDescriptor) + sizeof(endpointDescriptor), // wTotalLength 1, // bNumInterfaces 1, // bConfigurationValue 0, // iConfiguration 0x80, // bmAttributes 5 // bMaxPower (5 * 2 = 10 mA) }, { // interface descriptor 9, // bLength; 0x04, // bDescriptorType 0, // bInterfaceNumber 0, // bAlternateSetting 2, // bNumEndpoints 0xFF, // bInterfaceClass (vendor defined) 0xFF, // bInterfaceSubClass (vendor defined) 0xFF, // bInterfaceProtocol (vendor defined) 0 // iInterface }, { // endpoint descriptor (ep1 in) 7, // bLength 0x05, // bDescriptorType 0x01, // bEndpointAddress 0x02, // bmAttributes 64, // wMaxPacketSize 10 // bInterval }, { // endpoint descriptor (ep1 out) 7, // bLength 0x05, // bDescriptorType 0x81, // bEndpointAddress 0x02, // bmAttributes 64, // wMaxPacketSize 10 // bInterval } };
Usaremos los primeros 64 bytes de la XRAM para las transferencias del endpoint 0 (el endpoint de control) y los 64 bytes siguientes para las transferencias del endpoint 1, que será bidireccional y definimos todos los endpoints con un tamaño máximo de transferencia de 64 bytes. Nótese que, aunque haya dos descriptores de endpoint en el descriptor de configuración, estos dos descriptores hacen referencia a un mismo endpoint 1, pero en sentidos opuestos. En este caso tenemos dos descriptores y un único endpoint que es bidireccional.
La secuencia de acciones mínima que hacen los sistemas operativos cuando detectan que hay un dispositivo nuevo conectado al bus USB es la siguiente:
1. El host envía a la dirección 0 del bus una petición de GET_DESCRIPTOR(dispositivo) para que el dispositivo responda con su descriptor de dispositivo.
2. Una vez el host recibe el descriptor de dispositivo, comprueba que es válido y, si lo es, le asigna una dirección en el bus enviando una petición SET_ADDRESS al dispositivo para hacerle saber cual será su dirección en el bus en las siguientes peticiones.
3. A continuación el host vuelve a enviar una petición GET_DESCRIPTOR(dispositivo) pero esta vez a la nueva dirección de bus para comprobar que el dispositivo está ahí.
4. El host envía una petición GET_DESCRIPTOR(configuración) por cada configuración que haya (están indicadas en el descriptor del dispositivo y normalmente sólo hay una configuración, como en nuestro caso).
5. El host envía una petición SET_CONFIGURATION para indicar qué configuración se va a usar (por si hay más de una).
6. El host envía una petición GET_STATUS para preguntarle al dispositivo si está operativo.
7. A partir de este instante, el resto de endpoints deberán estar operativos para recibir tokens IN del host (enviar datos al host) o recibir tokens OUT del host (recibir datos del host).
Para configurar inicialmente el módulo USB del CH552:
- Deshabilitamos las interrupciones USB.
- Configuramos los endpoints: dirección de memoria en la XRAM donde se reciben y transmiten los paquetes y configuración del tipo de endpoint.
- Forzamos a que la dirección del dispositivo es la 0.
- Habilitamos la capa física (resistencias pull-up).
- Habilitamos el DMA que se encargará de escribir/leer en la XRAM cada paquetes que llegue/salga.
- Habilitamos las interrupciones: como mínimo para los eventos de reset USB y para los eventos de transferencia USB.
void usbInit() { IE_EX &= ~0x04; // disable usb interrupts USB_CTRL = 0; // device mode // endpoint 0 configuration UEP0_DMA_L = 0x00; // configure ep0 buffer address at XRAM 0x0000 UEP0_DMA_H = 0x00; UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK // endpoint 1 configuration UEP1_DMA_L = 0x40; // configure ep1 buffer address at XRAM 0x0040 UEP1_DMA_H = 0x00; UEP1_CTRL = 0x00; // ep1 OUT transfer return ACK, OUT transfer return ACK UEP1_T_LEN = 0; // ep1 IN transfers always tx 0 bytes UEP4_1_MOD = 0xC0; // enable ep1 IN (tx) and OUT (rx) // rest of usb device configuration USB_DEV_AD = 0x00; // initial device address = 0 UDEV_CTRL = 0x80; // disable internal DP/DM pull-down USB_CTRL = 0x29; // UC_DEV_PU_EN=1, UC_INT_BUSY=1, UC_DMA_EN=1 (0b00101001) UDEV_CTRL |= 0x01 ; // UD_PORT_EN=1 (0b00000001) USB_INT_FG = 0xFF; // clear interrupt flags USB_INT_EN = 0x03; // enable usb reset and transfer interrupts IE_EX |= 0x04; // enable usb interrupts IE |= 0x80; // enable global interrupts usbStatus = USB_STATUS_WAIT_SETUP; }
A partir de este momento la rutina de interrupción del vector número 8 (el asociado a USB) se disparará cada vez que haya un reset en el bus USB o cada vez que haya una transferencia en cualquiera de los endpoints que hemos configurado:
Si es un evento de reset USB: Se pone la dirección del dispositivo a 0. Se configuran de nuevo los endpoints, por si acaso. Si es un evento de transferencia USB terminada: Si es un token de SETUP y es el endpoint 0: Leemos el paquete de SETUP que estará en el buffer de recepción del endpoint 0. Si es una petición de tipo GET_DESCRIPTOR: Ponemos el descriptor que nos solicita el host en el buffer del endpoint 0. Nos preparamos para recibir un token IN. Si es una petición de tipo SET_ADDRESS: Guardamos en una variable la nueva dirección que tenemos que usar. Preparamos un paquete de respuesta de 0 bytes para el próximo token IN que llegue. Nos preparamos para recibir un token IN. Si es una petición de tipo SET_CONFIGURATION: Preparar próxima transferencia con token IN de 0 bytes a modo de ACK. Si es una petición de tipo GET_STATUS: Preparar próxima transferencia con token IN con 2 bytes a 0 para indicar status ok. Si es un token IN y es el endpoint 0: Si mandé un descriptor: Preparar próxima transferencia de tipo OUT a modo de ack desde el host. Si justo antes llegó un SET_ADDRESS: Ya se puede configurar la dirección USB del dispositivo con la que se almacenó en una variable. Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si justo antes llegó un SET_CONFIGURATION o un GET_STATUS: Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si es un token OUT y es el endpoint 0: Preparar endpoint 0 de nuevo para que llegue otro token SETUP. Si es un token IN y es el endpoint 1: Se habrán transferido 0 bytes desde el CH552 al host (PC). Nada que hacer. Si es un token OUT y es el endpoint 1: Encender o apagar el led en función del primer byte del buffer del endpoint 1.
Aquí puede verse el código fuente en C de este pseudocódigo:
void usbIsr() __interrupt (8) { // USB ISR if (USB_INT_FG & 0x01) { // usb reset interrupt UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK expecting DATA0, IN transfer return NAK USB_DEV_AD = 0x00; // initial device address = 0 usbStatus = USB_STATUS_WAIT_SETUP; } else if (USB_INT_FG & 0x02) { // usb transfer interrupt uint8_t token = (USB_INT_ST >> 4) & 0x03; // 0=out, 1=sof, 2=in, 3=setup uint8_t endpoint = (USB_INT_ST & 0x0F); if ((token == 3) && (endpoint == 0) && (USB_RX_LEN == 8)) { // SETUP token #define P ((setupPacket *) ep0Buffer) if ((P->bmRequestType == 0x80) && (P->bRequest == 0x06)) { // GET_DESCRIPTOR uint8_t descriptorType = P->wValue >> 8; uint8_t descriptorIndex = P->wValue & 0x00FF; if (descriptorType == 0x01) { // GET_DESCRIPTOR (device) descriptorPtr = (uint8_t *) &myDeviceDescriptor; descriptorPtrToSend = (P->wLength < sizeof(deviceDescriptor)) ? P->wLength : sizeof(deviceDescriptor); descriptorPtrNextToSend = descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR; } else if (descriptorType == 0x02) { // GET_DESCRIPTOR (configuration) descriptorPtr = (uint8_t *) &myConfigurationDescriptor; descriptorPtrToSend = (P->wLength < sizeof(myConfigurationDescriptorType)) ? P->wLength : sizeof(myConfigurationDescriptorType); descriptorPtrNextToSend = descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR; } else { // } } else if ((P->bmRequestType == 0x00) && (P->bRequest == 0x05)) { // SET_ADDRESS addressToUse = (P->wValue & 0x00FF); // response with a DATA1 with 0 length UEP0_T_LEN = 0; UEP0_CTRL = 0x40; // next IN transfer (response) with DATA1 usbStatus = USB_STATUS_WAIT_IN_AFTER_SET_ADDRESS; } else if ((P->bmRequestType == 0x00) && (P->bRequest == 0x09)) { // SET_CONFIGURATION // response with a DATA1 with 0 length UEP0_T_LEN = 0; UEP0_CTRL = 0x40; // next IN transfer (response) with DATA1 usbStatus = USB_STATUS_WAIT_IN_AFTER_SET_CONFIGURATION; } else if (((P->bmRequestType & 0x80) == 0x80) && (P->bRequest == 0x00)) { // GET_STATUS ep0Buffer[0] = ep0Buffer[1] = 0; // always return status ok UEP0_T_LEN = 2; // status word = 2 bytes UEP0_CTRL = 0xC0; // UEP_R_TOG=1, UEP_T_TOG=1, UEP_R_RES=ack, UEP_T_RES=ack usbStatus = USB_STATUS_WAIT_IN_AFTER_GET_STATUS; } } else if ((token == 2) && (endpoint == 0)) { // IN token if (usbStatus == USB_STATUS_WAIT_IN_AFTER_GET_DESCRIPTOR) { descriptorPtrToSend -= descriptorPtrNextToSend; descriptorPtr += descriptorPtrNextToSend; if (descriptorPtrToSend > 0) { descriptorPtrNextToSend = (descriptorPtrToSend > 8) ? 8 : descriptorPtrToSend; memcpy(ep0Buffer, descriptorPtr, descriptorPtrNextToSend); UEP0_T_LEN = descriptorPtrNextToSend; UEP0_CTRL ^= 0x40; // manual toggle UEP_T_TOG } else { UEP0_CTRL = 0x80; // expect OUT transfer with DATA 1 (status stage) usbStatus = USB_STATUS_WAIT_OUT_AFTER_IN_AFTER_GET_DESCRIPTOR; } } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_SET_ADDRESS) { USB_DEV_AD = (USB_DEV_AD & 0x80) | addressToUse; UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_SET_CONFIGURATION) { P3 |= 0x04; // switch on led when device configured UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } else if (usbStatus == USB_STATUS_WAIT_IN_AFTER_GET_STATUS) { UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } } else if ((token == 0) && (endpoint == 0)) { // OUT token UEP0_CTRL = 0x02; // ep0 OUT transfer return ACK, expect DATA0 (SETUP), IN transfer return NAK usbStatus = USB_STATUS_WAIT_SETUP; } // data in & out at endpoint 1 else if ((token == 2) && (endpoint == 1)) { // IN token: CH552 --> USB host (always tx 0 bytes) UEP1_CTRL ^= 0x40; // manual toggle UEP_T_TOG } else if ((token == 0) && (endpoint == 1)) { // OUT token: USB host --> CH552 (if first byte is '0' then switch off led, else if '1' then switch on len) if (ep1Buffer[0] == '1') P3 |= 0x04; else if (ep1Buffer[0] == '0') P3 &= ~0x04; UEP1_CTRL ^= 0x80; // manual toggle UEP_R_TOG } } USB_INT_FG = 0xFF; }
Nótese que algunos detalles como el cambio entre paquetes de tipo DATA0 y DATA1 no están indicados en el pseudocódigo para facilitar la comprensión.
De esta forma la función main queda muy sencilla:
int main() { // set Fsys = 24 MHz SAFE_MOD = 0x55; SAFE_MOD = 0xAA; CLOCK_CFG = (CLOCK_CFG & 0xF8) | 0x06; SAFE_MOD = 0; // configure P3.2 as push-pull output pin P3_MOD_OC &= 0xFB; P3_DIR_PU |= 0x04; P3 = 0; // turn off led // configure usb device usbInit(); // sleep while (1) ; }
El programa se compila y se tosta exactamente de la misma manera que se vió en la prueba de concepto (no tiene dependencias externas).
Funcionamiento
Una vez que el CH552 está programado lo desconectamos del conector USB que se usó para tostarlo, a continuación podemos irnos a una consola de root y hacer un
$ journalctl -f
Para empezar a trazar todos los eventos del kernel. Si en ese momento enchufamos el CH552 por USB (sin pulsar el botón de bootloader) podremos ver un mensaje de log del kernel parecido a este:
... feb 02 18:57:33 avelino kernel: usb 3-2: new full-speed USB device number 2 using xhci_hcd feb 02 18:57:33 avelino kernel: usb 3-2: New USB device found, idVendor=f055, idProduct=0001, bcdDevice= 0.00 feb 03 18:57:33 avelino kernel: usb 3-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0 ...
Lo que significará que el Kernel ha reconocido correctamente el dispositivo y lo ha enumerado. De hecho podemos hacer:
$ lsusb ... Bus 003 Device 002: ID f055:0001 ...
Y lo veremos junto con el resto de dispositivos USB que tengamos conectados en nuestro ordenador. Si hacemos un "lsusb -v -d f055:0001" nos dará incluso la información de los descriptores de dispositivo y de configuración.
Hasta aquí bien, pero aún no tenemos un driver de dispositivos en el kernel que sea capaz de manejar nuestro nuevo dispositivo inventado y que nos permita encender y apagar el led desde un programa de usuario (haciendo "echo 1 > /dev/loquesea" por ejemplo). En este sentido podemos usar el módulo de kernel "usbserial". Si, cuando cargamos este módulo en el kernel, le pasamos los parámetros "vendor=VENDORID product=PRODUCTID" (en nuestro caso "vendor=0xf055 product=0x0001") haremos que, si se detecta un dispositivo con esos vendor y product el módulo "usbserial" creará un /dev/ttyUSBn por cada par de endpoints IN/OUT que tenga el dispositivo. Por tanto en nuestro caso nos bastará con hacer un:
$ modprobe usbserial vendor=0xf055 product=0x0001
Después de enchufar nuestro nuevo dispositivo. Haciendo esto, el módulo "usbserial" creará un dispositivo en /dev/ttyUSB0 vinculado a los dos enpoints (ep1 IN y ep1 OUT indicados en el descriptor de configuración) de tal manera que escribiendo en /dev/ttyUSB0 se enviarán tokens OUT al endpoint 1 del CH552 para escribir datos en él, mientras que leyendo de /dev/ttyUSB0 se enviarán tokens IN al endpoint 1 del CH552 para leer datos de él. Tras esto ya podremos apagar y encender el led el CH552 con los comandos:
$ echo 1 > /dev/ttyUSB0 # encender $ echo 0 > /dev/ttyUSB0 # apagar
Implementación de un dispositivo de tipo HID
La solución descrita hasta ahora requiere, para su uso, que podamos insertar un driver en el kernel ("usbserial" o similar) que conozca la configuración de nuestro dispositivo o, en su defecto, utilizar "libusb" para que las aplicaciones de usuario puedan enviar y recibir datos del dispositivo. Existe, sin embargo, una alternativa que permitiría el uso del dispositivo desde el espacio de usuario sin necesidad de la instalación de ningún driver: configurar nuestro dispositivo como un dispositivo HID.
HID (Human Interface Device) son una clase de dispositivos dentro del estándar USB utilizados por teclados, ratones, joysticks, touch pads, etc. Un HID USB se diferencia con el dispositivo "plano" que teníamos hasta ahora en los siguientes aspectos:
- El descriptor de configuración indica en el cambo bInterfaceClass el valor 0x03 para indicar que es HID.
- Dentro de la estructura del descriptor de configuración se mete un descriptor HID entre el descriptor de interfaz y el primer descriptor de endpoint.
- Debe incluir al menos un descriptor de endpoint IN de tipo interrupt. En nuestro caso pondremos dos endpoints de tipo interrupt: uno IN y otro OUT (para encender y apagar el led).
- Debemos definir un descriptor "HID report". Dicho descriptor permite definir el formato de los datos que se enviarán y/o recibirán por los endpoints (por ejemplo, los teclados definen un descriptor de HID report donde indican el formato de los bytes que se envían al ordenador con cada pulsación).
- Debemos hacer que el dispositivo responda adecuadamente a la petición GET_DESCRIPTOR(hid report), que es una petición que envía en ordenador, cuando ve que el dispositivo es HID, con el propósito de que el propio dispositivo envíe su descriptor de "HID report". Nótese que una cosa es el descriptor HID (que se incluye dentro del descriptor de configuración) y otra cosa es el descriptor de HID report, que no se incluye en el descriptor de configuración, ya que el ordenador lo solicita mediante la petición GET_DESCRIPTOR(hid report) específica.
La clase de dispositivos HID de USB está muy bien explicada en la documentación oficial, aunque, como el resto de documentos, es algo densa y aquí tratamos de explicarlo de forma sencilla para el caso que nos ocupa. A continuación definimos el nuevo descriptor de configuración para nuestro dispositivo USB HID (se ha resaltado el descriptor HID):
__code const myConfigurationDescriptorType myConfigurationDescriptor = { { // configuration descriptor 9, // bLength 0x02, // bDescriptorType sizeof(configurationDescriptor) + sizeof(interfaceDescriptor) + sizeof(hidDescriptor) + sizeof(endpointDescriptor) + sizeof(endpointDescriptor), // wTotalLength 1, // bNumInterfaces 1, // bConfigurationValue 0, // iConfiguration 0x80, // bmAttributes 5 // bMaxPower (5 * 2 = 10 mA) }, { // interface descriptor 9, // bLength 0x04, // bDescriptorType 0, // bInterfaceNumber 0, // bAlternateSetting 2, // bNumEndpoints 0x03, // bInterfaceClass (HID) 0x00, // bInterfaceSubClass (none) 0x00, // bInterfaceProtocol (none) 0 // iInterface }, { // hid descriptor 9, // bLength 0x21, // bDescriptorType 0x0101, // bcdHID 0, // bCountryCode 1, // bNumDescriptors 0x22, // bReportDescriptorType (report) sizeof(hidReportDescriptor) // wReportDescriptorLength }, { // endpoint descriptor (ep1 in) 7, // bLength 0x05, // bDescriptorType 0x81, // bEndpointAddress 0x03, // bmAttributes (interrupt endpoint) 64, // wMaxPacketSize 10 // bInterval (max. ms between IN tokens) }, { // endpoint descriptor (ep1 out) 7, // bLength 0x05, // bDescriptorType 0x01, // bEndpointAddress 0x03, // bmAttributes (interrupt endpoint) 64, // wMaxPacketSize 10 // bInterval (max. ms between OUT tokens) } };
Como se puede ver se ha cambiado el campo bInterfaceClass para indicar que es un dispositivo HID, se ha incluido el descriptor HID y se han redefinido los endpoints para que sean ambos de tipo interrupt. El campo bReportDescriptorType del descriptor HID indica que hay definido, además, un descriptor de "HID report" con una longitud de wReportDescriptorLength bytes. Este descriptor de "HID report" será solicitado con posterioridad por el ordenador a través de una petición GET_DESCRIPTOR(hid report):
__code const uint8_t hidReportDescriptor[] = { // hid report descriptor for a raw 64 byte rx/tx pipe 0x06, 0xAB, 0xFF, // usage_page 0xFFAB 0x0A, 0x00, 0x02, // usage 0x0200 0xA1, 0x01, // collection 0x01 0x75, 0x08, // report size = 8 bits 0x15, 0x00, // logical minimum = 0 0x26, 0xFF, 0x00, // logical maximum = 255 0x95, 64, // report count = 64 bytes 0x09, 0x01, // usage 0x81, 0x02, // Input (array) 0x95, 64, // report count = 64 bytes 0x09, 0x02, // usage 0x91, 0x02, // Output (array) 0xC0 // end collection };
Los descriptores de HID report son complejos de diseñar y para tal menester se encuentran disponibles aplicaciones de ayuda (por ejemplo, la utilidad oficial de la organización USB Implementers Forum, que se puede ejecutar en Linux con wine) y varios tutoriales (recomiendo este). Para nuestro caso particular sólo necesitamos definir un HID "vendor specific" que permita enviar y recibir paquetes crudos de 64 bytes cada uno.
Una vez que el host (ordenador) detecta que es un dispositivo USB HID y obtiene sus descriptores, empieza a enviar (con la periodicidad en milisegundos indicada en los campos bInterval de los diferentes endpoints de tipo interrupt) tokens IN y OUT para recibir y enviar (respectivamente) datos desde y hasta el dispositivo.
A pesar de que, a priori puede resultar más engorrosa esta nueva "capa" HID sobre la primera aproximación, el definir un dispositivo como HID tienen un gran ventaja: Es posible enviar y recibir paquetes HID (que se envían y reciben por los endpoints interrupt) sin necesidad de ningún driver específico, ni en Windows ni en Linux (por ejemplo, en Linux el kernel crea un dispositivo /dev/hidrawN, cuando un proceso escribe en ese dispositivo, se envían tokens OUT por el endpoint OUT interrupt para escribir los datos, mientras que cuando un proceso intenta leer de ese dispositivo, se envían tokens IN por el endpoint IN interrupt para tratar de leer datos).
Prueba del HID
Cuando conectamos el CH552 configurado como USB HID vemos que el kernel lo ha detectado como un dispositivo USB HID:
... feb 05 13:53:18 avelino kernel: usb 3-2: new full-speed USB device number 18 using xhci_hcd feb 05 13:53:18 avelino kernel: usb 3-2: New USB device found, idVendor=f055, idProduct=0001, bcdDevice= 0.00 feb 05 13:53:18 avelino kernel: usb 3-2: New USB device strings: Mfr=0, Product=0, SerialNumber=0 feb 05 13:53:23 avelino kernel: hid-generic 0003:F055:0001.0008: hiddev0,hidraw3: USB HID v1.01 Device [HID f055:0001] on usb-0000:00:14.0-2/input0 ...
Creándose automáticamente el dispositivo (en este caso el "/dev/hidraw3") que puede ser escrito desde la shell para encender y apagar el led, sin necesidad de ningún driver:
$ echo 1 > /dev/hidraw3 # encender $ echo 0 > /dev/hidraw3 # apagar
Todo el software puede descargarse de la sección soft.
[ 1 comentario ] ( 1683 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 817 )
Es Navidad y toca montaje electrónico para el Belén. Este año trataremos de hacer un generador de movimiento lento que permita simular el paso de los tres reyes magos y hacer que estos transiten desde un extremo del belén hasta el pesebre moviéndose de forma lenta y autónoma a lo largo de los días.
Objetivo
La idea es que los tres reyes magos (tres figuras que van a lomos de sus respectivos camellos) se vayan desplazando lentamente desde un extremo del montaje del belén hasta el otro extremo (donde está situado el pesebre) a lo largo de los días y de forma imperceptible. De esta forma cada día estarán más cerca de su "destino".
Descripción funcional
Se plantea un montaje basado en un microcontrolador (en este caso un RISC-V), tres botones ("modo", "avance" y "retroceso"), un display de 7 segmentos y un motor paso a paso de 4 fases con reductora (4076 pasos por vuelta). El módulo funcionará en 3 modos entre los que se alternará pulsando uno de los botones:
- Modo de movimiento libre del motor paso a paso: en este modo se muestra la letra "c" en el display de 7 segmentos y usando los dos botones restantes se podrá mover rápidamente (aprox cinco vueltas por minuto) el motor paso a paso.
- Modo de configuración de días: En este modo se configurará la cantidad de días (en el display de 7 segmentos) que se desea que dure el movimiento completo del motor paso a paso.
- Modo de reposo (display de 7 segmentos apagado): Al entrar en este modo, el microcontrolador calcula, a partir de los pasos avanzados (o retrocedidos) en el modo 1 y a partir de los días configurados en el modo 2, la velocidad (lenta) a la que tendrá que moverse el motor paso a paso para que en el transcurso de esos días el motor vuelva a la posición de encendido. El montaje se quedaría en este modo hasta que el motor paso a paso llegue a su posición inicial (la que tenía cuando se encendió).
Cuando el montaje se enciende, el modo en el que arranca es el modo 1.
Un caso de uso típico sería el siguiente. El motor paso a paso se alojará en el pesebre, un hilo tendrá un extremo conectado al eje del motor (en el que se enrollará como si fuese el carrete de una caña de pescar) y el otro extremo de ese hilo estará pegado a una cartulina donde estarán alojados los reyes magos:
1. Encendemos el montaje (modo 1) y hacemos girar el motor paso a paso de tal forma que la cartulina con los reyes magos quede en lo que será el final de su recorrido (pegados al pesebre y con el hilo lo más enrollado posible al eje del motor).
2. Apagamos el módulo.
3. Volvemos a encender el módulo para que quede registrada la posición actual del motor como posición "reposo" o de "destino".
4. Con el módulo recién encendido (de nuevo modo 1), volvemos a mover el motor de forma rápida pero en el sentido contrario al dado en el paso 1, para que vaya soltando hilo mientras vamos tirando de los reyes magos (para mantener el hilo algo tenso) hasta que hayamos colocado los reyes magos en su posición inicial. Al estar en modo 1, el microcontrolador habrá estado contabilizando la cantidad de pasos que hemos tenido que hacer mover al motor para que los reyes magos lleguen a su posición inicial.
5. Pulsamos el botón de configuración para pasar al modo 2 y usamos los otros dos botones para definir la cantidad de días que queremos que tarden los reyes magos en llegar al pesebre.
6. Pulsamos de nuevo el botón de configuración para pasar el modo 3 (se apagará el display de 7 segmentos, aunque el led azul sigue parpadeando para que sepamos que el montaje sigue funcionando) y lo dejamos encendido así durante todos los días que dure el montaje. En este modo el motor paso a paso se irá moviendo poco a poco de tal manera que cuando haya pasado la cantidad de días configurada en el modo 2, los reyes magos hayan llegado al pesebre.
7. No se debe apagar el montaje ya que no se almacena el estado en ningún tipo de memoria no volátil. Si hay un corte de corriente en el montaje, hay que volver a configurarlo empezando por el paso 1.
Circuito
Como se puede ver se trata de un montaje muy sencillo que hace uso sólo de las características GPIO del microcontrolador y, por tanto, aunque está hecho con un RISC-V, el montaje es fácilmente portable a cualquier otro microcontrolador con la suficiente cantidad de pines GPIO: 3 de entrada y 11 de salida (7 para el display y 4 para el motor paso a paso).
- El teclado consta de 3 botones ("modo", "-" y "+") con circuito antirrebote básico.
- El display de 7 segmentos es uno sencillo de cátodo común.
- El motor paso a paso es un 28BYJ-48 de 4 fases, a 5 voltios y controlado por un chip ULN2003. El motor tiene una reductora que hace que requiera 4076 pasos para completar una vuelta.
Diseño del software
El software está desarrollado en C++ y el diagrama de clases es el siguiente:
Las clases están diseñadas para ser lo más independientes posible del hardware final en el que se ejecuten. De cara a la implementación específica para el GD32VF103, se definieron las siguientes clases:
"Heartbeat" es una clase que se encarga de hacer perpadear el led azul de la placa del GD32VF103 mientras que las clases My... son las encargadas de configurar y leer o escribir en los GPIO correspondientes a cada funcionalidad (teclado, 7 segmentos y motor paso a paso, respectivamente).
Por ejemplo, la clase "Stepper" es la encargada de mover el motor paso a paso. La velocidad está dada por el valor de la variable miembro "ticksPerStep", que indica la cantidad de veces que debe ejecutarse "run()" para avanzar en un paso el motor. Teniendo el cuenta que cada "run()" se ejecutan cada 10 milisegundos (100 veces por segundo), se puede calcular el valor de "ticksPerStep" a partir de la velocidad que queramos imprimirle al motor:
#include "Stepper.H" using namespace avelino; using namespace std; void Stepper::init() { this->gpioConfigure(); this->gpioWrite(0); } void Stepper::start() { this->doStart = true; } void Stepper::stop() { this->doStart = false; } void Stepper::run() { Status localStatus = this->status; do { this->status = localStatus; if (localStatus == Status::STOPPED) { if (this->doStart) { this->gpioWrite(this->lastMask); this->timer = this->ticksPerStep; //cout << "Stepper::run: STOPPED --> RUNNING_1, timer=" << this->timer << endl; localStatus = Status::RUNNING; } } else if (localStatus == Status::RUNNING) { this->timer--; int32_t threshold = (int32_t) this->ticksPerStep - (int32_t) this->getTicksPerPulse(); if (this->timer < threshold) this->gpioWrite(0); if (this->timer <= 0) { this->lastMask = this->getNextMask(this->lastMask, this->wise); this->gpioWrite(this->lastMask); if (this->listener != NULL) this->listener->stepDone(*this); this->timer = this->ticksPerStep; //cout << "Stepper::run: RUNNING_1 --> RUNNING_2, timer=" << this->timer << endl; } if (!this->doStart) { this->gpioWrite(0); //cout << "Stepper::run: RUNNING_1 --> STOPPED" << endl; localStatus = Status::STOPPED; } } } while (localStatus != this->status); }
Como se puede ver, la clase "Stepper" se encarga de la gestión de los avances en los pasos del motor, mientras que la clase "MyStepper" se encarga de implementar las operaciones de bajo nivel relacionadas con el motor:
#include "MyStepper.H" using namespace avelino; using namespace std; #define RCU_APB2EN *((uint32_t *) 0x40021018) #define GPIOA_CTL0 *((uint32_t *) 0x40010800) #define GPIOA_OCTL *((uint32_t *) 0x4001080C) #define GPIOB_CTL0 *((uint32_t *) 0x40010C00) #define GPIOB_OCTL *((uint32_t *) 0x40010C0C) void MyStepper::gpioConfigure() const { // enable clock on ports A and B RCU_APB2EN = RCU_APB2EN | (((uint32_t) 3) << 2); // configure A5, A6 and A7 as push/pull output GPIOA_CTL0 = (GPIOA_CTL0 & 0x000FFFFF) | 0x22200000; // configure B0 as push/pull output GPIOB_CTL0 = (GPIOB_CTL0 & 0xFFFFFFF0) | 0x00000002; } void MyStepper::gpioWrite(uint32_t mask) const { if (mask & 1) GPIOA_OCTL |= (((uint32_t) 1) << 5); else GPIOA_OCTL &= ~(((uint32_t) 1) << 5); if (mask & 2) GPIOA_OCTL |= (((uint32_t) 1) << 6); else GPIOA_OCTL &= ~(((uint32_t) 1) << 6); if (mask & 4) GPIOA_OCTL |= (((uint32_t) 1) << 7); else GPIOA_OCTL &= ~(((uint32_t) 1) << 7); if (mask & 8) GPIOB_OCTL |= ((uint32_t) 1); else GPIOB_OCTL &= ~((uint32_t) 1); } uint32_t MyStepper::getNextMask(uint32_t prevMask, Wise wise) const { if (wise == Stepper::Wise::CLOCK) return ((prevMask << 1) | ((prevMask >> 3) & 1)) & 0x0F; else return ((prevMask >> 1) | ((prevMask & 1) << 3)) & 0x0F; } const uint32_t MyStepper::getStepsPerRevolution() const { return 4076; } const uint32_t MyStepper::getTicksPerPulse() const { return 20; // 20 * 10 = 200 ms }
Las tareas de MyStepper se limitan a:
- Calcular una máscara de paso a partir de la anterior.
- Configurar las salidas GPIO.
- Emitir una máscara a las salidas GPIO.
De esta manera se mantiene separado el código C++ independiente del hardware del código C++ dependiente del hardware. De hecho la subcarpeta "linux" implementa versiones simuladas de Stepper, Keypad y SevenSegments, que permiten depurar en un terminal de Linux el funcionamiento del módulo antes de tostarlo en el microcontrolador.
Los tres diferentes modos de funcionamiento del módulo están representados por clases que heredan de la clase "State": una clase por cada modo. La clase "ConfigureStepsState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 1, la clase "ConfigureDaysState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 2, mientras que la clase "IdleState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 3 o modo de reposo. La clase "StateManager" se encarga de gestionar la "salida" de un modo y la "entrada" en el siguiente modo.
#ifndef __STATE_H__ #define __STATE_H__ #include "Task.H" extern "C++" { namespace avelino { using namespace std; template <typename T> class State : public Task { protected: T *data; public: virtual void onLoad(T &data) = 0; virtual void run() = 0; virtual State<T> &getNextState() = 0; virtual T &onUnload() = 0; }; template <typename T> class StateManager : public Task { protected: State<T> *currentState; public: StateManager(State<T> &initialState, T &initialData) : currentState(&initialState) { this->currentState->onLoad(initialData); }; virtual void run() { if (this->currentState != NULL) { this->currentState->run(); State<T> &nextState = this->currentState->getNextState(); if (this->currentState != &nextState) { T &data = this->currentState->onUnload(); this->currentState = &nextState; this->currentState->onLoad(data); } } }; }; } } #endif // __STATE_H__
Al final, lo que se hace en "main.cc" es configurar el CLIC (Core Local Interrupt Controller) del RISC-V para que genere una interrupción de timer cada 10 milisegundos y en cada callback del timer se ejecuta el run de los objetos:
- keypad (teclado)
- sevenSegments (7 segmentos)
- stepper (motor paso a paso)
- heartbeat (el led que parpadea)
- stateManager (encargado, a su vez de ejecutar el "run()" del "ConfigureStepsState", del "ConfigureDaysState" o del "IdleState", en función del modo en el que nos encontremos).
#include "Task.H" #include "Timer.H" #include "MyStepper.H" #include "MyKeypad.H" #include "MySevenSegments.H" #include "SharedData.H" #include "State.H" #include "MyIdleState.H" #include "MyConfigureStepsState.H" #include "ConfigureDaysState.H" #include "interrupt.H" #include "Heartbeat.H" using namespace avelino; using namespace std; class MyTimerListener : public TimerListener { public: Task **tasks; uint16_t numTasks; MyTimerListener(Task **tasks, uint16_t numTasks) : tasks(tasks), numTasks(numTasks) { }; virtual void timerExpired(); }; void MyTimerListener::timerExpired() { Task **t = this->tasks; for (uint16_t i = 0; i < this->numTasks; i++, t++) (*t)->run(); } int main() { interruptInit(); MyKeypad keypad; keypad.init(); MySevenSegments sevenSegments; sevenSegments.init(); MyStepper stepper; stepper.init(); Heartbeat heartbeat; // states SharedData data; MyConfigureStepsState configureStepsState(keypad, sevenSegments, stepper); ConfigureDaysState configureDaysState(keypad, sevenSegments, stepper); MyIdleState idleState(keypad, sevenSegments, stepper); // link states configureStepsState.setNextState(configureDaysState); configureDaysState.setNextState(idleState); idleState.setNextState(configureStepsState); StateManager<SharedData> stateManager(configureStepsState, data); // round robin tasks const int NUM_TASKS = 5; Task *tasks[NUM_TASKS] = { &keypad, &sevenSegments, &stepper, &heartbeat, &stateManager }; MyTimerListener myTimerListener(tasks, NUM_TASKS); Timer timer; timer.init(myTimerListener, 10_msForTimer); while (true) asm volatile ("wfi"); return 0; }
Inicializamos todos los objetos y creamos un sencillo gestor de tareas de tipo round-robin para invocar las funciones miembro "run()" de los objetos de tipo "Task".
Todo el código puede descargarse de la sección soft.
¡Feliz Navidad! :-)
[ 2 comentarios ] ( 25544 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 818 )
A raiz del anterior post en el que se introdujeron los conceptos y el código básico para el desarrollo de aplicaciones y juegos para la Game Boy Advance, he desarrollado un pequeño juego de tablero basado en bloques deslizantes e inspirado en los puzzles intermedios que aparecen en el juego "Mario + Rabbids Kingdom Battle".
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 ] ( 32011 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 1010 )
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 ] ( 1187 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 978 )
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 ] ( 1385 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 1043 )