Programando um jogo para um dispositivo incorporado no ESP32

Parte 0: motivação


Introdução


Eu estava procurando por um projeto de hobby em que pudesse trabalhar fora das minhas principais tarefas, a fim de escapar da situação no mundo. Estou mais interessado em programação de jogos, mas também gosto de sistemas embarcados. Agora eu trabalho em uma empresa de jogos, mas antes eu estava envolvido principalmente em microcontroladores. Embora no final eu tenha decidido mudar meu caminho e entrar na indústria de jogos, ainda gosto de experimentar com eles. Então, por que não combinar os dois hobbies?

Odroid go


Eu tinha o Odroid Go por aí , o que seria interessante para brincar. Seu núcleo é o ESP32 - um microcontrolador muito popular com funcionalidade MK padrão (SPI, I2C, GPIO, temporizadores etc.), mas também com WiFi e Bluetooth, o que o torna atraente para a criação de dispositivos IoT.

O Odroid Go complementa o ESP32 com vários periféricos, transformando-o em uma máquina de jogos portátil que lembra o Gameboy Color: uma tela de LCD, um alto-falante, uma cruz de controle, dois botões principais e quatro auxiliares, uma bateria e um leitor de cartão SD.

Principalmente as pessoas compram o Odroid Go para executar emuladores de sistemas antigos de 8 bits. Se isso for capaz de emular jogos antigos, ele também lidará com o lançamento de um jogo nativo projetado especificamente para ele.


Limitações


Resolução 320x240 A

tela tem um tamanho de apenas 320x240; portanto, somos muito limitados na quantidade de informações exibidas na tela ao mesmo tempo. Precisamos considerar cuidadosamente qual jogo criaremos e quais recursos usar.

Cor de 16 bits O

monitor suporta cores de 16 bits por pixel: 5 bits para vermelho, 6 bits para verde e 5 para azul. Por razões óbvias, esse circuito é geralmente chamado RGB565. O verde ficou um pouco mais vermelho e azul, porque o olho humano distingue melhor as gradações de verde do que azul ou vermelho.

Cores de 16 bits significa que temos acesso a apenas 65 mil cores. Compare isso com a cor padrão de 24 bits (8 bits por cor), fornecendo 16 milhões de cores.

Falta de GPU

Sem uma GPU, não podemos usar uma API como o OpenGL. Hoje, as mesmas GPUs são geralmente usadas para renderizar jogos 2D e jogos 3D. Em vez de objetos, quadrângulos são desenhados, sobre os quais as texturas de bits são sobrepostas. Sem uma GPU, temos que rasterizar cada pixel com uma CPU, que é mais lenta mas mais simples.

Com uma resolução de tela de 320x240 e cores de 16 bits, o tamanho total do buffer do quadro é 153.600 bytes. Isso significa que pelo menos trinta vezes por segundo precisaremos transmitir 153.600 bytes para o display. Em última análise, isso pode causar problemas, por isso precisamos ser mais inteligentes ao renderizar a tela. Por exemplo, você pode converter uma cor indexada em uma paleta para que, para cada pixel, você precise armazenar um byte, que será usado como um índice de uma paleta de 256 cores.

4 MB O

ESP32 possui 520 KB de RAM interna, enquanto o Odroid Go adiciona outros 4 MB de RAM externa. Mas nem toda essa memória está disponível para nós, porque parte é usada pelo ESP32 SDK (mais sobre isso mais tarde). Depois de desativar todas as funções estranhas possíveis e inserir minha função principal, o ESP32 relata que podemos usar 4.494.848 bytes. Se, no futuro, precisarmos de mais memória, mais tarde poderemos voltar a aparar funções desnecessárias.

Processador de 80-240 MHz

A CPU está configurada em três velocidades possíveis: 80 MHz, 160 MHz e 240 MHz. Mesmo um máximo de 240 MHz está longe do poder de mais de três gigahertz de computadores modernos com os quais estamos acostumados a trabalhar. Vamos começar a 80 MHz e ver até onde podemos ir. Se queremos que o jogo funcione com energia da bateria, o consumo de energia deve ser baixo. Para fazer isso, seria bom diminuir a frequência.

Depuração incorreta

Existem maneiras de usar depuradores com dispositivos incorporados (JTAG), mas, infelizmente, o Odroid Go não nos fornece os contatos necessários, portanto, não podemos percorrer o código no depurador, como geralmente é o caso. Isso significa que a depuração pode ser um processo difícil, e teremos que usar ativamente a depuração na tela (usando cores e texto) e também enviar informações para o console de depuração (que, felizmente, é facilmente acessível via USB UART).

