Programmieren eines Spiels für ein eingebettetes Gerät auf ESP32

Teil 0: Motivation


Einführung


Ich suchte nach einem Hobbyprojekt, an dem ich außerhalb meiner Hauptaufgaben arbeiten konnte, um der Situation in der Welt zu entkommen. Ich interessiere mich hauptsächlich für Spieleprogrammierung, aber ich mag auch eingebettete Systeme. Jetzt arbeite ich in einer Spielefirma, aber vorher beschäftigte ich mich hauptsächlich mit Mikrocontrollern. Obwohl ich mich am Ende entschlossen habe, meinen Weg zu ändern und in die Spielebranche einzusteigen, experimentiere ich immer noch gerne mit ihnen. Warum also nicht beide Hobbys kombinieren?

Odroid gehen


Ich hatte Odroid Go herumliegen , was interessant wäre, damit zu spielen. Sein Kern ist ESP32 - ein sehr beliebter Mikrocontroller mit Standard-MK-Funktionalität (SPI, I2C, GPIO, Timer usw.), aber auch mit WiFi und Bluetooth, was ihn für die Erstellung von IoT-Geräten attraktiv macht.

Odroid Go ergänzt das ESP32 mit einer Reihe von Peripheriegeräten und verwandelt es in einen tragbaren Spielautomaten, der Gameboy Color ähnelt: ein LCD-Display, einen Lautsprecher, ein Steuerkreuz, zwei Haupt- und vier Hilfstasten, einen Akku und einen SD-Kartenleser.

Meistens kaufen Leute Odroid Go, um Emulatoren alter 8-Bit-Systeme auszuführen . Wenn dieses Ding in der Lage ist, alte Spiele zu emulieren, wird es auch mit dem Start eines nativen Spiels fertig, das speziell für dieses Spiel entwickelt wurde.


Einschränkungen


Auflösung 320 x 240 Das

Display hat eine Größe von nur 320 x 240, sodass die Menge der Informationen, die gleichzeitig auf dem Bildschirm angezeigt werden, sehr begrenzt ist. Wir müssen sorgfältig überlegen, welches Spiel wir machen und welche Ressourcen wir verwenden sollen.

16-Bit-Farbe Das

Display unterstützt 16-Bit-Farben pro Pixel: 5 Bit für Rot, 6 Bit für Grün und 5 Bit für Blau. Aus offensichtlichen Gründen wird eine solche Schaltung normalerweise als RGB565 bezeichnet. Grün hat ein bisschen mehr Rot und Blau, weil das menschliche Auge besser zwischen Abstufungen von Grün als Blau oder Rot unterscheidet.

16-Bit-Farbe bedeutet, dass wir nur auf 65.000 Farben zugreifen können. Vergleichen Sie dies mit der Standardfarbe 24-Bit (8 Bit pro Farbe), die 16 Millionen Farben liefert .

Fehlende GPU

Ohne eine GPU können wir keine API wie OpenGL verwenden. Heutzutage werden für das Rendern von 2D-Spielen normalerweise dieselben GPUs verwendet wie für 3D-Spiele. Anstelle von Objekten werden Vierecke gezeichnet, denen Bittexturen überlagert werden. Ohne GPU müssen wir jedes Pixel mit einer CPU rasteren, die langsamer, aber einfacher ist.

Bei einer Bildschirmauflösung von 320 x 240 und 16-Bit-Farben beträgt die Gesamtgröße des Bildpuffers 153.600 Byte. Dies bedeutet, dass wir mindestens dreißig Mal pro Sekunde 153.600 Bytes an das Display senden müssen. Dies kann letztendlich zu Problemen führen, daher müssen wir beim Rendern des Bildschirms intelligenter sein. Sie können beispielsweise eine indizierte Farbe in eine Palette konvertieren, sodass Sie für jedes Pixel ein Byte speichern müssen, das als Index für eine 256-Farben-Palette verwendet wird.

4 MB

ESP32 verfügt über 520 KB internen RAM, während Odroid Go weitere 4 MB externen RAM hinzufügt. Dieser gesamte Speicher steht uns jedoch nicht zur Verfügung, da ein Teil vom ESP32-SDK verwendet wird (dazu später mehr). Nach dem Deaktivieren aller möglichen Fremdfunktionen und dem Eingeben meiner Hauptfunktion meldet ESP32, dass wir 4.494.848 Bytes verwenden können. Wenn wir in Zukunft mehr Speicher benötigen, können wir später wieder unnötige Funktionen kürzen.

80-240 MHz Prozessor

Die CPU ist mit drei möglichen Geschwindigkeiten konfiguriert: 80 MHz, 160 MHz und 240 MHz. Selbst ein Maximum von 240 MHz ist weit entfernt von der Leistung von mehr als drei Gigahertz moderner Computer, mit denen wir gewohnt sind zu arbeiten. Wir werden bei 80 MHz beginnen und sehen, wie weit wir gehen können. Wenn das Spiel mit Batteriestrom betrieben werden soll, sollte der Stromverbrauch niedrig sein. Dazu wäre es schön, die Frequenz zu senken.

Schlechtes Debuggen

Es gibt Möglichkeiten, Debugger mit eingebetteten Geräten (JTAG) zu verwenden, aber leider bietet Odroid Go uns nicht die erforderlichen Kontakte, sodass wir den Code im Debugger nicht wie üblich durchgehen können. Dies bedeutet, dass das Debuggen ein schwieriger Prozess sein kann, und wir müssen das Debuggen auf dem Bildschirm (unter Verwendung von Farben und Text) aktiv verwenden und auch Informationen an die Debugging-Konsole ausgeben (die glücklicherweise über USB UART leicht zugänglich ist).

