EE 入门(二) - 使用 ESP32 与 SPI 显示屏绘图、显示图片、跑贪吃蛇

系列 -

之前淘货买了挺多显示屏的,本文使用的是这一块:

开发板是 ESP-WROOM-32 模组开发板。其他需要的东西:杜邦线、面包板、四个 10 K$\Omega$ 电阻、四个按键。

至于需要的依赖库,我找到如下几个 stars 数较高的支持 ILI9488 + ESP32 的显示屏驱动库:

  • Bodmer/TFT_eSPI: 一个基于 Arudino 框架的 tft 显示屏驱动,支持 STM32/ESP32 等多种芯片。
  • lv_port_esp32: lvgl 官方提供的 esp32 port,但是几百年不更新了,目前仅支持到 esp-idf v4,试用了一波被坑了,不建议使用。
  • esp-idf/peripherals/lcd: ESP 官方的 lcd 示例,不过仅支持部分常见显示屏驱动,比如我这里用的 ili9488 官方就没有。

总之强烈推荐 TFT_eSPI 这个库,很好用,而且驱动支持很齐全。

ESP32 开发有好几种方式:

  1. vscode 的 esp-idf 插件 + 官方的 esp-idf 工具
  2. vscode 的 platformio 插件 + arudino 框架

Bodmer/TFT_eSPI 这个依赖库两种方式都支持,不过看了下官方文档,仓库作者表示 ESP-IDF 的支持是其他人提供的,他不保证能用,所以稳妥起见我选择了 PlatformIO + Arduino 框架作为开发环境。

首先当然是创建一个空项目,点击 VSCode 侧栏的 PlatformIO 图标,再点击列表中的PlatformIO Core CLI 选项进入 shell 执行如下命令:

1
pio project init --ide=vscode -d tft_esp32_arduino

这条命令会创建一个空项目,并配置好 vscode 插件相关配置,这样就算完成了一个空的项目框架。

网上简单搜了下 ESP32 pinout,找到这张图,引脚定义与我的 ESP32 开发板完全一致,用做接线参考:

可以看到这块 ESP32 开发板有两个 SPI 端口:HSPI 跟 VSPI,这里我们使用 HSPI,那么 MOSI/MISO/SCK 三个引脚的接线必须与上图的定义完全一致。而其他引脚随便找个普通 GPIO 口接上就行。

此外背光灯的线我试了下接 GPIO 口不好使,建议直接接在 3V3 引脚上(缺点就是没法通过程序关闭背光,问题不大)。

我的接线如下:

使用 wokwi.com 制作的示意图

接线实操

线接好后需要更新下 PlatformIO 项目根目录 platformio.ini 的配置,使其显示屏引脚相关的参数与我们的接线完全对应起来,这样才能正常驱动这个显示屏。

