此文是我在arduino下使用LVGL的一些随手笔记,网上的资料不全,总踩很多坑,所以记录一些关键点。
TFT为ST7789,触摸芯片使用电容屏芯片CST816D。购自某宝,价格有点小贵,但尺寸适合88*38的小机箱。分辨率170*320。
1.arduino下安装LVGL和TFT_eSPI库。
安装方法很多,百度一下这个没什么坑。
arduino个人习惯2.X版本,但是有时要使用1.8.X,至于原因后面会讲到,所以要装2个版本,下文没特别说明的都是2.X上进行操作。
网络不好的话离线安装效率高很多,百度一下arduino esp32离线安装包就能找到下载。
LVGL是在arduino内安装的8.3.10版本,应该是截止目前(23年10月19日)最新的版本了。
2.TFT_eSPI库的使用
(1)启用配置文件
在Documents\Arduino\libraries\TFT_eSPI下找到User_Setup_Select.h文件,取消#include <User_Setups/Setup24_ST7789.h> 行的注释(如果默认取消掉了#include <User_Setup.h> 行,就把它注释掉。)
(2)修改配置文件
在Documents\Arduino\libraries\TFT_eSPI\User_Setups文件夹中找到Setup24_ST7789.h打开,根据实际情况修改:
#define TFT_MISO -1
#define TFT_MOSI 13
#define TFT_SCLK 10
#define TFT_CS 12
#define TFT_DC 11
#define TFT_RST 9
#define TFT_BL 14 //Also used as TFT-enable Pin
//INVERSION(反色)和RGB_ORDER(RGB颜色顺序)根据实际情况,下面只是我的屏幕设置
#define TFT_INVERSION_ON
#define TFT_RGB_ORDER TFT_BGR // Colour order Blue-Green-Red
TOUCH_CS如果不是使用集成的电阻触摸屏驱动,可以不启用。
//#define TOUCH_CS 40
(3)测试TFT_eSPI是否正常
arduino下TFT_eSPI的测试示例:
#include <TFT_eSPI.h> // Hardware-specific library
#include <SPI.h>
TFT_eSPI tft = TFT_eSPI(170,320);
tft.setRotation(1);
tft.init();
tft.fillScreen(TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
tft.println("1");
此处有坑:
我的1.9寸液晶Rotation=0无旋转的情况下,width=170,height=320是竖着显示,就和前文中照片那样的显示方式,但我的应用场景是横着的,所以要setRotation(1)。
但是旋转之后,长变320正常,竖边170老是不正常,上面缺了几十行。
经过研究是TFT_eSPI.cpp文件中引用了一个旋转头文件:
#elif defined (ST7789_DRIVER)
#include "TFT_Drivers/ST7789_Rotation.h"
跟踪TFT_Drivers/ST7789_Rotation.h中发现如下代码:
#ifdef CGRAM_OFFSET
Serial.println( "CGRAM_OFFSET defined" );
if (_init_width == 135)
{
colstart = 40;
rowstart = 53;
}
else if(_init_height == 280)
{
colstart = 20;
rowstart = 0;
}
else if(_init_width == 172)
{
colstart = 0;
rowstart = 34;
}
else if(_init_width == 170)
{
colstart = 0;
rowstart = 35;
Serial.println( "set width=170" );
}
else
{
colstart = 0;
rowstart = 0;
}
#endif
Serial.println( "set width=170" );这行是我添加做测试的。测试的结果是该块代码并未被执行!然后跟踪发现CGRAM_OFFSET并没有define,所以colstart和rowstart的偏移并未生效,屏幕上无端少了几十行。
再查原因是ST7789_Defines.h中有如下行:
#if (TFT_HEIGHT == 320) && (TFT_WIDTH == 170)
#ifndef CGRAM_OFFSET
#define CGRAM_OFFSET
#endif
#endif
所以需要在TFT_eSPI.cpp把ST7789_Defines.h文件include进去,但是由于不好测试TFT_HEIGHT和TFT_WIDTH如何从代码传递到该文件的,所以我干脆在ST7789_Rotation.h中直接增加:
#define CGRAM_OFFSET
编译测试TFT_eSPI终于显示正常了,到此TFT_eSPI显示部分总算折腾好了,然后就是LVGL显示了。
有人问为什么要折腾TFT_eSPI,这是因为LVGL使用了TFT_eSPI做屏幕驱动!
3.触摸驱动
没有了触摸功能,显示屏就没有了灵魂。
我显示屏触摸芯片使用的是CST816D芯片,我在arduino库中没有找到CST816D,但是有一个CST816S的(by fbiego),实际测试是可以用的。
要使用该驱动,首先我们得知道TFT_eSPI实际是集成了触摸驱动的,只不过是电阻屏的触摸,而CST816D是电容触摸的,所以不能用集成的驱动。
首先,我们要禁用掉其集成的电阻屏驱动,在TFT_eSPI的Setup24_ST7789.h中有一行:
#define TOUCH_CS
这行我们不使用它集成的驱动的话,我们不需要去启用它,保持被注释掉即可,它会根据该行是否被注释掉来决定是否编译电阻触摸屏的驱动进去。
然后我们把CST816S(by fbiego)这个库安装好,安装好后打开其自带的example,是一个实时串口输出触摸点坐标的例程。修改一下引脚和你ESP32与触摸屏的引脚一致即可开始测试。下面是例程修改后的头部分。
#include <CST816S.h>
CST816S touch(21, 22, 5, 4); // sda, scl, rst, irq修改成你自己的实际连线
例程会从串口输出触摸点的坐标。用手触摸屏幕,观察串口输出的坐标是不是和你的屏幕坐标定义是否一致,也就是你的屏幕驱动的XY轴原点和正方向是不是和触摸测试的结果一致。这个很重要,不然后面触摸就是乱的。
不一致的话就要修改触摸驱动了。刚好,我的触摸就和屏幕的方向定义不一致,XY轴方向是交换的,Y轴正方向也是反的(因为我的屏幕做了旋转rotation=1)。
所以我做了如下修改,打开libraries\CST816S文件夹中的CST816S.cpp文件,修改void CST816S::read_touch()函数,下面代码中int tmp开始就是我增加的,用于调整触摸的坐标输出。
void CST816S::read_touch() {
byte data_raw[8];
i2c_read(CST816S_ADDRESS, 0x01, data_raw, 6);
data.gestureID = data_raw[0];
data.points = data_raw[1];
data.event = data_raw[2] >> 6;
data.x = ((data_raw[2] & 0xF) << 8) + data_raw[3];
data.y = ((data_raw[4] & 0xF) << 8) + data_raw[5];
int tmp;
tmp = data.x;
data.x = data.y;
data.y = tmp;
data.y = 170 - data.y;
}
至于为什么是修改这个函数?那是因为这个函数是LVGL中调用触摸的驱动就是通过这个函数来的触摸坐标结果,下文会讲到。
到此,触摸驱动正常后就可以开始做LVGL的测试了。
4.LVGL例程测试
我们从LVGL的示例代码开始测试。打开例程修改好screenWidth和screenHeight,注意和TFT_eSPI中定义得要一致,毕竟你再TFT_eSPI中测试是正常的了嘛。但是这里也有坑。
打开例程后特别要注意的是这两行:
disp_drv.ver_res = screenWidth;
disp_drv.hor_res = screenHeight;
屏幕进行了旋转,这两行也要跟着更改,width和height与ver_res和hor_res对应关系要实际测试,不行就width和height互换一下。
到此,显示应该基本正常了,然后就是增加触摸驱动。
触摸屏驱动主要是修改触摸屏的事件回调函数:touchpad_read(),该函数在驱动设备注册部分被设定为回调函数。下面给出了例程中涉及触摸驱动的代码。
#include <CST816S.h>
static void ta_event_cb(lv_event_t * e)
//软键盘例程的触摸事件回调函数
{
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t * ta = lv_event_get_target(e);
lv_obj_t * kb = lv_event_get_user_data(e);
if(code == LV_EVENT_FOCUSED) {
lv_keyboard_set_textarea(kb, ta);
lv_obj_clear_flag(kb, LV_OBJ_FLAG_HIDDEN);
}
if(code == LV_EVENT_DEFOCUSED) {
lv_keyboard_set_textarea(kb, NULL);
lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN);
}
}
/*Read the touchpad该函数是修改过的,适配CST816D驱动*/
void my_touchpad_read( lv_indev_drv_t * indev_drv, lv_indev_data_t * data )
{
if (touch.available())
{
data->state = LV_INDEV_STATE_PR;
/*Set the coordinates*/
data->point.x = touch.data.x;
data->point.y = touch.data.y;
Serial.print("Data x ");
Serial.println(touch.data.x);
Serial.print("Data y ");
Serial.println(touch.data.y);
}
else
{
data->state = LV_INDEV_STATE_REL;
}
}
void setup()
{
touch.begin();
/*Initialize the (dummy) input device driver注册触摸驱动程序*/
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_touchpad_read;/*触摸回调函数*/
lv_indev_drv_register(&indev_drv);
/*Create a keyboard to use it with an of the text areas*/
//软键盘例程,和触摸相关的只是lv_obj_add_event_cb句
lv_obj_t * kb = lv_keyboard_create(lv_scr_act());
/*Create a text area. The keyboard will write here*/
lv_obj_t * ta;
ta = lv_textarea_create(lv_scr_act());
lv_obj_align(ta, LV_ALIGN_TOP_LEFT, 10, 10);
lv_obj_add_event_cb(ta, ta_event_cb, LV_EVENT_ALL, kb);
lv_textarea_set_placeholder_text(ta, "Hello");
lv_obj_set_size(ta, 140, 80);
ta = lv_textarea_create(lv_scr_act());
lv_obj_align(ta, LV_ALIGN_TOP_RIGHT, -10, 10);
lv_obj_add_event_cb(ta, ta_event_cb, LV_EVENT_ALL, kb);
lv_obj_set_size(ta, 140, 80);
lv_keyboard_set_textarea(kb, ta);
}
void loop()
{
lv_timer_handler(); /* let the GUI do its work */
delay( 5 );
}
到此,LVGL显示和触摸功能就应该OK了。
运行起来的软键盘例程效果是这样的:
当然,我自己做的开发板是这样的:
这个是适配88*38机壳的前面板,把主要的功能都做上去了,留了扩展IO和电源供电到接口板上。
前面板开孔是自己用小型CNC铣床自己加工的,略显粗糙。
5.关于向esp32上传文件
由于项目需要,我们经常需要在ESP32中存储一些文件,比如webserver、配置文件等,这时我们需要在ESP32上建立一个文件系统,并能够从PC上传文件上去。
我习惯使用arduino2.X,但是arduino 1.8.X才能使用网上广为流传的arduino-esp32fs-plugin插件,通过串口向esp32传输文件。
下载地址:https://github.com/lorol/arduino-esp32fs-plugin
但是要注意:
(1)有2个东西要下载,一个是esp32fs.jar,另一个是mkfatfs.exe,按照链接地址的说明放到相应文件夹中。参考下文:ESP32 Arduino FAT文件系统详细使用教程_esp32 fat_perseverance52的博客-优快云博客
注意esp32fs也有两个版本,一个老的只支持littlefs,新一点的那个增加了支持FATFs和SPIFFS,点开后会弹窗要求选择哪个文件系统。
(2)在arduino的“工具”->partition scheme菜单和flash size菜单两个地方,要根据esp32实际情况,把flash大小和分区方法选正确,否则可能出现刷写后不断重启的情况。partition scheme里分区方法也有littlefs、FATFs、SPIFFS几种选择,不要选错了。
(3)在arduino1.8.X中,使用esp32fs工具上传文件后,可以切换成arduino2.X版本里使用FATFs里listdir()查看到上传的文件。但是要注意例程里有个#define FORMAT_FFAT,如果定义成true,begin代码里有一句if (FORMAT_FFAT) FFat.format();意思会格式化掉文件系统,所以要把#define FORMAT_FFAT改成false才能保留在arduino1.8.X中上传的文件并浏览到。这也是使用1.8.X上传,在arduino2.X中使用的目的。两个版本里的flash size和partition scheme必须选择一致。
6.LVGL使用ESP32-S3 的内部文件系统
ESP32-S3模块板载的flash空间可以到16M甚至32M,小型应用不用外挂SD卡也够用了。下面是如何用模块内部的flash文件系统存储LVGL所需要的图片文件。
LVGL的版本是8.3.10,老版本的LVGL可能区别比较大,不适用这个方法。
(1)上传图片文件
首先在arduino中测试一下文件系统。我的ESP32-S3是16M的flash。参考前文5中,在arduino1.8.X的“工具”->partition scheme中选择合适的分区方法,我选择的是“16M flash(2M APP,12.5MFATFS)”,然后打开examples的FFat->FFat test例程,另存该文件到自建的文件夹(因为要用前文5中的文件上传工具,所以自建文件夹方便些)。
运行该例程,例程会把ESP32的flash按照所选择的分区进行格式化FATFS文件系统。注意该例程的开头有一句:
#define FORMAT_FFAT true
默认为true,后面begin()部分的代码:
if (FORMAT_FFAT) FFat.format();
会格式化文件系统。
如果改成false,代码就不会在格式化文件系统。
文件系统格式化好后,就使用前文5中的文件上传工具把要使用的jpg、png图片文件上传进去。方法是在自建文件夹中建一个data文件夹,图片文件放进去,上传工具会把该文件夹内的内容整个上传到esp32文件系统的根目录。
(2)flash中的FFat文件系统适配LVGL
找到lv_conf.h注意该文件的路径是在Arduino\libraries下,与lvgl文件夹并列,并不是在lvgl文件夹内部!!我就是被这个疏忽坑了一整天,后来才发现改错了文件(改成了lvgl文件夹内的一个lv_conf.h)。
找到文件的这个部分,修改成这个样子:
/*API for fopen, fread, etc*/
#define LV_USE_FS_STDIO 1
#if LV_USE_FS_STDIO
#define LV_FS_STDIO_LETTER 'F' /*Set an upper cased letter on which the drive will accessible (e.g. 'A')*/
#define LV_FS_STDIO_PATH "/ffat" /*Set the working directory. File/directory paths will be appended to it.*/
#define LV_FS_STDIO_CACHE_SIZE 0 /*>0 to cache this number of bytes in lv_fs_read()*/
#endif
需要特别注意的是:
LV_FS_STDIO_LETTER是后面LVGL代码中读取文件的“盘符”。
LV_FS_STDIO_PATH则是文件系统的挂载点,注意不是文件系统的根目录!!这个必须和文件系统的初始化代码中的挂载点保持一致!
if(!FFat.begin(false,"/ffat")){
Serial.println("FFat Mount Failed");
return;
}
你可以看看FFat 文件系统例程文件中include进去的FFat.h中关于该begin函数是怎么定义的。
bool begin(bool formatOnFail=false, const char * basePath="/ffat", uint8_t maxOpenFiles=10, const char * partitionLabel = (char*)FFAT_PARTITION_LABEL);
默认的FFat文件系统挂载点就是/ffat,所以在调用begin()时,不自己指定的话就是/ffat,也可以自己指定。
如果你要使用LittleFS文件系统,那也是可以的,只是默认挂载点变成/littlefs了:
bool begin(bool formatOnFail=false, const char * basePath="/littlefs", uint8_t maxOpenFiles=10, const char * partitionLabel="spiffs");
使用LittleFS文件系统在lv_conf.h中也是修改#define LV_USE_FS_STDIO 1部分,因为都是Flash设备,属于STDIO类。
(3)LVGL读取图片文件
先用LVGL的文件读写函数来测试一下文件读取是否正常。主要是看FFat和LVGL对接是否成功、文件的读取路径是否正常。
lv_fs_file_t f;
lv_fs_res_t res;
res = lv_fs_open(&f, "F:/minus.png", LV_FS_MODE_RD);
if(res != LV_FS_RES_OK) Serial.println("lv fs open error");
lv_fs_close(&f);
没报错的话,说明LVGL能正常读取FFat文件系统中的文件了。
在lv_vonf.h中使能png解码器:#define LV_USE_PNG 1,然后就可以试试看图片能否正常显示出来:
lv_obj_t * img;
img = lv_img_create(lv_scr_act());
lv_img_set_src(img, "F:/minus.png");
lv_obj_align(img, LV_ALIGN_TOP_RIGHT, 0, 0);
如果文件打开没有报错,屏幕上却显示no data,尝试把lv_conf.h中的内存分配改大些,前提是你的esp32模块有那么多内存可用。下面这行默认是(32U * 1024U),我改到了(128U * 1024U)方能正常解码96*96像素的png图片。
#define LV_MEM_SIZE (128U * 1024U)
(5)这个部分的关键代码
#include "FS.h"
#include "FFat.h"
#define FORMAT_FFAT false //不再格式化文件系统,否则使用arduino 1.8.x中ESP32fs工具上传的文件会丢失
void setup()
{
Serial.begin( 115200 ); /* prepare for possible serial debug */
Serial.setDebugOutput(true);
if (FORMAT_FFAT) FFat.format();
if(!FFat.begin(false,"/ffat")){
Serial.println("FFat Mount Failed");
return;
}
//测试文件读取,可以不用
lv_fs_file_t f;
lv_fs_res_t res;
res = lv_fs_open(&f, "F:/minus.png", LV_FS_MODE_RD);
if(res != LV_FS_RES_OK) Serial.println("lv fs open error");
lv_fs_close(&f);
img = lv_img_create(lv_scr_act());
lv_img_set_src(img, "F:/minus.png");
lv_obj_align(img, LV_ALIGN_TOP_RIGHT, 0, 0);
}
7.LVGL中使用中文字体
暂时未了解到支持全部中文字库的方法。本方法是arduino IDE下的,vscode下有细节差异。
LVGL提供了一种比较节省flash存储空间的方法,即只把你所需要的中文转换成C字节阵列。
转换工具在:https://lvgl.io/tools/fontconverter
自己在电脑的c:\windows\fonts下面选择一种你中意的ttf字体,设置好字库名(例Font_CN)、大小,输入你想转换的文字,点击convert即可。注意:
(1)转换工具似乎不支持长路径和中文路径,所以最好把ttf字体文件复制到驱动器根目录再选择。
(2)不能使用特殊字符、运算符作为字库名,比如减号-、百分号%、除号/等,但下划线_可以。
转换成功会会下载一个.c文件,拷贝到lvgl/src/font文件夹。打开该文件,修改
#include "../../lvgl/lvgl.h"
行为你实际的lvgl.h文件所在路径即可,LVGL会自动编译你拷贝进去的字库文件。
剩下的就是你需要再arduino的程序文件中声明要使用该字库即可:
LV_FONT_DECLARE(Font_CN);
然后你就可以创建一个带中文的按钮来测试了。
lv_obj_t* btnStartStop=lv_btn_create(leftContainer);
//lv_obj_set_pos(btnStartStop, 0, 170-32);
lv_obj_set_size(btnStartStop, 50, 32);
lv_obj_align(btnStartStop,LV_ALIGN_BOTTOM_LEFT,-15,15);
labelStartStop = lv_label_create(btnStartStop);
lv_label_set_text(labelStartStop, "启动");
lv_obj_set_style_text_font(labelStartStop,&Font_CN,0);
lv_obj_set_align(labelStartStop, LV_ALIGN_CENTER);
注意:如果想使用ASCII字符的大号字体,需要再lvgl.h文件中启用该字体。
8.LVGL中回调函数操作多个对象
回调函数一定要定义成静态函数。回调函数中可以操作全局变量,回调函数也有一个user_data参数,可以把一个你想操作的对象传给回调函数进行操作。
所以一般是这样的,这是lvgl文档中的例子:
#include "../lv_examples.h"
#if LV_BUILD_EXAMPLES && LV_USE_BTN
static void btn_event_cb(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) {
static uint8_t cnt = 0;
cnt++;
/*Get the first child of the button which is the label and change its text*/
lv_obj_t * label = lv_obj_get_child(btn, 0);
lv_label_set_text_fmt(label, "Button: %d", cnt);
}
}
/**
* Create a button with a label and react on click event.
*/
void lv_example_get_started_1(void)
{
lv_obj_t * btn = lv_btn_create(lv_scr_act()); /*Add a button the current screen*/
lv_obj_set_pos(btn, 10, 10); /*Set its position*/
lv_obj_set_size(btn, 120, 50); /*Set its size*/
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_ALL, NULL); /*Assign a callback to the button*/
lv_obj_t * label = lv_label_create(btn); /*Add a label to the button*/
lv_label_set_text(label, "Button"); /*Set the labels text*/
lv_obj_center(label);
}
#endif
但是某些时候LVGL的回调函数需要处理多个对象,而这些对象并不一定是全局变量(典型的,有可能是自己定义的类变量),没法直接对其操作,这个时候就得像办法把多个对象传递给回调函数了。
例如:按钮btn1被点击时,你需要隐藏label1、label2。
方法有很多,比如:
(1)使用同一个隐藏的父对象
把label1和label2都定义成一个隐藏对象obj下,作为其child。首先定义一个空对象:
lv_obj_t * obj=lv_obj_create(lv_scr_act());
然后将其作为父对象来创建label1和2:
lv_label_create(label1,obj);
lv_label_create(label2,obj);
在btn1的回调函数cb中直接把obj作为user_data传过去,通过
lv_obj_add_flag(obj,LV_OBJ_FLAG_HIDDEN)
把两个label都隐藏掉。
(2)使用指针数组
把你想操作的对象指针依次放到数组里。注册回调函数时把指针数组作为user_data传给回调函数。然后在回调函数中去依次解出来操作。
首先定义一个指针数组:
lv_obj_t * lvObjPtrArray[5];
然后把两个label都加到数组里:
lv_label_create(label1,lv_scr_act());
lv_label_create(label2,lv_scr_act());
lvObjPtrArray[0]=label1;
lvObjPtrArray[1]=label2;
在btn的回调函数里把数组作为user_data注册进去:
lv_obj_add_event_cb(btn1, btn1_event_cb, LV_EVENT_ALL, lvObjPtrArray);
在回调函数里,需要把该指针数组分解成各个对象,就可以对其操作了:
static void btn1_event_cb(lv_event_t * e)
{
lv_obj_t ** ar = (lv_obj_t **)e->user_data;
lv_obj_t * label1=(lv_obj_t *)ar[0];
lv_obj_t * label2=(lv_obj_t *)ar[1];
lv_obj_add_flag(label1,LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(label2,LV_OBJ_FLAG_HIDDEN);
}
但是要特别小心的是,你要保证回调函数被调用时,lvObjPtrArray中的所有对象都已经被初始化了,或者是存在着的没有被删除掉。否则回调函数操作该对象会直接导致CPU core重启。最好在回调函数里先判断一下对象是否是NULL,不是NULL再对其操作。
(3)使用结构体
定义一个结构体,把需要操作的对象指针放到结构体里面。NXP的gui guider软件生成GUI就是这个操作方法。
定义结构体lv_gui:
typedef lv_gui{
lv_obj_t * btn1;
lv_obj_t * label1;
lv_obj_t * label2;
};
可以在类里面定义,也可以在外面,没有实质区别。
再定义一个结构体对象:
lv_gui * gui1;
注册回调函数时把结构体对象传递过去:
lv_obj_add_event_cb(btn1, btn1_event_cb, LV_EVENT_ALL, gui1);
回调函数里操作对象:
static void btn1_event_cb(lv_event_t * e)
{
lv_gui * gui1 = (lv_gui *)e->user_data;
lv_obj_t * label1=gui1->label1;
lv_obj_t * label2=gui1->label2;
lv_obj_add_flag(label1,LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(label2,LV_OBJ_FLAG_HIDDEN);
}