在ESP32上为嵌入式设备编程游戏

第0部分:动机


介绍


我一直在寻找一个业余爱好项目,我可以在主要任务之外进行工作,以便摆脱世界局势。我对游戏编程最感兴趣,但我也喜欢嵌入式系统。现在,我在一家游戏公司工作,但是在我主要从事微控制器之前。尽管最终我决定改变自己的道路,进入游戏行业,但我仍然喜欢尝试使用它们。那么,为什么不将两个爱好结合起来呢?

Odroid去


我让Odroid Go闲逛,这很有趣。它的核心是ESP32-一种非常流行的微控制器,具有标准的MK功能(SPI,I2C,GPIO,计时器等),还具有WiFi和蓝牙功能,因此对于创建IoT设备非常有吸引力。

Odroid Go通过一系列外围设备对ESP32进行了补充,使其变成了类似Gameboy Color的便携式游戏机:一个LCD显示屏,一个扬声器,一个控制叉,两个主按钮和四个辅助按钮,一个电池和一个SD卡读卡器。

通常,人们购买Odroid Go来运行旧的8位系统的仿真器如果这个东西能够模仿旧游戏,那么它也可以应付专门为其设计的本地游戏的发布。


局限性


分辨率320x240

显示器的尺寸仅为320x240,因此同时显示在屏幕上的信息量非常有限。我们需要仔细考虑我们将制作哪种游戏以及使用哪些资源。

16位彩色

显示器支持每个像素16位彩色:红色5位,绿色6位和蓝色5位。由于明显的原因,这种电路通常称为RGB565。绿色的红色和蓝色要多一些,因为人眼比蓝色或红色更好地区分了绿色的渐变。

16位颜色表示我们只能使用65,000种颜色。与此相比,标准的24位颜色(每种颜色8位)可提供1600 万种颜色。

缺乏GPU

没有GPU,我们将无法使用OpenGL之类的API。如今,用于渲染2D游戏的GPU通常与3D游戏相同。只是代替对象,而是绘制四边形,在其上叠加位纹理。如果没有GPU,我们必须使用CPU光栅化每个像素,这虽然速度较慢,但​​更为简单。

屏幕分辨率为320x240和16位彩色时,帧缓冲区的总大小为153,600字节。这意味着每秒至少需要三十次传输153,600字节到显示器。这最终会导致问题,因此渲染屏幕时我们需要变得更聪明。例如,您可以将索引颜色转换为调色板,这样对于每个像素,您需要存储一个字节,该字节将用作256色调色板的索引。

4 MB

ESP32具有520 KB内部RAM,而Odroid Go添加了另外4 MB外部RAM。但是我们无法使用所有这些内存,因为一部分由ESP32 SDK使用(稍后会详细介绍)。禁用所有可能的无关功能并输入我的主要功能后,ESP32报告我们可以使用4,494,848字节。如果将来我们需要更多的内存,那么以后我们可以返回以修剪不必要的功能。

80-240 MHz处理器

以三种可能的速度配置CPU:80 MHz,160 MHz和240 MHz。甚至最大240 MHz也远远不能满足我们过去使用的超过3 GHz的现代计算机的能力。我们将从80 MHz开始,看看能走多远。如果我们希望游戏依靠电池供电,则功耗应较低。为此,最好降低频率。

错误的调试

有多种方法可将调试器与嵌入式设备(JTAG)结合使用,但是不幸的是,Odroid Go无法为我们提供必要的联系,因此我们无法像通常情况那样逐步调试调试器中的代码。这意味着调试可能是一个困难的过程,我们将必须积极使用屏幕上的调试(使用颜色和文本),并将信息输出到调试控制台(幸运的是,可以通过USB UART轻松访问该信息)。

为什么所有麻烦?


为什么还要尝试为具有上述所有限制的这种性能较弱的设备创建游戏,而又不为台式机编写任何内容?造成这种情况的原因有两个:

限制会激发创造力

当您使用具有特定设备集的系统工作时,每种设备都有其自身的局限性,因此您需要考虑如何最好地利用这些局限性的优势。因此,我们与旧系统的游戏开发者(例如Super Nintendo)的关系更加紧密(但对我们而言,这比对他们而言要容易得多)。

低级开发很有趣

