首页
  • 上位机
  • 嵌入式
  • AI
  • Web
教程
MarkDown语法
有趣的项目
其他
GitHub
首页
  • 上位机
  • 嵌入式
  • AI
  • Web
教程
MarkDown语法
有趣的项目
其他
GitHub
  • ESP32 LVGL 开发教程:基于 ESP-IDF 与 PlatformIO

ESP32 LVGL 开发教程:基于 ESP-IDF 与 PlatformIO

本教程旨在为 ESP32 开发者提供使用 LVGL (Light and Versatile Graphics Library) 创建图形用户界面 (GUI) 的全面指南。我们将主要关注使用 Espressif 官方的 ESP-IDF (Espressif IoT Development Framework) 作为开发环境,并利用 esp_lvgl_port 组件简化集成过程。教程将涵盖显示屏(ILI9341, ST7789)、触摸屏(XPT2046, FT6236)以及外部按钮的配置与使用。

此外,本教程还将提供一个补充章节,介绍如何在 PlatformIO IDE 中使用 Arduino 框架进行 ESP32 和 LVGL 的开发,为开发者提供另一种流行的选择。

第 1 部分:ESP32 & LVGL 与 ESP-IDF (使用 esp_lvgl_port)

第 1 章:ESP32 与 LVGL 嵌入式 GUI 简介

1.1 ESP32 微控制器概述

ESP32 是一款功能强大、低成本、低功耗的片上系统 (SoC) 微控制器,集成了 Wi-Fi 和双模蓝牙功能 1。它采用 Tensilica Xtensa LX6、LX7 或 RISC-V 处理器,提供单核和双核版本,并内置天线开关、射频巴伦、功率放大器等模块 1。ESP32 因其强大的处理能力、丰富的外设接口(GPIO、SPI、I2C、ADC、DAC 等)以及对多种开发框架的支持,成为物联网 (IoT) 和嵌入式图形应用的热门选择 3。

1.2 LVGL:轻量级通用图形库

LVGL (Light and Versatile Graphics Library) 是一个开源的嵌入式图形库,专为资源受限的微控制器设计,旨在提供美观且高度互动的用户界面 3。它具有内存占用小、可移植性强、拥有丰富的控件(按钮、标签、滑块、图表等)和主题系统等优点 5。LVGL 的架构使其能够轻松集成到各种操作系统(如 FreeRTOS)或裸机系统中。

1.3 为何选择 ESP-IDF 与 LVGL 结合?

ESP-IDF 是 Espressif 官方的 ESP32 开发框架,提供了底层的硬件驱动、FreeRTOS 内核、网络协议栈以及丰富的组件库 6。将 LVGL 与 ESP-IDF 结合,开发者可以充分利用 ESP32 的硬件性能和 ESP-IDF 的系统级支持,创建复杂且响应迅速的 GUI 应用。特别是通过 esp_lvgl_port 组件,可以大大简化 LVGL 在 ESP-IDF 项目中的集成和配置工作,包括显示驱动、输入设备驱动以及 LVGL 核心任务的管理 5。这种组合为开发者提供了一个强大而高效的嵌入式 GUI 开发平台。

ESP-IDF 的组件化特性,如通过 idf.py add-dependency 添加依赖,反映了其生态系统的成熟,使得共享和重用如 LVGL 及其驱动等组件变得更加容易 6。这不仅简化了项目的初始设置,也促进了社区驱动的组件开发和共享,例如 ESP 组件注册表中的各种驱动程序 6。

第 2 章:使用 esp_lvgl_port 搭建 ESP-IDF 项目

2.1 esp_lvgl_port 组件简介

esp_lvgl_port 是 Espressif 官方提供的一个 ESP-IDF 组件,旨在简化 LVGL 在 ESP32 项目中的集成 5。它封装了 LVGL 的初始化、显示驱动接口、输入设备(触摸、按键、编码器)接口以及与 ESP-IDF 系统(如 FreeRTOS 任务、定时器)的集成逻辑 5。使用此组件,开发者可以更专注于 LVGL 应用层逻辑的开发,而无需深入处理底层的驱动和移植细节。该组件支持 LVGL v8 和 v9,并兼容 ESP-IDF v4.4 及以上版本 5。

esp_lvgl_port 的引入显著降低了在 ESP-IDF 上启动 LVGL 项目的“门槛”。若不使用此组件,开发者需要手动完成显示驱动的配置(SPI/I2C 初始化、命令发送、像素数据传输)、实现 LVGL 的 flush_cb 回调、配置输入设备驱动(触摸/按键的轮询或中断处理)、实现 read_cb 回调、将 LVGL 的 tick 与 FreeRTOS 集成、创建和管理 LVGL 任务,并处理线程安全所需的互斥锁等。esp_lvgl_port 为这些繁琐的步骤提供了自动化或清晰的 API,从而减少了样板代码,加快了从项目搭建到实际显示效果的迭代周期。

2.2 将 esp_lvgl_port 添加到您的项目

将 esp_lvgl_port 添加到 ESP-IDF 项目非常简单。在您的项目根目录下,打开终端并执行以下命令:

idf.py add-dependency "espressif/esp_lvgl_port^2.6.0"

(请注意,版本号 ^2.6.0 可能会更新,建议查阅官方文档获取最新版本 7)。此命令会将 esp_lvgl_port 及其依赖(包括 LVGL 库本身)添加到项目的 idf_component.yml 文件中。在下次构建项目时(例如执行 idf.py build),IDF 组件管理器会自动从 ESP 组件注册表下载并链接这些组件。

如果需要特定版本的 LVGL(例如,项目依赖 LVGL v8),可以在项目的 idf_component.yml 文件中明确指定 7:

dependencies:
  espressif/esp_lvgl_port: "^2.6.0" # 或者您选择的 esp_lvgl_port 版本
  lvgl/lvgl: # 显式指定 LVGL 版本
    version: "^8" # 例如,使用 LVGL 8.x 的最新兼容版本
    public: true # 通常需要设为 public

2.3 esp_lvgl_port 初始化

在您的主应用程序文件(通常是 main/main.c)中,包含 esp_lvgl_port.h 头文件,并调用 lvgl_port_init() 函数进行初始化。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_lvgl_port.h" // 引入 esp_lvgl_port 头文件

static const char *TAG = "app_main";

// LVGL 显示句柄,后续章节会用到
static lv_disp_t *disp_handle;

void app_main(void)
{
    ESP_LOGI(TAG, "Initializing LVGL port...");
    const lvgl_port_cfg_t lvgl_cfg = ESP_LVGL_PORT_INIT_CONFIG();
    // lvgl_cfg.task_priority = 优先级; // 可选:调整 LVGL 任务优先级
    // lvgl_cfg.task_stack = 栈大小;  // 可选:调整 LVGL 任务栈大小
    // lvgl_cfg.task_affinity = 核心; // 可选:绑定 LVGL 任务到特定核心
    // lvgl_cfg.timer_period_ms = 周期; // 可选:LVGL 定时器周期
    esp_err_t err = lvgl_port_init(&lvgl_cfg);
    if (err!= ESP_OK) {
        ESP_LOGE(TAG, "LVGL port initialization failed: %s", esp_err_to_name(err));
        return;
    }
    ESP_LOGI(TAG, "LVGL port initialized successfully.");

    // 后续章节将在此处添加显示、触摸和按钮的初始化代码

    // LVGL 的 lv_timer_handler() 由 esp_lvgl_port 内部任务自动调用
    // 用户代码通常在此之后创建 LVGL UI 元素
}

ESP_LVGL_PORT_INIT_CONFIG() 宏提供了一组默认的配置参数 7。开发者可以根据需要修改 lvgl_port_cfg_t 结构体中的成员,例如任务优先级、堆栈大小、核心亲和性等,以优化系统性能和资源使用。

2.4 lvgl_port_cfg_t 关键成员解析

lvgl_port_cfg_t 结构体用于配置 esp_lvgl_port 的行为。以下是一些关键成员及其作用,这些成员可以通过 ESP_LVGL_PORT_INIT_CONFIG() 获取默认值后进行修改:

成员描述默认值 (示例)
task_priorityLVGL 主处理任务的 FreeRTOS 优先级。4
task_stackLVGL 主处理任务的堆栈大小(字节)。4096
task_affinityLVGL 主处理任务运行的 CPU 核心 (0, 1, 或 tskNO_AFFINITY 表示无亲和性)。tskNO_AFFINITY
timer_period_msLVGL 内部定时器 lv_timer_handler() 的调用周期(毫秒)。5
task_max_sleep_msLVGL 任务在没有事件或刷新请求时的最大休眠时间(毫秒)。500
memory_alloc_methodLVGL 使用的内存分配方法 (例如 LV_MEM_ALLOC_INTERNAL, LV_MEM_ALLOC_EXTERNAL)。(LVGL默认)

理解这些参数对于需要优化或调试系统级交互的高级用户至关重要。例如,如果应用中有其他高优先级任务,可能需要调整 task_priority;如果 UI 非常复杂导致栈溢出,则需要增加 task_stack 12。

第 3 章:显示设置与基础图形

3.1 选择您的显示屏

ESP32 支持多种类型的显示屏接口,常见的 SPI 显示控制器如 ILI9341 和 ST7789 在爱好者模块中非常流行,它们通常具有良好的驱动支持和性价比 9。本教程将以这些 SPI 接口的显示屏为例。

3.2 硬件连接 (SPI 示例 - ILI9341/ST7789)

典型的 SPI 显示屏连接包括 MOSI (Master Out Slave In)、MISO (Master In Slave Out, 如果需要从屏幕读取数据)、SCLK (Serial Clock)、CS (Chip Select)、DC (Data/Command,也称 RS) 和 RST (Reset)。此外,通常还有一个 BLK/LED 引脚用于控制背光。

下面是一个通用的 ESP32 与 SPI 显示屏连接的 Mermaid 图:

表 3.1:SPI 显示屏 (ILI9341/ST7789) ESP32 引脚连接示例

显示屏引脚ESP32 GPIO 示例功能
VCC3.3V电源
GNDGND地线
CSGPIO 15片选
RSTGPIO 4复位
DC/RSGPIO 2数据/命令选择
MOSI/SDAGPIO 13SPI MOSI
SCLK/SCKGPIO 14SPI SCLK
MISO/SDOGPIO 12 (可选)SPI MISO
LED/BLKGPIO 21背光控制

注意:以上 GPIO 引脚仅为示例,请根据您的 ESP32 开发板和实际接线进行调整。

提供具体的引脚连接示例对用户非常重要,可以最大限度地减少常见的接线错误,这些错误是导致项目初期受挫的常见原因。

3.3 使用 esp_lvgl_port 配置显示驱动

esp_lvgl_port 组件底层依赖于 esp_lcd 组件来与显示硬件交互 5。esp_lcd 提供了一个抽象的驱动框架,支持多种 LCD 控制器 9。

步骤 1: 初始化 LCD IO 7

首先,需要配置与 LCD 通信的 SPI 总线,并创建一个 LCD IO 句柄。

  • 配置 SPI 总线参数,如 MOSI、MISO、SCLK 引脚,以及最大传输大小等。
  • 调用 spi_bus_initialize() 初始化 SPI 总线。
  • 定义 esp_lcd_panel_io_spi_config_t 结构体,指定 DC、CS 引脚,PCLK (像素时钟,即 SPI 时钟) 频率,命令和参数的位数,SPI 模式以及传输队列深度 20。
  • 调用 esp_lcd_new_panel_io_spi() 创建 LCD IO 句柄 (io_handle)。

步骤 2: 初始化 LCD 面板驱动 7

接下来,根据具体的 LCD 控制器型号初始化面板驱动。

  • 对于 ILI9341:
    • 定义 esp_lcd_panel_dev_config_t(通用配置)或更具体的 esp_lcd_ili9341_config_t 21。设置复位引脚、颜色空间 (例如 ESP_LCD_COLOR_SPACE_RGB)、每像素位数 (例如 16 位用于 RGB565)。
    • 调用 esp_lcd_new_panel_ili9341(io_handle, &panel_config, &panel_handle) 创建面板句柄 (panel_handle) 9。
  • 对于 ST7789:
    • 定义 esp_lcd_panel_dev_config_t。设置复位引脚、颜色空间、每像素位数。
    • 调用 esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle) 创建面板句柄 7。
  • 执行面板复位 esp_lcd_panel_reset(panel_handle) 和初始化 esp_lcd_panel_init(panel_handle)。
  • 打开显示 esp_lcd_panel_disp_on_off(panel_handle, true)。
  • 背光控制: esp_lcd_panel_disp_on_off 函数不控制背光。背光通常由一个单独的 GPIO 控制,需要额外设置其电平 14。

步骤 3: 将显示屏添加到 esp_lvgl_port 7