Por que todo esse problema?


Por que tentar criar um jogo para este dispositivo fraco com todas as limitações listadas acima e simplesmente não escrever nada para um PC de mesa? Há duas razões para isso:

Limitações estimulam a criatividade:

quando você trabalha com um sistema que possui um determinado conjunto de equipamentos, cada um com suas próprias limitações, isso faz você pensar em como usar da melhor maneira as vantagens dessas limitações. Então, nos aproximamos dos desenvolvedores de jogos de sistemas antigos, por exemplo, Super Nintendo (mas ainda é muito mais fácil para nós do que para eles).

Desenvolvimento de baixo nível é divertido

Para escrever um jogo do zero para um sistema de desktop comum, precisamos trabalhar com conceitos padrão de mecanismo de baixo nível: renderização, física, reconhecimento de colisão. Porém, ao implementar tudo isso em um dispositivo incorporado, também precisamos lidar com conceitos de computador de baixo nível, por exemplo, escrevendo um driver de LCD.

Quão baixo será o desenvolvimento?


Quando se trata de nível baixo e a criação de seu próprio código, você deve desenhar uma borda em algum lugar. Se estamos tentando escrever um jogo sem bibliotecas para a área de trabalho, é provável que a borda seja um sistema operacional ou uma API de plataforma cruzada como SDL. No meu projeto, traçarei uma linha para escrever coisas como drivers SPI e gerenciadores de inicialização. Com eles, muito mais tormento do que diversão.

Portanto, usaremos o ESP-IDF, que é essencialmente um SDK para o ESP32. Podemos assumir que ele nos fornece alguns utilitários que o sistema operacional geralmente fornece, mas o sistema operacional não funciona no ESP32 . A rigor, este MK usa o FreeRTOS, que é um sistema operacional em tempo realmas este não é um sistema operacional real. Este é apenas um planejador. Provavelmente, não vamos interagir com ele, mas em seu núcleo o ESP-IDF o usa.

O ESP-IDF fornece uma API para periféricos ESP32, como SPI, I2C e UART, bem como uma biblioteca de tempo de execução C, portanto, quando chamamos algo como printf, na verdade transfere bytes via UART para serem exibidos no monitor da interface serial. Ele também processa todo o código de inicialização necessário para preparar a máquina antes de invocar o ponto de lançamento do nosso jogo.

Neste post, vou manter uma revista de desenvolvimento na qual falarei sobre pontos interessantes que me pareceram e explicarei os aspectos mais difíceis. Não tenho um plano e provavelmente cometerei muitos erros. Tudo isso eu crio por interesse.

Parte 1: sistema de compilação


Introdução


Antes de começarmos a escrever o código para o Odroid Go, precisamos configurar o ESP32 SDK. Ele contém o código que inicia o ESP32 e chama nossa função principal, bem como o código periférico (por exemplo, SPI) que precisaremos quando escrevermos o driver do LCD.

A Espressif chama seu ESP-IDF SDK ; usamos a versão estável mais recente v4.0 .

Podemos clonar o repositório de acordo com suas instruções (com o sinalizador recursivo ) ou simplesmente fazer o download do zip na página de lançamentos.

Nosso primeiro objetivo é um aplicativo mínimo no estilo Hello World instalado no Odroid Go que comprove a configuração correta do ambiente de compilação.

C ou C ++


O ESP-IDF usa C99, por isso também a escolheremos. Se desejado, poderíamos usar C ++ (existe um compilador C ++ na cadeia de ferramentas ESP32), mas por enquanto vamos nos ater a C.

Na verdade, eu gosto de C e de sua simplicidade. Não importa o quanto eu escreva código em C ++, nunca consegui chegar ao momento de apreciá-lo.

Essa pessoa resume meus pensamentos muito bem.

Além disso, se necessário, podemos mudar para C ++ a qualquer momento.

Projeto mínimo


A IDF usa o CMake para gerenciar o sistema de compilação. Ele também suporta Makefile, mas eles foram descontinuados na v4.0, portanto, apenas usaremos o CMake.

No mínimo, precisamos de um arquivo CMakeLists.txt com uma descrição do nosso projeto, uma pasta principal com o arquivo de origem do ponto de entrada no jogo e outro arquivo CMakeLists.txt dentro de main , que lista os arquivos de origem.