Warum all die Schwierigkeiten?


Warum sollten Sie überhaupt versuchen, ein Spiel für dieses schwache Gerät mit allen oben aufgeführten Einschränkungen zu erstellen und nichts für einen Desktop-PC zu schreiben? Dafür gibt es zwei Gründe:

Einschränkungen fördern die Kreativität.

Wenn Sie mit einem System arbeiten, das über bestimmte Geräte verfügt, von denen jedes seine eigenen Einschränkungen hat, überlegen Sie, wie Sie die Vorteile dieser Einschränkungen am besten nutzen können. So kommen wir Spielentwicklern alter Systeme näher, zum Beispiel Super Nintendo (aber es ist für uns immer noch viel einfacher als für sie).

Low-Level-Entwicklung macht Spaß

Um ein Spiel für ein normales Desktop-System von Grund auf neu zu schreiben, müssen wir mit Standard-Low-Level-Engine-Konzepten arbeiten: Rendering, Physik, Kollisionserkennung. Wenn wir all dies auf einem eingebetteten Gerät implementieren, müssen wir uns auch mit einfachen Computerkonzepten befassen, beispielsweise dem Schreiben eines LCD-Treibers.

Wie niedrig wird die Entwicklung sein?


Wenn es um niedrige Ebenen und das Erstellen eines eigenen Codes geht, müssen Sie irgendwo einen Rahmen zeichnen. Wenn wir versuchen, ein Spiel ohne Bibliotheken für den Desktop zu schreiben, ist der Rand wahrscheinlich ein Betriebssystem oder eine plattformübergreifende API wie SDL. In meinem Projekt werde ich eine Grenze zum Schreiben von Dingen wie SPI-Treibern und Bootloadern ziehen. Mit ihnen viel mehr Qual als Spaß.

Wir werden also das ESP-IDF verwenden, das im Wesentlichen ein SDK für ESP32 ist. Wir können davon ausgehen, dass es uns einige Dienstprogramme zur Verfügung stellt, die das Betriebssystem normalerweise bereitstellt, aber das Betriebssystem funktioniert nicht in ESP32 . Genau genommen verwendet dieser MK FreeRTOS, ein EchtzeitbetriebssystemDies ist jedoch kein echtes Betriebssystem. Dies ist nur ein Planer. Höchstwahrscheinlich werden wir nicht damit interagieren, aber in seinem Kern verwendet ESP-IDF es.

ESP-IDF bietet uns eine API für ESP32-Peripheriegeräte wie SPI, I2C und UART sowie eine C-Laufzeitbibliothek. Wenn wir also so etwas wie printf aufrufen, werden tatsächlich Bytes über UART übertragen, die auf dem seriellen Schnittstellenmonitor angezeigt werden. Es verarbeitet auch den gesamten Startcode, der zur Vorbereitung der Maschine erforderlich ist, bevor der Startpunkt unseres Spiels aufgerufen wird.

In diesem Beitrag werde ich ein Entwicklungsmagazin führen, in dem ich über interessante Punkte spreche, die mir erschienen, und die schwierigsten Aspekte erläutere. Ich habe keinen Plan und werde höchstwahrscheinlich viele Fehler machen. All das erschaffe ich aus Interesse.

Teil 1: System erstellen


Einführung


Bevor wir mit dem Schreiben von Code für Odroid Go beginnen können, müssen wir das ESP32-SDK konfigurieren. Es enthält den Code, der ESP32 startet und unsere Hauptfunktion aufruft, sowie den Peripheriecode (z. B. SPI), den wir beim Schreiben des LCD-Treibers benötigen.

Espressif nennt sein ESP-IDF SDK ; Wir verwenden die neueste stabile Version v4.0 .

Wir können das Repository entweder gemäß den Anweisungen (mit dem rekursiven Flag ) klonen oder einfach die Zip-Datei von der Release-Seite herunterladen.

Unser erstes Ziel ist eine minimale Anwendung im Hello World-Stil, die auf Odroid Go installiert ist und die korrekte Einrichtung der Build-Umgebung beweist.

C oder C ++


ESP-IDF verwendet C99, daher werden wir es auch auswählen. Falls gewünscht, könnten wir C ++ verwenden (es gibt einen C ++ - Compiler in der ESP32-Toolchain), aber im Moment bleiben wir bei C.

Eigentlich mag ich C und seine Einfachheit. Egal wie viel ich Code in C ++ schreibe, ich habe es nie geschafft, den Moment zu erreichen, in dem ich ihn genieße.

Diese Person fasst meine Gedanken ziemlich gut zusammen.

Darüber hinaus können wir bei Bedarf jederzeit zu C ++ wechseln.

Minimales Projekt


IDF verwendet CMake, um das Build-System zu verwalten. Es unterstützt auch Makefile, aber sie sind in Version 4.0 veraltet, daher verwenden wir nur CMake.

Zumindest müssen wir eine CMakeLists.txt Datei mit einer Beschreibung unseres Projektes, einem Hauptordner mit der Quelldatei des Eintrittspunkt in das Spiel, und eine andere CMakeLists.txt Datei innerhalb Haupt , die die Quelldateien auflistet.

CMake muss auf Umgebungsvariablen verweisen, die angeben, wo nach IDF und Toolchain gesucht werden soll. Ich war verärgert, dass ich sie jedes Mal neu installieren musste, wenn ich eine neue Terminalsitzung startete, also schrieb ich das Skript export.sh . Es setzt IDF_PATH und IDF_TOOLS_PATHund ist auch eine IDF-Exportquelle, die andere Umgebungsvariablen festlegt.