最后,将初始化好的 LCD 面板注册到 esp_lvgl_port。

  • 定义 lvgl_port_display_cfg_t 结构体,填充以下成员:
    • io_handle: 上一步创建的 LCD IO 句柄。
    • panel_handle: 上一步创建的 LCD 面板句柄。
    • buffer_size: LVGL 绘图缓冲区大小(像素数)。例如,屏幕宽度 * 屏幕高度 / 10,或者对于双缓冲,则是整个屏幕大小。
    • double_buffer: 是否启用双缓冲 (true/false)。
    • hres, vres: 屏幕的水平和垂直分辨率。
    • monochrome: 是否为单色屏 (false)。
    • rotation: 屏幕旋转配置,包括 swap_xy, mirror_x, mirror_y。
    • flags: 其他标志,如 buff_dma (是否使用 DMA 传输缓冲区数据),swap_bytes (颜色字节序交换)。
  • 调用 disp_handle = lvgl_port_add_disp(&disp_cfg); 将显示屏添加到 LVGL。disp_handle 是一个 lv_disp_t* 类型的句柄,后续 LVGL 操作会用到。

esp_lcd 组件作为关键的抽象层,将显示硬件的细节与 esp_lvgl_port 及 LVGL 本身解耦。这种设计提升了代码的可重用性,并使得支持新的显示控制器变得更加容易,只需为新控制器实现 esp_lcd 驱动即可,而无需修改 esp_lvgl_port 或 LVGL 核心 9。

esp_lcd_panel_io_spi_config_t 和 esp_lcd_panel_dev_config_t(及其特定控制器变体)中的配置选项,突显了显示初始化所需的复杂性和精度。这些设置(如时钟速度、命令/参数位数、颜色空间)的错误是开发者常见的陷阱 20。因此,务必仔细核对数据手册并正确配置引脚。

lvgl_port_display_cfg_t 中的 buffer_size 和 double_buffer 选项直接影响 RAM 使用和性能。在像 ESP32 这样内存受限的 MCU 上,这是一个关键的权衡。全屏 16 位缓冲区(例如 320x240x2 字节 = 150KB)通常需要 PSRAM。esp_lvgl_port 允许部分缓冲(例如 buffer_size = H_RES * V_RES / N)作为一种折衷方案。这个选择不仅影响 LVGL,还影响应用程序其余部分可用的内存。

以下是一个结合了 ILI9341 21 和 esp_lvgl_port 的概念性代码示例:

#include "esp_lcd.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_vendor.h" // 包含特定厂商驱动头文件,如 esp_lcd_ili9341.h
#include "esp_lcd_panel_ops.h"
#include "esp_lvgl_port.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "esp_log.h"

// 假设已包含 ILI9341 驱动头文件: #include "esp_lcd_ili9341.h"
// 或 ST7789 驱动头文件: #include "esp_lcd_st7789.h"

#define LCD_HOST  SPI2_HOST // 或其他 SPI 主机

// 引脚定义 (请替换为您的实际引脚)
#define EXAMPLE_LCD_PIXEL_CLOCK_HZ    (20 * 1000 * 1000) // SPI 时钟频率
#define EXAMPLE_LCD_BK_LIGHT_ON_LEVEL 1                 // 背光点亮电平
#define EXAMPLE_PIN_NUM_SCLK          14
#define EXAMPLE_PIN_NUM_MOSI          13
#define EXAMPLE_PIN_NUM_MISO          12                 // MISO,如果显示屏需要读取则连接
#define EXAMPLE_PIN_NUM_LCD_DC        2
#define EXAMPLE_PIN_NUM_LCD_RST       4
#define EXAMPLE_PIN_NUM_LCD_CS        15
#define EXAMPLE_PIN_NUM_BK_LIGHT      21                 // 背光控制引脚示例

#define EXAMPLE_LCD_H_RES             320                // 水平分辨率
#define EXAMPLE_LCD_V_RES             240                // 垂直分辨率

static const char *TAG_LCD = "LCD_INIT";
extern lv_disp_t *disp_handle; // 从 main.c 引用或在此定义

static void initialize_lcd(void) {
    ESP_LOGI(TAG_LCD, "Turning off backlight");
    gpio_config_t bk_gpio_config = {
       .mode = GPIO_MODE_OUTPUT,
       .pin_bit_mask = 1ULL << EXAMPLE_PIN_NUM_BK_LIGHT
    };
    ESP_ERROR_CHECK(gpio_config(&bk_gpio_config));
    gpio_set_level(EXAMPLE_PIN_NUM_BK_LIGHT,!EXAMPLE_LCD_BK_LIGHT_ON_LEVEL);


    ESP_LOGI(TAG_LCD, "Initialize SPI bus");
    spi_bus_config_t buscfg = {
       .mosi_io_num = EXAMPLE_PIN_NUM_MOSI,
       .miso_io_num = EXAMPLE_PIN_NUM_MISO,
       .sclk_io_num = EXAMPLE_PIN_NUM_SCLK,
       .quadwp_io_num = -1,
       .quadhd_io_num = -1,
       .max_transfer_sz = EXAMPLE_LCD_H_RES * 80 * sizeof(uint16_t) + 8, // 根据需要调整
    };
    ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, &buscfg, SPI_DMA_CH_AUTO));

    ESP_LOGI(TAG_LCD, "Install panel IO");
    esp_lcd_panel_io_handle_t io_handle = NULL;
    esp_lcd_panel_io_spi_config_t io_config = {
       .dc_gpio_num = EXAMPLE_PIN_NUM_LCD_DC,
       .cs_gpio_num = EXAMPLE_PIN_NUM_LCD_CS,
       .pclk_hz = EXAMPLE_LCD_PIXEL_CLOCK_HZ,
       .lcd_cmd_bits = 8,
       .lcd_param_bits = 8,
       .spi_mode = 0,
       .trans_queue_depth = 10,
    };
    ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &io_handle));

    esp_lcd_panel_handle_t panel_handle = NULL;
    // 以 ILI9341 为例
    ESP_LOGI(TAG_LCD, "Install ILI9341 panel driver");
    esp_lcd_panel_dev_config_t panel_config = { // 通用设备配置
       .reset_gpio_num = EXAMPLE_PIN_NUM_LCD_RST,
       .rgb_endian = LCD_RGB_ENDIAN_RGB, // 根据屏幕调整 RGB 或 BGR
       .bits_per_pixel = 16,             // RGB565 为 16 位
    };
    // 如果使用 esp_lcd_ili9341.h 中的专用初始化函数
    // esp_lcd_ili9341_config_t panel_config_ili9341 = {
    //     .reset_gpio_num = EXAMPLE_PIN_NUM_LCD_RST,
    //     .color_space = ESP_LCD_COLOR_SPACE_RGB,
    //     .bits_per_pixel = 16,
    //      //.madctl_val = 0xB0, // [21] 中的示例,根据屏幕方向调整
    //      //.cmd_list = NULL, // 可提供自定义初始化命令
    //      //.init_cmds_size = 0,
    // };
    // ESP_ERROR_CHECK(esp_lcd_new_panel_ili9341(io_handle, &panel_config_ili9341, &panel_handle));
    ESP_ERROR_CHECK(esp_lcd_new_panel_ili9341(io_handle, &panel_config, &panel_handle));


    // 对于 ST7789 (结构类似, 使用 esp_lcd_new_panel_st7789)
    // esp_lcd_panel_dev_config_t panel_config_st7789 = {... };
    // ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config_st7789, &panel_handle));

    ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
    ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
    ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); // 打开显示

    ESP_LOGI(TAG_LCD, "Turning on backlight");
    gpio_set_level(EXAMPLE_PIN_NUM_BK_LIGHT, EXAMPLE_LCD_BK_LIGHT_ON_LEVEL);

    ESP_LOGI(TAG_LCD, "Add LCD screen to LVGL port");
    const lvgl_port_display_cfg_t disp_lvgl_cfg = {
       .io_handle = io_handle,
       .panel_handle = panel_handle,
       .buffer_size = EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 10, // 部分缓冲
       .double_buffer = false, // 若使用完整双缓冲,则为 true,且 buffer_size 需为全屏大小
       .hres = EXAMPLE_LCD_H_RES,
       .vres = EXAMPLE_LCD_V_RES,
       .monochrome = false,
       .rotation = { // 根据需要配置旋转
           .swap_xy = false,
           .mirror_x = false,
           .mirror_y = false,
        },
       .flags = {
           .buff_dma = true, // 尽可能使用 DMA 传输缓冲区
           .swap_bytes = false // 根据屏幕的字节序配置
        }
    };
    disp_handle = lvgl_port_add_disp(&disp_lvgl_cfg);
    ESP_LOGI(TAG_LCD, "LVGL display added successfully.");
}

// 在 app_main 中, lvgl_port_init() 之后调用:
// initialize_lcd();

请确保在 main.c 中定义 lv_disp_t *disp_handle; 并在 app_main 中调用 initialize_lcd();。

3.4 理解 LVGL 显示驱动和刷新回调

LVGL 本身不直接操作硬件,它将图形渲染到内部的一个或多个缓冲区中。然后,通过一个名为 flush_cb (刷新回调) 的函数,将这些缓冲区的已更改区域复制到实际的显示屏上。当使用 esp_lvgl_port 时,该组件会处理 flush_cb 的实现,它内部通常使用 esp_lcd_panel_draw_bitmap() 函数来完成实际的像素数据传输 7。开发者无需手动实现此回调。

3.5 第一个 LVGL 绘图示例

在显示屏成功添加到 esp_lvgl_port 并返回 disp_handle 后,就可以开始使用 LVGL API 进行绘图了。如果从 LVGL 内部任务以外的任务调用 LVGL API,或者存在任何并发访问的可能,务必使用 lvgl_port_lock(0); 和 lvgl_port_unlock(); 来保护 LVGL API 调用,以确保线程安全。

// 在 app_main 中, 显示屏添加完成且 LVGL 运行后
// (假设 initialize_lcd() 已被调用且 disp_handle 已被赋值)

// lvgl_port_lock(0); // 如果在 LVGL 任务外调用,则需要加锁

// lv_obj_t *scr = lv_disp_get_scr_act(disp_handle); // 获取当前活动屏幕

// // 创建一个简单的按钮
// lv_obj_t *btn = lv_btn_create(scr);
// lv_obj_set_size(btn, 100, 50);
// lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0);

// lv_obj_t *label = lv_label_create(btn);
// lv_label_set_text(label, "Hello!");
// lv_obj_center(label);

// lvgl_port_unlock(); // 解锁

注意:上述代码片段应在 initialize_lcd() 成功执行后,并在 LVGL 主循环开始处理事件前(或在其任务中)执行。通常,UI 创建逻辑会放在一个单独的函数中,在所有初始化完成后调用。

第 4 章:实现触摸控制

4.1 常见的触摸控制器

触摸屏为嵌入式设备提供了直观的交互方式。常见的触摸控制器类型包括:

  • 电阻式触摸控制器:如 XPT2046,通常通过 SPI 接口与 MCU 通信 15。
  • 电容式触摸控制器:如 FT6236 (或 FT6x06 系列),通常通过 I2C 接口与 MCU 通信 15。

4.2 硬件连接

触摸控制器的硬件连接取决于其类型(SPI 或 I2C)以及是否与显示屏共享总线。

  • XPT2046 (SPI): 通常与显示屏共享 MOSI, MISO, SCLK 引脚,但需要独立的 CS(片选)引脚,并可能有一个可选的 IRQ(中断)引脚 16。
  • FT6236 (I2C): 需要 SDA, SCL 引脚,以及可选的 IRQ 和 RST (复位) 引脚 18。

表 4.1:触摸控制器 ESP32 引脚连接示例

控制器引脚ESP32 GPIO 示例备注
XPT2046MOSIGPIO 13 (共享)与显示屏 SPI MOSI 共享
SCLKGPIO 14 (共享)与显示屏 SPI SCLK 共享
MISOGPIO 12 (共享)与显示屏 SPI MISO 共享
CS_T (Touch CS)GPIO 33触摸屏独立片选
IRQ_T (Touch IRQ)GPIO 32 (可选)触摸中断引脚
FT6236SDAGPIO 21I2C 数据线
SCLGPIO 22I2C 时钟线
INT (Touch IRQ)GPIO 39 (可选)触摸中断引脚
RST_T (Touch RST)GPIO 38 (可选)触摸复位引脚

注意:以上 GPIO 引脚仅为示例,请根据您的实际硬件和引脚规划进行调整。

提供清晰的硬件设置指南,特别是区分 SPI 和 I2C 的连接方式以及共享引脚的情况,对用户至关重要。

4.3 使用 esp_lvgl_port 配置触摸驱动

esp_lvgl_port 利用 esp_lcd_touch 系列组件来支持触摸输入 7。这些组件(如 esp_lcd_touch_xpt2046、esp_lcd_touch_ft6x36)为不同的触摸控制器提供了标准化的接口,这与 esp_lcd 为显示控制器提供标准化接口的思路一致,体现了 ESP-IDF 生态系统的模块化优势 14。

