☰
ESP32免重刷固件:浏览器直接修改NVS键值实现WiFi配置更新
2026/10/7 1:05:00 网站建设 项目流程

1. 为什么改个 WiFi 密码要折腾重刷固件

搞过 ESP32 项目的人大概率都经历过这个场景:设备已经装到现场了,外壳封好、螺丝拧紧、甚至打了胶固定在配电箱里,结果甲方或者自己家里换了路由器,WiFi 名字和密码全变了。这时候你怎么办?把设备拆回来、接上 USB 转串口、打开 Arduino IDE 或者 ESP-IDF,改两行代码,重新编译、重新烧录,再装回去。一套流程下来,半小时起步,如果设备装在不好够到的地方,那更是折磨。

这个痛点的根源在于:绝大多数 ESP32 入门项目和教程,都是把 WiFi 的 SSID 和密码直接写死在代码里的const char* ssid = "xxx";这种形式。代码编译成固件烧进 Flash 之后,这些字符串就固化在程序的只读数据段里了,运行时根本没法改。想改?只能改代码重新编译。

但 ESP32 其实自带了一个非常适合存这类配置数据的地方——NVS(Non-Volatile Storage,非易失性存储)。它是乐鑫在 ESP-IDF 里提供的一套键值对存储系统,底层跑在 Flash 的某个分区上,掉电不丢数据,专门用来存 WiFi 配置、设备参数、校准数据这类"需要持久保存又经常变动"的东西。ESP32 的 WiFi 驱动本身在配网成功后,也会把 SSID 和密码存进 NVS,下次上电自动重连。

问题就变成了:既然 NVS 里本来就存着 WiFi 信息,那我能不能不重刷固件,直接改 NVS 里的键值?答案是能,而且可以做得非常优雅——用一个浏览器工具,通过串口或者设备自己开的 Web 服务,直接读写 NVS 的键值对。这就是标题里说的"浏览器工具能直接改 NVS 键值"的核心思路。

这篇文章我会把整套方案的来龙去脉讲清楚:NVS 到底怎么组织的、为什么它能被外部工具改、浏览器工具是怎么跟 ESP32 通信的、具体怎么落地实现、以及我在实际项目里踩过的那些坑。适合已经会用 ESP32 做联网项目、但被"现场改配置"这件事折磨过的朋友,也适合想深入理解 NVS 机制的人。

2. NVS 到底是个什么东西,为什么它能被"直接改"

2.1 NVS 的存储结构和键值模型

很多人第一次听到 NVS 会以为是文件系统,其实不是。NVS 是一套基于分区的键值数据库,它在 Flash 上占用一个独立的分区(通常在分区表里叫nvs),默认大小 0x6000(24KB),可以自己调。它的数据组织方式跟 FAT、SPIFFS、LittleFS 这些文件系统完全不同,没有目录树,没有文件概念,就是纯粹的namespace + key -> value。

一个 NVS 条目由这几部分组成:

  • Namespace(命名空间):相当于一个分组,比如wifi_config、device_cfg,不同命名空间下的同名 key 互不干扰。
  • Key(键):字符串,最长 15 个字符(不含结尾的\0),这是硬限制,超了会报错。
  • Value(值):支持多种类型,整型(i8/u8/i16/u16/i32/u32/i64/u64)、字符串、二进制 blob。字符串和 blob 有长度限制,单条最大受分区大小约束。

底层实现上,NVS 把每个 namespace 映射成一个"页"(page),页大小固定 4096 字节,每个页里存若干条目(entry),每个 entry 32 字节。写入的时候是追加式的,删除只是把 entry 标记为 erased,真正回收空间靠垃圾回收(GC)机制在页写满时触发。这个设计的好处是写入磨损均衡,坏处是频繁写会触发 GC,有性能抖动。

理解这个结构很关键,因为它决定了外部工具能做什么:只要工具能按 NVS 的二进制格式解析和生成 entry,就能直接读写 Flash 上的 NVS 分区,完全绕过应用程序。

2.2 为什么 WiFi 密码存在 NVS 里就能被改

