[Перевод] Программирование игры для embedded-устройства на ESP32

Часть 0: мотивация


Введение


Я искал для себя хобби-проект, над которым можно было работать вне моих основных задач, чтобы отвлечься от ситуации в мире. По большей мере меня интересует программирование игр, но также мне нравятся embedded-системы. Сейчас я работаю в игровой компании, но раньше в основном занимался микроконтроллерами. Хотя в конце концов я решил сменить стезю и перейти в индустрию игр, мне по-прежнему нравится с ними экспериментировать. Так почему бы не совместить оба увлечения?

Odroid Go


У меня завалялся Odroid Go, с которым было бы интересно поиграться. Его ядром является ESP32 — очень популярный микроконтроллер со стандартной функциональностью МК (SPI, I2C, GPIO, таймеры и т.д.), но также имеющий WiFi и Bluetooth, что делает его привлекательным для создания IoT-устройств.

Odroid Go дополняет ESP32 кучей периферии, позволяя превратить его в портативную игровую машину, напоминающую Gameboy Color: ЖК-дисплей, динамик, крестовина управления, две основные и четыре вспомогательные кнопки, аккумулятор и устройство чтения SD-карт.

В основном люди покупают Odroid Go для запуска эмуляторов старых 8-битных систем. Если эта штука способна эмулировать старые игры, то справится и с запуском нативной игры, предназначенной специально для неё.

44b80042e94aa2f2da9da2d2296461ad.jpg


Ограничения


Разрешение 320x240

Дисплей имеет размер всего 320x240, поэтому мы очень ограничены в объёме одновременно отображаемой на экране информации. Нам нужно тщательно продумать, какую игру мы будем делать и какие ресурсы использовать.

16-битный цвет

Дисплей поддерживает 16-битный цвет на пиксель: 5 бит на красный, 6 бит на зелёный и 5 на синий. Такая схема по очевидным причинам обычно называется RGB565. Зелёный цвет получил на один бит больше красного и синего, потому что человеческий глаз лучше различает градации зелёного, чем синего или красного.

16-битный цвет означает, что мы имеем доступ всего к 65 тысячам цветов. Сравните это со стандартным 24-битным цветом (по 8 бит на цвет), обеспечивающим 16 миллионов цветов.

Отсутствие GPU

Без GPU мы не сможем использовать API наподобие OpenGL. Сегодня для отрисовки 2D-игр обычно используются те же GPU, что и для 3D-игр. Просто вместо объектов отрисовываются четырёхугольники, на которые накладываются битовые текстуры. Без GPU нам придётся растеризировать каждый пиксель при помощи CPU, что медленнее, но проще.

При разрешении экрана 320x240 и 16-битном цвете общий размер буфера кадров составляет 153 600 байт. Это значит, что как минимум тридцать раз в секунду нам нужно будет передавать на дисплей 153 600 байт. В конечном итоге это может вызвать проблемы, поэтому при отрисовке экрана нам нужно поступать умнее. Например, можно преобразовать индексированный цвет в палитру, чтобы для каждого пикселя нужно было хранить один байт, который будет использоваться как индекс палитры из 256 цветов.

Память 4 МБ

ESP32 имеет 520 КБ встроенного ОЗУ, а Odroid Go добавляет ещё 4 МБ внешнего ОЗУ. Но не вся эта память доступна нам, потому что часть используется ESP32 SDK (подробнее об этом позже). После отключения всех возможных посторонних функций и ввода моей основной функции ESP32 сообщает, что мы можем использовать 4 494 848 байт. Если в дальнейшем нам понадобится больше памяти, то позже мы можем вернуться к урезанию ненужных функций.

Процессор 80-240 МГц

