21. ESP32移植LVGL
一、LVGL简介
LVGL(Light and Versatile Graphics Library)是一个免费的轻量级开源图形库。LVGL 是一款具有丰富部件,具备高级图形特性,支持多种输入设备和多国语言,独立于硬件之外的开源图形库。LVGL 官方网址为:https://lvgl.io/。LVGL 源代码网址为:https://github.com/lvgl/lvgl/。
图形用户界面(GUI)是指采用图形方式显示的计算机操作用户界面,允许用户使用鼠标等输入设备操纵屏幕上的图标或菜单选项。图形用户界面由多种控件及其相应的控制机制构成,在各种新式应用程序中都是标准化的,即相同的操作总是以同样的方式来完成,在图形用户界面,用户看到和操作的都是图形对象,应用的是计算机图形学的技术。
二、组件注册表中安装LVGL组件
首先,我们在 VSCode 中按下 Ctrl + Shift + P
命令打开 命令面板,然后我们在命令面板中输入 ESP-IDF: Show ESP Component Registry
打开打开 组件注册表。
然后,我们在组件注册表中搜索 LVGL。
接着在点击安装按钮或在终端中输入如下命令 idf.py add-dependency "lvgl/lvgl^9.2.2"
安装 LVGL。
idf.py add-dependency "lvgl/lvgl^9.2.2"
安装完成后,我们会发现项目工程下的【main】文件夹自动生成 idf_component.yml
。当我们构建项目时,系统根据这个清单来下载对应版本的组件。
三、LVGL源码的目录解析
LVGL 源码的目录下有很多文件和文件夹,但我们并不需要完全了解它们,我们只需要了解与移植相关的部分即可。各文件夹和文件的功能如下表所示:
文件 | 说明 |
---|---|
demos | LVGL 提供的综合演示源码 |
docs | LVGL 文献,主要说明 LVGL 每个部件的使用方法 |
env_support | 环境的支持(MDK、ESP、RTThread) |
examples | LVGL 例程源码和 LVGL 输入设备驱动,显示屏驱动文件 |
scripts | LVGL 脚本(与 MicroPython 有关) |
src | LVGL 源文件(LVGL 部件源码、第三方库) |
tests | 官方人员的测试代码 |
lv_conf_template.h | LVGL 的剪裁文件 |
lvgl.h | LVGL 包含的头文件 |
上表中,与 LVGL 移植相关的有【examples】文件夹、【src】文件夹、lv_conf_template.h
和 lvgl.h
文件,其它的部分均与移植无关,我们可以选择忽略。
【1】、examples 文件夹
该文件夹主要包含 LVGL部件实例、动画实例、其他第三方库实例以及输入设备和显示器驱动文件等内容,具体如下所示:
文件 | 描述 |
---|---|
anim | LVGL 动画例程实例 |
arduino | arduino 开源电子平台 |
assets | 图片资源 |
event | LVGL 事件机制实例 |
get_started | LVGL 获取状态实例 |
layouts | LVGL 布局实例 |
libs | LVGL 移植第三方库实例 |
others | LVGL 其他测试 |
porting | LVGL 输入设备驱动、文件系统驱动以及显示器驱动 |
scroll | LVGL 滚动实例 |
styles | LVGL 对象样式实例 |
widgets | LVGL 部件实例 |
上表中,只有【porting】文件夹与移植相关,其它文件夹中存放的是各种实例。
【2】、src 文件夹
该文件夹主要包含 LVGL 源文件(部件源码、多种解码库),具体如下所示:
文件 | 描述 |
---|---|
core | LVGL 核心源码(事件、组、对象、坐标、样式、主题) |
draw | LVGL 绘画驱动(图片、解码、DMA2D、圆、线、圆弧、和文本) |
extra | LVGL 的拓展内容(布局、第三方库、其他测试、主题以及部件) |
font | LVGL 字库 |
gpu | LVGL 针对图形加速 |
hal | 硬件抽象层(显示驱动程序、输入设备程序以及 LVGL 系统滴答) |
misc | 主要描述 LVGL 其他定义(动画、内存管理、日志) |
widgets | LVGL 基础部件 |
四、旋转编码器的使用
这里,我们使用 1.8 寸的 SPI LCD 屏作为 LVGL 的显示设备,但它没有触摸功能,(一般是 2.4 寸以上的屏幕才带触摸功能)。因此,这里我们使用旋转编码器作为 LVGL 的输入设备。
旋转编码器是一种将旋转位移转换为一连串数字脉冲信号的旋转式传感器。这些脉冲用来控制角位移。读数系统通常采用差分方式,即将两个波形一样但相位差为 180° 的不同信号进行比较,以便提高输出信号的质量和稳定性。读数是在两个信号的差别基础上形成的,从而消除了干扰。
旋转编码器是通过两个引脚的相位差,实现的旋转方向判断。这里,为了方便,我们将 CLK 引脚称呼为 A 相,DT 引脚称呼为 B 相。当是顺时针旋转时,A 相超前 B 相 90 度,即 A 相为下降沿时,B 相为低电平;A 相为上升沿时,B 相为高电平。当是逆时针旋转时,B 相超前 A 相 90 度,即 A 相为下降沿时,B 相为高电平;A 相为上升沿时,B 相为低电平。
因此我们只需检测 A 相或者 B 相有发生高低电平跳变时,就判断另一相状态,来决定旋转方向。根据以下真值表,可以发现:
- 当两相同时为上升沿或者同时为下降沿时,则为顺时针。
- 当两相不同时为上升沿或者不同时为下降沿时,则为逆时针。
我们在【components】文件夹下的【device】文件夹中新增了一个 【encoder】 文件夹,用于存放 rotary_encoder.c 和 rotary_encoder.h 这两个文件。
#ifndef __ROTARY_ENCODER_H__
#define __ROTARY_ENCODER_H__
#include "driver/gpio.h"
void rotary_encoder_init(gpio_num_t clk_pin, gpio_num_t dt_pin, gpio_num_t sw_pin);
uint8_t rotary_encoder_scan(gpio_num_t clk_pin, gpio_num_t dt_pin, gpio_num_t sw_pin);
#endif // !__ROTARY_ENCODER_H__
#include "rotary_encoder.h"
/**
* @brief 旋转编码器初始化函数
*
* @param clk_pin A相引脚编号
* @param dt_pin B相引脚编号
* @param sw_pin 按键引脚编号
*/
void rotary_encoder_init(gpio_num_t clk_pin, gpio_num_t dt_pin, gpio_num_t sw_pin)
{
gpio_config_t gpio_config_struct =
{
.pin_bit_mask = (1ULL << clk_pin) | (1ULL << dt_pin) | (1ULL << sw_pin), // 配置引脚
.mode = GPIO_MODE_INPUT, // 输入模式
.pull_up_en = GPIO_PULLUP_ENABLE, // 使能上拉
.pull_down_en = GPIO_PULLDOWN_DISABLE, // 不使能下拉
.intr_type = GPIO_INTR_DISABLE // 不使能引脚中断
};
gpio_config(&gpio_config_struct);
}
/**
* @brief 旋转编码器扫描函数
*
* @param clk_pin A相引脚编号
* @param dt_pin B相引脚编号
* @param sw_pin 按键引脚编号
*/
uint8_t rotary_encoder_scan(gpio_num_t clk_pin, gpio_num_t dt_pin, gpio_num_t sw_pin)
{
static uint8_t last_clk_level = 0; // 上一次的CLK状态(A相)
static uint8_t last_dt_level = 0; // 上一次的DT状态(B相)
uint8_t result = 0;
// 当A发生跳变时采集B当前的状态,并将B与上一次的状态进行对比。
if (gpio_get_level(clk_pin) != last_clk_level)
{
// 若A 0->1 时,B 1->0 正转;B 0->1 反转
if (gpio_get_level(clk_pin) == 1)
{
// B和上一次状态相比,为下降沿,正转
if ((last_dt_level == 1) && (gpio_get_level(dt_pin) == 0))
{
result = 2;
}
// B和上一次状态相比,为上升沿,反转
else if ((last_dt_level == 0) && (gpio_get_level(dt_pin) == 1))
{
result = 1;
}
}
// 若A 1->0 时,B 0->1 正转;B 1->0 反转
else
{
// B和上一次状态相比,为下降沿,反转
if ((last_dt_level == 1) && (gpio_get_level(dt_pin) == 0))
{
result = 1;
}
// B和上一次状态相比,为上升沿,正转
else if ((last_dt_level == 0) && (gpio_get_level(dt_pin) == 1))
{
result = 2;
}
}
last_clk_level = gpio_get_level(clk_pin); // 更新编码器上一个状态暂存变量
last_dt_level = gpio_get_level(dt_pin); // 更新编码器上一个状态暂存变量
}
if (gpio_get_level(sw_pin) == 0)
{
result = 3;
}
return result;
}
然后,我们修改【components】文件夹下的【device】文件夹下的 CMakeLists.txt
文件。
# 源文件路径
set(src_dirs
encoder
)
# 头文件路径
set(include_dirs
encoder
# ${CMAKE_SOURCE_DIR}表示顶级 CMakeLists.txt 文件所在的目录的绝对路径
${CMAKE_SOURCE_DIR}/components/peripheral/inc
${CMAKE_SOURCE_DIR}/components/toolkit
)
# 设置依赖库
set(requires
driver
)
# 注册组件到构建系统的函数
idf_component_register(
# 源文件路径
SRC_DIRS ${src_dirs}
# 自定义头文件的路径
INCLUDE_DIRS ${include_dirs}
# 依赖库的路径
REQUIRES ${requires}
)
# 设置特定组件编译选项的函数
# -ffast-math: 允许编译器进行某些可能减少数学运算精度的优化,以提高性能。
# -O3: 这是一个优化级别选项,指示编译器尽可能地进行高级优化以生成更高效的代码。
# -Wno-error=format: 这将编译器关于格式字符串不匹配的警告从错误降级为警告。
# -Wno-format: 这将完全禁用关于格式字符串的警告。
component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format)
五、ESP32移植LVGL
这里,我们在【components】文件夹下新建一个【app】文件夹,用来存放应用代码。然后,我们在【app】文件夹下新建一个【inc】文件,用来存放应用程序的头文件。接着,我们在【app】文件夹下新建一个【src】文件,用来存放应用程序的源文件。然后,我们修改【components】目录下的【app】目录下新建一个 CMakeLists.txt
文件。
# 头文件路径
set(include_dirs
inc
# ${CMAKE_SOURCE_DIR}表示顶级 CMakeLists.txt 文件所在的目录的绝对路径
${CMAKE_SOURCE_DIR}/components/peripheral/inc
${CMAKE_SOURCE_DIR}/components/device
${CMAKE_SOURCE_DIR}/components/toolkit
)
# 源文件路径
set(src_dirs src)
# 设置依赖库
set(requires
driver
esp_timer
lvgl
)
# 注册组件到构建系统的函数
idf_component_register(
# 自定义头文件的路径
INCLUDE_DIRS ${include_dirs}
# 源文件路径
SRC_DIRS ${src_dirs}
# 依赖库的路径
REQUIRES ${requires}
)
# 设置特定组件编译选项的函数
# -ffast-math: 允许编译器进行某些可能减少数学运算精度的优化,以提高性能。
# -O3: 这是一个优化级别选项,指示编译器尽可能地进行高级优化以生成更高效的代码。
# -Wno-error=format: 这将编译器关于格式字符串不匹配的警告从错误降级为警告。
# -Wno-format: 这将完全禁用关于格式字符串的警告。
component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format)
我们在【components】目录下的【app】目录下的【inc】目录下新建一个 lvgl_demo.h
文件,它的内容如下:
#ifndef __LVGL_DEMO_H__
#define __LVGL_DEMO_H__
#include "freertos/FreeRTOS.h"
#include "esp_timer.h"
#include "lvgl.h"
#include "demos/lv_demos.h"
#include "lcd/lcd.h"
#include "encoder/rotary_encoder.h"
#define MY_DISP_HOR_RES 128
#define MY_DISP_VER_RES 160
#define BYTE_PER_PIXEL (LV_COLOR_FORMAT_GET_SIZE(LV_COLOR_FORMAT_RGB565)) /*will be 2 for RGB565 */
void lvgl_demo(void);
#endif // !__LVGL_DEMO_H__
我们在【components】目录下的【app】目录下的【src】目录下新建一个 lvgl_demo.c
文件,它的内容如下:
#include "lvgl_demo.h"
/**
* LVGL Demo Task 任务配置
* 包括: 任务优先级 任务栈大小 任务句柄 任务函数
*/
#define LVGL_DEMO_TASK_PRIORITY 1
#define LVGL_DEMO_TASK_STACK_SIZE 1024 * 6
TaskHandle_t g_lvgl_demo_task_handle;
void lvgl_demo_task(void *pvParameters);
lv_indev_t *indev_encoder;
static void increase_lvgl_tick(void *arg);
static void lv_port_disp_init(void);
static void disp_init(void);
static void disp_flush(lv_display_t * disp, const lv_area_t * area, uint8_t * px_map);
static void lv_port_indev_init(void);
static void encoder_init(void);
static void encoder_read(lv_indev_t * indev, lv_indev_data_t * data);
/**
* @brief LVGL程序入口
*
*/
void lvgl_demo(void)
{
// 动态创建任务
xTaskCreate((TaskFunction_t ) lvgl_demo_task, // 任务函数
(char * ) "lvgl_demo_task", // 任务名
(configSTACK_DEPTH_TYPE) LVGL_DEMO_TASK_STACK_SIZE, // 任务栈大小
(void * ) NULL, // 入口参数
(UBaseType_t ) LVGL_DEMO_TASK_PRIORITY, // 任务优先级
(TaskHandle_t * ) &g_lvgl_demo_task_handle); // 任务句柄
}
/**
* @brief LVGL Demo的任务函数
*
* @param pvParameters 任务函数的入口参数
*/
void lvgl_demo_task(void *pvParameters)
{
esp_timer_handle_t lvgl_tick_timer = NULL;
lv_init(); // 初始化LVGL图形库
lv_port_disp_init(); // LVGL显示接口初始化,放在lv_init()的后面
lv_port_indev_init(); // LVGL输入接口初始化,放在lv_init()的后面
// 为LVGL提供时基单元
const esp_timer_create_args_t lvgl_tick_timer_args =
{
.callback = &increase_lvgl_tick, // 设置定时器回调
.name = "lvgl_tick" // 设置定时器名称
};
ESP_ERROR_CHECK(esp_timer_create(&lvgl_tick_timer_args, &lvgl_tick_timer)); // 创建定时器
ESP_ERROR_CHECK(esp_timer_start_periodic(lvgl_tick_timer, 1 * 1000)); // 启动定时器
// 测试代码
// 使用编码器作为输入设备时,我们应该使用 lv_group_t * group = lv_group_create() 创建组
lv_group_t * group = lv_group_create();
lv_indev_set_group(indev_encoder, group); // 将编码器输入设备分配给组
lv_group_set_default(group); // 设置默认组
lv_obj_t *switch_obj = lv_switch_create(lv_scr_act()); // 创建开关部件
lv_obj_align(switch_obj, LV_ALIGN_TOP_MID, 0, 0); // 开关部件顶部居中对齐
lv_group_add_obj(group, switch_obj); // 将按钮部件加入到组中
lv_obj_t *dropdown_list_obj = lv_dropdown_create(lv_scr_act()); // 创建下拉列表部件
lv_obj_center(dropdown_list_obj); // 下拉列表部件居中对齐
lv_group_add_obj(group, dropdown_list_obj); // 将下拉列表部件加入到组中
lv_dropdown_set_options(dropdown_list_obj, "Sakura\nMikoto\nShana"); // 设置下拉列表选项
lv_dropdown_set_selected(dropdown_list_obj, 1); // 设置当前的选中项
lv_dropdown_set_dir(dropdown_list_obj, LV_DIR_BOTTOM); // 设置展开方式
lv_dropdown_set_symbol(dropdown_list_obj, LV_SYMBOL_RIGHT); // 设置图标
while (1)
{
lv_timer_handler(); // LVGL计时器
vTaskDelay(pdMS_TO_TICKS(10));
}
}
/**
* @brief LVGL定时器回调函数,用于提供时基单元。每1ms调用一次此函数。
*
* @param arg LVGL定时器回调函数参数,此处不需要使用。
*/
static void increase_lvgl_tick(void *arg)
{
lv_tick_inc(1); // 告诉LVGL已经过了多少毫秒
}
/**
* @brief LVGL显示接口初始化
*
*/
static void lv_port_disp_init(void)
{
disp_init(); // 初始化显示设备
/**
* LVGL需要一个缓冲区用来绘制小部件
* 随后,这个缓冲区的内容会通过显示设备的 'flush_cb'(显示设备刷新函数) 复制到显示设备上
* 这个缓冲区的大小需要大于显示设备一行的大小
*/
lv_display_t * disp = lv_display_create(MY_DISP_HOR_RES, MY_DISP_VER_RES);
lv_display_set_flush_cb(disp, disp_flush);
/**
*
* 双缓冲区: LVGL 会将显示设备的内容绘制到其中一个缓冲区,并将它写入显示设备。
* 需要使用 DMA 将要显示在显示设备的内容写入缓冲区。
* 当数据从第一个缓冲区发送时,它将使 LVGL 能够将屏幕的下一部分绘制到另一个缓冲区。
* 这样使得渲染和刷新可以并行执行。
*/
static uint8_t buf_2_1[MY_DISP_HOR_RES * 10 * BYTE_PER_PIXEL];
static uint8_t buf_2_2[MY_DISP_HOR_RES * 10 * BYTE_PER_PIXEL];
lv_display_set_buffers(disp, buf_2_1, buf_2_2, sizeof(buf_2_1), LV_DISPLAY_RENDER_MODE_PARTIAL);
}
/**
* @brief LVGL显示设备初始化函数实现部分
*
*/
static void disp_init(void)
{
bsp_spi_init(SPI2_HOST, GPIO_NUM_1, GPIO_NUM_NC, GPIO_NUM_2);
bsp_spi_bus_add_device(&g_lcd_spi_device_handle, SPI2_HOST, LCD_CS_GPIO_NUM, 0, 60000000);
lcd_init();
}
/**
* @brief LVGL显示设备刷新函数
*
* @param disp_drv 显示设备驱动结构体
* @param area 绘制区域
* @param px_map 绘制的像素数据
*/
static void disp_flush(lv_display_t * disp_drv, const lv_area_t * area, uint8_t * px_map)
{
lcd_show_picture(px_map, area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1);
lv_display_flush_ready(disp_drv);
}
/**
* @brief LVGL输入接口初始化
*
*/
void lv_port_indev_init(void)
{
// 编码器输入设备初始化,支持左旋、右旋和按下
encoder_init();
// 注册编码器输入设备
indev_encoder = lv_indev_create();
lv_indev_set_type(indev_encoder, LV_INDEV_TYPE_ENCODER);
lv_indev_set_read_cb(indev_encoder, encoder_read);
}
/**
* @brief 初始化编码器输入设备
*
*/
static void encoder_init(void)
{
rotary_encoder_init(GPIO_NUM_15, GPIO_NUM_16, GPIO_NUM_17);
}
/**
* @brief 读取编码器数值函数
*
* @param indev_drv 编码器输入设备
* @param data 传递给输入驱动程序进行填充的数据结构
*/
static void encoder_read(lv_indev_t * indev_drv, lv_indev_data_t * data)
{
int16_t encoder_diff = 0; // 记录编码器的值
lv_indev_state_t encoder_state = LV_INDEV_STATE_RELEASED; // 记录编码器的状态
uint8_t result = rotary_encoder_scan(GPIO_NUM_15, GPIO_NUM_16, GPIO_NUM_17);
switch (result)
{
case 1:
encoder_diff = -1;
break;
case 2:
encoder_diff = 1;
break;
case 3:
encoder_diff = 0;
encoder_state = LV_INDEV_STATE_PRESSED;
break;
}
data->enc_diff = encoder_diff;
data->state = encoder_state;
}
修改【main】文件夹下的 main.c
文件。
#include "freertos/FreeRTOS.h"
#include "lvgl_demo.h"
#include "lcd.h"
#include "rotary_encoder.h"
// app_main()函数是ESP32的入口函数,它是FreRTOS的一个任务,任务优先级是1
// main()函数是C语言入口函数,它会在编译过程中插入到二进制文件中的
void app_main(void)
{
lvgl_demo();
while (1)
{
// 将一个任务延迟给定的滴答数,IDF中提供pdMS_TO_TICKS可以将指定的ms转换为对应的tick数
vTaskDelay(pdMS_TO_TICKS(100));
}
}
六、LVGL的配置
我们可以打开 “SDK Configuration” 配置菜单,配置 LVGL 的功能。