Files
lucky-cat/docs/knowledge vault/littlefs-resource-build-flash-mount.md
T

23 KiB
Raw Blame History

LittleFS 资源构建、烧录与挂载流程

历史流程:当前固件已将 LCD 图片和提示音嵌入 OTA 应用镜像。下文的“当前”、命令和故障排查仅针对旧版 gifpack LittleFS,不适用于现行工程;请使用 应用内嵌素材使用与添加指南。

本文说明项目中的 LCD 图片和提示音资源如何从源码目录进入设备 Flash,以及设备运行时如何通过 LittleFS 访问这些资源。同时给出新增、替换和排查资源问题的完整操作步骤。

1. 先建立整体认识

项目不会把每一张图片分别写入 Flash,也不会在设备启动时从电脑复制资源。

实际流程是:构建系统先将需要的资源整理成一个临时目录,再将整个目录制作成 LittleFS 文件系统镜像 gifpack.bin,最后把这个镜像作为一个独立分区烧录到设备。设备启动后,将该分区挂载到 /littlefs,应用程序便可以像读取普通文件一样读取图片和音频。

源码资源
components/platform/assets/
        |
        | CMake 选择并复制资源
        v
构建临时目录
build/esp-idf/platform/asset_image/
        |
        | 制作 LittleFS 文件系统镜像
        v
build/gifpack.bin
        |
        | idf.py flash
        v
设备 Flash 中的 gifpack 分区
        |
        | esp_vfs_littlefs_register()
        v
设备文件路径 /littlefs/

需要区分以下三个概念:

名称 所在位置 作用
源码资源目录 components/platform/assets/ Git 管理的原始待烧录资源
构建临时目录 build/esp-idf/platform/asset_image/ 生成镜像前整理出来的目录树
LittleFS 镜像 build/gifpack.bin 最终烧入设备 gifpack 分区的文件系统镜像

临时目录只存在于开发电脑上。设备真正挂载的是 Flash 中的 gifpack.bin 内容,不是电脑上的 build 目录。

2. 相关文件在哪里