要从头开始为常规的台式机系统编写游戏,我们必须使用标准的低级引擎概念:渲染,物理,碰撞识别。但是,当在嵌入式设备上实现所有这些功能时,我们还必须处理低级计算机概念,例如,编写LCD驱动程序。

发展将有多低?


当涉及到低级和创建自己的代码时,您必须在某处绘制边框。如果我们试图为桌面编写不带库的游戏,那么边界很可能是操作系统或诸如SDL之类的跨平台API。在我的项目中,我将编写SPI驱动程序和引导加载程序之类的内容。与他们在一起的折磨不仅仅是乐趣。

因此,我们将使用ESP-IDF,它实际上是ESP32的SDK。我们可以假定它为我们提供了操作系统通常提供的一些实用程序,但是该操作系统无法在ESP32中运行。严格来说,该MK使用FreeRTOS,这是一个实时操作系统但这不是真正的操作系统。这只是一个计划者。最有可能的是,我们不会与之交互,但在其核心ESP-IDF中会使用它。

ESP-IDF为我们提供了适用于ESP32外设的API(例如SPI,I2C和UART)以及C运行时库,因此当我们调用诸如printf之类的东西时,它实际上是通过UART传输字节以显示在串行接口监视器上的。它还会在调用我们的游戏启动点之前处理准备机器所需的所有启动代码。

在这篇文章中,我将保留一份发展杂志,其中我将谈论我看来有趣的观点并解释最困难的方面。我没有计划,很可能会犯很多错误。我创建的所有这些都是出于兴趣。

第1部分:构建系统


介绍


在开始为Odroid Go编写代码之前,我们需要配置ESP32 SDK。它包含启动ESP32并调用我们的主要功能的代码,以及编写LCD驱动程序时所需的外围代码(例如SPI)。

乐鑫对其ESP-IDF SDK进行了调用我们使用最新的稳定版本v4.0

