Chapter 08

数据存储与文件系统

掌握 NVS 键值存储、Flash 文件系统和 SD 卡,实现数据掉电保存

Flash 分区表

ESP32 的 Flash(通常 4MB)通过分区表划分不同用途区域。分区表位于 Flash 的固定地址(0x8000),记录每个分区的类型、子类型、起始地址和大小。

ESP32 典型 Flash 分区布局(4MB): 偏移地址 大小 类型 名称 ───────────────────────────────────────────────────────── 0x000000 32KB bootloader 引导程序(只读) 0x008000 3KB partition 分区表本身 0x008C00 16KB nvs NVS 存储(Wi-Fi 校准、用户配置) 0x00c000 8KB phy_init PHY 初始化数据(RF 校准) 0x010000 1.9MB factory 出厂应用固件 0x1f0000 1.9MB ota_0 OTA 固件 A(双 OTA 时使用) 0x3d0000 192KB spiffs SPIFFS/LittleFS 文件系统 ───────────────────────────────────────────────────────── 自定义分区表(partitions.csv): # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, ota_data, data, ota, 0xf000, 0x2000, app0, app, ota_0, 0x10000, 0x1E0000, app1, app, ota_1, 0x1F0000, 0x1E0000, littlefs, data, spiffs, 0x3D0000, 0x30000,

NVS — 非易失性存储

NVS(Non-Volatile Storage)是 ESP-IDF 提供的键值对存储系统,数据存储在 Flash 的专用分区,断电后保留。它是存储 Wi-Fi 密码、设备配置、校准参数等小量数据的最佳方式。

NVS 核心概念

Namespace(命名空间)
类似文件夹,隔离不同模块的键值对。键名在同一命名空间内唯一,不同命名空间可有同名键。命名空间名最长 15 字节。
键(Key)
字符串标识符,最长 15 字节。支持存储类型:uint8/16/32/64、int8/16/32/64、float、double、字符串、二进制数据(Blob)。
磨损均衡
NVS 内部实现了 Flash 磨损均衡算法,避免反复写同一位置导致 Flash 寿命缩短。理论上每个扇区可擦写约 10 万次,NVS 将写操作分散到整个 NVS 分区。
NVS 分区大小建议
至少 0x6000(24KB)。系统需要约 8KB 存储 Wi-Fi 配置和 PHY 校准数据,剩余才是用户可用空间。

NVS 读写示例

#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"

#define NVS_NS  "app_config"   // 命名空间
static const char *TAG = "NVS";

void nvs_write_config(const char *ssid, const char *password, int32_t interval)
{
    nvs_handle_t handle;
    ESP_ERROR_CHECK(nvs_open(NVS_NS, NVS_READWRITE, &handle));

    nvs_set_str(handle, "wifi_ssid", ssid);
    nvs_set_str(handle, "wifi_pass", password);
    nvs_set_i32(handle, "report_interval", interval);

    ESP_ERROR_CHECK(nvs_commit(handle));  // 必须 commit 才真正写入 Flash
    nvs_close(handle);
    ESP_LOGI(TAG, "配置已保存");
}

void nvs_read_config(void)
{
    nvs_handle_t handle;
    esp_err_t err = nvs_open(NVS_NS, NVS_READONLY, &handle);
    if (err == ESP_ERR_NVS_NOT_FOUND) {
        ESP_LOGW(TAG, "命名空间不存在,使用默认配置");
        return;
    }

    char ssid[33] = {0};
    size_t ssid_len = sizeof(ssid);
    nvs_get_str(handle, "wifi_ssid", ssid, &ssid_len);

    int32_t interval = 60;    // 默认值
    nvs_get_i32(handle, "report_interval", &interval);

    ESP_LOGI(TAG, "SSID: %s, 上报间隔: %" PRId32 "秒", ssid, interval);
    nvs_close(handle);
}

void app_main(void)
{
    /* 初始化 NVS(必须在 Wi-Fi 初始化前调用)*/
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());   // 清空后重试
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    nvs_write_config("HomeWiFi", "password123", 30);
    nvs_read_config();
}

LittleFS — 推荐文件系统

