Programación de un juego para un dispositivo integrado en ESP32

Parte 0: motivación


Introducción


Estaba buscando un proyecto de pasatiempo en el que pudiera trabajar fuera de mis tareas principales para escapar de la situación en el mundo. Estoy principalmente interesado en la programación de juegos, pero también me gustan los sistemas integrados. Ahora trabajo en una empresa de juegos, pero antes me dedicaba principalmente a microcontroladores. Aunque al final decidí cambiar mi camino e ingresar a la industria del juego, todavía me gusta experimentar con ellos. Entonces, ¿por qué no combinar ambos pasatiempos?

Odroid go


Tenía a Odroid Go por ahí , con lo cual sería interesante jugar. Su núcleo es ESP32, un microcontrolador muy popular con funcionalidad MK estándar (SPI, I2C, GPIO, temporizadores, etc.), pero también con WiFi y Bluetooth, lo que lo hace atractivo para crear dispositivos IoT.

Odroid Go complementa el ESP32 con un montón de periféricos, convirtiéndolo en una máquina de juegos portátil que recuerda a Gameboy Color: una pantalla LCD, un altavoz, una cruz de control, dos botones principales y cuatro auxiliares, una batería y un lector de tarjetas SD.

La mayoría de las personas compran Odroid Go para ejecutar emuladores de viejos sistemas de 8 bits. Si esto es capaz de emular juegos antiguos, también hará frente al lanzamiento de un juego nativo diseñado específicamente para él.


Limitaciones


Resolución 320x240 La

pantalla tiene un tamaño de solo 320x240, por lo que estamos muy limitados en la cantidad de información que se muestra en la pantalla al mismo tiempo. Necesitamos considerar cuidadosamente qué juego haremos y qué recursos usar.

Color de 16 bits La

pantalla admite color de 16 bits por píxel: 5 bits para el rojo, 6 bits para el verde y 5 para el azul. Por razones obvias, dicho circuito generalmente se llama RGB565. El verde se puso un poco más rojo y azul, porque el ojo humano distingue mejor entre las gradaciones de verde que el azul o el rojo.

El color de 16 bits significa que tenemos acceso a solo 65 mil colores. Compare esto con el color estándar de 24 bits (8 bits por color), que proporciona 16 millones de colores.

Falta de GPU

Sin una GPU, no podemos usar una API como OpenGL. Hoy en día, las mismas GPU se usan generalmente para renderizar juegos 2D como para juegos 3D. Justo en lugar de objetos, se dibujan cuadrángulos, en los que se superponen texturas de bits. Sin una GPU, tenemos que rasterizar cada píxel con una CPU, que es más lenta pero más simple.

Con una resolución de pantalla de 320x240 y color de 16 bits, el tamaño total del búfer de cuadro es 153,600 bytes. Esto significa que al menos treinta veces por segundo necesitaremos transmitir 153,600 bytes a la pantalla. En última instancia, esto puede causar problemas, por lo que debemos ser más inteligentes al renderizar la pantalla. Por ejemplo, puede convertir un color indexado en una paleta de modo que para cada píxel necesite almacenar un byte, que se usará como índice de una paleta de 256 colores.

4 MB

ESP32 tiene 520 KB de RAM interna, mientras que Odroid Go agrega otros 4 MB de RAM externa. Pero no toda esta memoria está disponible para nosotros, porque parte es utilizada por el SDK de ESP32 (más sobre esto más adelante). Después de deshabilitar todas las funciones extrañas posibles e ingresar mi función principal, ESP32 informa que podemos usar 4,494,848 bytes. Si en el futuro necesitamos más memoria, luego podremos volver a recortar funciones innecesarias.

Procesador de 80-240 MHz

La CPU está configurada a tres velocidades posibles: 80 MHz, 160 MHz y 240 MHz. Incluso un máximo de 240 MHz está lejos de la potencia de más de tres gigahercios de computadoras modernas con las que estamos acostumbrados a trabajar. Comenzaremos a 80 MHz y veremos hasta dónde podemos llegar. Si queremos que el juego funcione con batería, el consumo de energía debería ser bajo. Para hacer esto, sería bueno bajar la frecuencia.

Mala depuración

Hay formas de usar depuradores con dispositivos integrados (JTAG), pero, desafortunadamente, Odroid Go no nos proporciona los contactos necesarios, por lo que no podemos pasar por el código en el depurador, como suele ser el caso. Esto significa que la depuración puede ser un proceso difícil, y tendremos que usar activamente la depuración en pantalla (usando colores y texto), y también enviar información a la consola de depuración (que, afortunadamente, es fácilmente accesible a través de USB UART).

¿Por qué todos los problemas?


¿Por qué incluso intentar crear un juego para este dispositivo débil con todas las limitaciones mencionadas anteriormente, y simplemente no escribir nada para una PC de escritorio? Hay dos razones para esto: las

limitaciones estimulan la creatividad:

cuando trabajas con un sistema que tiene un cierto conjunto de equipos, cada uno de los cuales tiene sus propias limitaciones, te hace pensar en cómo usar mejor las ventajas de estas limitaciones. Entonces nos acercamos a los desarrolladores de juegos de sistemas antiguos, por ejemplo, Super Nintendo (pero aún es mucho más fácil para nosotros que para ellos).

El desarrollo de bajo nivel es divertido

Para escribir un juego desde cero para un sistema de escritorio normal, tendremos que trabajar con conceptos de motor estándar de bajo nivel: renderizado, física, reconocimiento de colisión. Pero al implementar todo esto en un dispositivo integrado, también tenemos que lidiar con conceptos informáticos de bajo nivel, por ejemplo, escribir un controlador LCD.

¿Qué tan bajo será el desarrollo?


Cuando se trata de nivel bajo y de crear su propio código, debe dibujar un borde en alguna parte. Si estamos tratando de escribir un juego sin bibliotecas para el escritorio, es probable que el borde sea un sistema operativo o una API multiplataforma como SDL. En mi proyecto, trazaré una línea para escribir cosas como controladores SPI y cargadores de arranque. Con ellos mucho más tormento que diversión.

Entonces, usaremos el ESP-IDF, que es esencialmente un SDK para ESP32. Podemos suponer que nos proporciona algunas utilidades que generalmente proporciona el sistema operativo , pero el sistema operativo no funciona en ESP32 . Estrictamente hablando, este MK usa FreeRTOS, que es un sistema operativo en tiempo realPero este no es un sistema operativo real. Esto es solo un planificador. Lo más probable es que no interactuemos con él, pero en su núcleo ESP-IDF lo usa.

