ESP32 工程实战:用 NVS 做可校验、可迁移、可恢复的配置系统

做小实验时,把 Wi-Fi 名称和采样周期写死在代码里完全没问题;做成要交给别人使用的设备后,情况马上变了:用户需要改网络,现场需要校准,固件升级后旧配置还不能丢。NVS 的价值不在于“能存一个字符串”,而在于把配置当成一个有版本、有规则、能自我修复的小系统。

NVS 配置系统:默认值、校验与迁移

一、NVS 与 Preferences 的边界

ESP32 的 NVS 是掉电保持的键值存储,Arduino-ESP32 用 Preferences 对它做了更友好的封装。命名空间和键名目前通常限制为 15 个 ASCII 字符,适合很多小配置,不适合存大量日志、图片或音频。大文件应该放 LittleFS、SD 卡或外部存储。

本文不重复“调用 putString() 保存 Wi-Fi 名称”的入门示例,而是讨论几个真正会在项目里出现的问题:如何给配置加版本号,如何处理旧字段,如何拒绝非法输入,如何区分密码与普通参数,如何恢复出厂,以及如何避免每次循环都写闪存。

二、先定义配置结构和默认值

把配置集中定义,避免业务代码到处散落 preferences.getInt("period", 1000)。结构体里存运行时使用的值,Preferences 里存持久化字段,两者之间通过加载和保存函数转换。

#include <Preferences.h>

struct AppConfig {
  uint16_t schemaVersion = 2;
  char deviceName[24] = "esp32-node";
  uint32_t samplePeriodMs = 1000;
  bool enableWeb = true;
  char mqttHost[64] = "";
  uint16_t mqttPort = 1883;
};

AppConfig config;
Preferences prefs;

void migrateConfig(AppConfig& c, uint16_t oldVersion);

void setDefaults(AppConfig& c) {
  c = AppConfig{};
}

默认值要能让设备在“完全没有配置”时启动起来。例如采样周期给一个保守值,Web 配置页默认打开,MQTT 主机为空时不主动连接。默认值不是临时凑出来的,它是设备的安全降级状态。

三、加载时必须校验

NVS 中的字符串可能来自上一个版本的固件,也可能被用户网页输入了空值。读取成功不代表内容有效,所以加载流程应当是:先设置默认值,再读取持久化字段,最后统一校验。

bool validConfig(const AppConfig& c) {
  if (c.samplePeriodMs < 100 || c.samplePeriodMs > 60000) return false;
  if (c.mqttPort == 0) return false;
  if (strlen(c.deviceName) == 0) return false;
  return true;
}

void copySafe(char* dst, size_t size, const String& value) {
  value.substring(0, size - 1).toCharArray(dst, size);
}

bool loadConfig(AppConfig& c) {
  setDefaults(c);
  if (!prefs.begin("appcfg", true)) return false;

  uint16_t version = prefs.getUShort("version", 1);
  copySafe(c.deviceName, sizeof(c.deviceName),
           prefs.getString("name", c.deviceName));
  c.samplePeriodMs = prefs.getUInt("period", c.samplePeriodMs);
  c.enableWeb = prefs.getBool("web", c.enableWeb);
  copySafe(c.mqttHost, sizeof(c.mqttHost),
           prefs.getString("mqtt", c.mqttHost));
  c.mqttPort = prefs.getUShort("port", c.mqttPort);
  prefs.end();

  if (version < c.schemaVersion) migrateConfig(c, version);
  if (!validConfig(c)) {
    setDefaults(c);
    return false;
  }
  return true;
}

这里的示例把 Preferences 命名空间打开为只读,加载阶段不应该偷偷修改闪存。copySafe() 保证 C 字符串有结尾零,并截断过长输入。实际工程里还可以增加 UTF-8 长度、主机名字符集和 MQTT 地址格式检查。

四、版本迁移比“清空重来”更友好

固件 v1 可能只有 period 和 web,v2 新增 MQTT;如果升级后直接把所有键清空,用户需要重新配置,现场设备越多越痛苦。更好的方法是保存 schemaVersion,按版本执行小步迁移。

void migrateConfig(AppConfig& c, uint16_t oldVersion) {
  if (oldVersion < 2) {
    // v2 新增 MQTT,保持默认关闭/空主机,不影响旧设备运行
    c.mqttHost[0] = '\\0';
    c.mqttPort = 1883;
  }
  c.schemaVersion = 2;
}

如果版本跨度很大,不要写一个充满 if 的巨型函数,可以让 migrateV1ToV2()、migrateV2ToV3() 各自只处理一段变化。迁移完成后再把新版本写回 NVS。这样即使设备中途断电,也能在下一次启动时继续判断状态,而不是把所有旧配置解释成乱码。

五、保存要做到“少写、可验证”