Es reicht aus, wenn der Skriptbenutzer die Variablen IDF_PATH und IDF_TOOLS_PATH festlegt .

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 im Stammverzeichnis:

cmake_minimum_required(VERSION 3.5)

set(COMPONENTS "esptool_py main")

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

project(game)

Standardmäßig erstellt das Build-System jede mögliche Komponente in $ ESP_IDF / components , was zu einer längeren Kompilierungszeit führt. Wir möchten einen minimalen Satz von Komponenten kompilieren, um unsere Hauptfunktion aufzurufen, und später bei Bedarf weitere Komponenten verbinden. Dafür ist die Variable COMPONENTS gedacht .

CMakeLists.txt in main :

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

Alles, was er tut - unendlich einmal pro Sekunde zeigt auf dem Monitor die serielle Schnittstelle "Hello World" an. VTaskDelay verwendet FreeRTOS zum Verzögern .

Die Datei main.c ist sehr einfach:

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

Beachten Sie, dass unsere Funktion app_main heißt , nicht main . Die Hauptfunktion durch die IDF für die notwendige Vorbereitung verwendet, und dann erzeugt es eine Aufgabe mit unserer app_main Funktion als Einstiegspunkt.

Eine Aufgabe ist nur ein ausführbarer Block, den FreeRTOS verwalten kann. Obwohl wir uns darüber keine Gedanken machen sollten (oder vielleicht gar nicht), ist es wichtig zu beachten, dass unser Spiel in einem Kern läuft (ESP32 hat zwei Kerne) und mit jeder Iteration der for-Schleife die Ausführung der Task um eine Sekunde verzögert. Während dieser Verzögerung kann der FreeRTOS-Scheduler anderen Code ausführen, der in der Schlange auf die Ausführung wartet (falls vorhanden).

Wir können beide Kerne verwenden, aber jetzt beschränken wir uns auf einen.

Komponenten


Selbst wenn wir die Liste der Komponenten auf das für die Hello World-Anwendung erforderliche Minimum reduzieren ( esptool_py und main ), werden aufgrund der Konfiguration der Abhängigkeitskette einige andere Komponenten erfasst, die wir nicht benötigen. Es sammelt alle diese Komponenten:

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

Viele von ihnen sind ziemlich logisch ( Bootloader , esp32 , freertos ), aber es folgen unnötige Komponenten, da wir keine Netzwerkfunktionen verwenden: esp_eth, esp_wifi, lwip, mbedtls, tcpip_adapter, wpa_supplicant . Leider sind wir immer noch gezwungen, diese Komponenten zusammenzubauen.

Glücklicherweise ist der Linker intelligent genug und fügt nicht verwendete Komponenten nicht in eine vorgefertigte Binärdatei des Spiels ein. Wir können dies mit Komponenten der Größengröße überprüfen .

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

Vor allem beeinflusst libc die Größe der Binärdatei, und das ist in Ordnung.

Projektkonfiguration


Mit IDF können Sie Konfigurationsparameter zur Kompilierungszeit angeben, die während der Montage zum Aktivieren oder Deaktivieren verschiedener Funktionen verwendet werden. Wir müssen Parameter festlegen, die es uns ermöglichen, die zusätzlichen Aspekte von Odroid Go zu nutzen.

Zunächst müssen Sie das Quellenskript von export.sh ausführen, damit CMake auf die erforderlichen Umgebungsvariablen zugreifen kann. Wie bei allen CMake-Projekten müssen wir außerdem einen Assembly-Ordner erstellen und CMake von dort aus aufrufen.

source export.sh
mkdir build
cd build
cmake ..

Wenn Sie make menuconfig ausführen , wird ein Fenster geöffnet, in dem Sie die Projekteinstellungen konfigurieren können.

Erweiterung des Flash-Speichers auf 16 MB


Odroid Go erweitert die Standardkapazität des Flash-Laufwerks auf 16 MB. Sie können diese Funktion aktivieren, indem Sie zu Serial Flasher Config -> Flash Size -> 16MB gehen .

Schalten Sie den externen SPI-RAM ein


Wir haben auch Zugriff auf weitere 4 MB externen RAM, der über SPI verbunden ist. Sie können es aktivieren, indem Sie zu Komponentenkonfiguration -> ESP32-spezifisch -> Unterstützung für externen, mit SPI verbundenen RAM gehen und die Leertaste drücken, um es zu aktivieren. Wir möchten auch in der Lage sein, Speicher aus dem SPI-RAM explizit zuzuweisen. Dies kann aktiviert werden, indem Sie zu SPI RAM config -> SPI RAM-Zugriffsmethode -> RAM mit heap_caps_malloc zuweisbar machen .

Verringern Sie die Frequenz


ESP32 funktioniert standardmäßig mit einer Frequenz von 160 MHz. Verringern Sie die Frequenz jedoch auf 80 MHz, um zu sehen, wie weit Sie mit der niedrigsten Taktfrequenz gehen können. Wir möchten, dass das Spiel mit Batteriestrom arbeitet, und durch Verringern der Frequenz wird Strom gespart. Sie können dies ändern, indem Sie zu Komponentenkonfiguration -> ESP32-spezifisch -> CPU-Frequenz -> 80 MHz gehen .

