一 LVGL简介
最近emwin用的比较烦躁,同时被LVGL酷炫的界面吸引到了,所以准备换用LVGL试试水。
LVGL(轻量级和通用图形库)是一个免费和开源的图形库,它提供了创建嵌入式GUI所需的一切,具有易于使用的图形元素,美丽的视觉效果和低内存占用。
【1】主要特性
丰富且强大的模块化图形组件:按钮 (buttons)、图表 (charts)、列表 (lists)、滑动条 (sliders)、图片 (images) 等
高级的图形引擎:动画、抗锯齿、透明度、平滑滚动、图层混合等效果
支持多种输入设备:触摸屏、 键盘、编码器、按键等
支持多显示设备
不依赖特定的硬件平台,可以在任何显示屏上运行
配置可裁剪(最低资源占用:64 kB Flash,16 kB RAM)
基于UTF-8的多语种支持,例如中文、日文、韩文、阿拉伯文等
可以通过类CSS的方式来设计、布局图形界面(例如:Flexbox、Grid)
支持操作系统、外置内存、以及硬件加速(LVGL已内建支持STM32 DMA2D、NXP PXP和VGLite)
即便仅有单缓冲区(frame buffer)的情况下,也可保证渲染如丝般顺滑
全部由C编写完成,并支持C++调用
支持Micropython编程,参见:LVGL API in Micropython
支持模拟器仿真,可以无硬件依托进行开发
丰富详实的例程
详尽的文档以及API参考手册,可线上查阅或可下载为PDF格式
在 MIT 许可下免费和开源
【2】配置要求
【3】与其他显示工具的对比
二 LVGL移植前准备
【1】参考资料
LVGL官网:https://lvgl.io/
LVGL源码:https://gitee.com/my_lvgl/lvgl
百问网LVGL中文开发手册:http://lvgl.100ask.net/8.2/index.html
一些关于移植的经验资料:
LITTLEVGL (LVGL)入门之移植到STM32芯片
LVGL学习笔记 | 02 – 移植LVGL 8.2到STM32F407开发板(MCU屏)
STM32移植LVGL(LittleVGL)
在STM32F4上移植LVGL8.2
【2】硬件资源
硬件资源包括:
嵌入式平台(我用的MCU是华大的HC32F460芯片)
实现了屏幕驱动的液晶屏(我用的是3.2寸320*240的8080并口屏,屏幕驱动可以移步【嵌入式】MCU(HC32F460)+并口LCD液晶屏ILI9341 移植emWin记录)
输入设备(非必要,包括触摸屏、鼠标、键盘、编码器、按键,我自己使用的是磁控旋钮编码器)
【3】其他(GUI Guider)
推荐一款不错的LVGL设计工具GUI Guider,是由恩智浦开发的,通过NXP官网(https://www.nxp.com.cn/design/software/development-software/gui-guider:GUI-GUIDER)即可下载。
具体用法参考我的另一篇文章《【LVGL】学习笔记–(2)GUI Guider的使用》。
准备好了以上的内容,接下来就可以进行移植了。
三 LVGL移植
【1】源码下载
首先下载LVGL源码,链接见上一章的参考资料。有很多版本,我这边没有选择最新的版本,而是用了V1.5.0-GA版本GUI Guider支持的V8.3.2版本:
下载完解压后是这样的:
【2】源码添加工程
将上面解压完成的lvgl-v8.3.2文件夹中的下列文件复制到自己keil工程自建文件夹GUI_LVGL中:
复制进来后,删除proting文件夹以及lv_conf_template文件中_template后缀:
在keil中建立管理目录:
其中:
GUI/LVGL用来存放src及其子目录中所有.c文件。这一步比较考验耐心和细致,每一个子目录下都有不少源文件。(要是不想自己keil工程中有许多看起来凌乱的源文件,可以参考STM32/keil把多个c文件编译为静态库lib中的方法,预先将这许许多多的.c文件编译为lib库,那么GUI/LVGL中放一个lib文件即可。但是这种方法要注意创建lib的工程和使用lib的工程芯片型号要一致才可以)。我是使用的lib库的形式。
GUI/LVGL/PORT用来存放porting下的三个源文件,分别是lv_port_disp.c(显示接口文件)、lv_port_indev(输入设备接口文件)、lv_port_fs(文件系统接口文件)。
GUI/LVGL用来存放GUI Guider生成的图形界面文件,这边暂时还没有用到,可以不用管。
包含头文件路径:
最后还有两个重要的小操作,一个是要选用C99 Mode:
另一个是加大栈空间,方法是打开.s启动文件,修改其中的Stack_Size值,我这边配置的是8KB(根据上面LVGL运行配置要求的推荐值确定):
至此,可以编译一下,由于还没有使用显示接口等,所以这边应该是编译无误的。
【3】添加心跳接口
在timer.c中包含头文件”lvgl.h”,并在1ms定时器中调用lv_tick_inc(1)启用心跳:
/*************************************************************************** 函数名称: TimeraUnit2_IrqCallback* 功能描述: 定时器A 单元2初始化* 输入参数: * 输出参数: * 返 回 值: * 其它说明: 1ms定时器中断**************************************************************************/static void TimeraUnit2_IrqCallback(void){lv_tick_inc(1);TIMERA_ClearFlag(TIMERA_UNIT2, TimeraFlagOverflow);}
当然还有其他方式让LVGL开始心跳,这个可以自行选用,控制心跳周期为1ms即可。
【4】显示接口对接屏幕驱动
修改lv_conf.h头文件中的#if 0为#if 1,同时根据适配的显示设备设置颜色格式:
补充,如果想屏幕刷新快一点,可以将FPS做如下调整(30ms–>10ms,即FPS由30–>100):
把lv_port_disp.c、lv_port_disp.h、lv_port_indev.c、lv_port_indev.h四个文件的#if 0 都改成#if 1 ,这四个文件包含的头文件名字还需根据编译报错信息修改。:
注意,若头文件#include “lvgl/lvgl.h”包含报错,可以添加宏定义LV_CONF_INCLUDE_SIMPLE。
修改lv_port_disp.c中适配底层屏幕驱动分辨率(我用的就是320*240):
/********************* *DEFINES *********************/#ifndef MY_DISP_HOR_RES#warning Please define or replace the macro MY_DISP_HOR_RES with the actual screen width, default value 320 is used for now.#define MY_DISP_HOR_RES320#endif#ifndef MY_DISP_VER_RES#warning Please define or replace the macro MY_DISP_HOR_RES with the actual screen height, default value 240 is used for now.#define MY_DISP_VER_RES240#endif
修改lv_port_disp.c中lv_port_disp_init函数,该函数包含两个部分,一个是初始化disp_init(),一个是绘制缓冲区。
其中初始化函数disp_init()中加入自己的屏幕初始化程序:
void LCD_AllInit(void){LCD_InitGPIO();//初始化几个GPIO口,包括CS\RS\WR\RD\RES\DB0-15LCD_HardwareReset();//LCD复位LCD_ConfigureREG(); //RGB配置初始化LCD_Clear(WHITE);//清屏白色LCD_DisplayDir(data_inter.showDirect);//默认为横屏}static void disp_init(void){/*You code here*/LCD_AllInit();}
修改lv_port_disp.c中lv_port_disp_init函数绘制缓冲区的部分,删除不用的Example for 2/3,只留Example for 1即可:
void lv_port_disp_init(void){/*------------------------- * Initialize your display * -----------------------*/disp_init();/*----------------------------- * Create a buffer for drawing *----------------------------*//** * LVGL requires a buffer where it internally draws the widgets. * Later this buffer will passed to your display driver's `flush_cb` to copy its content to your display. * The buffer has to be greater than 1 display row * * There are 3 buffering configurations: * 1. Create ONE buffer: *LVGL will draw the display's content here and writes it to your display * * 2. Create TWO buffer: *LVGL will draw the display's content to a buffer and writes it your display. *You should use DMA to write the buffer's content to the display. *It will enable LVGL to draw the next part of the screen to the other buffer while *the data is being sent form the first buffer. It makes rendering and flushing parallel. * * 3. Double buffering *Set 2 screens sized buffers and set disp_drv.full_refresh = 1. *This way LVGL will always provide the whole rendered screen in `flush_cb` *and you only need to change the frame buffer's address. *//* Example for 1) */static lv_disp_draw_buf_t draw_buf_dsc_1;static lv_color_t buf_1[MY_DISP_HOR_RES * 10];/*A buffer for 10 rows*/lv_disp_draw_buf_init(&draw_buf_dsc_1, buf_1, NULL, MY_DISP_HOR_RES * 10); /*Initialize the display buffer*//*----------------------------------- * Register the display in LVGL *----------------------------------*/static lv_disp_drv_t disp_drv; /*Descriptor of a display driver*/lv_disp_drv_init(&disp_drv);/*Basic initialization*//*Set up the functions to access to your display*//*Set the resolution of the display*/disp_drv.hor_res = MY_DISP_HOR_RES;disp_drv.ver_res = MY_DISP_VER_RES;/*Used to copy the buffer's content to the display*/disp_drv.flush_cb = disp_flush;/*Set a display buffer*/disp_drv.draw_buf = &draw_buf_dsc_1;/*Required for Example 3)*///disp_drv.full_refresh = 1;/* Fill a memory array with a color if you have GPU. * Note that, in lv_conf.h you can enable GPUs that has built-in support in LVGL. * But if you have a different GPU you can use with this callback.*///disp_drv.gpu_fill_cb = gpu_fill;/*Finally register the driver*/lv_disp_drv_register(&disp_drv);}
补充,如果想屏幕刷新快一点,在SRAM足够的条件下,可以修改默认的buf_1[MY_DISP_HOR_RES * 10]为buf_1[MY_DISP_HOR_RES * MY_DISP_VER_RES / 4]:
static lv_disp_draw_buf_t draw_buf_dsc_1;static lv_color_t buf_1[MY_DISP_HOR_RES * MY_DISP_VER_RES / 4];/*A buffer for 10 rows*/lv_disp_draw_buf_init(&draw_buf_dsc_1, buf_1, NULL, MY_DISP_HOR_RES * MY_DISP_VER_RES / 4); /*Initialize the display buffer*/
修改lv_port_disp.c中的刷屏函数disp_flush,默认的刷屏用打点的方式速度太慢,改用刷块的方式:
void LCD_Fill_LVGL(U16 sx,U16 sy,U16 ex,U16 ey,U16 *color){uint16_t height, width;uint16_t i, j;width = ex - sx + 1;/* 得到填充的宽度 */height = ey - sy + 1; /* 高度 */for (i = 0; i < height; i++){LCD_SetCursor(sx, sy + i); /* 设置光标位置 */LCD_WriteRAM_Prepare();/* 开始写入GRAM */for (j = 0; j x1,area->y1,area->x2,area->y2,(U16 *)color_p);/*IMPORTANT!!! *Inform the graphics library that you are ready with the flushing*/lv_disp_flush_ready(disp_drv);}
至此,显示接口对接屏幕驱动完成。
【5】输入设备接口对接编码器
如果没有使用到诸如触摸屏、鼠标、键盘、编码器、按键这几类的外部设备,只要显示界面不用操作的话,可以跳过这一节,直接到LVGL测试章节继续。
我这边用到的是磁控旋钮编码器外设。
修改lv_port_indev.c中lv_port_indev_init函数,包含两个部分,一个是初始化encoder_init(),一个是注册编码器外部设备。
其中初始化函数encoder_init()中加入自己的屏幕初始化程序:
/*Initialize your keypad*/extern STRU_KNOB knob_old;extern char knob_state_now;static void encoder_init(void){/*Your code comes here*/MLX_InitGPIO();MLX_InitSPI();knob_old = knobPosInit();//旋钮位置初始化}
删除不用的外部设备,只留下编码器作为唯一外部输入:
void lv_port_indev_init(void){/** * Here you will find example implementation of input devices supported by LittelvGL: *- Touchpad *- Mouse (with cursor support) *- Keypad (supports GUI usage only with key) *- Encoder (supports GUI usage only with: left, right, push) *- Button (external buttons to press points on the screen) * *The `..._read()` function are only examples. *You should shape them according to your hardware */static lv_indev_drv_t indev_drv;/*------------------ * Encoder * -----------------*//*Initialize your encoder if you have*/encoder_init();/*Register a encoder input device*/lv_indev_drv_init(&indev_drv);indev_drv.type = LV_INDEV_TYPE_ENCODER;indev_drv.read_cb = encoder_read;indev_encoder = lv_indev_drv_register(&indev_drv);/*Later you should create group(s) with `lv_group_t * group = lv_group_create()`, *add objects to the group with `lv_group_add_obj(group, obj)` *and assign this input device to group to navigate in it: *`lv_indev_set_group(indev_encoder, group);`*/}
注册外部设备,修改encoder_read函数,encoder_handler可以不用管,encoder_diff的加减表示左旋和右旋,encoder_state赋值LV_INDEV_STATE_PR或者LV_INDEV_STATE_REL表示按下或松开:
/*Will be called by the library to read the encoder*/static void encoder_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data){if(knob_state_now == 9 || knob_state_now == 15)//按下动作和按下状态都算按下encoder_state = LV_INDEV_STATE_PR; //按下elseencoder_state = LV_INDEV_STATE_REL;//松开if(knob_state_now == 19)encoder_diff ++;else if(knob_state_now == 17)encoder_diff --;data->enc_diff = encoder_diff;data->state = encoder_state;encoder_diff = 0;}
knob_state_now表示当前旋钮的状态,其状态的获得在主循环中获取,将其映射到encoder_diff和encoder_state这两个变量:
#define KNOB_ESCAPE 8 //旋钮抬起状态,xy不变#define KNOB_UP 17//z不变,正向旋转#define KNOB_DOWN 19//z不变,反向旋转#define KNOB_ENTER_BEF15//按下动作
创建、挂载Group
旋钮、键盘这种外部设备比较特殊,在初始化完成之后,如果想使用,必须先创建、挂载到Group才行,这一点,初始化函数 lv_port_indev_init 的注释中也明确地提到了:
/*Later you should create group(s) with `lv_group_t * group = lv_group_create()`, *add objects to the group with `lv_group_add_obj(group, obj)` *and assign this input device to group to navigate in it: *`lv_indev_set_group(indev_encoder, group);`*/
上面的意思就是说,使用编码器作为外部输入设备,需要使用“组”来配合,步骤如下:
首先使用lv_group_create来创建组;
其次将需要外部设备控制的控件,通过lv_group_add_obj挂载到组中;
最后使用lv_indev_set_group将该组与输入设备绑定;
使用例程如下:
lv_group_t *groupRect;//定义组名//1.创建组groupRect = lv_group_create();//2.挂载控件到组lv_group_add_obj(groupRect,guider_ui.screen_plus);lv_group_add_obj(groupRect,guider_ui.screen_minus);lv_group_add_obj(groupRect,guider_ui.screen_next);//3.将输入设备与该组绑定lv_indev_set_group(indev_encoder,groupRect);
注1:上面有三个不同的控件加入到组中,加入”组”中的先后顺序,就对应着你使用编码器在各个对象之间进行切换的先后顺序。最先加入的会先被选择,后加入的就会排在后面,就是这样的选择顺序;
注2:并不是所有的控件都能绑定组,诸如Label就不行;
注3:一个输入设备最好只绑定一个组,否则可能会出问题。如果需要界面进行切换,并在每个界面操作不同的控件组,那么输入设备需要绑定的组也要随着界面的切换而重新绑定。
四 LVGL测试
至此便完成了LVGL的显示接口以及输入设备接口的移植。
在主任务中添加上面的初始化,这边简单做了一个小界面方便展示(这个展示不包含上面的输入设备操作,带输入设备的例程可以看后面一篇文章):
#include "lvgl.h"#include "lv_port_disp.h"#include "lv_port_indev.h"static void lv_ex_label_1(void){lv_obj_t * label2 = lv_label_create(lv_scr_act());lv_label_set_recolor(label2, true);lv_label_set_long_mode(label2, LV_LABEL_LONG_SCROLL_CIRCULAR); /*Circular scroll*/lv_obj_set_width(label2, 120);// Hello world ! Trisuborn.lv_label_set_text(label2, "#ff0000 Hello# #00ff00 world ! Trisuborn.#");lv_obj_align(label2, LV_ALIGN_CENTER, 0, 0);}void GUI_Task(void){//LVGL初始化lv_init();//显示器初始化lv_port_disp_init();//外部输入初始化lv_port_indev_init();//界面生成lv_ex_label_1();while(1){knob_now = knobPosNow();//确定旋钮当前位置knob_state_now = Knob_State_Entry(&knob_old, &knob_now, knob_state_old);//确定旋钮当前状态if(KNOB_ESCAPE == knob_state_now){knob_state_old = knob_state_now;}else{knob_old = knob_now;knob_state_old = knob_state_now;}lv_task_handler();os_dly_wait(5);}}
效果如下: