ESP-IDF 工程能力 / 存储与恢复发布 内容更新

ESP32 数据存哪里?NVS、小文件和 SD 卡分别适合什么

用一个可构建工程把NVS配置与追加日志分开,分别构建SPIFFS和SD分支;对缺键、应用限额、写失败和坏尾保留明确结果与恢复步骤。

读完能做什么
得到NVS配置和有界追加日志工程,能区分缺键、应用满额、实际I/O失败与坏尾,并在恢复前保留数据。
开始之前
先完成所列前置,再按本例固定板型与README执行;本文不把主机检查当成实体运行。
所需器材
指定S3板、USB数据线、IDF5.5.5、SD分支另需PID254、microSDHC FAT32卡、短线与读卡器、操作前可备份原有配置、文件与分区的电脑
版本组合
框架:ESP-IDF5.5.5 / SDK内置NVS、SPIFFS、FatFs/SDSPI介质:内部logs1MiB或外部FAT32 microSDHC;单日志应用限额4096B
适用范围

DevKitC-1 v1.1 / N8R8,IDF5.5.5、8MB Flash、桥接UART115200;NVS24KiB/factory2MiB/SPIFFS1MiB,无OTA;可选Adafruit PID254与4–32GB microSDHC FAT32、SDSPI10MHz。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/idf-systems.md#e08;固定上游与实际源码比对摘要。验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/e08-build.log;真实Exit0;变体/源/锁/日志/固件SHA见idf-systems-builds.json。对象:DevKitC-1 v1.1 / WROOM-1-N8R8版本:ESP-IDF5.5.5验证日期:
仿真不适用
未运行ESP32/触摸/文件系统器件仿真;主机只执行固件共享的纯C模型与文件故障夹具,不证明调度、电气或断电可靠性。
本站实板待验证
尚无这套固件烧录、串口、外设或断电测试;observations.csv实测字段为空。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 存储与恢复

配置和连续记录,不用同一个存法

NVS适合少量键值配置;SPIFFS适合板内的小文件;SD卡适合较多、需要取走的数据。这次一个工程用NVS保存日志间隔,追加CSV记录可在内部SPIFFS和外部SD/FAT32间编译切换。它们不是三个互相套用的文件名。

本例日志记录的是boot_id、sequence和本次启动uptime_ms,不伪造温湿度或UTC。每行带CRC32,帮助识别部分损坏;CRC不是认证,也不提供原子追加、事务或断电耐受保证。

两种存储分支都已真实构建,主机用同源journal.c检查实际文件和失败夹具;本站尚无NVS断电、SPIFFS掉电、真卡满、SD读写或恢复实板结果。

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[4] IDF5.5.5官方SDSPI/FAT挂载示例[5] IDF5.5.5 FATFS:VFS、同步和挂载选项

02 / 存储与恢复

先读分区,再接具体SD模块

主板固定DevKitC-1 v1.1 / N8R8、8MB Flash、不启用PSRAM、桥接UART115200。自定义分区为nvs 0x9000/0x6000、phy_init 0xF000/0x1000、factory 0x10000/0x200000、logs 0x210000/0x100000;未把全部8MB都分完,也没有OTA槽。换分区前备份原有配置与日志,不能仅刷app就假定旧数据布局兼容。

外部SD分支使用Adafruit PID254带稳压/电平转换microSD breakout,配microSDHC 4–32GB、FAT32卡。本例关闭自动格式化、exFAT未启用,不保证大容量exFAT卡可用。模块5V引脚由主板USB供电的5V端供电;GPIO只输出3.3V逻辑,不向ESP输入5V。

断电后接线:GPIO12→CLK、GPIO11→DI(MOSI)、GPIO13←DO(MISO)、GPIO10→CS、5V→5V、GND→GND。CD未接,代码没有热插拔或写保护检测;所有取卡/重接操作先停机断电,不把任意匿名裸卡座或5V直通信号模块代入。

DevKitPID254用途
USB供电5V5V模块稳压输入
GNDGND共地
GPIO12CLKSPI时钟;本例10MHz
GPIO11DIMOSI,主控到卡
GPIO13DOMISO,卡到主控
GPIO10CS片选
不接CD无热插拔监控

参考:[4] IDF5.5.5官方SDSPI/FAT挂载示例[6] Adafruit PID254 microSD breakout:稳压、逻辑转换、DI/DO/CS和CD[7] IDF5.5.5分区表解析:边界、对齐与分区字段