ESP-IDF nos proporciona una API para periféricos ESP32 como SPI, I2C y UART, así como una biblioteca de tiempo de ejecución C, por lo que cuando llamamos a algo como printf, en realidad transfiere bytes a través de UART para que se muestren en el monitor de interfaz serie. También procesa todo el código de inicio necesario para preparar la máquina antes de invocar el punto de inicio de nuestro juego.

En esta publicación mantendré una revista de desarrollo en la que hablaré sobre puntos interesantes que me parecieron y explicaré los aspectos más difíciles. No tengo un plan y muy probablemente cometeré muchos errores. Todo esto lo creo por interés.

Parte 1: sistema de construcción


Introducción


Antes de que podamos comenzar a escribir código para Odroid Go, debemos configurar el SDK de ESP32. Contiene el código que inicia ESP32 y llama a nuestra función principal, así como el código periférico (por ejemplo, SPI) que necesitaremos cuando escribamos el controlador LCD.

Espressif llama a su SDK ESP-IDF ; Utilizamos la última versión estable v4.0 .

Podemos clonar el repositorio de acuerdo con sus instrucciones (con el indicador recursivo ) o simplemente descargar el archivo zip desde la página de lanzamientos.

Nuestro primer objetivo es una aplicación mínima de estilo Hello World instalada en Odroid Go que demuestre la configuración correcta del entorno de compilación.

C o C ++


ESP-IDF usa C99, por lo que también lo elegiremos. Si lo desea, podríamos usar C ++ (hay un compilador de C ++ en la cadena de herramientas ESP32), pero por ahora, nos quedaremos con C.

En realidad, me gusta C y su simplicidad. No importa cuánto escriba código en C ++, nunca logré llegar al momento de disfrutarlo.

Esta persona resume mis pensamientos bastante bien.

Además, si es necesario, podemos cambiar a C ++ en cualquier momento.

Proyecto mínimo


IDF usa CMake para administrar el sistema de compilación. También es compatible con Makefile, pero están en desuso en v4.0, por lo que solo usaremos CMake.

Como mínimo, necesitamos un archivo CMakeLists.txt con una descripción de nuestro proyecto, una carpeta principal con el archivo fuente del punto de entrada al juego y otro archivo CMakeLists.txt dentro de main , que enumera los archivos fuente.

CMake necesita hacer referencia a variables de entorno que le indiquen dónde buscar IDF y cadena de herramientas. Me molestó que tuviera que reinstalarlos cada vez que iniciaba una nueva sesión de terminal, así que escribí el script export.sh . Establece IDF_PATH e IDF_TOOLS_PATH, y también es una fuente de exportación IDF que establece otras variables de entorno.

Es suficiente que el usuario del script establezca las variables IDF_PATH e IDF_TOOLS_PATH .

IDF_PATH=
IDF_TOOLS_PATH=


if [ -z "$IDF_PATH" ]
then
	echo "IDF_PATH not set"
	return
fi

if [ -z "$IDF_TOOLS_PATH" ]
then
	echo "IDF_TOOLS_PATH not set"
	return
fi


export IDF_PATH
export IDF_TOOLS_PATH

source $IDF_PATH/export.sh

CMakeLists.txt en la raíz:

cmake_minimum_required(VERSION 3.5)

set(COMPONENTS "esptool_py main")

include($ENV{IDF_PATH}/tools/cmake/project.cmake)

project(game)

Por defecto, el sistema de compilación compilará todos los componentes posibles dentro de $ ESP_IDF / components , lo que resultará en más tiempo de compilación. Queremos compilar un conjunto mínimo de componentes para llamar a nuestra función principal y conectar componentes adicionales más adelante si es necesario. Para eso está la variable COMPONENTES .

CMakeLists.txt dentro de main :

idf_component_register(
	SRCS "main.c"
    INCLUDE_DIRS "")

Todo lo que hace: infinitamente una vez que un segundo muestra en el monitor la interfaz en serie "Hello World". VTaskDelay usa FreeRTOS para retrasar .

El archivo main.c es muy simple:

#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>


void app_main(void)
{
	for (;;)
	{
		printf("Hello World!\n");
		vTaskDelay(1000 / portTICK_PERIOD_MS);
	}

	// Should never get here
	esp_restart();
}

Tenga en cuenta que nuestra función se llama app_main , no main . El IDF utiliza la función principal para la preparación necesaria, y luego crea una tarea con nuestra función app_main como punto de entrada.

Una tarea es solo un bloque ejecutable que FreeRTOS puede administrar. Si bien no deberíamos preocuparnos por esto (o tal vez no del todo), es importante tener en cuenta aquí que nuestro juego se ejecuta en un núcleo (ESP32 tiene dos núcleos), y con cada iteración del bucle for, la tarea retrasa la ejecución por un segundo. Durante este retraso, el planificador de FreeRTOS puede ejecutar otro código que esté esperando en línea para su ejecución (si corresponde).

Podemos usar ambos núcleos, pero por ahora, limitémonos a uno.

Componentes


Incluso si reducimos la lista de componentes al mínimo requerido para la aplicación Hello World (que son esptool_py y main ), debido a la configuración de la cadena de dependencia, aún recopila algunos otros componentes que no necesitamos. Reúne todos estos componentes:

app_trace app_update bootloader bootloader_support cxx driver efuse esp32 esp_common esp_eth esp_event esp_ringbuf
esp_rom esp_wifi espcoredump esptool_py freertos heap log lwip main mbedtls newlib nvs_flash partition_table pthread
soc spi_flash tcpip_adapter vfs wpa_supplicant xtensa

Muchos de ellos son bastante lógicos ( bootloader , esp32 , freertos ), pero son seguidos por componentes innecesarios porque no usamos funciones de red: esp_eth, esp_wifi, lwip, mbedtls, tcpip_adapter, wpa_supplicant . Desafortunadamente, todavía nos vemos obligados a ensamblar estos componentes.

Afortunadamente, el enlazador es lo suficientemente inteligente y no coloca componentes no utilizados en un archivo binario listo para usar del juego. Podemos verificar esto con make size-components .

Total sizes:
 DRAM .data size:    8476 bytes
 DRAM .bss  size:    4144 bytes
