配置和连续记录,不用同一个存法
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、同步和挂载选项
先读分区,再接具体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直通信号模块代入。
| DevKit | PID254 | 用途 |
|---|---|---|
| USB供电5V | 5V | 模块稳压输入 |
| GND | GND | 共地 |
| GPIO12 | CLK | SPI时钟;本例10MHz |
| GPIO11 | DI | MOSI,主控到卡 |
| GPIO13 | DO | MISO,卡到主控 |
| GPIO10 | CS | 片选 |
| 不接 | CD | 无热插拔监控 |
参考:[4] IDF5.5.5官方SDSPI/FAT挂载示例[6] Adafruit PID254 microSD breakout:稳压、逻辑转换、DI/DO/CS和CD[7] IDF5.5.5分区表解析:边界、对齐与分区字段
缺键是正常分支,保存需要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
追加文件之前先扫描,错误后停止
首次文件不存在是正常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、同步和挂载选项
完整构建与首次挂载路径
下载包包含根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分区表解析:边界、对齐与分区字段
完整入口: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、同步和挂载选项
按缺键、保存、重启、容量与损坏验收
首次用没有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、同步和挂载选项
同源文件夹具与许可
下列检查直接编译固件使用的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、同步和挂载选项
资料来源与版本说明
固定SDK、组件源码和具体板级已核对;真实构建与同源主机检查已留证,触摸、I2C、联网、存储断电和调度实板结果均未补造。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。