CPU конфигурируется на три возможные скорости: 80 МГц, 160 МГц и 240 МГц. Даже максимум в 240 МГц далёк от мощностей в три с лишним гигагерца современных компьютеров, с которыми мы привыкли работать. Мы начнём с 80 МГц и посмотрим, как далеко сможем зайти. Если мы хотим, чтобы игра работала от аккумулятора, то энергопотребление должно быть низким. Для этого хорошо было бы понизить частоту.

Плохая отладка

Существуют способы использования отладчиков с embedded-устройствами (JTAG), но, к сожалению, Odroid Go не предоставляет нам нужных контактов, поэтому мы не можем пошагово выполнять код в отладчике, как это обычно бывает. Это означает, что отладка может оказаться сложным процессом, и нам придётся активно пользоваться экранной отладкой (при помощи цветов и текста), а также выводить информацию в консоль отладки (которая, к счастью, легко доступна через USB UART).

Зачем вообще такие хлопоты?


Зачем вообще пытаться создать игру для этого слабенького устройства со всеми перечисленными ограничениями, а просто не написать что-нибудь для настольного PC? На то есть две причины:

Ограничения стимулируют творчество

Когда работаешь с системой, имеющей определённый набор оборудования, каждое из которых обладает собственными ограничениями, это заставляет продумывать, как лучше использовать преимущества этих ограничений. Так мы приближаемся к разработчикам игр старых систем, например, Super Nintendo (но нам всё равно гораздо проще, чем им).

Низкоуровневая разработка — это весело

Для написания игры с нуля для обычной настольной системы нам придётся работать со стандартными низкоуровневыми концепциями движков: рендерингом, физикой, распознаванием коллизий. Но при реализации всего этого на embedded-устройстве нам приходится иметь дело и с низкоуровневыми концепциями компьютеров, например, с написанием драйвера ЖК-дисплея.

Насколько разработка будет низкоуровневой?


Когда дело доходит до низкого уровня и создания собственного кода, приходится где-то прочертить границу. Если мы пытаемся написать игру без библиотек для десктопа, то границей скорее всего будет операционная система или кроссплатформенный API наподобие SDL. В своём проекте я прочерчу границу на написании таких вещей, как драйверы SPI и bootloaders. С ними гораздо больше мук, чем веселья.

Итак, мы будем использовать ESP-IDF, который по сути является SDK для ESP32. Можно считать, что он предоставляет нам некоторые утилиты, которые обычно даёт операционная система, но в ESP32 не работает операционная система. Строго говоря, этот МК использует FreeRTOS, которая является операционной системой реального времени, но это не настоящая ОС. Это всего лишь планировщик. Скорее всего, мы не будем с ним взаимодействовать, но в своём ядре ESP-IDF применяет его.

ESP-IDF предоставляет нам API к такой периферии ESP32, как SPI, I2C и UART, а также библиотеку среды выполнения C, поэтому когда мы вызываем что-нибудь типа printf, она на самом деле передаёт байты по UART для отображения на мониторе последовательного интерфейса. Также она обрабатывает весь код запуска, необходимый для подготовки машины, прежде чем она вызовет точку запуска нашей игры.

В этом посте я буду вести журнал разработки, в котором расскажу о показавшихся мне интересными моментах и объясню наиболее трудные аспекты. У меня нет плана и я, скорее всего, буду делать много ошибок. Всё это я создаю из интереса.

Часть 1: система сборки


Введение


Прежде чем мы сможем начать писать код для Odroid Go, нам нужно настроить ESP32 SDK. Он содержит код, запускающий ESP32 и вызывающий нашу основную функцию, а также код периферии (например, SPI), который потребуется нам, когда мы будем писать драйвер ЖК-дисплея.

Espressif называет свой SDK ESP-IDF; мы используем последнюю стабильную версию v4.0.

Мы можем или клонировать репозиторий по их инструкциям (с флагом recursive), или просто скачать zip со страницы релизов.

Наша первая цель — минимальное приложение в духе Hello World, установленное на Odroid Go, которое докажет правильность настройки среды сборки.

C или C++


