ESP32-S3 上居然能像电脑一样读写文件?SPIFFS 文件系统实战

前言:NVS 够用了,为什么还需要文件系统?

前面学完 NVS,我一度觉得存储问题已经彻底解决了——键值对嘛,想存什么存什么。

直到有一天我想存一段比较长的 HTML 网页内容,还想存一张小图片,突然发现 NVS 用起来很别扭。NVS 本质上是键值对存储,适合存配置参数、计数器这类结构化的小数据。但如果我要存文件呢?一个 txt、一个 html、一个 json,用 NVS 来管理就太勉强了。

这时候就需要文件系统了。而且让我惊喜的是,ESP-IDF 的文件系统用的竟然是标准 C 的文件操作函数——fopenfprintffgetsfclose——跟在电脑上写 C 语言作业一模一样。


一、ESP-IDF 支持哪些文件系统?

ESP-IDF 目前支持三种文件系统,我先做个横向对比,方便选型:

特性 SPIFFS FATFS LittleFS
设计目标 SPI Flash 专用,轻量级 通用 FAT 格式 嵌入式专用,高可靠
资源占用 最少 较多 适中
磨损均衡 基本支持 需额外实现 高级算法
掉电安全 一般 一般 原子写入,崩溃恢复
大文件支持 ❌ 不擅长 ✅ 支持 ✅ 支持
PC 兼容性 Windows/Linux 直接识别
适用场景 配置文件、小网页 多媒体、与 PC 交换数据 实时数据、重要配置

我的选择:先从 SPIFFS 入手。 它最轻量、API 最简单,而且三种文件系统的使用套路是相通的,学会一个,其他的很容易迁移。

SPIFFS vs NVS 到底有啥区别?

NVS SPIFFS
存储方式 键值对 文件
适合存什么 Wi-Fi 密码、计数器、开关状态 txt、html、json、小图片
读写方式 nvs_set_xxx / nvs_get_xxx fopen / fprintf / fgets
一句话总结 存参数用 NVS 存文件用 SPIFFS

二、第一步:创建分区表

上一篇学分区表的时候就说过,任何用到 Flash 的功能都得先在分区表里划地盘。SPIFFS 也不例外。

在工程根目录创建 partitions.csv

# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     ,        0x6000,
phy_init, data, phy,     ,        0x1000,
factory,  app,  factory, ,        1M,
storage,  data, spiffs,  ,        0xF0000,

重点看最后一行:新增了一个名为 storage 的分区,类型是 data,子类型是 spiffs,大小 0xF0000(960KB)。

然后打开 idf.py menuconfig,把分区表切换成自定义的:

Partition Table → Custom partition table CSV
Custom partition CSV file → partitions.csv

三、第二步:注册并挂载文件系统

分区划好了,接下来要告诉系统:“这块分区是 SPIFFS 文件系统,请帮我挂载好。”

esp_vfs_spiffs_conf_t conf = {
    .base_path = "/spiffs",            // 挂载路径,后面所有文件路径都以这个开头
    .partition_label = NULL,           // 用默认的 spiffs 分区
    .max_files = 5,                    // 最多同时打开 5 个文件
    .format_if_mount_failed = true     // 挂载失败就自动格式化
};

esp_err_t ret = esp_vfs_spiffs_register(&conf);

这里有几个关键配置我想单独说说:

base_path:相当于给文件系统指定一个"根目录"。设成 /spiffs 后,你要访问 hello.txt,完整路径就是 /spiffs/hello.txt

format_if_mount_failed:这个一定要设成 true!第一次使用时,Flash 上还没有文件系统的格式信息,挂载必然失败。设成 true 就会自动格式化,省得手动处理。

partition_label:设成 NULL 就自动找第一个子类型为 spiffs 的分区。如果你有多个 SPIFFS 分区,才需要手动指定名字。

挂载后还需要做错误处理:

if (ret != ESP_OK) {
    if (ret == ESP_FAIL) {
        ESP_LOGE(TAG, "挂载或格式化失败");
    } else if (ret == ESP_ERR_NOT_FOUND) {
        ESP_LOGE(TAG, "找不到 SPIFFS 分区");  // 多半是分区表没配对
    } else {
        ESP_LOGE(TAG, "初始化 SPIFFS 失败 (%s)", esp_err_to_name(ret));
    }
    return;
}

