
做嵌入式GUI开发特别是单片机这种资源紧张的环境下跑LVGL图片资源往哪儿放一直是个绕不开的问题。内部Flash算来算去就那么几兆塞一套图标、几套背景图就捉襟见肘了更别说还要存代码、存字库。我最早做LVGL项目时也踩过这个坑老老实实把图片转成C数组编进固件结果编译出来的bin文件大得离谱下载一次要等半天后面想换张图还得重新烧整个固件太痛苦了。后来把图片挪到外部Flash配合LVGL的读取机制按需加载才算真正解决这个问题。这篇文章就从实战角度讲清楚怎么在STM32这类MCU上高效地把外部Flash里的图片资源加载到LVGL界面里。我会把方案选型、底层原理、完整代码、踩坑记录全部铺开来讲代码部分直接基于实际项目整理复制过来改改引脚和参数就能用。无论是刚开始学LVGL的初学者还是正在优化资源的嵌入式工程师这篇都能给你省下不少时间。1. 图片资源存储方案选型为什么外部Flash是绕不开的选择1.1 内部存储空间的无奈先算一笔账。一个800x480的RGB565全屏图占用空间是800x480x2约750KB。一个手机App风格的主界面底图加图标加按钮切图随随便便就是两三兆。而常见的STM32F103系列内部Flash也就512KB到1MB光是一张全屏图就塞不下了。即便用STM32F429这种带2MB Flash的代码、字库、系统服务七七八八占掉一半剩下的空间也不敢说充裕。有人会说用PNG/JPEG压缩格式加载时解码。这个思路在PC上没问题但在MCU上很伤脑筋。跑一个png解码库需要额外占用几十KB的RAM做缓冲区解码时间动辄几百毫秒到几秒界面切换卡顿感非常明显。LCD控制器刷一帧也就十几毫秒解码居然比刷屏还慢用户体验直接崩坏。1.2 三种方案的横向对比目前常见的图片加载方式基本是这三种直接编进固件、从外部Flash读取、从SD卡/文件系统读取。我把它们放在一起对比一下。方案优点缺点适用场景转C数组编进固件实现简单加载快不依赖外部存储占用内部Flash修改需重新编译烧录单张图片、小尺寸图标、原型验证外部Flash直读容量大按需加载图片更新方便需要额外的Flash芯片和驱动代码图片较多、尺寸较大的产品级项目SD卡/U盘文件系统容量大可热插拔更换占用引脚多读速受SDIO/SPI限制协议复杂需要用户自定义素材的场景从实际项目角度看外部Flash直读是最平衡的选择。成本只有一两块钱一颗W25Q64SPI接口占用4根线读写速度在几十MB/s配合DMA几乎不占CPU。最关键的一点是图片更新不需要重新编译固件用烧录器通过SWD接口读出Flash地址或者通过串口OTA把图片数据写到指定扇区就行这在产品迭代期特别有用。1.3 选型时的几个考量点具体选Flash芯片我主要看这几点容量、接口、擦写寿命。容量方面8Mbit1MB只是入门推荐16Mbit2MB起步如果是32位颜色深度的UI界面建议直接上64Mbit8MB。接口方面首选SPI接口的四线QSPI硬件上兼容性最好STM32全系列都支持。普通的SPI双线或者单线也能用就是速度会慢一些。注意老一点的W25Q16/W25Q32这些型号只支持普通SPI模式不支持QSPI买的时候看清后缀是JV还是FW。擦写寿命这块容易忽略。NOR Flash的擦写次数通常10万次左右听起来很多但如果程序里频繁写入图片数据积累下来还是会磨损的。我的习惯是图片数据只在生产或OTA阶段写入运行阶段只读不写既保证寿命也避免意外擦除导致界面花屏。2. 硬件与基础环境准备SPI Flash、LVGL配置与图片格式2.1 硬件连接与芯片选型参考我用的平台是STM32F407VET6加上一颗W25Q64JVSSIQ工作在QSPI四线模式时钟跑到80MHz。实际读速度能达到约35MB/s显示一张全屏图分分钟的事完全不影响界面流畅度。接线方式比较固定QSPI需要6根信号线片选CS接单片机的PB6时钟CLK接单片机的PB2数据线IO0接PB0数据线IO1接PB1数据线IO2接PF8数据线IO3接PF9如果你手头的是普通SPI模式的Flash那就用标准的MOSI、MISO、SCK、CS四根线CubeMX里选SPI外设速度跑20MHz以上也够用。另一种选择是使用STM32系列的FMC接口并行NOR Flash速度更快但引脚占用太多除非有现成硬件设计否则没必要。2.2 LVGL配置文件裁剪要点LVGL的图片加载能力高度依赖配置文件lv_conf.h以下几个参数跟图片加载直接相关我建议这样设置#define LV_MEM_SIZE (64U * 1024U) // LVGL动态内存池至少64KB图片解码需要缓冲区 #define LV_IMG_CACHE_DEF_SIZE 8 // 图片缓存条目数缓存解码后的图片避免反复读取 #define LV_COLOR_DEPTH 16 // 颜色深度16位RGB565最快32位ARGB8888占空间大 #define LV_USE_FS_POSIX 0 // 不使用POSIX标准文件系统接口LV_MEM_SIZE这个值很关键。LVGL自己的内存池承担了控件对象、动画、图片解码缓冲区的所有分配。如果太小加载大图时会报lv_img_set_src失败。我测试过加载一张800x480的RGB565图片解码需要的临时缓冲区大约在80KB左右但通过自定义读取回调可以压缩到几KB后面代码部分细说。颜色深度建议用16位RGB565。一方面节省内存和Flash带宽另一方面16位色深的UI在大多数TFT屏幕上观感已经足够细腻RGB565和LCD硬件格式完全一致显示时不需要做任何颜色格式转换速度最快。2.3 图片格式的取舍RGB565裸数据优于PNG这一步决定了整个方案的走向。把图片转C数组存进内部Flash是旧方案但把什么格式的数据存进外部Flash很多人一开始就做错了。我强烈建议使用RGB565裸数据也就是不带压缩的原始像素格式而不是存PNG或者JPEG。裸数据的好处非常直观。第一是零解码时间数据从Flash读出来就能直接往显存里扔第二是不需要额外解码库不占Flash和RAM第三是不需要大缓冲区LVGL可以从Flash按行读取像素块边读边刷。缺点也显而易见文件体积大同样的800x480图片RGB565裸数据约750KBPNG可能只有150KB但Flash便宜空间换时间完全划算。如果你追求更高的图片质量可以用ARGB8888格式但单个像素4字节一张全屏图要1.5MB除非你的Flash容量在8MB以上不然还是优先RGB565。图片转换工具有两个推荐。一个是LVGL官方的Online Image Converter网站地址直接搜LVGL image converter就能找到支持输出C数组、bin文件以及不同颜色深度另一个是Image2Lcd老牌工具可以批量转换并输出bmp/bin格式。转RGB565时注意工具选项里要选好颜色格式、字节序小端模式以及是否带文件头。LVGL的lv_img控件加载bin文件时需要文件头部包含头部信息或者通过自定义格式直接跳过文件头。3. 高效加载的核心原理与机制拆解3.1 LVGL图片加载的本质从数据源到像素LVGL显示图片原理上就是把一张图片的像素数据通过lv_img控件在屏幕上按指定坐标绘制出来。但像素数据从哪来是理解整个加载机制的关键。LVGL抽象了一个接口图片的数据源可以是内置的C数组直接指向Flash地址文件系统路径如S:/image.bin自定义内存地址自动回调函数这四种方式最终都会走到lv_img_set_src这个函数。lv_img_set_src内部会根据源码类型调用不同的数据提供方式。如果传入的是文件路径LVGL会通过注册的文件系统驱动打开文件如果传入的是指针数组就直接用这个地址。我们的外部Flash方案本质上就是让LVGL把外部Flash当成一个文件系统来访问或者更底层一点直接把Flash地址映射成LVGL可读的数据源。理解了这一点后续代码就顺理成章了。3.2 按需读取与内存瓶颈传统做法是一次性把整张图片读进RAM然后交给LVGL绘制。这种方式内存开销大一张800x480的RGB565图需要750KB内存STM32F407只有192KB RAM根本塞不下。所以必须按需读取用多少读多少。LVGL的图片显示流程在拿到一张图片的数据源后会逐行或者逐块地获取像素数据。你只要提供一个从这里读数据的底层函数LVGL需要哪个块就去外部Flash读那个块。这样RAM只占几十KB的缓冲大大缓解内存压力。具体来说LVGL内部有一个图片解码器decoder机制注册一个自定义decoder之后lv_img_set_src加载自定义数据源时LVGL会调用你的decoder的open、read_line、close方法实现按需读取。这就是高效加载的核心机制。3.3 三种实现路径的技术对比通过底层机制的分析具体落地有三种实现路径。第一种直接内存映射。把外部Flash的地址通过FSMC或者Memory Mapped模式映射到MCU的寻址空间然后lv_img_set_src直接传Flash地址。这种方式最简单但需要MCU支持内存映射功能且一次读取的数据跨越边界时处理复杂一般用得少。第二种通过自定义decoder回调。这是最主流的方式。注册一个LVGL decoder当LVGL需要图片某一行数据时回调函数从Flash读取对应的行数据。灵活性好内存占用小推荐。第三种借助LVGL的文件系统抽象。实现lv_fs_drv_t驱动把外部Flash挂载成类似SD卡的设备图片路径写成F:/image.bin。这种方式代码量稍大但好处是图片管理统一走文件系统方便文件级操作也方便后续扩展SD卡。从实际项目的代码维护角度看我选择第二种自定义decoder 第三种文件系统结合的方式底层Flash驱动统一上层文件系统负责路径管理decoder负责数据按需读取。层次清晰测试也好写。4. 完整代码实现Flash驱动、LVGL文件系统与图片显示4.1 Flash底层驱动抽象先写Flash驱动的抽象层这部分是W25Q64的标准操作把它封装成flash_read、flash_write、flash_erase三个接口后面所有上层代码依赖这三个接口。// flash_drv.h #ifndef FLASH_DRV_H #define FLASH_DRV_H #include stdint.h #define FLASH_SECTOR_SIZE 4096 #define FLASH_PAGE_SIZE 256 // 初始化Flash int flash_init(void); // 从Flash读取数据addr为绝对地址buf为输出缓冲区size为读取字节数 int flash_read(uint32_t addr, uint8_t *buf, uint32_t size); // 向Flash写入数据只能从0xFF状态向0写写入前需先擦除 int flash_write(uint32_t addr, const uint8_t *buf, uint32_t size); // 擦除一个扇区addr需要按扇区对齐 int flash_erase_sector(uint32_t addr); #endif具体实现里flash_read用QSPI的标准读取命令直接发送读命令加上24位地址然后连续读数据。注意QSPI模式下读命令是0x03普通读或0x6B四线快速读我用0x6B配合DMA实现高速读取。// flash_drv.c 核心实现 int flash_read(uint32_t addr, uint8_t *buf, uint32_t size) { uint8_t cmd[4]; cmd[0] 0x6B; // 四线快速读指令 cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; // 片选拉低 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // 发送命令和地址这里通过HAL的SPI/QSPI接口发送 HAL_QSPI_Command(hqspi, cmdStruct, HAL_QSPI_TIMEOUT_DEFAULT_VALUE); // 如果使用普通SPI则用HAL_SPI_Transmit实现类似逻辑 // 连续读取数据开启DMA传输 HAL_QSPI_Receive(hqspi, buf, size, HAL_QSPI_TIMEOUT_DEFAULT_VALUE); // 片选拉高 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 0; }代码里我加了QSPI命令发送的封装实际项目中根据你用的HAL库或者标准库稍作调整。核心思想就是把读操作封装成从一个绝对地址读指定长度的字节数据上层不关心Flash类型。4.2 图片烧录流程从PC到Flash图片怎么烧录到Flash这是个让很多人卡壳的环节。我不会讲J-Link的Flash算法配置那个太麻烦这里分享一个更通用的方式通过串口或者USB CDC把bin文件传输到MCUMCU收到数据后写入Flash。在PC端把图片转成RGB565的bin文件然后用串口工具发送。MCU端预先烧录一个FlashWriter小程序接收数据并写入指定Flash地址。这样后续图片更新就不用改动主程序开发效率高很多。// FlashWriter核心逻辑 // 串口接收128字节就写入Flash一个扇区的缓存满了擦除扇区并写入 #define WRITE_BUF_SIZE 4096 static uint8_t write_buf[WRITE_BUF_SIZE]; static uint32_t write_index 0; static uint32_t flash_addr IMAGE_BASE_ADDR; void uart_rx_callback(uint8_t byte) { write_buf[write_index] byte; if (write_index WRITE_BUF_SIZE) { // 注意每次写之前要确保目标扇区已擦除 flash_erase_sector(flash_addr ~(FLASH_SECTOR_SIZE - 1)); flash_write(flash_addr, write_buf, WRITE_BUF_SIZE); flash_addr WRITE_BUF_SIZE; write_index 0; // 打印进度 printf(已写入: 0x%08X\n, flash_addr); } }这个FlashWriter程序只做一件事串口收到数据就写Flash实现简单可靠。实际做批量生产时可以用这个方式烧录图片不复用J-Link也不抬高生产成本。4.3 自定义LVGL decoder实现按需读取激动人心的部分来了。自定义decoder是LVGL高效加载外部图片的核心直接读取Flash指定偏移的像素行数据。#include lvgl.h #include flash_drv.h #define IMAGE_BASE_ADDR 0x0100000 // 图片存放的Flash起始地址,根据实际情况调整 #define IMAGE_WIDTH 800 #define IMAGE_HEIGHT 480 #define IMAGE_STRIDE (IMAGE_WIDTH * 2) // RGB565每像素2字节 static lv_img_dsc_t ext_img_dsc; // 打开图片时回调 static lv_res_t decoder_open_cb(lv_img_decoder_t *decoder, lv_img_decoder_dsc_t *dsc) { (void)decoder; // 检查请求的源是否是外部Flash的图片标记 if (dsc-src_type ! LV_IMG_SRC_VARIABLE) { return LV_RES_INV; } // 这里我们通过dsc-src传入自定义结构体指针来识别图片信息 // 简化起见直接使用全局图片描述 lv_img_cf_t cf LV_IMG_CF_TRUE_COLOR; dsc-header.always_zero 0; dsc-header.cf cf; dsc-header.w IMAGE_WIDTH; dsc-header.h IMAGE_HEIGHT; dsc-header.stride IMAGE_STRIDE; // 设置读取行的回调 dsc-img_data NULL; // 不直接提供数据指针 dsc-user_data (void *)IMAGE_BASE_ADDR; // 用user_data记录图片在Flash的偏移 return LV_RES_OK; } // 读取一行像素回调 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) { (void)decoder; (void)x; // 计算该行在Flash中的起始地址base y * stride uint32_t addr (uint32_t)dsc-user_data (uint32_t)y * IMAGE_STRIDE; // 整行读取 flash_read(addr, buf, len * 2); return LV_RES_OK; } // 关闭图片时回调 static void decoder_close_cb(lv_img_decoder_t *decoder, lv_img_decoder_dsc_t *dsc) { (void)decoder; (void)dsc; } // 注册decoder到LVGL void ext_flash_img_init(void) { 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); // 注册到全局解码器链表 lv_img_decoder_register(decoder); }这个decoder的巧妙之处在于dsc-user_data记录了图片在Flash的首地址read_line_cb每次按需读取一行。len是LVGL实际需要的像素数在部分裁剪场景下len会小于整行宽度这样可以节省读取量。x在当前实现里没用上如果做部分区域的缩放处理可以用它来做偏移计算。注册decoder之后使用时只需要创建一个lv_img控件把src指向一个自定义的lv_img_dsc_t即可。// 使用示例 void ui_create_img_usage(void) { ext_flash_img_init(); lv_obj_t *img lv_img_create(lv_scr_act()); lv_img_set_src(img, ext_img_dsc); lv_obj_set_pos(img, 0, 0); lv_obj_center(img); // 或者位置自定 }注意这里的ext_img_dsc需要填充正确告诉LVGL这是一张变量类型的图片但data字段可以留空decoder会接管实际的数据提供。lv_img_dsc_t ext_img_dsc { .header { .cf LV_IMG_CF_TRUE_COLOR, .w IMAGE_WIDTH, .h IMAGE_HEIGHT, .stride IMAGE_WIDTH * 2, }, .data NULL, // 关键不提供数据指针由decoder按需读取 .data_size IMAGE_WIDTH * IMAGE_HEIGHT * 2, };4.4 通过LVGL文件系统加载的备选方案如果业务需要更灵活的图片管理推荐实现LVGL文件系统抽象。在LVGL中文件系统驱动通过lv_fs_drv_t结构体注册底层读写还是复用Flash驱动。static lv_fs_drv_t fs_drv; // 打开文件 static void *fs_open(lv_fs_drv_t *drv, const char *path, lv_fs_mode_t mode) { (void)drv; // 将路径字符串转成Flash偏移地址 // 例如 F:/0.bin - IMAGE_BASE_ADDR 0 * IMAGE_BIN_SIZE uint32_t file_id simple_parse_file_index(path); uint32_t *file_handle malloc(sizeof(uint32_t)); *file_handle IMAGE_BASE_ADDR file_id * IMAGE_BIN_SIZE; return (void *)file_handle; } // 读取文件 static lv_fs_res_t fs_read(lv_fs_drv_t *drv, void *file_p, void *buf, uint32_t btr, uint32_t *br) { (void)drv; uint32_t file_offset *(uint32_t *)file_p current_pos; flash_read(file_offset, buf, btr); current_pos btr; *br btr; return LV_FS_RES_OK; } // 注册驱动 void lv_fs_flash_init(void) { lv_fs_drv_init(fs_drv); fs_drv.letter F; // 盘符为 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); }注册好后图片加载可以这样写lv_obj_t *img lv_img_create(lv_scr_act()); lv_img_set_src(img, F:/0.bin); // 直接使用文件路径LVGL会通过文件系统驱动读取这张bin图片。整体代码看起来更舒适逻辑也更接近PC端开发习惯。缺点是需要额外处理文件解析和偏移计算代码量比decoder方案多一些但管理大量图片时会更加从容。5. 常见问题与排查技巧实录5.1 图片花屏和数据错位花屏是目前遇到最多的反馈。RGB565图片花屏九成原因是字节序不对。LVGL默认是小端字节序即一个像素的十六位数据低字节在前而许多图像转换工具默认输出大端字节序这样就导致每一个像素的高低字节交换颜色就乱了。排查方法很简单取一张纯红色的图片转换后查看bin文件中前两字节。RGB565中纯红色是0xF800在小端模式下存储为0x00 0xF8。如果看到的是0xF8 0x00说明工具输出了大端字节序调整转换工具中的字节序选项即可。另一个容易忽略的是Flash读出来的数据需要对齐。假设你的Flash起始地址没有按照图片数据的边界对齐读取偏移差一两个字节整张图错位。所以我建议所有图片数据在Flash中的起始地址至少按照4字节对齐最好按扇区4096字节对齐既方便擦写也避免跨扇区读取时的性能损耗。5.2 图片加载慢或卡顿加载慢先区分是Flash读取慢还是LVGL解码慢。如果你使用的是普通SPI模式主频20MHz读取速度可能只有2MB/s左右一张750KB的图上屏差不多要375毫秒体感明显卡顿。解决方案是切换到四线QSPI模式速度能提到30MB/s以上或者开启Flash的Fast Read指令支持在命令序列中插入dummy cycle提高时钟频率。如果是解码慢大概率是因为你存了PNG或JPEG。read_line_cb每次只会读取一行但PNG解码需要整张图的数据才能解出压缩数据流。一旦使用压缩格式内存和CPU开销都会陡增。建议直接用RGB565裸数据零解码开销。另外检查LVGL的缓存设置合理设置LV_IMG_CACHE_DEF_SIZE可以减少重复图片的读取次数。对于频繁切换的同尺寸图片LVGL会自动缓存解码后的头信息省掉重复open的流程。5.3 内存分配失败导致图片不显示LVGL的lv_img_set_src调用失败lv_img控件没有显示多半是lv_mem分配失败。LV_MEM_SIZE设置太小的时候加载图片的临时分配就会失败。遇到这种情况定位方法是用LVGL自带的内存监控lv_mem_monitor_t mon; lv_mem_monitor(mon); printf(free_size%d, used_size%d, frag%d%%\n, (int)mon.free_size, (int)mon.used_size, (int)mon.frag_pct);如果free_size已经很小就调大LV_MEM_SIZE。我建议基于LV_MEM_SIZE在初始阶段专门给图片加载分一个较大的内存池避免其他控件把池子吃满。另外注意不要同时加载大量图片页面切换时及时删除旧图调用lv_obj_del释放控件的内存。5.4 常见问题速查表问题现象可能原因排查与解决方法花屏、颜色错乱字节序错误用纯色图片测试检查小端/大端配置图片不显示LVGL内存不足调大LV_MEM_SIZE监控free_size加载慢普通SPI模式未使用DMA切换到QSPI或四线Fast Read开启DMA图片整体偏移一行Flash地址未对齐图片起始地址按4字节或扇区对齐首屏图片撕裂半张读取和LCD刷新存在竞争图片读取使用DMA时等待DMA完成再刷屏更换图片后旧图残留未释放旧图缓存lv_img_cache_invalidate_src清除缓存6. 性能进阶与实战优化建议6.1 开启DMA和双缓冲图片加载的性能瓶颈在Flash读取而Flash读取完全可以用DMA来解放CPU。在flash_read中配置HAL_QSPI_Receive_DMA数据从Flash读到内存的耗时里CPU全程不参与这期间可以先让LVGL处理其他控件的布局或者将上一条DMA读到的数据直接写入显存。双缓冲的思路在这里同样适用。把需要显示的多行数据分成两个缓冲区先是DMA读入缓冲区A读的同时LCD控制器刷缓冲区B交替进行。说白了就是边读边刷实现在大图上屏时几乎无感切换。工程上这套代码需要放在LVGL的tick线程或者RTOS任务里管理注意用信号量同步DMA完成和刷新完成事件。6.2 图片切片与局部刷新有时候一张全屏图可能只需要显示局部区域比如地图上某个小窗口。如果整张图读取浪费时间也浪费内存。我的建议是预先把大图按照UI区域切成若干小图在Flash中连续存放每个小图有独立的头部记录宽高和偏移。UI上需要某块区域时只读取这个区域对应的小图消耗的Flash带宽和RAM都大幅降低。切片之后配合LVGL的lv_img位置设置可以把不同切片图摆到一个逻辑界面上视觉上像是一张大图。缺点是图片边界需要精确计算切片过多时管理稍微繁琐。但好处是内存占用从整张图降为若干小图中最坏的那几个。6.3 大图拆分存储与OTA升级配合产品上线之后不可避免要更新图片素材。如果图片都存外部FlashOTA只需要更新Flash中的图片区域。我的项目里是把Flash划分为三个区BootLoader区、固件区、素材区。素材区按图片ID固定偏移更新时FlashWriter只操作素材区不碰固件和BootLoader降低刷写风险。还要养成一个好习惯在Flash素材区最后保留一个校验区存放所有图片的CRC32校验值。开机时或者OTA完成后遍历读取所有图片数据计算CRC和预期值比对能及时发现图片写入错误或者Flash损坏。这个校验逻辑写起来不多但能在现场调试和返修时候省下大量排查时间。7. 写在最后的经验分享外部Flash加载图片的方案我实际做完一个完整项目后最大的体会是先定格式再写代码。图片格式决定了后续所有代码的实现难度RGB565裸数据看起来最土但实际用起来是最省心的没有解码库依赖没有内存压力Flash读取带宽也足够。刚开始别贪心用PNG或者带透明通道的ARGB8888尤其是新手阶段先把RGB565跑通再考虑颜色深度和压缩优化。还有一点一定要提醒所有的优化都要建立在能看到的量化数据上。打开LV_IMG_CACHE_DEF_SIZE、调大LV_MEM_SIZE、开启DMA这些改动前后用HAL_GetTick()记录图片从lv_img_set_src到实际上屏的时间每次都记录并对比。我第一次优化从750毫秒降到70毫秒就是通过一步步测量定位到是Flash读取模式的问题而不是内存或者解码。心里有数优化才不会走偏。如果你在调试时碰到了别的问题也可以试试先把数据源换成C数组跑通了再切换到外部Flash。逐层排查总能找到问题在哪一环。希望这篇实战解析能帮你减少一些弯路。