我们可以根据存储库的说明(带有递归标志克隆存储库,或者直接从发行版页面下载zip。

我们的首要目标是在Odroid Go上安装最小的Hello World风格的应用程序,以证明构建环境的正确设置。

C或C ++


ESP-IDF使用C99,因此我们也将选择它。如果需要的话,我们可以使用C ++(在ESP32工具链中有一个C ++编译器),但是现在我们仍然坚持使用C。

实际上,我喜欢C及其简单性。不管我用C ++编写多少代码,我都无法设法享受它。

这个人很好地总结了我的想法。

此外,如有必要,我们可以随时切换到C ++。

最小的项目


IDF使用CMake来管理构建系统。它还支持Makefile,但v4.0中已弃用它们,因此我们仅使用CMake。

至少,我们需要一个带有项目描述的CMakeLists.txt文件,一个包含游戏入口点源文件的文件夹以及main内的另一个CMakeLists.txt文件,其中列出了源文件。 CMake需要引用环境变量,这些变量告诉它在哪里寻找IDF和工具链。我很生气,每次启动新的终端会话时都必须重新安装它们,所以我写了export.sh脚本。它设置IDF_PATHIDF_TOOLS_PATH

,它还是设置其他环境变量的IDF导出源。

脚本用户只要设置IDF_PATHIDF_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.txtmain内部

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而不是 mainIDF使用main函数进行必要的准备,然后使用app_main函数作为入口点创建任务 任务只是FreeRTOS可以管理的可执行块。尽管我们不必为此担心(或根本不用担心),但这里需要特别注意的是,我们的游戏运行在一个内核中(ESP32有两个内核),并且每次for循环迭代时,任务都会将执行延迟一秒钟。在此延迟期间,FreeRTOS调度程序可能会执行其他正在排队等待执行的代码(如果有)。



我们可以同时使用两个内核,但是现在让我们将其限制为一个。

组件


即使由于依赖链的配置,即使我们将组件列表减少到Hello World应用程序所需的最少数量(即esptool_pymain),它仍会收集一些我们不需要的其他组件。它收集所有这些组件:

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

其中许多都是相当合乎逻辑的(bootloaderesp32freertos),但是它们后面是不必要的组件,因为我们不使用网络功能: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的其他方面。

首先,您需要运行export.sh的源脚本,以便CMake可以访问必要的环境变量。此外,对于所有CMake项目,我们需要创建一个程序集文件夹并从中调用CMake。

source export.sh
mkdir build
cd build
cmake ..

如果运行make menuconfig,则会打开一个窗口,您可以在其中配置项目设置。

将闪存扩展到16 MB


Odroid Go将标准闪存驱动器容量扩展到16 MB。您可以通过转至串行闪存配置->闪存大小-> 16MB来启用此功能

打开外部SPI RAM


我们还可以访问通过SPI连接的额外4 MB外部RAM。您可以通过以下方法启用它:转到Component config-> ESP32-specific->支持外部,SPI连接的RAM,然后按空格键将其启用。我们还希望能够从SPI RAM中显式分配内存。可以通过转到SPI RAM配置-> SPI RAM访问方法->使用heap_caps_malloc使RAM可分配来启用此功能

降低频率


ESP32默认以160 MHz的频率工作,但让我们将其降低到80 MHz以查看最低时钟频率能走多远。我们希望游戏靠电池供电,降低频率可以节省功率。您可以通过转到Component config-> ESP32-specific-> CPU frequency-> 80MHz来更改它

如果选择保存,则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,并使Monitor看到printf语句的输出

make
make flash
make monitor

我们也可以一行执行它们。

make flash monitor

结果并不是特别令人印象深刻,但是它将成为该项目其余部分的基础。


参考文献



第2部分:输入


介绍


我们需要能够读取玩家所按下的按钮以及Odroid Go上的叉号。

纽扣



通用输入输出


Odroid Go具有六个按钮:AB选择开始菜单音量

每个按钮都连接到单独的通用IO(GPIO)引脚GPIO引脚可以用作输入(用于读取)或用作输出(我们对其进行写入)。对于按钮,我们需要阅读。

首先,您需要将联系人配置为输入,然后我们可以读取其状态。内部触点具有两个电压(3.3V或0V)之一,但是当使用IDF功能读取它们时,它们将转换为整数值。

初始化


在图标记为SW的元素是物理按钮本身。未按下时,ESP32触点(IO13IO0等)连接到3.3 V;即3.3 V表示未按下按钮。这里的逻辑与预期的相反。

IO0IO39的板上有物理电阻。如果未按下按钮,则电阻器会将触点至高电压。如果按下该按钮,则

过触点的电流将流向地面,因此将从触点读取电压0 IO13IO27IO32IO33没有电阻,因为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结构将六个按钮中的每个按钮配置为上拉输入。在IO13IO27IO32IO33的情况下我们需要让IDF打开这些触点的上拉电阻。对于IO0IO39,我们不需要这样做,因为它们具有物理电阻,但是无论如何我们都会这样做,以使配置美观。

ESP_ERROR_CHECK是IDF的帮助程序宏,它自动检查所有返回esp_err_t的函数的结果(大多数IDF),并断言结果不等于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-垫)



ADC


连接十字架不同于连接按钮。向上和向下按钮连接到模数转换器(ADC)的一个引脚,向左和向右按钮连接到另一ADC引脚。

与GPIO数字触点不同,我们可以从其中读取两种状态(高或低)之一,ADC将连续的模拟电压(例如,从0 V到3.3 V)转换为离散的数值(例如,从0到4095) )

我想Odroid Go的设计人员这样做是为了节省GPIO引脚(您只需要两个模拟引脚,而不是四个数字引脚)。不管怎样,这会使配置和从这些触点读取的内容稍微复杂化。

组态


触点IO35连接到星形轮的Y轴,触点IO34连接星形轮的X轴。我们看到十字架的关节比数字按钮稍微复杂一些。每个轴都有两个开关(Y轴为SW1SW2X轴为SW3SW4),每个开关都连接到一组电阻(R2R3R4R5)。

如果未按下“上”或“下”,则IO35引脚通过R3下拉至地,我们将其值视为0V。如果未按下“左”或“右”,请与IO34联系通过R5下拉到地面,我们将值计数为0V。

如果按下SW1(“向上”),则使用IO35计数为3.3V。如果按下SW2(“向下”),则使用IO35计数为1, 65 V,因为一半的电压将降到电阻R2上