O CMake precisa fazer referência a variáveis ​​de ambiente que informam onde procurar IDF e cadeia de ferramentas. Fiquei aborrecido por ter que reinstalá-los toda vez que iniciei uma nova sessão de terminal, então escrevi o script export.sh . Ele define IDF_PATH e IDF_TOOLS_PATHe também é uma fonte de exportação IDF que define outras variáveis ​​de ambiente.

É suficiente para o usuário script para definir os IDF_PATH e variáveis 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 na raiz:

cmake_minimum_required(VERSION 3.5)

set(COMPONENTS "esptool_py main")

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

project(game)

Por padrão, o sistema de compilação criará todos os componentes possíveis dentro de $ ESP_IDF / components , o que resultará em mais tempo de compilação. Queremos compilar um conjunto mínimo de componentes para chamar nossa função principal e conectar componentes adicionais posteriormente, se necessário. É para isso que serve a variável COMPONENTS .

CMakeLists.txt dentro de main :

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

Tudo o que ele faz - infinitamente uma vez por segundo exibe no monitor a interface serial "Hello World". O VTaskDelay usa o FreeRTOS para atrasar .

O arquivo main.c é muito simples:

#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();
}

Observe que nossa função é chamada app_main , não main . A função principal é usada pelo IDF para a preparação necessária e, em seguida, cria uma tarefa com a função app_main como ponto de entrada.

Uma tarefa é apenas um bloco executável que o FreeRTOS pode gerenciar. Embora não devamos nos preocupar com isso (ou talvez nem um pouco), é importante observar aqui que nosso jogo é executado em um núcleo (o ESP32 possui dois núcleos) e, a cada iteração do loop for, a tarefa atrasa a execução por um segundo. Durante esse atraso, o agendador do FreeRTOS pode executar outro código que está aguardando na fila para execução (se houver).

Podemos usar os dois núcleos, mas, por enquanto, vamos nos limitar a um.

Componentes


Mesmo se reduzirmos a lista de componentes ao mínimo necessário para o aplicativo Hello World (que é esptool_py e main ), devido à configuração da cadeia de dependências, ele ainda coleta outros componentes que não são necessários. Ele coleta todos esses 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

Muitos deles são bastante lógicos ( bootloader , esp32 , freertos ), mas são seguidos por componentes desnecessários porque não usamos funções de rede: esp_eth, esp_wifi, lwip, mbedtls, tcpip_adapter, wpa_supplicant . Infelizmente, ainda somos obrigados a montar esses componentes.

Felizmente, o vinculador é inteligente o suficiente e não coloca componentes não utilizados em um arquivo binário pronto do jogo. Podemos verificar isso com 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

Acima de tudo, libc afeta o tamanho do binário, e isso é bom.

Configuração do projeto


O IDF permite especificar parâmetros de configuração em tempo de compilação usados ​​durante a montagem para ativar ou desativar várias funções. Precisamos definir parâmetros que nos permitam tirar proveito dos aspectos adicionais do Odroid Go.

Primeiro, você precisa executar o script de origem do export.sh para que o CMake tenha acesso às variáveis ​​de ambiente necessárias. Além disso, como em todos os projetos do CMake, precisamos criar uma pasta de montagem e chamar o CMake a partir dela.

source export.sh
mkdir build
cd build
cmake ..

Se você executar o make menuconfig , será aberta uma janela onde você poderá definir as configurações do projeto.

Expansão de memória flash de até 16 MB


O Odroid Go expande a capacidade padrão da unidade flash para 16 MB. Você pode ativar esse recurso acessando Configuração do pisca-pisca serial -> Tamanho do flash -> 16 MB .

Ativar RAM SPI externa


Também temos acesso a mais 4 MB de RAM externa conectados via SPI. Você pode habilitá-lo acessando Component config -> ESP32-specific -> Support for RAM externa conectada a SPI e pressionando a barra de espaço para habilitá-lo. Também queremos poder alocar explicitamente a memória da SPI RAM; isso pode ser ativado acessando SPI RAM config -> SPI RAM access method -> Tornar a RAM alocável usando heap_caps_malloc .

Abaixe a frequência


O ESP32 funciona por padrão com uma frequência de 160 MHz, mas vamos abaixá-lo para 80 MHz para ver até onde você pode ir com a menor frequência de clock. Queremos que o jogo funcione com bateria, e diminuir a frequência economizará energia. Você pode alterá-lo indo para Component config -> ESP32 specific -> CPU frequency -> 80MHz .