NVS 有擦写寿命,不能在 loop() 里每秒保存一次温度。Web 表单提交时先比较新旧配置,确实变化才写;写入后再读回关键字段验证。对于多个相互关联的字段,可以先写临时键和校验标记,全部完成后再切换活动版本。

bool saveConfig(const AppConfig& c) {
  if (!validConfig(c)) return false;
  if (!prefs.begin("appcfg", false)) return false;

  bool ok = true;
  ok &= prefs.putUShort("version", c.schemaVersion) > 0;
  ok &= prefs.putString("name", c.deviceName) > 0;
  ok &= prefs.putUInt("period", c.samplePeriodMs) > 0;
  ok &= prefs.putBool("web", c.enableWeb);
  ok &= prefs.putString("mqtt", c.mqttHost) > 0;
  ok &= prefs.putUShort("port", c.mqttPort) > 0;
  prefs.end();
  return ok;
}

putBool() 返回值和其他 putXxx() 的类型细节可能随库版本略有差别,编译时以当前头文件为准。重点是不要把“调用函数没有崩溃”当成保存成功,失败时应该在日志中记录原因,并保留 RAM 中最后一份有效配置。

六、Web 配置页应该是一个受控入口

网页输入永远是不可信的。采样周期只能在合理范围内,端口不能为零,设备名称要限制长度,密码字段不能在页面回显。提交成功后给用户一个明确提示,并决定是否立即重启;网络配置通常需要重启网络栈,而普通阈值则可以热更新。

void handleConfigPost() {
  AppConfig next = config;
  if (server.hasArg("period")) {
    long value = server.arg("period").toInt();
    if (value < 100 || value > 60000) {
      server.send(400, "text/plain; charset=utf-8", "周期范围错误");
      return;
    }
    next.samplePeriodMs = (uint32_t)value;
  }
  if (server.hasArg("name")) {
    copySafe(next.deviceName, sizeof(next.deviceName), server.arg("name"));
  }
  if (!validConfig(next) || !saveConfig(next)) {
    server.send(500, "text/plain; charset=utf-8", "配置未保存");
    return;
  }
  config = next;
  server.send(200, "text/plain; charset=utf-8", "配置已保存");
}

正式设备至少加一个管理口令,最好限制配置页只在局域网开放。NVS 里的 Wi-Fi 密码和 MQTT 密码不是加密保险箱,物理上拿到设备的人仍可能读取闪存,所以高安全场景要结合安全启动、Flash 加密和密钥管理。

七、恢复出厂和“救援模式”

用户最怕设备改错网络后再也找不到。可以设计一个物理按键:上电时按住 5 秒,清除网络配置但保留设备序列号;或者进入临时 AP,提供恢复页面。恢复操作要有明确提示,避免普通按键误触发。

void factoryReset() {
  if (!prefs.begin("appcfg", false)) return;
  prefs.clear();
  prefs.end();
  setDefaults(config);
}

官方 Preferences 文档也提醒,库没有“删除整个命名空间”的方法,长期迭代时要有意识地管理键名和空间,不能每个版本都随手创建新 namespace。清空当前 namespace 后仍然保留 namespace 本身,这是正常行为。

八、配置的完整生命周期

配置生命周期:加载、修复、保存和恢复出厂

启动时先读取版本,再做迁移和校验;运行中只使用 RAM 中的 config;用户修改时生成副本、校验、保存、回读;出现异常时回退默认值并记录原因。这个生命周期让业务模块不用知道 NVS 细节,只依赖一个可信的配置对象。

建议日志里输出“配置来源”和“版本”,例如 config: defaults、config: nvs v2、config: migrated v1->v2。排查现场问题时,这两行日志往往比一屏传感器数值更有用。

九、常见问题

每次重启都恢复默认

确认 begin() 的 namespace 拼写一致,写入后是否调用了 end();确认使用的是同一分区;不要在启动流程后面无条件调用 factoryReset()。如果字段名超过 15 个字符,Preferences 可能无法按预期保存。

改了网页配置,业务模块没变化

通常是只更新了 NVS,没有更新 RAM 中正在运行的 config,或者业务任务把旧值复制到了自己的全局变量。可以在配置保存成功后发布一个“配置更新”事件,让各模块重新读取。

NVS 空间越来越紧张

检查是否为每次配置提交生成了不同的键名或 namespace;大文本、日志和 JSON 不要塞进 Preferences;升级时保持键名稳定,必要时在维护版本中做一次清理和迁移。

十、总结

NVS 的入门代码只有几行,但可靠的配置系统需要默认值、版本、校验、迁移、写入策略和恢复路径。把这些逻辑集中起来,未来不论是加 Web 配置、接 Home Assistant,还是把项目交给别人使用,都会轻松很多。

真正成熟的设备不是“永远不出错”,而是出错后能回到可用状态,并且告诉我们它为什么这样做。ESP32 的配置层就是实现这件事的一个很小、但非常关键的练习。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-29 09:32  潇洒石头  阅读(15)  评论(0)    收藏  举报

导航