步骤 1: 初始化触摸控制器 IO 和驱动

  • 对于 XPT2046 (SPI): 7
    • 确保 XPT2046 使用的 SPI 总线已初始化(可以与显示屏共享)。
    • 如果触摸屏的 CS 与显示屏不同,或有其他特定配置,需要为触摸屏配置 esp_lcd_panel_io_spi_config_t。可以使用宏 ESP_LCD_TOUCH_IO_SPI_XPT2046_CONFIG(TOUCH_CS_PIN) 来获取一个推荐的配置 24。
    • 调用 esp_lcd_new_panel_io_spi() 获取触摸屏的 IO 句柄 (tp_io_handle)。
    • 定义 esp_lcd_touch_config_t 结构体,设置 x_max、y_max (通常为显示屏的分辨率),可选的中断引脚 int_gpio_num,以及触摸方向相关的标志位 (swap_xy, mirror_x, mirror_y)。
    • 调用 esp_lcd_touch_new_spi_xpt2046(tp_io_handle, &touch_config, &touch_handle) 创建触摸驱动句柄 24。
  • 对于 FT6236 (I2C): 7
    • 初始化 I2C 总线 (例如,通过 bsp_i2c_init() 或标准的 i2c_driver_install 和 i2c_param_config)。
    • 定义 esp_lcd_panel_io_i2c_config_t,指定 FT6236 的 I2C 从地址和时钟速度。esp_lvgl_port 的文档中有针对 GT911 的示例 ESP_LCD_TOUCH_IO_I2C_GT911_CONFIG(),可以参考其结构进行适配 7。
    • 调用 esp_lcd_new_panel_io_i2c() 获取触摸屏的 IO 句柄 (tp_io_handle)。
    • 定义 esp_lcd_touch_config_t 结构体,设置 x_max、y_max,可选的中断和复位引脚,以及触摸方向标志。
    • 调用 esp_lcd_touch_new_i2c_ft6x36(tp_io_handle, &touch_config, &touch_handle) 创建触摸驱动句柄。

步骤 2: 将触摸输入添加到 esp_lvgl_port 7

  • 定义 lvgl_port_touch_cfg_t 结构体:
    • disp: 指向之前通过 lvgl_port_add_disp() 返回的 lv_disp_t* 显示句柄。
    • handle: 指向上一步创建的 esp_lcd_touch_handle_t 触摸驱动句柄。
  • 调用 lv_indev_t *touch_indev = lvgl_port_add_touch(&touch_cfg); 将触摸设备注册到 LVGL。
  • 保存返回的 touch_indev 句柄,以便后续可能需要移除。

可选的 IRQ(中断)引脚对于触摸控制器可以显著提高响应速度并降低 CPU 负载,因为它使得系统可以从轮询检测转变为中断驱动的触摸检测。esp_lvgl_port 的架构理想情况下应能利用此机制以实现节能和效率提升 24。这对于电池供电设备尤为重要,esp_lvgl_port 的节能特性很可能依赖于这类机制 7。

对于某些电阻触摸屏(如 XPT2046),如果原始坐标与屏幕像素映射不准确,可能需要进行触摸校准。虽然 esp_lvgl_port 或 esp_lcd_touch 可能提供一些基本的变换(如 swap_xy、mirror_x/y),但完整的校准程序可能需要在应用层面实现或作为扩展功能 。

以下是基于 XPT2046 的概念性代码示例 7:

#include "esp_lcd_touch.h"
#include "esp_lcd_touch_xpt2046.h" // 引入 XPT2046 驱动头文件 (例如来自 atanisoft/esp_lcd_touch_xpt2046 组件)
#include "esp_lvgl_port.h"
#include "esp_log.h"

// 假设与显示屏共享 SPI 总线
#define TOUCH_SPI_HOST      LCD_HOST // 使用与 LCD 相同的 SPI 主机
#define EXAMPLE_PIN_NUM_TOUCH_CS  33   // 触摸屏片选引脚示例
// #define EXAMPLE_PIN_NUM_TOUCH_IRQ 32 // 可选的触摸中断引脚

static const char *TAG_TOUCH = "TOUCH_INIT";
extern lv_disp_t *disp_handle; // 从主程序获取显示句柄
static lv_indev_t *touch_indev_handle;

static void initialize_touch_xpt2046(lv_disp_t *disp) {
    esp_lcd_panel_io_handle_t tp_io_handle = NULL;
    // SPI IO 配置,使用 XPT2046 的特定 CS 引脚
    // ESP_LCD_TOUCH_IO_SPI_XPT2046_CONFIG 宏来自 atanisoft/esp_lcd_touch_xpt2046 组件
    // 如果您未使用此组件,则需要手动配置 esp_lcd_panel_io_spi_config_t
    const esp_lcd_panel_io_spi_config_t tp_io_config = ESP_LCD_TOUCH_IO_SPI_XPT2046_CONFIG(EXAMPLE_PIN_NUM_TOUCH_CS);

    ESP_LOGI(TAG_TOUCH, "Initializing touch IO for XPT2046");
    ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)TOUCH_SPI_HOST, &tp_io_config, &tp_io_handle));

    esp_lcd_touch_handle_t tp_handle = NULL;
    esp_lcd_touch_config_t tp_cfg = {
       .x_max = EXAMPLE_LCD_H_RES, // 使用之前定义的屏幕宽度
       .y_max = EXAMPLE_LCD_V_RES, // 使用之前定义的屏幕高度
       .rst_gpio_num = -1,         // XPT2046 通常没有复位引脚
       .int_gpio_num = -1,         // EXAMPLE_PIN_NUM_TOUCH_IRQ, // 如果使用中断,则设置此引脚
       .levels = {
           .interrupt = 0,         // 如果使用中断,设置中断有效电平 (例如低电平有效)
        },
       .flags = {                  // 根据触摸屏和显示方向配置
           .swap_xy = 0,
           .mirror_x = 0,
           .mirror_y = 0,
        },
        //.process_coordinates = NULL, // 可选的坐标处理回调,用于校准等
        //.driver_data = NULL,
    };
    ESP_LOGI(TAG_TOUCH, "Installing XPT2046 touch driver");
    ESP_ERROR_CHECK(esp_lcd_touch_new_spi_xpt2046(tp_io_handle, &tp_cfg, &tp_handle));

    const lvgl_port_touch_cfg_t lvgl_touch_cfg = {
       .disp = disp,
       .handle = tp_handle,
    };
    touch_indev_handle = lvgl_port_add_touch(&lvgl_touch_cfg);
    ESP_LOGI(TAG_TOUCH, "XPT2046 touch input initialized and added to LVGL.");
}

// 在 app_main 中, 显示屏添加完成后调用:
// initialize_touch_xpt2046(disp_handle);

请确保在 main.c 中正确获取 disp_handle 并在适当的时候调用 initialize_touch_xpt2046()。

4.4 LVGL 输入设备注册和读取回调

LVGL 通过周期性调用注册输入设备的 read_cb (读取回调) 函数来轮询输入状态 26。当使用 esp_lvgl_port 时,它负责处理输入设备的注册,并提供 read_cb 的实现。这个实现内部会调用 esp_lcd_touch 组件的函数,如 esp_lcd_touch_read_data() 来从控制器读取原始数据,以及 esp_lcd_touch_get_coordinates() 来获取处理后的坐标点 7。esp_lcd_touch_xpt2046 组件的文档也展示了如何将其与 LVGL 的 read_cb 集成 24。

read_cb 函数需要填充一个 lv_indev_data_t 结构体,其中包含触摸状态 (state,如 LV_INDEV_STATE_PRESSED 或 LV_INDEV_STATE_RELEASED) 和触摸点坐标 (point.x, point.y) 。

4.5 触摸交互示例

现在,我们可以修改第 3 章中的按钮示例,使其能够响应触摸事件。我们需要为 LVGL 按钮添加一个事件回调函数,用于处理 LV_EVENT_CLICKED 事件。

static void my_touch_event_cb(lv_event_t * e) {
    lv_event_code_t code = lv_event_get_code(e); // 获取事件代码
    lv_obj_t *obj = lv_event_get_target(e);      // 获取事件目标对象 (即按钮)

    if(code == LV_EVENT_CLICKED) {
        ESP_LOGI("TouchExample", "Button clicked via touch!");

        // 示例:切换按钮上标签的文本
        lv_obj_t *label = lv_obj_get_child(obj, 0); // 假设标签是按钮的第一个子对象
        if (label) { // 确保标签存在
            if (strcmp(lv_label_get_text(label), "Clicked!") == 0) {
                lv_label_set_text(label, "Hello!");
            } else {
                lv_label_set_text(label, "Clicked!");
            }
        }
    }
}

// 在创建 LVGL 按钮 (例如名为 btn 的对象) 后,添加事件回调:
// lv_obj_add_event_cb(btn, my_touch_event_cb, LV_EVENT_CLICKED, NULL);

此回调函数 my_touch_event_cb 会在按钮被点击(通过触摸)时被调用。它首先记录一条日志,然后获取按钮内部的标签对象,并切换其文本内容。

第 5 章:集成外部按钮控制

5.1 为何使用外部按钮?

尽管触摸屏提供了便捷的交互,但在某些场景下,物理按钮仍然具有不可替代的优势:

  • 触觉反馈:物理按钮提供明确的按下和释放的触感。
  • 恶劣环境:在潮湿、油污或用户佩戴手套等不便使用触摸屏的环境中,按钮更为可靠。
  • 专用功能触发:某些功能(如急停、模式切换)可能需要专用按钮以确保快速准确操作。
  • 辅助功能:对于有视觉或操作障碍的用户,物理按钮可能更易于使用。
  • 导航:在没有触摸屏或作为触摸屏补充时,按钮可用于菜单导航。

5.2 硬件连接

将外部按钮连接到 ESP32 的 GPIO 通常很简单。每个按钮需要一个 GPIO 引脚。为了确保稳定的电平读取,需要使用上拉或下拉电阻。ESP32 的 GPIO 内置了可配置的上拉和下拉电阻,可以简化外部电路 27。

以下是一个使用外部上拉电阻(按钮按下时 GPIO 为低电平)的连接示例图:

如果使用 ESP32 内部上拉电阻,则外部电阻 R1, R2, R3 可以省略,按钮的另一端直接接地。

表 5.1:外部按钮 ESP32 GPIO 连接示例

按钮功能ESP32 GPIO 示例拉电阻配置有效电平
上/前一个GPIO 35内部上拉 / 外部上拉低
下/后一个GPIO 34内部上拉 / 外部上拉低
选择/确认GPIO 39内部上拉 / 外部上拉低
返回GPIO 36内部上拉 / 外部上拉低

注意:GPIO 引脚和有效电平可根据实际需求配置。使用内部上拉时,GPIO 配置为 GPIO_PULLUP_ONLY。

清晰的硬件连接和电气特性配置指导,对于确保可靠的按钮输入至关重要。

5.3 使用 espressif/button 组件

为了简化按钮处理,特别是解决按键抖动、识别短按、长按、多次点击等问题,Espressif 提供了 button 组件 28。该组件功能强大,推荐在 ESP-IDF 项目中使用。

添加依赖:

idf.py add-dependency "espressif/button^4.1.3"

(请检查 ESP 组件注册表以获取 espressif/button 的最新版本 29。)

配置与创建按钮: 7

  1. 包含头文件 #include "iot_button.h"。
  2. 为每个按钮定义 button_gpio_config_t 结构体,指定 gpio_num (GPIO 编号) 和 active_level (有效电平,0 表示低电平有效,1 表示高电平有效)。
  3. 定义通用的 button_config_t 结构体,设置 type = BUTTON_TYPE_GPIO,并将上面定义的 button_gpio_config_t 赋值给其 gpio_button_config 成员。
  4. 调用 iot_button_create(&button_config) 或更新的 API iot_button_new_gpio_device(&button_config) 7 来创建按钮句柄 (button_handle_t)。

按键去抖:

espressif/button 组件内部处理按键去抖。可以通过 menuconfig ( idf.py menuconfig -> Component config -> Button Configuration ) 或直接在代码中修改相关宏定义(如 BUTTON_DEBOUNCE_TICKS)来配置去抖时间等参数 28。BUTTON_DEBOUNCE_TICKS 定义了在初始状态改变后,系统忽略后续状态改变的持续时间(以扫描周期为单位),从而有效滤除机械抖动。

espressif/button 组件提供的功能(如去抖、多种事件类型、节能考虑 30)极大地简化了物理按钮的处理,使 UI 开发更稳健高效。其与 esp_lvgl_port 的集成进一步简化了使用这些已处理的按钮事件进行标准 LVGL 导航的过程。节能方面尤其重要,因为按钮常作为唤醒源;一个设计良好的按钮组件需要正确处理低功耗模式下的行为 28。

5.4 将按钮与 esp_lvgl_port 集成用于导航

esp_lvgl_port 可以直接使用 espressif/button 组件创建的按钮句柄来实现 LVGL 界面的导航功能(如上一个、下一个、确认)7。

  1. 创建按钮句柄:按照 5.3 节所述,为导航所需的每个物理按钮(例如“上/前一个”、“下/后一个”、“确认/进入”)创建 button_handle_t。
  2. 配置 lvgl_port_nav_btns_cfg_t:
    • disp: 指向已初始化的 lv_disp_t* 显示句柄。
    • button_prev: “上/前一个”按钮的 button_handle_t。
    • button_next: “下/后一个”按钮的 button_handle_t。
    • button_enter: “确认/进入”按钮的 button_handle_t。
  3. 添加导航按钮到 LVGL:调用 lv_indev_t *btn_indev = lvgl_port_add_navigation_buttons(&btns_cfg);。
  4. LVGL 组 (Group):这是关键一步。为了使导航按钮能够控制 LVGL 控件(如按钮、列表项等),这些可聚焦的 LVGL 对象必须被添加到一个 lv_group_t 中,并且这个组必须与上面创建的按钮输入设备 (btn_indev) 相关联 7。如果忽略组的创建和关联,外部按钮导航将无法工作。