ESP-IDF использует C99, поэтому мы тоже выберем его. При желании мы могли бы использовать C++ (в тулчейне ESP32 есть компилятор C++), но пока будем придерживаться C.

На самом деле мне нравится C и его простота. Сколько бы я ни писал кода на C++, мне никогда не удавалось достичь момента наслаждения им.

Этот человек довольно неплохо резюмирует мои размышления.

Кроме того, при необходимости мы в любой момент сможем перейти на C++.

Минимальный проект


Для управления системой сборки IDF использует CMake. Также он поддерживает Makefile, но в версии v4.0 они считаются устаревшими, поэтому мы просто воспользуемся CMake.

Как минимум, нам понадобится файл CMakeLists.txt с описанием нашего проекта, папка main с исходным файлом точки входа в игру и ещё один файл CMakeLists.txt внутри main, в котором перечисляются исходные файлы.

CMake нужно ссылаться на переменные окружения, которые сообщат ему, где искать IDF и тулчейн. Меня раздражало, что приходится каждый раз устанавливать их заново при запуске новой сессии терминала, поэтому я написал скрипт export.sh. Он задаёт IDF_PATH и IDF_TOOLS_PATH, а также является источником экспорта IDF, задающим другие переменные окружения.

Пользователю скрипта достаточно задать переменные IDF_PATH и 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 в корне:

cmake_minimum_required(VERSION 3.5)

set(COMPONENTS "esptool_py main")

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

project(game)


По умолчанию система сборки будет собирать каждый возможный компонент внутри $ESP_IDF/components, из-за чего время компиляции будет больше необходимого. Мы хотим скомпилировать минимальный набор компонентов для вызова нашей основной функции, а дополнительные компоненты подключать позже по необходимости. Именно для этого нужна переменная COMPONENTS.

CMakeLists.txt внутри main:

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


Всё, что он делает — бесконечно раз в секунду выводит на монитор последовательного интерфейса «Hello World». Для задержки vTaskDelay использует FreeRTOS.

Файл main.c очень прост:

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


Обратите внимание, что наша функция называется app_main, а неmain. Функция main используется IDF для необходимой подготовки, а затем она создаёт задачу (task) с нашей функцией app_main в качестве точки входа.

Задача — это просто исполняемый блок, которым может управлять FreeRTOS. Пока нам не стоит об этом беспокоиться (а может, и вообще не придётся), но здесь важно заметить, что наша игра выполняется в одном ядре (ESP32 имеет два ядра), и с каждой итерацией цикла for задача откладывает выполнение на одну секунду. Во время этой задержки планировщик FreeRTOS может выполнять другой код, который ожидает своей очереди на выполнение (если такой код есть).

Мы можем использовать и оба ядра, но пока давайте ограничимся одним.

Компоненты


Даже если мы снизим список компонентов до самого минимума, необходимого для приложения Hello World (а это esptool_py и main), из-за настройки цепочки зависимостей оно всё равно собирает некоторые другие компоненты, которые нам не нужны. Оно собирает все эти компоненты:

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


Многие из них вполне логичны (bootloader, esp32, freertos), но за ними идут ненужные нам компоненты, потому что мы не используем сетевых функций: esp_eth, esp_wifi, lwip, mbedtls, tcpip_adapter, wpa_supplicant. К сожалению, мы всё равно вынуждены собирать эти компоненты.

К счастью, компоновщик достаточно умён и не компонует неиспользуемые компоненты в готовый двоичный файл игры. Мы можем проверить это при помощи 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


Больше всего на размер двоичного файла влияет libc, и это нормально.

Конфигурация проекта


IDF позволяет задавать параметры конфигурации времени компиляции, которые он использует при сборке для включения или отключения различных функций. Нам нужно задать параметры, которые позволят нам воспользоваться преимуществами дополнительных аспектов Odroid Go.

