本节目标:理解 VFS 抽象层与 SPIFFS 的关系,会挂载 SPIFFS 并用 C 标准库读写文件,会把文件夹打包进 flash 分区
一、本节要解决的问题
上一节的 NVS 适合存"键值"式的小配置,但遇到下面这些需求就力不从心了:
- 要把一个网页(html)、一段长文本、一张小图片存到 flash 里;
- 想按"文件名"组织管理这些内容,像在电脑上一样用
fopen、fread读写; - 文件数量、大小不固定,不想每加一个文件就改一次地址。
这就需要一个文件系统。ESP32 上常用的嵌入式文件系统是SPIFFS(面向 SPI NOR flash,支持磨损均衡)。而 ESP-IDF 里所有文件系统都通过VFS(虚拟文件系统)这层抽象接入 C 标准库——注册好之后,fopen("/spiffs/xxx")就能直接打开 SPIFFS 里的文件。本节先讲清 VFS 这层"翻译官"的作用,再把 spiffs 例程跑起来,最后介绍如何把电脑上的文件夹打包进 flash 分区一起烧录。
二、相关知识
2.1 文件系统是什么
文件系统是操作系统中用于组织、管理和存储持久化数据的组件,由三部分组成:
- 文件系统的接口:应用程序与文件系统交互的方式(如 fopen、fwrite);
- 对象操纵和管理的软件集合:实现创建、删除、读写、重命名等操作;
- 对象及属性:实际存储的数据文件以及相关的元数据信息。
有了它,调用fwrite(buffer, size, count, file)时完全不用关心写到哪个地址、偏移多少,只要说明"往哪个文件写什么内容"即可。
2.2 VFS:让 C 库函数"认识"各种文件系统
ESP32 上可能有多种文件系统(SPIFFS、FATFS……)甚至 SD 卡,VFS 为驱动程序提供统一接口,把 C 库函数和具体的文件系统驱动连接起来:
- 每个 FS 驱动注册时绑定一个路径前缀(挂载点),例如
/fat; - 调用
fopen("/fat/file.txt")时,VFS 按最长前缀匹配找到对应驱动,把调用转给它; - 驱动收到的是去掉前缀的相对路径:VFS 调用 FAT 驱动的 open,参数是
/file.txt,后续的 read、write、close 也全部转给这个驱动。
挂载点必须以/开头。存在嵌套挂载时按最长前缀匹配:打开/data/static/index.html只会找挂在/data/static的驱动,即使它里面没有这个文件,也不会退回/data去找。ESP-IDF 已经把"SPIFFS + VFS 注册"封装好了,我们不需要自己定义esp_vfs_t结构体、调用esp_vfs_register(),直接用esp_vfs_spiffs_register()就能一步完成挂载。
2.3 SPIFFS 的特点与限制
- 专为 SPI NOR flash 设计,支持磨损均衡和文件系统一致性检查;
- 使用前要在分区表里划一块
Type=data、SubType=spiffs的分区; - 限制(先了解,踩坑时回来查):
- 不支持目录,只有扁平结构。挂载在
/spiffs下时,/spiffs/tmp/myfile.txt在 SPIFFS 内部实际叫/tmp/myfile.txt; - 不是实时文件系统,每次写操作耗时不等;空间不足触发垃圾回收时,一次写可能要好几秒;
- 只能稳定使用约75%的指定分区容量;
- 文件系统操作期间断电可能导致损坏,可用
esp_spiffs_check()修复。
- 不支持目录,只有扁平结构。挂载在
三、知识点
3.1 挂载配置 esp_vfs_spiffs_conf_t
| 字段 | 含义 |
|---|---|
| base_path | 挂载点(路径前缀),如 “/spiffs”,之后用fopen("/spiffs/...")访问文件 |
| partition_label | 指定要挂载的 spiffs 分区名;填 NULL 表示自动使用分区表中第一个 spiffs 类型的分区 |
| max_files | 最大可同时打开的文件数 |
| format_if_mount_failed | 挂载失败时是否自动格式化;开发阶段填 true 方便,正式产品要谨慎(格式化会清空数据) |
3.2 一个函数完成三件事
esp_vfs_spiffs_register(&conf)内部依次做:
- 初始化 SPIFFS 文件系统(必要时格式化);
- 把 SPIFFS 的读写等操作注册到 VFS;
- 把文件系统挂载到
base_path指定的挂载点。
挂载成功后,就可以用fopen/fprintf/fgets/rename/unlink/stat这些标准 C 库函数操作文件了——VFS 负责把它们转发给 SPIFFS 驱动。
3.3 文件系统维护三件套
| API | 用途 |
|---|---|
esp_spiffs_check(label) | 检查并修复文件系统(清理未引用页面等);官方推荐在esp_spiffs_info返回 used 大于 total 或出现一致性错误码时调用 |
esp_spiffs_info(label, &total, &used) | 获取分区总容量与已用字节数 |
esp_spiffs_format(label) | 格式化分区(数据全部丢失) |
3.4 把文件夹打包进分区:spiffs_create_partition_image
还有一个问题:怎么把电脑上的文件放进 flash 里的 spiffs 分区?总不能每次手工写入。在组件的 CMakeLists.txt 里加一行即可,仓库中的web_monitor工程正是用这种方式把网页打包进 flash 的:
idf_component_register(SRCS "ws.c" "softap.c" "dht11.c" "main.c" "led_ws2812.c" INCLUDE_DIRS ".") spiffs_create_partition_image(html ../html FLASH_IN_PROJECT)三个参数的含义:
html:分区名,必须与分区表 CSV 里的 Name 一致。web_monitor/partitions_webserver.csv中对应的行是:
html, data, spiffs, , 0x4000,即 16KB 的 spiffs 分区;
../html:要打包的目录(相对当前 CMakeLists.txt),里面只有一个esp.html(约 4KB,能放进 16KB 分区);FLASH_IN_PROJECT:让生成的镜像跟随idf.py flash一起烧录。不加这个选项时镜像照样会生成(构建目录下的html.bin),但需要用idf.py html-flash单独烧,或手工写到分区的偏移地址。
镜像按 SPIFFS 格式生成并烧进分区后,程序就能通过/spiffs/esp.html这样的路径打开它——web_monitor/main/main.c里正是这么做的:
#defineINDEX_HTML_PATH"/spiffs/esp.html"...esp_vfs_spiffs_conf_tconf={.base_path="/spiffs",.partition_label=NULL,.max_files=5,.format_if_mount_failed=true};//挂载spiffsESP_ERROR_CHECK(esp_vfs_spiffs_register(&conf));注意:镜像里的文件是烧录时写入的,运行时用
fopen读取;如果挂载失败且format_if_mount_failed = true触发了格式化,预置文件就会被清空。产品固件通常把它设为 false,确认需要时才允许格式化。
四、代码分析
4.1 工程结构
spiffs/ ├── CMakeLists.txt ├── partitions_example.csv # 自定义分区表:storage, data, spiffs, 0xF0000 ├── sdkconfig # CONFIG_PARTITION_TABLE_CUSTOM=y,文件名指向上面的 CSV └── main/ ├── CMakeLists.txt └── main.c # 挂载 → 检查 → 读写 → 卸载分区表内容(注意 storage 分区的 Offset 留空,由构建工具自动计算):
# Name, Type, SubType, Offset, Size, Flags # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, storage, data, spiffs, , 0xF0000,- 前三行与默认表一致,只是把 Offset 显式写了出来;
factory到 0x110000 结束,storage自动排到0x110000,大小 0xF0000(960KB),全部用作 SPIFFS 存储;- 代码里
partition_label = NULL,所以挂载的就是这张表中第一个 spiffs 类型的分区——storage。
4.2 挂载与错误处理
staticconstchar*TAG="example";voidapp_main(void){ESP_LOGI(TAG,"Initializing SPIFFS");esp_vfs_spiffs_conf_tconf={.base_path="/spiffs",//可以认为挂着点,后续使用C库函数fopen("/spiffs/...").partition_label=NULL,//指定spiffs分区,如果为NULL,则默认为分区表中第一个spiffs类型的分区.max_files=5,//最大可同时打开的文件数.format_if_mount_failed=true};//初始化和挂载spiffs分区esp_err_tret=esp_vfs_spiffs_register(&conf);//失败处理if(ret!=ESP_OK){if(ret==ESP_FAIL){ESP_LOGE(TAG,"Failed to mount or format filesystem");}elseif(ret==ESP_ERR_NOT_FOUND){ESP_LOGE(TAG,"Failed to find SPIFFS partition");}else{ESP_LOGE(TAG,"Failed to initialize SPIFFS (%s)",esp_err_to_name(ret));}return;}错误处理区分了三种情况:挂载或格式化失败(ESP_FAIL)、找不到 spiffs 分区(ESP_ERR_NOT_FOUND,通常是分区表没写对)、其他初始化错误。
4.3 检查与读容量
//执行SPIFFS文件系统检查ESP_LOGI(TAG,"Performing SPIFFS_check().");ret=esp_spiffs_check(conf.partition_label);//操作spiffs文件系统器件断电,可能会导致 SPIFFS 损坏,可通过esp_spiffs_check恢复...//获取SPIFFS可用区域大小size_ttotal=0,used=0;ret=esp_spiffs_info(conf.partition_label,&total,&used);例程为了演示,挂载后立刻检查一次;如果esp_spiffs_info获取失败,则调用esp_spiffs_format格式化后返回。若读出的used > total,说明文件系统状态异常,代码会再执行一次esp_spiffs_check。
4.4 用 C 库函数读写文件
核心演示只有几行,注意路径都带/spiffs前缀:
//结合VFS,可以使用标准C库函数进行文件读写ESP_LOGI(TAG,"Opening file");FILE*f=fopen("/spiffs/hello.txt","w");if(f==NULL){ESP_LOGE(TAG,"Failed to open file for writing");return;}fprintf(f,"Hello World!\n");fclose(f);接着演示"先删、再改名、再读回":
//检查/spiffs/foo.txt这个文件是否存在,如果存在删除它structstatst;if(stat("/spiffs/foo.txt",&st)==0){// 删除/spiffs/foo.txt文件unlink("/spiffs/foo.txt");}//重命名文件ESP_LOGI(TAG,"Renaming file");if(rename("/spiffs/hello.txt","/spiffs/foo.txt")!=0){ESP_LOGE(TAG,"Rename failed");return;}...charline[64];fgets(line,sizeof(line),f);fclose(f);// strip newlinechar*pos=strchr(line,'\n');if(pos){*pos='\0';}ESP_LOGI(TAG,"Read from file: '%s'",line);stat判断文件是否存在,存在就unlink删除(SPIFFS 不支持目录,这些函数都在扁平结构上操作);rename改名;fgets读回一行后把末尾的换行符去掉再打印。
最后卸载文件系统:
//测试完成,卸载esp_vfs_spiffs_unregister(conf.partition_label);ESP_LOGI(TAG,"SPIFFS unmounted");与教程不一致:PDF 里
static const char *TAG = "spiffs";,仓库里改成了"example",所以实际日志的前缀是 example 而不是 spiffs;其余代码与教程基本一致。另外教程只演示了挂载与读写流程,并没有讲"文件夹打包烧录"的部分,本笔记用同仓库web_monitor的真实用法做了补充。
五、关键 API 速查
| API | 作用 |
|---|---|
esp_vfs_spiffs_register(&conf) | 初始化 SPIFFS、注册 VFS、挂载到 base_path(声明在esp_spiffs.h) |
esp_vfs_spiffs_unregister(label) | 卸载文件系统;label 为 NULL 时操作分区表中第一个 spiffs 分区 |
esp_spiffs_check(label) | 检查 / 修复文件系统 |
esp_spiffs_info(label, &total, &used) | 查询总容量与已用字节数 |
esp_spiffs_format(label) | 格式化分区 |
fopen / fprintf / fgets / fclose | 标准 C 库文件接口,经 VFS 落到 SPIFFS 驱动 |
stat / unlink / rename | 判断文件是否存在 / 删除 / 重命名 |
spiffs_create_partition_image(分区名 目录 [FLASH_IN_PROJECT]) | CMake 函数,把目录打包成 SPIFFS 镜像,可选随固件一起烧录 |
六、编译与现象验证
cdesp32-board/spiffs idf.py build idf.py-pCOM<端口>flash monitor正常输出如下(数值以实际运行为准):
I (xxx) example: Initializing SPIFFS I (xxx) example: Performing SPIFFS_check(). I (xxx) example: SPIFFS_check() successful I (xxx) example: Partition size: total: ..., used: ... I (xxx) example: Opening file I (xxx) example: File written I (xxx) example: Renaming file I (xxx) example: Reading file I (xxx) example: Read from file: 'Hello World!' I (xxx) example: SPIFFS unmounted最后打印出Hello World!,说明文件走完"写入 → 改名 → 读回"整个流程都是通的。
另一个值得注意的现象:程序每次上电都会重新执行一遍,hello.txt被改名为foo.txt后,下次开机stat("/spiffs/foo.txt")命中就先删掉再重建——文件确实保存在 flash 里,掉电并没有消失。如果把写入部分注释掉、只保留读取,重启后依然能读到上次留下的文件。