#include "iot_button.h" // 来自 espressif/button 组件
#include "esp_lvgl_port.h"
#include "esp_log.h"

// 假设 button_handle_prev, button_handle_next, button_handle_enter 已通过 iot_button_new_gpio_device 创建
// 假设 disp_handle 是有效的显示句柄

static const char *TAG_BTN_NAV = "BTN_NAV";
static lv_indev_t *button_indev_handle_nav;
static lv_group_t *default_lvgl_group;

// 示例:按钮引脚定义
#define PIN_BTN_PREV  GPIO_NUM_35
#define PIN_BTN_NEXT  GPIO_NUM_34
#define PIN_BTN_ENTER GPIO_NUM_39

static button_handle_t app_driver_button_init(int gpio_num)
{
    button_config_t gpio_btn_cfg = {
       .type = BUTTON_TYPE_GPIO,
       .long_press_time = CONFIG_BUTTON_LONG_PRESS_TIME_MS,
       .short_press_time = CONFIG_BUTTON_SHORT_PRESS_TIME_MS,
       .gpio_button_config = {
           .gpio_num = gpio_num,
           .active_level = 0, // 低电平有效 (假设使用上拉电阻)
        },
    };
    button_handle_t btn_handle = iot_button_new_gpio_device(&gpio_btn_cfg);
    if (btn_handle == NULL) {
        ESP_LOGE(TAG_BTN_NAV, "Failed to create button for GPIO %d", gpio_num);
    }
    return btn_handle;
}


static void initialize_navigation_buttons_for_lvgl(lv_disp_t *disp) {
    // 1. 创建按钮句柄
    button_handle_t button_prev_h = app_driver_button_init(PIN_BTN_PREV);
    button_handle_t button_next_h = app_driver_button_init(PIN_BTN_NEXT);
    button_handle_t button_enter_h = app_driver_button_init(PIN_BTN_ENTER);

    if (!button_prev_h ||!button_next_h ||!button_enter_h) {
        ESP_LOGE(TAG_BTN_NAV, "One or more navigation buttons failed to initialize.");
        // 根据需要处理错误,例如不注册导航按钮
        return;
    }

    // 2. 配置 LVGL port 的导航按钮
    const lvgl_port_nav_btns_cfg_t nav_btns_cfg = {
       .disp = disp,
       .button_prev = button_prev_h,
       .button_next = button_next_h,
       .button_enter = button_enter_h
    };
    button_indev_handle_nav = lvgl_port_add_navigation_buttons(&nav_btns_cfg);
    if (button_indev_handle_nav) {
        ESP_LOGI(TAG_BTN_NAV, "Navigation buttons added to LVGL.");
    } else {
        ESP_LOGE(TAG_BTN_NAV, "Failed to add navigation buttons to LVGL.");
        return;
    }


    // 3. 创建 LVGL 组并分配给输入设备
    default_lvgl_group = lv_group_create();
    lv_indev_set_group(button_indev_handle_nav, default_lvgl_group);
    ESP_LOGI(TAG_BTN_NAV, "Default LVGL group created and assigned to navigation buttons.");

    // 可选:将默认组设为显示器的默认组,这样新创建的可聚焦对象会自动加入
    // lv_disp_set_default_group(disp, default_lvgl_group);
}

// 在 app_main 中, 显示和触摸初始化完成后调用:
// initialize_navigation_buttons_for_lvgl(disp_handle);

// 当创建可聚焦的 LVGL 对象 (如 lv_btn_t* my_lvgl_button) 时:
// lv_group_add_obj(default_lvgl_group, my_lvgl_button);

5.5 使用按钮进行自定义 LVGL 事件处理 (导航的替代方案)

如果不想使用 esp_lvgl_port 提供的标准化导航功能,而是希望按钮触发更灵活的、应用特定的操作,可以采取以下方法:

  1. 直接使用 espressif/button 组件的回调:

    • 为通过 iot_button_create() 创建的按钮注册事件回调函数 (例如,单击、长按等)。
    • 在这些回调函数中,可以直接调用 LVGL API 来改变界面元素的状态、执行特定逻辑或发送自定义 LVGL 事件到目标控件。
    • 这种方式下,按钮的输入不直接通过 LVGL 的输入设备系统,而是由应用程序逻辑在按钮事件发生时驱动 LVGL 更新。
  2. 实现自定义的 LVGL 输入设备驱动:26

    • 创建一个类型为 LV_INDEV_TYPE_KEYPAD 或 LV_INDEV_TYPE_BUTTON 的 LVGL 输入设备。
    • 为其提供一个 read_cb (读取回调) 函数。
    • 在此 read_cb 中:
      • 读取物理按钮的状态 (可以使用 iot_button_get_event() 来获取 espressif/button 组件处理后的事件,或者如果未使用该组件,则直接读取 GPIO 电平并自行去抖)。
      • 根据按钮状态,填充 lv_indev_data_t 结构体:
        • data->key: 设置为 LVGL 定义的按键值 (如 LV_KEY_UP, LV_KEY_DOWN, LV_KEY_ENTER, LV_KEY_PREV, LV_KEY_NEXT) 或自定义键值。
        • data->state: 设置为 LV_INDEV_STATE_PRESSED 或 LV_INDEV_STATE_RELEASED。
    • 如果使用 LV_INDEV_TYPE_BUTTON,可以调用 lv_indev_set_button_points(indev, points_array) 将物理按钮映射到屏幕上的特定坐标点,模拟触摸点击 31。points_array 是一个 lv_point_t 数组,定义了每个按钮对应的屏幕 (x, y) 坐标。

选择使用 esp_lvgl_port 的导航按钮功能还是手动处理按钮事件并发送自定义 LVGL 事件,取决于所需的 UI 交互模型。esp_lvgl_port 提供了一种标准化的导航体验,适用于菜单和列表等典型场景。而手动处理则为应用特定的按钮功能提供了更大的灵活性。

5.6 按钮控制示例

此示例将演示如何使用外部按钮(通过 esp_lvgl_port 的导航功能)来聚焦和“点击”屏幕上的 LVGL 按钮。

// (续上面的 initialize_navigation_buttons_for_lvgl 和 app_main)

static void event_handler_lvgl_btn(lv_event_t * e)
{
    lv_event_code_t code = lv_event_get_code(e);
    lv_obj_t *btn = lv_event_get_target(e);
    if(code == LV_EVENT_CLICKED) {
        ESP_LOGI(TAG_BTN_NAV, "LVGL Button %p clicked!", (void*)btn);
        // 示例:简单地改变按钮上的标签文本
        lv_obj_t *label = lv_obj_get_child(btn, 0);
        if (label) {
            lv_label_set_text_fmt(label, "Clicked %d", esp_random() % 100);
        }
    }
}

void create_button_ui_for_navigation(lv_disp_t *disp) {
    // 确保 default_lvgl_group 已被创建和设置 (见 initialize_navigation_buttons_for_lvgl)
    if (!default_lvgl_group) {
        ESP_LOGE(TAG_BTN_NAV, "LVGL group not initialized!");
        return;
    }

    lv_obj_t *scr = lv_disp_get_scr_act(disp); // 获取活动屏幕

    // 创建第一个 LVGL 按钮
    lv_obj_t *btn1 = lv_btn_create(scr);
    lv_obj_set_size(btn1, 120, 50);
    lv_obj_align(btn1, LV_ALIGN_TOP_MID, 0, 20);
    lv_obj_t *label1 = lv_label_create(btn1);
    lv_label_set_text(label1, "Button 1");
    lv_obj_center(label1);
    lv_obj_add_event_cb(btn1, event_handler_lvgl_btn, LV_EVENT_CLICKED, NULL);
    lv_group_add_obj(default_lvgl_group, btn1); // 将按钮添加到组中以便导航

    // 创建第二个 LVGL 按钮
    lv_obj_t *btn2 = lv_btn_create(scr);
    lv_obj_set_size(btn2, 120, 50);
    lv_obj_align(btn2, LV_ALIGN_CENTER, 0, 0);
    lv_obj_t *label2 = lv_label_create(btn2);
    lv_label_set_text(label2, "Button 2");
    lv_obj_center(label2);
    lv_obj_add_event_cb(btn2, event_handler_lvgl_btn, LV_EVENT_CLICKED, NULL);
    lv_group_add_obj(default_lvgl_group, btn2); // 将按钮添加到组中

    // 创建第三个 LVGL 按钮
    lv_obj_t *btn3 = lv_btn_create(scr);
    lv_obj_set_size(btn3, 120, 50);
    lv_obj_align(btn3, LV_ALIGN_BOTTOM_MID, 0, -20);
    lv_obj_t *label3 = lv_label_create(btn3);
    lv_label_set_text(label3, "Button 3");
    lv_obj_center(label3);
    lv_obj_add_event_cb(btn3, event_handler_lvgl_btn, LV_EVENT_CLICKED, NULL);
    lv_group_add_obj(default_lvgl_group, btn3); // 将按钮添加到组中

    ESP_LOGI(TAG_BTN_NAV, "Button UI created and objects added to group.");
    // 默认聚焦第一个按钮 (可选)
    // lv_group_focus_obj(btn1);
}

// 在 app_main 中,所有初始化完成后:
// lvgl_port_lock(0); // 保护 LVGL 调用
// initialize_navigation_buttons_for_lvgl(disp_handle); // 初始化物理按键并关联到 LVGL
// create_button_ui_for_navigation(disp_handle);      // 创建 LVGL 界面元素并加入组
// lvgl_port_unlock();

在这个示例中,当物理的“上/下一个”按钮被按下时,LVGL 的焦点会在 btn1, btn2, btn3 之间切换。当物理的“确认/进入”按钮被按下时,当前聚焦的 LVGL 按钮的 LV_EVENT_CLICKED 事件会被触发,从而调用 event_handler_lvgl_btn。

第 6 章:构建示例应用:交互式恒温器 UI

本章将结合前面讨论的显示、触摸和按钮控制,构建一个简单的交互式恒温器用户界面。这个实际应用有助于巩固所学知识,并展示如何将各个组件集成到一个有凝聚力的系统中。

6.1 UI 设计与功能

恒温器 UI 将包含以下主要功能和界面元素:

  • 主屏幕 (Screen 1):
    • 显示当前温度 (使用 lv_label)。
    • 显示目标温度 (使用 lv_label 或 lv_roller / lv_spinbox)。
    • “+” 和 “-” LVGL 按钮,用于调整目标温度。
    • 一个“设置” LVGL 按钮,用于导航到设置屏幕。
  • 设置屏幕 (Screen 2):
    • 温度单位选择 (摄氏/华氏,使用 lv_switch 或 lv_btnmatrix)。
    • 工作模式选择 (制热/制冷,使用 lv_dropdown 或 lv_roller)。
    • 一个“返回” LVGL 按钮,用于返回主屏幕。

6.2 使用 LVGL 实现 UI 元素

我们将使用 LVGL 提供的各种控件来构建这两个屏幕。

创建屏幕:

LVGL 中,屏幕本身也是一个对象。可以使用 lv_obj_create(NULL) 来创建一个新屏幕,或者直接在默认屏幕 lv_scr_act() 上创建控件。为了实现多屏幕切换,我们会创建两个屏幕对象。

// 全局或静态变量
static lv_obj_t *screen_main;
static lv_obj_t *screen_settings;

// 标签用于显示温度
static lv_obj_t *label_current_temp;
static lv_obj_t *label_target_temp_val;

// 目标温度变量
static int8_t target_temperature = 22; // 初始目标温度

// 事件回调函数声明
static void event_handler_target_temp_inc(lv_event_t * e);
static void event_handler_target_temp_dec(lv_event_t * e);
static void event_handler_goto_settings(lv_event_t * e);
static void event_handler_goto_main(lv_event_t * e);
static void event_handler_temp_unit_switch(lv_event_t * e);

// 更新目标温度标签的函数
static void update_target_temp_label(void) {
    if (label_target_temp_val) {
        lv_label_set_text_fmt(label_target_temp_val, "%d°C", target_temperature);
    }
}