这里有个容易被忽略的事实:ESP32 的 WiFi 驱动默认就会把配网信息存进 NVS。当你调用esp_wifi_set_config()并连接成功后,驱动会把 SSID、密码、信道等信息写进nvs分区里一个叫nvs.net80211的命名空间。下次上电,esp_wifi_start()之后驱动会自己去读这个命名空间,尝试自动重连。

也就是说,你的应用程序代码里写死的 SSID 密码,和 NVS 里驱动存的那份,是两套东西。如果你的代码是WiFi.begin("写死的SSID", "写死的密码"),那它每次都会用代码里的值覆盖 NVS;但如果你改成WiFi.begin()不带参数,或者用esp_wifi_set_config之前先读 NVS,那 NVS 里的值就是权威来源。

这就给了我们操作空间:把 WiFi 配置的读取逻辑改成"优先读 NVS,读不到再用默认值",然后外部工具只需要改 NVS 里的键值,设备重启后就会用新配置。整个过程不需要动固件。

2.3 直接改 NVS vs 重刷固件的成本对比

我把两种方式的实际成本列个表,你一看就明白为什么值得折腾这套方案:

对比项重刷固件直接改 NVS
需要拆设备是否(Web 方式)或仅需接串口
需要编译环境是否
单次耗时5-30 分钟10 秒-2 分钟
操作门槛需要开发者普通运维/用户即可
出错风险烧录失败可能变砖只改数据,风险低
适合场景功能升级配置变更

注意:直接改 NVS 只适合"配置类"变更,如果是要改业务逻辑、加功能,那还是得重刷固件。别把这两件事混为一谈。

3. 浏览器工具是怎么跟 ESP32 搭上话的

3.1 两条技术路线:串口直连 vs 设备内嵌 Web

"浏览器工具改 NVS"这个说法,落地时有两条完全不同的路线,我分别说一下,你根据场景选。

路线一:Web Serial API 串口直连。现代浏览器(Chrome、Edge 89+)支持 Web Serial API,网页可以直接调用navigator.serial.requestPort()拿到串口句柄,然后以任意波特率跟 ESP32 通信。这条路线的本质是:浏览器替代了传统的串口助手,你在网页上输入新的 SSID 和密码,网页通过串口把命令发给 ESP32 上运行的一个"配置服务"固件,由它去写 NVS。优点是设备不需要联网、不需要额外服务;缺点是必须物理接触设备(插 USB)。

路线二:设备内嵌 Web 服务器。ESP32 自己跑一个 HTTP 服务器(用WebServer.h或ESPAsyncWebServer),手机或电脑连上设备的热点(或者设备已在局域网里),浏览器访问设备的 IP,打开一个配置页面,提交表单后设备自己写 NVS。优点是完全无线,设备装在高处也能改;缺点是设备得先能连上(要么开 AP 热点,要么已经在网里)。

实际项目里我一般两个都做:首次配网用 AP + 内嵌 Web,后续改配置用 Web Serial 兜底。因为万一设备连不上网、AP 也进不去,串口是最后的救命稻草。

3.2 Web Serial API 的关键约束

用 Web Serial 有几个坑必须提前知道:

  • 必须 HTTPS 或 localhost:Web Serial 属于敏感权限 API,http://的页面调不起来,本地测试用localhost或者起个本地 https 服务。
  • 必须用户手势触发:requestPort()必须在 click 等用户操作的回调里调用,不能在页面加载时自动弹窗。
  • 一次只能一个页面占用:串口是独占资源,串口助手开着,网页就打不开。
  • 波特率要匹配:ESP32 默认 115200,但如果你改过Serial.begin()的参数,网页这边也要跟着改。

3.3 内嵌 Web 服务器的实现要点

