
最近在做一个带屏的物联网控制面板核心板是 ESP32 系列屏幕是 240x320 的 SPI 电容触摸屏。UI 部分我选了 LVGL开发环境用 ESP-IDF。整个集成过程踩了不少坑从组件依赖、menuconfig 配置、显示驱动注册到触摸接入每一步都有值得记录的东西。这篇就是我的 ESP-IDF 集成 LVGL 配置记录适合正在做类似带屏嵌入式项目、想少走弯路的开发者参考。我会把选型理由、版本匹配、关键配置、驱动接入、编译运行期遇到的问题和性能调优都讲清楚。1. 先把需求说清楚为什么 LVGL 和 ESP-IDF 是常见组合1.1 我的场景与硬件基础我做的是一个家用环境数据采集面板需要实时显示温度、湿度、空气质量带几个触控按钮用来切换页面。硬件上用的是带 PSRAM 的 ESP32 模组屏幕是 ST7789 驱动芯片的 SPI 屏触摸芯片是 FT6236走 I2C 接口。这个组合在市面上非常常见几乎可以说是带屏物联网设备的标准答案。选择这种方案的原因很直接ESP32 生态成熟、资料多、IDF 框架本身对显示和输入外设支持完善SPI 屏成本低、货源足240x320 分辨率做简单 UI 完全够用FT6236 电容触摸在 3.5 寸以下的小屏上体验足够好。整套物料成本能压得很低而且不需要额外买 RGB 接口的开发板用现成的模组加屏幕就能跑起来。1.2 为什么在几个 GUI 方案里选了 LVGL带屏嵌入式项目的 GUI 方案无非就那几个方向裸写帧缓冲、LVGL、TouchGFX、柿饼 UI。裸写帧缓冲在页面少、交互简单时可行但一旦要做列表、滑动、动画、多页面切换开发和维护成本会迅速膨胀。TouchGFX 效果确实炫但对硬件要求更高而且 IDE 工作流和许可证模式不是我喜欢的风格。柿饼 UI 的生态相对封闭社区资料不如 LVGL 丰富。LVGL 的优势在于开源、控件齐全、支持中文字体、内存占用可裁剪、用 C 语言编写、与 ESP-IDF 的集成方式非常灵活。它不像 TouchGFX 那样要求你有特定的工具链直接当成一个普通 C 组件编进去就行。社区里有很多现成示例遇到问题搜索起来很方便。对我来说这些都是实打实的效率因素。提示如果只是做一个固定页面、没有复杂交互的显示界面不一定非要上 LVGL。但如果计划做多页面、动态列表、动画过渡LVGL 的投入产出比非常高。2. 环境准备IDF 版本、组件管理器和依赖声明2.1 软件版本匹配集成遇到的大多数问题根源都在版本不匹配。ESP-IDF 的组件管理器IDF Component Manager在 IDF 4.4 之后才逐步完善LVGL 本身的 API 在 v8 到 v9 之间也有不少调整。我这次用的是 ESP-IDF v5.2 LVGL v8.3.x 的组合这个搭配在任何地方查资料都最方便因为 v8.3 是 LVGL 使用最广的稳定版本v5.x 的 IDF 对 SPI 外设和 DMA 的支持也比老版本好得多。如果你还在用 IDF 4.x也不是不行但要注意老的lvgl_esp32_drivers仓库里的部分代码和应用层 API 在 LVGL v8 之后有兼容问题尤其是显示驱动注册部分。我的建议是能升级就升级到 IDF v5.x能选 v8.3 就选 v8.3不要贪新用 v9 的 RC 版本做生产项目。2.2 用 idf_component.yml 声明依赖新版 IDF 项目默认使用组件管理器解析第三方组件。在main目录下创建或修改idf_component.yml声明需要的组件dependencies: lvgl/lvgl: ^8.3.0这会在首次编译时自动从组件仓库拉取 LVGL 源码。要注意IDF 组件管理器读取的是项目根目录的dependencies配置和main/idf_component.yml的合并结果如果项目里有多个组件目录各自的依赖声明要保持一致。我没有用社区里常见的lvgl_esp32_drivers组件而是选择只拉 LVGL 本体、自己写显示和触摸驱动。原因在后面的章节会展开讲。简单说就是自定义屏幕接线和自定义触摸坐标映射时自己写驱动反而比套用通用组件更可控。2.3 首次拉取依赖时容易出的问题第一次编译时组件管理器会从远程仓库下载 LVGL 源码。如果网络不稳定下载会失败或者卡住表现为编译刚开始就报Failed to resolve component之类的错误。解决办法是重试、给终端配置好代理注意我说的是正常的网络代理配置或者手动把组件下载到managed_components目录。另一个我遇到过的坑是 Python 环境问题组件管理器需要有可用的 Python 环境和 pip 依赖IDF v5.x 在安装时带了独立的 Python 虚拟环境但如果你自己又装过系统 Python 并且环境变量冲突了就会莫名其妙地报错。这种情况我建议直接用 IDF 提供的idf.py启动脚本进入开发环境。3. menuconfig 配置项逐项说明3.1 内存相关颜色深度、颜色字节序和 LVGL 内存池LVGL 的配置是通过 Kconfig 系统合并到工程配置里的运行idf.py menuconfig后在Component config - LVGL configuration下面可以找到全部选项。这一节只讲直接影响能否跑起来、会不会花屏、会不会内存崩溃的关键项其他选项等需要时再细调。首先是颜色深度LV_COLOR_DEPTH。320x240 的 SPI 屏绝大多数是 RGB565 格式也就是每个像素两个字节分别存放 R、G、B 分量。所以配置应该是 16 bit。这一点看起来简单但如果你从网上复制了一段配置为 32 bit 的旧示例屏幕不是白屏就是颜色完全错乱。其次是颜色字节序LV_COLOR_16_SWAP。RGB565 的一个像素由两个字节组成SPI 发送时如果先发高字节还是先发低字节直接决定屏幕显示是否正确。这个选项和具体 LCD 控制器、接线方式有关。我的经验是先在菜单里把这项打开试一次如果花屏或者颜色错位再关掉试通常总有一个是对的。这一步没有绝对的正确配置因为不同的屏幕驱动对字节序要求可能不同。然后是LV_MEM_SIZE这是 LVGL 内部自己管理的内存池大小。LVGL 创建控件、图片对象时会从这个池子里分配内存。默认 8KB 连一个带十几个控件的页面都跑不起来我用的是 64KB。如果把 PSRAM 映射到 LVGL 的分配器里可以更大。这个参数宁大勿小一旦内存池耗尽控件的创建会静默失败现象就是 UI 莫名其妙少了一部分。3.2 显示刷新周期和字体选择LV_DISP_DEF_REFR_PERIOD控制 LVGL 多久刷新一次脏矩形区域单位是毫秒。默认值是 30ms对应约 33 FPS 的理论刷新上限。实际受限于 SPI 总线带宽很难跑满这个值。我直接保持默认因为在后面实测中发现瓶颈根本不在 LVGL 的刷新周期而在于 SPI 传输和缓冲切换。字体配置也很关键。LVGL 默认字体只包含 ASCII 字符要做中文界面必须额外加载中文字体。我用了 LVGL 的字体转换工具把需要用的十几、二十个汉字离线转换成 C 数组放在工程里编译进去。这样既不用在运行期解析字库文件又不会因为加载完整中文字库导致 Flash 占用爆炸。真正做产品时建议只包含界面上实际出现过的字符用字库裁剪工具把不需要的字剔除。这一手能省下大量 Flash 空间而且切换界面的字体加载速度也快得多。3.3 调试选项和生产环境的取舍开发阶段建议把LV_USE_LOG打开日志级别调到 warn 或者 info这样能看到 LVGL 内部报出的内存分配失败、驱动注册错误等信息。LV_USE_ASSERT也可以开着它能在参数类型错误、指针为空时直接断言帮你快速定位问题。但正式发布时这两项建议关掉因为断言和日志都会拖慢运行速度、增加固件体积。另外如果工程里同时用了其他图形库或者你自己的 malloc/free 实现需要注意LV_USE_LVGL_MALLOC这条路径。LVGL 默认使用标准库的malloc/free如果你为显示缓冲申请了 DMA 专用的内存走的是heap_caps_malloc那这条配置和你无关。只有在你想让 LVGL 的lv_malloc走 RTOS 堆管理时才需要动它。3.4 推荐的 sdkconfig.defaults 片段我把习惯配置写成sdkconfig.defaults这样每次清理工程重新编译时不需要再手动去 menuconfig 里点一遍CONFIG_LV_COLOR_DEPTH_16y CONFIG_LV_COLOR_16_SWAPy CONFIG_LV_MEM_SIZE65536 CONFIG_LV_DISP_DEF_REFR_PERIOD30 CONFIG_LV_TICK_CUSTOMy CONFIG_LV_FONT_MONTSERRAT_14y CONFIG_LV_USE_LOGy CONFIG_LV_USE_ASSERTy注意LV_TICK_CUSTOM开启后LVGL 不再依赖外部lv_tick_inc调用而是用系统 tick 接口自取。但实际在 ESP-IDF 上我更习惯在代码里显式调用lv_tick_inc(2)配合定时器回调所以这个选项我在最终版本里是关掉的。两种方式都可以选一种就行不要同时用两套 tick 来源。4. 显示驱动接入从 SPI 初始化到 flush 回调4.1 SPI 总线和 LCD 控制器初始化LVGL 本身不负责硬件初始化它只认显示驱动和flush 回调。所以第一步是把屏幕用 SPI 点亮能在裸机层面显示纯色再谈 LVGL 的事。我用的 ST7789 是 4 线 SPI 控制接 8 根左右SCLK、MOSI、CS、DC、RESET、BL、VCC、GND。初始化顺序是先复位再发初始化命令序列最后打开背光。SPI 总线的配置如下#include driver/spi_master.h #include esp_lcd_panel_io.h #include esp_lcd_panel_vendor.h #define LCD_HOST SPI2_HOST #define LCD_PIN_SCLK GPIO_NUM_10 #define LCD_PIN_MOSI GPIO_NUM_11 #define LCD_PIN_CS GPIO_NUM_9 #define LCD_PIN_DC GPIO_NUM_8 #define LCD_PIN_RST GPIO_NUM_7 #define LCD_PIN_BL GPIO_NUM_6 #define LCD_H_RES 240 #define LCD_V_RES 320 spi_device_handle_t spi_dev; static void lcd_spi_init(void) { spi_bus_config_t buscfg { .sclk_io_num LCD_PIN_SCLK, .mosi_io_num LCD_PIN_MOSI, .miso_io_num -1, .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz LCD_H_RES * LCD_V_RES * 2, }; ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, buscfg, SPI_DMA_CH_AUTO)); spi_device_interface_config_t devcfg { .clock_speed_hz 40 * 1000 * 1000, .mode 0, .spics_io_num LCD_PIN_CS, .queue_size 8, .flags SPI_DEVICE_HALF_DUPLEX, }; ESP_ERROR_CHECK(spi_bus_add_device(LCD_HOST, devcfg, spi_dev)); }这里有两个细节值得注意。第一max_transfer_sz设成整屏两字节大小是为了后续一次性 DMA 传输整块缓冲做准备。第二clock_speed 我直接上 40MHz实际传输中 ST7789 可以支持但如果你板子布线比较差、干扰大降到 26MHz 会更稳。初始化完 SPI发 ST7789 的命令序列。这部分代码很长不同厂商屏幕的命令可能略有差异网上抄的时候要擦亮眼睛。常见的问题就是初始化序列里漏了0x21(INVON) 或0x13(NORON)导致屏幕反色或者不亮。最简单的方式是直接用 IDF 自带的esp_lcd_panel_st7789驱动通过esp_lcd_new_panel_st7789接口初始化比自己手写命令序列稳妥得多。4.2 注册 LVGL 显示驱动LVGL 显示驱动的核心是一个lv_disp_drv_t结构体里面需要设置分辨率、缓冲、flush 回调。初始化后注册到 LVGL 系统里。以下是我工程里的写法#include lvgl.h #define DRAW_BUF_HEIGHT 40 static lv_color_t buf1[LCD_H_RES * DRAW_BUF_HEIGHT]; static lv_color_t buf2[LCD_H_RES * DRAW_BUF_HEIGHT]; static void lvgl_disp_init(void) { lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(draw_buf, buf1, buf2, LCD_H_RES * DRAW_BUF_HEIGHT); static lv_disp_drv_t disp_drv; lv_disp_drv_init(disp_drv); disp_drv.hor_res LCD_H_RES; disp_drv.ver_res LCD_V_RES; disp_drv.flush_cb lcd_flush_cb; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); }至于 flush 回调LVGL 会把需要刷新的矩形区域和对应的颜色缓冲传进来你要做的就是设置窗口地址然后一次性把整个矩形区域的数据发给屏幕static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t w lv_area_get_width(area); uint32_t h lv_area_get_height(area); // 设置窗口地址 // 根据 LCD 控制器的格式发送 CASET/RASET/RAMWR 等命令 // 然后 spi 发送 color_p 指向的 w*h*2 字节数据 // 关键发送完成队列为空后再告诉 LVGL 可以复用缓冲 lv_disp_flush_ready(drv); }这里最大的坑是缓冲生命周期问题。LVGL 默认在 flush 回调里调用lv_disp_flush_ready之后就认为这块缓冲的数据已经被消费掉了可以继续往里写。但如果你用异步 DMA 发送数据还在 DMA 队列里没发完LVGL 就开始往同一块缓冲写下一帧画面就会撕裂甚至花屏。解决方法是等 SPI 传输真正完成后再调用lv_disp_flush_ready具体可以用spi_device_get_trans_result等待或者干脆用轮询发送接口。4.3 为什么用两个部分缓冲而不用全屏缓冲内存这件事在 ESP-IDF 集成 LVGL 里永远绕不开。320x240 的 RGB565 全屏缓冲是 153600 字节约 150KB。在 ESP32 的 320KB SRAM 里扣掉系统、协议栈、显示驱动本身占用的部分全屏缓冲会让内存捉襟见肘。所以我用两个 40 行高的缓冲每个 240x40x2 19200 字节两个加起来约 37.5KB内存压力就小多了。两个缓冲配合 LVGL 的机制是当一个缓冲在 SPI 上传输时LVGL 在另一个缓冲里绘制下一块区域相当于绘制和传输流水线化。实际刷新率比单缓冲有明显提升。如果你内存实在紧张单缓冲也能跑但刷新时会看到绘制和发送交替进行视觉效果会差一些。有 PSRAM 的情况下可以用更大的缓冲甚至两个接近半屏大小的缓冲刷新流畅度会有质的提升因为 LVGL 可以更快地完成脏矩形合并。5. 触摸与输入设备I2C 上报和坐标映射5.1 I2C 初始化与触摸芯片轮询FT6236 是一个很普及的电容触摸控制器通过 I2C 接口读取坐标数据。I2C 初始化很简单关键是它的 INT 引脚要接对。我在接线的时候把 INT 接到了 GPIO 上但最开始没启用中断输入模式导致后续轮询坐标时经常读到无效数据。正确做法是把 INT 配置为输入且启用上拉这样芯片有触摸时能主动拉低电平你可以在中断回调里置一个标志位也可以在主循环轮询时直接读 I2C。I2C 初始化示例#include driver/i2c.h #define I2C_HOST I2C_NUM_0 #define TOUCH_INT_PIN GPIO_NUM_21 #define TOUCH_I2C_ADDR 0x38 static void touch_i2c_init(void) { i2c_config_t conf { .mode I2C_MODE_MASTER, .sda_io_num GPIO_NUM_4, .scl_io_num GPIO_NUM_5, .clk_speed 400000, }; ESP_ERROR_CHECK(i2c_param_config(I2C_HOST, conf)); ESP_ERROR_CHECK(i2c_driver_install(I2C_HOST, I2C_MODE_MASTER, 0, 0, 0)); gpio_config_t int_conf { .pin_bit_mask (1ULL TOUCH_INT_PIN), .mode GPIO_MODE_INPUT, .pull_up_en GPIO_PULLUP_ENABLE, }; gpio_config(int_conf); }FT6236 的 I2C 地址有可能是 0x38 也可能是 0x3A取决于芯片引脚电平。我第一次只试了 0x38 没反应最后用 I2C 地址扫描才找到实际地址。如果你也遇到触摸完全没反应的情况先在枚举层面确认地址对不对再谈上层配置。5.2 LVGL indev 注册与坐标旋转LVGL 的输入设备通过lv_indev_drv_t注册类型是LV_INDEV_TYPE_POINTER。核心是提供一个read_cb回调回调里把坐标值填到lv_indev_data_t里然后返回false表示不是按下的状态事件。坐标必须经过旋转和缩放否则会跟屏幕显示方向不一致。static lv_indev_drv_t indev_drv; static void touch_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint16_t x, y; uint8_t touched touch_read_coordinates(x, y); if (touched) { >