void create_thermostat_ui(lv_disp_t *disp) {
    // 获取默认组,假设已在按钮初始化时创建
    extern lv_group_t *default_lvgl_group;
    if (!default_lvgl_group) {
         ESP_LOGW("ThermostatUI", "Default LVGL group not found, creating one.");
         default_lvgl_group = lv_group_create();
         // 如果按钮输入设备已创建,需要将其关联到这个新组
         // extern lv_indev_t *button_indev_handle_nav;
         // if (button_indev_handle_nav) lv_indev_set_group(button_indev_handle_nav, default_lvgl_group);
    }


    // --- 创建主屏幕 ---
    screen_main = lv_obj_create(NULL); // 创建一个新屏幕对象作为主屏幕

    // 当前温度标签
    lv_obj_t *label_current_title = lv_label_create(screen_main);
    lv_label_set_text(label_current_title, "Current:");
    lv_obj_align(label_current_title, LV_ALIGN_TOP_LEFT, 20, 20);

    label_current_temp = lv_label_create(screen_main);
    lv_label_set_text(label_current_temp, "25°C"); // 示例值
    lv_obj_set_style_text_font(label_current_temp, &lv_font_montserrat_28, 0);
    lv_obj_align_to(label_current_temp, label_current_title, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 10);

    // 目标温度标签和值
    lv_obj_t *label_target_title = lv_label_create(screen_main);
    lv_label_set_text(label_target_title, "Target:");
    lv_obj_align(label_target_title, LV_ALIGN_LEFT_MID, 20, -20);

    label_target_temp_val = lv_label_create(screen_main);
    update_target_temp_label(); // 设置初始目标温度文本
    lv_obj_set_style_text_font(label_target_temp_val, &lv_font_montserrat_36, 0);
    lv_obj_align_to(label_target_temp_val, label_target_title, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 10);

    // 目标温度 "+" 按钮
    lv_obj_t *btn_inc = lv_btn_create(screen_main);
    lv_obj_set_size(btn_inc, 60, 60);
    lv_obj_align(btn_inc, LV_ALIGN_RIGHT_MID, -20, -30);
    lv_obj_add_event_cb(btn_inc, event_handler_target_temp_inc, LV_EVENT_CLICKED, NULL);
    lv_obj_t *label_inc = lv_label_create(btn_inc);
    lv_label_set_text(label_inc, LV_SYMBOL_PLUS);
    lv_obj_center(label_inc);
    if (default_lvgl_group) lv_group_add_obj(default_lvgl_group, btn_inc);


    // 目标温度 "-" 按钮
    lv_obj_t *btn_dec = lv_btn_create(screen_main);
    lv_obj_set_size(btn_dec, 60, 60);
    lv_obj_align_to(btn_dec, btn_inc, LV_ALIGN_OUT_BOTTOM_MID, 0, 10);
    lv_obj_add_event_cb(btn_dec, event_handler_target_temp_dec, LV_EVENT_CLICKED, NULL);
    lv_obj_t *label_dec = lv_label_create(btn_dec);
    lv_label_set_text(label_dec, LV_SYMBOL_MINUS);
    lv_obj_center(label_dec);
    if (default_lvgl_group) lv_group_add_obj(default_lvgl_group, btn_dec);

    // "设置" 按钮
    lv_obj_t *btn_settings = lv_btn_create(screen_main);
    lv_obj_set_size(btn_settings, 100, 40);
    lv_obj_align(btn_settings, LV_ALIGN_BOTTOM_RIGHT, -20, -20);
    lv_obj_add_event_cb(btn_settings, event_handler_goto_settings, LV_EVENT_CLICKED, NULL);
    lv_obj_t *label_settings = lv_label_create(btn_settings);
    lv_label_set_text(label_settings, "Settings");
    lv_obj_center(label_settings);
    if (default_lvgl_group) lv_group_add_obj(default_lvgl_group, btn_settings);


    // --- 创建设置屏幕 ---
    screen_settings = lv_obj_create(NULL); // 创建一个新屏幕对象作为设置屏幕

    lv_obj_t *label_settings_title = lv_label_create(screen_settings);
    lv_label_set_text(label_settings_title, "Settings");
    lv_obj_set_style_text_font(label_settings_title, &lv_font_montserrat_22, 0);
    lv_obj_align(label_settings_title, LV_ALIGN_TOP_MID, 0, 10);

    // 温度单位切换开关
    lv_obj_t *label_unit = lv_label_create(screen_settings);
    lv_label_set_text(label_unit, "Units (C/F):");
    lv_obj_align(label_unit, LV_ALIGN_TOP_LEFT, 20, 50);

    lv_obj_t *sw_unit = lv_switch_create(screen_settings);
    lv_obj_align_to(sw_unit, label_unit, LV_ALIGN_OUT_RIGHT_MID, 80, 0);
    lv_obj_add_event_cb(sw_unit, event_handler_temp_unit_switch, LV_EVENT_VALUE_CHANGED, NULL);
    if (default_lvgl_group) lv_group_add_obj(default_lvgl_group, sw_unit);


    // "返回" 按钮
    lv_obj_t *btn_back = lv_btn_create(screen_settings);
    lv_obj_set_size(btn_back, 100, 40);
    lv_obj_align(btn_back, LV_ALIGN_BOTTOM_LEFT, 20, -20);
    lv_obj_add_event_cb(btn_back, event_handler_goto_main, LV_EVENT_CLICKED, NULL);
    lv_obj_t *label_back = lv_label_create(btn_back);
    lv_label_set_text(label_back, "Back");
    lv_obj_center(label_back);
    if (default_lvgl_group) lv_group_add_obj(default_lvgl_group, btn_back);


    // 初始加载主屏幕
    lv_scr_load(screen_main);
}

// 事件回调函数实现
static void event_handler_target_temp_inc(lv_event_t * e) {
    if (target_temperature < 30) { // 假设最高30度
        target_temperature++;
        update_target_temp_label();
    }
    ESP_LOGI("ThermostatUI", "Target temp incremented to: %d", target_temperature);
}

static void event_handler_target_temp_dec(lv_event_t * e) {
    if (target_temperature > 15) { // 假设最低15度
        target_temperature--;
        update_target_temp_label();
    }
    ESP_LOGI("ThermostatUI", "Target temp decremented to: %d", target_temperature);
}

static void event_handler_goto_settings(lv_event_t * e) {
    ESP_LOGI("ThermostatUI", "Switching to Settings screen");
    lv_scr_load(screen_settings); // 加载设置屏幕
}

static void event_handler_goto_main(lv_event_t * e) {
    ESP_LOGI("ThermostatUI", "Switching to Main screen");
    lv_scr_load(screen_main); // 加载主屏幕
}

static bool current_unit_is_celsius = true;
static void event_handler_temp_unit_switch(lv_event_t * e) {
    lv_obj_t *sw = lv_event_get_target(e);
    if (lv_obj_has_state(sw, LV_STATE_CHECKED)) {
        current_unit_is_celsius = false; // 切换到华氏
        ESP_LOGI("ThermostatUI", "Temperature unit switched to Fahrenheit");
        // 此处可以添加单位转换逻辑并更新所有温度显示
    } else {
        current_unit_is_celsius = true; // 切换到摄氏
        ESP_LOGI("ThermostatUI", "Temperature unit switched to Celsius");
    }
    // 示例:简单更新目标温度标签(实际应用中需要转换数值)
    update_target_temp_label(); // 重新格式化显示,可能需要根据 current_unit_is_celsius 调整后缀
}

在 app_main 中,所有初始化完成后,调用 create_thermostat_ui(disp_handle);。记得使用 lvgl_port_lock(0); 和 lvgl_port_unlock(); 包裹 UI 创建和修改代码。

6.3 触摸和按钮的事件处理

  • 触摸:如上代码所示,LVGL 按钮(btn_inc, btn_dec, btn_settings, btn_back)和开关(sw_unit)都已通过 lv_obj_add_event_cb() 注册了事件回调函数。当用户触摸这些控件时,对应的回调函数会被触发。
  • 外部按钮:
    • 如果使用了第 5.4 节中 esp_lvgl_port 的导航按钮功能,并且所有交互式 LVGL 控件(按钮、开关等)都已添加到 default_lvgl_group 中,那么:
      • 物理的“上/下一个”按钮将用于在屏幕上的这些控件之间切换焦点。
      • 物理的“确认/进入”按钮将触发当前聚焦控件的 LV_EVENT_CLICKED 事件(对于按钮)或相应的主要事件(对于开关可能是 LV_EVENT_VALUE_CHANGED,但这通常由 LVGL 内部处理焦点和点击)。
    • 如果采用自定义按钮处理(第 5.5 节),则需要在物理按钮的回调函数中直接调用 LVGL API 或发送自定义事件来模拟对界面控件的操作。

6.4 应用逻辑流程

应用程序的逻辑流程主要围绕状态管理和屏幕切换。

  • 状态管理:
    • target_temperature 变量存储用户设定的目标温度。
    • current_unit_is_celsius 变量存储当前的温度单位选择。
    • (实际应用中还应有 current_temperature,可能从传感器读取)。
  • 屏幕切换:
    • 使用 lv_scr_load(screen_name); 函数在 screen_main 和 screen_settings 之间切换。
  • UI 更新:
    • 当状态变量(如 target_temperature)改变时,调用 update_target_temp_label() 这样的函数来刷新界面上对应的标签。

下面是应用状态流程的 Mermaid 图:

6.5 集成触摸和按钮控制逻辑

LVGL 的事件系统设计良好,通常情况下,一个注册到 LV_EVENT_CLICKED 的回调函数,无论是通过触摸屏直接点击该控件,还是通过外部按钮将焦点移至该控件后按下“确认”键,都会被触发。这意味着 event_handler_target_temp_inc 等回调函数无需关心事件是由触摸还是外部按钮产生的。

如果确实需要区分输入源(例如,为不同输入源提供略微不同的行为或反馈),可以在注册输入设备时传递用户数据 user_data 给 lv_indev_drv_t,并在事件回调中通过 lv_indev_t * indev = lv_event_get_indev(e); 获取当前的输入设备,然后检查其 user_data 或类型。但对于大多数标准交互,这种区分是不必要的。

这个示例应用展示了如何将独立的显示、触摸和按钮功能组合成一个功能性的用户界面。它强调了状态管理、UI 更新和事件处理在构建交互式嵌入式应用中的重要性。同时,UI 设计和控件映射(触摸与物理按钮)对可用性有显著影响,一个好的教程应引导用户做出合理的设计选择,例如,如果按钮导航是主要模式,则确保所有交互元素都可以通过按钮访问。将应用状态(如当前温度、设置)与 UI 元素分开管理,然后根据状态变化更新 UI,是构建复杂应用时的良好实践,可以提升模块化程度。

第 7 章:高级主题与最佳实践

7.1 LVGL 任务管理与线程安全

esp_lvgl_port 组件会创建一个专用的 FreeRTOS 任务来运行 LVGL 的主处理循环 (lv_timer_handler())。LVGL 的核心 API 通常不是线程安全的。这意味着,如果从 LVGL 任务以外的任何其他任务(例如,一个传感器数据读取任务或网络处理任务)调用 LVGL 的函数(如创建控件、修改属性等),必须使用互斥锁来保护这些调用,防止并发访问导致的数据损坏或程序崩溃 7。

esp_lvgl_port 提供了两个宏来实现这种保护:

  • lvgl_port_lock(timeout_ms): 获取 LVGL 互斥锁。timeout_ms 是等待锁的超时时间(毫秒),设为 0 表示如果锁不可用则立即返回。
  • lvgl_port_unlock(): 释放 LVGL 互斥锁。

示例:

void my_other_task(void *pvParameters) {
    //...
    if (lvgl_port_lock(100)) { // 尝试获取锁,超时100ms
        // 安全地调用 LVGL API
        lv_obj_t *label = lv_label_create(lv_scr_act());
        lv_label_set_text(label, "Updated from another task!");
        lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);

        lvgl_port_unlock(); // 释放锁
    } else {
        ESP_LOGE("MyTask", "Failed to acquire LVGL lock");
    }
    //...
}

在 RTOS 环境中,线程安全是一个常见的难点。esp_lvgl_port 提供的锁机制对于构建稳定的、多任务并发访问 LVGL 的应用至关重要。

7.2 ESP32 上 LVGL 的性能优化

嵌入式 GUI 的性能优化是一个涉及软件(LVGL 设置、驱动效率、应用代码)和硬件(CPU/Flash 速度、内存类型、显示接口)的多方面问题。esp_lvgl_port 的文档和 ESP-IDF 的配置选项为 ESP32 提供了宝贵的优化手段 12。

