4、文件系统与存储:SPIFFS / LittleFS 文件系统介绍、读写配置文件、固件分区表设计

做嵌入式OTA升级,说白了就是跟存储打交道。你想想看,新固件下载下来放哪儿?配置参数存哪儿?掉电了怎么恢复?这些问题都绕不开文件系统和分区表。今天我就把这块内容掰开了讲清楚。

4.1 为什么需要文件系统?

很多刚入行的朋友会问:我直接操作Flash地址读写不就行了?嗯,理论上可以。但实际项目中你会发现,直接操作地址有几个痛点:

  • 地址管理麻烦:今天存个WiFi密码,明天存个传感器阈值,后天又要存个校准参数。每次都得算地址偏移,一不小心就覆盖了。
  • 数据碎片化:Flash擦写有寿命限制,频繁擦除同一块区域会加速老化。
  • 移植性差:换个Flash芯片,地址映射全得重写。

文件系统就是来解决这些问题的。它帮你管理存储空间,提供类似PC上的文件操作接口——打开、读写、关闭、删除。你只需要关心文件名,不用管底层地址。

核心观点:在嵌入式OTA中,文件系统不是必需品,但用了它,你的代码会干净很多,维护成本也低。

4.2 SPIFFS vs LittleFS:怎么选?

目前ESP32、ESP8266这类芯片上,主流就两个文件系统:SPIFFS和LittleFS。我两个都用过,说说我的感受。

特性 SPIFFS LittleFS
诞生时间 较早(2015年左右) 较新(2018年左右)
掉电安全 一般,可能丢文件 强,有日志机制
磨损均衡 有,但较弱 较好
目录支持 扁平结构,无目录 支持目录
内存占用 较小 稍大
社区活跃度 逐渐减少 越来越主流

我的建议:新项目直接用LittleFS。为什么?我在一个灌溉控制器项目里吃过SPIFFS的亏——设备突然断电,配置文件损坏了,导致第二天灌溉时间全乱套。换成LittleFS后,再没出过这种问题。

小技巧:如果你还在用ESP8266,Flash只有2MB,SPIFFS可能更省空间。但ESP32起步4MB,LittleFS完全够用。

4.3 读写配置文件实战

配置文件是OTA升级里最常用的功能。比如设备ID、服务器地址、升级间隔这些参数,都得存下来。我习惯用JSON格式,可读性好,解析也方便。

下面是一个完整的读写示例,基于LittleFS和ArduinoJson库:

#include <LittleFS.h>
#include <ArduinoJson.h>

// 配置文件路径
const char* configPath = "/config.json";

// 配置结构体
struct Config {
  char deviceId[32];
  char serverUrl[128];
  int updateInterval;  // 单位:小时
  float latitude;
  float longitude;
};

// 保存配置
bool saveConfig(const Config& cfg) {
  // 我习惯先检查文件系统是否挂载
  if (!LittleFS.begin()) {
    Serial.println("文件系统挂载失败!");
    return false;
  }

  File file = LittleFS.open(configPath, "w");
  if (!file) {
    Serial.println("打开配置文件失败");
    return false;
  }

  // 构建JSON文档
  StaticJsonDocument<256> doc;
  doc["deviceId"] = cfg.deviceId;
  doc["serverUrl"] = cfg.serverUrl;
  doc["updateInterval"] = cfg.updateInterval;
  doc["latitude"] = cfg.latitude;
  doc["longitude"] = cfg.longitude;

  // 序列化并写入
  if (serializeJson(doc, file) == 0) {
    Serial.println("JSON写入失败");
    file.close();
    return false;
  }

  file.close();
  Serial.println("配置保存成功");
  return true;
}