Wenn Sie Speichern auswählen , wird die Datei sdkconfig im Stammverzeichnis des Projektordners gespeichert . Wir können diese Datei in git schreiben, aber sie enthält viele Parameter, die für uns nicht wichtig sind. Bisher sind wir mit den Standardparametern zufrieden, mit Ausnahme derjenigen, die wir gerade geändert haben.

Sie können stattdessen die Datei sdkconfig.defaults erstellenwelches die oben geänderten Werte enthält. Alles andere wird standardmäßig konfiguriert. Während des Builds liest die IDF sdkconfig.defaults , überschreibt die von uns festgelegten Werte und verwendet den Standard für alle anderen Parameter.

Jetzt sieht sdkconfig.defaults folgendermaßen aus:

# 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

Im Allgemeinen sieht die ursprüngliche Struktur des Spiels folgendermaßen aus:

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

Bauen und flashen


Der Montage- und Firmware-Prozess selbst ist recht einfach.

Wir führen make to compile aus (für paralleles Erstellen fügen Sie -j4 oder -j8 hinzu ), machen Flash , um das Bild in Odroid Go zu schreiben, und machen Monitor , um die Ausgabe der printf- Anweisungen anzuzeigen .

make
make flash
make monitor

Wir können sie auch in einer Zeile ausführen.

make flash monitor

Das Ergebnis ist nicht besonders beeindruckend, wird aber zur Grundlage für den Rest des Projekts.


Verweise



Teil 2: Eingabe


Einführung


Wir müssen in der Lage sein, die vom Spieler gedrückten Tasten und das Kreuz auf Odroid Go zu lesen.

Tasten



GPIO


ODROID Go verfügt über sechs Tasten: A , B , Select , Start - , Menü und Lautstärke .

Jede der Tasten ist mit einem separaten GPIO-Pin (General Purpose IO) verbunden . GPIO-Pins können als Eingänge (zum Lesen) oder als Ausgänge (wir schreiben darauf) verwendet werden. Bei Schaltflächen müssen wir lesen.

Zuerst müssen Sie die Kontakte als Eingänge konfigurieren, danach können wir ihren Status lesen. Die Kontakte im Inneren haben eine von zwei Spannungen (3,3 V oder 0 V), aber wenn sie mit der IDF-Funktion gelesen werden, werden sie in ganzzahlige Werte umgewandelt.

Initialisierung


Elemente, die im Diagramm als SW markiert sind, sind die physischen Tasten selbst. Wenn nicht gedrückt, werden die ESP32-Kontakte ( IO13 , IO0 usw.) an 3,3 V angeschlossen. d.h. 3,3 V bedeutet, dass die Taste nicht gedrückt wird . Die Logik hier ist das Gegenteil von dem, was erwartet wird.

IO0 und IO39 haben physikalische Widerstände auf der Platine. Wenn die Taste nicht gedrückt wird, zieht der Widerstand die Kontakte auf eine hohe Spannung. Wenn die Taste gedrückt wird, geht der durch die Kontakte

fließende Strom stattdessen nach Masse, sodass die Spannung 0 von den Kontakten abgelesen wird . IO13 , IO27 , IO32 und IO33haben keine Widerstände, da der Kontakt am ESP32 interne Widerstände hat, die wir für den Pull-up-Modus konfiguriert haben.

In diesem Wissen können wir sechs Schaltflächen mithilfe der GPIO-API konfigurieren.

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

Die am Anfang des Codes angegebenen Konstanten entsprechen jedem der Schaltungskontakte. Wir verwenden die Struktur gpio_config_t , um jede der sechs Schaltflächen als Pull-up-Eingabe zu konfigurieren. Im Fall von IO13 , IO27 , IO32 und IO33 müssen wir IDF bitten, die Pull-up-Widerstände dieser Kontakte einzuschalten . Für IO0 und IO39 müssen wir dies nicht tun, da sie physikalische Widerstände haben, aber wir werden es trotzdem tun, um die Konfiguration schön zu machen.

ESP_ERROR_CHECK ist ein Hilfsmakro von IDF, das automatisch das Ergebnis aller Funktionen überprüft, die esp_err_t zurückgeben(die meisten IDF) und behaupten, dass das Ergebnis nicht gleich ESP_OK ist . Dieses Makro ist praktisch für eine Funktion zu verwenden, wenn ihr Fehler kritisch ist und es keinen Sinn macht, die Ausführung fortzusetzen. In diesem Spiel ist ein Spiel ohne Eingabe kein Spiel, daher ist diese Aussage wahr. Wir werden dieses Makro oft verwenden.

Tasten lesen


Also haben wir alle Kontakte konfiguriert und können endlich die Werte lesen.

Die Zifferntasten werden von der Funktion gpio_get_level gelesen , aber wir müssen die empfangenen Werte invertieren, da die Kontakte hochgezogen werden, dh ein hohes Signal bedeutet tatsächlich "nicht gedrückt" und ein niedriges Signal bedeutet "gedrückt". Beim Invertieren bleibt die übliche Logik erhalten: 1 bedeutet "gedrückt", 0 - "nicht gedrückt".

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

Kreuzstück (D-Pad)



ADC


Das Anschließen des Kreuzes unterscheidet sich vom Anschließen der Tasten. Die Auf- und Ab-Tasten sind mit einem Pin eines Analog-Digital-Wandlers (ADC) verbunden , und die linke und rechte Taste sind mit einem anderen ADC-Pin verbunden.

Im Gegensatz zu den digitalen GPIO-Kontakten, aus denen wir einen von zwei Zuständen (hoch oder niedrig) lesen konnten, wandelt der ADC die kontinuierliche analoge Spannung (z. B. von 0 V bis 3,3 V) in einen diskreten numerischen Wert (z. B. von 0 bis 4095) um )

