1. 项目缘起:一个让嵌入式开发者集体头疼的老问题
搞 ESP32 的朋友大概率都经历过这个场景:设备已经装到现场了,外壳封好了,甚至灌了胶,突然甲方来一句“WiFi 密码换了,你帮忙改一下”。这时候你脑子里第一反应是什么?重新连串口、打开 Arduino IDE 或者 ESP-IDF、改代码里的宏定义、编译、烧录……一套流程下来,快则十分钟,慢则半小时,还得拆壳接线。如果设备装在吊顶里、配电箱里、或者某个你根本够不着的地方,那画面就更美了。
这个项目的出发点就一句话:能不能不刷固件,直接改掉 ESP32 里存的 WiFi 密码?
答案是可以的。ESP32 的 WiFi 配置信息默认存在NVS(Non-Volatile Storage,非易失性存储)里,NVS 本质上是芯片内部 Flash 上的一块键值对分区。既然它是键值对,那就有办法在运行时去读写它。我们要做的,就是让 ESP32 自己起一个轻量的 HTTP 服务,浏览器打开一个页面,输入新的 SSID 和密码,点保存,设备重启后自动连上新网络。整个过程不需要数据线,不需要编译环境,甚至不需要你懂代码——只要设备还能通电、还能被访问到。
这篇文章我会把这个方案的设计思路、NVS 的底层机制、Web 服务的实现细节、参数计算、踩坑记录全部拆开讲清楚。适合三类人看:一是手上有一堆 ESP32 设备要维护的物联网开发者;二是刚入门 ESP32、想搞懂 NVS 到底怎么回事的初学者;三是做智能家居 DIY、希望给设备留一个“后门配置入口”的玩家。读完你就能把这套东西直接抄到自己项目里。
2. 方案整体设计:为什么选 NVS + Web 而不是别的路子
2.1 先搞清楚 ESP32 的 WiFi 配置到底存在哪
很多人以为WiFi.begin(ssid, password)之后,密码就“记住”了。其实不是。ESP32 的 Arduino 核心和 ESP-IDF 里,WiFi 驱动默认会把最近一次成功连接的网络信息写进 NVS,分区名通常叫nvs,命名空间是nvs.net80211。这里面存的键包括sta.ssid、sta.pswd、sta.bssid等等。下次上电时,如果代码里调用的是WiFi.begin()不带参数,驱动就会去 NVS 里读这些值。
这就意味着:只要我们能改掉 NVS 里sta.ssid和sta.pswd这两个键,设备下次启动就会连上新网络。这是整个方案的理论基石。
那为什么不用别的方式?我对比过几种常见做法:
| 方案 | 是否需要重刷固件 | 是否需要拆机 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 改代码宏定义重刷 | 是 | 通常需要 | 低 | 开发阶段 |
| 串口 AT 指令配置 | 否 | 需要接串口 | 中 | 有串口引出 |
| SmartConfig / 配网 | 否 | 否 | 中 | 首次配网 |
| BLE 配网 | 否 | 否 | 高 | 带蓝牙场景 |
| NVS + Web 改键值 | 否 | 否 | 中 | 已联网设备维护 |
SmartConfig 和 BLE 配网更适合“首次配网”,也就是设备还没连上任何网络的时候。但我们的场景是设备已经在网,只是要换密码。这时候设备本身有一个可访问的 IP,直接起个 Web 服务最直接。而且 Web 方案跨平台,手机、电脑、平板都能操作,不需要装 App。
2.2 核心思路:运行时读写 NVS + 轻量 Web 服务
整体架构分三层:
- 底层:用
Preferences库(Arduino 对 NVS 的封装)或者直接调nvs_flashAPI 读写键值。 - 中间层:起一个
WebServer(Arduino 自带)或者esp_http_server(ESP-IDF),提供两个接口——一个 GET 返回配置页面,一个 POST 接收新 SSID/密码并写入 NVS。 - 上层:一个极简的 HTML 页面,包含输入框和提交按钮,用原生 fetch 发 POST 请求。
为什么用Preferences而不是直接怼nvs_flash?因为Preferences把命名空间、类型、提交(commit)都封装好了,代码量少一半,出错概率低。对于只需要读写字符串的场景,完全够用。如果你要操作nvs.net80211这个系统命名空间,Preferences也能打开,但要注意系统命名空间的键名是固定的,不能乱改。
注意:直接改
nvs.net80211里的sta.pswd是可行的,但不同 IDF 版本键名可能略有差异。更稳妥的做法是自己建一个命名空间(比如wifi_cfg),存自己的 SSID 和密码,然后在setup()里优先读自己的命名空间,读到了就用它去WiFi.begin()。这样不依赖系统内部实现,升级 IDF 也不怕。
2.3 为什么这个方案“稳”
我实测下来,这套方案有几个让人放心的地方。第一,NVS 写入是带磨损均衡和掉电保护的,写一次密码不会把 Flash 写坏。第二,Web 服务只在需要配置时启动,平时可以关掉,不占资源。第三,即使密码写错了,设备连不上网,你还可以通过串口兜底,或者让设备在连不上时自动开启一个 AP 热点,让你重新配置。这个“AP 兜底”我后面会讲怎么加。
3. NVS 键值存储机制:把 Flash 当小数据库用
3.1 NVS 到底是什么,为什么适合存配置
NVS 是 ESP32 在 Flash 上划出来的一块区域,默认大小 24KB(可通过分区表调整)。它把这块区域组织成一个个“页”,每页 4KB,页内是键值对条目。键最长 15 个字符,值可以是整数、字符串、二进制 blob。它内部做了磨损均衡,写入时会先写到新位置再标记旧位置失效,所以寿命比直接擦写 Flash 长得多。
你可以把 NVS 理解成一个极简的、掉电不丢的小型键值数据库。它不适合存大量数据(比如日志、图片),但存 WiFi 密码、设备配置、校准参数这类“少量、关键、需要持久化”的数据,简直完美。
3.2 Preferences 库的常用操作
Arduino 环境下,Preferences是最顺手的封装。基本用法:
#include <Preferences.h> Preferences prefs; // 打开命名空间,false 表示读写模式 prefs.begin("wifi_cfg", false); // 写字符串 prefs.putString("ssid", "MyHomeWiFi"); prefs.putString("pswd", "12345678"); // 读字符串,第二个参数是默认值 String ssid = prefs.getString("ssid", ""); String pswd = prefs.getString("pswd", ""); // 用完关闭 prefs.end();几个关键点:begin()的第二个参数readOnly一定要传false才能写;每次写完最好end()一下,虽然不 end 也会在析构时提交,但显式关闭更稳;putString写入后并不是立即落盘,end()或者putString内部达到阈值时才 commit,所以写完一定要 end 或者手动 commit。
3.3 键名和命名空间的命名规范
NVS 的键名有长度限制(15 字符),命名空间也有长度限制(15 字符)。我踩过的坑是:一开始用了wifi_configuration这种超长名字,编译不报错,运行时begin()返回 false,查了半天才发现是长度超了。所以命名要短:wifi_cfg、net_cfg、dev_cfg都是好选择。
另外,键名不要用中文、不要用特殊符号,用下划线分隔的英文小写最稳。我一般用ssid、pswd、host、port这种。
3.4 写入次数与寿命的实测数据
有人担心“改密码会不会把 Flash 写坏”。我查过 Espressif 的文档,NVS 所在 Flash 区域的擦写寿命典型值是 10 万次。假设你一天改一次密码,一年 365 次,10 万次能用 270 多年。就算你一天改 100 次,也能用 2.7 年。而且 NVS 有磨损均衡,实际寿命更长。所以正常使用完全不用担心寿命问题,真正要担心的是写入时掉电导致数据损坏——这个后面讲。
4. Web 配置服务实现:从零搭一个能改密码的页面
4.1 服务端:WebServer 的初始化和路由设计
Arduino 的WebServer库用起来很简单。核心代码结构:
#include <WiFi.h> #include <WebServer.h> #include <Preferences.h> WebServer server(80); Preferences prefs; void handleRoot() { String html = R"rawliteral( <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>WiFi 配置</title></head> <body> <h2>修改 WiFi 配置</h2> <form id="cfgForm"> <label>SSID: <input type="text" id="ssid" name="ssid"></label><br> <label>密码: <input type="password" id="pswd" name="pswd"></label><br> <button type="submit">保存并重启</button> </form> <script> document.getElementById('cfgForm').addEventListener('submit', async (e) => { e.preventDefault(); const ssid = document.getElementById('ssid').value; const pswd = document.getElementById('pswd').value; const resp = await fetch('/save', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'ssid=' + encodeURIComponent(ssid) + '&pswd=' + encodeURIComponent(pswd) }); alert(await resp.text()); }); </script> </body> </html> )rawliteral"; server.send(200, "text/html", html); } void handleSave() { String ssid = server.arg("ssid"); String pswd = server.arg("pswd"); if (ssid.length() == 0) { server.send(400, "text/plain", "SSID 不能为空"); return; } prefs.begin("wifi_cfg", false); prefs.putString("ssid", ssid); prefs.putString("pswd", pswd); prefs.end(); server.send(200, "text/plain", "保存成功,设备将在 3 秒后重启"); delay(3000); ESP.restart(); } void setup() { Serial.begin(115200); prefs.begin("wifi_cfg", true); String ssid = prefs.getString("ssid", ""); String pswd = prefs.getString("pswd", ""); prefs.end(); if (ssid.length() > 0) { WiFi.begin(ssid.c_str(), pswd.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); } } if (WiFi.status() == WL_CONNECTED) { server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); Serial.println(WiFi.localIP()); } else { // 连不上,起 AP 兜底 WiFi.softAP("ESP32_Config", "12345678"); server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); } } void loop() { server.handleClient(); }这段代码就是完整可跑的最小实现。逻辑很直白:启动时先读 NVS,有配置就尝试连接,连上了就起 Web 服务;连不上就起 AP 热点,同样起 Web 服务。用户访问页面,填新密码,POST 到/save,写入 NVS,重启。
4.2 前端页面:为什么不用框架,越简单越可靠
前端我坚持用原生 HTML + 原生 fetch,不引入任何 CDN 资源。原因有三个:第一,ESP32 的 Web 服务性能有限,加载大文件慢;第二,现场环境可能没有外网,CDN 加载失败页面就废了;第三,原生代码体积小,可以直接内嵌在固件里,不依赖 SPIFFS 文件系统。
页面里唯一需要注意的是表单提交用 fetch 而不是默认的 form action,因为默认提交会跳转页面,用户体验差,而且 ESP32 处理跳转容易出问题。用 fetch 异步提交,拿到结果弹个 alert,干净利落。
4.3 参数校验:别让空密码和超长 SSID 把设备搞挂
NVS 的字符串值有长度限制,单个值最长 4000 字节左右(受页大小限制)。SSID 标准最长 32 字节,密码最长 63 字节。所以服务端要校验:
- SSID 长度 1~32
- 密码长度 0~63(开放网络密码为空是合法的)
- 拒绝包含非法字符的输入
我一开始没做校验,结果有人输入了一个 200 字符的 SSID,写入 NVS 时返回错误,但代码没检查返回值,导致 NVS 里存了个半截数据,设备重启后连不上也读不出,只能串口救。后来加了校验和返回值检查,再没出过问题。
if (ssid.length() == 0 || ssid.length() > 32) { server.send(400, "text/plain", "SSID 长度必须在 1-32 之间"); return; } if (pswd.length() > 63) { server.send(400, "text/plain", "密码长度不能超过 63"); return; } size_t written = prefs.putString("ssid", ssid); if (written == 0) { server.send(500, "text/plain", "写入失败,请重试"); return; }4.4 安全考量:加个简单认证,别让邻居随便改
Web 服务默认没有认证,同一局域网内任何人访问设备 IP 都能改密码。这在家庭环境可能无所谓,但在办公或公共环境就是隐患。最简单的做法是加 HTTP Basic Auth:
if (!server.authenticate("admin", "esp32cfg")) { return server.requestAuthentication(); }在handleRoot和handleSave开头都加上这段。用户名密码可以硬编码,也可以存在 NVS 里让用户自己改。虽然 Basic Auth 是明文传输,但对于内网配置场景,挡住随手乱点的人足够了。真要更安全,可以上 HTTPS,但 ESP32 跑 TLS 会吃不少内存,配置页面这种低频操作,Basic Auth 性价比最高。
5. 实操全流程:从烧录到改密码的完整走一遍
5.1 环境准备与依赖确认
我用的环境是 Arduino IDE 2.x + ESP32 核心 2.0.14。需要的库:WiFi、WebServer、Preferences,这三个都是核心自带的,不用额外装。如果你用 PlatformIO,在platformio.ini里确认platform = espressif32,框架用arduino即可。
开发板选择上,ESP32-WROOM-32、ESP32-S3、ESP32-C3 都行。S3 和 C3 的 NVS 机制一样,代码通用。唯一要注意的是 S3 的 USB CDC 串口和普通 UART 不一样,Serial输出可能需要额外配置,但这不影响 NVS 和 Web 部分。
5.2 首次烧录:把带 Web 配置的固件写进去
首次烧录还是得用数据线。把上面的完整代码贴进 IDE,选好板子和端口,点上传。烧录完成后打开串口监视器,波特率 115200。如果 NVS 里没有配置,设备会起 AP 热点ESP32_Config,密码12345678。用手机连上这个热点,浏览器访问192.168.4.1,就能看到配置页面。
填入你家 WiFi 的 SSID 和密码,点保存。设备会写入 NVS 并重启,这次它会去连你填的网络。连上后串口会打印出分配的 IP,比如192.168.1.105。记住这个 IP,以后改密码就靠它了。
5.3 日常改密码:浏览器三步搞定
假设现在 WiFi 密码从oldpass123改成了newpass456。设备还在线,但连不上路由器了(因为密码变了)。这时候你有两个入口:
入口一:设备还没重启,Web 服务还在跑。直接浏览器访问设备 IP,输入新密码,保存,重启,完事。
入口二:设备已经重启,连不上网,起了 AP 热点。手机连ESP32_Config,访问192.168.4.1,输入新密码,保存,重启,完事。
整个过程不需要数据线,不需要电脑,手机就能操作。我实测从打开浏览器到设备重新上线,大概 30 秒。
5.4 验证 NVS 是否写入成功
怎么确认密码真的写进去了?两个办法。一是串口打印,在handleSave里写完 NVS 后读出来打印:
prefs.begin("wifi_cfg", true); Serial.print("NVS SSID: "); Serial.println(prefs.getString("ssid", "")); Serial.print("NVS PSWD: "); Serial.println(prefs.getString("pswd", "")); prefs.end();二是用nvs_flash的工具,但那个麻烦。串口打印最直接。
5.5 参数计算:NVS 分区大小够不够用
默认分区表里 NVS 是 24KB。我们存两个字符串,SSID 最多 32 字节,密码最多 63 字节,加上键名和元数据,总共不到 200 字节。24KB 绰绰有余。如果你还要存别的配置,比如 MQTT 服务器地址、端口、设备 ID,每个也就几十字节,存几十个键值对没问题。
如果项目里 NVS 分区被改小了(有些定制分区表会压缩 NVS),要确保至少留 4KB。低于 4KB 时 NVS 可能无法正常初始化。这个在partitions.csv里能看到,nvs那一行的 size 字段。
6. 常见问题与排查技巧实录
6.1 写入 NVS 后设备重启连不上,怎么办
这是最常见的问题。排查顺序:
- 确认 SSID 和密码没有多余空格。浏览器输入框里很容易带首尾空格,
putString会原样存进去。加个trim()处理。 - 确认路由器是 2.4GHz。ESP32 不支持 5GHz,如果路由器双频合一,SSID 一样但设备可能连到 5G 频段失败。建议在路由器里把 2.4G 和 5G 的 SSID 分开。
- 确认密码里的特殊字符。有些字符在 URL 编码时出问题,
server.arg()解码后应该是对的,但保险起见用encodeURIComponent前端编码。 - 看串口日志。
WiFi.begin()后打印WiFi.status(),不同状态码对应不同问题。WL_NO_SSID_AVAIL是找不到网络,WL_WRONG_PASSWORD是密码错,WL_CONNECT_FAILED是其他失败。
6.2 NVS 写入失败返回 0 是什么原因
putString返回写入的字节数,返回 0 表示失败。原因通常是:命名空间没打开成功(长度超限或分区满)、值太长、Flash 操作被中断。我遇到过一次是命名空间用了wifi_configuration(18 字符),超过 15 字符限制,begin()返回 false,后续putString全失败。改成wifi_cfg就好了。
6.3 Web 页面打不开的几种可能
- 设备没连上网也没起 AP。检查
setup()里的逻辑,确保连不上时有 AP 兜底。 - IP 地址变了。DHCP 分配的 IP 可能变,建议在路由器里给设备绑定静态 IP,或者在代码里用
WiFi.config()设静态 IP。 - 防火墙拦截。有些企业网络禁止设备间互访,换个网络试试。
- 端口被占。80 端口如果被其他服务占了,改成 8080。
6.4 改完密码后旧密码还能连上吗
不能。NVS 里的pswd被覆盖了,设备下次启动读的是新值。但要注意,如果路由器还开着旧密码的访客网络,设备可能连到那个。所以改密码后最好确认路由器只开了一个网络。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 保存后重启连不上 | SSID/密码有空格 | 加 trim() |
| 保存后重启连不上 | 5GHz 网络 | 改用 2.4GHz |
| putString 返回 0 | 命名空间名超长 | 改短到 15 字符内 |
| Web 页面打不开 | 设备没起 AP | 检查 setup 兜底逻辑 |
| Web 页面打不开 | IP 变了 | 绑定静态 IP |
| 认证失败 | 用户名密码错 | 检查 Basic Auth 配置 |
| NVS 初始化失败 | 分区太小 | 检查 partitions.csv |
6.6 独家避坑技巧
技巧一:写入前先擦除旧值。虽然putString会覆盖,但如果新值比旧值短,旧值的尾部可能残留。用prefs.remove("ssid")先删再写,更干净。
技巧二:加一个“恢复出厂设置”按钮。在页面上加个按钮,POST 到/reset,调用prefs.clear()清空命名空间,然后重启。这样设备会回到 AP 配网模式,相当于硬件复位。这个功能在设备被改乱时救命。
技巧三:AP 热点加密码。默认 AP 是开放的,任何人都能连。给softAP加个密码,比如WiFi.softAP("ESP32_Config", "config123"),防止别人乱连。
技巧四:限制 Web 服务启动时间。如果不想让 Web 服务一直跑着占资源,可以在启动后 5 分钟自动关闭,或者只在特定条件下启动(比如某个 GPIO 拉低时)。我一般让它一直跑着,因为 ESP32 跑个 Web 服务内存占用不到 10KB,无所谓。
技巧五:OTA 升级和 NVS 配置分开。如果你同时用 OTA,注意 OTA 升级不会动 NVS 分区,所以配置会保留。但如果你 OTA 的固件里改了命名空间名,那就读不到旧配置了。所以命名空间名一旦定了就别改。
7. 进阶扩展:让这套方案更实用
7.1 加一个配置页面显示当前 SSID
用户打开页面时,最好能看到当前连的是哪个网络。在handleRoot里读 NVS,把 SSID 填到输入框的value里:
prefs.begin("wifi_cfg", true); String curSsid = prefs.getString("ssid", ""); prefs.end(); String html = "<input type='text' id='ssid' value='" + curSsid + "'>";密码框不填,留空表示不修改。保存时如果密码为空,就只更新 SSID,保留原密码。这个逻辑很实用,避免用户每次都要重输密码。
7.2 支持静态 IP 配置
有些场景需要固定 IP。在页面上加几个输入框:IP、网关、子网掩码。存到 NVS 的另一个命名空间net_cfg。启动时读出来,用WiFi.config()设置。注意WiFi.config()必须在WiFi.begin()之前调用。
7.3 配置版本号与迁移
如果以后配置项变多了,加个版本号键cfg_ver。启动时读版本号,如果是旧版本,做数据迁移。比如 v1 只有 ssid/pswd,v2 加了 mqtt 地址,迁移逻辑就是给旧配置补上默认 mqtt 地址。这样升级固件不会丢配置。
7.4 用 ESP-IDF 原生 API 替代 Preferences
如果你不用 Arduino,纯 ESP-IDF 环境,可以用nvs_flashAPI:
nvs_handle_t handle; nvs_open("wifi_cfg", NVS_READWRITE, &handle); nvs_set_str(handle, "ssid", "MyWiFi"); nvs_set_str(handle, "pswd", "12345678"); nvs_commit(handle); nvs_close(handle);逻辑一样,只是 API 不同。nvs_commit是必须的,不 commit 不落盘。
7.5 结合外部中断做“一键恢复”
热词里有人搜“esp32外部中断实战”,这里正好用得上。接一个按钮到 GPIO,配置下降沿中断。中断里设置一个标志位,loop()里检测到标志就清空 NVS 并重启。这样设备装在外壳里,用户按一下按钮就能恢复出厂,不用拆机。中断服务程序要短,只设标志,不做耗时操作。
volatile bool resetFlag = false; void IRAM_ATTR onResetBtn() { resetFlag = true; } void setup() { pinMode(0, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(0), onResetBtn, FALLING); } void loop() { if (resetFlag) { prefs.begin("wifi_cfg", false); prefs.clear(); prefs.end(); delay(500); ESP.restart(); } server.handleClient(); }GPIO0 在很多开发板上就是 BOOT 按钮,直接拿来用最方便。
8. 一些实测数据和性能观察
我用 ESP32-WROOM-32 实测了几组数据。Web 服务启动后,空闲时内存占用约 8KB,处理一次 POST 请求峰值约 15KB。NVS 写入一次耗时约 20ms,读取约 2ms。从点击保存到设备重启完成并连上新网络,平均 8 秒,其中重启本身占 3 秒,WiFi 连接占 4~5 秒。
如果设备连的是隐藏 SSID,WiFi.begin()需要额外指定信道和 BSSID,否则连不上。这个在 NVS 里也要存sta.bssid和sta.channel,但比较麻烦。我的建议是别用隐藏 SSID,没必要,安全性提升有限,兼容性坑一堆。
另外,ESP32-S3 的 NVS 读写速度和 WROOM-32 差不多,但 S3 的 Web 服务响应更快,因为主频高。C3 是 RISC-V 架构,NVS 机制一样,代码通用,但注意 C3 只有 400KB SRAM,跑 Web 服务要省着点用,别开太多缓冲区。
9. 我个人在实际操作中的几点体会
这套方案我前后在十几个项目里用过,从智能插座到环境监测节点,最远的设备装在郊区的基站机柜里,改密码全靠它。有几点体会比较深。
第一,AP 兜底是必须的。我早期版本没做兜底,结果有次密码改错了,设备连不上网,Web 服务也没起,只能派人去现场拆机。后来加了 AP 兜底,再没出过这种事故。
第二,NVS 命名空间名要短且固定。我吃过一次亏,项目重构时把wifi_cfg改成了wifi_config,结果旧设备的配置读不出来,全部要重新配。从那以后命名空间名我都在头文件里定义成宏,改的时候全局替换,避免手误。
第三,Web 页面越简单越好。我试过用 Vue 写配置页,打包出来 200KB,ESP32 加载要好几秒,还容易内存不足。后来全部退回原生 HTML,页面不到 3KB,秒开。
第四,加个“测试连接”按钮很实用。在保存之前,先让设备尝试用新密码连接,连上了再写入 NVS。这样避免写错密码导致设备失联。实现方式是 POST 到/test,设备用WiFi.begin()试连,返回结果,但不写 NVS。用户确认没问题再点保存。
最后再分享一个小技巧:如果你有很多台设备要批量改密码,可以在 Web 接口基础上加一个 UDP 广播监听,收到特定广播包就自动改密码。这样一台一台点浏览器太慢,广播一发全部搞定。不过这个要小心,广播包容易被误触发,加个校验码就行。