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 Atela 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 Omonitor 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 GPUSem 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 OESP32 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 MHzA 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 incorretaExistem 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 é divertidoPara 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);
}
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 queflui 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)
{
}
else if (dpadX > ADC_NEGATIVE_LEVEL)
{
}
uint32_t dpadY = adc1_get_raw(DPAD_PIN_Y_AXIS);
if (dpadY > ADC_POSITIVE_LEVEL)
{
}
else if (dpadY > ADC_NEGATIVE_LEVEL)
{
}
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);
}
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;
spiBusConfig.quadhd_io_num = -1;
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:- Damos um sinal baixo para DC
- Enviamos um byte do comando
- Damos um sinal alto para DC
- Envie zero ou mais bytes, dependendo dos requisitos do comando
- 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[] =
{
{
SOFTWARE_RESET,
{},
0
},
{
MEMORY_ACCESS_CONTROL,
{0x20 | 0xC0 | 0x08},
1
},
{
PIXEL_FORMAT_SET,
{0x55},
1
},
{
SLEEP_OUT,
{},
0
},
{
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_SETExistem 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)
{
uint8_t drawWidth[] = { 0, 0, UPPER_BYTE_16(LCD_WIDTH), LOWER_BYTE_16(LCD_WIDTH) };
SendCommandCode(COLUMN_ADDRESS_SET);
SendCommandParameters(drawWidth, ARRAY_COUNT(drawWidth));
uint8_t drawHeight[] = { 0, 0, UPPER_BYTE_16(LCD_HEIGHT), LOWER_BYTE_16(LCD_HEIGHT) };
SendCommandCode(PAGE_ADDRESS_SET);
SendCommandParameters(drawHeight, ARRAY_COUNT(drawHeight));
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. Definirmacro 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.Vermelho11111 | 000000 | 00000? -> 11111000 00000000 -> 00000000 11111000Verde00000 | 111111 | 00000? -> 00000111 11100000 -> 11100000 00000111Azul00000 | 000000 | 11111? -> 00000000 00011111 -> 00011111 00000000Demo
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);
}
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 é realizadana 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