news 2026/10/15 19:03:07

【ESP32笔记】LVGL移植(基于ST7789)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【ESP32笔记】LVGL移植(基于ST7789)

本节目标:理解 LVGL 移植的三个核心接口(显示刷新、输入读取、时基),能用 SPI 点亮 ST7789 屏并跑起可触摸的 LVGL 界面

一、本节要解决的问题

串口打印和 LED 能传递的信息很有限。要让设备"有脸",就需要图形界面。LVGL(Light and Versatile Graphics Library)是嵌入式领域最常用的开源 GUI 库,专为资源有限的设备设计,对操作系统、MCU、图形加速器提供统一接口。

LVGL 本身与硬件无关:它不知道你用的什么屏、触摸怎么读、时间从哪来。所以"移植"就是给它接上三根线:

  1. 显示刷新回调flush_cb—— LVGL 画完一块区域,通过这个函数把像素送到屏上;
  2. 输入设备读取回调read_cb—— LVGL 周期性调用它,拿到触摸坐标与按下/松开状态;
  3. 时基lv_tick_inc—— LVGL 靠它走时,动画、长按、双击等时间相关逻辑都依赖它。

本节用两个工程讲全流程:display是"裸移植"(ST7789 屏驱动 + CST816T 触摸驱动 + LVGL 端口层 + 官方 demo);lvgl_display在同样的移植代码上开发自己的界面(对象、事件、定时器、图片)。

二、硬件基础

用的是开发板配套的 1.69 寸 IPS 屏(模块型号 P169H002-CTP,驱动 IC ST7789V):面板可见区 240x280 像素,而 ST7789V 内部显存(GRAM)是240x320—— 面板只用了其中 280 行,上下各空 20 行,这就是后面刷新函数里"+20 偏移"的来历。屏支持 SPI 和 8080 并口,本工程用4 线 SPI,另加 DC 一根"命令/数据"选择线。

接线(两个工程完全一致,源码见display/components/bsp/lv_port.c):

信号GPIO说明
SCLK18SPI 时钟
MOSI19主机输出,接屏数据输入
CS5片选,低有效
DC17命令/数据选择:0 命令、1 数据
RST21硬件复位,低有效
BL26背光控制
SCL22CST816T 触摸的 I2C 时钟
SDA23CST816T 触摸的 I2C 数据

触摸芯片 CST816T 的 7 位 I2C 地址是0x15,SCL/SDA 在代码里使能了芯片内部上拉,主机速率 200 kHz(display)或 300 kHz(lvgl_display)。

三、知识点

3.1 SPI 基础与 ST7789

SPI 是高速、全双工、同步的串行总线,4 线定义:CS片选(低有效)、SCLK时钟、MOSI主端输出从端输入、MISO主端输入从端输出。

数据在 SCLK 的边沿被采样,是哪个边沿由两个参数决定:CPOL表示空闲时 SCLK 的电平,CPHA表示数据在第几个跳变沿采样(0 是第一个,1 是第二个),组合出 4 种模式:模式 0 (0,0)、模式 1 (0,1)、模式 2 (1,0)、模式 3 (1,1)。

驱动 ST7789 时 ESP32 是主端,只写不读,不需要 MISO;另外多用 3 根普通 GPIO:DC、RST、BL。本工程用模式 0。

常用命令(详见 IC 数据手册):SWRESET(01h) 软件复位、SLPOUT(11h) 退出休眠、COLMOD(3Ah) 像素格式(0x55=RGB565,0x66=RGB666)、MADCTL(36h) 显示方向、CASET(2Ah)/RASET(2Bh) 设置列/行写入窗口、RAMWR(2Ch) 写像素数据、INVON/NORON 颜色反转/普通模式、DISPON(29h) 打开显示。

3.2 LVGL 移植的三个核心接口

接口一:显示刷新。LVGL 需要一块"画布"—— 显示缓冲lv_disp_draw_buf_t,把屏幕分成若干块轮流渲染(本工程用两块 buffer 做双缓冲)。一块画好后,LVGL 调用flush_cb请我们搬运像素;搬完必须调用lv_disp_flush_ready()告诉它"可以画下一块了",否则 LVGL 会一直等,画面只刷新一次就不动了。