LittleFS 是 ARM Mbed 团队开发的嵌入式文件系统,专为 Flash 存储设计,具有断电安全、磨损均衡、压缩等特性。ESP-IDF v5 起推荐使用 LittleFS 替代老旧的 SPIFFS。

LittleFS 优点

  • 支持真正的目录结构
  • 原子操作(断电安全)
  • 更好的磨损均衡
  • 更快的文件系统修复

SPIFFS 局限性

  • 不支持目录(所有文件在根)
  • 断电可能损坏文件
  • 大量小文件时性能下降
  • 官方建议迁移到 LittleFS
#include "esp_littlefs.h"
#include "esp_log.h"
#include <stdio.h>
#include <sys/stat.h>

static const char *TAG = "LFS";

void littlefs_init(void)
{
    esp_vfs_littlefs_conf_t conf = {
        .base_path              = "/littlefs",    // 挂载点
        .partition_label        = "littlefs",     // 分区表中的名称
        .format_if_mount_failed = true,           // 挂载失败时自动格式化
    };
    ESP_ERROR_CHECK(esp_vfs_littlefs_register(&conf));

    size_t total = 0, used = 0;
    esp_littlefs_info("littlefs", &total, &used);
    ESP_LOGI(TAG, "文件系统: 总计 %d KB, 已用 %d KB", total/1024, used/1024);
}

void write_sensor_log(float temp, float hum, uint32_t timestamp)
{
    FILE *f = fopen("/littlefs/sensor.csv", "a");
    if (!f) {
        ESP_LOGE(TAG, "无法打开文件");
        return;
    }
    fprintf(f, "%" PRIu32 ",%0.1f,%0.1f\n", timestamp, temp, hum);
    fclose(f);
}

void read_sensor_log(void)
{
    FILE *f = fopen("/littlefs/sensor.csv", "r");
    if (!f) return;
    char line[64];
    while (fgets(line, sizeof(line), f)) {
        ESP_LOGI(TAG, "%s", line);
    }
    fclose(f);
}
上传文件到 Flash 的方法

将本地文件(HTML/配置文件/证书)打包到 LittleFS 镜像中:
1. 将文件放入 data/ 目录
2. CMakeLists.txt 中添加 littlefs_create_partition_image(littlefs ../data FLASH_IN_PROJECT)
3. idf.py flash 时会自动打包上传

SD 卡(SPI 模式)

#include "driver/spi_common.h"
#include "driver/sdspi_host.h"
#include "sdmmc_cmd.h"
#include "esp_vfs_fat.h"

#define SD_CS   GPIO_NUM_5
#define SD_CLK  GPIO_NUM_18
#define SD_MOSI GPIO_NUM_23
#define SD_MISO GPIO_NUM_19

void sd_card_init(void)
{
    sdmmc_host_t host = SDSPI_HOST_DEFAULT();
    spi_bus_config_t bus_cfg = {
        .mosi_io_num = SD_MOSI,
        .miso_io_num = SD_MISO,
        .sclk_io_num = SD_CLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
    };
    spi_bus_initialize(host.slot, &bus_cfg, SDSPI_DEFAULT_DMA);

    sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT();
    slot_config.gpio_cs = SD_CS;

    esp_vfs_fat_sdmmc_mount_config_t mount_config = {
        .format_if_mount_failed = false,
        .max_files              = 5,
        .allocation_unit_size   = 16 * 1024,
    };

    sdmmc_card_t *card;
    esp_vfs_fat_sdspi_mount("/sdcard", &host, &slot_config, &mount_config, &card);
    sdmmc_card_print_info(stdout, card);

    /* 之后可以用标准 POSIX 文件 API */
    FILE *f = fopen("/sdcard/log.txt", "a");
    fprintf(f, "ESP32 启动\n");
    fclose(f);
}
SD 卡 SPI 模式速度限制

SPI 模式下 SD 卡最高约 20~25 MHz,实际读写速度约 2~4 MB/s。若需更高速度(如连续录制音频/视频),应选用 SDMMC 4-bit 接口,但需注意引脚冲突(GPIO 12/13/14/15 与 JTAG 和 Flash 共用)。

NVS 存储 Flash 寿命管理

