Часть 0: мотивация
Введение
Я искал для себя хобби-проект, над которым можно было работать вне моих основных задач, чтобы отвлечься от ситуации в мире. По большей мере меня интересует программирование игр, но также мне нравятся embedded-системы. Сейчас я работаю в игровой компании, но раньше в основном занимался микроконтроллерами. Хотя в конце концов я решил сменить стезю и перейти в индустрию игр, мне по-прежнему нравится с ними экспериментировать. Так почему бы не совместить оба увлечения?
Odroid Go
У меня завалялся Odroid Go, с которым было бы интересно поиграться. Его ядром является ESP32 — очень популярный микроконтроллер со стандартной функциональностью МК (SPI, I2C, GPIO, таймеры и т.д.), но также имеющий WiFi и Bluetooth, что делает его привлекательным для создания IoT-устройств.
Odroid Go дополняет ESP32 кучей периферии, позволяя превратить его в портативную игровую машину, напоминающую Gameboy Color: ЖК-дисплей, динамик, крестовина управления, две основные и четыре вспомогательные кнопки, аккумулятор и устройство чтения SD-карт.
В основном люди покупают Odroid Go для запуска эмуляторов старых 8-битных систем. Если эта штука способна эмулировать старые игры, то справится и с запуском нативной игры, предназначенной специально для неё.
Ограничения
Разрешение 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
Результат не особо впечатляет, но он станет основой для оставшейся части проекта.
Ссылки
Часть 2: ввод
Введение
Нам нужно иметь возможность считывания нажимаемых игроком кнопок и крестовины на Odroid Go.
Кнопки
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)
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.
Ссылки
Часть 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 требуемыми параметрами и добавить ЖК-дисплей на шину, сконфигурировав соответствующие контакты.
Имена 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 высокий сигнал, то получаемые данные будут считаться параметрами ранее переданной команды.
В целом поток выглядит вот так:
- Подаём на DC низкий сигнал
- Отправляем один байт команды
- Подаём на DC высокий сигнал
- Отправляем ноль или больше байтов, в зависимости от требований команды
- Повторяем этапы 1-4
Здесь наш лучший друг — спецификация ILI9341. В ней перечислены все возможные команды, их параметры и способы их использования.
Пример команды без параметров — Display ON. Байт команды равен 0x29, но для него не указано параметров.
Пример команды с параметрами — 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, потому что передаём свой собственный буфер.
Затем можно передавать все команды запуска.
#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);
}
}
Мы итеративно обходим массив команд запуска, сначала передавая код команды, а затем параметры (если они есть).
Отрисовка кадра
После инициализации дисплея можно начинать отрисовку на нём.
#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 имеет возможность перерисовки отдельных частей экрана. Это может пригодиться нам в дальнейшем, если мы заметим падение частоты кадров. При этом можно будет обновлять только изменившиеся части экрана, но пока мы будем просто заново перерисовывать весь экран.
Для отрисовки кадра требует задания окна отрисовки. Для этого нужно отправить команду COLUMN_ADDRESS_SET с шириной окна и команду PAGE_ADDRESS_SET с высотой окна. Каждая из команд принимает четыре байта параметра, описывающих окно, в которое мы будем выполнять отрисовку.
UPPER_BYTE_16 и LOWER_BYTE_16 — это вспомогательные макросы для извлечения старшего и младшего байта из 16-битного значения. Параметры этих команд требуют, чтобы мы разделили 16-битное значение на два 8-битных значения, поэтому так мы и делаем.
Отрисовка инициируется командой MEMORY_WRITE и отправкой дисплею за раз всех 153 600 байт буфера кадров.
Существуют и другие способы передачи буфера кадров дисплею:
- Мы можем создать ещё одну задачу FreeRTOS (task), отвечающую за координирование транзакций SPI.
- Можно передавать кадр не в одной, а в нескольких транзакциях.
- Можно использовать неблокирующую передачу, при которой мы инициируем отправку, а затем продолжаем выполнять другие операции.
- Можно использовать какое-нибудь сочетание из перечисленных способов.
Пока мы воспользуемся простейшим способом: единственной блокирующей транзакцией. При вызове DrawFrame инициируется передача на дисплей и наша задача приостанавливается, пока не будет завершена передача. Если позже мы выясним, что не можем добиться хорошей частоты кадров при таком способе, то вернёмся к этой проблеме.
RGB565 и порядок следования байтов
Обычный дисплей (например, монитор вашего компьютера) имеет битовую глубину 24 бита (1,6 миллиона цветов): по 8 бит на красный, зелёный и синий. Пиксель записывается в память как RRRRRRRRGGGGGGGGBBBBBBBB.
ЖК-дисплей Odroid имеет битовую глубину 16 бит (65 тысяч цветов): 5 бит красного, 6 бит зелёного и 5 бит синего. Пиксель записывается в память как RRRRRGGGGGGBBBBB. Этот формат называется RGB565.
#define SWAP_ENDIAN_16(value) ( (((value) & 0xFFu) << 8u) | ((value) >> 8u) )
#define RGB565(red, green, blue) ( SWAP_ENDIAN_16( ((red) << 11u) | ((green) << 5u) | (blue) ) )
Зададим макрос, создающий цвет в формате RGB565. Будем передавать ему байт красного, байт зелёного и байт синего. Он возьмёт пять старших бит красного, шесть старших бит зелёного и пять старших бит синего. Мы выбрали старшие биты, потому что они содержат больше информации, чем младшие.
Однако ESP32 хранит данные в порядке Little Endian, то есть в нижнем адресе памяти хранится наименее значимый байт.
Например, 32-битное значение [0xDE 0xAD 0xBE 0xEF] будет храниться в памяти как [0xEF 0xBE 0xAD 0xDE]. При передаче данных на дисплей это становится проблемой, потому что наименее значимый байт будет отправляться первым, а ЖК-дисплей ожидает получить первым наиболее значимый байт.
Зададим макрос SWAP_ENDIAN_16 для перемены байт местами и используем его в макросе RGB565.
Вот как каждый из трёх основных цветов описывается в RGB565 и как они хранятся в памяти ESP32, если не изменить порядок байтов.
Красный
11111|000000|00000?-> 11111000 00000000 -> 00000000 11111000
Зелёный
00000|111111|00000?-> 00000111 11100000 -> 11100000 00000111
Синий
00000|000000|11111?-> 00000000 00011111 -> 00011111 00000000
Демо
Мы можем создать простое демо, чтобы посмотреть на ЖК-дисплей в действии. В начале кадра оно сбрасывает буфер кадров на чёрный цвет и отрисовывает квадрат размером 50x50. Мы можем перемещать квадрат крестовиной и изменять его цвет кнопками A, B и Start.
void app_main(void)
{
Odroid_InitializeInput();
Odroid_InitializeDisplay();
ESP_LOGI(LOG_TAG, "Odroid initialization complete - entering main loop");
uint16_t* framebuffer = (uint16_t*)heap_caps_malloc(320 * 240 * 2, MALLOC_CAP_DMA);
assert(framebuffer);
int x = 0;
int y = 0;
uint16_t color = 0xffff;
for (;;)
{
memset(framebuffer, 0, 320 * 240 * 2);
Odroid_Input input = Odroid_PollInput();
if (input.left) { x -= 10; }
else if (input.right) { x += 10; }
if (input.up) { y -= 10; }
else if (input.down) { y += 10; }
if (input.a) { color = RGB565(0xff, 0, 0); }
else if (input.b) { color = RGB565(0, 0xff, 0); }
else if (input.start) { color = RGB565(0, 0, 0xff); }
for (int row = y; row < y + 50; ++row)
{
for (int col = x; col < x + 50; ++col)
{
framebuffer[320 * row + col] = color;
}
}
Odroid_DrawFrame(framebuffer);
}
// Should never get here
esp_restart();
}
Мы выделяем буфер кадров по полному размеру дисплея: 320 x 240, два байта на пиксель (16-битный цвет). Используем heap_caps_malloc, чтобы он был выделен в памяти, которую можно использовать для транзакций SPI с Direct Memory Access (DMA). DMA позволяет периферии SPI иметь доступ к буферу кадров без необходимости участия ЦП. Без DMA транзакции SPI занимают гораздо больше времени.
Мы не выполняем проверки, гарантирующие, что отрисовка не происходит за границами экрана.
Заметен сильный тиринг. В десктопных приложениях стандартным способом устранения тиринга является использование нескольких буферов. Например, при двойной буферизации есть два буфера: передний и задний буферы. Пока отображается передний буфер, запись выполняется
в задний. Затем они меняются местами и процесс повторяется.
У ESP32 нет достаточно ОЗУ с возможностями DMA для хранения двух буферов кадров (4 МБ внешней SPI RAM, к сожалению, не имеет возможностей DMA), поэтому этот вариант не подходит.
У ILI9341 есть сигнал (TE), сообщающий, когда происходит VBLANK, чтобы мы могли выполнять запись на дисплей, пока он не отрисовывается. Но у Odroid (или у модуля дисплея) этот сигнал не подключен, так что мы не можем получить к нему доступ.
Возможно, мы смогли бы найти достойное значение, но пока не будем этим заниматься, потому что сейчас наша задача — просто вывести пиксели на экран.
Исходный код
Весь исходный код можно найти здесь.
Продолжение: накопитель, аккумулятор, звук.