关键优化点包括:

  • LVGL 缓冲区配置:这是影响性能最重要的因素。
    • 缓冲区大小 (buffer_size in lvgl_port_display_cfg_t): 缓冲区越大,全屏更新时 flush_cb 调用次数越少,通常性能越好。但 ESP32 内部 RAM 有限,全屏16位色深缓冲区(如 320x240x2 字节 = 150KB)往往需要 PSRAM。esp_lvgl_port 支持部分缓冲(如屏幕大小的 1/10)作为折衷。
    • 双缓冲 (double_buffer in lvgl_port_display_cfg_t): 可以消除屏幕撕裂,但需要两倍于单缓冲区大小的内存。
  • 编译器优化:在 menuconfig 中启用性能优化 (Component config -> Compiler options -> Optimization Level -> Optimize for performance (-O2))。对应 Kconfig CONFIG_COMPILER_OPTIMIZATION_PERF 12。
  • CPU 频率:将 ESP32 CPU 频率设置为最大值 (通常为 240 MHz) 可以显著提升性能 (Component config -> ESP System Settings -> CPU frequency)。对应 Kconfig CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240 12。
  • Flash 频率和模式:更高的 Flash 时钟频率和更宽的 Flash 模式(如 QIO、OPI)可以加快代码和资源加载速度。这些设置受硬件 Flash型号限制。对应 Kconfig CONFIG_ESPTOOLPY_FLASHFREQ_* 和 CONFIG_ESPTOOLPY_FLASHMODE_* 12。
  • 将 LVGL 函数放入 IRAM:某些频繁调用的 LVGL 函数可以放置在指令 RAM (IRAM) 中以加速执行 (Component config -> LVGL configuration -> LVGL Settings -> Place frequently used LVGL functions into IRAM)。对应 Kconfig CONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAM 12。
  • 使用原生 memcpy/memset:ESP-IDF 提供的 memcpy 和 memset 实现可能比 LVGL 内置的更快 (Component config -> LVGL configuration -> LVGL Settings -> Use system's (memcpy, memset))。对应 Kconfig CONFIG_LV_MEMCPY_MEMSET_STD 12。
  • 显示刷新周期:调整 LVGL 的默认刷新周期 (Component config -> LVGL configuration -> LVGL Timers and Tasks settings -> Default display refresh period) 可能会影响感知性能,尤其是在滚动等过渡动画时。
  • DMA 传输:确保在 lvgl_port_display_cfg_t 的 flags 中启用 buff_dma,如果硬件支持,可以显著提高数据从内存到显示屏的传输效率。

7.3 节能注意事项

esp_lvgl_port 本身包含一些节能特性,例如在没有显示更新或输入事件时,LVGL 任务可以进入休眠状态 7。

  • espressif/button 组件如果配置了 enable_power_save = true,并且按钮连接到 RTC GPIO,可以在 ESP32 进入低功耗模式(如 Light Sleep)时,通过按钮中断唤醒系统 28。
  • 触摸屏的中断引脚也可以配置为 ESP32 的唤醒源。
  • 在设计低功耗 UI 应用时,应尽量减少不必要的屏幕刷新,合理利用 LVGL 的局部刷新机制,并在空闲时允许系统进入睡眠模式。

7.4 常见问题调试

  • 显示不工作/花屏/颜色错误:
    • 检查硬件接线:CS, DC, RST, SPI/I2C 引脚是否正确连接。
    • 检查 esp_lcd 初始化函数的返回值,确保没有错误。
    • 确认背光引脚和点亮电平 (EXAMPLE_LCD_BK_LIGHT_ON_LEVEL) 是否正确 23。
    • 确认 esp_lcd_panel_dev_config_t 中的颜色空间 (rgb_endian) 和每像素位数 (bits_per_pixel) 与显示屏规格一致。
    • 确认 SPI 时钟频率 (pclk_hz) 在显示屏支持范围内。
  • 触摸不工作/不准确:
    • 检查硬件接线:触摸 CS (SPI) 或 I2C 地址,IRQ 引脚(如果使用)。
    • 检查 esp_lcd_touch 初始化函数的返回值。
    • 对于 I2C 触摸屏,确认 I2C 地址是否正确。
    • 检查 esp_lcd_touch_config_t 中的 x_max, y_max 是否与屏幕分辨率匹配,以及 swap_xy, mirror_x, mirror_y 是否根据触摸屏和显示方向正确配置。
    • 对于电阻屏,可能需要校准。
  • 按钮不响应:
    • 检查硬件接线,特别是上拉/下拉电阻是否正确配置(内部或外部)。
    • 确认 button_gpio_config_t 中的 active_level 是否与硬件一致。
    • 检查 espressif/button 组件的初始化和回调注册是否正确。
    • 确认去抖配置是否合理。
  • LVGL 崩溃或行为异常:
    • 栈溢出:LVGL 任务的堆栈大小 (task_stack in lvgl_port_cfg_t) 可能不足,尤其对于复杂的 UI。尝试增加堆栈大小。
    • 线程安全问题:确保从其他任务访问 LVGL API 时使用了 lvgl_port_lock() 和 lvgl_port_unlock()。
    • 控件使用不当:查阅 LVGL 文档,确保控件的创建、父子关系、属性设置等符合 API 要求。
    • 内存不足:LVGL 对象和绘图缓冲区会消耗内存。检查可用 RAM,特别是如果未使用 PSRAM。
  • 调试工具:
    • 使用 ESP_LOGI, ESP_LOGE 等 ESP-IDF 日志宏输出调试信息。
    • LVGL 本身也提供了日志功能,可以在 lv_conf.h 中启用 LV_USE_LOG 并配置日志级别。
    • 对于硬件问题,逻辑分析仪或示波器可能有助于分析 SPI/I2C 通信。

有效的调试需要理解所涉及的不同层次:硬件连接、底层驱动 (esp_lcd, esp_lcd_touch)、移植层 (esp_lvgl_port)、LVGL 核心以及应用代码。将问题定位到正确的层次是解决问题的关键。

7.5 更多资源

  • LVGL 官方文档: https://docs.lvgl.io 5
  • Espressif ESP-IDF 编程指南: ESP-IDF Programming Guide - ESP32 - — ESP-IDF Programming Guide latest documentation 6
  • esp_lvgl_port 组件 (通常在 esp-bsp 仓库中): esp-bsp/components/esp_lvgl_port at master · espressif/esp-bsp · GitHub 5
  • espressif/button 组件文档: ESP Component Registry 或 Button - - — ESP-IoT-Solution latest documentation 29
  • ESP-IDF 示例: ESP-IDF examples 目录下有许多关于外设(包括 LCD 和 SPI)的示例代码,例如 peripherals/lcd/spi_lcd_touch 9。

第 2 部分:补充教程 - ESP32 & LVGL 与 PlatformIO

本部分将介绍如何使用 PlatformIO IDE 结合 Arduino 框架进行 ESP32 和 LVGL 的开发。

第 1 章:PlatformIO ESP32-LVGL 开发简介

1.1 PlatformIO:跨平台构建系统与 IDE

PlatformIO 是一个开源的、跨平台的构建系统和开发工具集,通常作为 VS Code 的扩展使用 34。它旨在简化嵌入式项目的开发流程,提供了统一的项目配置文件 (platformio.ini)、强大的库管理器、对多种开发板和框架(包括 Arduino, ESP-IDF, STM32Cube 等)的支持。

PlatformIO 对于许多 ESP32 开发者,尤其是那些有 Arduino 背景的开发者来说,其主要吸引力在于相比原生 ESP-IDF CMake 更简化的库管理和项目设置。这对于需要多个库(显示、触摸、LVGL)的图形用户界面等复杂设置尤其如此,可以显著加快项目的初始配置速度。像 TFT_eSPI 这样的库在 ESP32 的 Arduino 框架上进行了深度优化。

1.2 PlatformIO 内的框架选择:Arduino vs. ESP-IDF

PlatformIO 允许为 ESP32 项目选择不同的底层框架。本补充教程将主要关注使用 Arduino 框架,因为这是集成许多现有图形和外设库(如 TFT_eSPI)的常见且便捷的方式。

虽然 PlatformIO 也支持将 ESP-IDF 作为框架使用,但其工作流程会更接近本教程第一部分描述的原生 ESP-IDF 开发。选择 Arduino 框架可以利用其庞大的库生态系统和相对简单的 API。

尽管 PlatformIO 提供了便利,但理解它如何与底层框架(如 ESP32 的 Arduino核心,其本身构建于 ESP-IDF 之上)交互,对于高级调试或需要 Arduino API 未直接暴露的功能时非常重要。

1.3 先决条件

  • 安装了 VS Code (Visual Studio Code)。
  • 在 VS Code 中安装了 PlatformIO IDE 扩展 34。
  • 对 PlatformIO 创建项目和编辑 platformio.ini 文件有基本了解。
  • 硬件与第 1 部分相同(ESP32 开发板、显示屏、触摸屏、按钮)。

第 2 章:为 LVGL 设置 PlatformIO 项目

2.1 创建新的 PlatformIO 项目

  1. 打开 VS Code。
  2. 点击 PlatformIO 图标进入 PIO Home。
  3. 选择 "New Project"。
  4. 输入项目名称,选择您的 ESP32 开发板型号 (例如 "ESP32 Dev Module"),并选择 "Arduino" 作为框架。
  5. 选择项目存储位置,然后点击 "Finish"。

2.2 通过 platformio.ini 管理库 (lib_deps)

platformio.ini 文件是 PlatformIO 项目的核心配置文件,用于声明依赖库、构建标志等。

  • LVGL 库: lvgl/lvgl@^9.2.0 (或您希望使用的特定 LVGL 版本,如 lvgl/lvgl@8.3.11 以兼容特定示例或驱动) 35。
  • 显示驱动库:
    • TFT_eSPI by Bodmer: bodmer/TFT_eSPI 36。这是一个非常流行的选择,拥有广泛的社区支持和显示屏兼容性。
    • LovyanGFX by lovyan03: lovyan03/LovyanGFX 35。这是一个功能强大、性能优越的替代库,支持多种平台。 本教程将主要以 TFT_eSPI 为例,但会提及 LovyanGFX。
  • 触摸控制器库:
    • XPT2046 (电阻式): 通常与 TFT_eSPI 配合使用,可以使用 paulstoffregen/XPT2046_Touchscreen 38。
    • FT6236/FT6206 (电容式): 可以使用 adafruit/Adafruit FT6206 Library 41 或其他兼容库。

platformio.ini 示例片段:

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
    lvgl/lvgl@^9.2.0
    bodmer/TFT_eSPI
    paulstoffregen/XPT2046_Touchscreen ; 如果使用 XPT2046
    ; adafruit/Adafruit FT6206 Library ; 如果使用 FT6206/FT6236
build_flags =
    -D LV_CONF_INCLUDE_SIMPLE
    ; -D USER_SETUP_LOADED=1 ; 如果使用 TFT_eSPI 的自定义 User_Setup.h
    ; -I include ; 如果 User_Setup.h 在项目的 include 文件夹中
    ; 其他 TFT_eSPI User_Setup 构建标志 (如果不在单独文件中定义)
    ; 例如: -D ILI9341_DRIVER=1 -D TFT_WIDTH=240 -D TFT_HEIGHT=320
    ;       -D TFT_CS=15 -D TFT_DC=2 -D TFT_RST=4 (根据实际引脚修改)
lib_ldf_mode = deep+ ; 确保 LVGL 的依赖项被正确链接

表 2.1:PlatformIO 中 ESP32 LVGL 项目的常用 lib_deps

库用途lib_deps 条目备注
LVGL 图形核心lvgl/lvgl@^9.2.0 (或指定版本)
显示驱动 (常用)bodmer/TFT_eSPI需要配置 User_Setup.h
显示驱动 (高性能)lovyan03/LovyanGFX需要特定配置 (如 LGFX_Config.hpp)
触摸驱动 (XPT2046)paulstoffregen/XPT2046_TouchscreenSPI 接口
触摸驱动 (FT6236)adafruit/Adafruit FT6206 LibraryI2C 接口

此表为 PlatformIO 项目设置提供了快速参考,简化了基本库的添加,这正是 PlatformIO 的主要优势之一。

2.3 LVGL 配置 (lv_conf.h)

在 PlatformIO 中配置 LVGL,通常有以下几种方法:

  1. 复制 lv_conf_template.h: 从 LVGL 库的源文件夹 (通常在项目 .pio/libdeps/<your_env>/lvgl/lv_conf_template.h) 复制到项目的 src/ 或 include/ 目录下,并重命名为 lv_conf.h。然后在该文件中进行自定义修改 3。
  2. 在 platformio.ini 中添加构建标志 build_flags = -D LV_CONF_INCLUDE_SIMPLE,并在您的主源文件 (如 main.cpp) 的开头 #include "lv_conf.h"。这种方式要求 lv_conf.h 文件位于项目的 src 或 include 目录中,并且该目录已添加到包含路径中(通常 PlatformIO 会自动处理 src)。
  3. 不推荐直接修改 .pio/libdeps/ 目录下的文件,因为这些文件在库更新或清理项目时可能会被覆盖。

lv_conf.h 中的关键配置项 (与 ESP-IDF 类似,但针对 Arduino 环境):

  • #define LV_COLOR_DEPTH 16 (或其他支持的值,如 32, 8, 1)

  • #define LV_HOR_RES_MAX (screenWidth)

  • #define LV_VER_RES_MAX (screenHeight) (将 screenWidth 和 screenHeight 替换为实际的屏幕分辨率)

  • 启用所需的字体 (例如 #define LV_FONT_MONTSERRAT_16 1)。

  • 配置 LVGL 的 tick 源:

    #define LV_TICK_CUSTOM 1
    #if LV_TICK_CUSTOM
        #define LV_TICK_CUSTOM_INCLUDE "Arduino.h"         /*Header for the system time function*/
        #define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis())    /*Expression evaluating to current system time in ms*/
    #endif /*LV_TICK_CUSTOM*/
    

2.4 显示库配置 (例如 TFT_eSPI 的 User_Setup.h)

TFT_eSPI 库需要一个名为 User_Setup.h 的配置文件来定义所使用的显示控制器驱动、SPI 引脚、字体等选项 17。

配置方法:

  1. 直接修改库中的文件 (不推荐): 找到 .pio/libdeps/<your_env>/TFT_eSPI/User_Setup.h 并直接编辑。这种方法的问题是,当 TFT_eSPI 库更新时,您的修改可能会丢失。

  2. 创建自定义 User_Setup.h (推荐):

    • 在您的项目 include/ 目录(或 src/ 目录)下创建一个名为 (例如) my_user_setup.h 的文件。

    • 将 TFT_eSPI 库中的 User_Setups/Setup25_TTGO_T_Display.h (或与您的显示屏最接近的预设文件) 的内容复制到 my_user_setup.h 中,并根据您的硬件进行修改。

    • 在 platformio.ini 中添加构建标志,告诉 TFT_eSPI 加载您的自定义配置文件:

      build_flags =
         -D LV_CONF_INCLUDE_SIMPLE
         -D USER_SETUP_LOADED=1 ; 必须定义此宏
         -include "include/my_user_setup.h" ; 假设文件在 include 目录下
         ; 或者如果文件在 src 目录: -include "src/my_user_setup.h"
      

      或者,更常见的方法是,在 TFT_eSPI 库的根目录下创建一个 User_Setup_Select.h 文件(如果不存在),然后在其中 #include <User_Setups/your_chosen_setup.h>,或者直接将您的配置内容放在 User_Setup.h 中,并确保 platformio.ini 中没有冲突的 -D 定义覆盖这些设置。对于 PlatformIO,通常的做法是在 platformio.ini 中通过 -D 标志直接定义 TFT_eSPI 的引脚和驱动类型,从而避免修改库文件 37。

User_Setup.h (或通过 build_flags 定义) 中的关键设置:

  • 选择正确的显示驱动,例如 #define ILI9341_DRIVER。
  • 定义 SPI 引脚:#define TFT_MOSI 13, #define TFT_SCLK 14, #define TFT_CS 15, #define TFT_DC 2, #define TFT_RST 4 (根据实际连接修改)。
  • 如果 TFT_eSPI 也用于控制触摸屏的 CS 引脚,则定义 #define TOUCH_CS 33。
  • 设置 SPI 通信频率,例如 #define SPI_FREQUENCY 40000000。
  • 屏幕分辨率 (尽管 LVGL 也会设置,但 TFT_eSPI 可能也需要)。

配置文件(lv_conf.h, User_Setup.h)的管理是 PlatformIO 开发中的一个常见难点。清晰地概述方法和最佳实践对于流畅的用户体验至关重要。PlatformIO 将库文件抽象到 .pio/libdeps 目录。直接修改此目录下的文件通常不是好做法,因为它们可能在清理或更新时被覆盖。因此,将 lv_conf.h 复制到 src/ 目录,并为 TFT_eSPI 的 User_Setup.h 使用构建标志或项目内的自定义文件,是更推荐的、符合 PlatformIO 习惯的做法,有助于项目的可移植性和可维护性。

选择 TFT_eSPI 还是 LovyanGFX 通常取决于特定的硬件支持需求、开发者已有的熟悉程度,或对特定功能(如精灵图处理)的性能要求。两者都是强大的库 15。TFT_eSPI 在 Arduino 社区中被广泛采用,拥有大量示例。LovyanGFX 以其高性能、广泛的硬件支持(包括 ESP-IDF 原生模式)和高级功能(如 DMA 和精灵图操作)而闻名 15。对于优先考虑在 Arduino 环境中轻松上手常见显示屏的用户,TFT_eSPI 通常是首选。对于需要极致性能、支持不太常见的显示屏/接口,或者计划同时使用 ESP-IDF 的用户,LovyanGFX 可能更合适。

第 3 章:在 PlatformIO (Arduino 框架) 中集成显示、触摸和按钮

3.1 显示驱动初始化与 LVGL 刷新回调

使用 TFT_eSPI 库作为显示驱动:

  1. 包含头文件并实例化对象:

    #include <lvgl.h>
    #include <TFT_eSPI.h>
    
    TFT_eSPI tft = TFT_eSPI(); // 创建 TFT_eSPI 实例
    
    // LVGL 显示缓冲区
    static lv_disp_draw_buf_t disp_buf;
    // 根据屏幕宽度和 LVGL 建议(至少为屏幕高度的1/10行)定义缓冲区大小
    // 例如,对于 320x240 屏幕,可以分配 320 * 24 (或更大) 的缓冲区
    // 如果内存允许,全屏缓冲区或双全屏缓冲区可以提高性能
    #define screenWidth 320  // 替换为您的屏幕宽度
    #define screenHeight 240 // 替换为您的屏幕高度
    static lv_color_t buf_1[screenWidth * screenHeight / 10]; // 单缓冲区示例
    // static lv_color_t buf_2[screenWidth * screenHeight / 10]; // 可选:用于双缓冲的第二个缓冲区
    
  2. 实现 LVGL 刷新回调 (my_disp_flush):

    此函数在 LVGL 需要更新屏幕区域时被调用。它使用 TFT_eSPI 的函数将 LVGL 缓冲区中的像素数据发送到显示屏。

    void my_disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
       uint32_t w = (area->x2 - area->x1 + 1);
       uint32_t h = (area->y2 - area->y1 + 1);
    
       tft.startWrite(); // 准备写入显示 RAM
       tft.setAddrWindow(area->x1, area->y1, w, h); // 设置要更新的窗口区域
       tft.pushColors((uint16_t *)color_p, w * h, true); // 发送颜色数据, true 表示字节交换 (如果需要)
       tft.endWrite(); // 结束写入
    
       lv_disp_flush_ready(disp_drv); // 通知 LVGL 刷新完成
    }
    
  3. 在 setup() 函数中初始化并注册 LVGL 显示驱动:

    void setup() {
       Serial.begin(115200);
    
       lv_init(); // 初始化 LVGL 库
    
       tft.begin();          // 初始化 TFT_eSPI
       tft.setRotation(1);   // 设置屏幕旋转 (0-3, 根据您的屏幕调整)
    
       // 初始化 LVGL 显示缓冲区
       lv_disp_draw_buf_init(&disp_buf, buf_1, NULL, screenWidth * screenHeight / 10);
       // 如果使用双缓冲: lv_disp_draw_buf_init(&disp_buf, buf_1, buf_2, screenWidth * screenHeight / 10);
    
       // 初始化 LVGL 显示驱动
       static lv_disp_drv_t disp_drv;
       lv_disp_drv_init(&disp_drv);
       disp_drv.hor_res = screenWidth;
       disp_drv.ver_res = screenHeight;
       disp_drv.flush_cb = my_disp_flush; // 设置刷新回调函数
       disp_drv.draw_buf = &disp_buf;     // 设置绘图缓冲区
       // disp_drv.full_refresh = 1;      // 如果使用全屏刷新,则取消注释
       // disp_drv.sw_rotate = 1;         // 如果使用 LVGL 软件旋转,则取消注释
       lv_disp_drv_register(&disp_drv);   // 注册显示驱动
    
       //... 后续初始化触摸和按钮...
       //... 创建 LVGL UI...
    }
    

Arduino 框架简化了 LVGL 的驱动实现(显示刷新、触摸读取),因为像 TFT_eSPI 和 XPT2046_Touchscreen 这样的库提供了高级函数,抽象了直接的硬件交互。这与 ESP-IDF 中使用较低级的 esp_lcd 和 esp_lcd_touch 的方法形成对比。

3.2 触摸控制器初始化与 LVGL 读取回调

以 XPT2046_Touchscreen 库为例(通常与 TFT_eSPI 配合使用):

  1. 包含头文件并实例化对象:

    #include <XPT2046_Touchscreen.h>
    
    #define TOUCH_CS_PIN 33 // XPT2046 的 CS 引脚,根据实际连接修改
    // #define TOUCH_IRQ_PIN 32 // 可选:XPT2046 的 IRQ 引脚
    
    XPT2046_Touchscreen ts(TOUCH_CS_PIN); // 创建 XPT2046 实例
    // 如果使用 IRQ: XPT2046_Touchscreen ts(TOUCH_CS_PIN, TOUCH_IRQ_PIN);
    
  2. 实现 LVGL 读取回调 (my_touchpad_read):

    此函数在 LVGL 需要获取触摸状态时被调用。

    void my_touchpad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) {
       if (ts.touched()) { // 检查屏幕是否被触摸
           TS_Point p = ts.getPoint(); // 获取触摸点坐标
           // 注意:p.x 和 p.y 可能需要根据屏幕旋转和触摸屏方向进行映射或校准
           // 例如:
           // data->point.x = map(p.x, RAW_X_MIN, RAW_X_MAX, 0, screenWidth);
           // data->point.y = map(p.y, RAW_Y_MIN, RAW_Y_MAX, 0, screenHeight);
           // 或者根据 tft.getRotation() 进行调整
           data->point.x = p.x; // 简单示例,未校准
           data->point.y = p.y;
           data->state = LV_INDEV_STATE_PR; // 设置状态为按下
       } else {
           data->state = LV_INDEV_STATE_REL; // 设置状态为释放
       }
    }
    
  3. 在 setup() 函数中初始化并注册 LVGL 输入设备:

    // (在 setup() 函数中,lv_disp_drv_register(&disp_drv); 之后)
    
    ts.begin();             // 初始化触摸屏库
    ts.setRotation(1);      // 设置触摸屏旋转,应与显示屏旋转匹配
    
    // 初始化 LVGL 输入设备驱动 (触摸)
    static lv_indev_drv_t indev_drv_touch;
    lv_indev_drv_init(&indev_drv_touch);
    indev_drv_touch.type = LV_INDEV_TYPE_POINTER; // 类型为指针设备 (触摸屏/鼠标)
    indev_drv_touch.read_cb = my_touchpad_read;   // 设置读取回调函数
    lv_indev_drv_register(&indev_drv_touch);      // 注册输入设备驱动
    

