做嵌入式GUI开发这几年,LVGL基本上成了我默认的图形方案。无论是产品原型、仪表盘还是智能家居面板,LVGL配上STM32,再加上HAL库这套组合,成熟度确实高,网上资料也多,遇到问题随便一搜基本都有答案。但“能跑起来”和“跑得流畅”完全是两码事。这篇文章我打算把LVGL在STM32上基于HAL库的移植过程、性能优化思路、以及我实际踩过的坑一次性讲清楚,希望能帮你少走点弯路。
这篇文章适合正在用STM32做GUI项目、准备从裸机UI切到LVGL、或者已经在用LVGL但觉得界面卡顿、内存吃紧的开发者。内容会从方案选型开始,一直讲到底层接口实现和优化技巧,偏实操,不整虚的。
1. 移植前的关键决策与方案选型
1.1 为什么是LVGL而不是TouchGFX或emWin
做GUI方案选型的时候,很多人会纠结。我在几个项目里对比过TouchGFX、emWin和LVGL,最终多数场景选了LVGL,核心原因有三点:
第一是开源协议友好。LVGL使用MIT协议,商业项目几乎不受限制,不用像emWin那样仔细算授权费,也不存在TouchGFX那种“绑死某家芯片”的问题。产品要走量、要出海,这点很关键。
第二是内存占用可控。LVGL在资源受限的MCU上表现确实能打,官方说最低8KB RAM、64KB Flash就能跑,实际做一个小型仪表界面,STM32F103这种M3内核芯片也能勉强带起来。而TouchGFX想做得顺滑,通常需要更大内存和更高主频,硬件成本直接上去了。
第三是生态和控件丰富度。LVGL 8.x之后控件库越来越完善,图表、弧形、动画、主题系统都有,而且支持中文字库,中文显示方案很成熟,产品化落地时省事很多。再加上社区活跃,Github Issues里基本能找到你遇到的所有问题。
如果你用的是STM32F429、H743这类带LTDC和DMA2D的芯片,TouchGFX在性能上确实有一定优势,但LVGL配合DMA2D加速也能达到非常接近的效果,差距没有想象中大。最终怎么选,得看你的产品形态、团队技术栈和成本目标。
1.2 HAL库与LL库、标准库的选择逻辑
STM32开发库现在主流就是HAL、LL和标准库(老项目还在用)。标准库已经停止更新,新项目基本不建议碰,除非你是维护老产品。
HAL库最大的优点是抽象层统一、外设初始化代码生成方便,配合STM32CubeMX,几分钟就能把时钟、GPIO、SPI、DMA这些底层全部配好,生成代码之后你只需要关注业务逻辑。LVGL的底层接口说白了就是“把像素点刷到屏幕”和“读触摸/按键状态”,HAL库非常适合干这件事。
但HAL库也有被诟病的地方,比如代码量大、运行效率不如直接操作寄存器、某些API调用链路长。所以项目中我通常采用HAL库 + LL库混合的方式:对外设初始化、中断处理这种不频繁的操作用HAL,对SPI刷屏这种高频路径直接切到寄存器操作或者LL库,这样兼顾开发效率和运行速度。后面讲优化的时候会详细展开。
1.3 版本选择和硬件配置建议
LVGL目前主流是8.x和9.x两个大版本。如果你是新项目,我更推荐8.3.x或9.x。9.x在绘制架构和控件上有重构,比如内置了更多渲染后端,性能上限更高,但API变化比较大,网上教程很多还是基于8.3写的。我的建议是:如果你熟悉8.x,直接用8.3最稳;如果项目从零开始,也可以直接上9.x,踩完坑之后收益更明显。
硬件方面,我整理了一个配置参考表,方便你评估手头的板子够不够用:
| 芯片系列 | 典型型号 | 屏幕分辨率建议 | 内存配置建议 | 是否建议用LVGL |
|---|---|---|---|---|
| M3低端 | STM32F103RCT6 | 320x240以下 | 48KB RAM,建议外扩SRAM | 只适合极简界面 |
| M4中端 | STM32F407VET6 | 480x272以下 | 192KB RAM,可跑中等界面 | 很适合 |
| M7/带LTDC | STM32F429、H743 | 800x480 | 板载SDRAM,1MB以上 | 强烈推荐 |
| 跨界MCU | STM32H750、H7A3 | 1024x600 | 外部SDRAM + 内部大RAM | 可做高性能方案 |
注意:内存是LVGL项目的生命线。内部RAM不够的时候,优先考虑SPI接口PSRAM或者并口SDRAM,但外扩内存会带来延迟,优化时要格外注意缓存和DMA的配合。
2. 基于HAL库的LVGL底层接口移植实操
2.1 整体流程和工程结构规划
移植LVGL绝不是把源码扔进Keil里编译过就完事了。LVGL本身是纯C写的库,不依赖具体硬件,它只要求你提供几个底层接口,类似于驱动程序里的函数指针回调。真正要做的核心工作就三件事:屏幕刷新回调、系统Tick时钟、输入设备回调。
我习惯的工程结构是这样规划的,方便后续维护和升级:
Project/ ├── Core/ │ ├── Inc/ │ └── Src/ ├── LVGL/ │ ├── lvgl/ // LVGL源码 │ ├── lv_port/ │ │ ├── lv_port_disp.c // 屏幕刷新接口 │ │ ├── lv_port_indev.c // 输入设备接口 │ │ └── lv_conf.h // LVGL配置头文件 ├── Drivers/ │ └── BSP/ │ ├── st7789.c // LCD驱动 │ ├── ft6236.c // 触摸驱动 │ └── ... └── MDK-ARM/lv_port_* 这两个文件是LVGL官方推荐的自定义接口文件,你在代码里实现相应的函数体,核心逻辑就是拿到LVGL渲染好的图像缓冲,通过LCD驱动刷到屏幕上;以及把触摸屏或按键的数据回传给LVGL。
2.2 屏幕刷新回调 flus_cb 的HAL库实现
屏幕刷新是整个GUI性能的关键。LVGL绘制一帧画面后,会调用你注册的disp_flush函数,把这个区域内的像素数据发送给LCD驱动芯片。这个函数写得不好,界面帧率会直接崩掉。
以SPI接口屏幕 (ST7789/ILI9341) 为例,最基础的实现是这个样子:
void lv_port_disp_flush(lv_disp_drv_t *disp_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_set_address(area->x1, area->y1, area->x2, area->y2); lcd_write_data((uint8_t *)color_p, w * h * sizeof(lv_color_t)); lv_disp_flush_ready(disp_drv); // 告诉LVGL这一片刷完了 }这里最关键的两个点:
第一,lcd_set_address和lcd_write_data底层要用DMA传输,不要用CPU死等。HAL库里的SPI发送可以用HAL_SPI_Transmit_DMA()。一次DMA发送可以传几十KB数据,CPU完全释放出来,帧率能提升几倍。
第二,lv_disp_flush_ready()必须在DMA传输完成的中断回调里调用,这样LVGL能立刻开始下一次渲染,实现流水线并行。如果丢在发送函数后面同步调用,LVGL就得等DMA发完才继续工作,性能大打折扣。
// 在LCD驱动的DMA完成中断回调中调用 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi == &hspi_lcd) { lv_disp_flush_ready(&disp_drv); } }2.3 tick时钟与输入设备的对接
LVGL内部所有动画、长按、双击判断都需要一个时间基准,这就是tick函数。它不需要精确到微秒,但必须稳定定期递增。最简单的方式是直接用HAL库的SysTick,比如:
void HAL_SYSTICK_Callback(void) { lv_tick_inc(1); }HAL_SYSTICK_Callback默认是弱函数,你可以在自己的文件里重写它。如果用了FreeRTOS,也可以单独创建定时器任务来调用lv_tick_inc(1)。
输入设备方面,LVGL抽象了鼠标、触摸板、键盘、编码器等多种类型。触摸屏用LV_INDEV_TYPE_POINTER,实体按键或编码器用LV_INDEV_TYPE_KEY或LV_INDEV_TYPE_ENCODER。实现方式都是轮询或中断读取硬件状态,然后填充lv_indev_data_t结构体:
void lv_port_indev_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) { static int16_t last_x, last_y; static lv_indev_state_t last_state = LV_INDEV_STATE_REL; if (touch_get_xy(&last_x, &last_y)) { last_state = LV_INDEV_STATE_PR; } else { last_state = LV_INDEV_STATE_REL; } >void gui_task(void *param) { while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }第二,如果多个任务需要操作UI,比如“传感器数据来了,要更新某个Label”,不要直接在传感器任务里调lv_label_set_text()。正确的做法是:传感器任务把数据写入一个队列或全局变量并置标志位,GUI任务在lv_timer_handler()前后统一处理这些数据。如果需要跨任务访问,则加一个互斥量,比如osMutexAcquire。
第三,内存管理冲突。LVGL的内存管理是通过lv_mem_alloc/lv_mem_free从静态内存池分配的,默认不走malloc/free,比如配置LV_MEM_POOL_ALLOC或使用lv_mem_init初始化。这与FreeRTOS的pvPortMalloc互不相干。如果LV_MEM_CUSTOM = 1,LVGL会走标准malloc/free,这时候一定要确认你的编译器/库支持多线程安全,否则malloc被并发调用会出问题。
3. 性能优化实战:从卡顿到流畅
3.1 内存优化:帧缓冲策略和LV_MEM_SIZE设置
LVGL渲染画面的原理是:先在内存里的一块缓冲区(draw buffer)里绘制控件,画完后再整体送到屏幕。这个缓冲区大小直接决定了内存占用和显示效果。
LVGL支持两种缓冲区模式:
- 单缓冲:一块缓冲,LVGL画完一块,更新一块,屏幕可能因为更新过快出现撕裂,体验较差。
- 双缓冲:两块缓冲,一块在传给屏幕,另一块在绘制下一帧,画面更平稳。如果屏幕支持部分刷新,还能配合
LV_COLOR_DEPTH减少缓冲占用。
常见的做法是,使用与屏幕分辨率等大的全屏缓冲,但这对内存要求太高,比如 800x480x16bit = 768KB,STM32F4内部根本放不下。因此大多数项目采用“局部缓冲 + 行刷新”策略。
以 320x240 分辨率、16位色深为例:
#define HOR_RES 320 #define VER_RES 240 #define COLOR_DEPTH 16 static lv_color_t buf_1[HOR_RES * 30]; // 一行的30倍 static lv_color_t buf_2[HOR_RES * 30]; static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; void lv_port_disp_init(void) { lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, HOR_RES * 30); lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = lv_port_disp_flush; disp_drv.hor_res = HOR_RES; disp_drv.ver_res = VER_RES; lv_disp_drv_register(&disp_drv); }这里缓冲取行数的30倍,可以根据你的RAM余量调整。一般是16~32行,太少了会频繁刷屏,效率低;太多了占RAM。
LV_MEM_SIZE是LVGL动态分配控件的内存池大小,和帧缓冲是两回事。配置在 lv_conf.h 里:
#define LV_MEM_CUSTOM 0 #define LV_MEM_SIZE (64U * 1024U) // 64KB这个值太小,创建控件时会分配失败,表现为界面卡在某个页面或直接死机;太大又浪费RAM。我习惯的做法是,先把常用控件一次性创建,看看lv_mem_monitor()报告的使用率,再反推调整LV_MEM_SIZE。
3.2 CPU优化:DMA2D/GPU加速和局部刷新
如果用的是STM32F4系列,DMA2D是LVGL加速的最大帮手。DMA2D可以干这么几件事:图像填充、图像拷贝、像素格式转换、混合Alpha混合,而LVGL的绘制底层大量依赖这几类操作。
HAL库对DMA2D的封装主要在stm32f4xx_hal_dma2d.h里,但LVGL 8.x默认并没有直接绑定DMA2D,需要用官方移植文件或者自己集成DMA2D加速驱动。网上有很多博主分享了lv_draw_dma2d.c的集成代码,本质上是把LVGL的绘制回调替换成DMA2D实现,性能提升非常明显。
不过我对DMA2D建议是:如果你只是做个简单界面,显示几个文本、几个按钮,没必要强行上DMA2D,普通SPI刷屏就能跑得很顺。DMA2D大多在图像缩放、大块颜色填充、多图层混合时才见效果。
另一个优化点是局部刷新。很多驱动默认清屏是全屏重绘,但如果你的UI只有一小块区域变化,LVGL会自动只刷新变化区域(脏矩形机制),这时 flush 回调里的area参数就是那一个小矩形。你只需要把这个矩形区域的像素数据发送到LCD就行了,实际传输的数据量大幅下降。所以 flush 回调里不要盲目全屏发送,一定要用area参数精准裁剪。
如果发现界面频繁全屏刷新,多半是某些控件的样式触发了不透明背景变化,比如默认的背景色填充。排查方法是在动画场景里打印area的宽高,看是不是每次都全屏。要尽量使用半透明或者静态背景,尽量减少全屏重绘频率。
3.3 渲染优化:图像格式、字体缓存和动画策略
LVGL渲染文本时,字模是逐像素生成还是从缓存读取,对CPU占用率影响很大。建议开启字体缓存功能:
#define LV_FONT_CACHE_ENABLE 1这样常用字符会被缓存下来,之后的文本绘制直接从缓存拿字模,速度提升非常明显。
图片方面,LVGL默认用LV_COLOR_FORMAT_ARGB8888或RGB565,建议在嵌入式平台上使用RGB565格式,不要用ARGB8888。ARGB8888一个像素占4字节,刷屏数据量直接翻倍,内存占用也翻倍,虽然SPI传输可以按DMA批量走,但LFGL要处理和Alpha混合的开销也大。
对于大图,更推荐用LVGL的图片转换器把PNG转成C数组,甚至转成适合直接按地址映射的格式,比如LV_IMG_CF_TRUE_COLOR_ALPHA。不要直接放一堆外部图片依赖解码库,不仅慢,还占用大量heap。
动画策略上,有一点特别值得说:动画回调尽量避免在动画过程中动态创建或销毁控件,这会导致频繁内存分配和释放,产生内存碎片。尽量在动画开始前一次性创建好所有节点,动画只是控制位置、旋转、透明度等属性。
3.4 使用缓存和预解码优化外部存储图片
如果产品需要显示大量图标或图片,放Flash里会撑爆容量,放SD卡/外部Flash里就会面临读取耗时问题。这里我用过两个方案:
方案一:图片在启动时预解码到RAM,然后直接显示。适合图片总数不多、每张图尺寸不大的场景。启动时先把核心图标加载到内存,之后所有界面切换都是内存拷贝,速度飞快。
方案二:使用LVGL自带的图片缓存机制,配合文件系统接口(lv_fs),让LVGL按需读取外部图片并缓存在内存中。设置一个缓存大小,比如 200KB,超过阈值后自动淘汰旧图片,这样既不撑爆RAM,也能保证切换界面时不会卡太狠。
SD卡读取图片的瓶颈主要在于文件系统访问和SDIO/SPI传输。建议使用SDIO + DMA模式,配合FATFS的f_read,一次读一整块,不要按像素逐点读。实测下来,用SPI SD卡读一张200x200的RGB565图片(80KB)大约耗时200ms,用SDIO DMA模式可以压到20ms左右,完全不是一个量级。
3.5 帧率测量与调优工具
不要靠肉眼判断卡不卡,帧率才是硬指标。LVGL自带了简单的帧率测试,也可以自己在lv_timer_handler()里统计每帧耗时:
uint32_t frame_ms = lv_tick_get() - last_tick; last_tick = lv_tick_get(); float fps = 1000.0f / frame_ms;如果你的项目支持串口输出,把FPS打到串口助手里,根据数值来调优。一般来说:
| 帧率区间 | 体验评价 | 优化建议 |
|---|---|---|
| 60 FPS以上 | 非常流畅 | 无需优化 |
| 30~60 FPS | 可用于简单交互 | 可优化图片格式/缓冲策略 |
| 15~30 FPS | 能看出卡顿 | 检查刷屏路径和DMA |
| 15 FPS以下 | 明显不可用 | 优先减小分辨率/色深,检查内存不足 |
另外,LVGL还有内存监视器和耗时分析工具,比如lv_mem_monitor()和lv_debug_*系列API,开发阶段把这些调试信息挂到一个调试页面,通过长按某个隐藏按键打开,非常实用。
4. 常见问题与排查技巧实录
4.1 黑屏、白屏、花屏问题的排查思路
这一步让我卡过很久。出现屏幕不显示或者乱滚屏,不外乎下面几个原因:
第一,SPI工作模式不对。很多LCD屏是Mode 3(CPOL=1, CPHA=1),少数是Mode 0,配置错了整个屏幕花掉。用逻辑分析仪或者示波器抓一下,看看SCLK空闲电平是高还是低,数据在哪个沿采样,对照屏体手册确认。
第二,RGB565 和 BGR565 顺序搞混了。同一张图,R和B通道互换,显示出来颜色怪怪的,但图形轮廓清楚。解决方法是改LCD驱动里的颜色码序,或者LVGL里配置LV_COLOR_16_SWAP。
第三,并行RGB接口的话,时钟极性(PCLK Polarity)、行同步/场同步极性、DE模式等配置错误会导致无显示或显示偏移。摄像头拍屏可能看不出,必须用示波器测PCLK/H/V时序。
第四,DMA传输和LCD驱动的write_data函数没有配合好,导致数据只发了一半。排查时可以先把DMA换成普通的HAL_SPI_Transmit()同步发送,看能不能正常显示,如果能,说明问题出在DMA配置或中断回调上。
4.2 LVGL 内存不足导致的崩溃
LVGL内存不足通常表现为:程序运行一会突然HardFault、创建控件时卡死、刷屏出现残影。排查方法很直接:
lv_mem_monitor(&monitor); printf("total=%d, free=%d, used=%d, frag=%d%%\r\n", monitor.total_size, monitor.free_size, monitor.used_size, monitor.frag_pct);如果free_size持续下降,说明有内存泄漏。常见的泄漏原因是:动态创建的控件没有正确删除,或者动画占用的内存没有释放。LVGL里删除控件要把lv_obj_del()和lv_anim_del()配合好,尤其注意定时器和动画回调里直接删除控件导致的野指针。
如果frag_pct很高(比如超过40%),说明内存碎片严重。解决办法是:减少频繁的动态创建/删除控件,改为创建后用lv_obj_move_foreground/background或者lv_obj_set_parent复用控件;要么把LV_MEM_SIZE再调大一圈,要么换成更小的内存块粒度(LV_MEM_BUF_MAX_NUM之类的配置项去调)。
4.3 触摸漂移和方向不对的解决方案
触摸漂移是所有触摸屏项目的通病。最常见的原因,是屏幕显示方向和触摸屏坐标方向不一致。LVGL里面触摸坐标可以直接做变换:
// 假设屏幕做了90度旋转 int16_t tmp =>x = (raw_x - x_min) * SCREEN_W / (x_max - x_min);电容触摸屏一般出厂校准过,偏移不大,但如果用了电阻屏,四角校准算法就得上了,不能省。
4.4 HAL库与LL库混用时的注意点
初期因为图省事,我直接在HAL库工程里写寄存器操作或者用LL库函数,结果踩了个大坑:清了某个外设的中断标志位,结果HAL库那边的状态机乱了。
原因是HAL库的驱动内部维护了一套状态和标志,比如SPI的hspi->State和hspi->ErrorCode。如果你绕过HAL库直接操作寄存器,HAL库不知道发生了什么,下次调用HAL函数时可能基于过期状态做出错误判断。
后来我总结出一个原则:初始化、时钟、中断用HAL库,高性能路径(比如刷屏数据传输)要么用HAL库DMA + 回调,要么直接操作寄存器,但操作寄存器之后必须手动同步状态。如果只是临时用LL库某个函数,要确认它不会破坏HAL库的内部状态。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 白屏无显示 | SPI初始化或复位时序问题 | 用示波器检查SCLK/CS/DC/RESET电平,确认SPI模式和LCD初始化序列 |
| 花屏 | 色彩格式不对,或SPI采样沿错误 | 检查RGB565/BGR565、检查SPI Mode 0/3 |
| 界面卡顿严重 | 没有使用DMA刷屏,或全局刷新 | 开启DMA刷屏,确认LVGL脏矩形是否只发局部区域 |
| 触摸无反应 | 输入设备类型注册错误,或I2C触摸芯片地址不对 | 确认indev类型,I2C设备能否正常读到坐标 |
| 触摸点击错乱 | 坐标方向与显示方向不一致 | 在indev回调里做坐标映射变换 |
| 动画闪烁 | 单缓冲且刷新频率不高 | 改双缓冲,或者降低闪烁控件的不透明度变化次数 |
| 内存越跑越小 | 动态控件未释放 | 检查lv_obj_del是否调用,动画定时器是否清理 |
| HardFault一进界面就崩 | 数组越界或指针野指针 | 打开LVGL调试宏,定位到具体控件和回调,检查创建的控件是否有空指针 |
4.6 调试技巧:善用LVGL日志和Debug宏
LVGL内置了日志输出和一个不太起眼但很有用的宏开关:LV_USE_LOG。搞清楚它的用法,排查问题会比看代码快得多。
#define LV_USE_LOG 1 #define LV_LOG_LEVEL LV_LOG_LEVEL_WARN // 或者 INFO/DEBUGLVGL日志能输出内存分配、控件树结构、图像加载等重要信息。比如怀疑图片加载失败,日志里会明确告诉你文件路径打不开或者格式不支持。要在lv_conf.h里配置对应的输出函数,然后把串口打开。
有个不太常见但非常实用的功能:LVGL的lv_log_register_print_cb(),可以重定向日志输出到文件系统或自定义接口。做复杂产品时,我把日志写到SD卡里的txt文件,方便远程分析,这个是真的好用。
调试的时候我还会用LVGL的运行时检查,比如创建控件后立刻断言:
LV_ASSERT(obj != NULL);一旦内存不足导致创建失败,断言会直接提示你,而不是后面访问空指针才崩。这一步能省下大量排查时间。
5. 内存模型与缓冲机制的深入理解
5.1 LVGL的内存分配模型
LVGL默认使用自带的内存分配器,它维护一个静态数组作为堆,分配和释放都在这块内存上进行。你可以把LV_MEM_CUSTOM设为1,让LVGL改用C标准库的malloc/free,但在嵌入式MCU上,FreeRTOS或裸机环境里malloc可能没有线程安全保证,所以我更推荐用自带分配器,更可控。
LVGL自带分配器有一个碎片整理策略,核心就是按块管理,支持合并相邻空闲块。但即便这样,长时间频繁分配释放小对象仍然可能产生碎片。碎片率太高最终会导致一个大控件分配失败,表现为程序卡死、但小控件还能创建。
为了减少碎片,我一般遵循几个原则:
- 界面切换时,避免一个页面创建100个控件,另一个页面全部销毁,而是用
lv_obj_clean()清空父节点内容,尽量维持一个对象池。 - 回调函数里临时申请内存的,用完立刻释放,不要等到某个事件才统一回收。
- 使用LVGL自带的
lv_mem_test()检查内存池完整性,在监控任务里定时执行,一旦发现内存池被破坏,可以及时复位或输出错误信息。
5.2 双缓冲、局部刷新和DMA协同
双缓冲 + DMA + 局部刷新,可以理解成一条流水线:
- LVGL绘制引擎在一号缓冲里画图;
- 画完后调用 flush 回调,通知LCD驱动通过DMA把数据发送到屏幕;
- DMA传输期间,CPU是空闲的,LVGL立刻切到二号缓冲继续画下一帧;
- DMA传输完成后中断,LVGL等下一次flush时切换到合适的缓冲。
这个流水线的核心在于,LVGL需要知道“上一个flush已完成”,才敢复用那块缓冲。所以lv_disp_flush_ready()的调用时机极其关键,早调或晚调会导致渲染错乱或等待。
实际操作中,有人为了简化,直接同步调用SPI发送再调lv_disp_flush_ready(),那样等于把流水线变回单任务:画一帧、等一帧。帧率会明显下降。我的经验是:无论如何都要用异步DMA + 中断回调,哪怕最开始DMA不太稳定,也值得花时间调通。
5.3 lv_conf.h 里的关键配置项
很多人移植完毕,lv_conf.h 随便填一下能用就行。但我发现,很多卡顿、显示异常问题都出在下述配置项上:
#define LV_COLOR_DEPTH 16 #define LV_COLOR_16_SWAP 0LV_COLOR_DEPTH必须和你的屏幕色深一致。LV_COLOR_16_SWAP则针对的是大小端字节序问题,很多SPI屏幕芯片内部是按8bit按字节收发的,这时如果你发送的16bit像素是高位在前,那么需要把LV_COLOR_16_SWAP打开,否则颜色会严重失真。
#define LV_DISP_DEF_REFR_PERIOD 33 // 刷新周期,单位ms,约30fps #define LV_INDEV_DEF_READ_PERIOD 33 // 输入扫描周期这两个值是LVGL内置的默认扫描周期,一般33ms够用,但如果追求高帧率,可以改成16甚至10。缺点是CPU占用率会上升,需要实测平衡。
#define LV_DPI_DEF 96这个值影响所有控件的大小和字体缩放。如果你的产品屏幕物理DPI不高或者动作做出来控件太小,调这个值很有效。
内存监控接口LV_USE_MEM_MONITOR和性能分析开关LV_USE_PERF_MONITOR也建议在开发阶段打开,产品发布前再关掉。
6. 进阶优化:从能用迈向好用
6.1 使用画布和图层减少重绘区域
LVGL支持画布(lv_canvas)和多图层。画布的用途是你能在普通控件的基础上,额外绘制一些自定义内容,比如实时波形、动态图形。它本身也是一块内存缓冲,绘制结束后整块作为图像刷新,能有效减少脏矩形区域的次数。
多图层则比较高级,LVGL的图层相当于多个独立绘制表面,其中顶层(sys layer)用于类似光标、调试信息这类全局元素,即使主界面刷新也不会受影响。合理分配图层,可以减少切换界面时的重复重绘。
6.2 结合FreeRTOS任务优先级优化刷新率
如果你用了FreeRTOS,任务优先级设计对GUI流畅度影响很大。GUI任务优先级不能太高,否则会抢占SPI/DMA传输完成回调,反而降低吞吐;也不能太低,否则界面响应慢。
我建议GUI任务优先级设为中等,SPI/DMA中断优先级设为最高。核心逻辑是:GUI任务主要做LVGL的定时器处理和事件响应,而真正耗时的数据传输在中断里完成,GUI任务只需要在等待DMA完成时切换去处理其他任务,形成“渲染+传输”并行。
如果串口、网络、音频也要跑,注意这些任务的优先级不要和DMA回调冲突,否则传输会被迟延,表现就是屏幕偶尔闪一下。
6.3 字库压缩和中文显示优化方案
LVGL中文字体是典型的内存杀手。一个GB2312字库的完整字模轻松占用上百KB Flash,如果全量放入内存,MCU的Flash直接爆掉。我的做法是分等级处理:
- 界面固定文本,比如按钮标题、菜单项,用
lv_font_custom_cjk手动生成所需的少量汉字字模,几十个字才几KB。 - 需要动态显示用户输入的中文,再考虑加载全字库到外部Flash,配合
lv_fs按需读取。 - 使用LvglFontTool这类工具,把字库裁剪成项目需要的一部分常用汉字,只要保证覆盖产品所有业务词条就行。
字体渲染后生成字模回填缓冲区,如果显示中文出现毛边,多半是字体抗锯齿没开或者字体太小。建议打开LV_FONT_ANTIALIAS,这个小开关能让中文字体显示质量提升一个档次。
6.4 用硬件解码器或外部协处理器分担渲染
有些STM32系列内部带了JPEG硬件解码器,比如STM32F7、H7系列。如果你的产品需要显示大量JPEG图片,LVGL本身解JPEG会占用大量CPU时间,可以先把JPEG解码成RGB565存入SDRAM,再交给LVGL显示,效果会好很多。
如果还想更进一步,可以接外部GUI协处理器芯片,比如一些低成本LCD控制板会带GPU,LVGL只需要通过SPI发送绘制指令,但这样架构复杂度高,适合产品稳定量产、需要严格低功耗的场景,开发阶段一般不碰。
7. 一次完整移植周期的经验复盘
这篇文章写到这里,我把移植LVGL到STM32的全过程、踩过的坑、优化的手段基本都过了一遍。最后复盘一下,如果让我重新做一次LVGL项目,我会怎么安排节奏。
第一周做方案选型和硬件验证:确认MCU型号、屏幕接口、RAM/Flash余量,决定用什么色深、什么缓冲模式。这一步不要着急写代码,先把硬件点亮,确保SPI/I2C/RGB接口都正常。
第二周做最小系统移植:只显示一个Label和一个Button,跑通“LVGL渲染 -> flush刷屏 -> 触摸响应”这条完整链路。不要一上来就堆界面,先保证最基本的循环没问题。
第三周做界面模块开发:把产品页面按模块拆开,每个页面独立创建、独立测试,同时把字体、图片资源按需加载搞定。这周最容易出内存问题,随时用lv_mem_monitor()检查。
第四周做优化:从帧率测试开始,逐项优化DMA、局部刷新、缓存、字体,再疯狂做压力测试,比如快速切换页面、长时间挂机、反复点击触摸屏,直到稳定为止。
有很多人的项目死在第一周,因为直接跳过硬件验证,拿例程一改就上LVGL,结果各种花屏、触摸漂移分不清是驱动问题还是LVGL问题。先点亮LCD,再引LVGL,这一条永远不要省。
如果你照着这个过程走一遍,基本能把LVGL底层机制吃透。后续想加动画、图表、复杂控件,原理都是通的,不会再有那种“代码能跑但不知道为什么”的悬空感。