Ich nehme an, Odroid Go-Designer haben es getan, um GPIO-Pins einzusparen (Sie benötigen nur zwei analoge Pins anstelle von vier digitalen Pins). Wie auch immer, dies erschwert die Konfiguration und das Lesen dieser Kontakte geringfügig.

Aufbau


Der Kontakt IO35 ist mit der Y-Achse der Spinne verbunden , und der Kontakt IO34 ist mit der X-Achse der Spinne verbunden . Wir sehen, dass die Gelenke des Kreuzes etwas komplizierter sind als die Zifferntasten. Jede Achse verfügt über zwei Schalter ( SW1 und SW2 für die Y-Achse, SW3 und SW4 für die X-Achse), die jeweils mit einem Satz von Widerständen ( R2 , R3 , R4 , R5 ) verbunden sind.

Wenn weder "oben" noch "unten" gedrückt wird, wird der IO35-Stift über R3 auf den Boden gezogen , und wir betrachten den Wert 0 V. Wenn weder "links" noch "rechts" gedrückt wird, wenden Sie sich an IO34zieht durch R5 auf den Boden und wir zählen den Wert bis 0 V.

Wenn SW1 gedrückt wird ("up") , dann zählen wir mit IO35 3,3 V. Wenn SW2 gedrückt wird ("down") , dann zählen wir mit IO35 ungefähr 1, 65 V, da die Hälfte der Spannung am Widerstand R2 abfällt .

Wenn SW3 ("links") gedrückt wird , zählen wir mit IO34 3,3 V. Wenn SW4 ("rechts") gedrückt wird , zählen wir mit IO34 auch ungefähr 1,65 V, da die Hälfte der Spannung am Widerstand R4 abfällt .

Beide Fälle sind Beispiele für Spannungsteiler.. Wenn zwei Widerstände im Spannungsteiler den gleichen Widerstand haben (in unserem Fall - 100K), beträgt der Spannungsabfall die Hälfte der Eingangsspannung.

Wenn wir das wissen, können wir das Kreuzstück konfigurieren:

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

Wir haben den ADC auf 12 Bit Breite eingestellt, so dass 0 V als 0 und 3,3 V als 4095 (2 ^ 12) gelesen wurden. Die Dämpfung meldet, dass wir das Signal nicht dämpfen müssen, damit wir den vollen Spannungsbereich von 0 V bis 3,3 V erhalten.

Bei 12 Bit können wir erwarten, dass, wenn nichts gedrückt wird, 0 gelesen wird, wenn nach oben und links gedrückt wird - 4096 und Ungefähr 2048 werden gelesen, wenn nach unten und rechts gedrückt wird (weil Widerstände die Spannung um die Hälfte reduzieren).

Kreuzlesen


Das Kreuz zu lesen ist schwieriger als Schaltflächen, da wir die Rohwerte (von 0 bis 4095) lesen und interpretieren müssen.

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 und ADC_NEGATIVE_LEVEL sind Werte mit einem Rand, um sicherzustellen, dass wir immer die richtigen Werte lesen.

Umfrage


Es gibt zwei Möglichkeiten, um Schaltflächenwerte abzurufen: Abfragen oder Interrupts. Wir können Eingabeverarbeitungsfunktionen erstellen und IDF bitten, diese Funktionen aufzurufen, wenn Tasten gedrückt werden, oder den Status der Tasten manuell abfragen, wenn wir sie benötigen. Interrupt-gesteuertes Verhalten macht die Dinge komplizierter und schwieriger zu verstehen. Außerdem bemühe ich mich immer, alles so einfach wie möglich zu gestalten. Bei Bedarf können wir später Interrupts hinzufügen.

Wir werden eine Struktur erstellen, die den Status von sechs Schaltflächen und vier Richtungen des Kreuzes speichert. Wir können eine Struktur mit 10 booleschen oder 10 int oder 10 vorzeichenlosen int erstellen. Stattdessen erstellen wir die Struktur jedoch mithilfe von Bitfeldern .

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;

Bei der Programmierung für Desktop-Systeme werden Bitfelder normalerweise vermieden, da sie schlecht auf verschiedene Computer portiert sind. Wir programmieren jedoch für einen bestimmten Computer und müssen uns darüber keine Gedanken machen.

Anstelle von Feldern könnte eine Struktur mit 10 Booleschen Werten mit einer Gesamtgröße von 10 Bytes verwendet werden. Eine weitere Option ist uint16_t mit Bitverschiebungs- und Bitmaskierungsmakros, mit denen einzelne Bits gesetzt, gelöscht und überprüft werden können. Es wird funktionieren, aber es wird nicht sehr schön sein.

Ein einfaches Bitfeld ermöglicht es uns, beide Ansätze zu nutzen: zwei Datenbytes und benannte Felder.

Demo


Jetzt können wir den Status der Eingaben in der Hauptschleife abfragen und das Ergebnis anzeigen.

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

Die Funktion printf verwendet \ r , um die vorherige Zeile zu überschreiben, anstatt eine neue hinzuzufügen. fflush wird benötigt, um eine Zeile anzuzeigen, da sie im Normalzustand durch das Zeilenumbruchzeichen \ n zurückgesetzt wird .


Verweise



Teil 3: Anzeige


Einführung


Wir müssen in der Lage sein, Pixel auf dem Odroid Go LCD zu rendern.

