做嵌入式GUI这件事,做到后面最难的根本不是界面布局,而是资源没地方放。LVGL项目进入中后期,一套像样的皮肤加上图标、背景、多语言字体,动辄几百KB;要是再放几张全屏背景或者做开机动画,上MB都很正常。而绝大多数MCU的内部Flash也就512KB到1MB,代码本身还要占掉一大半。我最初也习惯把所有图片转成C数组直接编进固件,直到有一次连bootloader一起算,固件空间只剩下不到30KB,才被迫认真研究外部Flash这条路。这篇笔记就从方案选型、底层机制、完整代码到调优思路,把“LVGL加载外部Flash图片资源”这件事一次说透,供准备做资源外置的开发者参考。
1. 图片资源膨胀:从C数组到外部Flash的必然之路
1.1 算一笔容量账:一个UI界面到底吃掉多少Flash
很多人一开始做LVGL界面时,都会从最简单的图标着手,把一张图转成一个C数组,然后LV_IMG_DECLARE一下,几百字节觉得没什么。但项目一旦铺开,这个账就算不过来了。
一张128x128的RGB565图标是32KB,96x96的约18KB,240x320的全屏背景RGB565要150KB。如果按钮、状态栏、弹窗、设置页各放几十张图标,再加上两套字体和一个整屏背景,随随便便就突破1.2MB。这还只是静态资源,没算开机动画序列帧。
对比一下常见MCU的容量:STM32F407VET6内部Flash也就512KB,代码、协议栈、算法库至少要占掉300KB,剩下给图片的空间寥寥无几。如果继续把所有图片编进固件,最终结果就是编译通过但烧录失败,或者为了塞图片不得不砍功能。
所以当资源总量超过内部Flash可用空间的那一刻,把图片迁到外部Flash就是唯一务实的选择。外部SPI NOR Flash单价便宜,16MB的W25Q128几块钱,容量足够放几百张图和完整字库,而且不占用MCU宝贵的内部存储。
1.2 三种外部Flash落地方案怎么选
图片放外部Flash,具体怎么“放”和“取”,行业内大致有三种做法,各有适用场景。
| 方案 | 实现成本 | 灵活性 | 读取速度 | 适用场景 |
|---|---|---|---|---|
| 继续用C数组,编进App | 零 | 差 | 最快 | 少量小图标、固定资源 |
| 外部Flash + 自定义文件驱动 | 中 | 好 | 较快 | 图片多、资源基本固定 |
| 外部Flash + LittleFS等文件系统 | 中高 | 最好 | 较快 | 资源需要OTA升级、动态更新 |
| QSPI Flash + 内存映射(XIP) | 中 | 好 | 接近内部Flash | 大图高频刷新、系统复杂 |
第一种方案不讨论,因为容量问题没有解决。第二种方案是我这篇笔记的重点:图片以二进制文件形式烧录到外部Flash的固定地址,LVGL通过一个自定义的文件驱动去读。它的好处是代码量小、行为可控,资源变更时只需要改一张映射表,适合绝大多数量产产品。
第三种方案引入LittleFS之类的文件系统,真正做到“文件管理”,可以在设备端删改文件、升级资源包,但工程复杂度也上去了,要处理wear leveling、文件表、掉电保护等一堆事。如果你的产品需要现场换肤、远程更新UI资源,那就得走这条路。
第四种方案属于硬件红利,要求MCU带QSPI接口并支持memory mapped模式,典型如STM32F7/H7系列。图片放在外部QSPI Flash里,CPU可以直接按地址读取,传给LVGL时就像读内部RAM一样快,但硬件成本稍高,而且不是所有MCU都支持。
我下面给的完整代码走的是第二条路,核心是搞懂LVGL的文件驱动机制。这个机制理解了,后面换LittleFS、换QSPI都是水到渠成的事。
2. 一条路径字符串背后的调用链:LVGL文件驱动与解码器
2.1 lv_fs_drv_t:LVGL与外部存储之间的“翻译官”
LVGL不是直接操作Flash芯片的,它设计了一套文件系统抽象层,类似PC操作系统的VFS。只要注册一个文件系统驱动,LVGL就能像读本地文件一样去读外部存储。
驱动结构体是lv_fs_drv_t,里面最核心的是一组回调函数。对显示图片这个场景来说,必须实现的是这几个:
typedef struct { char letter; // 盘符,比如 'F' void * (*open_cb)(lv_fs_drv_t * drv, const char * path, lv_fs_mode_t mode); lv_fs_res_t (*read_cb)(lv_fs_drv_t * drv, void * file_p, void * buf, uint32_t btr, uint32_t * br); lv_fs_res_t (*seek_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t pos, lv_fs_whence_t whence); lv_fs_res_t (*tell_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t * pos_p); lv_fs_res_t (*close_cb)(lv_fs_drv_t * drv, void * file_p); lv_fs_res_t (*size_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t * size_p); // ... } lv_fs_drv_t;说白了,LVGL不关心你背后是SPI Flash、SD卡还是U盘,它只认这套接口。我们的工作,就是把外部Flash的“读数据”能力翻译成open/read/seek/tell/close这些函数。
注册时还要给驱动指定一个盘符,比如'F',这样代码里写lv_img_set_src(img, "F:/logo.bin"),LVGL看到以F:开头的路径,就会自动找到我们注册的这个驱动来处理。
这里有个容易踩坑的版本差异:LVGL 8.0到8.2,回调成员名是open、read;LVGL 8.3之后统一改成open_cb、read_cb,一直沿用到9.x。网上旧帖子的代码直接复制到新版本会编译不过,多数就是这个原因。
2.2 从“F:/logo.bin”到屏幕像素:完整数据链路
很多初学者以为lv_img_set_src传入路径后,LVGL会自己在某个magic层面把图片变出来。实际上它内部走了一条很清晰的调用链:
lv_img_set_src解析字符串,发现是以F:开头的路径,于是把这张图标记为“文件图片”。- LVGL图像模块调用已注册的
F盘驱动,执行open_cb打开logo.bin。 - 打开后,LVGL先通过
read_cb读取文件开头的lv_img_header_t,拿到图片的宽度、高度、颜色格式。 - 真正绘制时,解码器按需调用
read_cb读取像素数据,必要时用seek_cb跳转偏移。 - 读出来的图像数据进入LVGL的图片缓存,随后交给显示驱动flus到屏幕。
这条链路里,文件驱动其实只负责两件事:把文件指针定位到正确位置,然后把那一小块数据读出来。至于图片是PNG还是裸的RGB565,那是解码器的事。
我特意把这条链路讲清楚,是因为在排查问题时非常有用。比如花屏,问题大概率出在第2步到第5步之间的格式转换;白屏,问题可能出在盘符或路径上;卡死,往往是open了文件没close,或者一次读太多数据撑爆了内存。
2.3 为什么自定义文件驱动是通用解
有人可能会问,LVGL官方不是有lv_fs_win32、lv_fs_posix、lv_fs_stdio这些现成驱动吗?直接用不就好了。
这些驱动是给PC模拟器或带操作系统的环境用的,它们底层调用Windows/Linux的文件API。在裸机STM32上,没有文件系统,没有POSIX接口,这些驱动一个都用不了。自定义文件驱动是唯一能同时兼容裸机和RTOS环境的做法。
而且自定义驱动的好处是:它只依赖三个底层函数——读Flash、擦除Flash、写Flash。这三个函数任何SPI Flash驱动都有,所以这套代码几乎可以无脑移植到任何MCU平台,改的只有底层调用的函数名。
3. 完整代码实现:在STM32+W25Q128上加载外部Flash图片
3.1 准备工作:硬件连接、CubeMX配置和图片转换
先交代一下我用的环境,方便你对照:
- MCU:STM32F407VET6
- 外部Flash:W25Q128(16MB,SPI模式)
- 屏幕:2.8寸TFT,ILI9341,16bit并口
- LVGL版本:9.1(代码同样适配8.3+)
- 裸机环境,未上RTOS(RTOS注意事项放在第5章)
CubeMX里把SPI1设成18MHz,Mode 0(W25Q128支持Mode 0和Mode 3,默认用Mode 0),片选引脚配成GPIO输出。注意W25Q128的WP引脚和HOLD引脚不能悬空,必须拉高,否则初始化正常但读数据会出现随机错误,这点很多人第一次栽过。
图片转换我用LVGL官方的在线图片转换器(lvgl.io/tools/imageconverter),设置如下:
- Output format:Binary
- Color format:RGB565
- 勾选
Embed header(默认就带LVGL图像头) - 不勾抖动
转换后生成logo.bin,大小就是宽高乘积的2倍。比如128x128的图,生成的文件正好32768字节。保存好这个bin,后面烧录要用。
3.2 文件驱动核心代码与逐段解析
下面就是整个方案的核心,一个基于固定地址映射的自定义LVGL文件驱动。代码里我把每个回调的职责都写清楚了,可以直接放到工程里用。
/* ui_flash_fs.c * 自定义LVGL文件驱动:将外部Flash中的图片资源映射为虚拟文件 * 配套LVGL 8.3+ / 9.x */ #include "lvgl.h" #include "w25qxx.h" /* 你的SPI Flash驱动,至少提供W25QXX_Read */ /* 图片资源表:记录每张图片在外部Flash中的地址和大小 */ typedef struct { const char * name; /* 文件名,LVGL路径里使用 */ uint32_t addr; /* 在Flash中的偏移地址 */ uint32_t size; /* 文件大小,字节 */ } image_rom_t; /* 虚拟文件句柄池 */ typedef struct { bool used; uint32_t addr; uint32_t size; uint32_t pos; } image_file_t; /* 根据实际烧录地址和文件大小维护这张表 */ static const image_rom_t rom_table[] = { { "logo.bin", 0x00100000, 32768 }, /* 128x128 RGB565 */ { "home.bin", 0x00108000, 18432 }, /* 96x96 RGB565 */ { "bg.bin", 0x0010C800, 153600 }, /* 320x240 RGB565 */ }; #define ROM_TABLE_SIZE (sizeof(rom_table) / sizeof(rom_table[0])) #define FILE_POOL_SIZE 8 static image_file_t file_pool[FILE_POOL_SIZE]; /* 通过文件名查找资源 */ static const image_rom_t * find_rom(const char * name) { if (name == NULL) return NULL; for (int i = 0; i < ROM_TABLE_SIZE; i++) { if (strcmp(rom_table[i].name, name) == 0) { return &rom_table[i]; } } return NULL; } /* 分配一个空闲句柄 */ static image_file_t * find_free_file(void) { for (int i = 0; i < FILE_POOL_SIZE; i++) { if (!file_pool[i].used) { return &file_pool[i]; } } return NULL; } /* 打开文件:根据路径找到资源,记录起始地址和大小 */ static void * flash_fs_open(lv_fs_drv_t * drv, const char * path, lv_fs_mode_t mode) { (void)drv; /* 本驱动只读,拒绝写打开 */ if (mode & LV_FS_MODE_WR) return NULL; /* 路径形如 "F:/logo.bin",跳过开头的目录斜杠,直接取文件名 */ const char * fname = path; while (*fname == '/' || *fname == '\\') fname++; const image_rom_t * rom = find_rom(fname); if (rom == NULL) return NULL; image_file_t * f = find_free_file(); if (f == NULL) return NULL; /* 句柄池耗尽 */ f->used = true; f->addr = rom->addr; f->size = rom->size; f->pos = 0; return f; } /* 读取:从当前偏移读len字节到buf */ static lv_fs_res_t flash_fs_read(lv_fs_drv_t * drv, void * file_p, void * buf, uint32_t btr, uint32_t * br) { (void)drv; image_file_t * f = (image_file_t *)file_p; /* 剩余可读字节数,防止越界 */ uint32_t remain = f->size - f->pos; uint32_t len = (btr < remain) ? btr : remain; if (len > 0) { W25QXX_Read((uint8_t *)buf, f->addr + f->pos, len); f->pos += len; } if (br != NULL) *br = len; return LV_FS_RES_OK; } /* 定位:LVGL读取图片头、切换解码行时都会调用 */ static lv_fs_res_t flash_fs_seek(lv_fs_drv_t * drv, void * file_p, uint32_t pos, lv_fs_whence_t whence) { (void)drv; image_file_t * f = (image_file_t *)file_p; uint32_t new_pos; switch (whence) { case LV_FS_SEEK_SET: new_pos = pos; break; case LV_FS_SEEK_CUR: new_pos = f->pos + pos; break; case LV_FS_SEEK_END: new_pos = f->size + pos; break; default: return LV_FS_RES_INV_PARAM; } if (new_pos > f->size) return LV_FS_RES_INV_PARAM; f->pos = new_pos; return LV_FS_RES_OK; } /* 返回当前文件偏移 */ static lv_fs_res_t flash_fs_tell(lv_fs_drv_t * drv, void * file_p, uint32_t * pos_p) { (void)drv; image_file_t * f = (image_file_t *)file_p; if (pos_p != NULL) *pos_p = f->pos; return LV_FS_RES_OK; } /* 获取文件大小:LVGL读取图片头时需要知道文件边界 */ static lv_fs_res_t flash_fs_size(lv_fs_drv_t * drv, void * file_p, uint32_t * size_p) { (void)drv; image_file_t * f = (image_file_t *)file_p; if (size_p != NULL) *size_p = f->size; return LV_FS_RES_OK; } /* 关闭文件:释放句柄 */ static lv_fs_res_t flash_fs_close(lv_fs_drv_t * drv, void * file_p) { (void)drv; image_file_t * f = (image_file_t *)file_p; f->used = false; return LV_FS_RES_OK; } /* 注册驱动:在main初始化阶段调用一次 */ void ui_flash_fs_init(void) { static lv_fs_drv_t fs_drv; /* 必须static,LVGL持有指针 */ lv_fs_drv_init(&fs_drv); fs_drv.letter = 'F'; fs_drv.open_cb = flash_fs_open; fs_drv.read_cb = flash_fs_read; fs_drv.seek_cb = flash_fs_seek; fs_drv.tell_cb = flash_fs_tell; fs_drv.close_cb = flash_fs_close; fs_drv.size_cb = flash_fs_size; lv_fs_drv_register(&fs_drv); }这段代码里,rom_table就是“虚拟文件系统”的目录。烧录图片时我把logo.bin写在Flash偏移0x00100000处,那映射表里就记录这个地址和大小。以后要加新图,只需把bin烧到空闲区域,然后在表里加一行,重新编译即可。
file_pool是句柄池,限制同时打开8个文件。LVGL的图片缓存可能会同时持有多个打开状态,8个通常够用。如果你的界面一个页面同时加载很多图,可以把FILE_POOL_SIZE调大,但每个句柄会占12字节RAM,权衡一下。
read_cb是性能关键路径,LVGL会频繁调用它来读数据。我直接用W25QXX_Read每次读一小段,实际项目中可以加一个预读缓冲或者直接走DMA,能进一步降低CPU占用。但注意DMA模式下回调是异步的,需要等传输完成再返回br,否则LVGL会读到空数据。
3.3 显示端代码:把图片从“磁盘”搬到屏幕
驱动注册好之后,显示图片的代码反而非常简洁,和加载C数组图片一样自然:
void ui_demo(void) { ui_flash_fs_init(); /* 注册外部Flash文件驱动 */ lv_obj_t * img = lv_img_create(lv_scr_act()); lv_img_set_src(img, "F:/logo.bin"); lv_obj_center(img); lv_obj_t * img2 = lv_img_create(lv_scr_act()); lv_img_set_src(img2, "F:/home.bin"); lv_obj_align(img2, LV_ALIGN_BOTTOM_MID, 0, -20); }lv_img_set_src识别到F:前缀后,会走我们注册的flash_fs_open,然后自动读取文件头、加载像素。整个调用过程和从C数组加载唯一的区别就是数据来源不同,界面代码不需要额外分支判断。
有一点要提醒:路径字符串最好用静态或全局字符串,不要用临时栈上的char path[32]去拼,然后传给lv_img_set_src。LVGL内部可能会持有这个指针,函数返回后栈内存被释放,再访问就是未定义行为,典型症状是界面加载时正常,切屏一段时间后随机崩溃。
3.4 烧录与验证:如何确认图片数据正确入Flash
驱动代码写好了,图片bin也转换好了,接下来就是把bin烧进外部Flash的指定地址。这一步很多人会忽略,然后代码跑起来白屏,第一反应是代码有bug,其实Flash里压根没数据。
烧录方式取决于你的调试器。我用的J-Link + STM32CubeProgrammer,加载对应型号的外部Flash loader,直接把logo.bin烧到0x00100000。如果你手头没有loader,也可以在App里写一个简单的XMODEM/YMODEM串口升级函数,把bin分包写入Flash,一样的效果。
烧完建议先做个快速验证:在显示前读Flash头部几个字节打印出来,确认写入正确。
uint8_t hdr[8]; W25QXX_Read(hdr, 0x00100000, 8); LV_LOG_USER("0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X", hdr[0], hdr[1], hdr[2], hdr[3], hdr[4], hdr[5], hdr[6], hdr[7]);正常情况下,前4字节是LVGL图像头,其中包含了颜色格式、宽、高等信息,后面跟着的是第一行像素数据。看到有规律的数值,再跑界面代码,心里就很有底了。
4. 性能优化:缓存、颜色格式与流式解码的实战取舍
4.1 图片缓存:一次读取多次命中
LVGL内部自带图片缓存,目的是避免同一张图在反复绘制时重复读Flash。默认缓存可能很小或者没开对,很多人在项目里没注意这个配置。
LVGL 8.x里,可以通过lv_img_cache_set_size设置缓存条目数,比如lv_img_cache_set_size(32)。LVGL 9.x统一到了lv_cache子系统,最常见的是用lv_cache_set_max_size给图片缓存分配内存上限。
缓存命中时,从外部Flash读图片只有第一次慢,之后切换页面、弹窗显示几乎是秒开。缓存未命中时,每显示一次就要重新open、seek、read一整张图,卡顿感非常明显。
实测下来,对几十张图标级别的UI,把图片缓存RAM预算做到64KB到128KB,页面切换就能非常流畅。代价是这部分内存从LVGL堆里划走,如果堆不够,可以在lv_conf.h里加大LV_MEM_SIZE。
4.2 颜色格式与索引色:轻松省下一半存储
外部Flash空间虽然大,但不是无限的,而且读的数据量直接决定加载速度。所以图片格式的选型很关键。
| 格式 | 每像素位数 | 128x128图片大小 | 场景建议 |
|---|---|---|---|
| ARGB8888 | 32bit | 64KB | 需要半透明效果且存储充足 |
| RGB565 | 16bit | 32KB | 无透明需求,最常用 |
| Indexed 256色 | 8bit | 16KB | 色数少的小图标、按钮 |
| Indexed 16色 | 4bit | 8KB | 极简图标,存储极度紧张 |
从RGB565换到Indexed 256色,图片体积直接减半,读取时间也差不多减半。对于按钮、菜单图标这些色彩不复杂的资源,肉眼几乎看不出区别。图片转换器里把Color format选成Indexed 256即可,生成的bin自动带调色板,LVGL解码时会自己处理。
我现在的项目里,大背景和照片类图片用RGB565,图标和按钮一律Indexed 256,整体资源占用比最初方案少了37%。
4.3 流式解码应对超大图
全屏背景图往往是压垮内存的最后一根稻草。240x320的RGB565背景图就是150KB,如果用lv_img_set_src正常加载,LVGL会把整张图片解到RAM里,加上显示缓冲和LVGL堆,很多MCU直接内存溢出。
LVGL 9.x提供了一个更合适的选择:lv_img_set_src_fs(obj, "F:/bg.bin", LV_IMG_CF_TRUE_COLOR)。这个接口走流式解码,不会一次性把整张图数据读进RAM,而是按需从文件读取并解码,内存占用可以压到很低,特别适合大尺寸背景图。
不过要注意,lv_img_set_src_fs对流式解码的文件驱动有要求,驱动必须正确实现seek,因为解码器需要反复跳跃读取。我们上面的代码已经支持seek,所以只要LVGL版本到位,直接把接口换成lv_img_set_src_fs即可。
如果你的LVGL还是8.x,没有lv_img_set_src_fs,一个变通办法是把大图作为软键盘或复杂界面的底层,仍然用lv_img_set_src,同时配合缓存和局部刷新来扛,或者干脆换9.x。
4.4 硬件提效:四线QSPI与内存映射模式
软件层面再怎么优化,普通SPI模式读Flash的带宽也就那么高。遇到需要频繁切换全屏页面的场景,QSPI是一个真正的硬件级提速手段。
支持memory mapped模式的QSPI Flash,MCU可以直接把外部Flash映射到地址空间,读取时像读内部存储器一样,不需要先拷贝到RAM再交给LVGL。因为这个特性,很多做复杂GUI的硬件方案会硬性要求MCU带QSPI接口。
如果你的平台支持QSPI但引脚和成本都在控制范围内,我建议在硬件设计阶段就把QSPI留出来,日后图片资源膨胀或者UI复杂度上来了,至少有硬件层面的退路。
5. 高频踩坑记录:花屏、白屏、卡死的排查链路
5.1 花屏:颜色格式不匹配,屏幕上全是噪点
花屏是最常见的问题,没有之一。现象是图片能显示出来,但颜色完全不对,条纹、噪点、色块混合在一起。
排查路径我建议从三层入手:
- 屏幕驱动和LVGL的
LV_COLOR_DEPTH是否一致。16bit屏配LV_COLOR_DEPTH 16,32bit屏配LV_COLOR_DEPTH 32。 - 图片转换器的颜色格式是否匹配。
LV_COLOR_DEPTH为16时,图片转换选RGB565;如果选了ARGB8888,数据量翻倍且布局不同,必然花屏。 - 转换器是否带了透明通道。带alpha的ARGB8888需要有透明处理的屏幕和相应配置,驱动里没做混合就会显示成奇怪的色块。
我排查过的最隐蔽一次是:屏幕本身是RGB565,LVGL也是16bit,但图片转换器输出命名看起来是RGB565,实际勾选了Alpha选项,结果就是整个界面偏色且边缘有锯齿。
5.2 白屏与黑屏:文件打不开,先查这三处
白屏和黑屏通常不是像素格式问题,而是文件驱动根本没读到数据。
第一处查SPI Flash通信。初始化时打印W25Q128的ID,如果ID读出来全是0xFF或0x00,先查WP引脚、HOLD引脚有没有拉高,SPI极性有没有配置对。
第二处查路径。盘符字母大小写必须和驱动注册时一致,我注册的是'F',路径就必须写"F:/logo.bin",写"F:\\logo.bin"或者"f:/logo.bin"都可能导致打开失败。有些文件驱动是大小写敏感的,图片转换器生成的文件名是什么,代码里就得一字不差。
第三处查Flash内容。用3.4节的方法读一下烧录地址的头几个字节,如果全是0xFF,说明图片根本没烧进去,或者烧录地址和映射表里的地址对不上。
5.3 加载后系统卡死:句柄泄漏与内存不足
卡死比花屏更让人头疼,因为它通常是间歇性的。最典型的两个原因:
第一个是句柄泄漏。每次打开图片都要占用一个file_pool条目,如果打开后没有正常close,句柄池最终会被耗尽,之后的open回调返回NULL,LVGL内部没做好空指针保护,直接HardFault。
解决思路是:把FILE_POOL_SIZE调大一点是治标,更关键的是在close_cb里做日志记录,怀疑泄漏时把open/close的次数打印出来对比。我在调试阶段给open_cb加了一个计数器,专门盯这个。
第二个是内存分配失败。lv_img_set_src加载一张大图时,需要在堆上分配一整块图像内存。堆不够时lv_malloc返回NULL,LVGL部分版本直接崩。建议把lv_conf.h的日志等级开到LV_LOG_LEVEL_WARN以上,崩之前能看到内存分配失败的报错。
5.4 RTOS环境下的并发冲突:给SPI Flash加把锁
如果你的项目跑的是FreeRTOS,显示任务和业务任务并发访问外部Flash就是绕不开的问题。SPI Flash本身不具备多终端并发能力,两个任务同时发起读操作,轻则数据错乱,重则总线冲突。
稳妥的做法是在SPI Flash操作外面加一个互斥量,例如FreeRTOS的SemaphoreHandle_t:
W25QXX_Read_MutexTake(); W25QXX_Read(buf, addr, len); W25QXX_Read_MutexGive();把这段封装成新的读取函数,文件驱动的read_cb里只调这个安全版本。如果是裸机中断环境,则用临界区保护。别觉得短读取不需要加锁,我遇到过一帧图像里偶尔出现几个像素错位的诡异bug,最后定位就是SPI读操作被另一个任务打断,数据读到一半被改写。
顺手提一句,LVGL自己的lv_tick、lv_timer和显示刷新在RTOS下也有优先级讲究。图片解码是相对重的CPU操作,建议把LVGL处理任务优先级设成中等,别和实时性要求高的任务抢CPU,否则会出现界面操作卡顿但系统其他功能正常的现象。
这套方案在我项目里用了大半年,从最开始2MB资源全部编进固件,到现在几百张图全放在外部Flash,代码改动量其实很小,核心就是这个文件驱动和一张映射表。工程上我习惯把rom_table单独放到一个头文件里,每次新增资源只加一行映射、烧一个bin,应用代码基本不用动。最后再分享一个个人建议:资源规划表最好在项目立项时就建好,Flash地址分区、盘符设计、转换格式这些先定下来,不然图形界面做了一半再迁移资源,地址全部推翻重来的成本很高。外部Flash放图片不是万能药,但确实是现阶段解决嵌入式GUI资源膨胀最务实的手段,值得在动手写UI之前认真规划。