开头
做嵌入式这几年,最常被朋友问起的问题之一就是:"我想给ESP32加块屏幕,到底怎么搞?"说实话,ESP32搭配ST7789驱动的小尺寸IPS屏,再挂上LVGL图形库,几乎是我见过性价比最高、也最适合快速出效果的显示方案了。这块组合在智能家居面板、桌面小摆件、运动手表原型、环境监测站里随处可见,网上资料虽然多,但大多零散,要么只讲点亮屏幕,要么只讲LVGL的API,很少有文章把"从拿到一块裸屏到跑出流畅界面"的完整链路串起来讲透。
这篇文章就是我基于实际项目整理的完整实战记录,涵盖硬件接线、ST7789底层驱动、SPI与DMA加速、LVGL移植、界面开发、内存优化和常见坑点排障。我自己用的是ESP-IDF v5.x环境,但文章中会把关键差异点标注出来,你用Arduino IDE或者PlatformIO也能跟着走通。无论你是刚接触单片机的学生,还是想快速给产品加显示功能的工程师,这篇文章的目标只有一句话:让你少走弯路,用最短时间把界面跑起来。
1. 项目全景与方案选型
1.1 为什么是ESP32、ST7789和LVGL的组合
先聊聊这仨为什么能凑到一块。
ESP32是我个人非常喜欢的主控,双核240MHz的Xtal,内置520KB SRAM,这个内存规模在微控制器里算相当宽裕了。驱动一块240x240分辨率的屏幕,如果用16位色深,一帧完整画面需要240x240x2字节,算下来差不多112.5KB。ESP32完全可以装下两块这样的帧缓冲,这为LVGL的双缓冲刷新模式提供了硬件基础。而且ESP32的SPI外设带DMA,可以做到屏幕上屏不占CPU,这在显示复杂界面时非常关键。
ST7789是目前小尺寸彩屏的主流驱动芯片,常见于1.3寸、1.54寸、2.0寸的IPS屏模组,分辨率从240x240到240x320都有。它支持SPI接口,最少只要4根信号线加电源就能点亮,硬件连接极其简单,对飞线和手工焊接都很友好。同时ST7789具有1.8V到3.3V的IO电平容忍范围,可以直接和ESP32的3.3V GPIO对接,不需要电平转换电路。
LVGL(Light and Versatile Graphics Library)则是目前嵌入式图形库的事实标准。它的优势在于控件丰富、动画流畅、支持中文字体,而且对资源的要求很灵活,裁剪配置做好之后,ROM占用可以控制在几十KB,RAM占用也很可控。更关键的是LVGL是纯C写的,移植接口就几个回调函数,不挑底层平台。
这个组合本质上解决的是一个经典矛盾:想快速做出漂亮界面,又不想在产品成本、开发周期、硬件体积上付出太大代价。ESP32负责"带得动",ST7789负责"显示好",LVGL负责"画得快",三者各司其职,几乎没有短板。
1.2 显示方案的三种驱动思路对比
在实际动手之前,我建议先搞清楚一个方向性问题:你的显示路径走哪条。
第一种:裸机直接推帧缓冲。这种方案就是自己准备好一整块显示数据,然后通过SPI整帧推给ST7789,不经过任何图形库。优点是实现极简,适合开机logo、静态画面、数字仪表等简单场景。缺点是所有绘图逻辑都要自己写,画个圆、显示个汉字都是体力活,后续维护成本很高。
第二种:单缓冲加局部刷新。先移植LVGL,但只开辟一块较小的缓冲区(比如240x20像素),LVGL每渲染好一小块区域就通过DMA发送到屏幕,发完再渲染下一块。这种方案RAM占用小,适合内存紧张的MCU。缺点是屏幕刷新过程有明显的"撕裂"或"扫描感",尤其是显示滚动列表或动画时,视觉上会有轻微闪烁。
第三种:双缓冲加DMA后台传输。这是体验最好的方案。LVGL在后台缓冲区渲染,前台缓冲区通过DMA发送到屏幕,两块交替使用,渲染和传输可以并行。对于240x240的屏幕,双缓冲需要约120KB RAM,ESP32刚好够用。我实测下来,这种配置下LVGL的60fps动画可以跑得比较顺畅,拖动列表也不会出现撕裂。
我的建议是:如果你用的是ESP32且屏幕分辨率不超过320x320,直接上双缓冲,这是最优体验和开发效率的平衡点。如果内存确实紧张,再退回单缓冲模式,LVGL的配置改动只是一个宏定义的事。
2. 硬件连接与环境准备
2.1 屏幕接口与ESP32接线
市面上ST7789屏幕模组引脚定义大同小异,常见的就8个引脚:VCC、GND、SCL、SDA、RES、DC、CS、BLK。有些模组还会把BLK标注为BL或者LED。接线之前一定先看屏幕背面丝印,别直接照着网上的引脚顺序套,我见过不少因为同一家店的模组批次不同引脚顺序就变了而烧板子的案例。
下面这张表是我在项目中实际使用的接线对应关系:
| 屏幕引脚 | ESP32引脚 | 说明 |
|---|---|---|
| VCC | 3V3 | 部分模组也兼容5V,但建议3.3V |
| GND | GND | 共地必须接,不能省 |
| SCL | GPIO18 | SPI时钟 |
| SDA | GPIO23 | SPI数据(主出从入) |
| RES | GPIO4 | 复位,低电平有效 |
| DC | GPIO2 | 数据/命令选择 |
| CS | GPIO5 | 片选,低电平有效 |
| BLK | GPIO22 | 背光控制,也可以直接接3V3常亮 |
ESP32的SPI引脚不是固定的,GPIO18和GPIO23是我惯用的默认SPI引脚,实际你可以根据自己PCB布局改到任何支持输出的引脚上。
需要特别提醒的是背光引脚的处理。BLK直接接3V3虽然能常亮,但如果你有低功耗需求,或者想在界面空闲时灭屏,最好把BLK接到一个独立的GPIO上通过PWM控制亮度。我调试初期为了省事直接接了3V3,结果每次想测息屏逻辑都要拔线,后来才用GPIO22接管。另外注意BLK引脚如果悬空,部分模组会默认关闭背光,屏幕点不亮还以为是驱动代码的问题,特别坑。
电源方面也要多说一句:屏幕模组虽然标称3.3V,但瞬时电流在点亮全白画面时可能到几十毫安,如果用开发板上的AM1117稳压器供电,在WiFi开启和高亮度背光同时工作时可能会造成压降,导致屏幕闪烁或重启。稳妥的做法是外接一个独立的3.3V LDO给屏幕供电,或者至少保证供电电流足够。
2.2 开发环境搭建:ESP-IDF还是Arduino
做这个项目之前,开发环境选择会直接影响后面所有代码的写法。
我强烈推荐使用ESP-IDF v5.x,原因有三:一是ESP-IDF的SPI驱动(esp_lcd和spi_master)对ST7789这类屏幕驱动芯片有非常完善的现成支持,底层DMA调度、中断处理都做好了;二是官方的组件仓库可以直接拉取LVGL,版本管理省心;三是IDF带FreeRTOS,LVGL跑在独立任务里非常自然,后面扩展OTA、蓝牙、传感器采集都方便。
当然如果你对Arduino更熟悉,也没问题。Arduino环境下有TFT_eSPI这个库,配合LVGL的Arduino绑定也很流畅。TFT_eSPI的好处是配置简单,改一下User_Setup.h文件就能驱动ST7789,但我个人感觉它在复杂场景下对DMA和中断的掌控力还是不如IDF的esp_lcd。
安装ESP-IDF的过程我就不展开了,官方文档按步骤来就行。需要留意的点是:如果你用VS Code搭配Espressif插件,安装的时候选完整版,避免后面拉取组件时缺工具链。另外如果网络下载慢,记得配置代理源,这个在实际开发中能省很多时间。
工程结构上,我习惯用如下目录组织项目:
project/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c │ ├── lvgl_demo/ │ │ ├── ui.c │ │ └── ui.h │ └── hardware/ │ ├── st7789_driver.c │ └── st7789_driver.h └── dependencies.lock2.3 LVGL组件的获取方式
ESP-IDF v5.x下获取LVGL有两条路,一条是用组件管理器(Component Manager),在工程根目录的main/idf_component.yml里声明依赖,另一条是把LVGL源码直接以git子模块形式放进components目录。
我推荐用组件管理器,因为LVGL官方在ESP-IDF注册中心维护了一个lvgl/lvgl组件包,直接支持通过esp_idf_components拉取。在main目录下创建idf_component.yml,写入以下内容:
dependencies: idf: ">=5.0" lvgl/lvgl: "^8.3.0"需要注意的版本匹配问题:LVGL 8.x和LVGL 9.x的API变化不小,8.x的lv_disp_drv_t在9.x中被改名为lv_display_t,如果你参考的是老教程,一定要注意版本差异。我目前生产项目还在用8.3,因为生态最成熟、资料最多,等9.x的坑踩得差不多了再迁移。
如果你在Arduino环境下开发,更简单:在库管理器里直接搜lvgl安装,版本同样建议选8.x。
3. ST7789驱动核心细节
3.1 ST7789芯片特性与初始化时序
ST7789这颗驱动IC内部集成了240x320像素的GRAM(显存),它本身不存储完整图像帧,你通过SPI把像素数据写入它的GRAM,屏幕控制器就会持续刷新显示。所以从主控的角度看,你的工作只有两件事:初始化屏幕寄存器、往GRAM里灌像素数据。
初始化可以简单理解为"让屏幕进入一个已知的正常工作状态"。下面是ST7789初始化过程中最关键的几个寄存器和操作序列:
- 软复位(Software Reset, 0x01):让芯片回到上电默认状态,之后要等待至少5ms。
- 退出休眠(Sleep Out, 0x11):屏幕从低功耗模式唤醒,等待至少120ms,让内部DC-DC稳压器稳定。
- 设置像素格式(Color Format, 0x3A):将数据格式设为RGB565,即16位色深,命令参数是0x05(表示65K色)。
- 设置扫描方向和坐标偏移(0x36, 0x37):这两个寄存器决定图像是正对屏幕还是旋转了90度/镜像,以及原点偏移量,不同模组的封装差异主要就在这里。
- 打开显示(Display On, 0x29):让屏幕真正开始显示GRAM里的内容。
下面是我在实际项目中整理出来的一段精简初始化序列,你可以直接照着用:
static const uint8_t st7789_init_cmds[] = { 0x01, 0, // Software Reset 0x11, 0, // Sleep Out 0x3A, 1, 0x05, // RGB565 color format 0x36, 1, 0x00, // MADCTL: scan direction 0x37, 2, 0x00, 0x00, // Vertical scroll start 0xB2, 5, 0x0C, 0x0C, 0x00, 0x33, 0x33, // Porch setting 0xB7, 1, 0x35, // Gate Control 0xBB, 1, 0x19, // VCOM setting 0xC0, 1, 0x2C, // LCM Control 0xC2, 1, 0x01, // VDV and VRH command enable 0xC3, 1, 0x12, // VRH setting 0xC4, 1, 0x20, // VDV setting 0xC6, 1, 0x0F, // Frame rate control 0xD0, 2, 0xA4, 0xA1, // Power control 0xE0, 14, 0xD0,0x04,0x0D,0x11,0x13,0x2B,0x3F,0x54,0x4C,0x18,0x0D,0x0B,0x1F,0x23, // Gamma+ 0xE1, 14, 0xD0,0x04,0x0C,0x11,0x13,0x2C,0x3F,0x44,0x51,0x2F,0x1F,0x1F,0x20,0x23, // Gamma- 0x21, 0, // Display Inversion On 0x29, 0, // Display On };这段序列里的Gamma设置部分可能因屏幕批次而异,如果你发现显示偏色严重,优先检查Gamma参数和模块厂商提供的参考值。另外要注意速度问题,发送初始化命令时节奏不能太快,关键延时不能省,比如Sleep Out后必须等待至少120ms,否则屏幕可能无法正常唤醒,表现为白屏或者闪屏。
很多人会在这里犯一个错误:想当然地以为屏幕驱动芯片都是相同的初始化序列,直接从别人代码里抄过来。不同厂家、不同批次的屏幕初始化参数可能不同,最保险的是向模组供应商要初始化代码,或者对照datasheet逐个寄存器核对。
3.2 SPI通信参数与DMA加速
ST7789通过SPI接收数据,时钟极性(CPOL)和相位(CPHA)建议用SPI模式0(CPOL=0, CPHA=0)。ESP32的SPI主机支持的最高时钟频率远高于屏幕本身的上限,实际使用中,ST7789的SPI时钟上限通常在60MHz左右,但我不建议一上来就用最高频。
我在调试时习惯从10MHz起步,确认屏幕能正常显示后再逐步提升频率,直到出现花屏再降回安全值。这个"花屏降频"的临界点会有个体差异,和你的飞线长度、PCB布线质量都有关系。我最终稳定使用的SCLK是40MHz,配合DMA传输,整帧240x240x2字节数据大概耗时2.4ms,这个速度足够LVGL流畅刷屏了。
在ESP-IDF下启用DMA很简单,配置spi_bus_config_t时把.quadwp_io_msb留空,然后在spi_device_interface_config_t中配置.flags = SPI_DEVICE_HALFDUPLEX,发送大块数据时使用spi_device_transmit配合spi_transaction_t.tx_buffer。
值得注意的一点是,ESP32的DMA对发送缓冲区有对齐要求。如果你用的是动态分配的内存(比如heap_caps_malloc),一定要用MALLOC_CAP_DMA标志来分配,否则DMA传输可能出现随机性的数据错乱。我第一次移植时就忽略了这一点,用默认的malloc分配帧缓冲,结果屏幕偶尔出现横条纹,排查了大半天才定位到是缓冲区不对齐。
3.3 坐标窗口、偏置与屏幕方向的坑
ST7789内部GRAM的实际大小是240x320,而你买的屏幕可能是240x240,这意味着芯片有很多显存地址超出了物理屏幕范围。当你往超出屏幕范围的内存地址写入数据时,数据不会显示出来,但也不会报错,界面会出现内容偏移或者"只显示左上角"的情况。
解决这个问题靠两个寄存器配合:**CASET(0x2A)**设定列地址范围,**RASET(0x2B)**设定行地址范围,也就是所谓的"窗口"(window)。每次往GRAM写数据前,都要先设定窗口,然后持续写入数据,数据量超过窗口宽度时会自动换行。
对于240x240的屏幕,最常用的做法是把窗口设置在GRAM的某个偏移位置上,使屏幕正好显示GRAM的一个子区域。常见的偏移组合是水平偏移0、垂直偏移80,或者水平偏移52、垂直偏移40,具体取哪个值取决于模组出厂时封装的玻璃走线方向。最快的方法是自己试:先用全屏填充命令画一个纯色背景,然后调整偏移参数看屏幕是否被完整均匀覆盖。
屏幕方向控制则是通过MADCTL寄存器(0x36)实现的。MADCTL的bit5到bit7分别控制行/列方向交换和刷新方向,常见的方向配置值有0x00(竖屏)、0x60(90度旋转)、0xC0(180度旋转)、0xA0(270度旋转)。注意方向改变后,窗口偏移参数也需要对应调整,否则会出现图像颠倒但位置不对的问题。我在做不同的项目时经常需要适配不同安装方向的屏幕,这块踩过好多次坑,建议你在驱动层封装一个函数专门用来设置方向,方便后续复用。
4. LVGL移植与适配
4.1 LVGL移植的整体思路
LVGL的移植官方文档写得很清楚,本质上是实现三个底层接口:
- 显示屏驱动回调(flush_cb):接收LVGL渲染好的像素数据,通过SPI写入屏幕。
- 触摸/输入设备驱动回调(read_cb):如果接的是触摸屏,通过I2C或SPI读取触摸坐标并传给LVGL。
- 时钟心跳(tick):LVGL内部所有动画、定时器都依赖内部的系统时钟节拍。
弄清楚这三个接口之后,LVGL移植这件事就变得非常简单了,难点反而在于你对底层的屏幕驱动是否足够熟悉。所以我在第3节花了大篇幅讲ST7789,因为如果屏幕底层不稳定,就算LVGL移植成功,显示效果也一定一团糟。
在ESP-IDF环境下,LVGL官方还提供了一个叫esp_lcd的抽象层,它把SPI接口和屏幕驱动芯片进一步封装成了标准的esp_lcd_panel_io和esp_lcd_panel接口。用这套接口的好处是代码更简洁,而且官方对ST7789的支持是开箱即用的,你只需要传入SPI总线和GPIO配置,就能得到一个已经初始化好的面板对象。
但我个人建议,如果你是初学者,还是先自己写一遍裸驱动的流程,再考虑用esp_lcd抽象。自己写一遍能让你真正理解DMA怎么工作、窗口怎么设定、底层数据流怎么走,将来遇到问题时不会抓瞎。
4.2 lv_conf.h关键配置项详解
LVGL通过一个头文件lv_conf.h来裁剪功能,这个文件可以在LVGL源码目录下找到模板,拷贝出来后根据需求修改。以下是我认为影响最大的几个配置项:
#define LV_COLOR_DEPTH 16颜色深度必须和屏幕驱动的RGB565格式对应,如果你这里定义了32位色深,显示会直接崩溃或者颜色全乱,这是移植初期最常见的错误。
#define LV_MEM_CUSTOM 1LVGL内部的动态内存分配器默认使用自带的lv_mem实现,你也可以设为1后让它直接调用系统的malloc和free。在ESP-IDF下,我建议设为1,因为IDF的堆管理器本身就带内存统计功能,方便你监控剩余内存。
#define LV_DISP_DEF_REFR_PERIOD 30这个宏表示LVGL的刷新周期,单位是毫秒。默认30ms意味着约33fps的刷新率,如果你觉得界面不够流畅,可以改成16甚至10,但要注意刷新太快会显著增加CPU占用。我的经验是30ms在大多数场景下够用,追求动画丝滑可以改成15ms,同时确保你的SPI传输速度跟得上。
#define LV_USE_LOG 1 #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN打开日志输出,调试时能看到LVGL内部的警告和错误,发布时关掉可以省一点Flash和CPU资源。
内存管理方面,LVGL在初始化时会从系统堆里取一块内存作为自己的缓冲区池。这个大小在lv_init之后由lv_mem_init决定,实际上取决于LV_MEM_SIZE宏。240x240屏幕建议至少给LVGL分配48KB内存池,才能比较从容地创建多个控件和动画。
4.3 和ST7789驱动对接:flush_cb实现细节
flush_cb是LVGL移植的核心,LVGL每渲染好一块局部画面,就会调用这个回调把数据送出去。我的实现大概长这样:
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { esp_lcd_panel_handle_t panel = (esp_lcd_panel_handle_t)drv->user_data; esp_lcd_panel_draw_bitmap(panel, area->x1, area->y1, area->x2 + 1, area->y2 + 1, color_p); lv_disp_flush_ready(drv); }要注意lv_disp_flush_ready的调用时机会影响性能。如果你在SPI数据发送完成后再调用flush_ready,那么发送期间LVGL会等待,浪费了时间;正确做法是在启动DMA传输后立即返回并调用flush_ready,LVGL就会立刻开始渲染下一块画面,而DMA在后台默默发送,实现了渲染和传输的流水线并行。
不过这样做有一个隐患:如果缓冲区还在被DMA读取,LVGL就开始往里写新数据,会冲掉还没发送完的内容,造成画面错乱。所以实际实现里需要为DMA准备两块缓冲区交替使用,或者等DMA完成后才释放缓冲区,这又回到前面讲的双缓冲问题。
以下是我在项目中最终采用的方案:分配两块缓冲区,一个用于LVGL渲染,另一个用于SPI DMA发送。当LVGL完成渲染并调用flush_cb后,交换两块缓冲区的角色,同时启动DMA发送新数据。这样在DMA发送期间,LVGL在另一块缓冲区渲染新的画面,效率最大化。
4.4 FreeRTOS下的LVGL任务架构
ESP-IDF自带的FreeRTOS让你可以很自然地把LVGL放进一个独立任务里运行。我的任务结构大致如下:
void lvgl_task(void *arg) { lv_init(); // ... 初始化显示屏驱动和输入设备 while (1) { lv_timer_handler(); // LVGL定时器处理:处理输入事件、更新动画、调度渲染 vTaskDelay(pdMS_TO_TICKS(5)); } }lv_timer_handler是LVGL的心脏,它负责处理所有事件、刷新过期动画、触发渲染,每次调用返回后需要小延时一下,避免把CPU占满。上面代码里的5ms是经验值,这样CPU使用率不会太高,动画也足够流畅。
由于LVGL不是线程安全的,所有涉及UI的操作都要在lvgl_task的上下文里执行。如果你在其他任务里直接调用LVGL的API修改控件,会出现随机崩溃或显示错乱。正确的做法是通过队列或事件组把消息投递给lvgl_task,由它在主循环里统一处理。这个架构问题一定要在一开始就设计好,不然后面加功能时到处都要改。
5. 界面开发与资源优化
5.1 第一个LVGL界面:屏幕、标签与按钮
驱动层打通之后,LVGL界面开发其实是一件很愉快的事情。先看一段最简单的界面代码:
void create_demo_ui(void) { lv_obj_t *scr = lv_scr_act(); // 获取当前活动屏幕 // 创建一个标签 lv_obj_t *label = lv_label_create(scr); lv_label_set_text(label, "Hello LVGL"); lv_obj_set_style_text_font(label, &lv_font_montserrat_28, 0); lv_obj_center(label); // 创建一个按钮 lv_obj_t *btn = lv_btn_create(scr); lv_obj_set_size(btn, 120, 50); lv_obj_align(btn, LV_ALIGN_BOTTOM_MID, 0, -20); lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL); }这段代码的运行效果是屏幕上出现一行居中的"Hello LVGL"文字,底部一个按钮,点击按钮后触发btn_event_cb回调函数。
LVGL的布局思路和前端框架很像:控件树(screen -> container -> label/btn),样式系统(style)和自动布局(flex、grid)。如果你以前搞过CSS,上手会非常快。
在实际开发中,我强烈建议用SquareLine Studio或者LVGL官方网页版UI生成器来拖拽搭建界面原型,再导出C代码集成到工程里。对于复杂UI,手写C代码逐像素对齐太耗费时间,可视化的设计工具能把开发效率提升好几倍。但最终代码审查和性能调优还是得能看懂导出的代码,所以手写基础控件的能力也不能丢。
5.2 中文字库与图片显示的处理
LVGL默认字体只包含ASCII字符,直接lv_label_set_text写中文只能看到一个个方块。要显示中文,需要把中文字库转成LVGL认识的格式。
我用的流程是:先在电脑上用字库生成软件(LVGL官方有在线字体转换器,也可以在本地跑lv_font_conv命令行工具)选择需要的字体文件和字符范围,然后生成一个C源文件,放进工程里编译。
中文字体文件会很大,一个常用字(约3500个汉字)的16号字体在16位色深下可能占用100KB到200KB的Flash,所以在选字时务必按需选择字符范围,只包含界面里真正用到的字。我习惯把界面上的静态文案汇总到一张表里,用这些字生成一个精简字库,可以把体积压缩到几KB级别。
图片显示也类似,LVGL原生不支持PNG/JPG解码(虽然8.x提供了可选的图片解码插件),最常见的方式是先用工具把图片转换成C数组,格式可以是RGB565或者带透明通道的ARGB8888。如果你只需要简单图标,推荐用lv_img_conv工具生成RGB565格式,体积小、显示快,不需要额外解码器。
图片转数组在Flash占用上最夸张。一张240x240的RGB565图片就要115KB,放两张图Flash就见底了。所以做产品界面时尽量多用线条、矢量图形和纯色,少用大图,这是嵌入式UI设计和网页设计一个很重要的区别。
5.3 内存与渲染性能调优
界面跑起来只是第一步,跑得流畅才是体验的关键。在实际使用中,我总结出几个影响LVGL性能的关键因素。
首先是缓冲区大小和刷新模式。前面推荐的双缓冲方案能显著提升流畅度,但它对RAM的要求也摆在那里。如果内存不够,退而求其次可以采用局部刷新加单缓冲,代价是滚动时会有闪烁。LVGL 8.x还支持一种叫"直接渲染(direct mode)"的模式,允许LVGL渲染到一块大缓冲区后整帧直接输出,适合有充足RAM的场景。
其次是渲染效果开关。LVGL 8.x里有几个很吃资源的效果:阴影(shadow)、模糊(blur)、渐变(gradient)。这三个特性会显著增加CPU渲染耗时,对嵌入式设备来说是性能杀手。我在性能敏感的界面上会全局禁用这些效果,或者只在高端屏上开启,在240x240的小屏幕上说实话这些花哨效果的意义不大,反而拖慢帧率。
再就是局部刷新的天然优势。LVGL本身只对被修改的区域重新渲染,不刷新整帧。这意味着界面如果只是更新一个数字,那么实际传输到屏幕的数据量很小,SPI带宽压力也就小了。但前提是屏幕驱动支持任意窗口渲染,ST7789是支持的,所以要确保你的驱动没有把窗口强制设为整个屏幕。
最后提一下CPU负载的观察方法。我的做法是在lvgl_task里周期性地用esp_timer_get_time()统计两帧渲染间隔,并将其与理论值做对比。如果间隔明显超过预期,说明渲染耗时过长,可以逐步关掉特效、调低刷新率来定位瓶颈。
6. 常见问题与排查实录
6.1 白屏、花屏、颜色不对
这三个问题是屏幕驱动入门时遇到最多的,下面的排查顺序是我反复验证过的,建议按顺序检查:
白屏最常见的原因是初始化时序问题。重点检查:Sleep Out后的120ms延时是否足够、复位引脚是否有足够宽的复位脉冲、SPI模式是否匹配(ST7789用Mode 0)、CS和DC引脚是否接反。如果初始化流程没问题,再用示波器看一眼SCL上是否有正常的时钟波形,没有波形的话基本就是SPI走的是错误的引脚或者SPI根本没有被初始化。
花屏大概率是SPI时钟太快或者接线太长导致信号质量差。先把时钟频率降到10MHz附近,如果问题消失,就是信号完整性问题。宽松点说,飞线超过10cm时40MHz时钟就可能出现数据错乱,尤其是没有共地或者地线很细的情况下。
颜色不对则优先检查颜色深度配置。ST7789需要设置为RGB565格式,如果你代码里设置的是RGB888或者RGB666,颜色会明显偏色或变成奇怪的条纹。另一类颜色问题出现在LCD和OLED屏上,有些LCD屏需要开启反色显示(Display Inversion On,即命令0x21),否则画面呈现"负片"效果,我之前调试一块新模组时就被这个坑了好几个小时。
6.2 LVGL画面闪烁、撕裂、刷新慢
LVGL界面闪烁一般有两种可能。
第一种是帧率太低,人眼在30fps以下能明显感觉到闪烁和卡顿。排查方法很简单:在lv_timer_handler里加一个计数器打印到串口,看看一秒实际调用多少次。如果每秒调用次数只有10次左右,说明渲染跟不上,需要减少界面复杂度或提高SPI时钟。
第二种是缓冲区刷新策略不当。如果你用的是单缓冲加局部刷新,屏幕不同区域刷新时间不一致会产生明显的"扫描线"效果。这时候最好的解决办法是升级到双缓冲模式。这里有个容易混淆的点:LVGL的lv_disp_buf_init函数里的两个缓冲区参数,如果传入两块独立的缓冲区,LVGL会自动选择双缓冲模式,很多教程只传一块或者传了同一块数组,等于还是单缓冲,刷新模式没切换过来。
刷新慢还有一种可能是因为你在flush_cb里同步等待DMA传输完成才返回,这等于把渲染和传输串行化了。优化做法是前面提过的双缓冲流水线,或者在发送完成后用中断回调的方式调用lv_disp_flush_ready,不要让CPU空等。
6.3 触摸屏没有反应或点击位置偏移
如果你用的是带触摸功能的屏幕模组,触摸不工作时先确认两件事:触摸芯片是什么型号,走的是什么接口。
市面上ST7789屏幕模组常见的触摸芯片有CST816S、FT6236、GT911等,大多数走I2C接口,少数走SPI。驱动的第一步是在I2C总线上扫描设备的I2C地址,确认芯片和主控通信正常。
触摸坐标偏移则一般是坐标系没有和屏幕方向对齐。LVGL里可以通过lv_indev_drv_t的read_cb函数手动校正,把读取到的触摸坐标做旋转或镜像变换后再传给LVGL:
bool touch_read_cb(lv_indev_drv_t *indev, lv_indev_data_t *data) { uint16_t x, y; touch_read(&x, &y); // 根据屏幕方向进行坐标变换 >