我们直接进入正题。做嵌入式GUI的兄弟应该都有体会:LVGL本身是个非常优秀的图形库,动画、控件、主题都做得相当完善,但一遇到“图片资源多”这个场景,痛点立马就来了。MCU内部的Flash空间是硬约束,动不动就是几百KB的单张全屏图,几张图塞进去之后固件体积彻底失控。把图片资源搬到外部Flash,是绕不开的一条路。但“搬过去”只是第一步,“搬过去之后还能流畅跑起来”才是真正考验功力的事情。这篇文章我会从方案选型、数据组织、解码器实现到性能调优,把整个链路拆开讲透,希望能给正在被Flash容量和加载速度折磨的朋友一些参考。
1. 为什么图片必须外置:内部Flash的存储困局
1.1 LVGL默认图片模型的性能账单
LVGL里加载图片最传统的方式,是直接把图片转成C语言数组,编译进固件。标准做法是:
const lv_img_dsc_t img_bg = { .header.cf = LV_COLOR_FORMAT_RGB565, .header.w = 480, .header.h = 272, .data_size = 480 * 272 * 2, .data = img_bg_data, };很多人可能没算过这笔账。以一块最常见的480x272分辨率屏幕为例,RGB565格式下,一张全屏图占用的空间是:
480 × 272 × 2 Bytes = 261,120 Bytes ≈ 255 KB
这还只是单张背景图。如果你再配几张全屏页面、一组图标、几个按钮的背景,轻轻松松超过1MB。对很多MCU来说,内部Flash总共才512KB或者1MB,固件代码、字体、协议栈、RTOS这些还要挤占空间,活生生把“外置Flash”从可选项变成了必选项。
很多MCU的方案是:从外部Flash读取图片,而图片数据已经以大端字节序的裸RGB565存储。这种方案的精髓在于——数据不经过文件系统,直接按地址读。省去了文件系统的开销,也省去了解析PNG/JPEG的CPU和RAM消耗。但这种方案的代价是,图片必须预先在PC端转换、拼接、烧录到指定的Flash地址,后期想换图得重新烧Flash,灵活性差一些。
1.2 外置方案的分水岭:数据量决定架构
我的经验是,确定外置方案之前,先想清楚图片资源的规模和处理方式。这就引出了两个完全不同的技术路线:
路线A:裸数据直读。图片在PC端提前转换成RGB565/ARGB8888裸数据,按固定偏移烧录到外部Flash的特定地址。程序运行时,直接把外部Flash地址告诉LVGL,LVGL通过lv_image_set_src()配合自定义解码器读取。这个方案的优点是读取速度快,解码成本为零,CPU占用低,适合图片格式统一、数量较多、对切换速度要求高的场景。缺点是灵活性差,改用别的分辨率或格式时,需要重新转换和烧录。
路线B:文件系统+压缩格式。在外部Flash上挂一个LittleFS或者FatFS,图片以PNG/JPEG文件形式存储,LVGL通过文件系统接口读取并解码。这个方案的好处是灵活,图片可以随时增删,也不需要预先管理Flash地址,调试阶段特别省心。但代价是解码过程需要额外的时间和RAM,尤其大尺寸JPEG图对MCU来说压力不小。
我个人的项目经验是:如果产品界面相对固定,图片资源在开发阶段就已经确定,优先走路线A;如果产品有OTA升级、用户自定义图片之类的需求,则必须考虑路线B。这篇文章的重点放在路线A上,因为它是“高效”二字的真正体现——也是最容易被折腾出性能瓶颈的一条路。
2. 图片如何组织在外部Flash里:从裸数据到文件系统
2.1 裸数据直读 vs 文件系统挂载
先直观对比一下这两条路线的差异:
| 对比维度 | 外部Flash裸数据直读 | 外部Flash + 文件系统(LittleFS/FatFS) |
|---|---|---|
| 读取路径 | MCU直接按地址访问Flash | LVGL → 文件系统 → Flash驱动 |
| 额外开销 | 无 | 文件系统层和解析层开销 |
| 解码需求 | 通常无(直接RGB565/ARGB8888) | PNG/JPEG解码,CPU和RAM开销大 |
| 灵活性 | 差,换图需要重新规划Flash布局 | 好,支持动态增删图片 |
| 适用场景 | 固定UI资源、启动画面、背景图 | 动态资源加载、OTA、用户图片 |
| 性能表现 | 高,可直接配合DMA/Cache | 较低,受文件系统拆分和解析算法影响 |
很多人会纠结“到底要不要上文件系统”。我的判断标准很简单:当你需要管理几十上百个图片文件时,文件系统带来的维护便利性远超它的性能损耗;当你只需要显示三五张固定背景图时,裸数据直读是性能最优解。项目初期不确定图片数量会膨胀到多少,可以先上文件系统,吃透之后再针对热点图片走裸数据Cache加速,两者结合也是一种很实用的打法。
2.2 图片格式选型:不是所有格式都适合MCU解压
这个坑我踩得比较深。最初图方便,把所有素材都转成了JPEG,想着压缩率高、Flash省空间。结果到了实际运行时,问题就来了:LVGL自带的JPEG解码器(基于tjpgd)虽然占用资源不算夸张,但在主频只有几百MHz的MCU上解码一张480x272的JPEG图,耗时往往要几百毫秒甚至更多,而且解码过程需要申请较大的临时缓冲区,RAM也吃紧。
后来我做了个对比测试,在相同场景下:
| 图片格式 | Flash占用 | 解码/显示耗时(估) | RAM占用 | 适用场景 |
|---|---|---|---|---|
| RGB565裸数据 | 最高(直接像素量) | 极低,近似于memcpy | 低(可按需读取) | 全屏背景、固定UI资源 |
| ARGB8888裸数据 | 更高(多出Alpha通道) | 极低,但内存带宽翻倍 | 低 | 需要半透明效果的UI元素 |
| PNG | 中等(无损压缩) | 中高,解码开销大 | 高(整图解码缓冲) | 小图标、需要Alpha通道的图片 |
| JPEG | 低(有损压缩) | 高,解码耗时严重 | 高 | 照片、真彩大图(但对MCU不友好) |
所以我的建议是:全屏大图尽量用RGB565裸数据,需要Alpha混合的图标用小尺寸PNG,不到万不得已不要用JPEG做全屏背景。特别是在低成本MCU上,一次JPEG解码带来的现场卡顿会直接影响产品体验,而RGB565直接从Flash搬数据到显存,体验完全是两码事。
2.3 离线转换与烧录流程
无论选哪条路,图片最终都要变成MCU能识别的东西。如果是裸数据方案,在PC端就要把PNG/JPEG统一转成RGB565裸数据,并且按规划的Flash布局拼接成一个bin文件。
我常用的工具链是:
- 用Python(Pillow库)做批量转换和拼接,顺便生成一个C语言头文件,记录每张图的Flash偏移地址、宽度、高度等元信息。
- 用STM32CubeProgrammer或者OpenOCD直接把bin文件烧录到外部Flash。
转换脚本核心逻辑类似:
from PIL import Image import struct def img_to_rgb565_bin(img_path): img = Image.open(img_path).convert('RGB') pixels = list(img.getdata()) bin_data = bytearray() for r, g, b in pixels: # RGB565 小端模式 rgb565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) bin_data += struct.pack('<H', rgb565) return bytes(bin_data)这一步的逻辑很直白,但有几个容易被忽略的细节:
- 像素对齐:MCU端处理数据时,最好保证每张图片的起始地址按4字节或32字节对齐,方便DMA搬运,避免因为非对齐访问导致读Flash变慢甚至HardFault。
- 大小端:PC端生成的是小端字节序,但有些MCU是大小端可配的,LVGL编译时也要统一确认颜色字节序,否则会出现颜色错乱。
- Flash擦除块大小:规划bin文件布局时,要考虑到外部Flash的扇区擦除粒度(常见是4KB),尽量让每张图片完整落在整数个扇区内,方便后期单独更新某个资源,不用整片重擦。
3. 让LVGL认识外部Flash:自定义图片解码器的完整实现
3.1 接管LVGL的图片数据流:解码器机制
LVGL从8.0版本开始,图片解码器(decoder)机制就非常清晰了。它的核心思想是:LVGL只管向解码器要“描述图片信息”和“读取指定区域像素数据”,至于数据从哪来、怎么来,完全由解码器自己决定。这给了我们非常大的自由——我们可以让解码器直接从外部Flash地址读数据,甚至只在需要显示的瞬间才去读。
先看LVGL最核心的图片描述结构体(以LVGL 8.x为例):
typedef struct { lv_img_header_t header; // 图片分辨率、色彩格式等基础信息 uint32_t data_size; // 图片数据总字节数 const uint8_t * data; // 图片数据指针(默认是RAM或内部Flash指针) uint8_t reserved; // 保留字段 } lv_img_dsc_t;如果走默认路径,LVGL会直接把这个data指针指向的数据当作图片内容。但这里有一个致命问题:LVGL读取数据时是线性的、连续的,它不知道这个指针指向的是MMIO映射地址,也不懂SPI Flash的读命令时序。所以我们必须自定义解码器,让LVGL在打开图片时拿到正确的Flash地址,在读取像素时再触发实际的Flash读取。
3.2 关键回调函数的实现细节
LVGL解码器的实现核心是三个回调:open_cb、read_line_cb和close_cb。先说open_cb,它负责解析图片头部信息,并决定解码器是否处理这张图片。
static lv_res_t decoder_open_cb(lv_img_decoder_t * decoder, lv_img_decoder_dsc_t * dsc) { if (dsc->src_type != LV_IMG_SRC_VARIABLE) { return LV_RES_INV; // 不处理非变量源 } // 这里的src_data实际上是指向我们自定义的图片描述 ext_img_info_t * img_info = (ext_img_info_t *)dsc->src; if (img_info->magic != EXT_IMG_MAGIC) { return LV_RES_INV; // 校验魔术字,避免误处理 } dsc->header.w = img_info->width; dsc->header.h = img_info->height; dsc->header.cf = img_info->color_format; dsc->img_data = NULL; // 不直接提供数据指针,稍后按需读取 return LV_RES_OK; }这里有几个关键点需要强调:
第一,dsc->img_data到底给不给?如果给NULL,LVGL会在需要像素数据时调用read_line_cb;如果直接给上整块数据的RAM指针,LVGL会跳过后续回调,但这对Flash直读场景没有意义,因为不是所有数据都能一次性放进RAM。对外部Flash大图,正确做法是返回NULL,并配合read_line_cb按行读取。
read_line_cb是性能的关键,也是最容易写砸的地方。他的职责是:把图片中指定的行(dsc->line)的像素数据填入buf缓冲区。
static lv_res_t decoder_read_line_cb(lv_img_decoder_t * decoder, lv_img_decoder_dsc_t * dsc, lv_coord_t x, lv_coord_t y, lv_coord_t len, uint8_t * buf) { ext_img_info_t * img_info = (ext_img_info_t *)dsc->src; uint32_t offset = img_info->flash_addr + (y * img_info->width + x) * bytes_per_pixel; uint32_t bytes_to_read = len * bytes_per_pixel; // 从外部Flash偏移地址读取数据 ext_flash_read(offset, buf, bytes_to_read); return LV_RES_OK; }这个实现已经能工作,但性能不理想——每次读一行数据都要发起一次SPI Flash读操作,如果是SPI模式,一次读一行和一次读整帧的耗时差距非常大。所以我实际项目里会做一层“行缓存”:当第一次读取第N行时,直接把从N行开始的一整块连续数据(比如4行或8行)都读进RAM缓存,后续几行直接从缓存里切片给LVGL,这样能把Flash的读放大效应降到最低。
close_cb则比较简单,主要是释放open_cb阶段分配的任何资源。如果你用了行缓存,这里记得把缓存内存归还给LVGL内存池。
3.3 快速走通最小闭环
为了让你对整体结构有个完整认识,我建议把下面这套流程在工程里快速走一遍,再逐步优化:
第一步:在外部Flash规划一个资源区,比如从地址0x90000000(假设MCU支持memory-mapped访问)或通过SPI接口访问的某个偏移开始,按表格存储每张图的偏移、宽度、高度、格式。
第二步:定义自己的图片描述结构体,别直接用lv_img_dsc_t,方便扩展:
typedef struct { uint32_t magic; // 魔术字,用于decoder识别 uint32_t flash_addr; // 外部Flash偏移地址 uint16_t width; uint16_t height; lv_color_format_t color_format; } ext_img_info_t;第三步:注册解码器到LVGL:
lv_img_decoder_t * decoder = lv_img_decoder_create(); lv_img_decoder_set_info_cb(decoder, decoder_open_cb); lv_img_decoder_set_read_line_cb(decoder, decoder_read_line_cb); lv_img_decoder_set_close_cb(decoder, decoder_close_cb);第四步:UI里这样使用图片:
ext_img_info_t img_bg = { .magic = EXT_IMG_MAGIC, .flash_addr = 0x100000, // 根据实际烧录地址修改 .width = 480, .height = 272, .color_format = LV_COLOR_FORMAT_RGB565, }; lv_obj_t * img = lv_img_create(screen); lv_img_set_src(img, &img_bg); // 注意这里直接传结构体指针这一套走通之后,你的LVGL就已经能把外部Flash里的图片资源显示出来了。不要急着一上来就优化,先确认整条链路是通的,再去抠性能和稳定性,排查起来会容易很多。
4. 性能优化:不是“能显示”就完事
4.1 底层Flash读取提速:SPI时钟、QSPI与内存映射
走进度条的时候,最直观的变化来自底层Flash读取性能。很多人会觉得“反正是外部Flash,慢就慢点”,但实际上一张480x272的RGB565图片,裸数据有261KB,如果你的SPI时钟只有20MHz,理论带宽才2.5MB/s,光是把这张图片的数据从Flash搬到内存就要100ms以上,这还没算上其他协议开销。翻页时不卡才怪。
提速第一板斧:SPI时钟频率拉高。如果你的MCU与Flash之间走的是普通SPI,确认数据手册里Flash支持的最高时钟(常见的W25Q系列能跑到104MHz,但受限于PCB走线、Flash型号和MCU的SPI外设上限),尽可能把SPI时钟配置到80MHz以上。读操作和写操作不同,读指令从发出到数据返回有延迟,充分利用硬件FIFO和DMA可以显著减少CPU等待。
第二板斧:从SPI切换到QSPI。Quad SPI用4根数据线并行读,带宽直接翻4倍(比如80MHz时钟下,理论带宽从10MB/s提升到40MB/s)。绝大多数主流MCU和Flash都支持QSPI,代价仅仅是多占几根IO。如果你的MCU的QSPI控制器支持memory-mapped模式,那更理想——把外部Flash映射到CPU地址空间,软件读Flash就和读内部RAM一样简单,LVGL的decoder甚至不需要知道底层是Flash,CPU流水线和Cache会自动做预取和缓存,性能会有质的飞跃。
实测数据来自我自己项目中一个STM32H750的平台:同样的图片数据,用SPI模式读虽然能亮屏,但翻页时帧率只有个位数;切到QSPI并开启memory-mapped后,翻页几乎感觉不到延迟,帧率直接拉满到显示屏刷新率上限。这一层的优化,收益远超后面任何代码层面的优化。
4.2 解码优化与缓存命中:让LVGL少读几次Flash
如果你的图片格式不是裸RGB565,而是PNG之类的压缩格式,那么解码优化的空间更大。
第一,避免重复解码。LVGL本身有LV_IMG_CACHE_DEF_SIZE这个配置项,默认可能只有1张。如果你的界面有多张图片切换,或者同一张图被多个控件使用,建议把缓存数量调大,让热图尽量留在内存里,不要反复触发Flash读取和解码。缓存策略类似于CPU的L2 Cache,命中率越高,整体性能越好。
第二,按需解码而非整图解码。如果图片很大(比如做一个相册浏览功能,滑动查看大图),不要一上来就把整张图从Flash读出来解码到RAM。LVGL 8.x以上的解码器支持read_line_cb按行读取,你可以在回调里利用Flash的memory-mapped或QSPI连续读特性,只解码当前可见区域的那几行。这样不管图片本身多大,内存开销都只跟显示高度相关,而且Flash读取量也大幅下降。
第三,因地制宜地做颜色格式转换。如果Flash里存的图片是RGB565,而屏幕是RGB888(少见但存在),那解码器返回给LVGL的数据格式就必须和显示控制器匹配,否则会出颜色问题。不要把转换逻辑放在热点路径上反复算,可以使用硬件DMA2D等2D加速单元来做像素格式转换,或者干脆在PC端就把图片转成目标格式,避免运行时转换。
4.3 巧用DMA和双缓冲:不要让CPU干等Flash
这里想多说一点关于“怎么让整个渲染管线不卡顿”的思路。
LVGL的渲染流程本质上是:计算哪些区域需要刷新 → 调用flush_cb把像素数据发送到显示控制器 → 等待显示控制器完成 → 继续下一帧。如果你在read_line_cb里同步等Flash数据回来,整个渲染线程就会被阻塞。这就像你做饭时每炒一道菜都要等食材从菜市场送过来,效率肯定差。
最好的做法是引入DMA + 双缓冲机制:
- 在
read_line_cb里发起DMA传输,把外部Flash的数据搬到内存缓冲区; - 在DMA传输完成中断(或
lv_timer_handler轮询检查DMA完成标志)中通知LVGL数据已就绪; - LVGL继续后续的渲染处理。
代码伪代码如下:
static volatile bool dma_busy = false; static uint8_t dma_buffer[LINE_BUFFER_SIZE * 2]; static void dma_complete_cb(void) { dma_busy = false; } static lv_res_t decoder_read_line_cb(...) { while (dma_busy) { // 等待上一次DMA完成,这里可以配合调度器让出CPU lv_timer_handler(); } dma_busy = true; // 发起DMA传输,从Flash到dma_buffer dma_transfer(img_info->flash_addr + offset, dma_buffer, bytes_to_read, dma_complete_cb); // 这里直接返回,LVGL会在下次轮询时发现数据准备好了 return LV_RES_OK; }实际项目中不建议在read_line_cb里死等,更合理的做法是提前预取下一行/下一块数据,让Flash读取和LVGL渲染并行进行。比如LVGL需要读取第0~15行时,在读取第0行之前,就通过DMA把第0~15行全部读进一个大缓冲区,这样LVGL逐行渲染的16行周期内,CPU不会因为等待Flash而停滞。
4.4 进阶:利用LVGL 9.x的Draw Buffer机制
LVGL 9.x相比8.x,一个很大的变化是引入了新的Draw Buffer机制。8.x时代你通常只有1~2个全屏缓冲,LVGL绘制和显示控制器的刷新互相牵制;9.x允许你把一个大的Draw Buffer拆分成多个小的部分缓冲区,绘制完成一个小缓冲区就立刻送到显示控制器,这样降低了对RAM的需求,也提高了流水线并行度。
如果你用的芯片有DMA2D这类2D加速器(比如STM32系列带DMA2D,或者全志T113这类芯片带G2D模块),在LVGL 9.x里可以配置LV_DRAW_SW_DMA_CACHE或者LV_USE_DMA2D,让图片数据从Flash到显示缓冲区的搬运由DMA2D完成,而不是CPU一条条像素地复制。这样能让CPU专注于布局、事件响应、动画计算,搬运工作交给专用硬件,整体帧率能提升不少。
不过这里要提示一个坑:DMA2D的源地址和目的地址都要求32位对齐,且缓冲区的行宽最好是32位对齐的整数倍。外部Flash的映射起始地址通常在厂商设计时已经对齐,但你在内存里分配的缓冲区就不一定了,分配缓冲区时记得用lv_mem_alloc配合LV_ATTRIBUTE_MEM_ALIGN(或者直接改用malloc后手动做对齐),不然DMA2D会直接罢工,表现是画面撕裂、花屏甚至HardFault。
5. 实测数据与调优经验
5.1 三种方案的真实性能对比
为了让你有个直观感受,我把自己做的一个项目实测数据分享出来。项目硬件:
- 主控:Cortex-M7 @ 480MHz
- 外部Flash:W25Q128JV(QSPI模式,时钟100MHz)
- 屏幕:480x272,RGB565,通过RGB接口并口输出
- 系统:FreeRTOS,LVGL 8.3
- 图片:全屏背景图,RGB565裸数据261KB
| 方案 | 是否开启缓存 | 翻页耗时(ms) | 每帧CPU占用 | 备注 |
|---|---|---|---|---|
| 裸数据直读(SPI方式) | 否 | 430 | 高 | 卡得没法用,肉眼可见刷新过程 |
| 裸数据直读(QSPI memory-mapped) | 否 | 180 | 高 | 能看,但翻页时有撕裂感 |
| 裸数据直读(QSPI memory-mapped) | 是(4行行缓存 + LVGL cache) | 45 | 低 | 流畅,基本无感知 |
| 裸数据直读(QSPI memory-mapped + DMA2D) | 是 | 30 | 极低 | 接近LCD控制器刷新极限 |
| 文件系统 + PNG解码 | 是 | 420 | 很高 | 每次翻页都有一两帧卡顿 |
这个数据很有代表性。可以看到,最大的性能提升来自QSPI memory-mapped这个底层改动,其次是缓存策略和DMA2D的引入。如果你现在的方案还在用普通SPI直读,优先做底层优化,效果立竿见影。
5.2 几个容易咬到舌头的小细节
以下这些坑都是我实际踩过,并且排查了很久才找到根因的,列出来帮你避雷。
细节一:外部Flash读取速度受“写操作”影响。如果你的程序在翻页的同时还在做Flash擦写(比如记录日志、保存用户配置),你会发现图片加载速度陡然变慢。原因很简单,SPI Flash不允许边写边读,写操作(尤其是擦除)会阻塞整个Flash总线。解决办法是:把Flash写操作放到低优先级任务里,并且尽量避开UI刷新窗口;如果必须同时进行,可以考虑用双Flash方案,一块专门存UI资源,一块存用户数据。
细节二:LVGL Cache不是万能的,大图慎用。LV_IMG_CACHE_DEF_SIZE默认可能配置的是1~2张,如果你把它调成大图数量,每张图几百KB,RAM很容易爆。我用过一种折中方案:缓存只对常用的小图标和控件背景生效,全屏大图不走Cache,而是依赖行缓存+按需读取。这样既保证了热路径的性能,又不会因为缓存占用过多RAM导致系统内存不足。
细节三:小心颜色格式与字节序错配。检查一下lv_conf.h里LV_COLOR_DEPTH的配置和图片实际格式是否一致。还有,如果你的MCU和Flash之间用的是memory-mapped方式,数据的字节序一定判断清楚——外部Flash默认按大端方式读出,但很多MCU是ARM核心(小端),LVGL内部对颜色数据的解释也默认小端。这个错配问题通常表现为颜色偏色或者红蓝交换,排查起来非常迷惑。最直接的办法是用单色填充的测试图验证,确认格式无误后再上真实素材。
细节四:Flash地址对齐对DMA2D是硬要求。上面提到过DMA2D要求地址对齐,这里再强调一下:如果你的外部Flash映射地址不是按32字节或64字节对齐的,DMA2D搬运时可能会截断或者错位。规划Flash地址时,要么手动填充对齐字节,要么在烧录时用工具强制对齐。
细节五:把解码器回调做成可重入的。如果LVGL跑在RTOS环境下,lv_timer_handler可能被多个任务调用,或者LVGL的刷新任务和UI任务并发,那么你的解码器回调必须是可重入的,不能有全局变量存状态,或者至少要用互斥锁保护。我在一个项目里因为行缓存用了全局数组,结果两个任务同时刷新UI时,图像偶尔出现条纹错位,排查了很久才发现是并发访问导致的。
6. 再往深走:从“显示图片”到“管理图片资源”
如果你已经做到了上面这一步,恭喜,外部Flash图片加载的性能问题已经基本解决了。但实际项目里,“能显示”只是起点,“好维护”“好扩展”才是让产品活下来的关键。
我强烈建议你在上位机和MCU之间设计一套简单的图片资源管理协议,而不是直接把图片地址硬编码在代码里。比如在外部Flash的开头放一个资源表(manifest):
typedef struct { uint32_t magic; // 固定为 'REST' uint16_t version; uint16_t img_count; uint32_t table_offset; } resource_header_t; typedef struct { char name[16]; // 资源名字,比如 "bg_main" uint32_t flash_offset; uint16_t width; uint16_t height; uint8_t color_format; uint8_t reserved; } resource_entry_t;这样MCU端就可以通过资源名来查找图片,而不是靠魔法数字般的Flash地址。上位机负责生成这张表,MCU端解析后加载。调试阶段改图只需要烧录资源bin,不用重新编译固件,开发效率提升非常大。
另一个经验是:图片资源做分级处理。启动时必须显示的企业Logo、开机动画,可以放在Flash前部区域,BootLoader阶段就能加载;主界面的背景图放在中间;低频使用的设置页配图可以往后放,甚至用压缩率更高的格式存,按需解压。这种分级策略能进一步压缩“冷启动到界面可用”的时间。
最后分享点个人实操感受
做LVGL外部Flash图片加载这套方案,我最大的体会是:性能瓶颈往往不是LVGL本身,而是数据从外设到达LVGL的路径。把底层Flash读取方式改对,比在LVGL层做一百个小优化都管用。
另外,调试阶段建议在PC模拟器上先跑通LVGL逻辑,把解码器抽象成接口,模拟器里用PC文件系统,板上用Flash驱动,这样能分开排查问题。我吃过一次亏:直接在板上调解码器,花了两天时间才发现问题出在Flash驱动时序上,而不是LVGL层,白白耗掉大量时间。分开调试之后,这种低效排查基本不会再发生。
如果你也正在做类似的项目,建议按这个顺序动手:先确认Flash物理层读写没问题,再裸读验证数据正确,然后接LVGL解码器,最后才上QSPI、DMA2D和缓存这些优化手段。每一步都验证完再进下一步,踩坑的概率会小很多。