1. 为什么改个 WiFi 密码要动固件
搞过 ESP32 项目的人大概率都经历过这个场景:设备已经装到现场了,外壳封好、螺丝拧紧、电源接上,结果甲方或者自己突然想换个 WiFi 密码。按照传统做法,你得把设备拆下来,连上电脑,打开 Arduino IDE 或者 ESP-IDF,改代码里的ssid和password,重新编译,重新烧录,再装回去。整个过程少说十几分钟,多则半小时,如果设备装在不好够到的地方,那体验简直酸爽。
这个问题的本质在于:WiFi 凭据被硬编码在固件里了。编译的时候写死,运行时从代码常量读取,改一次就得重新走一遍编译烧录流程。而 ESP32 本身是带 NVS(Non-Volatile Storage,非易失性存储)的,NVS 就是一块专门用来存键值对数据的 Flash 分区,掉电不丢。WiFi 的 SSID 和密码完全可以存在 NVS 里,运行时从 NVS 读,这样改密码就变成了改一个键值对,根本不需要碰固件。
那为什么很多人还是硬编码?因为改 NVS 需要额外的工具和通道。串口可以改,但得接线;蓝牙可以改,但得写 APP;MQTT 可以改,但设备得先连上网——鸡生蛋蛋生鸡。所以最优雅的方案是:在 ESP32 上跑一个内嵌 Web 服务器,浏览器打开一个页面就能直接读写 NVS 键值。设备本身作为一个 WiFi 热点或者连在局域网里,手机、电脑打开浏览器就能操作,不需要装任何软件,不需要接线,不需要重新烧录。
这篇文章就把这套方案的完整实现思路拆开讲清楚。从 NVS 的存储原理、Web 服务器的搭建、键值编辑页面的设计,到实际部署中会遇到的各种坑,比如并发写入、分区大小、掉电保护、安全认证等等。适合已经会用 Arduino 框架或者 ESP-IDF 开发 ESP32、想给设备加一个"免拆机配置"能力的开发者。读完你就能把这套东西移植到自己的项目里,以后改 WiFi 密码、改设备参数,浏览器点几下就完事。
2. NVS 到底能存什么、怎么存、有什么坑
2.1 NVS 的存储结构和读写机制
NVS 在 ESP32 的 Flash 里是一块独立的分区,默认大小通常是 0x5000(20KB),在分区表里以nvs类型标记。它的内部结构不是简单的文件系统,而是一套基于页(page)和条目(entry)的键值存储引擎。每个页默认 4096 字节,页头记录状态信息,后面跟着若干条目,每个条目包含键名(最长 15 个字符)、数据类型和值。
支持的数据类型包括:uint8_t、int8_t、uint16_t、int16_t、uint32_t、int32_t、uint64_t、int64_t、字符串(包括 blob)。对于 WiFi 密码这种场景,用字符串类型就够了。键名建议用简短的英文标识,比如wifi_ssid、wifi_pass,因为键名长度直接影响存储效率。
读写 API 在 Arduino 框架下是Preferences库,在 ESP-IDF 下是nvs.h。两者底层一样,只是封装不同。Preferences用起来更简单:
#include <Preferences.h> Preferences prefs; void saveWifiConfig(const char* ssid, const char* pass) { prefs.begin("wifi", false); // 命名空间 "wifi",false 表示读写模式 prefs.putString("ssid", ssid); prefs.putString("pass", pass); prefs.end(); } String loadSsid() { prefs.begin("wifi", true); // true 表示只读模式 String s = prefs.getString("ssid", ""); prefs.end(); return s; }这里有个细节:begin()的第二个参数是只读标志。如果你只是读取,传true可以减少不必要的写入操作,延长 Flash 寿命。NVS 的写入次数是有限的,虽然 ESP32 的 Flash 通常能撑 10 万次擦写,但频繁写入还是会加速老化。
2.2 命名空间的设计与隔离
NVS 支持命名空间(namespace),相当于数据库里的表。不同功能模块用不同命名空间,互不干扰。比如 WiFi 配置放wifi命名空间,设备参数放device命名空间,用户数据放user命名空间。这样做的好处是:清空某个模块的配置时不会误删其他数据。
命名空间名称最长 15 个字符,建议用有意义的短名称。实际项目中我见过有人把所有东西都塞进一个叫config的命名空间,结果键名冲突、管理混乱。分开命名空间之后,代码可读性和维护性都会好很多。
2.3 NVS 的容量限制和分区调整
默认 20KB 的 NVS 分区对于存几个 WiFi 凭据来说绰绰有余,但如果你打算存大量数据(比如日志、用户列表),就需要调整分区表。在 Arduino 框架下,可以通过Tools -> Partition Scheme选择不同的分区方案,或者自定义partitions.csv文件。
一个典型的自定义分区表长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x140000, app1, app, ota_1, 0x150000,0x140000, spiffs, data, spiffs, 0x290000,0x160000,如果你把 NVS 改大,比如改成0x10000(64KB),那就要相应调整后面分区的偏移地址。改分区表有风险,改错了设备起不来,建议改之前先备份原始分区表。
注意:NVS 分区一旦格式化,里面所有数据都会丢失。ESP32 在 NVS 分区损坏或者首次使用时会自动格式化,但如果你手动调用了
nvs_flash_erase(),那就是彻底清空。
2.4 掉电保护和写入原子性
NVS 的设计本身有一定的掉电保护能力。它采用页轮转机制,写入新数据时先写到新页,写完了再标记旧页为无效,最后擦除。这个过程如果中途掉电,最坏情况是这次写入没生效,但不会破坏已有数据。
不过,如果你在代码里连续写入多个键值对,中间掉电可能导致部分写入成功、部分失败。比如先写 SSID 再写密码,写到一半断电,结果 SSID 是新的、密码是旧的,设备就连不上了。解决办法是:要么把相关配置打包成一个 blob 一次性写入,要么在写入完成后做一个校验标记。
// 打包写入示例 struct WifiConfig { char ssid[32]; char pass[64]; uint32_t crc; }; void saveWifiConfigAtomic(const char* ssid, const char* pass) { WifiConfig cfg; strncpy(cfg.ssid, ssid, sizeof(cfg.ssid)); strncpy(cfg.pass, pass, sizeof(cfg.pass)); cfg.crc = calculateCRC(&cfg, sizeof(cfg) - sizeof(cfg.crc)); prefs.begin("wifi", false); prefs.putBytes("config", &cfg, sizeof(cfg)); prefs.end(); }读取的时候先校验 CRC,不匹配就说明数据损坏,回退到默认配置。这套机制在工业现场特别有用,因为现场断电是家常便饭。
3. 在 ESP32 上搭一个能改 NVS 的 Web 服务器
3.1 Web 服务器的选型:ESPAsyncWebServer 还是同步方案
ESP32 上跑 Web 服务器有两个主流选择:同步的WebServer库(Arduino 自带)和异步的ESPAsyncWebServer。同步方案代码简单,但每个请求都会阻塞主循环,如果同时有多个请求或者请求处理时间较长,设备会显得很卡。异步方案基于事件回调,不阻塞,能同时处理多个连接,适合这种配置页面场景。
我实测下来,ESPAsyncWebServer在响应速度和并发处理上明显优于同步方案。特别是当你需要在处理请求的同时维持 WiFi 连接、跑其他任务时,异步方案几乎是必选。安装方式也简单,Arduino IDE 的库管理器里搜ESPAsyncWebServer和AsyncTCP就行。
#include <WiFi.h> #include <ESPAsyncWebServer.h> #include <Preferences.h> AsyncWebServer server(80); Preferences prefs;这里有个版本兼容性问题:ESPAsyncWebServer对 ESP32 核心库的版本比较敏感。如果你用的是较新的 ESP32 Arduino Core(3.x),可能需要用特定分支的库。我踩过的坑是核心库 3.0.0 配老版本 AsyncWebServer 编译报错,换成 GitHub 上最新的 master 分支就好了。
3.2 配网模式与常连模式的切换逻辑
设备启动时应该先尝试用 NVS 里存的凭据连 WiFi。如果连不上(比如密码改了、路由器换了),就自动切换到 AP 模式,自己做一个热点,让用户连上来配置。这个逻辑用状态机实现最清晰:
enum WifiState { CONNECTING, CONNECTED, AP_MODE }; WifiState currentState = CONNECTING; void setupWifi() { String ssid = loadSsid(); String pass = loadPass(); if (ssid.length() == 0) { enterApMode(); return; } WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); } if (WiFi.status() == WL_CONNECTED) { currentState = CONNECTED; Serial.println(WiFi.localIP()); } else { enterApMode(); } } void enterApMode() { WiFi.mode(WIFI_AP); WiFi.softAP("ESP32-Config", "12345678"); currentState = AP_MODE; Serial.println(WiFi.softAPIP()); }AP 模式的默认密码建议设一个简单的,比如12345678,方便用户第一次连接。但要注意,这个密码也应该允许用户修改,否则就成了固定后门。可以在 Web 页面里加一个修改 AP 密码的选项,存到 NVS 的另一个键里。
3.3 路由设计:读取、写入、重启三个核心接口
Web 服务器只需要三个核心路由:一个返回当前配置的 HTML 页面,一个接收 POST 请求写入新配置,一个触发重启。页面用简单的 HTML 表单就行,不需要前端框架,减少 Flash 占用。
const char index_html[] PROGMEM = R"rawliteral( <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ESP32 配置</title> <style> body { font-family: Arial; max-width: 400px; margin: 40px auto; padding: 20px; } input { width: 100%; padding: 10px; margin: 8px 0; box-sizing: border-box; } button { width: 100%; padding: 12px; background: #007bff; color: white; border: none; } </style> </head> <body> <h2>WiFi 配置</h2> <form action="/save" method="POST"> <label>SSID</label> <input type="text" name="ssid" value="%SSID%" maxlength="32"> <label>密码</label> <input type="text" name="pass" value="%PASS%" maxlength="64"> <button type="submit">保存并重启</button> </form> </body> </html> )rawliteral";注意%SSID%和%PASS%是占位符,需要在请求处理时替换成实际值。ESPAsyncWebServer提供了processor机制来做模板替换:
String processor(const String& var) { if (var == "SSID") return loadSsid(); if (var == "PASS") return loadPass(); return String(); } server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send_P(200, "text/html", index_html, processor); });写入接口用 POST 处理,从请求体里解析表单数据:
server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request) { if (request->hasParam("ssid", true) && request->hasParam("pass", true)) { String ssid = request->getParam("ssid", true)->value(); String pass = request->getParam("pass", true)->value(); saveWifiConfig(ssid.c_str(), pass.c_str()); request->send(200, "text/plain", "保存成功,设备将在 3 秒后重启"); delay(3000); ESP.restart(); } else { request->send(400, "text/plain", "参数缺失"); } });这里有个细节:hasParam的第二个参数true表示从 POST body 里取,false表示从 URL query 里取。表单提交默认是application/x-www-form-urlencoded,所以要用true。
3.4 页面模板的轻量化处理
ESP32 的 Flash 和 RAM 都有限,页面不能太臃肿。我见过有人往 ESP32 里塞 Bootstrap 和 jQuery,结果编译出来固件快 2MB,运行起来还卡。其实配置页面只需要最基本的 HTML 和 CSS,几十行就够了。把 HTML 用PROGMEM存到 Flash 里,不占 RAM。
如果需要更复杂的交互,比如实时显示 WiFi 扫描结果,可以用一小段原生 JavaScript 发 AJAX 请求,但不要引入任何框架。ESP32 的 Web 服务器处理能力有限,前端越简单越稳。
4. 键值编辑页面的交互细节与安全考量
4.1 表单字段的校验与边界处理
用户输入是不可信的。SSID 最长 32 字节,密码最长 64 字节(WPA2 标准),这些边界必须在服务端校验。如果用户输入超长字符串,直接写入 NVS 可能导致截断或者写入失败。
bool validateInput(const String& ssid, const String& pass) { if (ssid.length() == 0 || ssid.length() > 32) return false; if (pass.length() > 0 && pass.length() < 8) return false; // WPA2 最少 8 位 if (pass.length() > 64) return false; return true; }另外,SSID 里可能包含特殊字符,比如中文、空格、引号。这些在 HTML 表单里需要正确转义,否则页面会乱掉。ESPAsyncWebServer的send_P不会自动转义,需要自己处理。简单做法是在processor里对返回值做 HTML 实体编码:
String htmlEscape(const String& input) { String output = input; output.replace("&", "&"); output.replace("<", "<"); output.replace(">", ">"); output.replace("\"", """); return output; }4.2 配置页面的访问控制
AP 模式下,任何连上热点的人都能访问配置页面。如果设备部署在公共场所,这就是个安全隐患。至少应该加一个简单的 HTTP Basic Auth:
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { if (!request->authenticate("admin", "esp32config")) { return request->requestAuthentication(); } request->send_P(200, "text/html", index_html, processor); });用户名和密码也可以存在 NVS 里,允许用户修改。但要注意,HTTP Basic Auth 是明文传输的,在局域网里问题不大,但如果设备暴露在公网(不建议这么做),就必须上 HTTPS。ESP32 跑 HTTPS 会消耗大量 RAM,一般项目没必要。
4.3 写入后的状态反馈与重启策略
用户点击保存后,设备需要重启才能用新配置连 WiFi。但直接重启会让浏览器显示连接中断,用户体验不好。我的做法是:先返回一个"保存成功,即将重启"的页面,延迟 3 秒再重启。这样浏览器能正常收到响应,用户也能看到提示。
request->send(200, "text/html", "<h3>保存成功</h3><p>设备将在 3 秒后重启,请重新连接 WiFi。</p>" "<script>setTimeout(()=>{location.href='/';}, 5000);</script>");重启后,如果新配置正确,设备会连上新的 WiFi,AP 模式关闭。如果配置错误,设备会再次进入 AP 模式,用户可以重新配置。这个"失败回退"机制很重要,否则配错了就只能拆机。
4.4 多用户并发写入的冲突避免
虽然配置页面通常只有一个人用,但万一两个人同时打开页面提交,就可能出现写入冲突。NVS 本身没有锁机制,两个任务同时写同一个键可能出问题。简单的解决办法是加一个软件标志位:
volatile bool isSaving = false; server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request) { if (isSaving) { request->send(429, "text/plain", "正在保存,请稍后重试"); return; } isSaving = true; // ... 保存逻辑 ... isSaving = false; });更严谨的做法是用 FreeRTOS 的互斥锁(mutex),但对于这种低频操作,一个布尔标志就够了。
5. 实际部署中遇到的坑与排查过程
5.1 连不上 WiFi 但 AP 模式也进不去
有一次我把设备装到现场,上电后既连不上原来的 WiFi,也搜不到 AP 热点。排查了半天,发现是 NVS 里的 SSID 存了一个空字符串,但代码逻辑判断ssid.length() == 0才进 AP 模式,而空字符串的长度确实是 0,应该进 AP 才对。后来发现是loadSsid()返回了乱码,因为 NVS 分区被意外格式化了,读取时返回了默认值,但默认值我设的是"",按理说没问题。
最终定位到是Preferences库的begin()调用失败,返回了false,但我没检查返回值,后续的getString就返回了未定义内容。修复方法很简单:检查begin()的返回值,失败就进 AP 模式。
bool loadWifiConfig(String& ssid, String& pass) { if (!prefs.begin("wifi", true)) { return false; } ssid = prefs.getString("ssid", ""); pass = prefs.getString("pass", ""); prefs.end(); return ssid.length() > 0; }这个坑的教训是:所有 NVS 操作都要检查返回值,不能假设一定成功。
5.2 保存后重启,新密码不生效
另一个常见问题是:用户保存了新密码,设备重启后还是连不上。排查发现是保存的时候写入了 NVS,但重启后读取的还是旧值。原因是Preferences的putString之后没有调用end(),数据还在缓存里没刷到 Flash。Preferences库在end()时才会真正提交写入,如果中间断电或者直接重启,数据就丢了。
修复方法:每次写入后必须调用end(),或者用prefs.putString后跟一个prefs.end()。另外,写入完成后最好加一个短暂的延迟(比如 100ms),确保 Flash 操作完成。
5.3 页面加载慢或者样式丢失
有用户反馈配置页面打开要好几秒,而且 CSS 不生效。排查发现是 HTML 页面太大,而且每次请求都从 Flash 读取并拼接字符串,效率低。优化方法是把静态部分用PROGMEM存储,动态部分只替换占位符。另外,CSS 直接内联在 HTML 里,不要单独请求.css文件,减少 HTTP 请求次数。
还有一个细节:ESPAsyncWebServer默认的 TCP 发送缓冲区较小,如果页面超过 2KB,可能会分多次发送,导致浏览器渲染慢。可以在AsyncWebServer初始化时调整缓冲区大小,或者把页面压缩到 2KB 以内。
5.4 AP 模式下手机连不上或者频繁掉线
ESP32 的 AP 模式默认最多支持 4 个客户端,而且如果信道拥挤,连接会不稳定。我遇到过手机连上 AP 后几秒就断开的情况,排查发现是 AP 的信道和周围路由器冲突。解决办法是在softAP时指定一个相对空闲的信道:
WiFi.softAP("ESP32-Config", "12345678", 6); // 指定信道 6另外,AP 模式下如果同时尝试连接路由器(STA 模式),会互相干扰。建议在 AP 模式下关闭 STA 接口,只保留 AP。
6. 从能用到好用:几个提升体验的扩展思路
6.1 扫描周围 WiFi 并下拉选择
让用户手动输入 SSID 容易出错,尤其是那些隐藏了 SSID 或者名字很长的网络。可以在配置页面加一个"扫描"按钮,点击后 ESP32 扫描周围 WiFi,把结果返回给前端,用下拉框展示。
server.on("/scan", HTTP_GET, [](AsyncWebServerRequest *request) { int n = WiFi.scanNetworks(); String json = "["; for (int i = 0; i < n; i++) { if (i > 0) json += ","; json += "{\"ssid\":\"" + WiFi.SSID(i) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + "}"; } json += "]"; request->send(200, "application/json", json); });前端用一小段 JavaScript 发请求并填充下拉框。注意扫描操作会阻塞几秒钟,期间 WiFi 连接会中断,所以最好在 AP 模式下做这个操作。
6.2 配置导入导出与批量部署
如果你有多个设备要部署,一个个配置太麻烦。可以加一个导出功能,把当前 NVS 里的配置以 JSON 格式下载下来,然后在其他设备上导入。实现方式就是加两个路由:/export返回 JSON,/import接收 JSON 并写入 NVS。
这个功能在批量生产或者现场调试时特别有用。我做过一个项目,20 台设备,用导出导入功能,5 分钟全部配好,比一台台手动输入快多了。
6.3 OTA 升级与配置保留的配合
设备支持 OTA 升级后,固件更新不会影响 NVS 里的配置,这是 NVS 的一大优势。但要注意,如果新固件改了 NVS 的键名或者数据结构,旧配置可能读不出来。解决办法是在固件里加一个版本号,启动时检查 NVS 里的配置版本,不匹配就迁移或者重置。
#define CONFIG_VERSION 2 void checkConfigVersion() { prefs.begin("wifi", false); uint32_t ver = prefs.getUInt("cfg_ver", 0); if (ver != CONFIG_VERSION) { // 执行迁移逻辑 prefs.putUInt("cfg_ver", CONFIG_VERSION); } prefs.end(); }6.4 把配置页面做成可复用的组件
如果你经常做 ESP32 项目,建议把这套 Web 配置功能封装成一个独立的库或者头文件,下次直接引用。核心接口就三个:beginConfigPortal()启动服务器,loadConfig()读取配置,saveConfig()保存配置。这样不同项目之间可以复用,不用每次都重写。
我在实际使用中发现,把这套东西封装好之后,新项目的配置功能开发时间从半天缩短到十分钟。而且因为经过了多个项目的验证,稳定性也有保障。
最后分享一个小技巧:在 NVS 里存一个config_dirty标志位,每次保存配置时置位,设备成功连上 WiFi 后清零。如果启动时发现这个标志是置位的,说明上次配置后没连上,可以自动进入 AP 模式让用户重新配置。这个机制能避免设备"变砖",在现场部署时特别有用。