Se você selecionar Salvar , o arquivo sdkconfig será salvo na raiz da pasta do projeto . Podemos escrever esse arquivo no git, mas ele possui muitos parâmetros que não são importantes para nós. Até agora, estamos satisfeitos com os parâmetros padrão, exceto aqueles que acabamos de alterar.

Você pode criar o arquivo sdkconfig.defaultsque conterá os valores alterados acima. Todo o resto será configurado por padrão. Durante a construção, o IDF lerá sdkconfig.defaults , substituirá os valores que definimos e usará o padrão para todos os outros parâmetros.

Agora, o sdkconfig.defaults fica assim:

# 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

Em geral, a estrutura original do jogo é assim:

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

Construção e flash


O processo de montagem e firmware é bastante simples.

Corremos make para compilar (para compilação paralela, adicione -j4 ou -j8 ), flash para gravar a imagem no Odroid Go e monitor para exibir a saída das instruções printf .

make
make flash
make monitor

Também podemos executá-los em uma linha.

make flash monitor

O resultado não é particularmente impressionante, mas se tornará a base para o restante do projeto.


Referências



Parte 2: entrada


Introdução


Precisamos ser capazes de ler os botões pressionados pelo jogador e a cruz no Odroid Go.

Botões



GPIO


O Odroid Go possui seis botões: A , B , Selecionar , Iniciar , Menu e Volume .

Cada um dos botões está conectado a um pino GPO (General Purpose IO) separado . Os pinos GPIO podem ser usados ​​como entradas (para leitura) ou como saídas (escrevemos neles). No caso de botões, precisamos de uma leitura.

Primeiro, você precisa configurar os contatos como entradas, após o que podemos ler seu status. Os contatos internos têm uma das duas tensões (3,3V ou 0V), mas ao lê-los usando a função IDF, eles são convertidos em valores inteiros.

Inicialização


Os elementos marcados como SW no diagrama são os próprios botões físicos. Quando não pressionados, os contatos do ESP32 ( IO13 , IO0 , etc.) são conectados a 3,3 V; isto é, 3,3 V significa que o botão não está pressionado . A lógica aqui é o oposto do que é esperado.

IO0 e IO39 possuem resistores físicos na placa. Se o botão não for pressionado, o resistor puxa os contatos para uma alta tensão. Se o botão for pressionado, a corrente que

flui através dos contatos será direcionada ao solo, para que a tensão 0 seja lida nos contatos IO13 , IO27 , IO32 e IO33não possui resistores, porque o contato no ESP32 possui resistores internos, configurados para o modo pull-up.

Sabendo disso, podemos configurar seis botões usando a 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));

As constantes especificadas no início do código correspondem a cada um dos contatos do circuito. Usamos a estrutura gpio_config_t para configurar cada um dos seis botões como entrada pull-up. No caso de IO13 , IO27 , IO32 e IO33, precisamos solicitar à IDF para ativar os resistores de pull-up desses contatos. Para IO0 e IO39 , não precisamos fazer isso porque eles têm resistores físicos, mas faremos de qualquer maneira para tornar a configuração bonita.

ESP_ERROR_CHECK é uma macro auxiliar do IDF que verifica automaticamente o resultado de todas as funções que retornam esp_err_t(a maioria das IDF) e afirma que o resultado não é igual a ESP_OK . É conveniente usar essa macro para uma função se o erro for crítico e depois não fizer sentido continuar a execução. Neste jogo, um jogo sem entrada não é um jogo, portanto, essa afirmação é verdadeira. Geralmente usaremos essa macro.

Botões de leitura


Então, configuramos todos os contatos e finalmente podemos ler os valores.

Os botões numéricos são lidos pela função gpio_get_level , mas precisamos inverter os valores recebidos, porque os contatos são puxados para cima, ou seja, um sinal alto realmente significa "não pressionado" e um baixo significa "pressionado". A inversão preserva a lógica usual: 1 significa "pressionado", 0 - "não pressionado".

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);

Travessa (D-pad)



ADC


Conectar a cruz é diferente de conectar os botões. Os botões para cima e para baixo são conectados a um pino de um conversor analógico-digital (ADC) e os botões esquerdo e direito são conectados a outro pino ADC.

Ao contrário dos contatos digitais GPIO, dos quais podemos ler um dos dois estados (alto ou baixo), o ADC converte uma tensão analógica contínua (por exemplo, de 0 V a 3,3 V) em um valor numérico discreto (por exemplo, de 0 a 4095 )