Во-первых, нужно выполнить source скрипта export.sh, чтобы CMake имел доступ к необходимым переменным окружения. Далее, как для всех проектов CMake, нам нужно создать папку сборки и вызвать CMake из неё.

source export.sh
mkdir build
cd build
cmake ..


Если запустить make menuconfig, то откроется окно, где можно настроить параметры проекта.

Расширяем флеш-память до 16 МБ


Odroid Go расширяет стандартный объём флеш-накопителя до 16 МБ. Эту возможность можно включить, перейдя в Serial flasher config -> Flash size -> 16MB.

Включаем внешнее SPI RAM


Также у нас есть доступ к дополнительным 4 МБ внешнего ОЗУ, подключенного по SPI. Можно включить его, перейдя в Component config -> ESP32-specific -> Support for external, SPI-connected RAM и нажав «пробел» для включения. Ещё мы хотим иметь возможность явным образом выделять память из SPI RAM; это можно включить, перейдя в SPI RAM config -> SPI RAM access method -> Make RAM allocatable using heap_caps_malloc.

Понижаем частоту


ESP32 по умолчанию работает с частотой 160 МГц, но давайте снизим её до 80 МГц, чтобы увидеть, как далеко можно зайти с самой низкой тактовой частотой. Мы хотим, чтобы игра работала от аккумулятора, а снижение частоты позволит экономить заряд. Изменить её можно, перейдя в Component config -> ESP32-specific -> CPU frequency -> 80MHz.

Если выбрать Save, в корень папки проекта будет сохранён файл sdkconfig. Мы можем записать этот файл в git, но в нём много параметров, которые нам не важны. Пока нас устраивают стандартные параметры, кроме тех, которые мы только что изменили.

Можно создать вместо него файл sdkconfig.defaults, который будет содержать изменённые выше значения. Всё остальное будет настроено по умолчанию. Во время сборки IDF будет считывать sdkconfig.defaults, переопределять заданные нами значения и использовать стандартные для всех остальных параметров.

Сейчас sdkconfig.defaults выглядит так:

# 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


В целом, исходная структура игры выглядит так:

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


Выполнение сборки и прошивка


Сам процесс выполнения сборки и прошивки достаточно прост.

Мы запускаем make для выполнения компиляции (для параллельной сборки добавьте -j4 или -j8), make flash для записи образа на Odroid Go и make monitor для просмотра выходной информации операторов printf.

make
make flash
make monitor


Также мы можем выполнить их одной строкой

make flash monitor


Результат не особо впечатляет, но он станет основой для оставшейся части проекта.

3c232962f8e782c9220b25b7e3c0df5e.png


Ссылки


Часть 2: ввод


Введение


Нам нужно иметь возможность считывания нажимаемых игроком кнопок и крестовины на Odroid Go.

Кнопки


106142ca2250eb9301215015b6f0bbad.png


GPIO


Odroid Go имеет шесть кнопок: A, B, Select, Start, Menu и Volume.

Каждая из кнопок подключена к отдельному контакту General Purpose IO (GPIO). Контакты GPIO могут использоваться как вводы (для считывания) или как выводы (мы выполняем в них запись). В случае кнопок нам нужно считывание.

Сначала требуется сконфигурировать контакты в качестве вводов, после чего мы сможем считывать их состояние. Внутри контакты имеют одно из двух напряжений (3.3V или 0V), но при считывании их с помощью функции IDF они преобразуются в целочисленные значения.

Инициализация


Элементы, помеченные на схеме как SW — это сами физические кнопки. Когда не нажаты, контакты ESP32 (IO13, IO0 и т.д.) соединены с 3,3 В; то есть напряжение 3,3 В означает, что кнопка не нажата. Логика здесь противоположна ожидаемой.

IO0 и IO39 имеют на плате физические резисторы. Если кнопка не нажата, то резистор подтягивает контакты до высокого напряжения. Если кнопка нажата, то идущий по контактам ток вместо этого уходит в землю, поэтому с контактов будет считываться напряжение 0.