Used static DRAM:   12620 bytes ( 168116 available, 7.0% used)
Used static IRAM:   56345 bytes (  74727 available, 43.0% used)
      Flash code:   95710 bytes
    Flash rodata:   40732 bytes
Total image size:~ 201263 bytes (.bin may be padded larger)
Per-archive contributions to ELF file:
            Archive File DRAM .data & .bss   IRAM Flash code & rodata   Total
                  libc.a        364      8   5975      63037     3833   73217
              libesp32.a       2110    151  15236      15415    21485   54397
           libfreertos.a       4148    776  14269          0     1972   21165
                libsoc.a        184      4   7909        875     4144   13116
          libspi_flash.a        714    294   5069       1320     1386    8783
                libvfs.a        308     48      0       5860      973    7189
         libesp_common.a         16   2240    521       1199     3060    7036
             libdriver.a         87     32      0       4335     2200    6654
               libheap.a        317      8   3150       1218      748    5441
             libnewlib.a        152    272    869        908       99    2300
        libesp_ringbuf.a          0      0    906          0      163    1069
                liblog.a          8    268    488         98        0     862
         libapp_update.a          0      4    127        159      486     776
 libbootloader_support.a          0      0      0        634        0     634
                libhal.a          0      0    519          0       32     551
            libpthread.a          8     12      0        288        0     308
             libxtensa.a          0      0    220          0        0     220
                libgcc.a          0      0      0          0      160     160
               libmain.a          0      0      0         22       13      35
                libcxx.a          0      0      0         11        0      11
                   (exe)          0      0      0          0        0       0
              libefuse.a          0      0      0          0        0       0
         libmbedcrypto.a          0      0      0          0        0       0
     libwpa_supplicant.a          0      0      0          0        0       0

Sobre todo, libc afecta el tamaño del binario, y eso está bien.

Configuración del proyecto


IDF le permite especificar los parámetros de configuración en tiempo de compilación que utiliza durante el ensamblaje para habilitar o deshabilitar varias funciones. Necesitamos establecer parámetros que nos permitan aprovechar los aspectos adicionales de Odroid Go.

Primero, debe ejecutar el script fuente de export.sh para que CMake tenga acceso a las variables de entorno necesarias. Además, como para todos los proyectos de CMake, necesitamos crear una carpeta de ensamblaje y llamar a CMake desde ella.

source export.sh
mkdir build
cd build
cmake ..

Si ejecuta make menuconfig , se abre una ventana donde puede configurar los ajustes del proyecto.

Ampliación de memoria flash hasta 16 MB


Odroid Go amplía la capacidad de la unidad flash estándar a 16 MB. Puede habilitar esta función yendo a Configuración de flasheo en serie -> Tamaño de flash -> 16 MB .

Encienda la RAM SPI externa


También tenemos acceso a 4 MB adicionales de RAM externa conectada a través de SPI. Puede habilitarlo yendo a Configuración de componentes -> ESP32 específico -> Soporte para RAM externa conectada a SPI y presionando la barra espaciadora para habilitarlo. También queremos poder asignar explícitamente memoria desde la RAM SPI; esto se puede habilitar yendo a la configuración de RAM SPI -> Método de acceso SPI RAM -> Hacer RAM asignable usando heap_caps_malloc .

Bajar la frecuencia


ESP32 funciona de manera predeterminada con una frecuencia de 160 MHz, pero bajémoslo a 80 MHz para ver qué tan lejos puede llegar con la frecuencia de reloj más baja. Queremos que el juego funcione con batería, y reducir la frecuencia ahorrará energía. Puede cambiarlo yendo a Configuración de componente -> ESP32 específico -> Frecuencia de CPU -> 80MHz .

Si selecciona Guardar , el archivo sdkconfig se guardará en la raíz de la carpeta del proyecto . Podemos escribir este archivo en git, pero tiene muchos parámetros que no son importantes para nosotros. Hasta ahora, estamos satisfechos con los parámetros estándar, excepto los que acabamos de cambiar.

En su lugar, puede crear el archivo sdkconfig.defaultsque contendrá los valores cambiados anteriormente. Todo lo demás se configurará por defecto. Durante la compilación, el IDF leerá sdkconfig.defaults , anulará los valores que establecemos y usará el estándar para todos los demás parámetros.

Ahora sdkconfig.defaults se ve así:

# Set flash size to 16MB
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y

# Set CPU frequency to 80MHz
CONFIG_ESP32_DEFAULT_CPU_FREQ_80=y

# Enable SPI RAM and allocate with heap_caps_malloc()
CONFIG_ESP32_SPIRAM_SUPPORT=y
CONFIG_SPIRAM_USE_CAPS_ALLOC=y

En general, la estructura original del juego se ve así:

game
├── CMakeLists.txt
├── export.sh
├── main
│   ├── CMakeLists.txt
│   └── main.c
└── sdkconfig.defaults

Construir y flashear


El proceso de ensamblaje y firmware en sí es bastante simple.

Corremos make para compilar (ADD -j4 o -j8 para compilaciones paralelas ), que Flash para escribir la imagen para ODROID Ve, y monitor de maquillaje para ver la salida de los printf declaraciones .

make
make flash
make monitor

También podemos ejecutarlos en una línea.

make flash monitor

El resultado no es particularmente impresionante, pero se convertirá en la base para el resto del proyecto.


Referencias



Parte 2: entrada


Introducción


Necesitamos poder leer los botones presionados por el jugador y la cruz en Odroid Go.

Botones



GPIO


Odroid Go tiene seis botones: A , B , Seleccionar , Inicio , Menú y Volumen .

Cada uno de los botones está conectado a un pin IO de propósito general (GPIO) separado . Los pines GPIO se pueden usar como entradas (para leer) o como salidas (les escribimos). En el caso de los botones, necesitamos una lectura.

Primero debe configurar los contactos como entradas, después de lo cual podemos leer su estado. Los contactos internos tienen uno de dos voltajes (3.3V o 0V), pero cuando los leen usando la función IDF, se convierten a valores enteros.

Inicialización


Los elementos marcados como SW en el diagrama son los botones físicos en sí. Cuando no se presiona, los contactos ESP32 ( IO13 , IO0 , etc.) están conectados a 3.3 V; es decir, 3.3 V significa que el botón no está presionado . La lógica aquí es lo contrario de lo que se espera.