如果按下SW3(“左”),则使用IO34,我们将计数为3.3V。如果按下SW4(“右”),则使用IO34,我们还将计数为1.65 V,因为一半的电压将下降到电阻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 V读取为0,将3.3 V读取为4095(2 ^ 12)。衰减报告显示,我们无需衰减信号即可获得0 V至3.3 V的整个电压范围。

在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_LEVELADC_NEGATIVE_LEVEL是带有边距的值,确保我们始终读取正确的值。

轮询


获取按钮值有两个选项:轮询或中断。我们可以创建输入处理功能,并在按下按钮时要求IDF调用这些功能,或者在需要时手动轮询按钮的状态。中断驱动的行为使事情变得更加复杂和难以理解。此外,我一直在努力使一切变得尽可能简单。如有必要,我们可以稍后添加中断。

我们将创建一个结构,该结构将存储六个按钮和十字的四个方向的状态。我们可以创建一个包含10个布尔值,10个int或10个unsigned int的结构。但是,相反,我们将使用bit field创建结构

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 LCD上渲染像素。

由于LCD有大脑,因此在屏幕上显示颜色比读取输入状态要困难。屏幕由ILI9341控制-ILI9341是一种非常流行的TFT LCD驱动器,位于单个芯片上。

换句话说,我们正在与ILI9341对话,后者通过控制LCD上的像素来响应我们的命令。当我在这一部分中说“屏幕”或“显示”时,实际上是指ILI9341。我们正在处理ILI9341。它控制LCD。

SPI


LCD通过SPI(串行外设接口)连接到ESP32

SPI是一种标准协议,用于在印刷电路板上的设备之间交换数据。它具有四个信号:MOSI(主机输出从机输入)MISO(主机输入从机输出)SCK(时钟)CS(芯片选择)

总线上的单个主设备通过控制SCK和CS协调数据传输。一条总线上可以有多个设备,每个设备都有自己的CS信号。激活此设备的CS信号后,它可以发送和接收数据。

ESP32将是SPI主机(主机),而LCD将是SPI从机。我们需要使用所需的参数配置SPI总线,并通过配置相应的触点将LCD显示添加到总线。



名称VSPI.XXXX只是该图中触点的标签,但是我们可以通过查看LCD和ESP32图的各个部分来遍历触点本身。

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

我们还有IO14和IO21IO14是用于打开背光灯的GPIO引脚,而IO21LCD 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传输,该传输等于LCD的宽度(以像素为单位)乘以其高度(以像素为单位)乘以每个像素的字节数。

宽度为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));

初始化总线后,我们需要在总线上添加一个LCD设备,以便我们可以开始与之对话。

  • clock_speed_hz — - , SPI 40 , . 80 , .
  • spics_io_num — CS, IDF CS, ( SD- SPI).
  • queue_size — 1, ( ).
  • 标志 -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后,背光将一直亮着。

队伍


与LCD的通信采用命令形式。首先,我们传递一个字节来表示要发送的命令,然后传递命令参数(如果有)。如果DC信号为低电平,则显示屏将理解该字节为命令如果直流信号为高电平,则接收到的数据将被视为先前发送命令的参数。

通常,流看起来像这样:

  1. 我们给直流低信号
  2. 我们发送命令的一个字节
  3. 我们给DC高信号
  4. 发送零个或多个字节,具体取决于命令的要求
  5. 重复步骤1-4

在这里,我们最好的朋友是ILI9341规范它列出了所有可能的命令,它们的参数以及如何使用它们。


没有参数的命令示例是Display ON命令字节为0x29,但未为其指定参数。


带参数的命令示例是“ 列地址集”命令字节为0x2A,但为此指定了四个必需的参数。要使用此命令,您需要向DC发送一个低信号,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)),但是它们在调试器中没有符号,也没有作用域。就像我们使用GPIO触点一样,也可以使用整数静态常数,但是由于有了枚举,我们一眼就能了解将哪些数据传递给函数或结构的成员:它们是CommandCode类型的否则,可能是原始的uint8_t不会告诉程序员阅读代码的任何信息。

发射


在初始化期间,我们可以传递不同的命令以绘制一些东西。每个命令都有一个命令字节,我们将其称为命令代码

我们将定义用于存储启动命令的结构,以便您可以指定其数组。

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

  • code是命令代码。
  • parameters是命令参数(如果有)的数组。这是一个大小为15的静态数组,因为这是我们需要的最大参数数量。由于数组是静态的,因此我们不必担心每次都为每个命令分配动态数组。
  • length参数数组中参数的数量

