资讯动态

LVGL外部Flash图片存储方案:从选型到性能优化全解析

发布时间:2026/10/3 15:28:34 来源:尧图企业网站定制
做嵌入式GUI开发特别是LVGL玩到一定阶段图片资源往哪放基本是绕不开的问题。内部Flash就那么几MBUI素材稍微上点量就爆了尤其是现在动不动1024x600、RGB565的大背景图一张图超过1MB内部Flash根本塞不下。所以LVGL配合外部Flash存放图片资源几乎是量产产品的标配方案我自己在STM32F429 W25Q64 LVGL 8.3这套组合上把整套UI切图全部搬到了外部Flash从启动加载到页面切换都做过一轮调优这篇就把整个方案的选型思路、核心实现和踩过的坑完整整理出来。这篇文章适合正在做LVGL外部存储图片方案的人看无论是刚把LVGL跑起来、想知道怎么把图片移到外部Flash还是已经能显示但切图卡顿、花屏、偶发死机不知道怎么排查里面都会有你用得上的内容。我会从最根上的数据链路讲起再到实际代码怎么组织最后是性能和稳定性优化尽量把“为什么这么做”也讲清楚而不是只丢给你一段能跑的代码。1. 项目整体设计与思路拆解1.1 为什么图片必须放外部Flash容量与读取速度的权衡先算一笔最直观的账。一块320x240的RGB565图片单个像素16bit也就是2字节体积是3202402 150KB。一块1024x600的RGB565图片是1.17MB光这一张图就能吃掉F407这种512KB内部Flash的四分之一还多。一个稍微像样的产品UI开机logo、背景图、按钮图标、状态栏图标、弹窗背景林林总总加一起轻松超过5MB。把图片放内部Flash在成本上完全不现实。但放外部Flash不是没有代价的。内部Flash读取路径短CPU可以直接按地址访问速度几乎不损耗。外部SPI Flash走的是SPI协议要发命令、等等待周期、读数据吞吐量再快也有物理瓶颈更别说还有文件系统的解析开销、动态内存分配这些额外成本。所以外部Flash方案的核心问题从来不是“能不能读出来”而是“怎么读才能不拖累LVGL的绘制性能”。我做这个项目时定的原则是能用外部Flash绝对不用内部FlashUI素材再大也不心疼能用bin裸数据绝不用PNG/JPEG解码开销在MCU上是致命的能走内存映射绝不走文件系统读取少一层封装就少一分延迟必须有缓存层不能让LVGL渲染时直接跟Flash打交道。1.2 一张图片从文件到屏幕的完整链路要优化先得知道一张图片在LVGL里是怎么走完生命周期的。这里我先讲典型流程后面代码部分再展开。当你在代码里调用lv_img_set_src把一张外部图片赋值给lv_img控件后LVGL内部会做这样几件事根据src字符串判断图片来源是内置符号、C数组、还是文件路径如果是文件路径调用lv_fs_open打开文件然后按需读取图片头信息宽度、高度、颜色格式这里就涉及外部存储的读取LVGL核心的lv_timer_handler周期任务会检查显示缓冲区把所有待绘制控件包括lv_img按区域合并、排序生成脏矩形渲染列表渲染时把图片像素数据填入显示缓冲区display buffer这个阶段如果是PNG就要解码如果是bin裸数据就是纯内存拷贝显示缓冲区填满后调用flush_cb回调由显示驱动把这块内存通过SPI或者RGB并口刷到LCD面板上flush完成后必须调用lv_disp_flush_ready告知LVGL缓冲区已释放才能继续下一帧渲染。这个链路里每一步都可能成为卡顿点。文件解析慢、解码慢、缓冲区太小导致频繁flush、DMA没有开启导致CPU空转……任何一个环节出问题表现出来就是切图卡、滚动掉帧。所以优化外部Flash图片加载本质上是把这条链路上每一步的“浪费”都挤掉。1.3 主流实现路线怎么选文件系统、裸数据回调、内存映射我梳理了一下现阶段大家做LVGL外部Flash图片资源基本跑不出这三条路线路线实现方式优点缺点适用场景文件系统 binFlash烧录FatFS/LittleFSLVGL通过lv_fs_drv_t访问管理灵活、路径清晰、支持后续OTA替换文件有FAT表解析开销读取链路长资源经常更新、需要通过SD卡或网络升级UI裸数据回调不用文件系统按固定偏移读取Flash把数据填进lv_img_dsc_t读取路径短RAM占用少稳定性高资源地址硬编码增删素材要维护偏移表出厂烧录后资源不变、追求极致稳定内存映射(XIP)MCU QSPI/OSPI外设把外部Flash映射到地址空间LVGL直接按指针访问对LVGL不可见读取速度最接近内部Flash需要MCU硬件支持且对Flash型号有要求STM32F7/H7、NXP RT系列等带QSPI的芯片我的建议是如果你的MCU带QSPI外设且支持内存映射优先考虑第三条路线这种方案代码最简单、性能最好后面我会详细说。如果MCU没有这个外设那就走“裸数据回调”或“文件系统bin”根据自己的升级需求来定。我这次项目用的是裸数据回调为主、文件系统作为补充的混合方案大背景图和常驻图标走固定偏移裸读仅在有OTA上传新素材时才挂载文件系统覆盖读取。2. 资源准备与格式转换2.1 图片格式选型别再用PNG做背景图了我在不少群和论坛里看到有人问“LVGL怎么显示PNG”然后真有人把整张PNG背景图丢给LVGL去解码显示结果在MCU上跑起来就是灾难。这里必须把格式选型摆到台面上说清楚。PNG的优点是体积小、支持透明通道适合图标和装饰性小图。但代价是解码需要足够大的RAM临时缓冲以及可观的CPU算力。一张1024x600的PNG背景解码过程中动辄需要分配数MB的临时内存很多MCU的RAM总共才256KB直接撑爆。LVGL官方提供了PNG解码器lv_png和JPEG解码器lv_jpeg但它们的定位是“偶尔用一下的小图”不是让你拿来做整屏背景的。对于MCU场景我的选型经验是大背景图、全屏图必须转成RGB565裸数据bin与屏幕色深完全一致LVGL拿到就能直接拷贝进缓冲小图标含透明通道优先转成LVGL支持的C数组或bin格式用ARGB8888如果Flash空间紧张可以压成索引色但绘制时CPU开销会上去数量多的话反而得不偿失PNG/JPEG仅在“图片无法预先转好、必须运行时从文件系统读取”的场景使用而且要限制分辨率300x300以内还能接受再大就不建议了。2.2 用脚本批量生成LVGL可用的bin文件图片转格式我强烈建议用脚本批量处理别一张一张用GUI工具点。实测下来ImageMagick这个命令行工具最顺手跨平台几行命令就能处理整个目录的素材。以大背景图为例我有这么个处理管线# 把源图缩放到目标尺寸强制RGB565输出 magick input.jpg -resize 1024x600! -type TrueColor -depth 8 rgb565:output.bin # 小图标保持透明通道 magick icon.png -alpha copy -define quantum:formatfloating-point rgba:icon.bin这里有个很关键的坑RGB565的字节序问题。RGB565一个像素占两个字节LVGL在STM32这种小端MCU上默认是低字节在前实际要看LV_COLOR_16BIT_SWAP宏而ImageMagick输出的是高位在前。我刚开始直接批量生成后烧进去屏幕上一片“雪花”排查半天才发现是字节序反了。最简单粗暴的办法先导出一张纯色图片在屏幕上显示验证字节序方向如果颜色不对就交换高低字节或者让转换脚本对每个16bit做一次swap。LVGL官方在线图片转换工具imgconv输出的C数组会默认处理这个字节序所以用它是比较省心的只是批量处理效率低。2.3 固定偏移表还是文件系统Flash资源管理方案对比素材转完之后怎么烧进Flash、怎么让固件找到它这是两个方案分叉的地方。固定偏移表方式是我这次主力在用的。我在Flash的开头放一个资源索引结构每一行记录图片ID、起始地址、数据长度、宽度、高度、颜色格式。程序里调一个接口传入图片ID就能拿到对应的偏移和大小然后从Flash读取。这个方案的优点是读取路径最短不需要解析FAT表也没有目录层级RAM占用几乎为零不需要挂载文件系统不会因为文件系统碎片或掉电损坏导致资源丢失LVGL侧只需要一个极轻量的“read_at(offset, len, buf)”函数。文件系统方式FatFS/LittleFS适合产品需要在线更新UI资源的场景。比如你想在设备端接收一个升级包解压后覆盖旧的图片文件用文件系统天然支持。但代价是系统复杂度上去了文件系统要占Flash空间存FAT表、要占RAM做文件句柄和缓冲读取一张图片要先open再seek再read链路长出错概率也高。FatFS在SPI Flash上还有磨损均衡问题如果频繁写操作需要叠加上限均衡层复杂度进一步上升。所以我的结论是产品不需要在线升级素材就老老实实用固定偏移表需要再考虑文件系统不要为了“将来可能需要”提前买单。后面代码部分我会重点讲怎么用固定偏移表方式接入LVGL文件系统方式会简单带过但思路是通的。3. 核心代码实现把外部Flash接入LVGL3.1 轻量级Flash读取封装不管走哪条路线第一步都是把Flash的读取能力封装好。以我用的W25Q64为例底层是标准SPI协议。我这里的做法是直接按“读取地址 读取长度 目标缓冲”的方式设计接口不暴露Flash页、扇区这些概念因为图片读取是纯只读场景不需要擦写均衡// flash_img.h #ifndef FLASH_IMG_H #define FLASH_IMG_H #include stdint.h #include stddef.h typedef struct { uint32_t addr; uint32_t size; uint16_t width; uint16_t height; uint8_t cf; // LVGL color format } img_resource_t; int img_resource_lookup(uint16_t img_id, img_resource_t *res); int img_resource_read(const img_resource_t *res, uint32_t offset, void *buf, uint32_t len); #endif实现层面底层用HAL库的SPI接口开DMA传输int img_resource_read(const img_resource_t *res, uint32_t offset, void *buf, uint32_t len) { if (offset len res-size) { return -1; } uint32_t flash_addr FLASH_BASE_OFFSET res-addr offset; uint8_t cmd[4] {0x03, (uint8_t)(flash_addr 16), (uint8_t)(flash_addr 8), (uint8_t)(flash_addr)}; // 拉低CS HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi2, cmd, 4, HAL_MAX_DELAY); HAL_SPI_Receive_DMA(hspi2, (uint8_t *)buf, len); // 这里必须等DMA完成才能拉高CS while (HAL_SPI_GetState(hspi2) ! HAL_SPI_STATE_READY) { // 超时保护 } HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); return (int)len; }这里有个很容易踩的坑DMA传输期间CS必须保持低电平如果SPI是半双工共用的还要保证没有其他任务在同时使用同一个SPI外设。在FreeRTOS LVGL的环境里我会用互斥量保护整个Flash访问函数否则一旦另一个任务切进来操作SPI读取内容直接错乱。3.2 固定偏移表方式接入LVGL图片控件资源索引表本身是一段烧写在Flash前部的数据结构体数组。为了在运行时能定位到它我把它放在一个固定地址偏移处固件里用绝对偏移读取#define RESOURCE_INDEX_ADDR 0x00001000 const img_resource_t *get_resource_table(uint32_t *count) { // 从内部Flash或RAM保存一份只读表避免每次都读外部Flash static img_resource_t table[64]; static int loaded 0; if (!loaded) { // 读Flash前部的资源表 read_flash(RESOURCE_INDEX_ADDR, table, sizeof(table)); loaded 1; *count sizeof(table) / sizeof(img_resource_t); } return table; }索引表放外部Flash前部的好处是固件升级时如果资源表变了可以连资源区一起整体更新。这里我踩过一个坑不要把索引表放在固件bin文件的同一区域内烧录否则每次固件升级会覆盖索引表导致运行时资源位置全乱。拿到索引之后怎么把一张图片显示出来如果MCU支持内存映射比如STM32F769的QSPI直接把data指针指向映射地址就行// 用内存映射方式显示LVGL直接读Flash数据 lv_img_dsc_t bg_img_dsc { .header.always_zero 0, .header.w 1024, .header.h 600, .header.cf LV_IMG_CF_TRUE_COLOR, .data_size 1024 * 600 * 2, .data (const uint8_t *)(0x90000000UL RESOURCE_OFFSET), }; lv_obj_t *bg lv_img_create(lv_scr_act()); lv_img_set_src(bg, bg_img_dsc);但很多MCU不支持QSPI内存映射那就要退一步先按需把整张图片读进RAM缓冲再把缓冲指针交给LVGL。这种方式适合页面切换时一次性加载的大图代码也清晰static uint8_t s_bg_buf[1024 * 600 * 2]; // 池子预分配 void show_background(lv_obj_t *parent, uint16_t img_id) { img_resource_t res; lv_img_dsc_t dsc; img_resource_lookup(img_id, res); img_resource_read(res, 0, s_bg_buf, res.size); memset(dsc, 0, sizeof(dsc)); dsc.header.always_zero 0; dsc.header.w res.width; dsc.header.h res.height; dsc.header.cf res.cf; dsc.data_size res.size; dsc.data s_bg_buf; lv_obj_t *img lv_img_create(parent); lv_img_set_src(img, dsc); }注意缓存池s_bg_buf要静态分配不要用malloc否则长期运行会有碎片问题。3.3 文件系统方式接入LVGL如果你的方案定了文件系统LVGL这边要注册一个自定义文件系统驱动。LVGL 8.3里实现lv_fs_drv_t把open/read/seek/close这些回调接上static void *fs_open(lv_fs_drv_t *drv, const char *path, lv_fs_mode_t mode) { FIL *fp lv_mem_alloc(sizeof(FIL)); if (f_open(fp, path, FA_READ) ! FR_OK) { lv_mem_free(fp); return NULL; } return fp; }注册的时候指定盘符比如F:lv_fs_drv_t fs_drv; lv_fs_drv_init(fs_drv); fs_drv.letter F; fs_drv.open_cb fs_open; fs_drv.read_cb fs_read; fs_drv.close_cb fs_close; fs_drv.seek_cb fs_seek; lv_fs_drv_register(fs_drv);之后图片资源路径就是F:/ui/bg.binLVGL内部会自动调文件系统接口去读。这条链路比裸数据读取多了开文件、路径解析等操作但对维护来说方便很多如果UI素材经常调整这个成本是值得的。3.4 LVGL关键配置项调整不管你走哪条路线lv_conf.h里的几个宏直接影响外部Flash图片的加载体验LV_COLOR_DEPTH建议16RGB565与屏幕一致不要用32。32位色深在拷贝到显示缓冲时带宽翻倍Flash读取压力也翻倍LV_COLOR_16BIT_SWAP按你屏幕的字节序设置这个错了就是花屏LV_MEM_SIZELVGL自己管理的堆。外部Flash方案下PNG解码、文件句柄、动态创建的控件都从这里分配建议至少给8KB~16KB如果跑文件系统解码建议32KB以上LV_DISP_DEF_REFR_PERIODLVGL定时器多久刷新一次渲染默认30ms。如果你感觉界面操作响应慢可以调到16ms或者10ms但会提高CPU占用需要实测折中LV_IMG_CACHE_DEF_SIZE默认0即不缓存解码后的图片。如果用的是PNG之类带解码格式这个务必开启建议4~8张的容量。bin裸数据没有解码过程缓存意义不大但文件系统场景下缓存能避免频繁open/close。3.5 FreeRTOS环境下的集成注意点LVGL官方推荐把lv_timer_handler放在独立任务里周期调用。外部Flash读取如果直接在UI任务里同步进行整帧渲染会被拖住。我这边用FreeRTOS结构是这样LVGL任务优先级中周期4~5ms调用一次lv_timer_handler显示驱动任务负责LCD刷新DMA中断里做同步Flash读取任务低优先级负责预读图片到缓存池通过消息队列通知LVGL任务“图片已就绪”。关键点是SPI Flash读取要用互斥量保护避免和LCD的SPI冲突。如果Flash和LCD共用同一个SPI外设很多低成本方案都这么干那问题更明显必须确认LCD刷新DMA和Flash读取不能同时占用总线否则数据会被打乱。4. 性能优化让页面切换不再卡顿4.1 先定位瓶颈是Flash读得慢还是LVGL画得慢优化之前先做测量不要凭感觉。我的经验是做一个基准测试隐藏掉图片只绘制纯色块测一次UI刷新周期耗时用内部Flash数组方式加载同一张图片测一次页面切换耗时用外部Flash方式加载同一张图片测一次页面切换耗时。如果步骤1很快、步骤2快、步骤3慢说明问题在Flash读取链路。如果步骤2本身就慢那问题在LVGL渲染配置上比如缓冲太小、未开DMA、刷新周期太长等。实测下来W25Q64这种SPI Flash在80MHz时钟、开DMA的情况下读一张150KB的RGB565图大约需要15~25ms片选、指令开销、SPI时序损失都算上。对一个页面切换来说这个数字如果叠加在渲染流程里用户能明显感觉到卡顿。所以优化的核心目标就是“让Flash读取时间与渲染时间重叠”而不是减少Flash读取时间本身——后者物理极限摆在那。4.2 DMA、双缓冲与display buffer协同显示驱动这边flush_cb里我们要把显示缓冲区的数据交给LCD控制器。如果LCD是SPI接口flush_cb里发SPI写命令数据也是DMA传输。优化点在于DMA传输期间CPU不能去等要立刻返回让LVGL继续渲染下一块数据。这就引出双缓冲或部分刷新机制。LVGL配置里可以设置两个display bufferlv_disp_draw_buf_init传两个buf当LVGL渲染到buf1时DMA正在把buf0刷给屏幕。两边并行CPU利用率上去整体帧率几乎能翻倍。和外部Flash图片结合时我的做法是页面切换瞬间Flash读取任务先把新页面最耗时的背景图读入RAM缓存LVGL渲染时只用缓存指针不直接触发Flash读取。这样渲染路径完全不跟Flash打交道切图自然快。4.3 预加载与RAM缓存池设计缓存池是我这套方案收益最大的部分。原理很简单页面切换的耗时大头通常是背景大图只要把“下一屏”的背景图提前读进RAM切换时就只是换指针。具体实现上我维护了一个固定大小的缓存池存放两张最大背景图的RAM空间比如2 * 1024 * 600 * 2 2.4MB对大多数MCU太奢侈所以实际产品背景图通常不超过480x272那池子就是2480272*2 512KB还可接受。用一个简单的状态机页面A显示时后台任务预读页面B的背景图到空闲缓冲用户点击跳转页面BUI任务把bg_img_dsc.data改为已就绪的缓冲地址页面B显示时后台任务按算法腾出一块缓冲预读页面C或返回A的图如果预读未完成用户就点了跳转则放弃预读直接同步读取并接受这一次的延迟。这个方案对任何MCU都适用只是池子大小按RAM余量调整。如果你的RAM只够缓存一张半图就不要预读改成同步读保证用户看到的画面一致。4.4 减少图片数据量的三个实用手段除了缓存从源头减少数据量也能直接提升加载速度。第一色深能降就降。UI里能接受RGB565就绝对不搞ARGB8888数据量直接砍一半。只有必须带透明通道的图标才用ARGB8888。第二小图标能合图就合图。把十几个小图标横向拼成一张Atlased大图LVGL 9.x里可以直接用lv_image的crop功能显示子区域8.x的话可以自己在解码层处按区域读取或者干脆拆成独立小文件。合图的好处是文件句柄少、读取次数少文件系统场景下尤其明显。第三用LVGL内置的压缩格式。比如LV_IMG_CF_RAW可以在转换工具里选择压缩选项但代价是绘制时CPU要解压。MCU主频如果只有几十上百MHz压缩带来的CPU开销可能超过Flash读取的节省反而更慢。这个要实测别只看Flash占用。4.5 一个容易忽略的点Cache一致性如果你用的是Cortex-M7这类带D-Cache的MCUDMA读外部Flash后CPU读RAM缓冲必须是经过DMA写回的。这时候不做Cache维护就会出现“DMA明明把数据读好了但CPU读到的还是旧的缓存内容”这种诡异问题表现为偶发花屏、复位后第一次显示异常。解决方法是刷新invalidate对应RAM区域的CacheSCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);一定要在DMA完成、但CPU访问该缓冲之前调用。在H7/NXP RT系列上这个坑基本必踩提前加进去能省很多调试时间。5. 常见问题与排查技巧实录5.1 高频问题速查表我在这个项目里遇到的坑按出现频率排个序做成速查表症状可能原因解决方向图片花屏RGB565字节序反了LV_COLOR_16BIT_SWAP配置错误图片宽高与dsc不一致用纯色测试图验证字节序逐一排除白屏/黑屏无显示文件路径错误资源索引表读错data_size为0用调试器打印src路径和dsc.data指针确认非空图片显示一半后卡死DMA传输未完成就触发lv_disp_flush_readySPI被其他任务打断严格在DMA完成回调里调用lv_disp_flush_readySPI加互斥页面切换明显卡顿未开DMAFlash时钟太低同步读取大图LV_DISP_DEF_REFR_PERIOD太长开DMA、提时钟、走缓存预读、缩短刷新周期偶发花屏/旧图Cache一致性未处理M7芯片LVGL内存被踩加Cache invalidate查lv_mem监控看是否有溢出硬错误/内存爆LV_MEM_SIZE太小图片数据直接malloc大块调大LV_MEM_SIZE大图缓冲用静态数组某些图标显示成方块颜色格式不匹配比如把ARGB8888的bin按RGB565显示检查dsc.header.cf和转换工具的格式5.2 一个真实调试案例花屏花得毫无规律我印象最深的一次花屏排障。主题是“切到二级页面背景图偶发花屏不是每次都花复位后第一次百分百花继续切几次又好了”。当时怀疑过Flash时序、DMA传输、SPI线上干扰折腾了半天直到我意识到芯片是Cortex-M7内核才反应过来Cache一致性问题。DMA把Flash数据读进RAM缓冲区后没有invalidate CacheCPU从Cache里读到了旧数据所以显示出来的图就是残缺的。加上一行SCB_InvalidateDCache_by_Addr之后问题彻底消失。这个坑没有任何调试器能帮你查出来只能靠经验。所以我强烈建议M7及以上带Cache的MCUDMA方案里第一优先把Cache维护写好。还有一个案例是关于文件路径的。当时我在一个文件系统方案里把图片路径字符串定义成一个局部变量LVGL内部是异步读取文件的函数返回后局部变量已经销毁LVGL再去访问就是野指针表现为“经常显示一下然后系统崩了”。排查很久才发现是生命周期问题。用LVGL文件系统时路径字符串必须保证在整个图片生命周期内都有效建议用静态数组或全局const字符串。5.3 一条本文没有展开的扩展OTA资源更新最后再分享一个方向。如果你产品需要在现场更新UI素材光靠固定偏移表肯定不行那就需要在文件系统方案里实现资源区覆盖。我做过的做法是外部Flash规划两个资源分区OTA升级包先写入备用分区校验通过后再把备用分区整体拷贝到主分区。虽然看上去多一倍的Flash空间开销但换来的是“升级过程中断电也不怕大不了回滚版本”的可靠性。LVGL侧完全无感切换主备只需要改一个启动参数指向不同偏移。踩过几次偏方之后我现在做外部Flash图片加载方案的第一原则是不炫技不引入不必要的复杂度。能用缓存解决性能就用缓存能用固定偏移表解决资源定位就坚决不挂文件系统能用DMA并行解决等待就绝不让CPU空转。MCU资源有限方案每多一层封装就多一层出错的机会所有“优化”都必须以可稳定量产为底线。这个内容后续还可以这样扩展把资源索引表改成带CRC校验的版本管理结构启动时校验资源和固件的版本匹配性或者在显示驱动层面做局部脏矩形和外部Flash图片读取的联动进一步减少无效读取。核心思路就一条整个链路里每一毫秒都值得抠但前提是你能说清楚这一毫秒到底消耗在哪里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