⚠️ 注意:首次启动时,格式化过程大约需要 10 秒左右,串口会卡住不动,这是正常的,别急着拔线。


四、第三步:获取分区信息

挂载成功后,可以查看一下分区有多大、已经用了多少:

size_t total = 0, used = 0;
ret = esp_spiffs_info(conf.partition_label, &total, &used);
if (ret != ESP_OK) {
    ESP_LOGE(TAG, "获取分区信息失败,正在格式化...");
    esp_spiffs_format(conf.partition_label);
    return;
} else {
    ESP_LOGI(TAG, "分区大小 - 总计: %d, 已用: %d", total, used);
}

五、第四步:一致性检查

这一步是个保险措施。如果 ESP32-S3 在文件操作过程中突然断电,SPIFFS 可能会损坏,导致 used > total 这种不合理的情况。这时候就需要调用 esp_spiffs_check 来修复:

if (used > total) {
    ESP_LOGW(TAG, "已用空间大于总空间,执行一致性检查...");
    ret = esp_spiffs_check(conf.partition_label);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "SPIFFS 检查失败 (%s)", esp_err_to_name(ret));
        return;
    } else {
        ESP_LOGI(TAG, "SPIFFS 检查通过");
    }
}

六、第五步:像在电脑上一样读写文件

前面所有步骤都是准备工作,到这里才是真正激动人心的部分——用标准 C 函数操作文件

写入文件

ESP_LOGI(TAG, "创建并写入文件...");
FILE* f = fopen("/spiffs/hello.txt", "w");
if (f == NULL) {
    ESP_LOGE(TAG, "打开文件失败");
    return;
}
fprintf(f, "Hello World!\n");
fclose(f);
ESP_LOGI(TAG, "文件写入成功");

读取文件

ESP_LOGI(TAG, "读取文件...");
f = fopen("/spiffs/hello.txt", "r");
if (f == NULL) {
    ESP_LOGE(TAG, "打开文件失败");
    return;
}
char line[64];
fgets(line, sizeof(line), f);
fclose(f);

// 去掉换行符
char* pos = strchr(line, '\n');
if (pos) {
    *pos = '\0';
}
ESP_LOGI(TAG, "读取到的内容: '%s'", line);

用完记得卸载

esp_vfs_spiffs_unregister(conf.partition_label);
ESP_LOGI(TAG, "SPIFFS 已卸载");

七、完整代码

把上面所有步骤串起来,就是一个完整的 Demo:

#include <stdio.h>
#include <string.h>
#include <sys/unistd.h>
#include <sys/stat.h>
#include "esp_err.h"
#include "esp_log.h"
#include "esp_spiffs.h"

static const char *TAG = "SPIFFS_DEMO";

void app_main(void)
{
    // ========== 1. 配置并挂载 SPIFFS ==========
    ESP_LOGI(TAG, "初始化 SPIFFS...");

    esp_vfs_spiffs_conf_t conf = {
        .base_path = "/spiffs",
        .partition_label = NULL,
        .max_files = 5,
        .format_if_mount_failed = true
    };

    esp_err_t ret = esp_vfs_spiffs_register(&conf);
    if (ret != ESP_OK) {
        if (ret == ESP_FAIL) {
            ESP_LOGE(TAG, "挂载或格式化失败");
        } else if (ret == ESP_ERR_NOT_FOUND) {
            ESP_LOGE(TAG, "找不到 SPIFFS 分区");
        } else {
            ESP_LOGE(TAG, "初始化失败 (%s)", esp_err_to_name(ret));
        }
        return;
    }

    // ========== 2. 获取分区信息 ==========
    size_t total = 0, used = 0;
    ret = esp_spiffs_info(conf.partition_label, &total, &used);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "获取分区信息失败,格式化中...");
        esp_spiffs_format(conf.partition_label);
        return;
    }
    ESP_LOGI(TAG, "分区大小 - 总计: %d, 已用: %d", total, used);

    // ========== 3. 一致性检查 ==========
    if (used > total) {
        ESP_LOGW(TAG, "数据异常,执行一致性检查...");
        ret = esp_spiffs_check(conf.partition_label);
        if (ret != ESP_OK) {
            ESP_LOGE(TAG, "检查失败 (%s)", esp_err_to_name(ret));
            return;
        }
        ESP_LOGI(TAG, "检查通过");
    }

    // ========== 4. 写入文件 ==========
    ESP_LOGI(TAG, "写入文件...");
    FILE* f = fopen("/spiffs/hello.txt", "w");
    if (f == NULL) {
        ESP_LOGE(TAG, "打开文件失败");
        return;
    }
    fprintf(f, "Hello World!\n");
    fclose(f);
    ESP_LOGI(TAG, "写入成功");

    // ========== 5. 读取文件 ==========
    ESP_LOGI(TAG, "读取文件...");
    f = fopen("/spiffs/hello.txt", "r");
    if (f == NULL) {
        ESP_LOGE(TAG, "打开文件失败");
        return;
    }
    char line[64];
    fgets(line, sizeof(line), f);
    fclose(f);

    char* pos = strchr(line, '\n');
    if (pos) *pos = '\0';
    ESP_LOGI(TAG, "读取到: '%s'", line);

    // ========== 6. 卸载 ==========
    esp_vfs_spiffs_unregister(conf.partition_label);
    ESP_LOGI(TAG, "SPIFFS 已卸载");
}