Suponho que os designers do Odroid Go fizeram isso para economizar nos pinos GPIO (você só precisa de dois pinos analógicos em vez de quatro pinos digitais). Seja como for, isso complica um pouco a configuração e a leitura desses contatos.

Configuração


O contato IO35 é conectado ao eixo Y da aranha e o contato IO34 é conectado ao eixo X da aranha . Vemos que as articulações da cruz são um pouco mais complicadas que os botões numéricos. Cada eixo possui dois interruptores ( SW1 e SW2 para o eixo Y, SW3 e SW4 para o eixo X), cada um dos quais é conectado a um conjunto de resistores ( R2 , R3 , R4 , R5 ).

Se nem "para cima" nem "para baixo" for pressionado, o pino IO35 é puxado para o chão via R3 e consideramos o valor 0 V. Se nem "esquerdo" nem "direito" forem pressionados, entre em contato com IO34desce ao solo através de R5 e contamos o valor até 0 V.

Se SW1 for pressionado ("para cima") , então com IO35 contamos 3,3 V. Se SW2 for pressionado ("para baixo") , com IO35 contamos cerca de 1, 65 V, porque metade da tensão cairá no resistor R2 .

Se SW3 for pressionado (“esquerda”) , então com IO34 contamos 3,3 V. Se SW4 for pressionado (“direita”) , então com IO34 também contamos cerca de 1,65 V, porque metade da tensão cairá no resistor R4 .

Ambos os casos são exemplos de divisores de tensão.. Quando dois resistores no divisor de tensão têm a mesma resistência (no nosso caso - 100K), a queda de tensão será metade da tensão de entrada.

Sabendo disso, podemos configurar a peça transversal:

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));

Definimos o ADC para 12 bits de largura, de modo que 0 V foi lido como 0 e 3,3 V como 4095 (2 ^ 12). A atenuação relata que não precisamos atenuar o sinal para obter a faixa de tensão total de 0 V a 3,3 V.

A 12 bits, podemos esperar que se nada for pressionado, 0 será lido, quando pressionado para cima e para a esquerda - 4096, e aproximadamente 2048 será lido quando pressionado e para a direita (porque os resistores reduzem a tensão pela metade).

Leitura cruzada


Ler a cruz é mais difícil que os botões, porque precisamos ler os valores brutos (de 0 a 4095) e interpretá-los.

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 e ADC_NEGATIVE_LEVEL são valores com uma margem, garantindo que sempre lemos os valores corretos.

Votação


Existem duas opções para obter valores de botão: polling ou interrupções. Podemos criar funções de processamento de entrada e solicitar ao IDF que chame essas funções quando os botões forem pressionados, ou pesquise manualmente o estado dos botões quando precisarmos. O comportamento orientado a interrupções torna as coisas mais complicadas e difíceis de entender. Além disso, eu sempre me esforço para tornar tudo o mais simples possível. Se necessário, podemos adicionar interrupções mais tarde.

Criaremos uma estrutura que armazenará o estado de seis botões e quatro direções da cruz. Podemos criar uma estrutura com 10 int booleanos, 10 int ou 10 não assinados. No entanto, em vez disso, criaremos a estrutura usando 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;

Ao programar para sistemas de desktop, os campos de bits geralmente são evitados porque são mal portados para máquinas diferentes, mas programamos para uma máquina específica e não precisamos nos preocupar com isso.

Em vez de campos, uma estrutura com 10 valores booleanos com um tamanho total de 10 bytes pode ser usada. Outra opção é uma uint16_t com macros de deslocamento e mascaramento de bits que podem definir, limpar e verificar bits individuais. Funcionará, mas não será muito bonito.

Um campo de bits simples nos permite tirar proveito de ambas as abordagens: dois bytes de dados e campos nomeados.

Demo


Agora podemos pesquisar o estado das entradas dentro do loop principal e exibir o 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();
}

A função printf usa \ r para substituir a linha anterior em vez de adicionar uma nova. fflush é necessário para exibir uma linha, porque no estado normal ela é redefinida pelo caractere de nova linha \ n .


Referências



Parte 3: exibição


Introdução


Precisamos ser capazes de renderizar pixels no LCD do Odroid Go.

Exibir cores na tela será mais difícil do que ler o status de entrada porque o LCD possui cérebros. A tela é controlada pelo ILI9341 - um driver TFT LCD muito popular em um único chip.