03 / 存储与恢复

缺键是正常分支,保存需要commit回执

启动用NVS_READONLY打开store_demo/config。命名空间/键不存在,或8字节blob大小、版本、保留字节、interval范围不合法时,用2000ms默认且不自动保存。NVS键与命名空间最多15字符,本例名称均在范围内。

串口set 1000..60000只改当前间隔;save执行set_blob成功后才commit,打印NVS_SAVE的真实esp_err名。空间不足、初始化失败或commit失败都不声称保存成功,也不自动擦NVS。失败后的持久状态应在重启后重新读取,不能凭内存中的新值推断已经写入。

日志序号从已有文件最后有效行恢复;boot_id是本次随机标识,uptime从本次启动起算,不是绝对时间。NVS只在显式save时改写间隔,不每条日志反复写一次配置。

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[8] IDF5.5.5 NVS API:get_blob长度、空间不足与commit

04 / 存储与恢复

追加文件之前先扫描,错误后停止

首次文件不存在是正常LOG_MISSING,第一次成功追加再创建。已有文件逐行检查规范格式与CRC;发现半行、CRC错或超限就LOG_RECOVERY_REQUIRED并停止,不自动截掉读者尚未备份的尾部。

每条追加依次检查fwrite、fflush、fsync和fclose,全部成功才推进成功序号。任何阶段失败都停写,避免在可能的半行后盲目接下一行。CRC能发现部分损坏,不能修复Flash页、证明文件系统一致或保证掉电前最后一行必定落盘。

应用把单个日志限制4096字节,满了返回JOURNAL_LIMIT_REACHED。这不是SPIFFS分区满或SD卡满;主机另以ENOSPC回调注入检查I/O失败、以真正短写制造半行。真介质容量耗尽、NVS页满与断电故障仍待实体测试。