如果用内嵌 Web 方案,ESP32 端要处理的事情更多:

  • 起一个WebServer server(80),注册/返回配置页面 HTML,注册/save处理 POST 表单。
  • 配置页面 HTML 可以直接以字符串形式嵌在固件里(PROGMEM),也可以用 SPIFFS/LittleFS 存,后者更灵活但多一层文件系统。
  • 保存时调用nvs_open/nvs_set_str/nvs_commit写入,然后ESP.restart()重启生效。
  • 如果设备当前连不上目标 WiFi,要先起 AP 模式(WiFi.softAP("ESP32-Config", "12345678")),让用户连上来配置。

提示:内嵌 Web 方案里,配置页面一定要做表单校验,SSID 不能为空、密码长度符合 WPA2 要求(8-63 字符),否则写进去连不上,用户还得再折腾一次。

4. 手把手实现:从 NVS 读写到浏览器交互

4.1 ESP32 端:封装一套 NVS 配置读写函数

先把 NVS 的读写封装好,这是整个方案的地基。下面这段代码我用了很多项目,稳定可靠:

#include <nvs_flash.h> #include <nvs.h> #define CFG_NAMESPACE "wifi_cfg" #define KEY_SSID "ssid" #define KEY_PASS "pass" bool save_wifi_config(const char* ssid, const char* pass) { nvs_handle_t handle; esp_err_t err = nvs_open(CFG_NAMESPACE, NVS_READWRITE, &handle); if (err != ESP_OK) { Serial.printf("nvs_open failed: %s\n", esp_err_to_name(err)); return false; } err = nvs_set_str(handle, KEY_SSID, ssid); if (err == ESP_OK) err = nvs_set_str(handle, KEY_PASS, pass); if (err == ESP_OK) err = nvs_commit(handle); nvs_close(handle); return err == ESP_OK; } bool load_wifi_config(char* ssid, size_t ssid_len, char* pass, size_t pass_len) { nvs_handle_t handle; if (nvs_open(CFG_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) return false; esp_err_t e1 = nvs_get_str(handle, KEY_SSID, ssid, &ssid_len); esp_err_t e2 = nvs_get_str(handle, KEY_PASS, pass, &pass_len); nvs_close(handle); return (e1 == ESP_OK && e2 == ESP_OK); }

几个关键点解释一下。nvs_open的第二个参数决定读写权限,读配置用NVS_READONLY更安全。nvs_set_str之后必须调用nvs_commit,否则数据只在缓存里,掉电就没了——这是新手最常犯的错。nvs_get_str传入的len是传入传出参数,调用前要填缓冲区大小,调用后会被改成实际长度,所以别传个常量进去。

4.2 启动逻辑:优先读 NVS,读不到用默认值

void connect_wifi() { char ssid[33] = {0}; char pass[65] = {0}; if (load_wifi_config(ssid, sizeof(ssid), pass, sizeof(pass))) { Serial.printf("Using NVS config, SSID=%s\n", ssid); } else { strncpy(ssid, DEFAULT_SSID, sizeof(ssid) - 1); strncpy(pass, DEFAULT_PASS, sizeof(pass) - 1); Serial.println("No NVS config, using default"); } WiFi.mode(WIFI_STA); WiFi.begin(ssid, pass); // ... 等待连接 }

这段逻辑是整个方案的灵魂:NVS 优先,默认值兜底。这样即使 NVS 是空的(比如刚烧录的新设备),设备也能用默认配置启动,不会变砖。

4.3 内嵌 Web 配置页面的完整实现

下面是一个能直接用的最小实现,包含 AP 模式和配置页面:

#include <WebServer.h> WebServer server(80); const char CONFIG_PAGE[] PROGMEM = R"HTML( <!DOCTYPE html><html><head><meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>WiFi Config</title></head><body> <h2>WiFi 配置</h2> <form action="/save" method="POST"> SSID: <input name="ssid" maxlength="32" required><br> 密码: <input name="pass" type="password" maxlength="63"><br> <button type="submit">保存并重启</button> </form></body></html> )HTML"; void handleRoot() { server.send_P(200, "text/html", CONFIG_PAGE); } void handleSave() { String ssid = server.arg("ssid"); String pass = server.arg("pass"); if (ssid.length() == 0) { server.send(400, "text/plain", "SSID empty"); return; } if (save_wifi_config(ssid.c_str(), pass.c_str())) { server.send(200, "text/plain", "Saved, rebooting..."); delay(500); ESP.restart(); } else { server.send(500, "text/plain", "Save failed"); } } void start_config_portal() { WiFi.mode(WIFI_AP); WiFi.softAP("ESP32-Config", "12345678"); server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); Serial.print("Config portal at "); Serial.println(WiFi.softAPIP()); }

PROGMEM把 HTML 字符串放到 Flash 而不是 RAM,省内存。send_P是配套的发送函数。表单提交后写 NVS 再重启,重启后connect_wifi()就会读到新配置。

4.4 Web Serial 网页端的实现

如果走串口路线,网页端核心代码是这样:

let port, writer, reader; async function connectSerial() { port = await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); writer = port.writable.getWriter(); readLoop(); } async function readLoop() { const decoder = new TextDecoderStream(); port.readable.pipeTo(decoder.writable); reader = decoder.readable.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; if (value) console.log("ESP32:", value); } } async function sendConfig(ssid, pass) { const cmd = `SETCFG:${ssid}:${pass}\n`; await writer.write(new TextEncoder().encode(cmd)); }