这里我以驱动库官方提供的模板Bodmer/TFT_eSPI/docs/PlatformIO 为基础,更新了其构建参数对应的引脚,加了点注释,得到的内容如下(如果你的接线与我一致,直接抄就行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
  bodmer/TFT_eSPI@^2.5.0
  Bodmer/TFT_eWidget@^0.0.5
monitor_speed = 115200
build_flags =
  -Os
  -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG
  -DUSER_SETUP_LOADED=1

  ; Define the TFT driver, pins etc here:
  ; 显示屏驱动要对得上
  -DILI9488_DRIVER=1
  # 宽度与高度
  -DTFT_WIDTH=480
  -DTFT_HEIGHT=320
  # SPI 引脚的接线方式,
  -DTFT_MISO=12
  -DTFT_MOSI=13
  # SCLK 在显示屏上对应的引脚可能叫 SCK,是同一个东西
  -DTFT_SCLK=14
  -DTFT_CS=15
  # DC 在显示屏上对应的引脚可能叫 RS 或者 DC/RS,是同一个东西
  -DTFT_DC=4
  -DTFT_RST=2
  # 背光暂时直接接在 3V3 上
  ; -DTFT_BL=27
  # 触摸,暂时不用
  ;-DTOUCH_CS=22
  -DLOAD_GLCD=1
  # 其他配置,保持默认即可
  -DLOAD_FONT2=1
  -DLOAD_FONT4=1
  -DLOAD_FONT6=1
  -DLOAD_FONT7=1
  -DLOAD_FONT8=1
  -DLOAD_GFXFF=1
  -DSMOOTH_FONT=1
  -DSPI_FREQUENCY=27000000

修好后保存修改,platformio 将会自动检测到配置文件变更,并根据配置文件下载 Arduino/ESP32 工具链,更新构建配置、拉取依赖库(建议开个全局代理,不然下载会贼慢)。

现在找几个 demo 跑跑看,新建文件 src/main.ino,从如下文件夹中随便找个 demo copy 进去然后编译上传,看看效果:

可以直接从 libdeps 中 copy examples 代码过来测试:cp .pio/libdeps/esp32dev/TFT_eSPI/examples/480\ x\ 320/TFT_Meters/TFT_Meters.ino src/main.ino

我跑出来的效果:

这需要首先将图片/文字转换成 bitmap 格式的 C 代码,可使用在线工具javl/image2cpp 进行转换,简单演示下:

注意高度与宽度调整为与屏幕大小一致,设置放缩模式,然后色彩改为 RGB565,最后上传图片、生成代码。

将生成好的代码贴到 src/test_img.h 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// We need this header file to use FLASH as storage with PROGMEM directive:

// Icon width and height
const uint16_t imgWidth = 480;
const uint16_t imgHeight = 320;

// 'evt_source', 480x320px
const uint16_t epd_bitmap_evt_source [] PROGMEM = {
  // 这里省略掉图片内容......
}

然后写个主程序 src/main.ino 显示图像:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <TFT_eSPI.h>       // Hardware-specific library

TFT_eSPI tft = TFT_eSPI();  // Invoke custom library

// Include the header files that contain the icons
#include "test_img.h"

void setup()
{
  Serial.begin(115200);
  tft.begin();
  tft.setRotation(1);	// landscape

  tft.fillScreen(TFT_BLACK);
  // Swap the colour byte order when rendering
  tft.setSwapBytes(true);

  // 显示图片
  tft.pushImage(0, 0, imgWidth, imgHeight, epd_bitmap_evt_source);

  delay(2000);
}

void loop() {}

编译上传,效果如下:

N 年前我写的第一篇博客文章,是用 C 语言写一个贪吃蛇,这里把它移植过来玩玩看~

我的旧文章地址为:贪吃蛇—C—基于easyx图形库(下):从画图程序到贪吃蛇【自带穿墙术】 , 里面详细介绍了程序的思路。

那么现在开始代码移植,TFT 屏幕前面已经接好了不需要动,要改的只有软件部分,还有就是添加上下左右四个按键的电路。

首先清空 src 文件夹,新建文件 src/main.ino,内容如下,其中主要逻辑均移植自我前面贴的文章:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#include <math.h>
#include <stdio.h>
#include <TFT_eSPI.h> // Hardware-specific library

#define WIDTH 480
#define HEIGHT 320

// 四个方向键对应的 GPIO 引脚
#define BUTTON_UP_PIN     5
#define BUTTON_LEFT_PIN   18
#define BUTTON_DOWN_PIN   19
#define BUTTON_RIGHT_PIN  21

TFT_eSPI tft = TFT_eSPI(); // Invoke custom library

typedef struct Position // 坐标结构
{
  int x;
  int y;
} Pos;

Pos SNAKE[3000] = {0};
Pos DIRECTION;
Pos EGG;
long SNAKE_LEN;

void setup()
{
  Serial.begin(115200);
  tft.begin();
  tft.setRotation(1); // landscape

  tft.fillScreen(TFT_BLACK);
  // Swap the colour byte order when rendering
  tft.setSwapBytes(true);

  // initialize the pushbutton pin as an input: the default state is LOW
  pinMode(BUTTON_UP_PIN, INPUT);
  pinMode(BUTTON_LEFT_PIN, INPUT);
  pinMode(BUTTON_DOWN_PIN, INPUT);
  pinMode(BUTTON_RIGHT_PIN, INPUT);

  init_game();
}

void loop()
{
  command(); // 获取按键消息
  move();    // 修改头节点坐标-蛇的移动
  eat_egg();
  draw(); // 作图
  eat_self();
  delay(100);
}

void init_game() {
  // 初始化小蛇
  SNAKE_LEN = 1;
  SNAKE[0].x =  random(50, WIDTH - 50); // 头节点位置随机化
  SNAKE[0].y =  random(50, HEIGHT - 50);
  DIRECTION.x = pow(-1, random()); // 初始化方向向量
  DIRECTION.y = 0;
  creat_egg();

  Serial.println("GAM STARTED, Having Fun~");
}

void creat_egg()
{
  while (true)
  {
    int ok = 0;
    EGG.x = random(50, WIDTH - 50); // 头节点位置随机化
    EGG.y = random(50, HEIGHT - 50);
    for (int i = 0; i < SNAKE_LEN; i++)
    {
      if (SNAKE[i].x == 0 && SNAKE[i].y == 0)
        continue;
      if (fabs(SNAKE[i].x - EGG.x) <= 10 && fabs(SNAKE[i].y - EGG.y) <= 10)
        ok = -1;
      break;
    }
    if (ok == 0)
      return;
  }
}

void command() // 获取按键命令命令
{
  if (digitalRead(BUTTON_LEFT_PIN) == HIGH) {
      if (DIRECTION.x != 1 || DIRECTION.y != 0)
      { // 如果不是反方向,按键才有效
        Serial.println("Turn Left!");
        DIRECTION.x = -1;
        DIRECTION.y = 0;
      }
  } else if (digitalRead(BUTTON_RIGHT_PIN) == HIGH) {
      if (DIRECTION.x != -1 || DIRECTION.y != 0)
      {
        Serial.println("Turn Right!");
        DIRECTION.x = 1;
        DIRECTION.y = 0;
      }
  } else if (digitalRead(BUTTON_UP_PIN) == HIGH) {
      if (DIRECTION.x != 0 || DIRECTION.y != 1)
      {  // 注意 Y 轴,向上是负轴,因为屏幕左上角是原点 (0,0)
        Serial.println("Turn Up!");
        DIRECTION.x = 0;
        DIRECTION.y = -1;
      }
  } else if (digitalRead(BUTTON_DOWN_PIN) == HIGH) {
      if (DIRECTION.x != 0 || DIRECTION.y != -1)
      {
        Serial.println("Turn Down!");
        DIRECTION.x = 0;
        DIRECTION.y = 1;
      }
  }
}

void move() // 修改各节点坐标以达到移动的目的
{
  // 覆盖尾部走过的痕迹
  tft.drawRect(SNAKE[SNAKE_LEN - 1].x - 5, SNAKE[SNAKE_LEN - 1].y - 5, 10, 10, TFT_BLACK);

  for (int i = SNAKE_LEN - 1; i > 0; i--)
  {
    SNAKE[i].x = SNAKE[i - 1].x;
    SNAKE[i].y = SNAKE[i - 1].y;
  }
  SNAKE[0].x += DIRECTION.x * 10; // 每次移动10pix
  SNAKE[0].y += DIRECTION.y * 10;

  if (SNAKE[0].x >= WIDTH) // 如果越界,从另一边出来
    SNAKE[0].x = 0;
  else if (SNAKE[0].x <= 0)
    SNAKE[0].x = WIDTH;
  else if (SNAKE[0].y >= HEIGHT)
    SNAKE[0].y = 0;
  else if (SNAKE[0].y <= 0)
    SNAKE[0].y = HEIGHT;
}

void eat_egg()
{
  if (fabs(SNAKE[0].x - EGG.x) <= 5 && fabs(SNAKE[0].y - EGG.y) <= 5)
  {
    // shade old egg
    tft.drawCircle(EGG.x, EGG.y, 5, TFT_BLACK);
    creat_egg();
    // add snake node
    SNAKE_LEN += 1;
    for (int i = SNAKE_LEN - 1; i > 0; i--)
    {
      SNAKE[i].x = SNAKE[i - 1].x;
      SNAKE[i].y = SNAKE[i - 1].y;
    }
    SNAKE[0].x += DIRECTION.x * 10; // 每次移动10pix
    SNAKE[0].y += DIRECTION.y * 10;
  }
}

void draw() // 画出蛇和食物
{
  for (int i = 0; i < SNAKE_LEN; i++)
  {
    tft.drawRect(SNAKE[i].x - 5, SNAKE[i].y - 5, 10, 10, TFT_BLUE);
  }
  tft.drawCircle(EGG.x, EGG.y, 5, TFT_RED);
}

void eat_self()
{
  if (SNAKE_LEN == 1)
    return;
  for (int i = 1; i < SNAKE_LEN; i++)
    if (fabs(SNAKE[i].x - SNAKE[0].x) <= 5 && fabs(SNAKE[i].y - SNAKE[0].y) <= 5)
    {
      delay(1000);
      tft.setTextColor(TFT_RED, TFT_BLACK);
      tft.drawString("GAME OVER!", 200, 150, 4);
      delay(3000);

      setup();
      break;
    }
}

代码就这么点,没几行,接下来我们来接一下按键电路,这部分是参考了 arduino 的官方文档How to Wire and Program a Button

接线方式如下,主要原理就是通过 GND 接线,使四个方向键对应的 GPIO 口默认值为低电平。当按键按下时,GPIO 口会被拉升成高电平,从而使程序识别到该按键被按下。

接线示意图如下(简单起见,省略了前面的显示屏接线部分):

使用 wokwi.com 制作的示意图

现在运行程序,效果如下(手上只有两个按键,所以是双键模式请见谅…):

相关内容