使用此结构,我们可以指定启动命令列表。

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,将初始化程序列表设置为参数为空(即,带有一个零),并将长度设置为0。带参数的命令填充参数并指定长度。如果我们可以自动设置长度而不写数字(如果我们输入错误或参数更改),那将是很好的选择,但是我认为这样做不值得。

除了两个,大多数团队的目的很明确。

MEMORY_ACCESS_CONTROL

  • 横向模式:默认情况下,显示器使用纵向(240x320),但我们要使用横向(320x240)。
  • Top-Left Origin: (0,0) , ( ) .
  • BGR Panel: , BGR. , , , , .

PIXEL_FORMAT_SET

  • 16 bits per pixel: 16- .

在启动时还可以发送许多其他命令来控制各个方面,例如gamma。 LCD本身(而非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总线传输某些内容时,我们将其填充。我们知道有效负载是多少位,并转移负载本身。

我们既可以传递指向有效负载的指针,也可以使用内部结构tx_data结构,该结构只有4个字节,但是可以节省驱动程序访问外部存储器的时间。如果使用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);
}

传递参数类似于发送命令,只是这一次我们使用自己的缓冲区(数据)并向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_16LOWER_BYTE_16-这些是辅助宏,用于从16位值中提取高字节和低字节。这些命令的参数要求我们将16位值分成两个8位值,这就是我们这样做的原因。

渲染由MEMORY_WRITE命令启动,并一次将所有153,600字节的帧缓冲区发送到显示器。

还有其他将帧缓冲区传输到显示器的方法:

  • 我们可以创建另一个FreeRTOS任务(任务),该任务负责协调SPI事务。
  • 您可以不以一个为单位传送帧,而是以多个事务传送。
  • 您可以使用非阻塞传输,在该传输中我们将启动发送,然后继续执行其他操作。
  • 您可以使用上述方法的任意组合。

现在,我们将使用最简单的方法:唯一的阻塞事务。调用DrawFrame时,将启动到显示传输,并且我们的任务将暂停直到传输完成。如果以后发现使用此方法无法获得良好的帧速率,那么我们将回到这个问题。

RGB565和字节顺序


典型的显示器(例如计算机的显示器)的位深度为24位(160万色):红色,绿色和蓝色为8位。像素作为RRRRRRRGGGGGGGGGGBBBBBBBBBBB写入内存

Odroid LCD的位深度为16位(65,000种颜色):5位红色,6位绿色和5位蓝色。该像素将作为RRRRRRGGGGGGGBBBBBBB写入内存。此格式称为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]的形式存储在内存中。当将数据传输到显示器时,这将成为一个问题,因为最低有效字节将首先发送,并且LCD希望首先接收最高有效字节。设置

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

演示版


我们可以创建一个简单的演示来观看正在运行的LCD。在帧的开头,它将帧缓冲区刷新为黑色,并绘制一个50x50的正方形。我们可以使用十字移动正方形并使用按钮AB开始更改其颜色

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,以便在内存中分配内存,该内存可用于具有直接内存访问(DMA)的 SPI事务DMA允许SPI外设访问帧缓冲区,而无需占用CPU。如果没有DMA,SPI事务将花费更长的时间。

我们不会执行检查以确保不会在屏幕边界之外进行渲染。


强烈的撕裂感很明显。在桌面应用程序中,消除撕裂的标准方法是使用多个缓冲区。例如,在双缓冲时,有两个缓冲区:前缓冲区和后缓冲区。显示前缓冲区时,
在后方进行记录。然后他们改变位置,然后重复该过程。

ESP32没有足够的RAM具有DMA功能来存储两个帧缓冲区(不幸的是,4 MB的外部SPI RAM没有DMA功能),因此此选项不适合。

ILI9341具有一个信号(TE),该信号可告诉您何时发生VBLANK以便我们可以在显示之前进行写入。但是使用Odroid(或显示模块)时,该信号未连接,因此我们无法访问它。

也许我们可以找到一个不错的值,但是现在我们不会这样做,因为现在我们的任务是简单地在屏幕上显示像素。

资源


所有源代码都可以在这里找到

参考文献



All Articles