接口二:输入设备。触摸屏用LV_INDEV_TYPE_POINTER类型,注册一个read_cb。LVGL 每隔LV_INDEV_DEF_READ_PERIOD(本项目配置为 30ms)调用它,回调里填data->point.x/y和data->state(1=按下、0=松开)。

接口三:时基。用lv_tick_inc(ms)累加毫秒计数。ESP-IDF 下最方便的是用esp_timer周期定时器,每 5ms 调一次。

三个接口的数据流向:触摸坐标进 LVGL → LVGL 更新对象状态并渲染 →flush_cb把像素送出 → 屏幕硬件 DMA 发完后回调lv_disp_flush_ready。这条链路是异步的:flush_cb返回时数据可能还在路上,所以"搬运完成"的通知要由底层驱动发出(见 4.2 的回调链)。

3.3 界面开发三件套:对象、事件、定时器

  • 对象(lv_obj):LVGL 的一切控件都是对象,有父子关系 —— 子对象跟随父对象移动,父对象销毁时子对象一起销毁。lv_scr_act()拿到当前屏幕这个"根"对象,控件都往上挂;
  • 事件(event):用lv_obj_add_event_cb()给对象绑定回调,回调里先用lv_event_get_code(e)判断事件类型(如LV_EVENT_CLICKED、LV_EVENT_VALUE_CHANGED),再用lv_event_get_target(e)拿到触发者;
  • 定时器(lv_timer):lv_timer_create(cb, period_ms, user_data)创建一个由lv_timer_handler()驱动的周期任务,适合周期刷新数据(本工程用它每 2 秒读一次 DHT11)。

四、代码分析

4.1 两个工程的结构

display/ lvgl_display/ ├── CMakeLists.txt ├── CMakeLists.txt ├── main/ ├── main/ │ ├── st7789_driver.c/.h │ ├── lvgl_display.c # app_main() │ ├── cst816t_driver.c/.h │ ├── lv_port.c/.h # 移植三件套 │ └── main.c │ ├── ui_home.c/.h # 主页界面 ├── components/ │ ├── ui_led.c/.h # LED 控制页 │ ├── lvgl/ # LVGL 8.3.10 源码│ └── img/ # 预编译图片数组 │ └── bsp/ # 移植层 lv_port └── components/ └── sdkconfig ├── lvgl/ # git 子模块 └── bsp/ # 驱动+DHT11+WS2812

两个工程引入 LVGL 的方式不同,这是最容易踩的坑:

  • display/components/lvgl是把 LVGL 8.3.10 源码直接提交进主仓库(受版本控制的文件有一千多个),克隆下来即可编译;
  • lvgl_display/components/lvgl是未初始化的 git 子模块(目录是空的,.gitmodules中指向 github.com/lvgl/lvgl),直接编译会找不到 lvgl 组件,必须先执行git submodule update --init --recursive。

教程里说 display 的 LVGL 也是"用 git 子仓库的形式引入",与仓库现状不一致 —— 以实际目录为准。

4.2 ST7789 驱动:用 esp_lcd 点屏

驱动只做三件事:初始化 SPI、发初始化命令、提供写 RGB 数据的接口。先初始化 SPI 总线:

//初始化SPIspi_bus_config_tbuscfg={.sclk_io_num=cfg->clk,//SCLK引脚.mosi_io_num=cfg->mosi,//MOSI引脚.miso_io_num=-1,.flags=SPICOMMON_BUSFLAG_MASTER,//SPI主模式.max_transfer_sz=cfg->width*40*sizeof(uint16_t),//DMA单次传输最大字节,最大32768};ESP_ERROR_CHECK(spi_bus_initialize(LCD_SPI_HOST,&buscfg,SPI_DMA_CH_AUTO));

max_transfer_sz是单次 DMA 传输上限,这里设为"屏宽 x 40 行 x 2 字节" —— 正好等于后面 LVGL 一块显示缓冲的大小(280x40x2 = 22400 字节),保证一次刷新不被拆成多次传输。