ESP32 端则要解析这个SETCFG:协议:

void handleSerialCommand() { if (!Serial.available()) return; String line = Serial.readStringUntil('\n'); if (line.startsWith("SETCFG:")) { int first = line.indexOf(':'); int second = line.indexOf(':', first + 1); String ssid = line.substring(first + 1, second); String pass = line.substring(second + 1); if (save_wifi_config(ssid.c_str(), pass.c_str())) { Serial.println("OK"); } else { Serial.println("FAIL"); } } }

协议设计上我建议用带前缀的文本行,别用二进制,调试方便,用串口助手也能手动发。分隔符选:或者|都行,只要 SSID 和密码里不会出现这个字符——注意 SSID 里理论上可以出现冒号,所以更稳妥的是用长度前缀或者 JSON,但为了简单,实际项目里用:也够,因为带冒号的 SSID 极少见。

5. 实操中踩过的坑和排查技巧

5.1 NVS 写入失败的那些原因

我遇到过好几次nvs_set_str返回ESP_ERR_NVS_NOT_ENOUGH_SPACE,排查下来无非几种:

  • 分区太小:默认 24KB 看着够,但如果你存了很多 blob,很快就满。改分区表把nvs调到 0x10000(64KB)能解决大部分问题。
  • key 太长:超过 15 字符直接报ESP_ERR_NVS_KEY_TOO_LONG,这个错误信息很明确,但新手容易忽略。
  • 没 commit:前面强调过,nvs_set_str不 commit 等于没写。
  • namespace 名太长:namespace 也有 15 字符限制,别起太长的名字。

5.2 改了 NVS 但设备还是连旧 WiFi

这个现象特别常见,原因通常是代码里还在用写死的 SSID 覆盖 NVS。检查你的WiFi.begin()调用,如果带了参数,那 NVS 里的值就被忽略了。改成WiFi.begin()无参形式,或者干脆自己读 NVS 再WiFi.begin(ssid, pass)。

还有一种情况是WiFi 驱动自己缓存了配置。ESP32 的 WiFi 驱动在nvs.net80211命名空间里也存了一份,如果你改的是自己的wifi_cfg命名空间,驱动那份没变,某些情况下驱动会优先用自己那份。解决办法是改完配置后调用esp_wifi_restore()清掉驱动缓存,或者干脆用WiFi.disconnect(true)再重连。

5.3 Web Serial 打不开串口的排查顺序

按这个顺序查,基本能定位:

现象可能原因解决
按钮点了没反应页面不是 HTTPS/localhost换 localhost 或起 https
弹窗里没有设备驱动没装/线是充电线装 CP210x/CH340 驱动,换数据线
打开报错串口被占用关掉串口助手、Arduino 监视器
能连但收不到数据波特率不对确认两边都是 115200
收到乱码波特率或数据位不匹配检查 8N1 配置