Das Anzeigen von Farben auf dem Bildschirm ist schwieriger als das Lesen des Eingabestatus, da das LCD über ein Gehirn verfügt. Der Bildschirm wird von ILI9341 gesteuert - einem sehr beliebten TFT-LCD-Treiber auf einem einzelnen Chip.

Mit anderen Worten, wir sprechen mit ILI9341, das auf unsere Befehle reagiert, indem es die Pixel auf dem LCD steuert. Wenn ich in diesem Teil "Bildschirm" oder "Anzeige" sage, meine ich eigentlich ILI9341. Wir haben es mit ILI9341 zu tun. Es steuert das LCD.

SPI


Das LCD ist über SPI (Serial Peripheral Interface) mit dem ESP32 verbunden .

SPI ist ein Standardprotokoll zum Datenaustausch zwischen Geräten auf einer Leiterplatte. Es hat vier Signale: MOSI (Master Out Slave In) , MISO (Master In Slave Out) , SCK (Clock) und CS (Chip Select) .

Ein einzelnes Master-Gerät auf dem Bus koordiniert die Datenübertragung durch Steuern von SCK und CS. Es können mehrere Geräte an einem Bus vorhanden sein, von denen jedes seine eigenen CS-Signale hat. Wenn das CS-Signal dieses Geräts aktiviert ist, kann es Daten senden und empfangen.

Der ESP32 ist der SPI-Master (Master) und das LCD ist der Slave-SPI-Slave. Wir müssen den SPI-Bus mit den erforderlichen Parametern konfigurieren und dem Bus eine LCD-Anzeige hinzufügen, indem wir die entsprechenden Kontakte konfigurieren.



Die Namen VSPI.XXXX sind nur Beschriftungen für die Kontakte im Diagramm, aber wir können die Kontakte selbst durchgehen, indem wir uns die Teile der LCD- und ESP32-Diagramme ansehen.

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

Wir haben auch IO14 , den GPIO-Pin, mit dem die Hintergrundbeleuchtung eingeschaltet wird, und IO21 , der mit dem DC- Pin des LCD verbunden ist. Dieser Kontakt steuert die Art der Informationen, die wir an das Display senden.

Konfigurieren Sie zunächst den SPI-Bus.

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

Wir konfigurieren den Bus mit spi_bus_config_t . Es ist notwendig, die von uns verwendeten Kontakte und die maximale Größe einer Datenübertragung mitzuteilen.

Im Moment führen wir eine SPI-Übertragung für alle Bildpufferdaten durch, die der Breite des LCD (in Pixel) mal seiner Höhe (in Pixel) mal der Anzahl der Bytes pro Pixel entspricht.

Die Breite beträgt 320, die Höhe 240 und die Farbtiefe 2 Byte (die Anzeige erwartet, dass die Pixelfarben 16 Bit tief sind).

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

Nach der Initialisierung des Busses müssen wir dem Bus ein LCD-Gerät hinzufügen, damit wir mit ihm sprechen können.

  • clock_speed_hz — - , SPI 40 , . 80 , .
  • spics_io_num — CS, IDF CS, ( SD- SPI).
  • queue_size — 1, ( ).
  • Flags - Der IDF-SPI-Treiber fügt normalerweise leere Bits in die Übertragung ein, um Zeitprobleme beim Lesen vom SPI-Gerät zu vermeiden. Wir führen jedoch eine Einwegübertragung durch (wir lesen nicht vom Display). SPI_DEVICE_NO_DUMMY meldet, dass wir diese Einwegübertragung bestätigen und keine leeren Bits einfügen müssen.


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

Wir müssen auch die DC- und Hintergrundbeleuchtungsstifte als GPIO-Stifte festlegen . Nach dem Umschalten von DC ist die Hintergrundbeleuchtung ständig eingeschaltet.

Teams


Die Kommunikation mit dem LCD erfolgt in Form von Befehlen. Zuerst übergeben wir ein Byte, das den Befehl angibt, den wir senden möchten, und dann übergeben wir die Befehlsparameter (falls vorhanden). Die Anzeige versteht, dass das Byte ein Befehl ist, wenn das DC- Signal niedrig ist. Wenn das Gleichstromsignal hoch ist, werden die empfangenen Daten als Parameter des zuvor gesendeten Befehls betrachtet.

Im Allgemeinen sieht der Stream folgendermaßen aus:

  1. Wir geben DC ein niedriges Signal
  2. Wir senden ein Byte des Befehls
  3. Wir geben DC ein hohes Signal
  4. Senden Sie je nach den Anforderungen des Befehls null oder mehr Bytes
  5. Wiederholen Sie die Schritte 1 bis 4

Hier ist unser bester Freund die ILI9341-Spezifikation . Es listet alle möglichen Befehle, ihre Parameter und deren Verwendung auf.


Ein Beispiel für einen Befehl ohne Parameter ist Anzeige EIN . Das Befehlsbyte ist 0x29 , es sind jedoch keine Parameter angegeben.


Ein Beispiel für einen Befehl mit Parametern ist der Spaltenadressensatz . Das Befehlsbyte ist 0x2A , es werden jedoch vier erforderliche Parameter angegeben. Um den Befehl zu verwenden, müssen Sie ein niedriges Signal an DC senden, 0x2A senden, ein hohes Signal an DC senden und dann die Bytes von vier Parametern übertragen.

Die Befehlscodes selbst werden in der Aufzählung angegeben.

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;

