01. 初识LVGL
一、LVGL简介
LVGL(Light and Versatile Graphics Library)是一个免费的轻量级开源图形库。LVGL 是一款具有丰富部件,具备高级图形特性,支持多种输入设备和多国语言,独立于硬件之外的开源图形库。LVGL 官方网址为:https://lvgl.io/。LVGL 源代码网址为:https://github.com/lvgl/lvgl/。
图形用户界面(GUI)是指采用图形方式显示的计算机操作用户界面,允许用户使用鼠标等输入设备操纵屏幕上的图标或菜单选项。图形用户界面由多种控件及其相应的控制机制构成,在各种新式应用程序中都是标准化的,即相同的操作总是以同样的方式来完成,在图形用户界面,用户看到和操作的都是图形对象,应用的是计算机图形学的技术。
LVGL 主要特征有:
- 丰富的部件:开关、按钮、图表、列表、滑块、图片,等等。
- 高级图形属性:具有动画、抗锯齿、不透明度、平滑滚动等高级图形属性。
- 支持多种输入设备:如触摸屏、鼠标、键盘、编码器等。
- 支持多语言:UTF-8 编码。
- 支持多显示器:它可以同时使用多个 TFT 或者单色显示器。
- 支持多种样式属性:它具有类 CSS 的样式,支持自定义图形元素。
- 独立于硬件之外:它可以与任何微控制器或显示器一起使用。
- 可扩展性:它能够以小内存运行(最低 64 kB 闪存,16 kB RAM 的 MCU)。
- 支持操作系统、外部存储器和 GPU(不是必需的)。
- 具有高级图形效果:可进行单帧缓冲区操作。
- 纯 C 编写: LVGL 基于 C 语言编写,以获得最大的兼容性。
二、LVGL移植要求
2.1、MCU移植要求
- 微控制器:16、32 、64 位的微控制器或处理器。
- 主控频率(Hz):大于 16 MHz 时钟速度。
- Flash/ROM:大于 64 kB ,如果使用非常多的部件,推荐大于 180 kB。
- 内存(RAM):8kB(建议配置 24kB)。
2.2、显示屏移植要求
LVGL 只需要一个简单的驱动程序函数即可将像素阵列复制到显示器的给定区域中,其对显示屏的兼容性很强,具体要求如下(满足其一即可):
- 具有 8/16/24/32 位色深的显示屏。
- HDMI 端口的显示器。
- 小型单色显示器。
- LED 矩阵。
- 其它可以控制像素颜色/状态的显示器。
三、LVGL源码下载
LVGL 源代码网址为:https://github.com/lvgl/lvgl/。解压压缩包后文件目录结构如下:
上图中,与 LVGL 移植相关的有 【examples】 文件夹、【src】 文件夹、【lv_conf_template.h】 和 【lvgl.h】 文件,其他的部分均与移植无关,用户可以选择忽略。
【1】、examples 文件夹
该文件夹主要包含 LVGL部件实例、动画实例、其他第三方库实例以及输入设备和显示器驱动文件等内容,具体下所示:
上图中,只有 【porting】 文件夹与移植相关,其他文件夹中存放的是各种实例。
【2】、src 文件夹
该文件夹主要包含 LVGL 源文件(部件源码、多种解码库),具体如下所示:
四、裸机移植上LVGL
4.1、文件移植
将 LVGL 的【examples】文件夹下的【porting】文件、【src】文件夹、【lv_conf_template.h】文件和【lvgl.h】文件拷贝到自己的工程中,并将【lv_conf_template.h】文件改名为【lv_conf.h】文件。
然后,在工程中添加对应的头文件路径 LVGL
、LVGL\src
和 LVGL\examples\porting
。
接着,打开【lv_conf.h】文件,修改条件编译指令。
4.2、配置输出
我们将【porting】目录下的【lv_port_disp_template.h】文件的条件编译打开。
然后添加输出设备的头文件,这里我们使用的输出设备是 LCD。然后,在 disp_init()
函数中初始化屏幕设备,设置屏幕尺寸。
static void disp_init(void)
{
/*You code here*/
LCD_Init();
}
接着,我们设置屏幕尺寸,官方模式的水平像素为 320,垂直像素为 240,这里,我们需要自己定义 MY_DISP_HOR_RES
宏和 MY_DISP_VER_RES
宏。
#define MY_DISP_HOR_RES LCD_WIDTH
#define MY_DISP_VER_RES LCD_HEIGHT
然后,我们还需要配置数据缓冲模式。这里,我们将单缓冲 10 行和全屏双缓冲的方式的代码注释掉。
然后,在 disp_flush()
函数中配置区域填充函数。
static void disp_flush(lv_display_t * disp_drv, const lv_area_t * area, uint8_t * px_map)
{
/*IMPORTANT!!!
*Inform the graphics library that you are ready with the flushing*/
LCD_ShowPicture(px_map, area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1);
lv_display_flush_ready(disp_drv);
}
4.3、配置输入
我们将【porting】目录下的【lv_port_indev_template.h】文件的条件编译打开。
然后我们需要按需裁剪输入设备。
这里,我们只使用触摸屏输入设备,其它的设备可以注释掉。然后,我们将 lv_port_indev_init()
函数将除了触摸屏之外的输入设备的初始化代码注释掉。接着,我们将除了触摸屏之外的输入设备的初始化函数也注释掉。
接着,我们在 touchpad_init()
函数中初始化触摸屏。
static void touchpad_init(void)
{
/*Your code comes here*/
Touch_Init();
}
然后,我们还需要配置触摸检测函数。
static bool touchpad_is_pressed(void)
{
/*Your code comes here*/
uint16_t xValue = 0, yValue = 0;
xValue = Touch_ReadAD(0xD0);
yValue = Touch_ReadAD(0x90);
return (xValue && yValue) ? false : true;
}
接着,我们还需要配置触摸坐标获取函数。
static void touchpad_get_xy(int32_t * x, int32_t * y)
{
/*Your code comes here*/
uint16_t xValue = 0, yValue = 0;
xValue = Touch_ReadAD(0xD0);
yValue = Touch_ReadAD(0x90);
(*x) = xValue * 320 / 4096;
(*y) = yValue * 480 / 4096;
}
4.3、添加时基
基本定时器初始化函数:
TIM_HandleTypeDef g_tim6_handle;
/**
* @brief 定时器定时功能初始化函数
*
* @param htim 定时器句柄
* @param TIMx 定时器寄存器基地址,可选值: TIMx, x可选范围: 1 ~ 14
* @param prescaler 预分频系数,可选值: 0 ~ 65535
* @param period 自动重装载值,可选值: 0 ~ 65535
*
* @note 默认为向上计数模式
*/
void TIM_Base_Init(TIM_HandleTypeDef *htim, TIM_TypeDef *TIMx, uint16_t prescaler, uint16_t period)
{
htim->Instance = TIMx; // 定时器寄存器基地址
htim->Init.CounterMode = TIM_COUNTERMODE_UP; // 计数模式
htim->Init.Prescaler = prescaler; // 预分频系数
htim->Init.Period = period; // 自动重装载值
HAL_TIM_Base_Init(htim);
}
基本定时器底层初始化函数:
/**
* @brief 基本定时器底层初始化函数
*
* @param htim 定时器句柄
*/
void HAL_TIM_Base_MspInit(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM6)
{
__HAL_RCC_TIM6_CLK_ENABLE(); // 使能定时器6的时钟
HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn); // 使能定时器6中断
HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 4, 0); // 设置中断优先级
}
}
定时器 6 中断服务函数:
/**
* @brief 定时器6中断服务函数
*
*/
void TIM6_DAC_IRQHandler(void)
{
HAL_TIM_IRQHandler(&g_tim6_handle); // 调用HAL库公共处理函数
}
我们在定时器的更新中断回调函数中调用 LVGL 的时基函数 lv_tick_inc()
。
#include "tick/lv_tick.h"
/**
* @brief 定时器更新中断回调函数
*
* @param htim 定时器句柄
*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM6)
{
// 这里定时器的定时时间要与形参一致
lv_tick_inc(1);
}
}
4.3、main()函数
#include "lvgl.h"
#include "lv_port_disp_template.h"
#include "lv_port_indev_template.h"
int main(void)
{
HAL_Init();
System_Clock_Init(8, 336, 2, 7);
Delay_Init(168);
SPI_Init();
TIM_Base_Init(&g_tim6_handle, TIM6, 83, 999);
__HAL_TIM_CLEAR_IT(&g_tim6_handle, TIM_IT_UPDATE); // 清除更新中断标志位
HAL_TIM_Base_Start_IT(&g_tim6_handle); // 使能更新中断,并启动计数器
lv_init();
lv_port_disp_init();
lv_port_indev_init();
// 测试代码
lv_obj_t *switch_obj = lv_switch_create(lv_scr_act());
lv_obj_set_size(switch_obj, 120, 60);
lv_obj_align(switch_obj, LV_ALIGN_CENTER, 0, 0);
while (1)
{
lv_timer_handler();
Delay_ms(5);
}
return 0;
}
如果编译报错,可能是 SRAM 的空间不够用,我们可以把 lv_conf.h 文件中 LV_MEM_SIZE
宏改小。
如果触摸效果不灵敏,我们可以在 lv_conf.h 文件中有关扫描时间的宏 LV_DEF_REFR_PERIOD
改小。
五、FreeRTOS上移植LVGL
在裸机移植的时候,我们使用基本定时器为 LVGL 提供时基,而当有了系统之后,提供时基的方式就有了第二种选择。需要使用 RTOS 提供时基。
首先,在 FreeRTOS 的配置文件 FreeRTOSConfig.h 中,设置 configUSE_TICK_HOOK
设置为 1。这将允许你使用 Tick 钩子函数 vApplicationTickHook()
。此函数在每个 Tick 中断中调用,因此它的执行时间应尽可能短,并且不能包含任何延迟。
#define configUSE_TICK_HOOK 1 // 1: 使能系统时钟节拍中断钩子函数,无默认需定义
然后,我们在 vApplicationTickHook()
函数中,调用 lv_tick_inc(1)
来通知 LVGL 已经过去了 1 毫秒。这个函数的调用周期由 configTICK_RATE_HZ
决定,通常设置为 1 毫秒。
#define configTICK_RATE_HZ 1000 // 定义系统时钟节拍频率,单位: Hz,无默认需定义
#include "tick/lv_tick.h"
/**
* @brief 实现Tick的钩子函数
*
*/
void vApplicationTickHook(void)
{
lv_tick_inc(1);
}
修改 main() 函数内容:
int main(void)
{
HAL_Init();
System_Clock_Init(8, 336, 2, 7);
Delay_Init(168);
SPI_Init();
lv_init();
lv_port_disp_init();
lv_port_indev_init();
freertos_demo();
return 0;
}
FreeRTOS 入口函数:
/**
* @brief FreeRTOS的入口函数
*
*/
void freertos_demo(void)
{
xTaskCreate((TaskFunction_t ) start_task, // 任务函数
(char * ) "start_task", // 任务名
(configSTACK_DEPTH_TYPE) START_TASK_STACK_SIZE, // 任务栈大小
(void * ) NULL, // 入口参数
(UBaseType_t ) START_TASK_PRIORITY, // 任务优先级
(TaskHandle_t * ) start_task_handle); // 任务句柄
vTaskStartScheduler(); // 开启任务调度器
}
START_TASK 任务配置:
/**
* START_TASK 任务配置
* 包括: 任务优先级 任务栈大小 任务句柄 任务函数
*/
#define START_TASK_PRIORITY 1
#define START_TASK_STACK_SIZE 128
TaskHandle_t start_task_handle;
void start_task(void *pvParameters );
/**
* @brief 开始任务的任务函数
*
* @param pvParameters 任务函数的入口参数
*/
void start_task(void *pvParameters)
{
taskENTER_CRITICAL(); // 进入临界区,关闭中断
xTaskCreate((TaskFunction_t ) task1, // 任务函数
(char * ) "task1", // 任务名
(configSTACK_DEPTH_TYPE) TASK1_STACK_SIZE, // 任务栈大小
(void * ) NULL, // 入口参数
(UBaseType_t ) TASK1_PRIORITY, // 任务优先级
(TaskHandle_t * ) &task1_handle); // 任务句柄
taskEXIT_CRITICAL(); // 退出临界区,重新开启中断
vTaskDelete(NULL); // 删除任务自身
}
创建一个任务来处理 LVGL 的任务,并分配给它较高的优先级。
/**
* TASK1 任务配置
* 包括: 任务优先级 任务栈大小 任务句柄 任务函数
*/
#define TASK1_STACK_SIZE 512
#define TASK1_PRIORITY 2
TaskHandle_t task1_handle;
void task1(void *pvParameters);
/**
* @brief 任务1的任务函数
*
* @param pvParameters 任务函数的入口参数
*/
void task1(void *pvParameters)
{
// 测试代码
lv_obj_t *switch_obj = lv_switch_create(lv_scr_act());
lv_obj_set_size(switch_obj, 120, 60);
lv_obj_align(switch_obj, LV_ALIGN_CENTER, 0, 0);
while (1)
{
lv_timer_handler();
vTaskDelay(5);
}
}
如果按钮触摸后没有效果,可能是任务栈比较小,该大一些就可以。
六、外部SRAM的使用
如果使用的是 GCC 编译器的话,定义全局变量时,要想定义在外部 SRAM 中,需要修改 STM32F407ZGTx_FLASH.ld 文件的内容,给 GCC 添加外挂内存,这里我们起名字为:EXTSRAM。
/* Specify the memory areas */
MEMORY
{
...
EXTSRAM (rw) : ORIGIN = 0x68000000, LENGTH = 1024K
}
然后,添加 section 段:ext_sram(NOLOAD),注意 NOLOAD 的意思是储存在 ext_sram 段的内存变量,在启动的时候(就是 main() 之前的阶段)不需要初始化。这里容易出问题,标准 HAL 库是在进入 main() 才开始初始化(外挂 SRAM 需要调用自定义的 FSMC_SRAM_Init() 函数进行初始化后,才可以使用),不加 NOLOAD 的话,在启动阶段 GCC 默认加载内存变量(extern 或者 static 变量),找不到外挂 SRAM,导致 HardFault。
/* Define output sections */
SECTIONS
{
...
/* external sram data, do not initialize at startup */
.ext_sram(NOLOAD) :
{
. = ALIGN(4);
_sext_sram = .; /* create a global symbol at ext_sram start */
*(.ext_sram)
*(.ext_sram*)
. = ALIGN(4);
_eext_sram = .; /* define a global symbol at ext_sram end */
} >EXTSRAM
...
}
如果想把 LVGL 管理的内存空间存放到外部 SRAM 中,我们可以在 lv_conf.h 文件中将 LV_MEM_ADR 定义到外部 SRAM 地址(不推荐使用)。
#define LV_MEM_ADR (0x68000000)
这里,比较推荐将 lv_port_disp_template.c 文件中绘图缓冲区放到外部 SRAM 中。这里,我们将 lv_port_disp_init()
函数中定义的缓冲数据注释掉,定义在全局。
static lv_color_t buf_2_1[MY_DISP_HOR_RES * 10] __attribute__((section(".ext_sram")));;
static lv_color_t buf_2_2[MY_DISP_HOR_RES * 10] __attribute__((section(".ext_sram")));;
void lv_port_disp_init(void)
{
/*-------------------------
* Initialize your display
* -----------------------*/
disp_init();
/*------------------------------------
* Create a display and set a 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);
/* Example 1
* One buffer for partial rendering*/
// static lv_color_t buf_1_1[MY_DISP_HOR_RES * 10]; /*A buffer for 10 rows*/
// lv_display_set_buffers(disp, buf_1_1, NULL, sizeof(buf_1_1), LV_DISPLAY_RENDER_MODE_PARTIAL);
/* Example 2
* Two buffers for partial rendering
* In flush_cb DMA or similar hardware should be used to update the display in the background.*/
// static lv_color_t buf_2_1[MY_DISP_HOR_RES * 10];
// static lv_color_t buf_2_2[MY_DISP_HOR_RES * 10];
lv_display_set_buffers(disp, buf_2_1, buf_2_2, sizeof(buf_2_1), LV_DISPLAY_RENDER_MODE_PARTIAL);
/* Example 3
* Two buffers screen sized buffer for double buffering.
* Both LV_DISPLAY_RENDER_MODE_DIRECT and LV_DISPLAY_RENDER_MODE_FULL works, see their comments*/
// static lv_color_t buf_3_1[MY_DISP_HOR_RES * MY_DISP_VER_RES];
// static lv_color_t buf_3_2[MY_DISP_HOR_RES * MY_DISP_VER_RES];
// lv_display_set_buffers(disp, buf_3_1, buf_3_2, sizeof(buf_3_1), LV_DISPLAY_RENDER_MODE_DIRECT);
}