Em outras palavras, estamos conversando com o ILI9341, que responde aos nossos comandos controlando os pixels no LCD. Quando digo "tela" ou "exibição" nesta parte, na verdade quero dizer ILI9341. Estamos lidando com ILI9341. Controla o LCD.

SPI


O LCD está conectado ao ESP32 via SPI (Serial Peripheral Interface) .

O SPI é um protocolo padrão usado para trocar dados entre dispositivos em uma placa de circuito impresso. Possui quatro sinais: MOSI (entrada principal escrava) , MISO (entrada principal escrava) , SCK (relógio) e CS (seleção de chip) .

Um único dispositivo mestre no barramento coordena a transferência de dados controlando SCK e CS. Pode haver vários dispositivos em um barramento, cada um com seus próprios sinais CS. Quando o sinal CS deste dispositivo é ativado, ele pode transmitir e receber dados.

O ESP32 será o mestre SPI (mestre) e o LCD será o escravo SPI escravo. Precisamos configurar o barramento SPI com os parâmetros necessários e adicionar um display LCD ao barramento, configurando os contatos correspondentes.



Os nomes VSPI.XXXX são apenas rótulos para os contatos no diagrama, mas podemos examinar os contatos observando as partes dos diagramas de LCD e ESP32.

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

Também temos o IO14 , que é o pino GPIO usado para ligar a luz de fundo, e também o IO21 , que é conectado ao pino DC do LCD. Este contato controla o tipo de informação que transmitimos para o display.

Primeiro, configure o barramento 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));

Nós configuramos o barramento usando spi_bus_config_t . É necessário comunicar os contatos que usamos e o tamanho máximo de uma transferência de dados.

Por enquanto, realizaremos uma transmissão SPI para todos os dados do buffer de quadro, que é igual à largura do LCD (em pixels) vezes sua altura (em pixels) vezes o número de bytes por pixel.

A largura é 320, a altura é 240 e a profundidade da cor é 2 bytes (a exibição espera que as cores dos pixels tenham 16 bits de profundidade).

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));

Após inicializar o barramento, precisamos adicionar um dispositivo de LCD ao barramento para que possamos começar a conversar com ele.

  • clock_speed_hz — - , SPI 40 , . 80 , .
  • spics_io_num — CS, IDF CS, ( SD- SPI).
  • queue_size — 1, ( ).
  • sinalizadores - o driver IDF SPI geralmente insere bits vazios na transmissão para evitar problemas de temporização durante a leitura do dispositivo SPI, mas realizamos uma transmissão unidirecional (não iremos ler a partir da tela). SPI_DEVICE_NO_DUMMY relata que confirmamos esta transmissão unidirecional e não precisamos inserir bits vazios.


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

Também precisamos definir os pinos DC e de luz de fundo como pinos GPIO. Depois de mudar para CC, a luz de fundo estará constantemente ligada.

Equipas


A comunicação com o LCD é na forma de comandos. Primeiro, passamos um byte que indica o comando que queremos enviar e depois passamos os parâmetros do comando (se houver). O visor entende que o byte é um comando se o sinal DC estiver baixo. Se o sinal DC estiver alto, os dados recebidos serão considerados os parâmetros do comando transmitido anteriormente.

Em geral, o fluxo fica assim:

  1. Damos um sinal baixo para DC
  2. Enviamos um byte do comando
  3. Damos um sinal alto para DC
  4. Envie zero ou mais bytes, dependendo dos requisitos do comando
  5. Repita as etapas 1 a 4

Aqui, nosso melhor amigo é a especificação ILI9341 . Ele lista todos os comandos possíveis, seus parâmetros e como usá-los.


Um exemplo de comando sem parâmetros é Display ON . O byte de comando é 0x29 , mas nenhum parâmetro foi especificado para ele.


Um exemplo de um comando com parâmetros é o Conjunto de endereços da coluna . O byte de comando é 0x2A , mas quatro parâmetros necessários são especificados para ele. Para usar o comando, você precisa enviar um sinal baixo para DC , enviar 0x2A , enviar um sinal alto para DC e transferir os bytes de quatro parâmetros.

Os códigos de comando em si são especificados na enumeração.

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;