IO0 e IO39 tienen resistencias físicas en el tablero. Si no se presiona el botón, la resistencia tira de los contactos a un alto voltaje. Si se presiona el botón, la corriente que

fluye a través de los contactos va a tierra, por lo que el voltaje 0 se leerá en los contactos. IO13 , IO27 , IO32 e IO33no tienen resistencias, porque el contacto en el ESP32 tiene resistencias internas, que configuramos para el modo pull-up.

Sabiendo esto, podemos configurar seis botones usando la API GPIO.

const gpio_num_t BUTTON_PIN_A = GPIO_NUM_32;
const gpio_num_t BUTTON_PIN_B = GPIO_NUM_33;
const gpio_num_t BUTTON_PIN_START = GPIO_NUM_39;
const gpio_num_t BUTTON_PIN_SELECT = GPIO_NUM_27;
const gpio_num_t BUTTON_PIN_VOLUME = GPIO_NUM_0;
const gpio_num_t BUTTON_PIN_MENU = GPIO_NUM_13;

gpio_config_t gpioConfig = {};

gpioConfig.mode = GPIO_MODE_INPUT;
gpioConfig.pull_up_en = GPIO_PULLUP_ENABLE;
gpioConfig.pin_bit_mask =
	  (1ULL << BUTTON_PIN_A)
	| (1ULL << BUTTON_PIN_B)
	| (1ULL << BUTTON_PIN_START)
	| (1ULL << BUTTON_PIN_SELECT)
	| (1ULL << BUTTON_PIN_VOLUME)
	| (1ULL << BUTTON_PIN_MENU);

ESP_ERROR_CHECK(gpio_config(&gpioConfig));

Las constantes especificadas al comienzo del código corresponden a cada uno de los contactos del circuito. Utilizamos la estructura gpio_config_t para configurar cada uno de los seis botones como entrada pull-up. En el caso de IO13 , IO27 , IO32 e IO33, necesitamos pedirle a IDF que active las resistencias pull-up de estos contactos. Para IO0 e IO39 no necesitamos hacer esto porque tienen resistencias físicas, pero lo haremos de todos modos para que la configuración sea hermosa.

ESP_ERROR_CHECK es una macro auxiliar de IDF que verifica automáticamente el resultado de todas las funciones que devuelven esp_err_t(la mayoría de las IDF) y afirman que el resultado no es igual a ESP_OK . Esta macro es conveniente de usar para una función si su error es crítico y no tiene sentido continuar la ejecución. En este juego, un juego sin entrada no es un juego, por lo que esta afirmación es cierta. A menudo usaremos esta macro.

Botones de lectura


Entonces, configuramos todos los contactos y finalmente podemos leer los valores.

La función gpio_get_level lee los botones numéricos , pero necesitamos invertir los valores recibidos, porque los contactos se levantan, es decir, una señal alta en realidad significa "no presionado" y una baja significa "presionado". La inversión conserva la lógica habitual: 1 significa "presionado", 0 - "no presionado".

int a = !gpio_get_level(BUTTON_PIN_A);
int b = !gpio_get_level(BUTTON_PIN_B);
int select = !gpio_get_level(BUTTON_PIN_SELECT);
int start = !gpio_get_level(BUTTON_PIN_START);
int menu = !gpio_get_level(BUTTON_PIN_MENU);
int volume = !gpio_get_level(BUTTON_PIN_VOLUME);

Cruceta (D-pad)



ADC


Conectar la cruz es diferente de conectar los botones. Los botones arriba y abajo están conectados a un pin de un convertidor analógico a digital (ADC) , y los botones izquierdo y derecho están conectados a otro pin ADC.

A diferencia de los contactos digitales GPIO, de los cuales podríamos leer uno de los dos estados (alto o bajo), el ADC convierte un voltaje analógico continuo (por ejemplo, de 0 V a 3.3 V) en un valor numérico discreto (por ejemplo, de 0 a 4095 )

Supongo que los diseñadores de Odroid Go lo hicieron para ahorrar en los pines GPIO (solo necesita dos pines analógicos en lugar de cuatro pines digitales). Sea como fuere, esto complica un poco la configuración y la lectura de estos contactos.

Configuración


El contacto IO35 está conectado al eje Y de la araña , y el contacto IO34 está conectado al eje X de la araña . Vemos que las articulaciones de la cruz son un poco más complicadas que los botones numéricos. Cada eje tiene dos interruptores ( SW1 y SW2 para el eje Y, SW3 y SW4 para el eje X), cada uno de los cuales está conectado a un conjunto de resistencias ( R2 , R3 , R4 , R5 ).

Si no se presiona "arriba" ni "abajo", el pin IO35 se baja hacia el suelo a través de R3 , y consideramos el valor 0 V. Si no se presiona "izquierda" ni "derecha", comuníquese con IO34baja hacia el suelo a través de R5 , y contamos el valor a 0 V.

Si se presiona SW1 ("arriba") , entonces con IO35 contamos 3.3 V. Si se presiona SW2 ("abajo") , entonces con IO35 contamos aproximadamente 1, 65 V, porque la mitad del voltaje caerá en la resistencia R2 .

Si se presiona SW3 (“izquierda”) , entonces con IO34 contamos 3.3 V. Si se presiona SW4 (“derecha”) , entonces con IO34 también contamos aproximadamente 1.65 V, porque la mitad del voltaje caerá en la resistencia R4 .

Ambos casos son ejemplos de divisores de voltaje.. Cuando dos resistencias en el divisor de voltaje tienen la misma resistencia (en nuestro caso, 100K), la caída de voltaje será la mitad del voltaje de entrada.

Sabiendo esto, podemos configurar el travesaño:

const adc1_channel_t DPAD_PIN_X_AXIS = ADC1_GPIO34_CHANNEL;
const adc1_channel_t DPAD_PIN_Y_AXIS = ADC1_GPIO35_CHANNEL;

ESP_ERROR_CHECK(adc1_config_width(ADC_WIDTH_BIT_12));
ESP_ERROR_CHECK(adc1_config_channel_atten(DPAD_PIN_X_AXIS,ADC_ATTEN_DB_11));
ESP_ERROR_CHECK(adc1_config_channel_atten(DPAD_PIN_Y_AXIS,ADC_ATTEN_DB_11));