接着创建 LCD 的 IO 句柄,把 DC/CS/频率/回调都交给esp_lcd框架:

esp_lcd_panel_io_spi_config_tio_config={.dc_gpio_num=cfg->dc,//DC引脚.cs_gpio_num=cfg->cs,//CS引脚.pclk_hz=cfg->spi_fre,//SPI时钟频率.lcd_cmd_bits=8,//命令长度.lcd_param_bits=8,//参数长度.spi_mode=0,//使用SPI0模式.on_color_trans_done=notify_flush_ready,//刷新完成回调函数};

(还有trans_queue_depth = 10缓存传输事务、user_ctx把回调参数透传下去。)关键成员是on_color_trans_done:底层 DMA 发完一帧像素后回调它,正好用来通知 LVGL"传输完了"。之后用esp_lcd_panel_io_tx_param()发命令(命令和参数走同一接口,框架自动控制 DC 引脚):

/*向LCD写入初始化命令*/esp_lcd_panel_io_tx_param(lcd_io_handle,LCD_CMD_SWRESET,NULL,0);//软件复位vTaskDelay(pdMS_TO_TICKS(150));esp_lcd_panel_io_tx_param(lcd_io_handle,LCD_CMD_SLPOUT,NULL,0);//退出休眠模式vTaskDelay(pdMS_TO_TICKS(200));esp_lcd_panel_io_tx_param(lcd_io_handle,LCD_CMD_COLMOD,(uint8_t[]){0x55,},1);//选择RGB数据格式,0x55:RGB565,0x66:RGB666

随后按spin参数在 0x00/0x60/0xC0/0xA0 中选旋转值写 MADCTL,最后LCD_CMD_DISPON打开显示,命令之间留出必要的延时。

写像素的函数负责"设窗口 + 写数据":先发 CASET 设置列窗口(RASET 对 y 同理),坐标含头含尾所以结束坐标要减 1;再用esp_lcd_panel_io_tx_color()把像素写进 RAMWR:

esp_lcd_panel_io_tx_param(lcd_io_handle,LCD_CMD_CASET,(uint8_t[]){(x1>>8)&0xFF,x1&0xFF,((x2-1)>>8)&0xFF,(x2-1)&0xFF,},4);// transfer frame buffersize_tlen=(x2-x1)*(y2-y1)*2;esp_lcd_panel_io_tx_color(lcd_io_handle,LCD_CMD_RAMWR,data,len);

最后是 DMA 完成回调,它只做一件事 —— 把"完成"转交给上层注册进来的函数(驱动不直接依赖 LVGL,因此可复用):

staticboolnotify_flush_ready(esp_lcd_panel_io_handle_tpanel_io,esp_lcd_panel_io_event_data_t*edata,void*user_ctx){if(s_flush_done_cb)s_flush_done_cb(user_ctx);returnfalse;}

4.3 display 工程的端口层 lv_port.c

显示缓冲:开两块各 40 行高的缓冲,必须带MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA—— SPI 走 DMA,无法访问外部 PSRAM:

staticlv_disp_draw_buf_tdraw_buf_dsc;size_tdisp_buf_height=40;/* 必须从内部RAM分配显存,这样刷新速度快 */lv_color_t*p_disp_buf1=heap_caps_malloc(LCD_WIDTH*disp_buf_height*sizeof(lv_color_t),MALLOC_CAP_INTERNAL|MALLOC_CAP_DMA);lv_color_t*p_disp_buf2=heap_caps_malloc(LCD_WIDTH*disp_buf_height*sizeof(lv_color_t),MALLOC_CAP_INTERNAL|MALLOC_CAP_DMA);

然后填显示驱动:hor_res/ver_res设为屏的宽高,再设置刷新函数与缓存并注册:

/* 设置刷新数据函数 */disp_drv.flush_cb=disp_flush;/*设置显示缓存*/disp_drv.draw_buf=&draw_buf_dsc;/*注册显示驱动*/lv_disp_drv_register(&disp_drv);

disp_flush把 LVGL 给的区域交给 ST7789,注意 x 方向加了 20—— 面板 240 行的可见区在 320 行显存里,旋转 90° 后偏移落在 x 上:

staticvoiddisp_flush(lv_disp_drv_t*disp_drv,constlv_area_t*area,lv_color_t*color_p){(void)disp_drv;st7789_flush(area->x1+20,area->x2+1+20,area->y1,area->y2+1,color_p);}

传输完成回调lv_port_flush_ready()只做一件事 —— 调用lv_disp_flush_ready(&disp_drv)。于是完整回调链为:DMA 完成 →notify_flush_ready→s_flush_done_cb(即lv_port_flush_ready)→lv_disp_flush_ready()。

教程 PDF 里disp_flush写的是st7789_flush(area->x1, area->x2 + 1, ...),没有 +20;仓库代码已经加了偏移才能正常显示。另外教程说触摸"后面一节再讲",但仓库的lv_port.c已经加了lv_port_indev_init、indev_read、tp_init三个函数,main里也有完整的 CST816T 驱动 —— 以仓库代码为准。

输入设备:触摸原始坐标要旋转/镜射后才是屏幕坐标:

voidIRAM_ATTRindev_read(struct_lv_indev_drv_t*indev_drv,lv_indev_data_t*data){int16_tx,y;intstate;cst816t_read(&x,&y,&state);data->point.x=y;if(x==0)x=1;data->point.y=LCD_HEIGHT-x;data->state=state;}

时基:用 5ms 的esp_timer周期定时器,回调里累加 tick(启动周期参数是tick_inc_period_ms * 1000,单位微秒):

staticvoidlv_tick_inc_cb(void*data){uint32_ttick_inc_period_ms=*((uint32_t*)data);lv_tick_inc(tick_inc_period_ms);}

lv_port_init()按顺序初始化:lv_init()→lcd_init()→lv_port_disp_init()→tp_init()→lv_port_indev_init()→lv_port_tick_init()。其中lcd_init()填入第二节表格里的引脚配置,spin = 1顺时针旋转 90 度,并把done_cb设为lv_port_flush_ready、cb_param传&disp_drv—— 拼上回调链的最后一段。

4.4 display 的 app_main

voidapp_main(void){lv_port_init();//初始化LVGLlv_demo_widgets();//初始化控件demo程序//lv_demo_stress();st7789_lcd_backlight(true);//打开背光while(1){vTaskDelay(pdMS_TO_TICKS(10));lv_task_handler();//LVGL循环处理}}

lv_task_handler()负责事件、动画、重绘,必须在循环里周期调用;lv_tick_inc由定时器后台累加,二者配合 LVGL 才能"动起来"。

4.5 lvgl_display:同一套移植的另一种写法

lvgl_display把移植代码直接放进main/lv_port.c,并采用了与 display 相反的坐标方案:

对比项displaylvgl_display
逻辑分辨率280x240240x280
ST7789 旋转 spin1(旋转 90°)0(不旋转)
flush 偏移x 方向 +20y 方向 +20
触摸换算x/y 交换 + 镜像坐标直接透传
触摸边界x_limit=LCD_HEIGHT、y_limit=LCD_WIDTHx_limit=LCD_WIDTH、y_limit=LCD_HEIGHT
#defineLCD_WIDTH240#defineLCD_HEIGHT280...voiddisp_flush(struct_lv_disp_drv_t*disp_drv,constlv_area_t*area,lv_color_t*color_p){st7789_flush(area->x1,area->x2+1,area->y1+20,area->y2+20+1,color_p);}

两种方案都能用:旋转 90° 是横屏,不旋转是竖屏。关键是屏幕驱动、触摸坐标、边界限制三者必须一致,改一个就要同步改另外两个。

4.6 界面开发:ui_home.c 与 ui_led.c

主页创建了图片、标签、滑条和定时器。滑条拖动时把 0~100 的亮度映射成灰度值,写入 12 颗 WS2812(GPIO32):

voidlight_slider_event_cb(lv_event_t*e){lv_event_code_tcode=lv_event_get_code(e);switch(code){caseLV_EVENT_VALUE_CHANGED:{lv_obj_t*slider_obj=lv_event_get_target(e);int32_tvalue=lv_slider_get_value(slider_obj);uint32_trgb_value=150*value/100;...//循环调用 ws2812_write 把 rgb_value 写给 12 颗灯break;}default:break;}}

温湿度用 LVGL 定时器每 2 秒刷新一次(lv_timer_create(dht11_timer_cb,2000,NULL),DHT11 接 GPIO25),避免在主循环里做慢速传感器操作:

voiddht11_timer_cb(struct_lv_timer_t*t){inttemp;inthumidity;if(DHT11_StartGet(&temp,&humidity)){chardisp_buf[32];snprintf(disp_buf,sizeof(disp_buf),"%.1f",(float)temp/10.0);lv_label_set_text(s_temp_label,disp_buf);...//湿度同理,用 "%d%%" 更新 s_humidity_label}}

ui_led.c是按钮控制 LED 的经典事件用法:按钮被点击时翻转 GPIO27 的电平,标签字号用lv_font_montserrat_20:

voidlv_led_button_cb(lv_event_t*e){lv_event_code_tcode=lv_event_get_code(e);staticuint32_tled_state=0;switch(code){caseLV_EVENT_CLICKED:led_state=led_state?0:1;gpio_set_level(GPIO_NUM_27,led_state);break;}}

而app_main里用#if 0把 LED 页关掉了,默认显示主页:

//lv_demo_widgets();#if0ui_led_create();//LED GPIO 初始化等#endifui_home_create();

想跑 LED 按钮页,把#if 0改成#if 1、再把ui_home_create()注释掉即可(两块界面都挂在lv_scr_act()上,同时创建会叠在一起)。

4.7 图片资源

main/img/下的temp_img.c、humidity_img.c是 64x64 的图片数组(图片转 LVGL 数组工具生成,每张 4096 字节),按LV_COLOR_DEPTH条件编译出不同像素格式,文件末尾是lv_img_dsc_t描述符。使用时声明后挂到图片对象上:

LV_IMG_DECLARE(temp_img)...s_temp_image=lv_img_create(lv_scr_act());lv_img_set_src(s_temp_image,&temp_img);

五、关键 API 速查

API作用所在头文件
spi_bus_initialize()初始化 SPI 总线(主机模式、DMA 通道)driver/spi_master.h
esp_lcd_new_panel_io_spi()创建 SPI 屏 IO 句柄,绑定 DC/CS/频率/回调esp_lcd_panel_io.h
esp_lcd_panel_io_tx_param()发送命令+参数(初始化命令、CASET/RASET)esp_lcd_panel_io.h
esp_lcd_panel_io_tx_color()发送 RGB 像素数据esp_lcd_panel_io.h
heap_caps_malloc()按内存能力分配(此处需内部 RAM+DMA)esp_heap_caps.h
lv_init()初始化 LVGL 内核lvgl.h
lv_disp_draw_buf_init()初始化显示缓冲(双缓冲)lvgl.h
lv_disp_drv_init()/lv_disp_drv_register()初始化/注册显示驱动lvgl.h
lv_disp_flush_ready()通知 LVGL 本次刷新完成(必须调用)lvgl.h
lv_indev_drv_init()/lv_indev_drv_register()初始化/注册输入设备lvgl.h
lv_tick_inc()累加 LVGL 时基,单位 mslvgl.h
lv_timer_handler()LVGL 主循环处理(事件、动画、刷新)lvgl.h
i2c_param_config()/i2c_driver_install()配置/安装 I2C 主机(CST816T)driver/i2c.h
lv_obj_add_event_cb()给对象绑定事件回调lvgl.h
lv_timer_create()创建 LVGL 周期定时器lvgl.h

六、编译与现象验证

两个工程的目标都是esp32(sdkconfig中CONFIG_IDF_TARGET="esp32")。

cdesp32-board/display idf.py build idf.py-pCOM<端口>flash monitor
  • 串口会打印Try allocate two 280 * 40 display buffer, size:44800 Byte、create esp_lcd_new_panel以及 CST816T 的 Chip ID/固件版本/Factory ID;
  • 屏上出现 LVGL 官方 widgets 演示(各种控件和动画)。没亮先检查背光跳针。
cdesp32-boardgitsubmodule update--init--recursive# lvgl_display 的 lvgl 子模块必须先拉取cdlvgl_display idf.py build idf.py-pCOM<端口>flash monitor
  • 屏上显示黑底主页:温度/湿度图标与数值、底部一条滑条;
  • 拖动滑条 12 颗 WS2812 亮度跟着变;DHT11 每 2 秒更新温湿度;若触摸位置整体偏移,检查 4.5 的"三者一致"。

教程里提到的idf.py menuconfig配置项,本仓库sdkconfig都已配好:CONFIG_LV_CONF_SKIP=y(不用 lv_conf.h,全部用 Kconfig 配置)、CONFIG_LV_COLOR_DEPTH=16、CONFIG_LV_COLOR_16_SWAP=y、CONFIG_LV_MEM_SIZE_KILOBYTES=48、CONFIG_LV_USE_DEMO_WIDGETS=y。要改 LVGL 行为,在 menuconfig 的 Component config → LVGL configuration 里找。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 19:02:52

Arduino blinker库实战:ESP8266/ESP32远程控制与避坑指南

简介&#xff1a;这份资源是面向Arduino物联网开发者的Blinker库&#xff08;点灯科技库&#xff09;压缩包&#xff0c;适合初学者与有一定经验的硬件爱好者&#xff0c;用于快速实现设备联网、远程控制与云端数据交互。包内以库源码文件为主&#xff0c;整体约718KB&#xff…

作者头像 李华
网站建设 2026/10/15 19:02:31

交通数据可视化系统性能优化实战:Node+Vue+MySQL+ECharts协同调优

简介&#xff1a;这是一套基于现代Web技术栈构建的交通大数据实时可视化系统&#xff0c;面向交通信息化开发者、智慧城市项目实践者及全栈学习者&#xff0c;解决海量交通数据难以直观分析与动态监控的问题。系统采用Node.js实现高并发后端服务与实时流处理&#xff0c;Vue.js…

作者头像 李华
网站建设 2026/10/15 19:01:32

LT9211D视频桥接芯片的MCU配置与硬件联调指南

简介&#xff1a;本资源为LT9211D MIPI转LVDS接口芯片的完整单片机驱动代码工程&#xff0c;面向嵌入式系统开发工程师、显示接口硬件工程师及高校相关方向实践学习者&#xff0c;解决MIPI DSI信号与LVDS显示设备间协议转换的底层驱动开发难题。压缩包含374个文件&#xff0c;以…

作者头像 李华
网站建设 2026/10/15 19:00:58

Modbus 寄存器怎么读?从寄存器地址到实际数值

做 PLC、仪表或传感器的数据采集时&#xff0c;经常会遇到一个问题&#xff1a;通信正常&#xff0c;也能读到数据&#xff0c;但数值就是不对。 温度显示成几千&#xff0c;电压变成小数&#xff0c;甚至同一个地址换个工具就读出了不同结果。排查这些问题&#xff0c;要先弄…

作者头像 李华
网站建设 2026/10/15 19:00:35

内存映射文件实战:.NET跨进程共享内存环形队列设计

先聊个真实的场景&#xff1a;某套系统里&#xff0c;主进程要往一个辅助进程里批量塞数据&#xff0c;峰值每秒几十万条&#xff0c;每条几十到几百字节不等。一开始图省事&#xff0c;直接用了命名管道&#xff0c;跑起来以后CPU一拉高&#xff0c;GC线程就开始喘&#xff0c…

作者头像 李华
网站建设 2026/10/15 19:00:22

信盈达STM32F407VG7T6最小可靠工程模板设计

简介&#xff1a;本资源是面向嵌入式初学者与STM32进阶开发者的Keil5工程模板&#xff0c;专为信盈达STM32F407VGT6核心板定制&#xff0c;解决新手环境搭建耗时、外设驱动配置繁琐、项目结构不清晰等痛点&#xff0c;适用于工业控制、智能硬件原型开发等实践场景。压缩包共233…

作者头像 李华