IO13, IO27, IO32 и IO33 не имеют резисторов, потому что контакте на ESP32 имеют внутренние резисторы, которые мы сконфигурировали на режим подтягивания.

Зная это, мы можем сконфигурировать шесть кнопок при помощи GPIO API.

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


Заданные в начале кода константы соответствуют каждому из контактов схемы. Мы используем структуру gpio_config_t для конфигурирования каждой из шести кнопок в качестве ввода с подтягиванием. В случае IO13, IO27, IO32 и IO33 нам нужно попросить IDF включить подтягивающие резисторы этих контактов. Для IO0 и IO39 нам не нужно этого делать, потому что у них есть физические резисторы, но мы всё равно сделаем это, чтобы конфигурация была красивой.

ESP_ERROR_CHECK — это вспомогательный макрос из IDF, который автоматически проверяет результат всех функций, возвращающих значение esp_err_t (бОльшая часть IDF) и утверждает (assert), что результат не равен ESP_OK. Этот макрос удобно использовать для функции, если её ошибка критична и после неё нет смысла продолжать выполнение. В данном игра без ввода — не игра, так что это утверждение справедливо. Мы часто будем пользоваться этим макросом.

Считывание кнопок


Итак, мы сконфигурировали все контакты, и можем наконец-то считывать значения.

Цифровые кнопки считываются функцией gpio_get_level, но нам нужно инвертировать получаемые значения, потому что контакты подтянуты вверх, то есть высокий сигнал на самом деле означает «не нажато», а низкий — «нажато». Инвертирование сохраняет привычную нам логику: 1 означает «нажато», 0 — «не нажато».

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


Крестовина (D-pad)


1e8d9ea7303257afd45846771726a275.png


ADC


Подключение крестовины отличается от подключения кнопок. Кнопки «вверх» и «вниз» подключены к одному контакту аналогово-цифрового преобразователя (Analog-to-Digital Converter, ADC), а кнопки «влево» и «вправо» — к другому контакту ADC.

В отличие от цифровых контактов GPIO, с которых мы могли считать одно из двух состояний (высокое или низкое), ADC преобразует непрерывное аналоговое напряжение (например, от 0 В до 3,3 В) в дискретное числовое значение (например, от 0 до 4095).

Предполагаю, проектировщики Odroid Go сделали так, чтобы сэкономить на контактах GPIO (нужно только два аналоговых контакта вместо четырёх цифровых). Как бы то ни было, это немного усложняет конфигурацию и считывание с этих контактов.

Конфигурация


Контакт IO35 подключен к оси Y крестовины, а контакт IO34 — к оси X крестовины. Мы видим, что соединения крестовины чуть сложнее, чем у цифровых кнопок. У каждой оси есть два переключателя (SW1 и SW2 для оси Y, SW3 и SW4 — для оси X), каждый из которых подключён к набору резисторов (R2, R3, R4, R5).

Если не нажато ни «вверх», ни «вниз», контакт IO35 подтягивается вниз к земле через R3, и мы считаем значение 0 В. Если не нажато ни «влево», ни «вправо», контакт IO34 подтягивается вниз к земле через R5, и мы считаем значение 0 В.

Если нажато SW1 («вверх»), то с IO35 мы считаем 3,3 В. Если нажато SW2 («вниз»), то с IO35 мы считаем приблизительно 1,65 В, потому что половина напряжения упадёт на резисторе R2.

Если нажато SW3 («влево»), то с IO34 мы считаем 3,3 В. Если нажато SW4 («вправо»), то с IO34 мы тоже считаем примерно 1,65 В, потому что половина напряжения упадёт на резисторе R4.

Оба случая являются примерами делителей напряжения. Когда два резистора в делителе напряжения имеют одинаковое сопротивление (в нашем случае — 100K), то падение напряжения составит половину напряжения на входе.