Ajustamos el ADC a 12 bits de ancho para que 0 V se lea como 0 y 3,3 V como 4095 (2 ^ 12). La atenuación informa que no necesitamos atenuar la señal para obtener el rango de voltaje completo de 0 V a 3.3 V.

A 12 bits, podemos esperar que si no se presiona nada, se leerá 0, cuando se presiona hacia arriba y hacia la izquierda - 4096, y se leerá aproximadamente 2048 cuando se presione hacia abajo y hacia la derecha (porque las resistencias reducen el voltaje a la mitad).

Lectura cruzada


Leer la cruz es más difícil que los botones, porque necesitamos leer los valores sin procesar (de 0 a 4095) e interpretarlos.

const uint32_t ADC_POSITIVE_LEVEL = 3072;
const uint32_t ADC_NEGATIVE_LEVEL = 1024;

uint32_t dpadX = adc1_get_raw(DPAD_PIN_X_AXIS);

if (dpadX > ADC_POSITIVE_LEVEL)
{
	// Left pressed
}
else if (dpadX > ADC_NEGATIVE_LEVEL)
{
	// Right pressed
}


uint32_t dpadY = adc1_get_raw(DPAD_PIN_Y_AXIS);

if (dpadY > ADC_POSITIVE_LEVEL)
{
	// Up pressed
}
else if (dpadY > ADC_NEGATIVE_LEVEL)
{
	// Down pressed
}

ADC_POSITIVE_LEVEL y ADC_NEGATIVE_LEVEL son valores con un margen, asegurando que siempre leamos los valores correctos.

Encuesta


Hay dos opciones para obtener los valores de los botones: sondeo o interrupciones. Podemos crear funciones de procesamiento de entrada y pedirle a IDF que llame a estas funciones cuando se presionan los botones, o sondear manualmente el estado de los botones cuando lo necesitemos. El comportamiento impulsado por interrupciones hace las cosas más complicadas y difíciles de entender. Además, siempre me esfuerzo por hacer que todo sea lo más simple posible. Si es necesario, podemos agregar interrupciones más tarde.

Crearemos una estructura que almacenará el estado de seis botones y cuatro direcciones de la cruz. Podemos crear una estructura con 10 booleanos, o 10 int, o 10 sin signo int. Sin embargo, en su lugar, crearemos la estructura utilizando campos de bits .

typedef struct
{
	uint16_t a : 1;
	uint16_t b : 1;
	uint16_t volume : 1;
	uint16_t menu : 1;
	uint16_t select : 1;
	uint16_t start : 1;
	uint16_t left : 1;
	uint16_t right : 1;
	uint16_t up : 1;
	uint16_t down : 1;
} Odroid_Input;

Cuando se programa para sistemas de escritorio, los campos de bits generalmente se evitan porque están mal portados a diferentes máquinas, pero programamos para una máquina específica y no tenemos que preocuparnos por eso.

En lugar de campos, se podría utilizar una estructura con 10 valores booleanos con un tamaño total de 10 bytes. Otra opción es una uint16_t con cambio de bit y macros de enmascaramiento de bits que pueden establecer, borrar y verificar bits individuales. Funcionará, pero no será muy hermoso.

Un campo de bits simple nos permite aprovechar ambos enfoques: dos bytes de datos y campos con nombre.

Manifestación


Ahora podemos sondear el estado de las entradas dentro del bucle principal y mostrar el resultado.

void app_main(void)
{
	Odroid_InitializeInput();

	for (;;)
	{
		Odroid_Input input = Odroid_PollInput();

		printf(
			"\ra: %d  b: %d  start: %d  select: %d  vol: %d  menu: %d  up: %d  down: %d  left: %d  right: %d",
			input.a, input.b, input.start, input.select, input.volume, input.menu,
			input.up, input.down, input.left, input.right);

		fflush(stdout);

		vTaskDelay(250 / portTICK_PERIOD_MS);
	}

	// Should never get here
	esp_restart();
}

La función printf usa \ r para sobrescribir la línea anterior en lugar de agregar una nueva. Se necesita fflush para mostrar una línea, porque en el estado normal se restablece con el carácter de nueva línea \ n .


Referencias



Parte 3: pantalla


Introducción


Necesitamos poder renderizar píxeles en la pantalla LCD de Odroid Go.

Mostrar colores en la pantalla será más difícil que leer el estado de entrada porque la pantalla LCD tiene cerebro. La pantalla está controlada por ILI9341 , un controlador TFT LCD muy popular en un solo chip.

En otras palabras, estamos hablando con ILI9341, que responde a nuestros comandos controlando los píxeles en la pantalla LCD. Cuando digo "pantalla" o "pantalla" en esta parte, en realidad me refiero a ILI9341. Estamos lidiando con ILI9341. Controla la pantalla LCD.

SPI


La pantalla LCD está conectada al ESP32 a través de SPI (interfaz periférica en serie) .

SPI es un protocolo estándar utilizado para intercambiar datos entre dispositivos en una placa de circuito impreso. Tiene cuatro señales: MOSI (Master Out Slave In) , MISO (Master In Slave Out) , SCK (Clock) y CS (Chip Select) .

Un solo dispositivo maestro en el bus coordina la transferencia de datos controlando SCK y CS. Puede haber varios dispositivos en un bus, cada uno de los cuales tendrá sus propias señales CS. Cuando se activa la señal CS de este dispositivo, puede transmitir y recibir datos.

El ESP32 será el maestro SPI (maestro), y la pantalla LCD será el esclavo SPI esclavo. Necesitamos configurar el bus SPI con los parámetros requeridos y agregar una pantalla LCD al bus configurando los contactos correspondientes.



Los nombres VSPI.XXXX son solo etiquetas para los contactos en el diagrama, pero podemos revisar los contactos mirando las partes de los diagramas LCD y ESP32.

  • MOSI -> VSPI.MOSI -> IO23
  • MISO -> VSPI.MISO -> IO19
  • SCK -> VSPI.SCK -> IO18
  • CS0 -> VSPI.CS0 -> IO5

También tenemos IO14 , que es el pin GPIO que se usa para encender la luz de fondo, y también IO21 , que está conectado al pin DC de la pantalla LCD. Este contacto controla el tipo de información que transmitimos a la pantalla.

Primero, configure el bus SPI.