如果使用 Adafruit_FT6206 库 (适用于 FT6206/FT6236 电容触摸屏),过程类似:包含其头文件,创建实例 (通常需要 I2C 初始化),调用 begin(),并在 my_touchpad_read 中使用 ctp.touched() 和 ctp.getPoint() 41。

3.3 外部按钮处理 (Arduino GPIO)

在 Arduino 框架下,可以直接使用 GPIO 函数处理外部按钮。

  1. 在 setup() 中配置按钮引脚:

    #define BTN_UP_PIN    35  // 示例:上按钮引脚
    #define BTN_DOWN_PIN  34  // 示例:下按钮引脚
    #define BTN_ENTER_PIN 39  // 示例:确认按钮引脚
    
    void setup_buttons() {
       pinMode(BTN_UP_PIN, INPUT_PULLUP);    // 设置为输入模式,并启用内部上拉电阻
       pinMode(BTN_DOWN_PIN, INPUT_PULLUP);  // 按钮另一端接地,按下时 GPIO 为 LOW
       pinMode(BTN_ENTER_PIN, INPUT_PULLUP);
    }
    // 在 setup() 主函数中调用 setup_buttons();
    
  2. 实现 LVGL 按键读取回调 (my_keypad_read):

    此回调函数需要读取按钮状态,进行去抖处理,并将按键信息传递给 LVGL。

    // 基本的按键去抖变量
    #define DEBOUNCE_DELAY 50 // 50ms 去抖延迟
    uint32_t last_key_press_time = 0;
    uint8_t last_btn_up_state = HIGH;
    uint8_t last_btn_down_state = HIGH;
    uint8_t last_btn_enter_state = HIGH;
    
    void my_keypad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) {
       static uint32_t last_lv_key = 0; // 存储上一个传递给 LVGL 的按键值
       uint32_t act_key = 0;            // 当前周期检测到的按键值
       bool btn_pressed_this_cycle = false;
    
       // 读取当前按钮状态 (低电平有效)
       uint8_t current_btn_up_state = digitalRead(BTN_UP_PIN);
       uint8_t current_btn_down_state = digitalRead(BTN_DOWN_PIN);
       uint8_t current_btn_enter_state = digitalRead(BTN_ENTER_PIN);
    
       if (millis() - last_key_press_time > DEBOUNCE_DELAY) {
           if (last_btn_up_state == HIGH && current_btn_up_state == LOW) { // 上按钮按下
               act_key = LV_KEY_UP;
               btn_pressed_this_cycle = true;
           } else if (last_btn_down_state == HIGH && current_btn_down_state == LOW) { // 下按钮按下
               act_key = LV_KEY_DOWN;
               btn_pressed_this_cycle = true;
           } else if (last_btn_enter_state == HIGH && current_btn_enter_state == LOW) { // 确认按钮按下
               act_key = LV_KEY_ENTER;
               btn_pressed_this_cycle = true;
           }
    
           if (btn_pressed_this_cycle) {
                last_key_press_time = millis(); // 更新最后按键时间以进行去抖
           }
       }
       // 更新上一状态,用于检测边沿触发
       last_btn_up_state = current_btn_up_state;
       last_btn_down_state = current_btn_down_state;
       last_btn_enter_state = current_btn_enter_state;
    
       if (btn_pressed_this_cycle) {
           data->state = LV_INDEV_STATE_PR; // 按下状态
           last_lv_key = act_key;           // 记录当前按键
       } else {
           data->state = LV_INDEV_STATE_REL; // 释放状态
           // LVGL 要求在释放时也报告上一个按键值,所以 last_lv_key 不在此处清除
       }
       data->key = last_lv_key; // 报告给 LVGL 的按键值
    }
    

    在 Arduino 框架中,如果未使用专门的按钮库,则需要手动实现去抖逻辑。上述示例提供了一个简单的基于时间延迟的去抖方法。按键抖动的处理质量会显著影响用户界面的响应性。

  3. 在 setup() 中注册 LVGL 按键输入设备,并创建和分配 LVGL 组:

    // (在 setup() 函数中,触摸设备注册之后)
    static lv_indev_drv_t indev_drv_keypad;
    lv_indev_drv_init(&indev_drv_keypad);
    indev_drv_keypad.type = LV_INDEV_TYPE_KEYPAD; // 类型为按键设备
    indev_drv_keypad.read_cb = my_keypad_read;    // 设置读取回调函数
    lv_indev_t *keypad_indev = lv_indev_drv_register(&indev_drv_keypad); // 注册输入设备
    
    // 创建 LVGL 组用于按键导航
    lv_group_t *default_group = lv_group_create();
    lv_indev_set_group(keypad_indev, default_group); // 将组分配给按键输入设备
    // lv_disp_set_default_group(lv_disp_get_default(), default_group); // 可选:设为默认组
    

    与 ESP-IDF 部分类似,可聚焦的 LVGL 控件必须添加到这个 default_group 中,外部按钮才能对其进行导航和操作。