文件或目录 作用
components/platform/assets/lcd/ LCD 的 .rle 图片资源
components/platform/assets/audio_prompts/ .opus 提示音资源
components/platform/CMakeLists.txt 选择资源、整理临时目录、生成 LittleFS 镜像
components/platform/idf_component.yml 声明 joltwallet/littlefs 组件依赖
managed_components/joltwallet__littlefs/project_include.cmake 定义 littlefs_create_partition_image()
16m.csv 定义 gifpack 分区的类型、大小和位置
components/platform/src/platform_behavior_stubs.c 运行时挂载 gifpack 分区
components/platform/src/lcd_direct_assets.c 将 LCD 资源名映射到 /littlefs/lcd/*.rle
components/platform/src/lcd_direct.c 打开、读取、解码并绘制 RLE 文件
components/domain/src/runtime/domain_runtime_adapters.c 将业务表达式映射为 LCD 资源播放请求

3. CMake 如何选中资源

3.1 定义资源目录

components/platform/CMakeLists.txt 中定义:

set(platform_audio_prompt_src_dir "${CMAKE_CURRENT_SOURCE_DIR}/assets/audio_prompts")
set(platform_lcd_asset_src_dir "${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd")
set(platform_asset_image_dir "${CMAKE_CURRENT_BINARY_DIR}/asset_image")

在当前组件中,这些变量最终大致对应:

platform_audio_prompt_src_dir
    -> components/platform/assets/audio_prompts

platform_lcd_asset_src_dir
    -> components/platform/assets/lcd

platform_asset_image_dir
    -> build/esp-idf/platform/asset_image

3.2 LCD 使用显式白名单

LCD 资源由 platform_lcd_flash_assets 明确列出:

set(platform_lcd_flash_assets
    buyunqian1
    jichubiaoqing0
    jichubiaoqing1
    jichubiaoqing2
    muyu0
    muyu1
    muyu2
    # ...
)

列表中不写 .rle 后缀。后续循环会自动拼出完整文件名:

"${platform_lcd_asset_src_dir}/${lcd_rle_asset}.rle"

例如白名单中的:

jichubiaoqing0

最终对应:

components/platform/assets/lcd/jichubiaoqing0.rle

因此,把新 .rle 文件放进 assets/lcd/ 并不代表它会自动进入 Flash。只有加入 platform_lcd_flash_assets 的 LCD 文件才会进入 gifpack.bin。

3.3 音频使用自动扫描

音频资源采用另一种策略:

file(GLOB_RECURSE platform_audio_prompt_files CONFIGURE_DEPENDS
    "${platform_audio_prompt_src_dir}/*.opus"
)

这会递归选中 assets/audio_prompts/ 下的全部 .opus 文件。因此:

  • 新增 LCD 文件时,需要手动加入 CMake 白名单;
  • 新增 .opus 文件时,一般不需要修改资源白名单。

4. CMake 如何形成临时目录

构建时,CMake 会先清理旧的临时目录:

COMMAND ${CMAKE_COMMAND} -E rm -rf "${platform_asset_image_dir}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${platform_asset_image_dir}"

这样可以避免已经从白名单删除的旧资源继续残留在镜像中。

随后,LCD 文件被复制到临时目录的 lcd/ 子目录:

COMMAND ${CMAKE_COMMAND} -E copy_if_different
    "${platform_lcd_asset_src_dir}/${lcd_rle_asset}.rle"
    "${platform_asset_image_dir}/lcd/${lcd_rle_asset}.rle"

音频文件被复制到 audio_prompts/ 子目录。完成后,临时目录大致如下:

build/esp-idf/platform/asset_image/
├── audio_prompts/
│   ├── boot.opus
│   ├── network_connect_success.opus
│   └── ...
└── lcd/
    ├── jichubiaoqing0.rle
    ├── jichubiaoqing1.rle
    ├── muyu0.rle
    └── ...

这个目录结构会原样成为 LittleFS 镜像内部的目录结构。

资源文件还被加入 DEPENDS:

DEPENDS
    ${platform_audio_prompt_files}
    ${platform_lcd_flash_asset_files}

因此,白名单内的资源发生变化时,构建系统能够重新整理目录并生成镜像。

5. littlefs_create_partition_image() 从哪里来

项目在 components/platform/idf_component.yml 中声明:

dependencies:
  joltwallet/littlefs: 1.20.4

ESP-IDF Component Manager 因此引入:

managed_components/joltwallet__littlefs/

这个组件包含特殊文件:

managed_components/joltwallet__littlefs/project_include.cmake

ESP-IDF 构建系统会自动加载各组件的 project_include.cmake。该文件定义了:

function(littlefs_create_partition_image partition base_dir)
    # ...
endfunction()

所以 littlefs_create_partition_image():

  • 不是标准 CMake 内置函数;
  • 也不是本项目自行定义的普通函数;
  • 它是 joltwallet/littlefs 组件提供并由 ESP-IDF 自动导入的构建扩展。

6. 如何生成 gifpack.bin

项目调用:

littlefs_create_partition_image(
    gifpack
    "${platform_asset_image_dir}"
    FLASH_IN_PROJECT
    DEPENDS "${platform_asset_stage_stamp}"
)

参数含义如下:

参数 含义
gifpack 要匹配的分区名称
platform_asset_image_dir 要制作成文件系统镜像的目录
FLASH_IN_PROJECT 将镜像加入项目的完整 flash 目标
DEPENDS 确保资源整理完成后再生成镜像

该函数会根据分区名查询分区表:

partition_table_get_partition_info(size "--partition-name ${partition}" "size")
partition_table_get_partition_info(offset "--partition-name ${partition}" "offset")

随后调用 littlefs-python,大致执行:

littlefs-python create \
    build/esp-idf/platform/asset_image \
    build/gifpack.bin \
    --fs-size=<gifpack分区大小> \
    --block-size=4096

得到:

build/gifpack.bin

gifpack.bin 不是简单压缩包,而是一个包含目录、文件名、文件内容和文件系统元数据的完整 LittleFS 镜像。

7. 分区表如何决定烧录位置

项目通过 sdkconfig.defaults 选择自定义分区表:

CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="16m.csv"

16m.csv 中定义:

gifpack, data, littlefs, , 0xa80000,

字段含义:

字段 当前值 含义
Name gifpack 分区标签,构建和挂载都依赖它
Type data 数据分区
SubType littlefs LittleFS 文件系统分区
Offset 空 由 ESP-IDF 根据前面的分区自动计算
Size 0xa80000 分区大小,约 10.5 MiB

当前生成结果中,gifpack 的起始地址是 0x10000。由于分区 Offset 没有写死,如果前面的分区布局发生变化,实际地址也可能变化;应以当前构建生成的分区表和 build/flasher_args.json 为准。

下面三处名称必须一致:

CMake:
littlefs_create_partition_image(gifpack ...)

分区表:
gifpack, data, littlefs, ...

运行时:
.partition_label = "gifpack"

任何一处名称不一致,都可能导致镜像生成失败、烧错位置或运行时找不到分区。

8. FLASH_IN_PROJECT 如何让镜像随项目烧录

FLASH_IN_PROJECT 会让 LittleFS 组件执行类似以下注册:

esptool_py_flash_target_image(
    flash
    "${partition}"
    "${offset}"
    "${image_file}"
)

它的含义是:把 gifpack.bin 加入 ESP-IDF 的完整 flash 目标。

当前 build/flasher_args.json 中可看到类似内容:

"0x10000": "gifpack.bin"

所以执行:

idf.py flash

时,ESP-IDF 会分别烧录 bootloader、分区表、应用固件和 gifpack.bin。gifpack.bin 并没有编入应用程序 .bin,它始终是一个独立的分区镜像。

需要特别区分:

命令 对资源分区的影响
idf.py build 生成或更新 gifpack.bin,不写设备
idf.py flash 按完整烧录清单写入 gifpack.bin
idf.py app-flash 通常只更新应用分区,不更新 gifpack
普通应用 OTA 通常只更新 OTA 应用分区,不更新 gifpack

因此,修改资源后只执行 app-flash 或普通应用 OTA,设备中的资源可能仍然是旧版本。

9. 设备运行时如何挂载

挂载函数位于 components/platform/src/platform_behavior_stubs.c:

#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs"
#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack"

esp_vfs_littlefs_conf_t conf = {
    .base_path = VOICE_PROMPT_LITTLEFS_BASE_PATH,
    .partition_label = VOICE_PROMPT_LITTLEFS_PARTITION,
    .partition = NULL,
    .format_if_mount_failed = false,
    .dont_mount = false,
};

esp_err_t err = esp_vfs_littlefs_register(&conf);

9.1 partition_label = "gifpack"

LittleFS 驱动根据标签在运行时分区表中查找 gifpack,得到该分区的实际 Flash 地址和大小。

9.2 partition = NULL

调用方没有直接传入 esp_partition_t,由 LittleFS 驱动根据 partition_label 自动查找分区。

9.3 base_path = "/littlefs"

将 LittleFS 注册到 ESP-IDF VFS 的 /littlefs 路径。挂载成功后,可以使用标准文件 API:

open("/littlefs/lcd/jichubiaoqing0.rle", O_RDONLY);
fopen("/littlefs/audio_prompts/boot.opus", "rb");

9.4 format_if_mount_failed = false

挂载失败时不格式化分区。这对预置资源分区非常重要,因为格式化会清空已经烧录的图片和音频。

9.5 dont_mount = false

注册后立即执行挂载,而不是只注册驱动。

9.6 VFS 路径如何对应到 LittleFS 内部路径

临时目录中的:

asset_image/lcd/jichubiaoqing0.rle

进入 LittleFS 后对应:

/lcd/jichubiaoqing0.rle

挂载到 /littlefs 后,应用程序看到:

/littlefs/lcd/jichubiaoqing0.rle

应用调用 open() 时,ESP-IDF VFS 识别 /littlefs 前缀,并将剩余的 /lcd/jichubiaoqing0.rle 交给 LittleFS 驱动读取。

10. 当前启动时机和隐式依赖

当前 platform_mount_littlefs() 不是由 platform_init() 直接调用,而是在 voice_prompt_play_file() 播放提示音之前调用:

esp_err_t mount_err = platform_mount_littlefs();

当前启动配置启用了 boot.opus,启动顺序大致是:

准备运行时存储
    -> 初始化音频
        -> 播放 boot.opus
            -> platform_mount_littlefs()
                -> 挂载 gifpack 到 /littlefs
    -> 初始化 LCD
        -> LCD 渲染线程读取 /littlefs/lcd/*.rle

这意味着当前 LCD 成功读取资源,隐式依赖“开机音流程已经触发 LittleFS 挂载”。如果以后禁用开机音,或者在调用 voice_prompt_play_file() 之前就失败,LCD 可能在尚未挂载时访问 /littlefs/lcd/。

更稳妥的长期方案是把 platform_mount_littlefs() 作为独立启动阶段,在音频和 LCD 使用资源之前显式调用。修改启动流程前,应保持现有分层规则,并由 app_composition 编排启动顺序。

11. LCD RLE 文件格式

当前 lcd_direct 驱动读取的 .rle 是原始二元组序列:

typedef struct {
    uint16_t color;
    uint16_t count;
} lcd_direct_rle_run_t;

每个 run 占 4 字节:

2 字节 RGB565 color,little-endian
2 字节重复像素数 count,little-endian

全屏图片解压后的像素总数应与屏幕绘制区域匹配。当前横屏画布通常为:

320 x 240 = 76800 像素

图标资源则应与 lcd_direct_icon_t 中配置的宽高匹配。

11.1 run_count 必须与文件一致

components/platform/src/lcd_direct_assets.c 不仅保存文件路径,还保存 run_count:

{
    .path = LCD_ASSET_BASE_PATH "jichubiaoqing0.rle",
    .run_count = 5411U,
},

对于当前原始格式:

run_count = 文件字节数 / 4

替换资源后,即使文件名没有变化,只要编码后的 run 数发生变化,就必须同步更新 run_count。否则驱动可能少读、读到文件末尾失败或显示异常。

可以用下面的方法检查:

python3 -c 'from pathlib import Path; p=Path("components/platform/assets/lcd/jichubiaoqing0.rle"); print(p.stat().st_size // 4)'

还应确认文件大小能被 4 整除。

11.2 不要误用 tools/png_to_rle.py

当前仓库的 tools/png_to_rle.py 使用 Pillow 生成标准 TGA RLE 文件;而 lcd_direct.c 读取的是上面描述的原始 color + count 二元组格式。

两种格式不兼容。不要直接用 tools/png_to_rle.py 的输出替换 components/platform/assets/lcd/*.rle,除非先统一转换工具和驱动格式。

tools/extract_lcd_assets.py 能写出当前原始二元组格式,但其中包含旧工程的固定 Windows 路径和固定资源映射,使用前必须检查和调整,不能假设它在所有开发环境中可直接运行。

12. 替换现有 LCD 资源

适用场景:资源名、帧数和业务行为不变,只替换画面内容。

以替换 jichubiaoqing0.rle 为例:

  1. 使用与当前驱动兼容的原始 RLE 转换流程生成新文件。

  2. 确认解压像素总数与目标画布一致。

  3. 用新文件替换:

    components/platform/assets/lcd/jichubiaoqing0.rle
    
  4. 计算新文件的 run_count。

  5. 更新 components/platform/src/lcd_direct_assets.c 中对应条目的 run_count。

  6. 因为文件名没有变化且已经在 CMake 白名单中,一般不需要修改 platform_lcd_flash_assets。

  7. 执行完整构建和烧录:

    eim run "idf.py build" v5.5.4
    eim run "idf.py flash" v5.5.4
    
  8. 在串口日志中确认 LittleFS 挂载成功,并检查 LCD 是否有 open 或 read 失败日志。

如果当前环境没有 eim,按照项目 AGENTS.md 中规定的 ESP-IDF v5.5.4 激活流程执行相同命令。

13. 给已有动画新增一帧

适用场景:例如已有 muyu0.rle、muyu1.rle、muyu2.rle,现在增加 muyu3.rle。

第一步:添加文件

放到:

components/platform/assets/lcd/muyu3.rle

第二步:加入 CMake 白名单

在 components/platform/CMakeLists.txt 的 platform_lcd_flash_assets 中加入:

muyu3

否则文件不会进入 gifpack.bin。

第三步:加入平台资源映射

在 components/platform/src/lcd_direct_assets.c 的对应数组中增加:

{
    .path = LCD_ASSET_BASE_PATH "muyu3.rle",
    .run_count = <实际 run 数>,
},

平台通过数组下标解释 frame_index,所以新条目在数组中的位置就是它的帧索引。

第四步:确认业务播放顺序

如果希望业务流程实际播放新帧,还需要检查 components/domain/src/runtime/domain_runtime_adapters.c 中的帧顺序,例如:

static const uint8_t s_muyu_frame_order[] = {0, 1, 2, 3, 2, 1};

只增加平台资源映射但不修改播放顺序,新帧可能已经烧入设备,却永远不会被请求。

第五步:完整构建并烧录

eim run "idf.py build" v5.5.4
eim run "idf.py flash" v5.5.4

14. 新增一组 LCD 资源

适用场景:增加一个新的动画组,例如:

happy0.rle
happy1.rle
happy2.rle

需要同时完成以下工作:

  1. 将所有文件放入 components/platform/assets/lcd/。
  2. 将 happy0、happy1、happy2 加入 platform_lcd_flash_assets。
  3. 在 components/platform/src/lcd_direct_assets.c 中建立资源数组,填写每帧路径和 run_count。
  4. 在 lcd_direct_asset_frame() 中增加资源名到数组的查找映射,使 asset_dir = "happy" 能找到对应帧。
  5. 如果这是新的业务表达式,按照 docs/lcd-expressions.md 的分层要求:
    • 在 domain 公共契约中增加语义表达式 ID;
    • 在 domain_runtime_adapters.c 中映射到 asset_dir = "happy";
    • 由 control_plane 决定何时请求该表达式;
    • 不要让 control_plane 直接调用 platform_lcd_*。
  6. 构建并完整烧录。
  7. 在硬件上验证帧序、帧间隔、中断优先级和播放结束后的返回行为。

这里涉及两个彼此独立的问题:

资源是否进入 Flash
    -> 由 CMake 白名单决定

资源是否会被业务播放
    -> 由 platform 资源映射和 domain 表达式映射决定

文件成功烧入 Flash,不代表业务代码一定能找到或播放它。

15. 新增或替换音频资源

15.1 替换已有音频

如果文件名和调用路径不变:

  1. 替换 components/platform/assets/audio_prompts/ 下对应的 .opus 文件。
  2. 执行 idf.py build 重新生成 gifpack.bin。
  3. 执行完整 idf.py flash 更新资源分区。

一般不需要修改 CMake,因为 .opus 文件会被自动扫描。

15.2 新增音频

  1. 将 .opus 文件加入 components/platform/assets/audio_prompts/。

  2. 确认播放代码引用的路径正确:

    /littlefs/audio_prompts/<文件名>.opus
    
  3. 如果传给 voice_prompt_play_file() 的是相对文件名,它会自动补上 /littlefs/audio_prompts/。

  4. 完整构建并烧录。

16. 推荐验证流程

资源修改后,建议按以下顺序验证。

16.1 检查源码资源

确认:

  • 文件名和大小写正确;
  • LCD 文件已加入白名单;
  • LCD run_count 与文件一致;
  • 资源映射路径与文件名一致。

16.2 构建镜像

eim run "idf.py build" v5.5.4

检查是否生成:

build/gifpack.bin

也可检查临时目录中是否存在目标文件:

build/esp-idf/platform/asset_image/lcd/<资源名>.rle
build/esp-idf/platform/asset_image/audio_prompts/<资源名>.opus

16.3 检查烧录清单

检查 build/flasher_args.json 中是否包含:

"gifpack": {
    "offset": "...",
    "file": "gifpack.bin"
}

不要把 0x10000 永久当作固定值,应以当前分区表生成结果为准。

16.4 完整烧录

eim run "idf.py flash" v5.5.4

不要只执行 app-flash。

16.5 检查运行日志

挂载成功时应出现类似日志:

LittleFS mounted at /littlefs, used=... total=...

LCD 资源异常时重点关注:

open LCD asset failed
read LCD asset failed
LCD presentation draw failed

音频异常时重点关注:

Mount LittleFS failed
Open prompt failed

17. 常见问题排查

17.1 源码目录里有文件,但设备提示找不到

按顺序检查:

  1. LCD 文件是否加入 platform_lcd_flash_assets;
  2. 是否重新执行了 idf.py build;
  3. 是否执行了完整 idf.py flash;
  4. lcd_direct_assets.c 中路径是否完全一致;
  5. /littlefs 是否已经挂载。

17.2 替换图片后还是显示旧图

最常见原因是只执行了 app-flash 或 OTA,只更新了应用分区,没有更新 gifpack。

重新执行完整烧录:

eim run "idf.py flash" v5.5.4

17.3 能打开文件,但读取失败

检查:

  • run_count 是否与新文件一致;
  • 文件大小是否能被 4 整除;
  • 文件是否为当前驱动要求的原始二元组 RLE;
  • 是否错误使用了 TGA RLE 文件;
  • RLE 解压后的像素数量是否匹配目标画布。

17.4 构建时报分区空间不足

说明临时目录中的总资源超过 gifpack 容量。处理方式包括:

  • 删除未使用资源并从白名单移除;
  • 优化资源编码;
  • 在确认整个 Flash 布局允许后调整 16m.csv;
  • 调整分区表时同时检查 OTA、模型和日志分区是否仍有足够空间。

不要只增大 gifpack 而忽略后续分区的地址和总 Flash 容量。

17.5 挂载失败

检查:

  1. 是否已经完整烧录 gifpack.bin;
  2. CMake、分区表、运行时使用的分区名是否都是 gifpack;
  3. 分区表是否和当前固件一起烧录;
  4. 镜像大小是否与当前分区表一致;
  5. 是否使用旧构建目录生成的镜像;
  6. 必要时执行 idf.py fullclean 后重新构建和完整烧录。

由于资源分区设置了 format_if_mount_failed = false,挂载失败不会自动格式化,这可以避免资源被意外清空。

18. 修改资源时的最终检查清单

只替换现有 LCD 文件

  • 替换 components/platform/assets/lcd/<name>.rle
  • 确认文件格式为原始 uint16_t color + uint16_t count
  • 更新 lcd_direct_assets.c 中的 run_count
  • 执行 idf.py build
  • 执行完整 idf.py flash
  • 检查挂载和 LCD 读取日志

新增 LCD 帧或资源组

  • 添加 .rle 文件
  • 加入 platform_lcd_flash_assets
  • 在 lcd_direct_assets.c 中增加路径和 run_count
  • 在 lcd_direct_asset_frame() 中增加或确认名称映射
  • 在 domain 层配置播放顺序或表达式映射
  • 执行完整构建和烧录
  • 在硬件上验证实际播放

替换或新增音频

  • 添加或替换 .opus 文件
  • 确认调用路径正确
  • 执行 idf.py build
  • 执行完整 idf.py flash
  • 检查提示音打开和播放日志

19. 一句话总结

资源交付链路可以概括为:

源码资源
    -> CMake 选择并整理目录
    -> LittleFS 组件生成 gifpack.bin
    -> ESP-IDF 根据分区表烧入 gifpack 分区
    -> 设备把 gifpack 挂载为 /littlefs
    -> LCD 和音频通过标准文件 API 读取资源

新增资源时要同时确认“是否进入 gifpack.bin”和“运行时代码是否会找到并使用它”;替换资源时则要特别注意更新 LCD run_count,并执行完整 idf.py flash 更新独立的资源分区。