const gpio_num_t LCD_PIN_MISO = GPIO_NUM_19;
const gpio_num_t LCD_PIN_MOSI = GPIO_NUM_23;
const gpio_num_t LCD_PIN_SCLK = GPIO_NUM_18;
const gpio_num_t LCD_PIN_CS = GPIO_NUM_5;
const gpio_num_t LCD_PIN_DC = GPIO_NUM_21;
const gpio_num_t LCD_PIN_BACKLIGHT = GPIO_NUM_14;
const int LCD_WIDTH = 320;
const int LCD_HEIGHT = 240;
const int LCD_DEPTH = 2;


spi_bus_config_t spiBusConfig = {};
spiBusConfig.miso_io_num = LCD_PIN_MISO;
spiBusConfig.mosi_io_num = LCD_PIN_MOSI;
spiBusConfig.sclk_io_num = LCD_PIN_SCLK;
spiBusConfig.quadwp_io_num = -1; // Unused
spiBusConfig.quadhd_io_num = -1; // Unused
spiBusConfig.max_transfer_sz = LCD_WIDTH * LCD_HEIGHT * LCD_DEPTH;

ESP_ERROR_CHECK(spi_bus_initialize(VSPI_HOST, &spiBusConfig, 1));

Configuramos el bus usando spi_bus_config_t . Es necesario comunicar los contactos que usamos y el tamaño máximo de una transferencia de datos.

Por ahora, realizaremos una transmisión SPI para todos los datos del búfer de cuadros, que es igual al ancho de la pantalla LCD (en píxeles) multiplicado por su altura (en píxeles) multiplicado por el número de bytes por píxel.

El ancho es 320, la altura es 240 y la profundidad de color es de 2 bytes (la pantalla espera que los colores de los píxeles tengan 16 bits de profundidad).

spi_handle_t gSpiHandle;

spi_device_interface_config_t spiDeviceConfig = {};
spiDeviceConfig.clock_speed_hz = SPI_MASTER_FREQ_40M;
spiDeviceConfig.spics_io_num = LCD_PIN_CS;
spiDeviceConfig.queue_size = 1;
spiDeviceConfig.flags = SPI_DEVICE_NO_DUMMY;

ESP_ERROR_CHECK(spi_bus_add_device(VSPI_HOST, &spiDeviceConfig, &gSpiHandle));

Después de inicializar el bus, necesitamos agregar un dispositivo LCD al bus para que podamos comenzar a hablar con él.

  • clock_speed_hz — - , SPI 40 , . 80 , .
  • spics_io_num — CS, IDF CS, ( SD- SPI).
  • queue_size — 1, ( ).
  • indicadores : el controlador IDF SPI generalmente inserta bits vacíos en la transmisión para evitar problemas de tiempo durante la lectura desde el dispositivo SPI, pero realizamos una transmisión unidireccional (no leeremos desde la pantalla). SPI_DEVICE_NO_DUMMY informa que confirmamos esta transmisión unidireccional y que no necesitamos insertar bits vacíos.


gpio_set_direction(LCD_PIN_DC, GPIO_MODE_OUTPUT);
gpio_set_direction(LCD_PIN_BACKLIGHT, GPIO_MODE_OUTPUT);

También necesitamos configurar los pines de CC y luz de fondo como pines GPIO. Después de cambiar DC, la luz de fondo estará encendida constantemente.

Equipos


La comunicación con la pantalla LCD es en forma de comandos. Primero, pasamos un byte que denota el comando que queremos enviar, y luego pasamos los parámetros del comando (si los hay). La pantalla comprende que el byte es un comando si la señal de CC es baja. Si la señal de CC es alta, los datos recibidos se considerarán los parámetros del comando transmitido previamente.

En general, la transmisión se ve así:

  1. Le damos una señal baja a DC
  2. Enviamos un byte del comando
  3. Damos una señal alta a DC
  4. Enviar cero o más bytes, según los requisitos del comando
  5. Repita los pasos 1-4.

Aquí nuestro mejor amigo es la especificación ILI9341 . Enumera todos los comandos posibles, sus parámetros y cómo usarlos.


Un ejemplo de un comando sin parámetros es Display ON . El byte de comando es 0x29 , pero no se especifican parámetros para él.


Un ejemplo de un comando con parámetros es el conjunto de direcciones de columna . El byte de comando es 0x2A , pero se especifican cuatro parámetros necesarios para ello. Para usar el comando, debe enviar una señal baja a DC , enviar 0x2A , enviar una señal alta a DC y luego transferir los bytes de cuatro parámetros.

Los códigos de comando se especifican en la enumeración.

typedef enum
{
	SOFTWARE_RESET = 0x01u,
	SLEEP_OUT = 0x11u,
	DISPLAY_ON = 0x29u,
	COLUMN_ADDRESS_SET = 0x2Au,
	PAGE_ADDRESS_SET = 0x2Bu,
	MEMORY_WRITE = 0x2Cu,
	MEMORY_ACCESS_CONTROL = 0x36u,
	PIXEL_FORMAT_SET = 0x3Au,
} CommandCode;