ESP32 内置 Flash 的擦写寿命约 100,000 次。NVS 通过磨损均衡(Wear Leveling)将写操作分散到整个分区,而不是重复写同一个扇区,大幅延长 Flash 使�命:

NVS 磨损均衡原理
NVS 将分区划分为多个页(Page),每页 4KB。写入数据时,旧数据不立即擦除,而是标记为过期,新数据写入下一个可用位置。当分区写满后,NVS 进行垃圾回收(GC),将有效数据整理到新页,然后擦除旧页。这样每次 GC 才擦除一个 4KB 扇区,而非每次写入都擦除。
分区大小建议
NVS 分区越大,每个扇区的写入次数越少,寿命越长。默认 NVS 分区 16KB(4 页),对于每分钟写一次的应用,100,000次 × 4页 ÷ 60 ≈ 可运行 111 天。如果需要更长寿命,增加 NVS 分区大小到 64KB 甚至 256KB。
/* NVS 高级用法:批量操作与错误处理 */
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"

typedef struct {
    float   temp_threshold;
    uint32_t report_interval_s;
    char    server_url[128];
    bool    led_enabled;
} DeviceConfig_t;

/* 将整个结构体写入 NVS(使用 blob 类型)*/
esp_err_t config_save(const DeviceConfig_t *cfg)
{
    nvs_handle_t h;
    esp_err_t err = nvs_open("device_cfg", NVS_READWRITE, &h);
    if (err != ESP_OK) return err;

    err = nvs_set_blob(h, "config", cfg, sizeof(DeviceConfig_t));
    if (err == ESP_OK) {
        err = nvs_commit(h);  /* commit 确保数据持久化到 Flash */
    }
    nvs_close(h);
    return err;
}

/* 读取结构体,不存在则使用默认值 */
esp_err_t config_load(DeviceConfig_t *cfg)
{
    /* 默认配置 */
    *cfg = (DeviceConfig_t){
        .temp_threshold   = 30.0f,
        .report_interval_s = 60,
        .server_url       = "mqtt://192.168.1.1",
        .led_enabled      = true
    };

    nvs_handle_t h;
    esp_err_t err = nvs_open("device_cfg", NVS_READONLY, &h);
    if (err == ESP_ERR_NVS_NOT_FOUND) {
        ESP_LOGW("NVS", "无配置,使用默认值");
        return ESP_OK;  /* 不算错误 */
    }
    if (err != ESP_OK) return err;

    size_t sz = sizeof(DeviceConfig_t);
    err = nvs_get_blob(h, "config", cfg, &sz);
    nvs_close(h);
    return err;
}

/* NVS 诊断:检查分区使用情况 */
void nvs_stats_print(void)
{
    nvs_stats_t stats;
    nvs_get_stats(NULL, &stats);
    ESP_LOGI("NVS", "已用条目: %d / 总计: %d(%d%% 使用率)",
             stats.used_entries, stats.total_entries,
             stats.used_entries * 100 / stats.total_entries);
    ESP_LOGI("NVS", "可用命名空间: %d", stats.available_entries);
}
NVS 数据损坏的恢复机制

掉电时如果恰好在 Flash 写操作中,可能造成 NVS 页损坏(Page State = CORRUPT)。NVS 会自动标记损坏页并跳过,但如果损坏严重(所有页都损坏),nvs_flash_init() 会返回 ESP_ERR_NVS_NO_FREE_PAGES。此时正确处理方式是调用 nvs_flash_erase() 格式化 NVS 分区后再 init(会丢失所有 NVS 数据):

esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
    ESP_ERROR_CHECK(nvs_flash_erase());
    err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
本章小结

ESP32 提供三种持久化存储方案:NVS(键值存储,适合配置参数和少量数据,内置磨损均衡)、文件系统(SPIFFS/LittleFS,适合网页、证书等文件,LittleFS 更稳定)、SD 卡(大容量日志和媒体文件)。NVS 使用 namespace 隔离不同组件的数据,写入后必须调用 nvs_commit 才能持久化。LittleFS 比 SPIFFS 抗掉电能力更强,新项目推荐使用。文件系统通过 VFS(虚拟文件系统)挂载到路径(如 /spiffs),可用标准 C 文件 API 读写。