ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ESP32 NVS 配置管理:浏览器直接读写键值,告别重新烧录

ESP32 NVS 配置管理:浏览器直接读写键值,告别重新烧录 1. 从一个让人抓狂的场景说起如果你玩过 ESP32大概率经历过这个场景设备已经焊好、装进壳子、挂在墙上跑了三个月突然要换个 WiFi 密码。你翻出数据线拆壳插 USB打开 Arduino IDE 或者 ESP-IDF改两行代码重新编译等两三分钟烧录再装回去。整个过程十分钟起步如果设备装在不好够到的地方那更是灾难。问题的根源在于很多人把 WiFi 的 SSID 和密码直接写死在代码里编译进固件。每次改配置就等于改代码改代码就得重新烧录。这在开发阶段无所谓但到了部署阶段尤其是小批量出货或者自己家里用这种方式的维护成本高得离谱。其实 ESP32 的 NVSNon-Volatile Storage非易失性存储天生就是干这个的。它可以在 Flash 里划出一块区域专门存键值对掉电不丢。WiFi 配置、设备参数、校准数据全都可以塞进去。改配置只需要改 NVS 里的值根本不用碰固件。但新的问题来了怎么改 NVS常规做法是串口命令行、或者自己写个 APP 通过蓝牙改。前者需要接线后者开发量大。有没有更轻的办法有——ESP32 本身可以跑一个内嵌 Web 服务器浏览器打开一个页面直接读写 NVS 键值。这就是标题里说的“浏览器工具直接改 NVS 键值”的核心思路。这篇文章我会把整套方案从头拆到尾为什么选 NVS 而不是其他存储方式、Web 服务器怎么搭、NVS 读写有哪些坑、页面怎么做才能不丑、以及实际部署中我踩过的那些雷。适合已经会用 Arduino 或 ESP-IDF 点灯、但对 NVS 和 Web 服务器还不太熟的朋友也适合想给自己设备加一个“配置页面”的老手参考。2. 整体方案设计与选型思路2.1 为什么是 NVS而不是 SPIFFS 或 PreferencesESP32 上能持久化存数据的地方主要有三个NVS、SPIFFS/LittleFS、以及直接操作 Flash 分区。很多人第一反应是用 SPIFFS 存一个 config.json读出来解析就行。这个方案能用但有几个问题。SPIFFS 是文件系统适合存大块数据、日志、网页资源。但它的写入寿命和碎片问题比较明显频繁小量写入容易导致文件系统损坏。而 NVS 是专门为小键值对设计的内部做了磨损均衡和掉电保护写一个几十字节的字符串非常稳。ESP32 的 WiFi 库本身在底层就是用 NVS 存 WiFi 配置的你用WiFi.begin()连过的网络下次上电会自动重连靠的就是 NVS。至于 Arduino 的 Preferences 库它其实就是 NVS 的一层封装用起来更简单。但如果你要做一个通用的“键值编辑器”直接操作 NVS 的 API 更灵活因为 Preferences 需要提前知道命名空间和键名而 NVS 可以枚举。所以选型结论很清晰配置类的小数据用 NVS网页资源用 SPIFFS 或直接内嵌成字符串。两者分工明确不要混用。2.2 Web 服务器方案同步还是异步ESP32 上跑 Web 服务器有两个主流库WebServer同步和ESPAsyncWebServer异步。同步库的优点是简单、稳定、依赖少缺点是每个请求会阻塞主循环如果同时有多个请求或者请求处理慢会影响其他任务。异步库基于 AsyncTCP能同时处理多个连接响应更快但依赖较多编译配置稍微麻烦一点。对于“配置页面”这种场景其实同步库完全够用。因为改配置是低频操作一次就一个用户在操作不存在高并发。而且同步库的代码更直观出问题好排查。我实测下来用WebServer库做一个配置页面响应时间在几十毫秒级别体验完全没问题。如果你还想在同一个设备上跑 WebSocket 做实时数据推送那就得上异步库。但那是另一个话题了本文聚焦配置读写。2.3 页面交互设计表单 列表页面要做的事情就两件列出当前所有 NVS 键值以及修改或新增某个键值。列表用表格展示每行显示命名空间、键名、类型、当前值后面跟一个“编辑”按钮。点击编辑弹出一个表单改完提交后端写入 NVS然后刷新列表。这里有个细节NVS 的值有类型之分整数、字符串、二进制 blob。页面上要能区分。最简单的做法是统一按字符串处理写入时根据用户选择的类型做转换。比如用户选“整数”后端就用nvs_set_i32选“字符串”就用nvs_set_str。另外NVS 的命名空间namespace是个容易忽略的点。一个 NVS 分区里可以有多个命名空间每个命名空间下才是键值对。WiFi 配置默认在nvs.net80211这个命名空间里但你自己存的数据可以另起一个比如myconfig。页面上要能切换命名空间否则用户找不到自己存的键。2.4 安全边界这个工具能做什么不能做什么必须说清楚这个浏览器工具是局域网内使用的。设备连上 WiFi 后你通过它的 IP 访问配置页面。它不应该暴露到公网也不应该在没有认证的情况下允许任何人修改。我的做法是加一个简单的 Token 认证页面首次访问时要求输入一个预设的密码后端校验通过后下发一个临时 Token后续请求带上这个 Token。虽然不算强安全但足以防止局域网内其他人误改。另外不要用这个工具去改 WiFi 本身的 SSID 和密码除非你很清楚自己在做什么。因为改完 WiFi 配置后设备会断开重连如果新密码错了你就再也连不上了只能重新烧录。这个坑我后面会详细讲。3. 核心细节解析与实操要点3.1 NVS 的命名空间与键名规则NVS 的命名空间名字最长 15 个字符键名最长 15 个字符。这个限制很多人不知道写代码时用了一个长名字结果nvs_set_str返回ESP_ERR_NVS_KEY_TOO_LONG排查半天。命名空间和键名都只允许可打印 ASCII 字符不能有空格和特殊符号。建议用下划线分隔比如wifi_config、device_id。还有一个隐藏规则NVS 的键名在同一个命名空间内必须唯一。如果你用nvs_set_str写一个已存在的键它会覆盖旧值。这正好符合“修改配置”的需求。3.2 读取所有键值的正确姿势NVS 提供了迭代器 API 来枚举命名空间下的所有键。核心流程是打开命名空间nvs_open(namespace, NVS_READONLY, handle)创建迭代器nvs_entry_find(partition, namespace, NVS_TYPE_ANY, it)循环获取nvs_entry_info(it, info)拿到键名和类型根据类型读取值nvs_get_str、nvs_get_i32等移动到下一个nvs_entry_next(it)释放迭代器nvs_release_iterator(it)这里有个坑nvs_entry_find的第一个参数是分区名通常传NULL表示默认的nvs分区。如果你自定义了分区表要传对应的分区名。另一个坑是字符串读取。nvs_get_str需要先传NULL获取长度再分配缓冲区读取。如果直接传一个固定大小的缓冲区长度不够会返回ESP_ERR_NVS_INVALID_LENGTH。size_t len 0; esp_err_t err nvs_get_str(handle, key, NULL, len); if (err ESP_OK) { char *buf malloc(len); nvs_get_str(handle, key, buf, len); // 使用 buf free(buf); }3.3 写入时的类型转换与错误处理页面上用户输入的都是字符串后端要根据用户选择的类型做转换。整数用atoi或strtol浮点数要注意 NVS 本身不直接支持 float需要转成 blob 或者用整数加小数点位数的方式存。写入前一定要检查返回值。NVS 写入失败常见原因有命名空间不存在需要先nvs_open用NVS_READWRITE、空间不足NVS 分区满了、键名太长。如果写入的是 WiFi 相关的键比如ssid和password写完还要调用esp_wifi_set_config或者WiFi.begin让配置生效。但这一步要谨慎后面会讲。3.4 Web 页面的最小实现页面不需要多漂亮但要能用。我用的是一个单页 HTML内嵌在固件的字符串里。核心元素一个下拉框选择命名空间一个表格显示键值列表一个模态框用于编辑一个刷新按钮前端用原生 JavaScript 发fetch请求后端用WebServer注册几个路由GET /返回 HTML 页面GET /api/list?nsxxx返回 JSON 格式的键值列表POST /api/set接收 JSON写入 NVSPOST /api/delete删除某个键JSON 的生成和解析在 ESP32 上可以用ArduinoJson库非常方便。注意 JSON 文档大小要预估好键值多的时候别溢出。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我用的环境是 Arduino IDE 2.x ESP32 开发板包 2.0.x。如果你用 ESP-IDF 也一样API 是同一套。需要的库WiFi.h自带WebServer.h自带ArduinoJson库管理器安装版本 6.xnvs_flash.hESP-IDF 自带如果你用 PlatformIO在platformio.ini里加lib_deps bblanchon/ArduinoJson^6.21.0编译速度慢是 ESP32 的常态尤其是第一次编译。建议开启编译缓存Arduino IDE 在首选项里可以设置。另外如果你用的是离线包确保版本和开发板包匹配否则会出现奇怪的链接错误。4.2 NVS 初始化与分区确认ESP32 上电后NVS 默认会自动初始化。但如果你在代码里调用了nvs_flash_erase或者分区表被改过就需要手动初始化esp_err_t err nvs_flash_init(); if (err ESP_ERR_NVS_NO_FREE_PAGES || err ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); err nvs_flash_init(); }这段代码很关键。当 NVS 分区版本不匹配或者没有空闲页时必须擦除重新初始化否则后续所有 NVS 操作都会失败。分区表方面默认的default.csv里 NVS 分区通常是0x9000开始大小0x500020KB。对于存配置来说够用但如果你要存很多键值可以调大。改分区表后要重新烧录分区表否则不生效。4.3 读取 NVS 并生成 JSON 列表这是后端最核心的一段逻辑。我把它封装成一个函数传入命名空间返回 JSON 字符串。String listNVS(const char* namespace) { DynamicJsonDocument doc(4096); JsonArray arr doc.createNestedArray(items); nvs_handle_t handle; esp_err_t err nvs_open(namespace, NVS_READONLY, handle); if (err ! ESP_OK) { doc[error] namespace not found; String out; serializeJson(doc, out); return out; } nvs_iterator_t it NULL; err nvs_entry_find(nvs, namespace, NVS_TYPE_ANY, it); while (err ESP_OK it ! NULL) { nvs_entry_info_t info; nvs_entry_info(it, info); JsonObject obj arr.createNestedObject(); obj[key] info.key; obj[type] info.type; if (info.type NVS_TYPE_STR) { size_t len 0; nvs_get_str(handle, info.key, NULL, len); char *buf (char*)malloc(len); nvs_get_str(handle, info.key, buf, len); obj[value] buf; free(buf); } else if (info.type NVS_TYPE_I32) { int32_t v; nvs_get_i32(handle, info.key, v); obj[value] v; } // 其他类型类似处理 err nvs_entry_next(it); } nvs_release_iterator(it); nvs_close(handle); String out; serializeJson(doc, out); return out; }注意DynamicJsonDocument的大小。4096 字节大概能存几十个键值对。如果键值很多要调大或者改用流式输出。4.4 写入 NVS 的完整流程写入比读取复杂一点因为要处理类型转换和错误。bool setNVS(const char* namespace, const char* key, const char* value, int type) { nvs_handle_t handle; esp_err_t err nvs_open(namespace, NVS_READWRITE, handle); if (err ! ESP_OK) return false; if (type NVS_TYPE_I32) { int32_t v atoi(value); err nvs_set_i32(handle, key, v); } else if (type NVS_TYPE_STR) { err nvs_set_str(handle, key, value); } if (err ! ESP_OK) { nvs_close(handle); return false; } err nvs_commit(handle); nvs_close(handle); return err ESP_OK; }nvs_commit是必须的它把缓存的数据真正写入 Flash。不调用 commit数据可能还在内存里掉电就丢了。4.5 Web 路由注册与请求处理用WebServer库注册路由WebServer server(80); server.on(/, HTTP_GET, []() { server.send(200, text/html, INDEX_HTML); }); server.on(/api/list, HTTP_GET, []() { String ns server.arg(ns); String json listNVS(ns.c_str()); server.send(200, application/json, json); }); server.on(/api/set, HTTP_POST, []() { String body server.arg(plain); DynamicJsonDocument doc(512); deserializeJson(doc, body); String ns doc[ns]; String key doc[key]; String value doc[value]; int type doc[type]; bool ok setNVS(ns.c_str(), key.c_str(), value.c_str(), type); server.send(200, application/json, ok ? {\ok\:true} : {\ok\:false}); }); server.begin();然后在loop()里调用server.handleClient()。注意handleClient要频繁调用否则请求会超时。4.6 页面 HTML 与前端逻辑页面我直接内嵌成一个大字符串。核心结构!DOCTYPE html html head meta charsetutf-8 titleNVS 配置/title style body { font-family: sans-serif; margin: 20px; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid #ccc; padding: 8px; } button { padding: 6px 12px; } /style /head body h2NVS 键值管理/h2 select idns option valuemyconfigmyconfig/option option valuenvs.net80211nvs.net80211/option /select button onclickloadList()刷新/button table idtbl theadtrth键名/thth类型/thth值/thth操作/th/tr/thead tbody/tbody /table script async function loadList() { const ns document.getElementById(ns).value; const res await fetch(/api/list?ns ns); const data await res.json(); const tbody document.querySelector(#tbl tbody); tbody.innerHTML ; (data.items || []).forEach(item { const tr document.createElement(tr); tr.innerHTML td${item.key}/tdtd${item.type}/tdtd${item.value}/td tdbutton onclickedit(${item.key},${item.value},${item.type})编辑/button/td; tbody.appendChild(tr); }); } function edit(key, value, type) { const nv prompt(新值, value); if (nv null) return; const ns document.getElementById(ns).value; fetch(/api/set, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ns, key, value: nv, type}) }).then(() loadList()); } loadList(); /script /body /html这个页面很简陋但功能完整。实际用的时候可以加个密码框、加个新增键的按钮、加个删除按钮。4.7 实测记录从烧录到改配置我第一次测试时设备连上 WiFi 后串口打印出 IP浏览器打开页面正常加载。列表里能看到myconfig命名空间下的几个测试键。改了一个字符串值提交后刷新值变了。断电重启值还在。整个过程不到两分钟。但中间遇到一个问题nvs_entry_find在 Arduino 环境下编译报错提示找不到函数。查了一下是因为 Arduino 的 ESP32 核心默认没有暴露这个 API。解决办法是在代码里包含nvs_flash.h和nvs.h并且确保开发板包版本在 2.0 以上。如果还是不行可以改用Preferences库的getString配合已知键名列表但那样就不能枚举了。5. 常见问题与排查技巧实录5.1 NVS 操作返回错误码速查错误码含义常见原因解决办法ESP_ERR_NVS_NOT_FOUND键不存在键名拼错或未写入检查键名先写入再读取ESP_ERR_NVS_INVALID_LENGTH缓冲区长度不对读取字符串时长度不够先获取长度再分配ESP_ERR_NVS_NO_FREE_PAGES没有空闲页分区满或未初始化擦除 NVS 重新初始化ESP_ERR_NVS_KEY_TOO_LONG键名太长超过 15 字符缩短键名ESP_ERR_NVS_NAMESPACE_NOT_FOUND命名空间不存在未打开或拼错用 READWRITE 打开会自动创建5.2 页面打不开的排查顺序串口是否打印了 IP 地址没有的话检查 WiFi 连接代码。设备和电脑是否在同一网段手机热点和路由器可能隔离设备。防火墙是否拦截了 80 端口临时关闭试试。server.handleClient()是否在loop()里被调用忘了调用是最常见的原因。路由注册是否在server.begin()之前顺序错了路由不生效。5.3 改 WiFi 配置导致设备失联的预防这是最大的坑。如果你通过页面改了nvs.net80211里的ssid或password设备下次重连会用新配置。如果新密码错了设备就连不上了你只能重新烧录。预防措施不要通过这个工具改 WiFi 配置。如果非要改加一个“测试连接”按钮先用新配置尝试连接成功了再写入 NVS。或者保留一个串口恢复通道实在不行还能接线救回来。5.4 编译速度慢的优化ESP32 编译慢是出了名的。几个优化点使用 PlatformIO 的build_cache或者 Arduino IDE 的编译缓存减少不必要的库依赖把网页资源放到 SPIFFS 而不是内嵌字符串减少编译时的字符串处理如果只是改网页不需要重新编译固件直接上传 SPIFFS 就行5.5 内存不足导致页面加载失败ESP32 的 RAM 有限如果 JSON 文档太大或者同时处理多个请求可能内存不足。表现是页面加载一半卡住或者返回 500 错误。解决办法减小DynamicJsonDocument的大小分页返回键值列表或者改用流式 JSON 输出。另外WebServer库的默认缓冲区也可以调整但一般不用动。6. 一些实操心得与扩展思路这个方案我用了大半年部署了十几台设备整体很稳。最大的感受是把配置和固件解耦维护成本直线下降。以前改一个参数要重新烧录现在浏览器点两下就完事。有几个小技巧可以分享。第一给页面加一个“导出配置”按钮把所有键值导成 JSON 文件下载换设备时直接导入省得一个个填。第二加一个“恢复默认”按钮把关键配置重置成出厂值。第三如果设备多可以做一个简单的发现协议让页面自动列出局域网内所有同类设备。扩展方向也很多。比如把 NVS 配置和 MQTT 结合设备上线后自动从服务器拉配置写入 NVS。或者做一个 OTA 升级页面配合 NVS 里的版本号做灰度发布。再或者把页面做成响应式的手机浏览器也能舒服地操作。最后提醒一句这个工具的核心价值是“方便”但方便的前提是安全。局域网内用没问题千万别把它暴露到公网。密码认证、Token 校验、操作日志这些该加就加别偷懒。
返回列表