Em vez disso, poderíamos usar uma macro ( #define SOFTWARE_RESET (0x01u) ), mas elas não possuem símbolos no depurador e não têm escopo. Também seria possível usar as constantes estáticas inteiras, como fizemos com os contatos do GPIO, mas, graças à enum, podemos entender rapidamente quais dados são passados ​​para uma função ou membro da estrutura: eles são do tipo CommandCode . Caso contrário, poderia ser uint8_t bruto que nada diz ao programador que está lendo o código.

Lançamento


Durante a inicialização, podemos passar comandos diferentes para poder desenhar algo. Cada comando possui um byte de comando, que chamaremos de código de comando .

Definiremos uma estrutura para armazenar o comando de inicialização, para que você possa especificar sua matriz.

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

  • code é o código de comando.
  • parameters é uma matriz de parâmetros de comando (se houver). Essa é uma matriz estática de tamanho 15, porque esse é o número máximo de parâmetros que precisamos. Devido à natureza estática da matriz, não precisamos nos preocupar em alocar uma matriz dinâmica para cada comando todas as vezes.
  • length é o número de parâmetros na matriz de parâmetros .

Usando essa estrutura, podemos especificar uma lista de comandos de inicialização.

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
	},
};

Comandos sem parâmetros, por exemplo, SOFTWARE_RESET , defina a lista do inicializador de parâmetros como vazia (ou seja, com um zeros) e o comprimento definido como 0. Os comandos com parâmetros preenchem os parâmetros e especificam o comprimento. Seria ótimo se pudéssemos definir o comprimento automaticamente e não escrever números (no caso de cometermos um erro ou os parâmetros mudarem), mas acho que não vale a pena.

O objetivo da maioria das equipes é claro desde o nome, com exceção de duas.

MEMORY_ACCESS_CONTROL

  • Modo Paisagem: Por padrão, a tela usa a orientação retrato (240x320), mas queremos usar a paisagem (320x240).
  • Top-Left Origin: (0,0) , ( ) .
  • BGR Panel: , BGR. , , , , .

PIXEL_FORMAT_SET

  • 16 bits per pixel: 16- .

Existem muitos outros comandos que podem ser enviados na inicialização para controlar vários aspectos, como gama. Os parâmetros necessários estão descritos na especificação do próprio LCD (e não no controlador ILI9341), ao qual não temos acesso. Se não transmitirmos esses comandos, serão usadas as configurações de exibição padrão, o que nos convém perfeitamente.

Depois de preparar uma série de comandos de inicialização, podemos começar a transferi-los para a tela.

Primeiro, precisamos de uma função que envie um byte de comando para o display. Não esqueça que os comandos de envio são diferentes dos parâmetros de envio, porque precisamos enviar um sinal baixo para o 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);
}

O IDF possui uma estrutura spi_transaction_t , que é preenchida quando queremos transferir algo através do barramento SPI. Sabemos quantos bits a carga útil tem e transferimos a carga propriamente dita.

Podemos passar um ponteiro para a carga útil ou usar a estrutura struct tx_data , que tem apenas quatro bytes de tamanho, mas evita que o driver precise acessar a memória externa. Se usarmos tx_data , devemos definir o sinalizador SPI_TRANS_USE_TXDATA .

Antes de transmitir dados, enviamos um sinal baixo para o DC , indicando que este é um 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);
}

A passagem de parâmetros é semelhante ao envio de um comando, só que desta vez usamos nosso próprio buffer ( dados ) e enviamos um sinal alto ao DC para informar ao display que os parâmetros estão sendo transmitidos. Além disso, não definimos o sinalizador SPI_TRANS_USE_TXDATA porque estamos passando nosso próprio buffer.

Então você pode enviar todos os comandos de inicialização.

#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);
	}
}

Percorremos iterativamente a matriz de comandos de inicialização, passando o código de comando primeiro e depois os parâmetros (se houver).

Desenho do quadro


Após inicializar a exibição, você pode começar a desenhar nela.

#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);
}

O ILI9341 tem a capacidade de redesenhar partes individuais da tela. Isso pode ser útil no futuro se notarmos uma queda na taxa de quadros. Nesse caso, será possível atualizar apenas as partes alteradas da tela, mas, por enquanto, simplesmente redesenharemos a tela inteira novamente.

Para renderizar um quadro, é necessário definir uma janela de renderização. Para fazer isso, envie o comando COLUMN_ADDRESS_SET com a largura da janela e o comando PAGE_ADDRESS_SET com a altura da janela. Cada um dos comandos usa quatro bytes do parâmetro que descrevem a janela na qual executaremos a renderização.

UPPER_BYTE_16 e LOWER_BYTE_16- Essas são macros auxiliares para extrair os bytes alto e baixo de um valor de 16 bits. Os parâmetros desses comandos requerem a divisão do valor de 16 bits em dois valores de 8 bits, e é por isso que fazemos isso.