八、进阶:把电脑上的文件直接烧录到板子里

这个功能让我眼前一亮。

假设我电脑上有个 hello1.txt,内容是 this is hello1 txt,我想让它在烧录程序的时候一起烧到 Flash 里,这样程序启动后直接就能读到这个文件,不需要代码里手动创建。

第一步:准备文件

在工程目录下创建一个文件夹,比如 txt,里面放上要烧录的文件:

demo/
├── main/
│   ├── CMakeLists.txt
│   └── main.c
├── txt/
│   └── hello1.txt        ← 内容:this is hello1 txt
├── partitions.csv
└── CMakeLists.txt

第二步:修改 CMakeLists.txt

demo/main/CMakeLists.txt 中添加一行:

spiffs_create_partition_image(storage "../txt" FLASH_IN_PROJECT)

这句话的意思是:

  • storage — 对应分区表里的分区名
  • "../txt" — 电脑上要打包的文件夹路径
  • FLASH_IN_PROJECT — 编译时自动打包成 SPIFFS 镜像,烧录时一起写入 Flash

第三步:代码中直接读取

// 不需要先 fopen + fprintf 创建文件了,直接读!
FILE* f = fopen("/spiffs/hello1.txt", "r");
if (f == NULL) {
    ESP_LOGE(TAG, "打开文件失败");
    return;
}
char line[64];
fgets(line, sizeof(line), f);
fclose(f);

char* pos = strchr(line, '\n');
if (pos) *pos = '\0';
ESP_LOGI(TAG, "读取到: '%s'", line);

编译烧录后,串口输出:

I (xxx) SPIFFS_DEMO: 读取到: 'this is hello1 txt'

文件确实在烧录阶段就写入 Flash 了,代码启动后直接就能读到。

这个功能非常实用! 比如做 Web Server 的时候,可以把 HTML、CSS、JS 文件放在文件夹里,编译时自动打包烧录,不需要在代码里拼接字符串。


九、总结

回顾一下,使用 SPIFFS 的完整流程:

创建分区表 → 配置 menuconfig → 注册挂载 → 获取信息 → 一致性检查 → 读写文件 → 卸载

几个关键点:

  1. 分区表是前提 — 没有分区,文件系统无处安放
  2. 首次格式化会卡 10 秒 — 别以为程序挂了
  3. 用的是标准 C 文件操作fopenfprintffgets,零学习成本
  4. spiffs_create_partition_image 是神器 — 可以把电脑上的文件直接烧进去
  5. 三种文件系统套路相同 — 学会 SPIFFS,LittleFS 和 FATFS 很容易迁移

对我来说,学完这个最大的感受是:ESP32-S3 远比我想象中强大。它不只是一个跑跑 GPIO、点点灯的单片机,它有 NVS 键值存储、有文件系统、有分区管理,这些能力组合起来,完全可以做出相当复杂的产品。

不过在 ESP-IDF v5.3+ 中,官方开始更推荐使用 LittleFS 替代 SPIFFS。SPIFFS 目前仍然可用,但未来可能被标记为 deprecated。如果是新项目,可以考虑直接用 LittleFS。


下一篇打算学习 Wi-Fi 相关的内容,敬请期待!如果觉得有帮助,欢迎点赞收藏~

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