参考:[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[3] IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射[4] IDF5.5.5官方SDSPI/FAT挂载示例[5] IDF5.5.5 FATFS:VFS、同步和挂载选项

05 / 存储与恢复

完整构建与首次挂载路径

下载包包含根CMake、main、同源journal.c/策略、partitions.csv、defaults、SD开关、lock、README、许可证和空观测表。IDF固定5.5.5,无额外社区文件系统依赖。默认SPIFFS app254240B,SD分支334032B,均在本例2MiB factory分区内。

初次空白SPIFFS很可能挂载失败,代码只报告MOUNT错误,不自动格式化。确认这是本例logs分区、且备份完成后,明确输入FORMAT SPIFFS才格式化并重新挂载。已有SD挂载失败不触发格式化;备份后在电脑准备FAT32,确认卡和接线,再断电重试。

运行时命令status显示backend/mounted/stopped/NVS_ready/间隔/应用日志字节计数/序号;log_bytes在journal_append的fflush/fsync成功后增加;随后fclose失败仍停写但不回退计数,启动坏尾时也只累计已校验前缀。它不是物理文件大小,也不等同所有阶段均成功的记录字节数。set 3000改运行值,save要求NVS成功。CLEAR LOG删除当前后端日志并重置本应用记录;它是有意的数据删除命令,只在备份后使用。SD分支拒绝FORMAT SPIFFS且不提供自动格式SD命令。

代码 / 配置
idf.py --version
# 项目根目录;ESP-IDF v5.5.5
idf.py set-target esp32s3
idf.py build
idf.py -p REPLACE_WITH_UART_PORT flash monitor
# 在串口逐行输入;先查状态,再有意选择需要的操作
status
set 3000
save
# SD变体:menuconfig -> Storage lesson -> DEMO_USE_SD;新build目录重建
# idf.py -B build-sd menuconfig
# idf.py -B build-sd build

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[3] IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射[4] IDF5.5.5官方SDSPI/FAT挂载示例[7] IDF5.5.5分区表解析:边界、对齐与分区字段

06 / 存储与恢复

完整入口:NVS、挂载与追加分开处理

logger和命令处理共享mutex,明确格式化/删除不会与应用自身的追加并发;这不保证断电原子性。修改间隔在下一轮等待生效,实际周期还包含写入和任务调度耗时,不能称精确采样时钟。

SPIFFS是平面路径、非实时文件系统,官方说明通常只能可靠利用约75%分区,GC可能耗时很长;1MiB分区不等于可承诺1MiB有效日志空间。SD卡也有FAT分配单元、卡控制器缓存和磨损,文件大了不能靠NVS无限追加来替代。

代码 / 配置
/* SPDX-License-Identifier: MIT */
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/spi_common.h"
#include "driver/sdspi_host.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_random.h"
#include "esp_spiffs.h"
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "sdkconfig.h"
#include "storage_model.h"
#include "journal.h"
static const char *TAG="storage_lesson";
static SemaphoreHandle_t mutex;
static uint32_t interval_ms=2000, sequence, boot_id;
static bool mounted, stopped, nvs_ready;
static size_t log_bytes;
#if CONFIG_DEMO_USE_SD
static const char *path="/sd/samples.csv";
#else
static const char *path="/data/samples.csv";
#endif
static uint32_t load_config(void) {
 nvs_handle_t h;esp_err_t e=nvs_open("store_demo",NVS_READONLY,&h);
 if(e==ESP_ERR_NVS_NOT_FOUND){ESP_LOGI(TAG,"NVS_MISSING: defaults=2000ms, no automatic save");return 2000;}
 if(e!=ESP_OK){ESP_LOGE(TAG,"NVS_OPEN=%s",esp_err_to_name(e));return 2000;}
 uint8_t b[8];size_t n=sizeof(b);e=nvs_get_blob(h,"config",b,&n);nvs_close(h);uint32_t ms;
 if(e==ESP_OK && config_decode(b,n,&ms))return ms;
 ESP_LOGW(TAG,"NVS_MISSING_OR_INVALID=%s size=%u, defaults remain unsaved",esp_err_to_name(e),(unsigned)n);return 2000;
}
static esp_err_t save_config(void) {
 if(!nvs_ready)return ESP_ERR_INVALID_STATE;
 uint8_t b[8];config_encode(interval_ms,b);nvs_handle_t h;esp_err_t e=nvs_open("store_demo",NVS_READWRITE,&h);
 if(e!=ESP_OK)return e;
 e=nvs_set_blob(h,"config",b,sizeof(b));if(e==ESP_OK)e=nvs_commit(h);nvs_close(h);return e;
}
static esp_err_t mount_files(void) {
#if CONFIG_DEMO_USE_SD
 sdmmc_host_t host=SDSPI_HOST_DEFAULT();host.max_freq_khz=10000;
 spi_bus_config_t bus={.mosi_io_num=11,.miso_io_num=13,.sclk_io_num=12,.quadwp_io_num=-1,.quadhd_io_num=-1,.max_transfer_sz=4000};
 esp_err_t e=spi_bus_initialize(host.slot,&bus,SDSPI_DEFAULT_DMA);if(e!=ESP_OK)return e;
 sdspi_device_config_t device=SDSPI_DEVICE_CONFIG_DEFAULT();device.gpio_cs=10;device.host_id=host.slot;
 esp_vfs_fat_sdmmc_mount_config_t config={.format_if_mount_failed=false,.max_files=3,.allocation_unit_size=16*1024};
 sdmmc_card_t *card=NULL;e=esp_vfs_fat_sdspi_mount("/sd",&host,&device,&config,&card);
 if(e==ESP_OK)sdmmc_card_print_info(stdout,card);else spi_bus_free(host.slot);
 return e;
#else
 esp_vfs_spiffs_conf_t config={.base_path="/data",.partition_label="logs",.max_files=3,.format_if_mount_failed=false};
 return esp_vfs_spiffs_register(&config);
#endif
}
static void inspect_log(void) {
 log_bytes=0;sequence=0;stopped=false;
 FILE *file=fopen(path,"r");
 if(!file){if(errno==ENOENT){ESP_LOGI(TAG,"LOG_MISSING: first append will create it");return;}stopped=true;ESP_LOGE(TAG,"LOG_OPEN errno=%d",errno);return;}
 journal_result_t result=journal_scan(file,&log_bytes,&sequence);
 if(fclose(file)!=0)result=JOURNAL_IO_ERROR;
 if(result!=JOURNAL_OK){stopped=true;ESP_LOGE(TAG,"LOG_RECOVERY_REQUIRED result=%d: preserve original; no automatic truncate",result);}
 ESP_LOGI(TAG,"LOG_SCAN bytes=%u last_sequence=%"PRIu32" result=%d",(unsigned)log_bytes,sequence,result);
}
static void logger(void *unused) {
 (void)unused;
 for(;;) {
  xSemaphoreTake(mutex,portMAX_DELAY);uint32_t wait=interval_ms;
  if(mounted && !stopped) {
   char line[96];record_encode(boot_id,sequence+1,(uint64_t)(esp_timer_get_time()/1000),line,sizeof(line));
   FILE *file=fopen(path,"a");journal_result_t result=JOURNAL_IO_ERROR;
   if(file){result=journal_append(file,&log_bytes,line,fwrite);if(fclose(file)!=0)result=JOURNAL_IO_ERROR;}
   if(result==JOURNAL_OK){++sequence;ESP_LOGI(TAG,"APPEND seq=%"PRIu32" bytes=%u (not a power-cut durability proof)",sequence,(unsigned)log_bytes);}
   else {stopped=true;ESP_LOGE(TAG,"APPEND_STOP result=%d errno=%d: LIMIT=1, BAD_TAIL=2, IO=3; do not retry a possible partial tail",result,errno);}
  }
  xSemaphoreGive(mutex);vTaskDelay(pdMS_TO_TICKS(wait));
 }
}
static void status(void) {
 ESP_LOGI(TAG,"backend=%s mounted=%u stopped=%u NVS_ready=%u interval=%"PRIu32" bytes=%u cap=%u seq=%"PRIu32,path,mounted,stopped,nvs_ready,interval_ms,(unsigned)log_bytes,JOURNAL_LIMIT,sequence);
#if !CONFIG_DEMO_USE_SD
 if(mounted){size_t total,used;esp_err_t e=esp_spiffs_info("logs",&total,&used);ESP_LOGI(TAG,"SPIFFS info=%s total=%u used=%u (application cap is separate)",esp_err_to_name(e),(unsigned)(e==ESP_OK?total:0),(unsigned)(e==ESP_OK?used:0));}
#endif
}
void app_main(void) {
 mutex=xSemaphoreCreateMutex();if(!mutex)return;
 esp_err_t e=nvs_flash_init();nvs_ready=e==ESP_OK;
 if(nvs_ready)interval_ms=load_config();else ESP_LOGE(TAG,"NVS_INIT=%s: no automatic erase; file logging may still run with defaults",esp_err_to_name(e));
 e=mount_files();mounted=e==ESP_OK;ESP_LOGI(TAG,"MOUNT=%s; format_if_mount_failed=false",esp_err_to_name(e));
 if(mounted)inspect_log();
 boot_id=esp_random();
 if(xTaskCreate(logger,"append_log",4096,NULL,3,NULL)!=pdPASS){ESP_LOGE(TAG,"logger allocation failed");return;}
 ESP_LOGI(TAG,"Commands: status | set 1000..60000 | save | CLEAR LOG | FORMAT SPIFFS (destructive after backup)");
 char command[80];
 for(;;) {
  if(!fgets(command,sizeof(command),stdin)){clearerr(stdin);vTaskDelay(pdMS_TO_TICKS(100));continue;}
  size_t n=strcspn(command,"\r\n");command[n]=0;
  xSemaphoreTake(mutex,portMAX_DELAY);
  if(!strcmp(command,"status"))status();
  else if(!strncmp(command,"set ",4)){
   char *end;errno=0;unsigned long value=strtoul(command+4,&end,10);
   if(errno || end==command+4 || *end || value>UINT32_MAX || !interval_valid((uint32_t)value))ESP_LOGW(TAG,"SET_REJECTED");
   else {interval_ms=(uint32_t)value;ESP_LOGI(TAG,"runtime interval changed; use save for NVS");}
  } else if(!strcmp(command,"save")){e=save_config();ESP_LOGI(TAG,"NVS_SAVE=%s: failure does not erase or claim success",esp_err_to_name(e));}
  else if(!strcmp(command,"CLEAR LOG")){
   if(!mounted)ESP_LOGE(TAG,"Not mounted; no delete");
   else if(unlink(path)==0 || errno==ENOENT){stopped=false;log_bytes=0;sequence=0;ESP_LOGW(TAG,"Log cleared by explicit command");}
   else ESP_LOGE(TAG,"DELETE errno=%d",errno);
  } else if(!strcmp(command,"FORMAT SPIFFS")){
#if CONFIG_DEMO_USE_SD
   ESP_LOGW(TAG,"SD variant: formatting disabled; use computer after backup");
#else
   e=esp_spiffs_format("logs");ESP_LOGW(TAG,"EXPLICIT_FORMAT=%s",esp_err_to_name(e));
   if(e==ESP_OK){if(!mounted){e=mount_files();mounted=e==ESP_OK;}if(mounted)inspect_log();}
#endif
  } else ESP_LOGW(TAG,"Unknown command; no storage mutation");
  xSemaphoreGive(mutex);
 }
}

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[3] IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射[4] IDF5.5.5官方SDSPI/FAT挂载示例[5] IDF5.5.5 FATFS:VFS、同步和挂载选项

07 / 存储与恢复

按缺键、保存、重启、容量与损坏验收

首次用没有store_demo命名空间的本例环境,记录NVS_MISSING与默认2000;不要为制造缺键擦掉整片Flash的其他资料。set后不save再重启,应恢复上次保存/默认;save返回成功后分别RESET与断电重启,读取status确认间隔。

等待4096字节应用限额停写,检查旧记录仍在、不会删旧续写;另用已备份的测试介质制造真空间不足,保存实际errno/NVS错误与后续状态。真实卡满、NVS空间满不要用本机ENOSPC注入替代。

SD断电取卡后在电脑复制samples.csv,截去副本最后几个字节再写回测试卡,或更改一行内容但不改CRC;重上电应停在LOG_RECOVERY_REQUIRED且原件不被自动截断。SPIFFS故障先读出原始分区备份,再考虑esp_spiffs_check或离线恢复;本文不自动执行修复。

恢复时先保留原文件、分区表、错误码和固件SHA;确认可丢弃当前测试日志后才CLEAR LOG或FORMAT SPIFFS。若掉电造成整个文件系统不可挂载,行CRC模型无法保证救回它。上述实体步骤当前pending。

故障本例结果目前证据
NVS缺键/无效blob默认且不自动保存同源codec已测;NVS实板pending
4096字节应用限额停止,不删旧续写同源host已测;非真介质满
ENOSPC/短写I/O失败并停写;不盲目追加注入+真实主机半行已测;实体pending
CRC错/截断尾行启动扫描停止,保留原文件真实主机文件已测;SD/Flashpending
save后RESET/断电重新读取持久配置与日志pending

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[3] IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射[5] IDF5.5.5 FATFS:VFS、同步和挂载选项

08 / 存储与恢复

同源文件夹具与许可

下列检查直接编译固件使用的storage_model.h和journal.c,覆盖空文件、多启动ID/uptime重置、配置长度版本范围、应用cap、ENOSPC注入、短写形成的真实尾部和CRC错误。主机临时文件的fsync不代表ESP Flash或SD卡已验证。

NVS适合配置,SPIFFS适合少量本地文件,可取走的大日志再选SD;先按数据规模、断电恢复与分区预算选择,再扩展记录传感器。原创代码、图和本文MIT;IDF/FatFs/SPIFFS保留上游各自许可,THIRD_PARTY.md与lock随包提供。

代码 / 配置
cc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -Werror -I main host/host_check.c main/journal.c -o /tmp/e08-host
/tmp/e08-host

参考:[1] IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明[2] IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制[3] IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射[4] IDF5.5.5官方SDSPI/FAT挂载示例[5] IDF5.5.5 FATFS:VFS、同步和挂载选项

BACK TO THE SOURCE

资料来源与版本说明

固定SDK、组件源码和具体板级已核对;真实构建与同源主机检查已留证,触摸、I2C、联网、存储断电和调度实板结果均未补造。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. IDF5.5.5 NVS:适用数据、15字符键/命名空间、commit及断电说明
  2. IDF5.5.5 SPIFFS:平面路径、约75%可用量、GC和断电限制
  3. IDF5.5.5 SPIFFS实现:显式format、fsync和ENOSPC映射
  4. IDF5.5.5官方SDSPI/FAT挂载示例
  5. IDF5.5.5 FATFS:VFS、同步和挂载选项
  6. Adafruit PID254 microSD breakout:稳压、逻辑转换、DI/DO/CS和CD
  7. IDF5.5.5分区表解析:边界、对齐与分区字段
  8. IDF5.5.5 NVS API:get_blob长度、空间不足与commit