Зная это, мы можем сконфигурировать крестовину:

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


Мы задали ADC ширину 12 битов, чтобы 0 В считывалось как 0, а 3,3 В — как 4095 (2^12). Attenuation сообщает, что не нужно ослаблять сигнал, чтобы мы получили полный диапазон напряжений от 0 В до 3,3 В.

При 12 битах можно ожидать, что если ничего не нажато, то будет считываться 0, при нажатии вверх и влево — 4096, и приблизительно 2048 будет считываться при нажатии вниз и вправо (потому что резисторы уменьшают напряжение вдвое).

Считывание крестовины


Считывать крестовину сложнее, чем кнопки, потому что нам нужно считывать сырые значения (от 0 до 4095) и интерпретировать их.

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 и ADC_NEGATIVE_LEVEL — это значения с запасом, гарантирующие, что мы всегда будем считывать правильные значения.

Опрос


Существует два варианта получения значений кнопок: опрос или прерывания. Мы можем создать функции обработки ввода и просить IDF вызывать эти функции при нажатии кнопок, или вручную опрашивать состояние кнопок, когда оно нам понадобится. Управляемое прерываниями поведение всё усложняет и затрудняет понимание потока кода. Кроме того, я всегда стремлюсь, чтобы всё было как можно проще. При необходимости мы можем добавить прерывания позже.

Мы создадим структуру, которая будет хранить состояние шести кнопок и четырёх направлений крестовины. Мы можем создать структуру с 10 boolean, или 10 int, или 10 unsigned int. Однако вместо этого мы создадим структуру с помощью битовых полей.

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;


При программировании для настольных систем битовых полей обычно избегают, потому что они плохо портируются на разные машины, но мы программируем для конкретной машины и волноваться нам об этом не нужно.

Вместо полей можно было бы использовать структуру с 10 булевыми значениями общим размером 10 байт. Ещё одним вариантом является одно uint16_t макросами битового сдвига и побитового маскирования, которые могут задавать, сбрасывать и проверять отдельные биты. Это сработает, но будет не очень красиво.

Простое битовое поле позволяет нам использовать преимущества обоих подходов: два байта данных и именованные поля.

Демо


Теперь мы можем опрашивать состояние вводов внутри основного цикла и выводить результат.

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


Функция printf использует \r для перезаписи предыдущей строки вместо добавления новой. fflush необходим для отображения строки, потому что в обычном состоянии она сбрасывается символом новой строки \n.

Your browser does not support HTML5 video.


Ссылки


Часть 3: дисплей


Введение


Нам нужно иметь возможность отрисовки пикселей на ЖК-дисплее Odroid Go.

Вывести цвета на экран будет сложнее, чем считывать состояние ввода, потому что у ЖК-дисплея есть мозги. Экран управляется ILI9341 — очень популярным драйвером TFT LCD на одном чипе.

Другими словами, мы общаемся с ILI9341, который реагирует на наши команды, управляя пикселями на ЖК-дисплее. Когда в этой части я буду говорить «экран» или «дисплей», то на самом деле буду иметь в виду ILI9341. Мы общаемся с ILI9341. Он управляет ЖК-дисплеем.

SPI


ЖК-дисплей подключён к ESP32 по SPI (Serial Peripheral Interface).

SPI — это стандартный протокол, используемый для обмена данными между устройствами на печатной плате. Он имеет четыре сигнала: MOSI (Master Out Slave In), MISO (Master In Slave Out), SCK (Clock) и CS (Chip Select).

Единое мастер-устройство на шине координирует передачу данных, управляя SCK и CS. На одной шине может быть несколько устройств, у каждого из которых будут собственные сигналы CS. Когда активируется сигнал CS этого устройства, оно может передавать и получать данные.

