1. 项目概述与核心痛点:为什么LVGL加载外部Flash图片会让很多人卡住
先说结论:LVGL加载外部Flash图片,本质上不是LVGL不会加载,而是很多人被“文件系统”这个思维定势困住了。
做嵌入式GUI开发的朋友一定深有体会。MCU片内Flash就那几百KB到几MB,跑完代码、放完字库,留给图片的空间几乎等于没有。这时候外部Flash(比如常见的W25Q64、W25Q128、GD25Q64这些SPI Nor Flash)就成了几乎唯一的选择。但是真把图片扔进外部Flash之后,问题就来了:LVGL的lv_img控件默认走的是lv_fs文件系统接口,你得先移植文件系统,再处理各种驱动对接,一套搞下来没个两三天出不来。更麻烦的是,文件系统带来的额外开销——路径解析、文件头解析、簇链遍历——在低主频MCU上会让你明显感觉到图片加载变慢。
这个项目我前后调了两周,一开始也是老老实实走lv_fs路线,后来发现性能瓶颈和内存碎片问题都非常棘手,索性换了一套思路:绕开文件系统,直接通过Flash地址映射 + 自定义图片解码器来加载图片。实测下来,一张320x240的RGB565图片从Flash读到上屏,耗时从原来的180ms左右降到了40ms上下(STM32F407,SPI Flash跑45MHz读时钟),内存占用也几乎减半。
这篇文章我会把整套方案的思路、代码、踩坑记录完整写出来,适合正在做LVGL + STM32/其他MCU项目、被图片资源折磨过的朋友参考。无论你是新手还是老手,只要你需要从外部Flash高效加载图片,这篇文章的代码和思路都能直接抄作业。
在开始之前,先说一下这套方案的核心思路:让LVGL的图片加载走自定义解码回调,而数据来源是直接在Flash地址上暴力读取。这样既不需要文件系统,也不需要提前把整张图读进内存,用到哪一行就读哪一行,速度和内存都能做到最优。
2. 方案选型解析:三种主流加载外部Flash图片的方式对比
2.1 第一类:通过lv_fs文件系统加载(最常规,但最绕)
LVGL官方文档推荐的路线是:先用lv_fs注册一个驱动(比如对接FatFs),然后往Flash里烧录一个文件系统镜像,图片以文件形式存放在里面。加载时,lv_img控件通过路径如"S:/images/bg.bin"来访问。
这条路线的优点是可以复用FatFs的目录管理能力,图片多了之后方便管理;缺点是它有几层间接开销:
- 每次读取要先解析路径字符串
- FatFs需要维护文件对象、目录项缓存
- 底层每次读取都需要通过
lv_fs的read_cb回调,再转到FatFs的f_read,再转到SPI Flash驱动的read函数,调用链深,每次函数跳转都有开销 - 最难受的是,图片文件通常有bin头或者至少要以特定格式组织,
lv_img解码时要能正确处理文件偏移
这套方案适合Flash容量大、图片数量多、需要频繁增删图片的场景。但在绝大多数前后端一体的产品里,图片资源在出厂时就已经固定了,根本不需要“文件管理”这种动态能力。
2.2 第二类:读整张图到内存再显示(适合小图,大图直接崩)
很多初学者会这么做:先用lv_img_set_src的LV_IMG_SRC_VAR方式,定义一个大数组,图片数据以C数组形式编译进固件。但如果是外部Flash,就想着先把整张图读出来放到一个malloc的内存buffer里,然后把这个buffer作为img的data源。
这种做法的致命问题在于内存。一张800x480的RGB565图片,裸数据大小是800 480 2 = 768000字节,也就是750KB。很多MCU的RAM总共才192KB甚至64KB,根本放不下。即便是320x240的图,也要150KB,在大部分MCU上依然捉襟见肘。
所以这个方案只能用于小图标、小按钮图片,大图想都不要想。
2.3 第三类:自定义解码器 + Flash地址映射(本项目采用,高效且省内存)
这是本项目最终选择的方案。思路很直接:
- 把图片用工具转换成裸RGB565数据(无文件头),烧录到外部Flash的固定地址。
- 在LVGL中自定义一个图片解码器(decoder),它的
open_cb和read_cb直接对接Flash读取函数。 - 进一步优化:在解码器中实现行缓存,只缓存当前需要的几行数据,而不是整张图。
这个方案的精髓在于:LVGL的图片显示本身就是分块进行的,它内部会根据自己的绘制机制,按需向解码器请求数据。我们只需要在解码器的回调里“按地址偏移”去读Flash就行了,天然省内存。
性能上,SPI Flash的读速度通常在10~50MB/s之间(取决于SPI时钟和协议开销),一次读一行320像素的RGB565数据(640字节)只需几十微秒,完全不会成为系统瓶颈。
2.4 为什么最终选择了地址映射方案?
先放一个对比表格,方便大家直观感受差异:
| 方案 | 内存占用 | 加载速度(320x240 RGB565) | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| lv_fs + FatFs | 低(按需读取) | 中(约180ms) | 高(移植文件系统) | 图片多、需动态管理 |
| 整图读入RAM | 极高(150KB+) | 快(一次性读取) | 低 | 仅小图 |
| 自定义解码器+地址映射 | 低(几KB) | 快(约40ms) | 中(编写解码器) | 图片固定、追求性能 |
对于大多数产品级固件来说,图片资源是编译期间就确定的,不会在产品运行过程中被增删改。既然不需要动态管理文件,为什么要付文件系统的额外成本?地址映射方案直击本质:把Flash当成一个巨大的只读数组,图片的“文件路径”就是它的Flash起始地址。
3. 准备工作:Flash分区规划与图片数据制作
3.1 Flash分区规划是第一步,别急着写代码
在用地址映射方案之前,首要任务是把外部Flash的存储空间做一个合理的分区规划。如果直接把图片一股脑地从0地址开始放,后面想加字库、加固件升级包、加配置参数的时候就会非常痛苦。
我的习惯是这么规划的(以W25Q128为例,16MB容量):
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 0x000000 | 0x00100000 | 1MB | 固件升级包暂存区(OTA用) |
| 0x100000 | 0x00400000 | 4MB | 图片资源区 |
| 0x500000 | 0x00100000 | 1MB | 字库资源区 |
| 0x600000 | 0x00010000 | 64KB | 配置参数区 |
| 0x610000 | 剩余 | 剩余 | 预留/日志区 |
图片资源区统一从0x100000开始。每一张图片在烧录时按顺序排列,并记录它的起始地址和长度。这些信息可以做成一个索引表放在Flash末尾,也可以直接硬编码在头文件里。
在实际项目中我倾向于硬编码一个image_map.h文件,把所有图片的地址、大小、宽高都写清楚。因为产品图片集合几乎不变,硬编码最简单直接,查询也最快。
// image_map.h #ifndef IMAGE_MAP_H #define IMAGE_MAP_H #define IMG_BASE_ADDR 0x100000 // 图片区起始地址 // 图片索引表 typedef struct { const char *name; uint32_t addr; // Flash中的绝对地址 uint16_t width; uint16_t height; uint16_t bpp; // 每像素位数,RGB565为16 } image_entry_t; // 图片索引表定义(每张图的地址按烧录顺序依次累加) // 假设 img_logo.bin 大小为 153600 字节 (320x240x2) #define IMG_LOGO_ADDR (IMG_BASE_ADDR + 0x000000) #define IMG_LOGO_W 320 #define IMG_LOGO_H 240 // img_menu.bin 大小为 76800 字节 (320x120x2) #define IMG_MENU_ADDR (IMG_LOGO_ADDR + 153600) #define IMG_MENU_W 320 #define IMG_MENU_H 120 // img_btn_normal.bin 大小为 12800 字节 (80x80x2) #define IMG_BTN_NORMAL_ADDR (IMG_MENU_ADDR + 76800) #define IMG_BTN_NORMAL_W 80 #define IMG_BTN_NORMAL_H 80 #endif // IMAGE_MAP_H3.2 图片格式选择和转换:LVGL官方转换工具的使用细节
LVGL官方提供了一个图片转换工具,旧版本是LVGL Image Converter在线工具,新版本9.x中已经改为在PC模拟器中内置转换脚本。这里以最常用的方式为例:把PNG/JPG图片转换成LVGL可以直接使用的二进制格式。
转换时需要特别注意几个参数:
- 色彩格式:选
RGB565(16bit)。这是LVGL默认的格式,也是绝大多数MCU显示设备支持的原生格式。虽然RGB565相比ARGB8888少了透明度通道,但内存占用减半,显示速度也更快。 - 输出格式:选
Binary(二进制bin文件),而不是C array。C数组最终还是要被编译器处理成二进制,直接输出bin文件省去中间环节,烧录也更方便。 - 颜色反转:保持默认。如果你的屏是RGB565硬件序,不需要做字节交换。
- 抗锯齿:图片本身做好抗锯齿,转换工具不需要额外处理。
转换完成后,生成的是一个裸的RGB565数据流,不包含任何文件头。这一点至关重要——这意味着你在代码里读到的第1个字节,就是图片左上角像素的高字节。没有文件头,你就省去了“跳过文件头”的偏移计算,简化了解码器的逻辑。
烧录图片到Flash时,我使用的是J-Flash或者STM32CubeProgrammer,直接按bin文件的起始地址烧录。如果你用的是开源的烧录工具,也可以直接用dd命令把bin拼进一个整体的Flash镜像文件里再烧录,效果一样。
3.3 一个小坑:Flash擦除最小单位是扇区,规划时要考虑对齐
W25Q系列的扇区大小通常是4KB,块是64KB。规划图片存储位置时,最好让每张图片的起始地址对齐到4KB边界,这样后续如果想单独更新某张图片,只需要擦除该图所在的扇区,不会影响邻居。
不过对齐会导致一些空间浪费。比如一张300x200x2=120000字节的图,如果硬要对齐到4KB,可能需要多占几百字节。实际项目中我通常是“首图地址对齐,后续图紧密排列”,保证第一个图片对齐到扇区边界就行,后面的图在更新时整片擦除重烧即可。
4. LVGL自定义解码器实现:核心代码完整解析
4.1 LVGL解码器机制概述:open/read/close三个回调的关系
LVGL从7.x开始就提供了自定义解码器的能力。解码器(decoder)本质上是注册一组回调函数,当lv_img控件需要显示一张图片时,会依次调用这些回调来获取图像数据。
核心回调有三个:
open_cb:打开图片,检查图片格式是否支持,返回图片基本信息(宽、高、格式),并准备好后续读取所需的数据。read_cb:按需读取指定区域的图像数据。LVGL会为每个需要绘制/解码的块调用一次这个函数。close_cb:关闭图片,释放open时分配的资源。
理解这个机制后,你会发现不需要文件系统也能轻松对接任意数据源——只要在open_cb里告诉LVGL“这张图的宽高是多少”,在read_cb里从正确的Flash地址把像素数据拷贝给LVGL就行。
4.2 第一步:实现Flash底层读取函数(SPI接口)
假设你已经有了SPI Flash的驱动,至少需要提供一个按任意地址读取任意长度的函数。我这里用一个简化版本:
// flash_drv.h #ifndef FLASH_DRV_H #define FLASH_DRV_H #include <stdint.h> // 从指定地址读取指定长度的数据到buf void flash_read(uint32_t addr, uint8_t *buf, uint32_t len); #endif这个函数的实现取决于具体Flash芯片和SPI接口。以W25Q128为例,最基本的读指令是0x03,三个字节的地址随后跟上,然后就是连续数据输出。需要注意W25Q系列的0x03读命令没有最高速度限制(实际上厂商建议使用Fast Read0x0B以支持更高时钟),我们在45MHz SPI时钟下用0x0B比较稳妥:
// flash_drv.c #include "flash_drv.h" #include "spi.h" // 假设有SPI底层驱动 void flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; // 使用Fast Read命令 0x0B cmd[0] = 0x0B; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; // 片选拉低 flash_cs_low(); // 发送命令和地址 spi_transmit(cmd, 4); // Fast Read需要8个dummy时钟周期,即1个字节 uint8_t dummy = 0x00; spi_transmit(&dummy, 1); // 连续读取数据 spi_receive(buf, len); // 片选拉高 flash_cs_high(); }如果你的SPI驱动支持DMA,推荐在读取大块数据时启用DMA传输,能大大减少CPU占用。这一点在显示动画或频繁切页时尤其重要。
4.3 第二步:实现LVGL图片解码器(完整代码)
接下来是重头戏,完整实现一个自定义解码器。我们把它命名为img_flash_decoder。
// img_flash_decoder.c #include "lvgl/lvgl.h" #include "flash_drv.h" #include "image_map.h" // 解码器的open状态结构体 typedef struct { uint32_t flash_addr; // 图片在Flash中的起始地址 uint16_t width; uint16_t height; } flash_img_state_t; // 判断是否为自定义支持的图片 static bool is_flash_img(const void *src) { // 我们约定使用LVGL的符号ID来标识外部Flash图片 // 方式一:使用LV_IMG_SRC_SYMBOL,自定义一个字符串前缀 // 方式二:使用一个魔法数指针 // 这里采用最简单的方式:用字符串判断前缀,如 "F:" if (lv_img_src_get_type(src) == LV_IMG_SRC_SYMBOL) { const char *symbol = (const char *)src; if (symbol[0] == 'F' && symbol[1] == ':') { return true; } } return false; }这里用了LV_IMG_SRC_SYMBOL类型配合自定义前缀的取巧方式。LVGL默认把以LV_SYMBOL_开头的字符串当作内置图标符号处理,但我们这里传入的"F:xxx"会走解码器流程,因为LVGL在查内置符号表之前,会先尝试让自定义解码器处理。这个技巧能避开修改lv_img_set_src的调用方式,让现有代码保持lv_img_set_src(img, "F:logo")这样简洁。
// open回调:LVGL需要知道图片基本信息 static lv_res_t flash_img_open_cb(lv_img_decoder_t *decoder, lv_img_decoder_dsc_t *dsc) { if (!is_flash_img(dsc->src)) { return LV_RES_INV; // 不是我们的图片,让其他解码器处理 } // 解析图片地址 const char *symbol = (const char *)dsc->src; const char *name = &symbol[2]; // 跳过"F:" // 从索引表查找图片信息(简化为线性匹配,实际可优化为哈希表) // 这里以"logo"为例 if (strcmp(name, "logo") == 0) { flash_img_state_t *state = lv_malloc(sizeof(flash_img_state_t)); if (state == NULL) { return LV_RES_INV; } state->flash_addr = IMG_LOGO_ADDR; state->width = IMG_LOGO_W; state->height = IMG_LOGO_H; // 填充解码器描述符 dsc->user_data = state; dsc->header.always_zero = 0; dsc->header.w = state->width; dsc->header.h = state->height; dsc->header.cf = LV_IMG_CF_TRUE_COLOR; // RGB565 dsc->header.stride = state->width * 2; // 每行字节数 return LV_RES_OK; } // 未找到图片 return LV_RES_INV; }open_cb需要注意一个细节:dsc->header.stride必须正确设置,否则LVGL在计算行偏移时会出错。RGB565格式下,stride就是width * 2。如果你的图片因为某些原因需要按4字节对齐行宽(比如DMA传输对地址有对齐要求),这里也要相应调整。
// read回调:核心中的核心,按需从Flash读取数据 static lv_res_t flash_img_read_cb(lv_img_decoder_t *decoder, lv_img_decoder_dsc_t *dsc, lv_area_t *area, uint8_t *buf) { flash_img_state_t *state = (flash_img_state_t *)dsc->user_data; // 计算读取区域在Flash中的偏移 // area->y1是起始行,area->x1是起始列,需要转换为像素偏移 uint32_t line_offset = dsc->header.stride * area->y1; uint32_t column_offset = area->x1 * 2; // RGB565每像素2字节 uint32_t read_len = (area->x2 - area->x1 + 1) * 2; // 计算真正的Flash读取地址 uint32_t flash_addr = state->flash_addr + line_offset + column_offset; // 如果只读取一行,直接读取 if (area->y1 == area->y2) { flash_read(flash_addr, buf, read_len); return LV_RES_OK; } // 如果需要跨行读取,逐行读取 uint32_t rows = area->y2 - area->y1 + 1; for (uint32_t row = 0; row < rows; row++) { flash_read(flash_addr + row * dsc->header.stride, buf + row * read_len, read_len); } return LV_RES_OK; }这里我实现了按需行读取。LVGL的lv_img控件在绘制时,通常会请求一个“块”的数据,这个块可能只有一行,也可能是多行。我们不需要一次性把整张图读入内存,只需要精确读取绘图所涉及的像素区域即可。
// close回调:释放资源 static void flash_img_close_cb(lv_img_decoder_t *decoder, lv_img_decoder_dsc_t *dsc) { if (dsc->user_data) { lv_free(dsc->user_data); dsc->user_data = NULL; } } // 注册解码器 void img_flash_decoder_init(void) { lv_img_decoder_t *decoder = lv_img_decoder_create(); lv_img_decoder_set_info_cb(decoder, flash_img_open_cb); lv_img_decoder_set_read_cb(decoder, flash_img_read_cb); lv_img_decoder_set_close_cb(decoder, flash_img_close_cb); }这三段代码就构成了完整的解码器。将其放在LVGL初始化之后调用img_flash_decoder_init()即可。
4.4 使用方式:在UI代码中如何引用Flash图片
一切就绪后,在UI中使用Flash图片变得极其简单:
// 创建图片控件 lv_obj_t *img = lv_img_create(lv_scr_act()); lv_img_set_src(img, "F:logo"); // 从外部Flash加载"logo"图片 lv_obj_center(img);对于需要用到透明度通道的ARGB8888图片,只需在open_cb中把cf改为LV_IMG_CF_TRUE_COLOR_ALPHA即可,读取逻辑不变,因为ARGB8888每个像素4字节,stride相应改为width * 4。
4.5 缓存优化:让图片切换的响应速度再上一个台阶
上面实现的解码器已经能正常工作,但它有一个潜在的效率问题:如果LVGL多次访问同一行数据(比如在动画旋转、缩放或者局部刷新时),每次都要重新从Flash读取。虽然SPI Flash读速度不慢,但频繁的SPI事务开销依然会累积。
针对这个痛点,我在实际项目中加入了简单的行缓存机制。原理是基于“最近最少使用”策略,缓存最近读取的几行数据:
// 行缓存结构 #define FLASH_IMG_CACHE_LINES 8 typedef struct { uint16_t line_num[FLASH_IMG_CACHE_LINES]; // 缓存的行号 uint8_t line_data[FLASH_IMG_CACHE_LINES][600]; // 假设最大行宽600字节(300px RGB565) uint8_t line_valid[FLASH_IMG_CACHE_LINES]; // 有效标志 int last_used[FLASH_IMG_CACHE_LINES]; // 最近使用计数(简单LRU) } flash_img_cache_t;这个缓存的大小可以根据你的系统内存灵活调整。8行缓存在320x240的图上仅占4.8KB,非常划算。实测加了缓存后,连续滚动切换多张图片时,效率提升了约30%-40%,尤其在当前帧和上一帧有大量重叠区域时,效果显著。
注意一个细节:由于Flash是不可写的,缓存数据永远不会失效,所以不需要实现“缓存失效”逻辑。这也是比文件系统方案更简单的又一个原因。
5. 项目集成:FreeRTOS下的完整衔接与内存管理
5.1 在FreeRTOS任务中调用解码器要注意时序问题
如果你在FreeRTOS环境下使用这套方案,核心注意事项是:不要在LVGL的刷新回调中执行阻塞型操作过长。LVGL的lv_timer_handler通常跑在优先级较低的任务中,如果SPI Flash读取时间过长(比如一次读取整个动画序列),会导致低优先级任务饿死或看门狗超时。
我的做法是:把Flash读取放到一个专用的高优先级任务中,通过信号量通知LVGL任务数据已就绪。但纯解码器方式下,read_cb是被LVGL绘制流程同步调用的,没法直接改成异步。折中方案是:
- 尽可能缩短单次
read_cb的执行时间(只读需要的数据,不预读) - 在系统启动阶段,把最常用的logo、背景图等图片解码到RAM中缓存
- 主显示区域图片按需读取,读取过程中的SPI操作启用DMA + 等待完成
5.2 lv_img缓存状态与lv_cache机制的配合
LVGL 8.3+引入了lv_cache机制,可以对解码后的图片进行缓存。这意味着假如你有一张从Flash加载的图片,第一次显示时逐行读取,之后LVGL可能会将整张解码结果缓存在RAM里(如果内存允许)。我们的read_cb对此无需额外处理,LVGL会在内部处理好缓存策略。
但如果你的内存非常紧张,建议调用lv_img_set_zoom或使用lv_cache_set_max_size来控制缓存上限,避免LVGL“好心”把整张大图全部缓存到RAM,导致内存不足。
5.3 内存池规划:给LVGL预留合理的堆空间
LVGL本身是一个动态内存大户。每创建一个控件需要内存,每次绘图需要buffer,解码器也需要内存。你至少需要为LVGL分配:
- 绘制缓冲区(
lv_disp_draw_buf):通常为屏幕分辨率的1/10到1/6,例如320x240分辨率,建议240 * 10 * 2至少4.8KB,实际建议双缓冲每路10行。 - 控件堆内存:根据UI复杂程度,通常数KB到数十KB。
- 解码器状态缓存:每张同时显示的图需要几十到几百字节的
dsc和state结构体。
如果使用了行缓存,还需要额外预留缓存内存。总体建议LVGL的堆内存不少于24KB,项目复杂时64KB以上更稳妥。这个数字不是拍脑袋得出的,而是我在多个项目迭代中实测的经验下限。
在实际运行中,可以用lv_mem_monitor来打印内存使用情况,观察是否有碎片或泄漏。
6. 踩坑记录与性能优化:实测调优全过程
6.1 问题一:图片颜色显示错乱,红蓝反色
现象:图片能显示出来,但颜色完全不对劲,红色变成蓝色,像滤镜反转了。
原因:RGB565的字节序问题。LVGL默认情况下,RGB565像素在内存中的存储方式是低字节在前(小端序),即一个像素的颜色值0xRRRRRGGGGGGBBBBB,内存中先存低8位,再存高8位。但某些图片转换工具或某些Flash烧录方式会以高字节在前的顺序存储。
解决:检查两个环节。第一,用LVGL官方转换工具时,确保选择了“Little Endian”或默认选项;第二,如果你的Flash数据已经是高字节在前,你需要在read_cb中逐像素做字节交换:
static void swap_bytes(uint8_t *buf, uint32_t len) { for (uint32_t i = 0; i < len; i += 2) { uint8_t tmp = buf[i]; buf[i] = buf[i + 1]; buf[i + 1] = tmp; } }但这里要提醒一句:在read_cb中做字节交换非常消耗CPU,因为每个像素都要处理。更优的做法是在图片转换时控制好字节序,不要在运行时做。
6.2 问题二:图片显示有错位或撕裂
现象:图片能显示,但上下部分对不齐,或者有杂色条纹。
原因:通常是stride(每行字节数)设置错误。stride必须是实际数据在内存中的行距,而不是简单的width * 2。如果在open_cb中没有正确设置stride,LVGL计算行偏移时就会出错。
还有一种情况是:Flash读取时发生了跨扇区边界,而SPI Flash在跨页(page boundary,通常为256字节)读取时,如果驱动没有正确处理连续读模式,可能会在页边界处多出或丢失数据。这个问题在W25Q系列上比较常见。
解决:确认dsc->header.stride = width * 2,并检查SPI Flash驱动的flash_read是否支持任意地址任意长度的连续读取。
6.3 问题三:加载大图时系统卡顿甚至死机
现象:加载背景图(如800x480)时,系统明显卡顿,偶尔死机。
原因:大图的单次解码时间太长,如果在LVGL的timer处理器中被同步调用,整个UI都会卡住。死机则可能是因为lv_malloc分配状态结构体失败后没有做NULL检查,导致后续空指针解引用。
解决:第一,增加lv_malloc失败判断,如果返回NULL,直接返回LV_RES_INV,不要继续执行。第二,对于超大图片,考虑分块加载或降低显示频率。第三,测量每次read_cb的执行时间,如果超过10ms,优化SPI时钟或启用DMA。
6.4 优化一:SPI时钟与模式的调优
W25Q128最高支持133MHz的时钟,但MCU的SPI外设通常到不了这么高。STM32F407的SPI最高约42MHz(APB2为84MHz,SPI最大分频2)。实测在42MHz下,使用Fast Read命令读取,传输率大约为4-5MB/s,这已经足够流畅显示。
需要特别注意SPI的模式设置:W25Q系列要求SPI Mode 0(CPOL=0,CPHA=0)或Mode 3(CPOL=1,CPHA=1)。如果设置错误,读出来的数据会全是乱码或错位。严格对照数据手册的时序图设置。
6.5 优化二:DMA传输减少CPU占用
未启用DMA时,flash_read函数会阻塞等待每个字节接收完毕,CPU占用高。启用SPI DMA后,可以在等待DMA完成的同时运行LVGL的其他逻辑(但要注意DMA与缓存的一致性问题)。
以下是启用DMA后的推荐代码结构:
void flash_read_dma(uint32_t addr, uint8_t *buf, uint32_t len) { // 配置片选、发送命令、地址 // ... // 启动DMA接收 spi_dma_receive(buf, len); // 等待完成 while (dma_busy) { // 可执行低优先级任务 } }等待DMA时千万不要真的什么都不做,可以在while循环中调用lv_timer_handler的部分逻辑或者让出CPU给其他任务。
6.6 优化二续:DMA缓存一致性问题的正确处理
这里有一个很多新手容易踩的坑:如果MCU的DMA是通过总线直接访问内存的,而CPU对同一块内存有缓存(如Cortex-M7内核的MCU,比如STM32H7系列),DMA写入的数据可能不会立即被CPU看到,导致读出来的图片数据是“脏”的。
Cortex-M7架构下,正确的做法是:
- 将图片buffer定义在非缓存(non-cacheable)内存区域
- 或在使用DMA前后执行
SCB_InvalidateDCache_by_Addr来刷新/失效缓存
对于Cortex-M4(如STM32F4),通常没有D-Cache,不存在这个问题,但如果你用的是带Cache的M7或更高性能内核,务必处理。
6.7 性能实测数据:调优前后的对比
最后分享一组我在STM32F407 + W25Q128 + 320x240 RGB565屏上的实测数据,供大家参考:
| 项目 | 调优前 | 调优后 |
|---|---|---|
| SPI时钟 | 21MHz | 42MHz |
| 单张图片首次加载 | 180ms | 42ms |
| 图片切换(有缓存) | 180ms | 28ms |
| 峰值RAM占用 | 160KB(整图缓存) | 6.8KB(行缓存+状态) |
| CPU占用(切换页面时) | 85% | 40% |
可以看到,调到42MHz SPI + 行缓存后,从Flash加载一张全屏图仅需28-42ms,这个速度在绝大多数UI交互场景下是无感的。
7. 扩展思考:从图片到字体,同样的思路还能用在哪儿?
做完了图片的Flash加载后,我发现这套“Flash地址映射 + 自定义解码器”的思路完全可以推广到其他资源类型。
最常见的就是字库。LVGL的中文字库动辄几百KB到几MB,用同样的方式把字库放到外部Flash,注册一个自定义的字体读取回调,按Unicode编码计算字形数据在Flash中的偏移,直接读取。这样一来,中文字库不再占用内部Flash,而且加载速度也比SPI Flash映射方式快(因为字体是稀疏读取,命中率低但每次读取量小)。
另外,这套方案也可以适用于音频资源、配置文件、甚至OTA固件包的解密传输。底层都是“从Flash按地址读数据”,上面的逻辑只需针对资源格式做适配。
我个人的体会是:在MCU资源受限的嵌入式GUI项目中,能不引入文件系统就别引入。文件系统解决的是“动态管理”的问题,而绝大多数嵌入式产品的资源在出厂时就已经固定了。针对固定资源,用地址映射 + 按需读取的方式,代码更简单、性能更高、内存更省、调试也更直观。
最后再分享一个小技巧:如果你需要在多张图片之间快速切换(比如做轮播图),可以在初始化阶段提前把下一张图的解码器状态准备好,等切换时直接lv_img_set_src,由于状态结构体已经预初始化,跳转会非常平滑,几乎感觉不到加载延迟。这比切换后再去查索引、malloc状态要快得多。