En su lugar, podríamos usar una macro ( #define SOFTWARE_RESET (0x01u) ), pero no tienen símbolos en el depurador y no tienen alcance. También sería posible usar constantes estáticas enteras, como hicimos con los contactos GPIO, pero gracias a enum, podemos comprender de un vistazo qué datos se pasan a una función o miembro de la estructura: son del tipo CommandCode . De lo contrario, podría ser uint8_t sin formato que no le dice nada al programador que lee el código.

Lanzamiento


Durante la inicialización, podemos pasar diferentes comandos para poder dibujar algo. Cada comando tiene un byte de comando, que llamaremos Código de comando .

Definiremos una estructura para almacenar el comando de lanzamiento para que pueda especificar su matriz.

typedef struct
{
	CommandCode code;
	uint8_t parameters[15];
	uint8_t length;
} StartupCommand;

  • El código es el código de comando.
  • parámetros es una matriz de parámetros de comando (si los hay). Esta es una matriz estática de tamaño 15, porque este es el número máximo de parámetros que necesitamos. Debido a la naturaleza estática de la matriz, no tenemos que preocuparnos de asignar una matriz dinámica para cada comando cada vez.
  • longitud es el número de parámetros en la matriz de parámetros .

Usando esta estructura, podemos especificar una lista de comandos de lanzamiento.

StartupCommand gStartupCommands[] =
{
	// Reset to defaults
	{
		SOFTWARE_RESET,
		{},
		0
	},

	// Landscape Mode
	// Top-Left Origin
	// BGR Panel
	{
		MEMORY_ACCESS_CONTROL,
		{0x20 | 0xC0 | 0x08},
		1
	},

	// 16 bits per pixel
	{
		PIXEL_FORMAT_SET,
		{0x55},
		1
	},

	// Exit sleep mode
	{
		SLEEP_OUT,
		{},
		0
	},

	// Turn on the display
	{
		DISPLAY_ON,
		{},
		0
	},
};

Los comandos sin parámetros, por ejemplo, SOFTWARE_RESET , establecen la lista de inicializadores en parámetros como vacíos (es decir, con un cero) y la longitud establecida en 0. Los comandos con parámetros completan los parámetros y especifican la longitud. Sería genial si pudiéramos establecer la longitud automáticamente y no escribir números (en caso de que cometamos un error o los parámetros cambien), pero no creo que valga la pena.

El propósito de la mayoría de los equipos se desprende del nombre, con la excepción de dos.

MEMORIA_ACCESO_CONTROL

  • Modo horizontal : de manera predeterminada, la pantalla usa orientación vertical (240x320), pero queremos usar horizontal (320x240).
  • Top-Left Origin: (0,0) , ( ) .
  • BGR Panel: , BGR. , , , , .

PIXEL_FORMAT_SET

  • 16 bits per pixel: 16- .

Hay muchos otros comandos que se pueden enviar al inicio para controlar varios aspectos, como gamma. Los parámetros necesarios se describen en la especificación de la pantalla LCD (y no del controlador ILI9341), a la que no tenemos acceso. Si no transmitimos estos comandos, se utiliza la configuración de pantalla predeterminada, que nos conviene perfectamente.

Habiendo preparado una serie de comandos de inicio, podemos comenzar a transferirlos a la pantalla.

Primero, necesitamos una función que envíe un byte de comando a la pantalla. No olvide que enviar comandos es diferente de enviar parámetros, porque necesitamos enviar una señal baja a DC .

#define BYTES_TO_BITS(value) ( (value) * 8 )

void SendCommandCode(CommandCode code)
{
	spi_transaction_t transaction = {};

	transaction.length = BYTES_TO_BITS(1);
	transaction.tx_data[0] = (uint8_t)code;
	transaction.flags = SPI_TRANS_USE_TXDATA;

	gpio_set_level(LCD_PIN_DC, 0);
	spi_device_transmit(gSpiHandle, &transaction);
}

El IDF tiene una estructura spi_transaction_t , que completamos cuando queremos transferir algo a través del bus SPI. Sabemos cuántos bits tiene la carga útil y transferimos la carga en sí.

Podemos pasar un puntero a la carga útil o usar la estructura interna struct tx_data , que tiene un tamaño de solo cuatro bytes, pero evita que el controlador tenga que acceder a la memoria externa. Si usamos tx_data , debemos establecer el indicador SPI_TRANS_USE_TXDATA .

Antes de transmitir datos, enviamos una señal baja al DC , indicando que este es un código de comando.

void SendCommandParameters(uint8_t* data, int length)
{
	spi_transaction_t transaction = {};

	transaction.length = BYTES_TO_BITS(length);
	transaction.tx_buffer = data;
	transaction.flags = 0;

	gpio_set_level(LCD_PIN_DC, 1);
	spi_device_transmit(SPIHANDLE, &transaction);
}

Pasar parámetros es similar a enviar un comando, solo que esta vez usamos nuestro propio búfer ( datos ) y enviamos una señal alta a DC para decirle a la pantalla que los parámetros se están transmitiendo. Además, no establecemos el indicador SPI_TRANS_USE_TXDATA porque estamos pasando nuestro propio búfer.

Luego puede enviar todos los comandos de inicio.

#define ARRAY_COUNT(value) ( sizeof(value) / sizeof(value[0]) )

int commandCount = ARRAY_COUNT(gStartupCommands);

for (int commandIndex = 0; commandIndex < commandCount; ++commandIndex)
{
	StartupCommand* command = &gStartupCommands[commandIndex];

	SendCommandCode(command->code);

	if (command->length > 0)
	{
		SendCommandData(command->parameters, command->length);
	}
}

Recorrimos iterativamente la matriz de comandos de lanzamiento, pasando primero el código del comando y luego los parámetros (si los hay).

Dibujo de marco


Después de inicializar la pantalla, puede comenzar a dibujar en ella.

#define UPPER_BYTE_16(value) ( (value) >> 8u )
#define LOWER_BYTE_16(value) ( (value) & 0xFFu )

void Odroid_DrawFrame(uint8_t* buffer)
{
	// Set drawing window width to (0, LCD_WIDTH)
    uint8_t drawWidth[] = { 0, 0, UPPER_BYTE_16(LCD_WIDTH), LOWER_BYTE_16(LCD_WIDTH) };
	SendCommandCode(COLUMN_ADDRESS_SET);
	SendCommandParameters(drawWidth, ARRAY_COUNT(drawWidth));

	// Set drawing window height to (0, LCD_HEIGHT)
    uint8_t drawHeight[] = { 0, 0, UPPER_BYTE_16(LCD_HEIGHT), LOWER_BYTE_16(LCD_HEIGHT) };
	SendCommandCode(PAGE_ADDRESS_SET);
	SendCommandParameters(drawHeight, ARRAY_COUNT(drawHeight));

	// Send the buffer to the display
	SendCommandCode(MEMORY_WRITE);
	SendCommandParameters(buffer, LCD_WIDTH * LCD_HEIGHT * LCD_DEPTH);
}

ILI9341 tiene la capacidad de volver a dibujar partes individuales de la pantalla. Esto puede ser útil en el futuro si notamos una caída en la velocidad de fotogramas. En este caso, será posible actualizar solo las partes cambiadas de la pantalla, pero por ahora simplemente volveremos a dibujar la pantalla completa nuevamente.

Para renderizar un marco, se requiere configurar una ventana de renderizado. Para hacer esto, envíe el comando COLUMN_ADDRESS_SET con el ancho de la ventana y el comando PAGE_ADDRESS_SET con la altura de la ventana. Cada uno de los comandos toma cuatro bytes del parámetro que describe la ventana en la que realizaremos el renderizado.

UPPER_BYTE_16 y LOWER_BYTE_16- Estas son macros auxiliares para extraer los bytes altos y bajos de un valor de 16 bits. Los parámetros de estos comandos requieren que dividamos el valor de 16 bits en dos valores de 8 bits, por lo que hacemos esto.

El procesamiento se inicia mediante el comando MEMORY_WRITE y se envían a la pantalla los 153,600 bytes del búfer de trama a la vez.

Hay otras formas de transferir el búfer de cuadros a la pantalla:

  • Podemos crear otra tarea (tarea) de FreeRTOS, que es responsable de coordinar las transacciones SPI.
  • Puede transferir un marco no en uno, sino en varias transacciones.
  • Puede utilizar la transmisión sin bloqueo, en la que iniciamos el envío y luego continuamos realizando otras operaciones.
  • Puede usar cualquier combinación de los métodos anteriores.

Por ahora, utilizaremos la forma más simple: la única transacción de bloqueo. Cuando se llama a DrawFrame, se inicia la transferencia a la pantalla y nuestra tarea se detiene hasta que se completa la transferencia. Si luego descubrimos que no podemos lograr una buena velocidad de cuadros con este método, volveremos a este problema.

RGB565 y orden de bytes


Una pantalla típica (por ejemplo, el monitor de su computadora) tiene una profundidad de 24 bits (1,6 millones de colores): 8 bits por rojo, verde y azul. El píxel se escribe en la memoria como RRRRRRRRGGGGGGGGGBBBBBBBBB .

El Odroid LCD tiene una profundidad de 16 bits (65 mil colores): 5 bits de rojo, 6 bits de verde y 5 bits de azul. El píxel se escribe en la memoria como RRRRRGGGGGGGBBBBB . Este formato se llama RGB565 .

#define SWAP_ENDIAN_16(value) ( (((value) & 0xFFu) << 8u) | ((value) >> 8u)  )
#define RGB565(red, green, blue) ( SWAP_ENDIAN_16( ((red) << 11u) | ((green) << 5u) | (blue) ) )

Defina una macro que cree un color en el formato RGB565. Le pasaremos un byte de rojo, un byte de verde y un byte de azul. Tomará los cinco bits más significativos de rojo, los seis bits más significativos de verde y los cinco bits más significativos de azul. Elegimos bits altos porque contienen más información que los bits bajos.

Sin embargo, el ESP32 almacena los datos en orden Little Endian , es decir, el byte menos significativo se almacena en la dirección de memoria inferior.

Por ejemplo, el valor de 32 bits [0xDE 0xAD 0xBE 0xEF] se almacenará en la memoria como [0xEF 0xBE 0xAD 0xDE] . Al transferir datos a la pantalla, esto se convierte en un problema porque el byte menos significativo se enviará primero y la pantalla LCD espera recibir primero el byte más significativo. Establecer

macro SWAP_ENDIAN_16para intercambiar bytes y usarlo en la macro RGB565 .

Así es como se describe cada uno de los tres colores primarios en RGB565 y cómo se almacenan en la memoria ESP32 si no cambia el orden de los bytes.

Rojo

11111 | 000000 | 00000? -> 11111000 00000000 -> 00000000 11111000

Verde

00000 | 111111 | 00000? -> 00000111 11100000 -> 11100000 00000111

Azul

00000 | 000000 | 11111? -> 00000000 00011111 -> 00011111 00000000

Manifestación


Podemos crear una demostración simple para ver la pantalla LCD en acción. Al comienzo del marco, vacía el búfer del marco a negro y dibuja un cuadrado de 50x50. Podemos mover el cuadrado con una cruz y cambiar su color con los botones A , B y Inicio .

void app_main(void)
{
	Odroid_InitializeInput();
	Odroid_InitializeDisplay();

	ESP_LOGI(LOG_TAG, "Odroid initialization complete - entering main loop");

	uint16_t* framebuffer = (uint16_t*)heap_caps_malloc(320 * 240 * 2, MALLOC_CAP_DMA);
	assert(framebuffer);

	int x = 0;
	int y = 0;

	uint16_t color = 0xffff;

	for (;;)
	{
		memset(framebuffer, 0, 320 * 240 * 2);

		Odroid_Input input = Odroid_PollInput();

		if (input.left) { x -= 10; }
		else if (input.right) { x += 10; }

		if (input.up) { y -= 10; }
		else if (input.down) { y += 10; }

		if (input.a) { color = RGB565(0xff, 0, 0); }
		else if (input.b) { color = RGB565(0, 0xff, 0); }
		else if (input.start) { color = RGB565(0, 0, 0xff); }

		for (int row = y; row < y + 50; ++row)
		{
			for (int col = x; col < x + 50; ++col)
			{
				framebuffer[320 * row + col] = color;
			}
		}

		Odroid_DrawFrame(framebuffer);
	}

	// Should never get here
	esp_restart();
}

Asignamos el búfer de trama de acuerdo con el tamaño completo de la pantalla: 320 x 240, dos bytes por píxel (color de 16 bits). Usamos heap_caps_malloc para que se asigne en la memoria, que se puede usar para transacciones SPI con acceso directo a memoria (DMA) . DMA permite que los periféricos SPI accedan al búfer de trama sin la necesidad de participación de la CPU. Sin DMA, las transacciones SPI toman mucho más tiempo.

No realizamos comprobaciones para garantizar que el renderizado no se produzca fuera de los bordes de la pantalla.


Fuerte desgarro es notable. En aplicaciones de escritorio, la forma estándar de eliminar el desgarro es usar múltiples buffers. Por ejemplo, cuando el almacenamiento en búfer doble, hay dos amortiguadores: amortiguadores delanteros y traseros. Mientras se muestra el búfer frontal, la grabación se realiza
en la parte trasera. Luego cambian de lugar y el proceso se repite.

ESP32 no tiene suficiente RAM con capacidades DMA para almacenar dos buffers de trama (desafortunadamente, 4 MB de SPI RAM externo no tiene capacidades DMA), por lo que esta opción no es adecuada.

ILI9341 tiene una señal ( TE ) que le indica cuándo ocurre VBLANK para que podamos escribir en la pantalla hasta que se dibuje. Pero con Odroid (o el módulo de visualización) esta señal no está conectada, por lo que no podemos acceder a ella.

Tal vez podríamos encontrar un valor decente, pero por ahora no lo haremos, porque ahora nuestra tarea es simplemente mostrar los píxeles en la pantalla.

Fuente


Todo el código fuente se puede encontrar aquí .

Referencias



All Articles