ESP32 的 NVS,我是踩过坑之后才真正搞懂的
一开始接触 NVS(Non-Volatile Storage)的时候,我对它的理解非常简单:
"不就是往 Flash 里存点数据吗?"
结果在真实项目里用起来之后才发现,
NVS 并不是一个"随便存、随便读"的东西 ,
而是一个有明确使用边界和工程语义的持久化存储机制。
这篇文章,我就完全基于我在 ESP32 / ESP32-S3 项目里用 NVS 的真实经验,把它讲清楚。
一、NVS 到底是个什么东西?
先说一句非常重要、但官方文档很少强调的话:
NVS 不是文件系统,也不是 Flash 的裸读写接口
NVS 的本质是:
- 位于 ESP32 Flash 中的一个独立分区
- 以 key-value(键值对) 的形式组织数据
- 面向的是:
配置参数、状态变量、少量业务数据
你可以把它理解成:
一个存在于 Flash 里的「持久化哈希表」
- key:字符串
- value:整数 / 字符串 / 二进制块
- 掉电不丢
- 重启还能接着用
📌 它最典型的使用场景包括:
- 设备重启计数
- WiFi / 蓝牙参数
- 设备 ID、序列号
- 校准参数
- 用户配置项
二、为什么 NVS 一定要初始化,而且还可能失败?
在真正开始用 NVS 之前,你必须初始化整个 NVS 分区:
c
esp_err_t nvs_flash_init(void);
这一步一般都会放在 app_main() 的最前面。
初始化为什么会失败?
在工程里,我实际遇到过的主要就是这两种:
ESP_ERR_NVS_NO_FREE_PAGESESP_ERR_NVS_NEW_VERSION_FOUND
这两种错误,本质上都在说一件事:
当前 NVS 分区的数据结构,已经不能正常使用了
所以官方给出的标准处理方式其实非常工程化:
c
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);
📌 这段代码我强烈建议你直接当模板用,原因很简单:
- 你迟早会遇到 NVS 版本变更
- 你迟早会在调试阶段写满 NVS
- 不处理,程序就会卡在初始化阶段
三、NVS 的"命名空间",比你想的重要
第一次用 NVS 的时候,我其实忽略了 nvs_open() 的第一个参数:
c
nvs_open("storage", NVS_READWRITE, &my_handle);
后来才意识到,这个 namespace(命名空间) 非常关键。
你可以把它理解为:
Flash 里的"逻辑文件夹"
-
同一个 namespace 下面
- key 不能重名
-
不同 namespace 之间
- key 可以相同,互不影响
📌 在稍微复杂一点的项目里,我一般会这样拆:
"sys":系统级参数"user":用户配置"cal":校准数据"stat":运行状态统计
这样做的好处是:
- 逻辑清晰
- 不容易误删
- 后期维护成本低
四、为什么 nvs_commit() 这么重要?
这是我踩过最真实的一次坑。
一开始我以为:
nvs_set_xxx()写了数据,就已经存进 Flash 了
但事实完全不是这样。
NVS 的写入流程是:
nvs_set_xxx()
👉 只是把数据写进 RAM 缓冲区nvs_commit()
👉 才是真正写入 Flash
也就是说:
在
nvs_commit()之前,所有数据都是"临时的"
如果你在 commit 之前:
- 重启
- 掉电
- 崩溃
👉 数据全部丢失
所以现在我的使用习惯是:
只要 set 过,就一定 commit
哪怕你觉得"这次改动不重要"。
五、NVS 能存什么?以及我一般怎么选
NVS 支持的类型其实非常全:
- 各种整型(i8 / u8 / i16 / u32 / i64 ...)
- 字符串
- 二进制 blob
我个人的使用经验是:
- 配置项、计数器
👉 直接用整型 - 设备名、版本号
👉 用nvs_set_str - 结构体 / 校准表 / 自定义数据
👉 用nvs_set_blob
比如存一个二进制数据块:
c
uint8_t blob_value[] = {0x01, 0x02, 0x03, 0x04};
nvs_set_blob(handle, "blob_key", blob_value, sizeof(blob_value));
📌 注意一点 :
NVS 不是数据库,不适合高频、大数据量写入。
六、读取 NVS 时,我一般会注意什么?
以 nvs_get_i32() 为例:
c
int32_t value = 0;
err = nvs_get_i32(handle, "restart_counter", &value);
这里有一个很容易被忽略的点:
-
如果 key 不存在
- 返回的不是你想象中的 0
- 而是一个错误码(通常是
ESP_ERR_NVS_NOT_FOUND)
所以在工程里,我一般会:
- 先给变量一个默认值
- 再根据返回值判断是否读取成功
七、完整示例:重启计数器(非常实用)
这个例子我非常喜欢,因为它完全符合 NVS 的设计初衷:
- 数据量小
- 写入频率低
- 掉电必须保留
每次启动:
- 从 NVS 读重启次数
- 自增
- 写回 Flash
- 延时后重启
c
#include <stdio.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"
static const char *TAG = "NVS"; // 定义日志标签
void app_main(void)
{
// 初始化 NVS
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
// NVS 分区被截断,需要擦除
// 重新初始化 nvs_flash
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
// 打开命名空间
ESP_LOGI(TAG, "打开非易失性存储 (NVS) 句柄...");
nvs_handle_t my_handle;
err = nvs_open("storage", NVS_READWRITE, &my_handle);
if (err != ESP_OK) {
ESP_LOGI(TAG, "打开 NVS 句柄时出错 (%s)!\n", esp_err_to_name(err));
} else {
// 读取重启计数器
ESP_LOGI(TAG, "从 NVS 读取重启计数器 ... ");
int32_t restart_counter = 0; // 如果 NVS 中未设置值,则默认为 0
err = nvs_get_i32(my_handle, "restart_counter", &restart_counter);
ESP_LOGI(TAG, "重启计数器 = %" PRIu32 "\n", restart_counter);
// 更新重启计数器
ESP_LOGI(TAG, "更新 NVS 中的重启计数器 ... ");
restart_counter++;
err = nvs_set_i32(my_handle, "restart_counter", restart_counter);
// 提交写入的值。设置任何值后,必须调用 nvs_commit() 以确保更改写入闪存存储。
ESP_LOGI(TAG, "提交 NVS 中的更改 ... ");
err = nvs_commit(my_handle);
// 关闭命名空间
nvs_close(my_handle);
}
ESP_LOGI(TAG, "\n");
// 重启模块
for (int i = 10; i >= 0; i--) {
ESP_LOGI(TAG, "将在 %d 秒后重启...\n", i);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
ESP_LOGI(TAG, "现在重启。\n");
fflush(stdout);
esp_restart();
}
📌 这种代码结构,在真实项目里是可以长期保留的。
八、我现在对 NVS 的"最终理解"
用到现在,我对 NVS 的定位已经非常清晰:
- 它不是存日志的
- 也不是存大数据的
- 更不是替代文件系统的
👉 它是用来"记住状态"的
如果一句话总结:
NVS = 少量、关键、必须跨重启存在的数据
只要你不越界使用,
NVS 会是 ESP32 系列里非常稳定、非常好用的一块基础设施。