Stattdessen könnten wir ein Makro verwenden ( #define SOFTWARE_RESET (0x01u) ), aber sie haben keine Symbole im Debugger und sie haben keinen Bereich. Es wäre auch möglich, statische Ganzzahlkonstanten zu verwenden, wie wir es bei GPIO-Kontakten getan haben, aber dank enum können wir auf einen Blick verstehen, welche Daten an eine Funktion oder ein Mitglied der Struktur übergeben werden: Sie sind vom Typ CommandCode . Andernfalls könnte es roh uint8_t sein , das dem Programmierer, der den Code liest , nichts sagt .

Starten


Während der Initialisierung können wir verschiedene Befehle übergeben, um etwas zeichnen zu können. Jeder Befehl hat ein Befehlsbyte, das wir Befehlscode nennen werden .

Wir definieren eine Struktur zum Speichern des Startbefehls, damit Sie deren Array angeben können.

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

  • Code ist der Befehlscode.
  • parameters ist ein Array von Befehlsparametern (falls vorhanden). Dies ist ein statisches Array der Größe 15, da dies die maximale Anzahl von Parametern ist, die wir benötigen. Aufgrund der statischen Natur des Arrays müssen wir uns nicht jedes Mal darum kümmern, jedem Befehl ein dynamisches Array zuzuweisen.
  • Länge ist die Anzahl der Parameter im Parameterarray .

Mit dieser Struktur können wir eine Liste von Startbefehlen angeben.

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

Befehle ohne Parameter, z. B. SOFTWARE_RESET , setzen die Initialisierungsliste auf Parameter als leer ( dh mit einer Null) und Länge auf 0. Befehle mit Parametern füllen die Parameter aus und geben die Länge an. Es wäre großartig, wenn wir die Länge automatisch einstellen und keine Zahlen schreiben könnten (falls wir einen Fehler machen oder die Parameter ändern), aber ich denke nicht, dass es die Mühe wert ist.

Der Zweck der meisten Teams ergibt sich aus dem Namen, mit Ausnahme von zwei.

MEMORY_ACCESS_CONTROL

  • Querformat : Standardmäßig verwendet das Display Hochformat (240 x 320), wir möchten jedoch Querformat (320 x 240) verwenden.
  • Top-Left Origin: (0,0) , ( ) .
  • BGR Panel: , BGR. , , , , .

PIXEL_FORMAT_SET

  • 16 bits per pixel: 16- .

Es gibt viele andere Befehle, die beim Start gesendet werden können, um verschiedene Aspekte wie Gamma zu steuern. Die erforderlichen Parameter sind in der Spezifikation des LCD selbst (und nicht des ILI9341-Controllers) beschrieben, auf die wir keinen Zugriff haben. Wenn wir diese Befehle nicht übertragen, werden die Standardanzeigeeinstellungen verwendet, die perfekt zu uns passen.

Nachdem wir eine Reihe von Startbefehlen vorbereitet haben, können wir beginnen, sie auf die Anzeige zu übertragen.

Zunächst benötigen wir eine Funktion, die ein Befehlsbyte an die Anzeige sendet. Vergessen Sie nicht, dass sich das Senden von Befehlen vom Senden von Parametern unterscheidet, da wir ein niedriges Signal an DC senden müssen .

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

Die IDF hat eine spi_transaction_t- Struktur , die wir füllen , wenn wir etwas über den SPI-Bus übertragen möchten. Wir wissen, wie viele Bits die Nutzlast ist und übertragen die Last selbst.

Wir können entweder einen Zeiger auf die Nutzdaten übergeben oder die interne Struktur tx_data verwenden, die nur vier Byte groß ist, aber dem Treiber den Zugriff auf externen Speicher erspart. Wenn wir tx_data verwenden , müssen wir das Flag SPI_TRANS_USE_TXDATA setzen .

Vor dem Übertragen von Daten senden wir ein niedriges Signal an den DC , was darauf hinweist, dass dies ein Befehlscode ist.

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

Das Übergeben von Parametern ähnelt dem Senden eines Befehls. Nur dieses Mal verwenden wir unseren eigenen Puffer ( Daten ) und senden ein hohes Signal an DC , um der Anzeige mitzuteilen, dass die Parameter übertragen werden. Außerdem setzen wir das SPI_TRANS_USE_TXDATA- Flag nicht, da wir unseren eigenen Puffer übergeben.

Dann können Sie alle Startbefehle senden.

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

Wir durchlaufen iterativ das Array der Startbefehle, indem wir zuerst den Befehlscode und dann die Parameter (falls vorhanden) übergeben.

Rahmenzeichnung


Nach dem Initialisieren der Anzeige können Sie mit dem Zeichnen beginnen.

#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 kann einzelne Teile des Bildschirms neu zeichnen. Dies kann in Zukunft nützlich sein, wenn wir einen Rückgang der Bildrate feststellen. In diesem Fall ist es möglich, nur die geänderten Teile des Bildschirms zu aktualisieren. Im Moment zeichnen wir jedoch einfach den gesamten Bildschirm erneut.

Zum Rendern eines Frames muss ein Renderfenster festgelegt werden. Senden Sie dazu den Befehl COLUMN_ADDRESS_SET mit der Fensterbreite und den Befehl PAGE_ADDRESS_SET mit der Fensterhöhe. Jeder der Befehle benötigt vier Bytes des Parameters, der das Fenster beschreibt, in dem das Rendering durchgeführt wird.

UPPER_BYTE_16 und LOWER_BYTE_16- Dies sind Hilfsmakros zum Extrahieren der hohen und niedrigen Bytes aus einem 16-Bit-Wert. Die Parameter dieser Befehle erfordern, dass wir den 16-Bit-Wert in zwei 8-Bit-Werte aufteilen, weshalb wir dies tun.

Das Rendern wird durch den Befehl MEMORY_WRITE initiiert und alle 153.600 Bytes des Bildpuffers gleichzeitig an die Anzeige gesendet .

Es gibt andere Möglichkeiten, den Bildspeicher auf die Anzeige zu übertragen:

  • Wir können eine weitere FreeRTOS-Aufgabe (Aufgabe) erstellen, die für die Koordination der SPI-Transaktionen verantwortlich ist.
  • Sie können einen Frame nicht in einer, sondern in mehreren Transaktionen übertragen.
  • Sie können eine nicht blockierende Übertragung verwenden, bei der wir das Senden initiieren und dann weitere Vorgänge ausführen.
  • Sie können eine beliebige Kombination der oben genannten Methoden verwenden.

Im Moment verwenden wir den einfachsten Weg: die einzige blockierende Transaktion. Wenn DrawFrame aufgerufen wird, wird die Übertragung zur Anzeige initiiert und unsere Aufgabe wird angehalten, bis die Übertragung abgeschlossen ist. Wenn wir später feststellen, dass wir mit dieser Methode keine gute Bildrate erzielen können, werden wir auf dieses Problem zurückkommen.

RGB565 und Bytereihenfolge


Ein typisches Display (z. B. der Monitor Ihres Computers) hat eine Bittiefe von 24 Bit (1,6 Millionen Farben): 8 Bit pro Rot, Grün und Blau. Das Pixel wird als RRRRRRRRGGGGGGGGBGBBBBBBBBB in den Speicher geschrieben .

Das Odroid LCD hat eine Bittiefe von 16 Bit (65.000 Farben): 5 Bit Rot, 6 Bit Grün und 5 Bit Blau. Das Pixel wird als RRRRRGGGGGGGBBBBB in den Speicher geschrieben . Dieses Format heißt RGB565 .

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

Definieren Sie ein Makro, das eine Farbe im RGB565-Format erstellt. Wir werden ihm ein Byte Rot, ein Byte Grün und ein Byte Blau übergeben. Er wird die fünf wichtigsten roten Teile, die sechs wichtigsten grünen und die fünf wichtigsten blauen Teile nehmen. Wir haben hohe Bits gewählt, weil sie mehr Informationen enthalten als niedrige Bits.

Der ESP32 speichert die Daten jedoch in Little Endian- Reihenfolge , d. H. Das niedrigstwertige Byte wird in der unteren Speicheradresse gespeichert.

Beispielsweise wird der 32-Bit-Wert [0xDE 0xAD 0xBE 0xEF] als [0xEF 0xBE 0xAD 0xDE] im Speicher gespeichert . Beim Übertragen von Daten zur Anzeige wird dies zu einem Problem, da das niedrigstwertige Byte zuerst gesendet wird und das LCD erwartet, zuerst das höchstwertige Byte zu empfangen. Setzen Sie das

Makro SWAP_ENDIAN_16um Bytes auszutauschen und im RGB565- Makro zu verwenden .

Hier erfahren Sie, wie jede der drei Primärfarben in RGB565 beschrieben wird und wie sie im ESP32-Speicher gespeichert werden, wenn Sie die Bytereihenfolge nicht ändern.

Rot

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

Grün

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

Blau

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

Demo


Wir können eine einfache Demo erstellen, um das LCD in Aktion zu sehen. Zu Beginn des Frames wird der Frame-Puffer auf Schwarz geleert und ein 50x50-Quadrat gezeichnet. Wir können das Quadrat mit einem Kreuz verschieben und seine Farbe mit den Schaltflächen A , B und Start ändern .

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

Wir ordnen den Bildpuffer entsprechend der vollen Größe der Anzeige zu: 320 x 240, zwei Bytes pro Pixel (16-Bit-Farbe). Wir verwenden heap_caps_malloc, damit es im Speicher zugewiesen wird, der für SPI-Transaktionen mit Direct Memory Access (DMA) verwendet werden kann . Mit DMA können SPI-Peripheriegeräte ohne CPU-Beteiligung auf den Frame-Puffer zugreifen. Ohne DMA dauern SPI-Transaktionen viel länger.

Wir führen keine Überprüfungen durch, um sicherzustellen, dass das Rendern nicht außerhalb der Bildschirmränder erfolgt.


Starkes Reißen ist spürbar. In Desktop-Anwendungen werden standardmäßig mehrere Puffer verwendet, um ein Zerreißen zu vermeiden. Bei doppelter Pufferung gibt es beispielsweise zwei Puffer: vordere und hintere Puffer. Während der vordere Puffer angezeigt wird, erfolgt die Aufnahme
im hinteren Bereich. Dann wechseln sie die Plätze und der Vorgang wiederholt sich.

ESP32 verfügt nicht über genügend RAM mit DMA-Funktionen, um zwei Frame-Puffer zu speichern (4 MB externer SPI-RAM verfügen leider nicht über DMA-Funktionen), daher ist diese Option nicht geeignet.

ILI9341 hat ein Signal ( TE ), das Ihnen sagt, wann VBLANK auftritt , damit wir auf das Display schreiben können, bis es gezeichnet wird. Bei Odroid (oder dem Anzeigemodul) ist dieses Signal jedoch nicht angeschlossen, sodass wir nicht darauf zugreifen können.

Vielleicht könnten wir einen anständigen Wert finden, aber im Moment werden wir es nicht tun, denn jetzt besteht unsere Aufgabe darin, einfach die Pixel auf dem Bildschirm anzuzeigen.

Quelle


Den gesamten Quellcode finden Sie hier .

Verweise



All Articles