5.4 一个容易被忽略的细节:NVS 的擦除粒度

NVS 底层是 Flash,Flash 的擦除粒度是 4KB 的扇区。这意味着即使你只改一个字节,底层也可能擦掉整个扇区再重写。如果你的应用频繁写 NVS(比如每秒写一次传感器数据),Flash 寿命会消耗得很快。ESP32 的 Flash 标称擦写寿命约 10 万次,按每秒一次算,不到 28 小时就到一个扇区的寿命上限了。

所以我的经验是:NVS 只存配置,不存高频数据。传感器数据要存,用 RTC 内存或者外部存储。配置类数据一天改不了几次,完全不用担心寿命。

提示:如果确实需要频繁写,考虑用nvs_set_blob批量写,减少 commit 次数,或者加一层 RAM 缓存,定时落盘。

6. 这套方案的边界和扩展玩法

6.1 什么场景适合,什么场景别用

适合的场景很明确:配置项变更频繁、设备部署后不易物理接触、操作者不一定是开发者。比如智能家居节点、工业传感器、共享设备、教学实验板。

不适合的场景也要说清楚:需要改业务逻辑、需要升级功能、需要修复 bug,这些必须重刷固件。另外如果设备有安全要求(比如金融、医疗),NVS 明文存 WiFi 密码是有风险的,得考虑加密存储或者用安全芯片。

6.2 扩展一:把配置项做成通用的

别只盯着 WiFi,NVS 可以存任何配置。我一般会做一个通用的配置框架:

struct DeviceConfig { char wifi_ssid[33]; char wifi_pass[65]; char mqtt_host[64]; uint16_t mqtt_port; uint32_t report_interval; };

然后写一套save_config/load_config把整个结构体序列化进 NVS。这样 Web 页面就能一次性配置所有参数,不用为每个参数单独写代码。

6.3 扩展二:配置版本号和回滚

生产环境里我会加一个config_version字段。每次保存配置时版本号加一,设备启动时如果发现配置版本比固件期望的低,就走迁移逻辑。这样即使配置格式变了,老设备升级固件后也能平滑过渡,不会因为读不懂旧配置而挂掉。

6.4 扩展三:OTA 和 NVS 配置的配合

OTA(空中升级)升级的是固件,NVS 配置默认是保留的。但要注意:如果新固件的 NVS 命名空间或 key 变了,旧配置就读不到了。所以 OTA 升级时最好带上配置迁移逻辑,或者干脆在升级前把配置导出、升级后导入。这个细节很多 OTA 教程都不提,但实际项目里踩过坑的人都知道有多重要。

7. 我个人在实际项目中的几点体会

这套"浏览器改 NVS"的方案,我从最早的纯串口助手手动改,到后来做 Web Serial 页面,再到内嵌 Web 服务器,前后迭代了好几版。最大的体会是:别追求一步到位做全功能,先把"能改"这件事跑通。很多人一上来就想做漂亮的配置页面、做多设备管理、做权限控制,结果卡在 NVS 读写这一步就放弃了。

另外一个血泪教训是:默认配置一定要留。我见过有人把默认 SSID 设成空字符串,结果 NVS 一空设备就彻底连不上,只能拆机重刷。默认值是你的安全网,哪怕设成ESP32-Default这种明显是占位的名字,也比空着强。

最后分享一个小技巧:调试 NVS 的时候,用nvs_get_stats()打印一下当前分区的使用情况,能看到已用条目数、剩余空间、namespace 数量,比盲猜高效得多。这个函数在nvs.h里,很多人不知道它的存在。

nvs_stats_t stats; nvs_get_stats(NULL, &stats); Serial.printf("Used: %d, Free: %d, Total: %d\n", stats.used_entries, stats.free_entries, stats.total_entries);

后续如果要做多设备批量配置,可以把这套逻辑封装成一个独立的配置服务固件,所有项目共用,通过串口协议或者 HTTP 接口暴露配置能力。这样每个新项目只要集成这个服务,就自动获得了"免重刷改配置"的能力,省下的时间相当可观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询