ESP32 будет управляющим устройством SPI (master), а ЖК-дисплей — подчинённым устройством SPI (slave). Нам нужно сконфигурировать шину SPI требуемыми параметрами и добавить ЖК-дисплей на шину, сконфигурировав соответствующие контакты.

7bc60e18c1b066ba7c22756464f92512.png


f124636158029efeab3259f299a2af36.png


Имена VSPI.XXXX — это просто метки для контактов на схеме, но мы можем пройти и до самих контактов, взглянув на части схемы LCD и ESP32.

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


Также у нас есть IO14, являющийся выводом GPIO, который используется для включения подсветки, а ещё IO21, который подключен к контакту DC ЖК-дисплея. Этот контакт контролирует тип информации, который мы передаём на дисплей.

Сначала сконфигурируем шину 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));


Мы конфигурируем шину при помощи spi_bus_config_t. Необходимо сообщить используемые нами контакты и максимальный размер одной передачи данных.

Пока мы будем выполнять одну передачу по SPI для всех данных буфера кадров, который равен ширине ЖК-дисплея (в пикселях), умноженной на его высоту (в пикселях), умноженным на количество байт на пиксель.

Ширина равна 320, высота — 240, а глубина цвета — 2 байтам (дисплей ожидает, что цвета пикселей имеют глубину 16 бит).

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


Инициализировав шину, мы должны добавить ЖК-устройство на шину, чтобы мы могли начать с ним общаться.

  • clock_speed_hz — в спецификациях ЖК-дисплея написано, что он может понимать скорости SPI вплоть до 40 МГц, поэтому её мы и зададим. Однако мне успешно удавалось обмениваться данными при 80 МГц, поэтому в дальнейшем мы при необходимости сможем повысить скорость.
  • spics_io_num — мы задаём контакт CS, чтобы IDF мог правильно переключать сигнал CS, когда ему нужно будет общаться с дисплеем (интерфейс SD-карты тоже находится на шине SPI).
  • queue_size — мы задаём его равным 1, потому что за раз мы хотим отправлять только одну передачу (буфер кадров целиком).
  • flags — драйвер IDF SPI обычно вставляет в передачу пустые биты, чтобы избежать проблем с таймингом во время считывания из SPI-устройства, но мы выполняем одностороннюю передачу (выполнять считывание с дисплея мы не будем). SPI_DEVICE_NO_DUMMY сообщает, что мы подтверждаем эту одностороннюю передачу и нам не нужно вставлять пустые биты.
gpio_set_direction(LCD_PIN_DC, GPIO_MODE_OUTPUT);
gpio_set_direction(LCD_PIN_BACKLIGHT, GPIO_MODE_OUTPUT);


Также нам нужно задать контакты DC и подсветки в качестве выводов GPIO. После переключения DC подсветка будет постоянно включена.

Команды


Обмен данными с ЖК-дисплеем выполняется в форме команд. Сначала мы передаём байт, обозначающий команду, которую хотим отправить, а затем передаём параметры команды (если они есть). Дисплей понимает, что байт является командой, если на DC низкий сигнал. Если на DC высокий сигнал, то получаемые данные будут считаться параметрами ранее переданной команды.

В целом поток выглядит вот так:

  1. Подаём на DC низкий сигнал
  2. Отправляем один байт команды
  3. Подаём на DC высокий сигнал
  4. Отправляем ноль или больше байтов, в зависимости от требований команды
  5. Повторяем этапы 1-4


Здесь наш лучший друг — спецификация ILI9341. В ней перечислены все возможные команды, их параметры и способы их использования.

68d8d809ceb9446142cd5c77bb78d0e2.png


Пример команды без параметров — Display ON. Байт команды равен 0x29, но для него не указано параметров.

35a4e04466c22aa6746fe441f345c02f.png


Пример команды с параметрами — Column Address Set. Байт команды равен 0x2A, но для неё указано четыре обязательных параметра. Чтобы использовать команду, нужно подать на DC низкий сигнал, передать 0x2A, подать на DC высокий сигнал, а затем передать байты четырёх параметров.

