
同事前两天拿了一个 ESP32 设备过来说客户把家里的 WiFi 密码改了设备死活连不上服务器。现场既没有 USB 线也不会用烧录工具最后折腾半天只能寄回来重刷固件。我听完第一反应是这不应该是 2015 年的玩法。ESP32 里明明带了一个叫 NVS 的非易失性存储WiFi 配置本来就是键值对运行时直接改掉就行为什么要为了改一个密码去重刷整个固件后来我花了一个下午写了个浏览器工具把“改 WiFi 密码必须重刷固件”这条路彻底堵死了。设备只要上电你拿手机或者电脑连上它的热点打开浏览器点几下新的 WiFi 配置就通过 HTTP 接口写进 NVS断电也不会丢。整个过程不需要 IDE、不需要 USB 线、不需要懂编译。这篇文章把这个工具的完整设计、关键代码、原理和踩坑记录都摊开讲适合正在做 ESP32 小批量设备、或者经常被“改配置”折腾的开发者和硬件爱好者参考。1. 这个痛点是真实的改WiFi密码为什么要重刷固件1.1 重刷固件的真实成本先说清楚一件事很多 ESP32 项目的 WiFi 配置是直接写在代码里的。WiFi.begin(MyWiFi, password)一行硬编码编译进固件。设备发布之后用户一旦换了路由器或改了密码这个设备就连不上网了。要恢复传统上有几条路用户拿 USB 线连电脑装 CH340/CP2102 驱动下载 esptool 或烧录工具找到固件文件重新烧录。对技术人来说这不算难但对普通用户来说等于劝退。设备支持 OTA 升级团队重新编译一个把新密码写死的固件发布到服务器再触发设备在线升级。光走一次发布流程就是几个小时为了一个密码发一个版本既浪费又危险。设备支持 SmartConfig 或 SoftAP 配网但很多产品形态根本没有按键、没有屏幕用户根本不知道怎么触发配网模式。这些路径都有一个共同的问题配置数据和固件代码耦合了。密码是运行时数据不该被编译进二进制里。ESP32 的 NVS 就是专门为这种场景设计的可现实里大量项目根本没把它用起来或者说“用是用了但只存在代码里没有提供一个能改它的入口”。1.2 NVS被大家忽略的威力NVS 的全称是 Non-Volatile Storage非易失性存储。它在 ESP32 内部 Flash 里划了一块独立分区以键值对Key-Value的形式保存数据。你存一个wifi_ssid它对应MyWiFi存一个wifi_pass它对应password123。断电之后数据还在固件升级时只要不用erase_flash这些键值也还在。开发环境里常见的Preferences库底层其实就是 NVS只是封装了一层更友好的 API。很多同学用Preferences存过亮度、温度阈值、设备编号这些当然没问题。但我们往往只把 NVS 当成“存参数”的地方没有把它当成“可管理资源”来做接口。我做的这个浏览器工具核心思路就是既然设备本身要连 WiFi那就顺便开一个 HTTP 服务把 NVS 里的键值通过 REST 接口暴露出来。用户访问网页提交新的 WiFi 账号密码固件接收后调用 NVS API 写入然后重启或重新连接。这样一来修改配置变成了“打开网页、填表单、点保存”和重刷固件彻底无关。2. NVS键值存储的底层逻辑2.1 NVS在Flash里长什么样NVS 不是一个数据库它本质上是 ESP32 内部 Flash 中一块分区里的键值存储。默认分区表里常见布局是# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x4000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M,不同版本的 ESP-IDF 模板略有差异有的 NVS 分区是 0x400016KB有的是 0x600024KB。换来的是一个抽象层。设备启动后要调用nvs_flash_init()系统会在这个分区上做 CRC 校验和数据恢复确保上次写入的键值没有半截损坏。写入一条数据的完整流程是nvs_open打开一个命名空间nvs_set_str或nvs_set_u32写入目标键最后必须调用nvs_commit提交。commit会触发实际的 Flash 擦写操作不调用它数据可能只停留在缓存里掉电就丢。Flash 的擦写寿命大约是十万次级别。NVS 内部有磨损均衡机制避免你反复写同一个键时永远磨坏同一个物理地址。但别因此就随便写——如果有一个定时器每隔几秒就set一次计数键哪怕有磨损均衡寿命也会被快速消耗。WiFi 密码这种低频修改的配置才是 NVS 最理想的用途。2.2 键值设计得不好后面运维全是坑我见过不少项目的 NVS 键值管理是混乱的命名空间随意WiFi 配置存在wifi_cfg设备信息存在sys用户数据又存在pref没有统一规划。键名大小写混用ssid和SSID在 NVS 里是两个不同键读的时候写错了查不出来。读取时不做默认值兜底。键不存在时nvs_get_str返回ESP_ERR_NVS_NOT_FOUND如果代码直接当成成功处理拿到的可能是空指针或垃圾值。我这次设计工具时定了几条规则统一 namespaceWiFi 相关全部放到wifi_cfg。键名小写加下划线ssid、pass、hostname。读取统一走一个封装函数键不存在时返回默认值并打日志方便排查。字符串类型只存短配置超过 100 字节的用 Blob 而不是字符串。看键值设计和工具协议你会发现它本质上是一个很简单的“数据字典”管理问题。先把命名规则想清楚后面再做 Web 接口和前端表单的时候会顺手很多。3. 浏览器工具的整体设计两条路线怎么选3.1 路线对比局域网HTTP vs Web Serial动手之前我纠结过两个方向方案使用场景是否需要USB浏览器兼容性适合阶段局域网 HTTP SoftAP用户现场用手机/电脑访问设备热点不需要一切现代浏览器产品交付、售后维护Web Serial API开发调试、产线配置、离线排障需要USB线仅 Chrome/Edge且要求安全上下文开发调试、工厂先说 HTTP 方案。设备启动后创建一个 SoftAP 热点用户连上热点后访问192.168.4.1页面里的 JavaScript 通过 Fetch 调用设备上的 REST 接口把新的 WiFi 配置 POST 进去。这个方案最贴近标题说的“浏览器工具直接改 NVS 键值”体验也是最好的。再说 Web Serial 方案。浏览器通过navigator.serial直接打开 USB 串口向 ESP32 发送文本指令由固件解析指令并写入 NVS。这个方案在开发阶段排查问题时特别方便不需要额外烧录配网页。但有一个大坑Web Serial 在 Chrome 里要求安全上下文http://192.168.4.1这个局域网地址并不算安全上下文所以你不能在设备自己的网页里启用 Web Serial。正确做法是页面跑在本机localhost或 HTTPS 环境再通过串口连接设备。它更适合工程师手里那台电脑不适合最终用户。我的结论是两条路都保留。HTTP 方案作为产品化的主路径Web Serial 作为开发调试的辅助路径。工具的主界面是一个本地 HTML 文件运行在电脑上两种模式都支持。3.2 “四合一”功能设计这个浏览器工具不只是一个“改 WiFi 密码”的小页面。既然已经要读写 NVS不如把能力做完整一次性解决运维里的常见问题。我最终定了四个功能一键读取当前所有 NVS 键值进入页面后自动拉取wifi_cfg命名空间下的所有键和值显示在一个表格里。快捷 WiFi 配置表单SSID、密码两个输入框填完点保存工具自动调用 NVS 写入接口。任意键值编辑器高级模式允许用户手动输入 namespace、key、value可以对任意 NVS 键做增删改查。保存后动作选择写入成功后可选“软重连”或“重启设备”我默认推荐重启干净利落。再加上两个安全兜底修改前需要输入一个预设的管理 Token防止局域网里任何人手滑乱改所有value写入时做 HTML 转义和 JSON 转义避免特殊字符把页面弄崩或让协议解析出错。4. 硬核实操代码怎么写4.1 设备端NVS读写封装先写一个通用的nvs_ops.h把读写逻辑收敛起来。项目里其他地方改配置时也直接复用这套接口避免每个模块都去调底层nvs_get_str。// nvs_ops.h #pragma once #include esp_err.h #define NVS_NAMESPACE_WIFI wifi_cfg esp_err_t nvs_get_str_value(const char* ns, const char* key, char* buf, size_t* len); esp_err_t nvs_set_str_value(const char* ns, const char* key, const char* value); esp_err_t nvs_get_u32_value(const char* ns, const char* key, uint32_t* out); esp_err_t nvs_set_u32_value(const char* ns, const char* key, uint32_t value);实现文件里要特别注意读取时的长度处理// nvs_ops.c esp_err_t nvs_get_str_value(const char* ns, const char* key, char* buf, size_t* len) { nvs_handle_t handle; esp_err_t err nvs_open(ns, NVS_READONLY, handle); if (err ! ESP_OK) return err; // 第一次调用传 NULL拿到实际长度 size_t required 0; err nvs_get_str(handle, key, NULL, required); if (err ! ESP_OK) { nvs_close(handle); return err; } if (required *len) { nvs_close(handle); return ESP_ERR_NVS_INVALID_LENGTH; } err nvs_get_str(handle, key, buf, required); nvs_close(handle); return err; } esp_err_t nvs_set_str_value(const char* ns, const char* key, const char* value) { nvs_handle_t handle; esp_err_t err nvs_open(ns, NVS_READWRITE, handle); if (err ! ESP_OK) return err; err nvs_set_str(handle, key, value); if (err ESP_OK) { err nvs_commit(handle); // 不 commit 等于白写 } nvs_close(handle); return err; }这里有一个很容易踩的坑nvs_get_str第一次调用时required返回的是包括结尾\0的长度。如果你分配缓冲区时只看字符长度容易少算 1 字节。所以我喜欢先拿长度再动态分配不要图省事固定一个char buf[32]万一用户 SSID 满 32 字节就溢出了。4.2 WiFi连接策略读NVS→失败→AP兜底设备启动后WiFi 逻辑要做一个优先级判断// wifi_app.c static bool try_sta_from_nvs(void) { char ssid[64] {0}; char pass[128] {0}; size_t ssid_len sizeof(ssid); size_t pass_len sizeof(pass); if (nvs_get_str_value(NVS_NAMESPACE_WIFI, ssid, ssid, ssid_len) ! ESP_OK) { ESP_LOGW(TAG, no ssid in nvs, fallback to ap); return false; } // pass 可以不存允许为空密码开放网络或无密码场景 if (nvs_get_str_value(NVS_NAMESPACE_WIFI, pass, pass, pass_len) ! ESP_OK) { pass[0] \0; } wifi_config_t wifi_cfg {0}; strncpy((char*)wifi_cfg.sta.ssid, ssid, sizeof(wifi_cfg.sta.ssid) - 1); strncpy((char*)wifi_cfg.sta.password, pass, sizeof(wifi_cfg.sta.password) - 1); wifi_cfg.sta.threshold.authmode WIFI_AUTH_WPA2_PSK; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, wifi_cfg)); ESP_ERROR_CHECK(esp_wifi_start()); esp_wifi_connect(); return true; }如果try_sta_from_nvs返回 false代表没有历史配置进入 AP 配网模式static void start_softap(void) { wifi_config_t ap_cfg {0}; strncpy((char*)ap_cfg.ap.ssid, ESP32-Config, sizeof(ap_cfg.ap.ssid) - 1); ap_cfg.ap.max_conn 4; ap_cfg.ap.authmode WIFI_AUTH_WPA2_PSK; strncpy((char*)ap_cfg.ap.password, 12345678, sizeof(ap_cfg.ap.password) - 1); ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, ap_cfg)); ESP_ERROR_CHECK(esp_wifi_start()); }注意一个细节SoftAP 的密码如果设置成 8 位以下的 WPA2 密码ESP32 会直接报错。所以我这里写的是12345678并且页面上也明确提示“接入配置热点的密码是 12345678”。4.3 HTTP接口实现有了 NVS 封装和 WiFi 逻辑接下来把 REST 接口挂上。我用esp_http_server库注册两个核心 URI// web_server.c static esp_err_t handle_wifi_config_post(httpd_req_t* req) { char buf[512] {0}; int ret httpd_req_recv(req, buf, sizeof(buf) - 1); if (ret 0) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, recv failed); return ESP_FAIL; } buf[ret] \0; cJSON* root cJSON_Parse(buf); if (root NULL) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, bad json); return ESP_FAIL; } const cJSON* ssid cJSON_GetObjectItem(root, ssid); const cJSON* pass cJSON_GetObjectItem(root, pass); if (!cJSON_IsString(ssid) || !cJSON_IsString(pass)) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, ssid/pass required); cJSON_Delete(root); return ESP_FAIL; } esp_err_t err; err nvs_set_str_value(NVS_NAMESPACE_WIFI, ssid, ssid-valuestring); if (err ! ESP_OK) goto fail; err nvs_set_str_value(NVS_NAMESPACE_WIFI, pass, pass-valuestring); if (err ! ESP_OK) goto fail; cJSON_Delete(root); httpd_resp_sendstr(req, {\result\:\ok\}); // 延迟 500ms 再重启让响应先发出去 vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); return ESP_OK; fail: cJSON_Delete(root); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, nvs write error); return ESP_FAIL; }代码里有两个设计细节。第一wifi_cfg命名空间下的ssid和pass要成功写入后才返回 ok。第二我没有调用esp_wifi_set_config esp_wifi_connect来软切换而是直接esp_restart()重启系统。重启的好处是干净所有模块重新初始化WiFi 逻辑按 NVS 里的新值走不存在旧配置残留、DNS 缓存错乱之类的问题。用户那边看到页面断线过几秒设备重连新 WiFi体验完全可以接受。同时注册一个读取接口static esp_err_t handle_keys_get(httpd_req_t* req) { // 打开 wifi_cfg 命名空间遍历所有键 nvs_handle_t handle; nvs_open(NVS_NAMESPACE_WIFI, NVS_READONLY, handle); // nvs_get_next_key 枚举键名逐个读出并对 JSON 转义 // 返回 {ssid:MyWiFi,pass:password123} }为了防手滑我还加了 Token 校验前端 POST 时必须带上token字段服务端用strcmp和sys_cfg命名空间里存的admin_token比对。Token 不匹配直接返回 403。原型阶段可以不加但做产品一定要加原因后面说。4.4 前端页面单HTML搞定前端我直接写成一个index.html用嵌入式字符串编译进固件读取后通过/路由返回。页面不依赖任何框架原生fetch就够用。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleESP32 NVS 配置工具/title /head body h2ESP32 NVS 配置工具/h2 form idwifiForm labelWiFi SSID/label input typetext idssid required labelWiFi 密码/label input typepassword idpass label管理 Token/label input typepassword idtoken placeholder默认admin button typesubmit保存并重启设备/button /form table idkvTable theadtrth键名/thth当前值/th/tr/thead tbody/tbody /table script async function loadKeys() { const res await fetch(/api/keys); const data await res.json(); const tbody document.querySelector(#kvTable tbody); tbody.innerHTML ; for (const [k, v] of Object.entries(data)) { const tr document.createElement(tr); const tdKey document.createElement(td); tdKey.textContent k; // 用 textContent不要用 innerHTML const tdVal document.createElement(td); tdVal.textContent v; tr.append(tdKey, tdVal); tbody.appendChild(tr); } } document.querySelector(#wifiForm).addEventListener(submit, async (e) { e.preventDefault(); const payload { ssid: document.querySelector(#ssid).value, pass: document.querySelector(#pass).value, token: document.querySelector(#token).value }; const res await fetch(/api/wifi, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); if (res.ok) { document.body.insertAdjacentHTML(beforeend, p stylecolor:green写入成功设备重启中.../p); } else { document.body.insertAdjacentHTML(beforeend, p stylecolor:red写入失败 res.status /p); } }); loadKeys(); /script /body /html页面上需要注意一点所有动态渲染的文本都用textContent不要用innerHTML拼接。SSID 可能是用户自定义的里面如果包含、字符用innerHTML会把页面结构搞坏甚至可能变成 XSS。这类工具是给现场维护人员用的本身信任域不高但该防的还是要防。5. 完整操作流程与验证5.1 编译烧录代码准备好后编译流程和常规 ESP-IDF 项目一致idf.py set-target esp32 idf.py build idf.py -p /dev/ttyUSB0 flash monitor如果你的分区表是自己写的记得${target}分区表里nvs分区至少要留 0x400016KB太小的话存几个字符串就满了。我实际测试时用的默认分区表完全够用。烧录完成后设备第一次启动时因为 NVS 里还没有ssid键会直接进入 SoftAP 模式。在电脑手机里搜ESP32-Config这个热点密码12345678连上浏览器打开192.168.4.1。5.2 实际改WiFi密码的完整过程页面加载后loadKeys()会拉到当前 NVS 里的全部键值。第一次使用时空表是正常的因为键不存在。在表单里填写SSID 填路由器名称我测试时填的是TP-LINK_5G_8A2C密码填新密码因为路由器开了 WPA2/WPA3 混合模式我填了 16 位长度。Token 保持默认admin点击保存大概 2 秒后页面显示“写入成功设备重启中”。这时候观察串口日志I (3021) wifi: nvs: ssidTP-LINK_5G_8A2C I (3025) wifi: STA mode, connecting to TP-LINK_5G_8A2C I (3342) wifi: pm start, type: 1 I (3344) wifi: connected to AP这说明设备已经用新配置成功连接路由器。此时再打开浏览器访问路由器的局域网 IP比如192.168.1.100就能看到配置工具页面并且表格里显示当前生效的ssid正是 TP-LINK 那个名称。整个流程下来从连热点到改完大概 30 秒不需要碰任何烧录工具。5.3 验证是否生效验证不能只看页面显示要确认设备真的连上了新 WiFi。我用三个维度确认串口日志里有connected to AP字样说明 STA 连接成功。设备拿到了 IP说明路由器 DHCP 正常分配。ping 设备的 IP往返延迟稳定说明网络链路通。还有一个容易被忽略的点改完 WiFi 后如果设备之前绑定过 TCP 长连接重启后连接会自动恢复。但如果你的产品需要连 MQTT/HTTP 平台记得在wifi_app.c里把“WiFi 连接成功”和“应用层连接成功”分开处理否则会出现设备连上网但业务系统不在线的假象。6. 常见问题和排查技巧6.1 问题速查表整理一下我调试过程中遇到的问题做成速查表现象原因解决办法热点搜不到ESP32-ConfigSoftAP信道与终端不兼容或设备未启动AP按住复位键重启串口日志确认esp_wifi_start是否成功连上热点但打不开192.168.4.1电脑/手机自动跳到其他网络或AP IP被改过关闭蜂窝数据手动指定静态IP192.168.4.2/24页面提示bad jsonSSID或密码包含特殊字符且前端转义不一致用JSON.stringify发送后端用 cJSON 解析时统一处理字符串写入成功但设备没重启vTaskDelay时间不够响应未发出就被切断了延迟增加至 1000ms用httpd_resp_sendstr后再 flush重启后还是连不上新 WiFiSSID 长度超过 32 字节或密码包含不可见字符检查pass是否被复制时截断在串口日志里打印收到的字符串长度NVS 写入返回ESP_ERR_NVS_INVALID_LENGTH字符串长度超过分区条目允许范围缩短键值超长配置改用 Blob 存储页面表格显示中文乱码浏览器编码和 HTML meta 声明不一致HTML 声明charsetUTF-8设备返回头加Content-Type: application/json; charsetutf-86.2 踩坑细节与心得有几个细节是常规文档里不会写的但实际项目里非常关键。第一nvs_commit不能省。我之前在一个原型里测试时nvs_set_str返回成功但设备重启后读到的是旧值查了半天才发现是没调commit。跑完nvs_set_str后数据只存在于缓存nvs_commit才是真正触发 Flash 写入的动作。第二Token 管理要在“产品化”阶段就做。最开始我做的是裸接口谁访问192.168.4.1都能改 WiFi这在开发环境没问题。但小批量交付后用户如果把配置工具暴露在局域网里邻居也能进。我在sys_cfg里加了个admin_token键出厂默认admin页面第一次进入时提示用户修改。这个设计在售后维护里很实用。第三不要试图用一个段代码同时处理“改完立即软重连”和“重启”两种模式。软重连看起来省时间但如果路由器的 DHCP 分配慢STA 会反复断连体验还不如重启干净。我默认只提供“写入并重启”一个动作逻辑简单用户也好理解。第四Web Serial 的兼容性是一个隐藏得很深的坑。我第一次写完这个工具想在设备自己的配置页面上直接加 Web Serial 调试按钮结果 Chrome 直接报错navigator.serial is undefined。原因就是http://192.168.4.1不是安全上下文。如果你真的需要在浏览器里用 Web Serial 直接连 ESP32请把 HTML 页面放在localhost上打开再通过 USB 连接设备。这是我在调试时绕了不少弯才确认的。第五POST 请求返回后设备立刻重启HTTP 响应未必能完整到达浏览器。我在handle_wifi_config_post里加了 500ms 的延迟能让 Fetch 请求拿到响应。如果页面提示“已保存”但设备没反应多半是延迟太短或响应被连接重置打断。电磁环境差的现场我建议延迟加到 1000ms 并返回一个简易 JSON前端收到再显示结果。最后说点个人体会。有了这个工具之后我以后做 ESP32 项目哪怕是最简单的温湿度传感器也会顺手把 NVS 键值管理页面加上。开发阶段用 Web Serial 调试配置量产交付后用 SoftAP 页面做现场维护。用户遇到改 WiFi 密码这种最基础的问题不再需要找厂家、寄设备、等固件。这个模式还能继续扩展在页面上增加 OTA 固件升级入口、日志查看、时间校准功能都属于同一套技术框架。一个能随时改配置的设备才谈得上好维护。