3.4 LVGL Tick 递增

LVGL 需要一个周期性的 tick 来驱动动画、处理超时等。

在 lv_conf.h 中通过 LV_TICK_CUSTOM 使用 millis() 是推荐的方式 44。如果未配置 LV_TICK_CUSTOM,则需要在主 loop() 函数中手动调用:

// 在 loop() 函数中
// lv_tick_inc(5); // 例如,每 5ms 调用一次,参数为逝去的时间(毫秒)

3.5 LVGL 任务处理器

LVGL 的事件处理、重绘请求等都由 lv_task_handler() (在 LVGL v8 及更早版本中,或在 LVGL v9 中称为 lv_timer_handler()) 函数处理。此函数也必须在主 loop() 中周期性调用。

// 在 loop() 函数中
void loop() {
    // 如果没有使用 LV_TICK_CUSTOM,则需要 lv_tick_inc()
    // lv_tick_inc(5); // 假设 loop 周期为 5ms

    lv_timer_handler(); // LVGL v9 (或 lv_task_handler() for v8)

    delay(5); // 维持一个大致的调用周期
}

Arduino 的主 loop() 成为 LVGL 处理的核心(tick 递增和任务处理)。这比 ESP-IDF 的任务方法简单,但如果 loop() 中包含其他长时间运行的任务,它们可能会阻塞 LVGL,导致界面卡顿。对于更复杂的应用,即使在 Arduino 框架内,开发者也可能转向在 ESP32 上使用 FreeRTOS 任务结构来并发管理 LVGL 和其他操作,这类似于 esp_lvgl_port 的工作方式。

第 4 章:PlatformIO 示例项目演练:简单恒温器 UI

本章将提供一个使用 PlatformIO 和 Arduino 框架的简单恒温器 UI 项目的概览,重点在于展示如何将前面章节的知识点整合起来。

4.1 项目结构概述

一个典型的 PlatformIO LVGL 项目(使用 TFT_eSPI 和 XPT2046_Touchscreen)结构如下:

Thermostat_PIO/
├── include/
│   └── my_user_setup.h   оптимальный (TFT_eSPI 自定义配置, 如果使用)
├── lib/
│   └── (PlatformIO 自动下载的库,如 lvgl, TFT_eSPI, XPT2046_Touchscreen)
├── src/
│   ├── lv_conf.h        (LVGL 配置文件)
│   └── main.cpp         (主程序代码,包含 setup 和 loop)
├── test/
│   └── (单元测试文件)
└── platformio.ini       (PlatformIO 项目配置文件)

一个完整的、可构建的示例对于 PlatformIO 教程至关重要,因为 platformio.ini、lv_conf.h 和 User_Setup.h(对于 TFT_eSPI)的正确设置通常是用户遇到困难的地方。提供一个用户可以立即克隆和构建的工作示例,能为他们打下坚实的基础。

4.2 代码演练 (main.cpp)

main.cpp 文件将包含所有核心逻辑:

  1. 头文件包含和全局对象实例化:

    #include <Arduino.h>
    #include <lvgl.h>
    #include <TFT_eSPI.h>
    #include <XPT2046_Touchscreen.h> // 如果使用 XPT2046
    
    // TFT_eSPI 和触摸屏实例 (已在第3章定义)
    extern TFT_eSPI tft;
    extern XPT2046_Touchscreen ts; // 如果使用
    
    // LVGL 显示和输入驱动回调 (已在第3章定义)
    extern void my_disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p);
    extern void my_touchpad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data); // 触摸回调
    // extern void my_keypad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data); // 按钮回调
    
    // LVGL 显示缓冲区 (已在第3章定义)
    extern lv_disp_draw_buf_t disp_buf;
    extern lv_color_t buf_1;
    // extern lv_color_t buf_2; // 如果使用双缓冲
    
    // 屏幕尺寸 (已在第3章定义)
    extern const int screenWidth;
    extern const int screenHeight;
    
    // 按钮引脚 (已在第3章定义)
    // extern const int BTN_UP_PIN;...
    
    // LVGL 组 (如果使用按钮导航)
    lv_group_t *g; 
    
  2. setup() 函数:

    • 初始化串口 (Serial.begin(115200);)。
    • 初始化 TFT_eSPI (tft.begin(); tft.setRotation(...);)。
    • 初始化触摸屏库 (ts.begin(); ts.setRotation(...);)。
    • 初始化 LVGL (lv_init();)。
    • 注册 LVGL 显示驱动 (调用 lv_disp_draw_buf_init, lv_disp_drv_init, lv_disp_drv_register)。
    • 注册 LVGL 输入设备驱动 (调用 lv_indev_drv_init, lv_indev_drv_register 分别用于触摸和/或按钮)。
    • 配置按钮 GPIO (pinMode(...))。
    • 创建恒温器 UI (类似于第 1 部分第 6 章的 create_thermostat_ui 函数,但使用 Arduino 风格的日志和硬件访问)。确保可聚焦控件被添加到 g 组中。
  3. loop() 函数:

    • 调用 lv_tick_inc(X); (如果未使用 LV_TICK_CUSTOM)。
    • 调用 lv_timer_handler(); (或 lv_task_handler() for v8)。
    • delay(X); (例如 delay(5); 以匹配 lv_tick_inc(5))。
  4. LVGL 事件回调函数:

    • 实现用于处理恒温器 UI 中 LVGL 控件(按钮、开关等)事件的回调函数,与第 1 部分第 6 章中的逻辑类似。

该示例应清晰地演示 LVGL 显示和输入驱动程序的注册过程,因为这在 PlatformIO/Arduino 中是手动完成的,与 ESP-IDF 中的 esp_lvgl_port 方法不同。my_disp_flush 和 my_touchpad_read(或 my_keypad_read)的实现是将 LVGL 与所选硬件库配合使用的核心。

4.3 与 ESP-IDF 方法的主要区别

  • 库初始化风格: Arduino 风格的 begin() 调用,而不是 ESP-IDF 组件的初始化函数。
  • 无 esp_lvgl_port: 需要手动设置 LVGL 的显示和输入驱动程序,包括实现 flush_cb 和 read_cb。
  • 按钮处理: 通过 Arduino GPIO 函数 (pinMode, digitalRead) 直接处理,并需要手动实现或引入库来进行去抖。
  • 主循环: LVGL 的 tick 和任务处理在 Arduino 的 loop() 函数中进行,而不是由专用任务管理。

第 5 章:PlatformIO-LVGL 开发技巧

5.1 PlatformIO 中的调试

  • PlatformIO 调试器: PlatformIO 通常与 VS Code 集成,支持使用 JLink、ESP-Prog 等调试探针进行源码级调试。

  • 串口打印: Serial.println() 是快速调试输出的常用方法。

  • LVGL 日志: 在 lv_conf.h 中启用 LV_USE_LOG 并设置 LV_LOG_LEVEL,可以将 LVGL 内部日志输出到串口。需要提供一个日志打印函数,例如:

    #if LV_USE_LOG!= 0
    void my_lvgl_log_cb(const char * buf)
    {
        Serial.printf(buf);
        Serial.flush();
    }
    #endif
    
    // 在 lv_init() 之后调用:
    #if LV_USE_LOG!= 0
        lv_log_register_print_cb(my_lvgl_log_cb);
    #endif
    

5.2 管理开发板配置

如果为不同的 ESP32 开发板(可能具有不同的引脚排列)开发,可以在 platformio.ini 中定义多个环境 ([env:board1], [env:board2]),每个环境可以有自己特定的 board 类型、build_flags(用于覆盖引脚定义等)。

5.3 TFT_eSPI 与 LovyanGFX 的选择

  • TFT_eSPI: 3
    • 优点:在 Arduino 社区非常流行,拥有大量用户和示例,对常见显示屏支持良好,易于上手。
    • 缺点:主要面向 Arduino 框架,配置依赖 User_Setup.h 或大量构建标志。
  • LovyanGFX: 15
    • 优点:高性能,支持 DMA,高级精灵图操作,跨平台(包括 ESP-IDF 原生组件模式),对多种显示接口(SPI, I2C, 并行)和控制器支持广泛。
    • 缺点:配置方式可能与 TFT_eSPI 不同,可能需要更深入的了解。
    • 如果使用 LovyanGFX,其配置通常通过创建一个继承其基础类的自定义配置类(例如在 LGFX_Config.hpp 中)或在实例化时传递参数来完成 15。对于那些可能需要从 Arduino 框架迁移到完整 ESP-IDF 的项目,LovyanGFX 是一个具有战略意义的选择,因为其显示驱动部分的代可能更易于移植。

5.4 性能优化

  • LVGL 缓冲区: 与 ESP-IDF 类似,disp_buf 的大小和是否使用双缓冲对性能影响显著。在内存允许的情况下,更大的缓冲区通常更好。
  • SPI 频率: 在 TFT_eSPI 的 User_Setup.h 中或 LovyanGFX 的配置中,确保 SPI 时钟频率设置得尽可能高(在显示屏和 ESP32 的规格范围内)。
  • 减少绘制操作: 优化 LVGL 应用代码,避免不必要的重绘和复杂的样式。
  • 编译器优化: PlatformIO 默认会进行一定程度的优化。可以在 platformio.ini 中通过 build_flags 调整优化级别 (例如 -Os 优化大小, -O2 优化速度)。

5.5 后续学习资源

  • LVGL 官方文档: https://docs.lvgl.io
  • TFT_eSPI GitHub 仓库😦GitHub - Bodmer/TFT_eSPI: Arduino and PlatformIO IDE compatible TFT library optimised for the Raspberry Pi Pico (RP2040), STM32, ESP8266 and ESP32 that supports different driver chips)
  • LovyanGFX GitHub 仓库: GitHub - lovyan03/LovyanGFX: SPI LCD graphics library for ESP32 (ESP-IDF/ArduinoESP32) / ESP8266 (ArduinoESP8266) / SAMD51(Seeed ArduinoSAMD51)
  • PlatformIO 社区论坛: https://community.platformio.org
  • XPT2046_Touchscreen 库😦GitHub - PaulStoffregen/XPT2046_Touchscreen: Touchscreen Arduino Library for XPT2046 Touch Controller Chip)
  • Adafruit FT6206 Library😦GitHub - adafruit/Adafruit_FT6206_Library: Arduino library for FT6206-based Capacitive touch screen)

在 PlatformIO/Arduino 中调试 LVGL 问题通常需要结合 C++ 调试(如果使用 PIO 调试器)、串口打印以及理解所选库(TFT_eSPI、LVGL)如何交互。开发者需要更直接地管理 LVGL 驱动程序的绑定,因此可能需要逐步检查:TFT_eSPI 是否能单独正确绘图,然后 LVGL 是否正确调用刷新回调,最后触摸坐标是否被正确读取并传递给 LVGL。

结论

本教程详细介绍了在 ESP32 上使用 LVGL 进行 GUI 开发的两种主要方法:基于 ESP-IDF 的 esp_lvgl_port 组件和基于 PlatformIO 的 Arduino 框架。

ESP-IDF 与 esp_lvgl_port 提供了一个与 Espressif 生态系统紧密集成的强大解决方案。esp_lvgl_port 极大地简化了 LVGL 的初始化、显示驱动和输入设备驱动的配置,使得开发者可以更专注于应用逻辑。结合 esp_lcd 和 esp_lcd_touch 等底层组件,以及 espressif/button 等实用程序组件,可以构建出功能丰富且性能优良的嵌入式 GUI 应用。这种方法更适合需要深度定制、利用 ESP-IDF 底层特性或追求极致性能的项目。

PlatformIO 与 Arduino 框架 则为熟悉 Arduino 生态的开发者提供了一条更平缓的学习曲线。通过 PlatformIO 强大的库管理功能,可以轻松集成如 TFT_eSPI、LovyanGFX 等流行的显示和触摸库。尽管需要手动实现 LVGL 的显示和输入驱动回调,但这些库通常提供了高级 API,使得实现过程相对直接。这种方法非常适合快速原型开发和利用 Arduino 社区丰富的库资源。

无论选择哪种开发环境,LVGL 都为 ESP32 提供了一个功能强大且灵活的图形库。通过本教程提供的硬件连接指南、驱动配置步骤、代码示例以及高级主题的探讨,开发者应能根据项目需求和个人偏好,成功地在 ESP32 上构建出具有吸引力和交互性的用户界面。关键在于理解所选框架和库的核心概念,并细致地进行硬件配置和软件集成。随着 ESP32 和 LVGL 社区的不断发展,未来将有更多易于使用的工具和资源涌现,进一步推动嵌入式 GUI 的创新。