Сами коды команд заданы в перечислении.

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;


Вместо него мы могли бы использовать макрос (#define SOFTWARE_RESET (0x01u)), но они не имеют символов в отладчике и у них отсутствует область видимости. Также можно было бы использовать статические константы integer, как мы поступили с контактами GPIO, но благодаря enum мы с первого взгляда можем понять, какие данные передаются функции или члену структуры: они имеют тип CommandCode. В противном случае это могли бы быть сырые uint8_t, которые ничего не говорят читающему код программисту.

Запуск


Во время инициализации мы можем передать разные команд, чтобы иметь возможность что-нибудь отрисовать. Каждая команда имеет байт команды, который мы назовём Command Code.

Мы зададим структуру для хранения команды запуска, чтобы можно было задать их массив.

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


  • code — это код команды.
  • parameters — это массив параметров команды (если они есть). Это статический массив размера 15, потому что таково максимально необходимое нам количество параметров. Благодаря статичности массива нам не придётся каждый раз волноваться о выделении динамического массива для каждой команды.
  • length — это количество параметров в массиве parameters.


При помощи этой структуры мы можем задать список команд запуска.

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


Команды без параметров, например, SOFTWARE_RESET, задают список инициализатора parameters как пустой (т.е. с одними нулями) а length присваивают значение 0. Команды с параметрами заполняют параметры и задают length. Было бы здорово, если бы могли задавать length автоматически, а не прописывать числами (на случай, если мы ошибёмся или параметры изменятся), но я не считаю, что это стоит трудов.

Предназначение большинства команд понятно из названия, за исключением двух.

MEMORY_ACCESS_CONTROL

  • Landscape Mode: по умолчанию в дисплее используется портретная ориентация (240x320), но мы хотим использовать альбомную (320x240).
  • Top-Left Origin: мы задаём точку начала координат (0,0) в левом верхнем углу дисплея, потому что логичнее (для меня) записывать в буфер кадров сверху вниз и слева направо.
  • BGR Panel: дисплей ожидает, что цвета пикселей будут указываться в формате BGR. Это стало понятно, когда я задавал пиксели, думая, что они красные, но они отображались как синие.


PIXEL_FORMAT_SET

  • 16 bits per pixel: мы используем 16-битные цвета.


Существует множество других команд, которые можно передавать при запуске для контроля различных аспектов, например, гаммы. Необходимые параметры описаны в спецификации самого ЖК-дисплея (а не контроллера ILI9341), к которой у нас нет доступа. Если мы не будем передавать эти команды, то используются параметры дисплея по умолчанию, что вполне нас устраивает.

Подготовив массив команд запуска, мы можем начать передавать их на дисплей.

Сначала нам нужна функция, отправляющая на дисплей один байт команды. Не забывайте, что отправка команд отличается от отправки параметров, потому что нам нужно подать на 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);
}


IDF имеет структуру spi_transaction_t, которую мы заполняем, когда хотим передать что-нибудь по шине SPI. Мы знаем, сколько бит составляет полезная нагрузка и передаём саму нагрузку.

Мы можем передать или указатель на полезную нагрузку, или использовать внутреннюю struct tx_data структуры, которая имеет размер всего четыре байта, но избавляет драйвер от необходимости доступа к внешней памяти. Если мы используем tx_data, то должны задать флаг SPI_TRANS_USE_TXDATA.

Прежде чем передавать данные, мы подаём на DC низкий сигнал, сообщающий, что это код команды.

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


Передача параметров аналогична передаче команды, только на этот раз мы используем собственный буфер (data) и подаём на DC высокий сигнал, чтобы сообщить дисплею, что передаются параметры. Кроме того, мы не устанавливаем флаг SPI_TRANS_USE_TXDATA, потому что передаём свой собственный буфер.

Затем можн

© Habrahabr.ru