ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32 NVS 浏览器配置工具:免重刷固件,局域网内直接读写键值对

ESP32 NVS 浏览器配置工具:免重刷固件,局域网内直接读写键值对 1. 从一个让人抓狂的场景说起如果你玩过 ESP32大概率经历过这个场景设备已经焊好、装进壳子、挂在墙上突然要换个 WiFi 密码。按照传统做法你得翻出 USB 线拆壳接串口打开 Arduino IDE 或者 ESP-IDF改代码里的ssid和password重新编译重新烧录运气不好还得按住 BOOT 键进下载模式。整个过程少说十分钟多则半小时如果设备装在不好够到的地方那更是灾难。这个项目的核心思路就是解决这个痛点在 ESP32 上跑一个轻量级的 Web 服务器通过浏览器直接读写 NVSNon-Volatile Storage里的键值对改完立即生效不用重刷固件。NVS 是 ESP32 非易失性存储分区里的一块区域专门用来存 WiFi 配置、设备参数、用户设置这类需要掉电保存的数据。ESP-IDF 和 Arduino 框架都提供了 NVS 的 API但平时我们都是在代码里写死或者通过串口命令去改很少有人把它做成一个可视化的浏览器界面。这个工具适合谁用如果你手上有 ESP32 项目需要频繁调整参数或者你做的产品要交付给非技术用户让他们自己配网又或者你单纯想搞清楚 NVS 到底怎么玩那这套方案都值得参考。它不依赖任何云服务不需要额外的 App只要设备连上网络同一局域网内打开浏览器就能操作。下面我把整个设计思路、关键代码、踩过的坑和实操细节全部拆开讲一遍。2. 整体设计思路与方案选型2.1 为什么选 NVS 而不是 SPIFFS 或 SD 卡ESP32 上能存数据的地方有好几个NVS、SPIFFS/LittleFS、SD 卡、外部 EEPROM。存 WiFi 密码这种小配置NVS 是最合适的。原因有三点第一NVS 是键值对结构读写单个键不需要操作整个文件系统速度快、磨损小第二NVS 有磨损均衡和掉电保护机制适合频繁写入的场景第三ESP-IDF 的 WiFi 驱动本身就把配网信息存在 NVS 里你用esp_wifi_set_config设置后它会自动持久化我们直接操作同一块区域改完重启就能生效。SPIFFS 适合存大块的配置文件或者网页资源但它的写入粒度和磨损管理不如 NVS 精细。SD 卡需要额外硬件而且文件系统挂载失败的概率不低。所以这个项目里NVS 是唯一合理的选择。2.2 Web 服务器选型ESPAsyncWebServer 还是同步方案ESP32 上跑 Web 服务器有两个主流选择ESP-IDF 自带的httpd组件和社区维护的ESPAsyncWebServer。我选的是后者理由很直接异步服务器不会阻塞主循环处理请求的同时 WiFi 连接和传感器读取不受影响。同步方案在处理请求时会卡住整个 loop如果你同时还在跑 MQTT 或者读传感器体验会很差。ESPAsyncWebServer配合AsyncTCP库在 Arduino 环境下几行代码就能起一个服务器。它支持 WebSocket这意味着你可以做实时刷新——改完一个键值页面上立刻显示新值不用手动刷新。对于配置工具来说这个体验提升很明显。2.3 前端方案单页 HTML 内嵌还是外部文件前端我选择把 HTML、CSS、JS 全部内嵌在固件的 PROGMEM 里通过server.on(/, HTTP_GET, ...)直接返回。这样做的好处是零依赖设备烧好之后不需要额外上传文件系统换台电脑、换个浏览器都能用。缺点是固件体积会大几 KB但对于 ESP32 动辄 4MB 的 Flash 来说完全可以接受。页面结构很简单一个表格列出当前 NVS 里所有的键值对每行有编辑和删除按钮底部有一个新增键值的表单。所有操作通过 fetch 发 POST 请求到/api/nvs服务器处理后返回 JSON前端更新表格。没有用任何前端框架原生 JS 足够加载速度快兼容性也好。2.4 安全边界局域网内使用不做公网暴露这里必须说清楚一个前提这个工具设计为仅在局域网内使用。它没有做用户认证也没有加密传输。如果你把 ESP32 的 Web 端口映射到公网任何人都能改你的配置这是极其危险的。正确的做法是设备连上家里或办公室的 WiFi你通过内网 IP 访问。如果需要远程管理应该走正规的 IoT 平台或者自己搭一套带认证的网关而不是直接把设备暴露出去。另外NVS 里可能存着 WiFi 密码本身所以这个工具在读取键值的时候对于wifi_password这类敏感键前端应该做掩码显示后端返回时也要考虑是否脱敏。我在实现里加了一个简单的规则键名包含pass、key、token的值默认显示为******点击“显示”按钮才明文展示。这个细节虽然简单但能避免很多尴尬。3. 核心细节解析与实操要点3.1 NVS 的命名空间与键值类型NVS 不是简单的全局键值对它有一个“命名空间”namespace的概念。你可以把它理解成数据库里的表不同的命名空间下可以有同名的键。ESP-IDF 的 WiFi 驱动默认使用nvs.net80211这个命名空间来存配置而用户自己的数据通常放在storage或者自定义的命名空间里。在 Arduino 环境下用Preferences库操作 NVS 最方便。它封装了底层的nvs_open、nvs_get_*、nvs_set_*代码写起来很简洁#include Preferences.h Preferences prefs; void setup() { prefs.begin(myapp, false); // 命名空间 myappfalse 表示读写模式 prefs.putString(wifi_ssid, MyHome); prefs.putString(wifi_pass, 12345678); prefs.putInt(led_bright, 128); prefs.end(); }但Preferences库有一个限制它不支持遍历命名空间下所有的键。也就是说你没法直接列出myapp里存了哪些键。这在做配置管理页面时是个问题因为你不知道用户存了什么。解决办法有两个一是自己维护一个键名列表存在另一个固定的键里二是直接用底层的 NVS API通过nvs_entry_find和nvs_entry_next遍历。我选的是第二种因为更通用。底层 API 稍微繁琐一点但能拿到完整的键值对列表包括类型信息。下面是遍历的核心代码片段nvs_iterator_t it nullptr; esp_err_t res nvs_entry_find(nvs, myapp, NVS_TYPE_ANY, it); while (res ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, info); // info.key 是键名info.type 是类型 // 根据类型调用 nvs_get_str / nvs_get_i32 等读取值 res nvs_entry_next(it); } nvs_release_iterator(it);注意nvs_entry_find的第一个参数是分区名通常是nvs第二个是命名空间。如果你传NULL作为命名空间它会遍历所有命名空间但那样键名可能会重复处理起来麻烦。建议还是按命名空间分开查。3.2 键值类型处理与 JSON 序列化NVS 支持的类型有u8、i8、u16、i16、u32、i32、u64、i64、str、blob。在 Web 页面上我们主要处理字符串和整数浮点数可以转成字符串存或者用 blob 存二进制。为了简化我在 API 层做了统一所有值都以字符串形式传输后端根据 NVS 里的实际类型做转换。读取时先通过nvs_entry_info拿到类型然后分别处理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); // buf 就是字符串值 free(buf); } else if (info.type NVS_TYPE_I32) { int32_t val; nvs_get_i32(handle, info.key, val); // val 是整数值 }写入时前端提交的 JSON 里包含key、value、type三个字段。后端根据type调用对应的nvs_set_*。如果键已存在但类型不同先nvs_erase_key再写入否则会返回类型不匹配的错误。JSON 序列化我用的是ArduinoJson库版本 6.x。它内存占用可控支持动态文档适合这种小规模数据。生成响应时先创建一个DynamicJsonDocument把键值对塞进去然后serializeJson成字符串返回。注意文档大小要估算好一般 2KB 到 4KB 足够如果键值对很多可以适当加大或者分页返回。3.3 Web 服务器的路由设计与请求处理路由设计遵循 RESTful 风格尽量简洁方法路径功能GET/返回配置页面 HTMLGET/api/nvs获取所有键值对POST/api/nvs新增或修改一个键值DELETE/api/nvs?keyxxx删除指定键ESPAsyncWebServer的路由注册很直观server.on(/, HTTP_GET, [](AsyncWebServerRequest *request){ request-send_P(200, text/html, index_html); }); server.on(/api/nvs, HTTP_GET, [](AsyncWebServerRequest *request){ // 遍历 NVS生成 JSON request-send(200, application/json, jsonStr); }); server.on(/api/nvs, HTTP_POST, [](AsyncWebServerRequest *request){ // 处理 POST body }, NULL, [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total){ // body 回调拼接数据 });注意 POST 请求的 body 处理需要用最后一个回调参数因为异步服务器不会一次性把 body 给你。你需要自己维护一个缓冲区等index len total时表示接收完毕然后解析 JSON。DELETE 请求我用了查询参数而不是 body因为有些 HTTP 客户端对 DELETE 带 body 支持不好。request-getParam(key)-value()就能拿到键名。3.4 前端页面的交互逻辑前端页面不复杂但有几个细节值得注意。首先是表格的渲染每次加载页面时发一个 GET 请求拿数据然后用 JS 动态生成表格行。编辑时点击“编辑”按钮把当前行的值填到底部的表单里表单的key字段设为只读因为改键名等于删旧建新不如直接删了再加。保存时发 POST成功后重新拉取列表刷新表格。删除操作加一个confirm确认框防止误触。对于敏感键值显示为掩码旁边有个小眼睛图标切换明文。这个用 CSS 的-webkit-text-security或者直接替换文本内容都能实现我选的是后者兼容性更好。还有一个细节页面加载时显示一个 loading 状态因为 ESP32 处理请求需要时间尤其是键值对多的时候。如果请求失败要给出明确的错误提示而不是白屏。我在 fetch 的 catch 里加了alert简单粗暴但有效。4. 实操过程与核心环节实现4.1 硬件准备与开发环境搭建硬件方面任意一款 ESP32 开发板都可以我用的是 ESP32-WROOM-32 核心板Flash 4MB。如果你用的是 ESP32-S3 或者 C3代码基本兼容只需要在 Arduino IDE 里选对开发板型号。注意 S3 的 USB 接口可能和经典 ESP32 不同下载电路有差异但 NVS 和 WiFi 部分的代码是一样的。开发环境我用的 Arduino IDE 2.x需要安装两个库ESPAsyncWebServer和AsyncTCP。这两个库不在默认的库管理器里需要手动下载或者通过 GitHub 安装。具体步骤在 Arduino IDE 的“首选项”里添加开发板管理器网址安装esp32包版本 2.0.14 以上然后从 GitHub 下载ESPAsyncWebServer和AsyncTCP的 zip 包通过“项目”-“加载库”-“添加 .ZIP 库”导入。如果你用 PlatformIO配置更简单在platformio.ini里加[env:esp32dev] platform espressif32 board esp32dev framework arduino lib_deps me-no-dev/ESPAsyncWebServer me-no-dev/AsyncTCP bblanchon/ArduinoJsonPlatformIO 会自动处理依赖编译速度也比 Arduino IDE 快不少。我实测下来同样的代码在 PlatformIO 下编译只要十几秒Arduino IDE 要将近一分钟。4.2 初始化 NVS 与 WiFi 连接代码的入口是setup()这里要做几件事初始化串口、初始化 NVS、连接 WiFi、启动 Web 服务器。顺序很重要NVS 必须在 WiFi 之前初始化因为 WiFi 驱动会读写 NVS。void setup() { Serial.begin(115200); // 初始化 NVS 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(); nvs_flash_init(); } // 从 NVS 读取 WiFi 配置 Preferences prefs; prefs.begin(wifi, true); String ssid prefs.getString(ssid, ); String pass prefs.getString(pass, ); prefs.end(); // 连接 WiFi WiFi.begin(ssid.c_str(), pass.c_str()); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(WiFi.localIP()); // 启动 Web 服务器 setupServer(); }这里有个细节nvs_flash_init()如果返回ESP_ERR_NVS_NO_FREE_PAGES说明 NVS 分区满了或者损坏了需要先擦除再初始化。这个错误在频繁写入的场景下偶尔会出现加上这段处理能提高稳定性。WiFi 配置我存在wifi命名空间下键名是ssid和pass。这样和 ESP-IDF 默认的nvs.net80211分开避免冲突。读取时用prefs.begin(wifi, true)第二个参数true表示只读模式减少不必要的写入。4.3 遍历 NVS 并生成 JSON 响应这是整个项目的核心逻辑。前面提到用nvs_entry_find遍历但实际写的时候有几个坑。首先nvs_entry_find的第一个参数是分区名对于默认的 NVS 分区传nvs就行。第二个参数是命名空间如果你想遍历所有命名空间传NULL但那样需要处理命名空间切换代码会复杂一些。我选择只遍历用户自定义的命名空间比如myapp这样键名不会和系统键冲突。遍历过程中每拿到一个键就根据类型读取值然后塞进JsonObject。字符串类型的值要注意内存分配nvs_get_str第一次调用传NULL获取长度第二次才真正读取。如果值很长比如超过 1KB要考虑分页或者截断避免 JSON 文档过大导致内存不足。DynamicJsonDocument doc(4096); JsonArray arr doc.createNestedArray(items); nvs_iterator_t it nullptr; esp_err_t res nvs_entry_find(nvs, myapp, NVS_TYPE_ANY, it); while (res ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, info); JsonObject obj arr.createNestedObject(); obj[key] info.key; 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; obj[type] string; free(buf); } else if (info.type NVS_TYPE_I32) { int32_t val; nvs_get_i32(handle, info.key, val); obj[value] val; obj[type] int; } // 其他类型类似处理 res nvs_entry_next(it); } nvs_release_iterator(it); String jsonStr; serializeJson(doc, jsonStr);注意nvs_entry_find返回的迭代器用完必须nvs_release_iterator释放否则会内存泄漏。这个在官方文档里写得不太显眼我一开始漏了跑久了就崩。4.4 处理 POST 请求与写入 NVSPOST 请求的处理稍微复杂一点因为要接收 body。ESPAsyncWebServer的 body 回调是分块调用的你需要自己拼接。我定义了一个全局的String postBody在回调里不断追加直到index len total表示接收完毕。String postBody ; server.on(/api/nvs, HTTP_POST, [](AsyncWebServerRequest *request){ // 这里处理已经接收完的 postBody DynamicJsonDocument doc(1024); DeserializationError err deserializeJson(doc, postBody); if (err) { request-send(400, application/json, {\error\:\invalid json\}); postBody ; return; } String key doc[key]; String value doc[value]; String type doc[type]; nvs_handle_t handle; nvs_open(myapp, NVS_READWRITE, handle); if (type string) { nvs_set_str(handle, key.c_str(), value.c_str()); } else if (type int) { nvs_set_i32(handle, key.c_str(), value.toInt()); } nvs_commit(handle); nvs_close(handle); request-send(200, application/json, {\status\:\ok\}); postBody ; }, NULL, [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total){ for (size_t i 0; i len; i) { postBody (char)data[i]; } });这里有个性能问题逐字节拼接字符串在数据量大时很慢。优化方法是预分配postBody.reserve(total)或者用memcpy到缓冲区。对于配置页面这种小数据量通常几百字节逐字节也能接受但如果你要传大块数据建议改成缓冲区方案。写入完成后必须调用nvs_commit否则数据不会真正落盘。这个和数据库的事务提交是一个道理不 commit 的话掉电就丢了。nvs_close也要记得调用释放句柄。4.5 前端页面的完整实现前端 HTML 我直接内嵌在代码里用PROGMEM存储。页面结构如下!DOCTYPE html html head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleNVS 配置工具/title style body { font-family: sans-serif; margin: 20px; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #ccc; padding: 8px; text-align: left; } .btn { padding: 4px 8px; margin: 2px; cursor: pointer; } .masked { -webkit-text-security: disc; } /style /head body h1NVS 键值管理/h1 table idnvsTable thead trth键名/thth值/thth类型/thth操作/th/tr /thead tbody/tbody /table h2新增/修改/h2 form ideditForm input typetext idkeyInput placeholder键名 required input typetext idvalueInput placeholder值 required select idtypeSelect option valuestring字符串/option option valueint整数/option /select button typesubmit保存/button /form script // JS 逻辑 /script /body /htmlJS 部分负责加载数据、渲染表格、处理表单提交和删除操作。加载数据用fetch(/api/nvs)拿到 JSON 后遍历生成表格行。对于键名包含pass或key的值显示为******点击“显示”按钮切换。async function loadData() { const res await fetch(/api/nvs); const data await res.json(); const tbody document.querySelector(#nvsTable tbody); tbody.innerHTML ; data.items.forEach(item { const tr document.createElement(tr); const isSensitive /pass|key|token/i.test(item.key); tr.innerHTML td${item.key}/td td class${isSensitive ? masked : }${item.value}/td td${item.type}/td td button classbtn onclickeditItem(${item.key}, ${item.value}, ${item.type})编辑/button button classbtn onclickdeleteItem(${item.key})删除/button /td ; tbody.appendChild(tr); }); }表单提交时阻止默认行为发 POST 请求成功后重新加载数据并清空表单。删除操作类似发 DELETE 请求后刷新。5. 常见问题与排查技巧实录5.1 NVS 写入失败返回 ESP_ERR_NVS_NOT_ENOUGH_SPACE这个错误说明 NVS 分区满了。NVS 默认大小是 24KB在分区表里定义能存的键值对数量有限。如果你频繁写入不同的键或者值很大很容易撑满。解决办法有两个一是扩大 NVS 分区修改分区表把nvs分区调大比如 64KB 或 128KB二是定期清理不再使用的键或者用nvs_erase_all清空整个命名空间。我建议在代码里加一个检查每次写入前先估算剩余空间如果不够就返回错误提示而不是让nvs_set_*失败。nvs_get_stats可以拿到当前使用情况nvs_stats_t stats; nvs_get_stats(nvs, stats); Serial.printf(Used: %d, Free: %d, Total: %d\n, stats.used_entries, stats.free_entries, stats.total_entries);5.2 Web 页面加载慢或请求超时ESP32 的 Web 服务器性能有限如果页面上有大量请求或者键值对很多响应会变慢。优化方法一是减少 HTTP 请求数量把 CSS 和 JS 内联到 HTML 里避免额外的文件请求二是对 API 响应做缓存如果数据没变就返回 304三是限制返回的键值对数量分页加载。还有一个常见原因是 WiFi 信号弱。ESP32 的天线设计对信号影响很大如果板子放在金属壳里或者离路由器太远丢包率会很高。我实测下来信号强度低于 -80dBm 时请求超时的概率明显增加。解决办法是调整设备位置或者加一个外置天线。5.3 修改 WiFi 密码后设备连不上这是最危险的操作你把 WiFi 密码改错了设备重启后连不上网络Web 服务器也访问不了只能重新刷固件。为了避免这种情况我在代码里加了一个“回滚”机制修改 WiFi 配置后先不立即重启而是尝试用新配置连接如果 30 秒内连不上自动恢复旧配置并重启。实现思路把旧配置存在另一个命名空间或者内存里新配置写入后调用WiFi.begin用一个定时器检查连接状态。如果超时从备份恢复。这个逻辑稍微复杂但能救命。如果你不想搞这么复杂至少要在页面上加一个明显的警告“修改 WiFi 配置可能导致设备失联请确保新密码正确”。5.4 常见问题速查表问题现象可能原因解决方法页面打不开WiFi 未连接或 IP 不对串口打印 IP确认在同一局域网API 返回 500NVS 句柄未打开或分区满检查nvs_open返回值清理空间写入后重启丢失未调用nvs_commit确保每次写入后 commit遍历时崩溃迭代器未释放调用nvs_release_iterator中文乱码编码不一致统一用 UTF-8HTML 加 meta charsetPOST 请求无响应body 未接收完检查index len total条件5.5 几个我踩过的坑第一个坑Preferences库的begin方法如果命名空间不存在会自动创建。但如果你用只读模式true它不会创建返回 false。我一开始没检查返回值导致读取空值。后来改成先以读写模式打开再切换。第二个坑nvs_entry_find的迭代器在遍历过程中如果调用了nvs_set_*或nvs_erase_*迭代器会失效导致崩溃。所以遍历和修改不能同时进行。我的做法是先遍历收集所有键名到数组关闭迭代器再逐个处理。第三个坑ESP32 的 Web 服务器默认端口是 80但有些路由器或者防火墙会屏蔽 80 端口。如果访问不了可以换成 8080 或其他端口。server.begin(8080)就行。第四个坑前端 JS 里的模板字符串如果包含单引号会破坏 HTML 属性。比如键名是itsonclickeditItem(its, ...)就会出错。解决办法是用encodeURIComponent转义或者用>
返回列表