1. 项目缘起:一个让人抓狂的日常痛点
但凡玩过 ESP32 的人,大概率都经历过这样一个场景:设备已经焊好、装进壳子、挂在墙上或者塞在某个角落,跑了大半年,突然要换 WiFi——可能是路由器换了、密码改了、或者要把设备从实验室挪到客厅。这时候你打开 Arduino IDE 或者 ESP-IDF,改一行ssid和password,重新编译,插上 USB 线,按住 BOOT 键,重新烧录。如果设备装在够不着的地方,那画面就更美了。
这个项目要解决的就是这件事:不重刷固件,直接在浏览器里改 ESP32 的 WiFi 配置。核心思路是把 WiFi 凭据从编译期硬编码的常量,挪到 ESP32 的NVS(Non-Volatile Storage,非易失性存储)里,然后在设备上跑一个轻量 Web 服务,用浏览器访问就能读写这些键值。改完重启,设备用新配置连网,固件一行都不用动。
适合谁来参考?三类人最受益:一是做 IoT 小产品、设备已经部署出去的开发者;二是玩智能家居、懒得每次拆壳重烧的 DIY 玩家;三是想搞明白 NVS 到底怎么用、Web 配置页怎么做的嵌入式初学者。哪怕你只会用 Arduino 框架,跟着走也能落地。
我先把结论摆在这:整套方案的核心不是"Web 服务器"那部分,而是配置读取的优先级设计和NVS 命名空间的规划。很多人第一次做会踩的坑,几乎都出在这两块。下面我按实际做项目的顺序,把设计思路、关键细节、完整实操和踩坑记录一层层拆开讲。
2. 整体设计思路:为什么是 NVS + Web,而不是别的方案
2.1 三种常见方案对比与选型逻辑
在动手之前,先想清楚"配置存哪、怎么改"这两个问题。我实际试过或见过的主流方案有三种,各自的取舍差别很大。
| 方案 | 配置存储位置 | 修改方式 | 优点 | 致命缺点 |
|---|---|---|---|---|
| 硬编码 + 重烧 | 固件源码 | 改代码重新烧录 | 简单直接 | 每次改都要接线、编译、烧录 |
| SD 卡 / 外部文件 | 外部存储 | 拔卡改文件 | 不占内部 Flash | 需要额外硬件,卡易松动 |
| NVS + Web 配置页 | 片内 Flash 的 NVS 分区 | 浏览器访问 | 无需接线、无需重烧 | 需要设备先能连上(首次配置需 AP 模式) |
选 NVS + Web 的理由很实在:ESP32 片内 Flash 本来就有一块专门给 NVS 用的分区,不额外花钱;NVS 是键值数据库,读写 API 成熟稳定;Web 配置页对用户来说零门槛,手机浏览器就能操作。SD 卡方案看着优雅,但多一个硬件就多一个故障点,设备震动、受潮都可能让卡接触不良,反而更麻烦。
注意:NVS 不是万能的。它适合存小块的配置数据(字符串、整数、布尔),不适合存大文件或频繁写入的数据。NVS 的擦写寿命有限,别把它当日志存储用。
2.2 配置读取的优先级设计:这是整个项目的灵魂
真正让这个方案好用的,不是"能改",而是"改坏了还能救回来"。我的设计是三级优先级:
- NVS 中已保存的配置(最高优先级)——用户通过 Web 页改过的值。
- 编译期默认值(兜底)——代码里写的
DEFAULT_SSID/DEFAULT_PASSWORD。 - AP 配网模式(最后防线)——如果前两级都连不上,设备自己开热点,让用户连上来重新配。
为什么这么设计?因为实际部署中,"改错密码导致设备失联"是最高频的事故。如果没有第三级兜底,用户改错一个字符,设备就变成砖,只能拆下来重烧——那这个项目就白做了。有了 AP 兜底,最坏情况也就是连上设备热点重新填一次。
这个优先级逻辑用伪代码表达就是:
String ssid = nvs_get_string("wifi", "ssid", DEFAULT_SSID); String pass = nvs_get_string("wifi", "pass", DEFAULT_PASSWORD); if (tryConnect(ssid, pass, TIMEOUT_MS)) { startWebServer(); // 连上了,正常跑业务 + 开配置页 } else { startAPMode(); // 连不上,开热点等用户来配 startWebServer(); // AP 模式下同样开配置页 }2.3 NVS 命名空间规划:别把所有东西塞一个筐
NVS 的结构是"命名空间(namespace)→ 键(key)→ 值(value)"。命名空间相当于数据库里的表,键相当于字段。我见过不少人图省事,所有配置全塞进一个叫config的命名空间,结果键名冲突、维护混乱。
我的划分习惯是按功能域拆:
wifi:ssid、pass、static_ip、gatewaydevice:name、location、report_intervalmqtt:broker、port、user、pass
这样每个模块只管自己的命名空间,代码解耦,后期加功能也不会互相干扰。键名统一用小写加下划线,长度控制在 15 字符以内——NVS 的键名上限是 15 个字符(不含结尾符),超了会直接报错,这个坑我踩过。
3. 核心细节解析:NVS 操作与 Web 服务的实操要点
3.1 NVS 读写 API 的正确打开方式
Arduino 框架下用Preferences库操作 NVS 最省事,它是对底层nvs_flash的封装。基本流程是"打开命名空间 → 读/写 → 关闭"。
#include <Preferences.h> Preferences prefs; // 写入 prefs.begin("wifi", false); // false = 读写模式 prefs.putString("ssid", newSsid); prefs.putString("pass", newPass); prefs.end(); // 必须关闭,否则占用句柄 // 读取 prefs.begin("wifi", true); // true = 只读模式 String ssid = prefs.getString("ssid", DEFAULT_SSID); String pass = prefs.getString("pass", DEFAULT_PASSWORD); prefs.end();几个必须记住的点:
begin()的第二个参数是只读标志。只读模式打开更快,读配置时优先用只读。- 每次
begin()后必须end(),否则句柄泄漏,多次操作后会失败。 getString(key, default)的第二个参数是默认值,键不存在时返回它——这正是我们做兜底逻辑的基础。- 写入操作会触发 Flash 擦写,别在循环里高频写。配置类数据一次写入就够。
提示:如果你用 ESP-IDF 原生开发,对应的是
nvs_open/nvs_set_str/nvs_get_str/nvs_commit。记得nvs_set_str之后要nvs_commit才真正落盘,Arduino 的Preferences帮你自动做了这一步。
3.2 Web 配置页的最小实现
Web 服务用WebServer库(ESP32 自带)就够了,不需要上异步库。核心是两个路由:一个返回配置页 HTML,一个接收表单提交。
#include <WebServer.h> WebServer server(80); void handleRoot() { String html = "<form action='/save' method='POST'>" "SSID:<input name='ssid'><br>" "PASS:<input name='pass' type='password'><br>" "<button>Save</button></form>"; server.send(200, "text/html", html); } void handleSave() { String s = server.arg("ssid"); String p = server.arg("pass"); prefs.begin("wifi", false); prefs.putString("ssid", s); prefs.putString("pass", p); prefs.end(); server.send(200, "text/html", "Saved. Rebooting..."); delay(1000); ESP.restart(); }这里有个细节值得说:保存后直接重启。为什么不热切换 WiFi?因为 ESP32 在已连接状态下切换 STA 配置,行为不稳定,容易卡死。重启一次几百毫秒,换来的是确定性,这笔账划算。
3.3 表单安全与输入校验:别让用户把设备搞死
用户输入是不可信的。SSID 里可能有空格、中文、特殊符号,密码可能超长。我一般做三层校验:
- 长度校验:SSID 1~32 字节,密码 8~63 字节(WPA2 规范)。
- 空值校验:SSID 不能为空,密码允许为空(开放网络)。
- 字符过滤:HTML 表单提交的值做基本转义,避免注入到页面里。
if (s.length() == 0 || s.length() > 32) { server.send(400, "text/plain", "Invalid SSID length"); return; }注意:ESP32 的 WiFi SSID 按字节算,一个中文字符占 3 字节(UTF-8)。如果你的 SSID 是中文,32 字节其实只能放 10 个汉字左右。这个限制很多人不知道,配中文热点名时容易翻车。
4. 完整实操流程:从零到能用的配置页
4.1 环境准备与分区确认
先确认你的开发环境。Arduino IDE 装好 ESP32 支持包,或者用 PlatformIO。分区表用默认的default.csv就行,它已经包含 NVS 分区。如果你想确认,打开分区表看一眼:
# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x50000x5000是 20KB,存几十个配置键绰绰有余。如果你的项目 NVS 用量大,可以调大这个值,但要注意别和其他分区重叠。
4.2 核心代码骨架
把前面几块拼起来,一个完整的setup()逻辑长这样:
void setup() { Serial.begin(115200); // 1. 读配置 prefs.begin("wifi", true); String ssid = prefs.getString("ssid", DEFAULT_SSID); String pass = prefs.getString("pass", DEFAULT_PASSWORD); prefs.end(); // 2. 尝试连接 WiFi.mode(WIFI_STA); WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); Serial.print("."); } // 3. 分支处理 if (WiFi.status() == WL_CONNECTED) { Serial.println("\nConnected: " + WiFi.localIP().toString()); } else { Serial.println("\nFailed, starting AP mode"); WiFi.mode(WIFI_AP); WiFi.softAP("ESP32-Config", "12345678"); Serial.println("AP IP: " + WiFi.softAPIP().toString()); } // 4. 启动 Web 服务 server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); } void loop() { server.handleClient(); }15 秒的超时是我反复调出来的经验值。太短,路由器 DHCP 慢的时候会误判;太长,用户等得心焦。15 秒是个平衡点。
4.3 参数选择背后的计算
超时时间怎么定?我算过一笔账:ESP32 连接 WiFi 的典型耗时是 2~5 秒,路由器 DHCP 分配 IP 通常再加 1~3 秒,网络拥堵或信号弱时可能到 10 秒。取 15 秒,覆盖了 95% 以上的正常情况,同时不至于让用户等太久。如果你部署环境信号特别差,可以调到 20 秒,但别超过 30 秒——超过这个数,用户会以为设备死机了。
AP 模式的密码12345678是临时用的,用户连上配完就走。你也可以设成开放热点,但那样附近任何人都能连进来改你的配置,安全性差。8 位数字密码是个折中,够用且好记。
4.4 实测记录
我在一块 ESP32-WROOM-32 上实测:首次烧录后,设备用默认配置连上家里路由器,浏览器访问它的 IP,改 SSID 和密码,点保存,设备重启,约 8 秒后连上新网络。整个过程不用碰 USB 线。第二次故意填错密码,设备 15 秒后自动进入 AP 模式,手机连上ESP32-Config热点,访问192.168.4.1重新配置,恢复正常。兜底逻辑生效。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 保存后连不上 | 密码错误 / SSID 含特殊字符 | 进 AP 模式重配,检查字节长度 |
| Web 页打不开 | 设备没连上 / IP 变了 | 串口打印 IP,或用 mDNS |
| NVS 写入失败 | 命名空间未关闭 / 分区满 | 检查end()调用,看分区大小 |
| 重启后配置丢失 | 没调commit/ 写到了只读句柄 | 确认begin用读写模式 |
| AP 模式连不上 | 热点名冲突 / 密码错 | 换热点名,确认 8 位密码 |
5.2 几个只有踩过才知道的坑
坑一:Preferences句柄没关,第二次begin直接失败。这个错误很隐蔽,因为第一次读写都正常,第二次才出问题。养成"begin 和 end 成对出现"的习惯,或者用 RAII 思路封装。
坑二:SSID 里的空格和中文。表单提交时,URL 编码会把空格变成+或%20,WebServer库的arg()会自动解码,但如果你手动拼 URL 就容易出错。中文 SSID 更要小心字节长度。
坑三:AP 模式和 STA 模式切换不干净。从 STA 切到 AP 前,先调WiFi.disconnect(true)彻底断开,否则可能残留状态导致 AP 起不来。我一开始没加这句,AP 模式十次有三次失败。
坑四:mDNS 让访问更省心。与其记 IP,不如起个 mDNS 名字,浏览器访问esp32.local就行。加两行代码:
#include <ESPmDNS.h> MDNS.begin("esp32");这样用户不用去串口看 IP,体验直接上一个台阶。
5.3 安全加固的几点建议
配置页默认没有鉴权,局域网内谁都能访问。如果设备部署在公共网络,建议加一层简单认证:在handleSave里检查一个预设的 token,或者用 HTTP Basic Auth。另外,配置页只在需要时开启——正常运行时可以关掉 Web 服务,省电也省心。我的做法是设备启动后开 5 分钟配置窗口,超时自动关闭,需要时通过物理按键重新唤起。
6. 后续可扩展的方向
这套骨架搭好之后,能扩展的地方很多。比如把配置页做成带扫描功能的——列出周围 WiFi,点一下自动填 SSID,省得手输。再比如加个"恢复出厂设置"按钮,一键清空 NVS 回到默认值。还可以把配置项做成动态的,从 JSON 描述文件生成表单,加新配置项不用改前端代码。
我自己在实际项目里还加了一个小功能:配置页显示当前连接状态和信号强度,用户一眼就能看出设备连得稳不稳。这个信息对排查"设备时好时坏"的问题特别有用,比翻串口日志直观多了。
最后分享一个我踩过好几次才总结出的经验:NVS 的键名和命名空间一旦定下来,尽量别改。因为设备部署出去之后,老固件写进去的键名和新固件读的键名对不上,配置就"丢"了。如果非要改,记得在新固件里做一次迁移逻辑,把老键的值读出来写到新键,再删掉老键。这个迁移代码写起来不复杂,但能省掉大量现场返工的麻烦。