// 读取配置
bool loadConfig(Config& cfg) {
  if (!LittleFS.begin()) {
    Serial.println("文件系统挂载失败!");
    return false;
  }

  if (!LittleFS.exists(configPath)) {
    Serial.println("配置文件不存在,使用默认值");
    return false;
  }

  File file = LittleFS.open(configPath, "r");
  if (!file) {
    Serial.println("打开配置文件失败");
    return false;
  }

  StaticJsonDocument<256> doc;
  DeserializationError error = deserializeJson(doc, file);
  if (error) {
    Serial.print("JSON解析失败:");
    Serial.println(error.c_str());
    file.close();
    return false;
  }

  // 提取数据
  strlcpy(cfg.deviceId, doc["deviceId"] | "default_id", sizeof(cfg.deviceId));
  strlcpy(cfg.serverUrl, doc["serverUrl"] | "http://192.168.1.100", sizeof(cfg.serverUrl));
  cfg.updateInterval = doc["updateInterval"] | 24;
  cfg.latitude = doc["latitude"] | 0.0;
  cfg.longitude = doc["longitude"] | 0.0;

  file.close();
  return true;
}

注意:JSON文档大小要提前算好。StaticJsonDocument是静态分配,太大容易栈溢出。如果配置项多,改用DynamicJsonDocument。

实际项目中,我还会加一个版本号字段。这样以后配置结构变了,可以自动迁移。比如旧版本没有经纬度字段,读取时给个默认值就行。

4.4 固件分区表设计

分区表是OTA升级的骨架。没有合理的分区设计,升级过程很容易出问题。我见过有人把固件和配置文件放在同一个分区,结果升级时把配置也覆盖了——这种坑踩一次就够了。

以ESP32为例,典型的OTA分区表长这样:

# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xe000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x1F0000,
app1,     app,  ota_1,   0x200000,0x1F0000,
spiffs,   data, spiffs,  0x3F0000,0x10000,
coredump, data, coredump,0x400000,0x10000,

我来解释一下每个分区的作用:

  • nvs:非易失性存储,存WiFi密码、校准参数等小数据。我一般不用它存配置文件,因为大小有限。
  • otadata:OTA启动信息,记录当前该从哪个app分区启动。这个分区千万别动,动了就变砖。
  • app0 / app1:两个固件分区,大小一样。一个跑当前固件,一个用来放新固件。这就是OTA的核心——双区备份。
  • spiffs:文件系统分区,存配置文件、日志、临时文件等。
  • coredump:崩溃转储,调试用。量产时可以去掉,省点空间。

设计原则:app0和app1大小必须一致。我曾经因为app1比app0小了1KB,升级后启动失败,查了两天才发现是分区表配错了。

分区大小怎么定?我一般这样算:

  1. 固件编译后多大?比如800KB,那每个app分区至少1MB,留点余量。
  2. 文件系统需要多大?配置文件一般几KB,日志如果存得多,可以给256KB。
  3. 总Flash多大?比如4MB,减去bootloader和otadata,剩下的合理分配。

举个例子,一个4MB Flash的ESP32:

分区 大小 说明
bootloader 64KB 启动引导
nvs 20KB 系统参数
otadata 8KB OTA状态
app0 1.5MB 当前固件
app1 1.5MB 备份/升级用
spiffs ~900KB 文件系统

这样分配,固件有1.5MB空间,一般够用。文件系统有900KB,存配置文件绰绰有余,还能存点日志。

经验之谈:分区表文件(partitions.csv)要放在项目根目录,并且在编译时指定。我习惯在platformio.ini里加一行:board_build.partitions = partitions.csv

4.5 避坑指南

最后分享几个我踩过的坑:

  • 文件系统挂载失败:有时候Flash没初始化好,或者分区表没烧录。解决办法是上电后延迟几百毫秒再挂载。
  • 配置文件写一半掉电:LittleFS虽然比SPIFFS好,但也不是100%安全。我习惯写完后读回来校验一下。
  • 分区表烧录错误:用esptool.py烧录时,记得加上--erase-region参数,把旧分区表擦干净。

嗯,文件系统和分区表这块,说白了就是给固件找个安稳的家。设计好了,OTA升级就成功了一半。下一章我们聊聊升级流程的具体实现。