A renderização é iniciada pelo comando MEMORY_WRITE e envia para a exibição todos os 153.600 bytes do buffer de quadros por vez.

Existem outras maneiras de transferir o buffer de quadros para a tela:

  • Podemos criar outra tarefa do FreeRTOS (tarefa), responsável por coordenar as transações SPI.
  • Você pode transferir um quadro não em um, mas em várias transações.
  • Você pode usar a transmissão sem bloqueio, na qual iniciamos o envio e, em seguida, continuamos a executar outras operações.
  • Você pode usar qualquer combinação dos métodos acima.

Por enquanto, usaremos a maneira mais simples: a única transação de bloqueio. Quando o DrawFrame é chamado, a transferência para a tela é iniciada e nossa tarefa é pausada até que a transferência seja concluída. Se mais tarde descobrirmos que não podemos alcançar uma boa taxa de quadros com esse método, retornaremos a esse problema.

Ordem de bytes e RGB565


Uma tela típica (por exemplo, o monitor do seu computador) tem uma profundidade de 24 bits (1,6 milhão de cores): 8 bits por vermelho, verde e azul. O pixel é gravado na memória como RRRRRRRRGGGGGGGGGBBBBBBBBB .

O LCD Odroid tem uma profundidade de 16 bits (65 mil cores): 5 bits de vermelho, 6 bits de verde e 5 bits de azul. O pixel é gravado na memória como RRRRRGGGGGGGBBBBB . Este formato é chamado 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 uma macro que cria uma cor no formato RGB565. Passaremos a ele um byte de vermelho, um byte de verde e um byte de azul. Ele pegará os cinco bits mais significativos de vermelho, os seis bits mais significativos de verde e os cinco bits mais significativos de azul. Escolhemos bits altos porque eles contêm mais informações que bits baixos.

No entanto, o ESP32 armazena os dados na ordem Little Endian , ou seja, o byte menos significativo é armazenado no endereço de memória mais baixo.

Por exemplo, o valor de 32 bits [0xDE 0xAD 0xBE 0xEF] será armazenado na memória como [0xEF 0xBE 0xAD 0xDE] . Ao transferir dados para a tela, isso se torna um problema, porque o byte menos significativo será enviado primeiro e o LCD espera receber o byte mais significativo primeiro. Definir

macro SWAP_ENDIAN_16para trocar bytes e usá-lo na macro RGB565 .

Veja como cada uma das três cores primárias é descrita no RGB565 e como elas são armazenadas na memória ESP32, se você não alterar a ordem dos bytes.

Vermelho

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

Verde

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

Azul

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

Demo


Podemos criar uma demonstração simples para assistir ao LCD em ação. No início do quadro, ele esvazia o buffer do quadro para preto e desenha um quadrado de 50x50. Podemos mover o quadrado com uma cruz e mudar sua cor com os botões A , B e Start .

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();
}

Alocamos o buffer de quadros de acordo com o tamanho total da tela: 320 x 240, dois bytes por pixel (cor de 16 bits). Usamos heap_caps_malloc para que seja alocado na memória, que pode ser usada para transações SPI com acesso direto à memória (DMA) . O DMA permite que os periféricos SPI acessem o buffer de quadros sem a necessidade de envolvimento da CPU. Sem o DMA, as transações SPI levam muito mais tempo.

Não realizamos verificações para garantir que a renderização não ocorra fora das bordas da tela.


Rasgando forte é perceptível. Em aplicativos de desktop, a maneira padrão de eliminar lacrimejamento é usar vários buffers. Por exemplo, ao fazer buffer duplo, há dois buffers: frontal e traseiro. Enquanto o buffer frontal é exibido, a gravação é realizada
na parte traseira. Então eles mudam de lugar e o processo se repete.

O ESP32 não possui RAM suficiente com recursos de DMA para armazenar dois buffers de quadro (4 MB de RAM SPI externa, infelizmente, não possui recursos de DMA), portanto, essa opção não é adequada.

O ILI9341 possui um sinal ( TE ) que informa quando o VBLANK acontece, para que possamos gravar no display até que ele seja desenhado. Mas com o Odroid (ou o módulo de exibição) esse sinal não está conectado, portanto, não podemos acessá-lo.

Talvez possamos encontrar um valor decente, mas por enquanto não o faremos, porque agora nossa tarefa é simplesmente exibir os pixels na tela.

Fonte


Todo o código fonte pode ser encontrado aqui .

Referências



All Articles