
1. 从一个让人抓狂的场景说起如果你玩过 ESP32大概率经历过这个场景设备已经焊好、装进外壳、挂在墙上跑了三个月突然要换个 WiFi 密码。你翻出数据线拆外壳插 USB打开 Arduino IDE 或者 ESP-IDF改一行ssid和password编译烧录等两分钟再装回去。整个过程十分钟起步如果设备装在不好够到的地方那更是灾难。更别提有些项目是批量部署的几十台设备散落在不同位置每换一次密码就得挨个刷固件这活儿谁干谁知道。问题的根源在于大多数人把 WiFi 配置写死在代码里编译进固件。固件是只读的改配置就得重新编译烧录。但 ESP32 其实自带了一块NVSNon-Volatile Storage非易失性存储分区专门用来存键值对数据掉电不丢。WiFi 的 SSID 和密码完全可以存在 NVS 里代码启动时从 NVS 读取而不是写死。那怎么改 NVS 里的值传统做法是写一段串口命令解析代码通过串口发指令修改。但这需要设备在线、需要串口连接、需要你自己实现一套协议。有没有更省事的办法有。用浏览器直接改。ESP32 跑一个轻量 HTTP 服务浏览器打开一个页面表单里填新的 SSID 和密码点提交NVS 就更新了重启后自动连上新 WiFi。整个过程不需要数据线不需要编译环境手机浏览器就能操作。这篇文章就把这套方案的完整实现思路、核心代码、踩坑经验全部拆开讲清楚。适合已经会用 Arduino IDE 或 ESP-IDF 开发 ESP32、想给项目加一个免刷机改配置能力的开发者。哪怕你只做过点灯和连 WiFi 的基础项目跟着思路也能落地。2. 整体方案设计与技术选型2.1 为什么选 NVS 而不是其他存储方式ESP32 上能存数据的地方有好几个选哪个得看场景。Flash 直接读写ESP32 的 Flash 可以通过esp_partitionAPI 直接读写但 Flash 有擦除块限制通常 4KB写之前要先擦除整个扇区操作不当容易把固件区域搞坏。而且没有磨损均衡频繁写会缩短寿命。SPIFFS/LittleFS 文件系统适合存大文件、日志、网页资源但存几个键值对属于杀鸡用牛刀挂载文件系统本身也要占资源和时间。NVSESP-IDF 原生支持的键值存储专门为小数据设计。支持磨损均衡、掉电保护、类型区分整数、字符串、二进制块。API 简单nvs_set_str、nvs_get_str两行搞定。Arduino 环境下通过Preferences库封装更好用。所以存 WiFi 配置这种几个字符串的需求NVS 是最合适的。它就像一个微型数据库你给它一个命名空间namespace和一个键key它帮你持久化。2.2 为什么用浏览器而不是串口或蓝牙串口改配置需要物理连接设备装进盒子后就很难够到。蓝牙需要配对、需要写 App 或者用通用蓝牙调试工具操作门槛高而且蓝牙和 WiFi 共存时射频资源紧张。浏览器方案的优势在于零客户端依赖。ESP32 自己开一个 HTTP 服务任何有浏览器的设备手机、电脑、平板连上就能访问。不需要装 App不需要驱动不需要数据线。配置页面就是一个 HTML 表单提交后 ESP32 解析请求参数写入 NVS返回结果。这里有个关键设计点配置模式和运行模式要分开。设备正常运行时连的是目标 WiFi你没法通过那个网络访问它IP 不确定而且可能不在同一网段。所以需要一个配置模式——设备启动时如果检测到某个条件比如按键按下、或者 NVS 里没有有效配置就切换成 AP 模式自己发一个热点你连上这个热点访问配置页面。2.3 双模式架构AP 配置 STA 运行整体架构是这样的首次启动或配置无效进入 AP 模式SSID 类似ESP32-Config开 HTTP 服务等待用户提交配置。配置有效进入 STA 模式从 NVS 读取 SSID 和密码连接目标 WiFi正常运行。需要重新配置可以通过按键触发长按 GPIO0 若干秒或者访问运行模式下的某个特定 URL 触发重启进入 AP 模式。这个架构的好处是正常运行时不影响业务逻辑需要改配置时又能随时切换。而且配置页面可以做得比较友好显示当前配置、扫描周围 WiFi 列表、表单验证等等。2.4 技术栈选择Arduino 还是 ESP-IDF两种都能做各有取舍。Arduino 框架Preferences库封装了 NVSWebServer库处理 HTTP代码量少上手快。适合中小项目和快速原型。缺点是WebServer是同步阻塞的处理请求时会卡住主循环如果同时有业务逻辑在跑需要注意。ESP-IDFnvs_flash和esp_http_server是原生组件性能更好支持异步。但代码量大配置项多对新手不友好。考虑到这篇文章面向的是想快速给项目加功能的开发者我用 Arduino 框架来演示核心逻辑在 ESP-IDF 下是相通的只是 API 名字不同。后面会给出关键 API 的对照。3. 核心细节解析与实操要点3.1 NVS 的命名空间与键值设计NVS 的数据组织是命名空间 → 键 → 值三层结构。命名空间相当于一个文件夹键是文件名值是内容。不同命名空间之间的键互不干扰。对于 WiFi 配置我建议这样设计命名空间键名类型说明wifi_cfgssid字符串目标 WiFi 名称wifi_cfgpass字符串目标 WiFi 密码wifi_cfgvalid整数配置是否有效0/1wifi_cfgmode整数0STA1AP为什么要加一个valid标志因为 NVS 刚擦除时是空的nvs_get_str会返回ESP_ERR_NVS_NOT_FOUND。你可以靠这个错误码判断但显式存一个标志位更清晰也方便做配置版本升级——比如以后加了新字段可以用版本号判断是否需要迁移。注意NVS 的键名最长 15 个字符命名空间最长 15 个字符。超了会报错。别用太长的名字。3.2 Preferences 库的读写封装Arduino 环境下用Preferences库操作 NVS比原生 API 简洁很多。核心代码#include Preferences.h Preferences prefs; // 写入配置 void saveWifiConfig(const String ssid, const String pass) { prefs.begin(wifi_cfg, false); // false 读写模式 prefs.putString(ssid, ssid); prefs.putString(pass, pass); prefs.putInt(valid, 1); prefs.end(); } // 读取配置 bool loadWifiConfig(String ssid, String pass) { prefs.begin(wifi_cfg, true); // true 只读模式 if (prefs.getInt(valid, 0) ! 1) { prefs.end(); return false; } ssid prefs.getString(ssid, ); pass prefs.getString(pass, ); prefs.end(); return ssid.length() 0; }这里有几个细节值得说prefs.begin()的第二个参数是只读标志。读的时候用true性能更好也不会意外写入。写的时候用false。每次操作完必须prefs.end()否则命名空间一直打开着下次begin会失败。getString的第二个参数是默认值当键不存在时返回这个值。这个设计很贴心省去了判断错误码的麻烦。实操心得Preferences对象不要做成全局的然后一直begin不end。我见过有人这么写结果第二次begin返回 false数据写不进去排查了半天。每次操作独立begin/end是最稳的。3.3 HTTP 服务端点的设计配置页面需要几个端点GET /返回配置页面的 HTMLGET /scan返回周围 WiFi 列表JSON 格式POST /save接收表单提交写入 NVS返回结果POST /reset清除配置重启进入 AP 模式用WebServer库实现#include WebServer.h WebServer server(80); void setupRoutes() { server.on(/, HTTP_GET, handleRoot); server.on(/scan, HTTP_GET, handleScan); server.on(/save, HTTP_POST, handleSave); server.on(/reset, HTTP_POST, handleReset); server.begin(); }handleRoot返回一段 HTML 字符串里面包含表单。表单的action指向/savemethod是POST。handleScan调用WiFi.scanNetworks()把结果拼成 JSON 返回。注意扫描是阻塞操作大概需要 2-3 秒这期间 HTTP 请求会等着浏览器可能超时。解决办法是扫描前先返回一个扫描中的响应或者用异步扫描 API。简单起见可以在页面加载时用 JavaScript 异步请求/scan这样不阻塞页面渲染。handleSave从server.arg(ssid)和server.arg(pass)取表单数据调用saveWifiConfig然后返回一个成功页面延迟几秒后重启。3.4 AP 模式与 STA 模式的切换逻辑启动流程void setup() { Serial.begin(115200); String ssid, pass; if (loadWifiConfig(ssid, pass)) { // 有有效配置尝试 STA 模式 WiFi.mode(WIFI_STA); WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long start millis(); while (WiFi.status() ! WL_CONNECTED millis() - start 10000) { delay(500); } if (WiFi.status() WL_CONNECTED) { // 连接成功正常运行 startNormalMode(); return; } // 连接失败降级到 AP 模式 } // 无配置或连接失败进入 AP 模式 startConfigMode(); }这里有个关键点连接超时要设置。如果不设超时WiFi 连不上时WiFi.begin会一直阻塞取决于底层实现设备就卡死了。设 10 秒超时超时后进入 AP 模式用户还能救回来。startConfigMode里void startConfigMode() { WiFi.mode(WIFI_AP); WiFi.softAP(ESP32-Config, 12345678); setupRoutes(); // 进入循环处理 HTTP 请求 }AP 的密码建议设一个简单的或者干脆不设softAP第二参数传NULL方便连接。但要注意不设密码的话任何人都能连上改配置如果设备部署在公共场所还是设一个。注意AP 模式和 STA 模式不能同时用同一个射频。ESP32 支持 APSTA 共存但同时工作时吞吐量会下降而且信道要一致。如果只是配置用建议纯 AP 模式配置完重启切 STA。4. 实操过程与核心环节实现4.1 完整代码框架把上面的片段拼起来一个最小可用的实现大概长这样#include WiFi.h #include WebServer.h #include Preferences.h Preferences prefs; WebServer server(80); const char* AP_SSID ESP32-Config; const char* AP_PASS 12345678; String cfgSsid, cfgPass; bool loadWifiConfig() { prefs.begin(wifi_cfg, true); bool valid prefs.getInt(valid, 0) 1; if (valid) { cfgSsid prefs.getString(ssid, ); cfgPass prefs.getString(pass, ); } prefs.end(); return valid cfgSsid.length() 0; } void saveWifiConfig(const String s, const String p) { prefs.begin(wifi_cfg, false); prefs.putString(ssid, s); prefs.putString(pass, p); prefs.putInt(valid, 1); prefs.end(); } void clearWifiConfig() { prefs.begin(wifi_cfg, false); prefs.clear(); prefs.end(); } const char CONFIG_PAGE[] PROGMEM RHTML( !DOCTYPE html html head meta charsetutf-8 meta nameviewport contentwidthdevice-width,initial-scale1 titleESP32 配置/title style body{font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px} input,button{width:100%;padding:10px;margin:8px 0;box-sizing:border-box;font-size:16px} button{background:#007bff;color:#fff;border:none;border-radius:4px} /style /head body h2WiFi 配置/h2 form action/save methodpost input namessid placeholderWiFi 名称 required input namepass typepassword placeholderWiFi 密码 button typesubmit保存并重启/button /form /body /html )HTML; void handleRoot() { server.send(200, text/html, CONFIG_PAGE); } void handleSave() { String s server.arg(ssid); String p server.arg(pass); if (s.length() 0) { server.send(400, text/plain, SSID 不能为空); return; } saveWifiConfig(s, p); server.send(200, text/html, meta charsetutf-8h3保存成功设备将在 3 秒后重启/h3); delay(3000); ESP.restart(); } void startConfigMode() { WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID, AP_PASS); server.on(/, HTTP_GET, handleRoot); server.on(/save, HTTP_POST, handleSave); server.begin(); Serial.print(AP IP: ); Serial.println(WiFi.softAPIP()); } void startNormalMode() { Serial.print(Connected, IP: ); Serial.println(WiFi.localIP()); // 这里放你的业务逻辑 } void setup() { Serial.begin(115200); delay(100); if (loadWifiConfig()) { WiFi.mode(WIFI_STA); WiFi.begin(cfgSsid.c_str(), cfgPass.c_str()); unsigned long t millis(); while (WiFi.status() ! WL_CONNECTED millis() - t 10000) { delay(500); Serial.print(.); } if (WiFi.status() WL_CONNECTED) { startNormalMode(); return; } Serial.println(\n连接失败进入配置模式); } startConfigMode(); } void loop() { server.handleClient(); // 业务逻辑 }这段代码烧进去首次启动会开热点ESP32-Config密码12345678。手机连上后浏览器访问192.168.4.1填 WiFi 信息保存设备重启后连上目标 WiFi。4.2 加入 WiFi 扫描功能上面的页面需要手动输入 SSID容易打错。加一个扫描功能列出周围 WiFi点击自动填充。在 HTML 里加一个按钮和列表button typebutton onclickscan()扫描 WiFi/button div idlist/div script function scan(){ document.getElementById(list).innerHTML扫描中...; fetch(/scan).then(rr.json()).then(d{ let h; d.forEach(n{ hdiv onclickdocument.querySelector(\[namessid]\).value\n.ssid\n.ssid (n.rssidBm)/div; }); document.getElementById(list).innerHTMLh||未发现网络; }); } /script服务端/scanvoid handleScan() { 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 ]; WiFi.scanDelete(); server.send(200, application/json, json); }实操心得WiFi.scanNetworks()是阻塞的扫描期间整个程序卡住。如果设备同时有别的任务比如读传感器扫描会导致任务延迟。解决办法是用WiFi.scanNetworks(true)开启异步扫描然后轮询WiFi.scanComplete()。不过配置模式下一般没别的任务同步扫描也能接受。4.3 运行模式下的重新配置入口设备正常运行后如果想改配置不可能每次都拆机。可以在运行模式下也开一个 HTTP 服务提供一个/config端点访问后触发重启进入 AP 模式。void handleEnterConfig() { server.send(200, text/plain, 即将进入配置模式); delay(1000); clearWifiConfig(); // 清除配置下次启动进 AP ESP.restart(); }在startNormalMode里注册这个路由server.on(/config, HTTP_GET, handleEnterConfig); server.begin();这样设备正常运行时浏览器访问http://设备IP/config就能触发重新配置。前提是你知道设备 IP可以通过路由器后台查或者在设备上显示如果有屏幕。另一种触发方式是物理按键。长按 GPIO0很多开发板上的 BOOT 键5 秒清除配置并重启void checkConfigButton() { if (digitalRead(0) LOW) { unsigned long t millis(); while (digitalRead(0) LOW) { if (millis() - t 5000) { clearWifiConfig(); ESP.restart(); } delay(50); } } }在loop里调用这个函数。这样即使设备连不上、IP 也不知道按一下按键就能救回来。4.4 ESP-IDF 下的等价实现如果你用 ESP-IDF 而不是 Arduino核心逻辑一样API 换一下功能ArduinoESP-IDF初始化 NVSprefs.begin()nvs_flash_init()写字符串prefs.putString()nvs_set_str()读字符串prefs.getString()nvs_get_str()提交更改自动nvs_commit()HTTP 服务WebServeresp_http_server启动 APWiFi.softAP()esp_wifi_set_mode(WIFI_MODE_AP)ESP-IDF 下nvs_set_str之后必须调nvs_commit否则数据可能还在缓存里没落盘。Arduino 的Preferences在end()时会自动 commit所以不用手动调。HTTP 服务端部分esp_http_server的注册方式是httpd_uri_t save_uri { .uri /save, .method HTTP_POST, .handler save_handler, .user_ctx NULL }; httpd_register_uri_handler(server, save_uri);handler 里用httpd_req_recv读 POST 数据解析表单。比 Arduino 的server.arg()麻烦一些但更灵活。5. 常见问题与排查技巧实录5.1 配置保存后连不上 WiFi这是最常见的问题。排查顺序第一检查 SSID 和密码是否真的写进去了。在loadWifiConfig里加串口打印把读出来的值打出来看。有时候是表单提交时字段名对不上server.arg(ssid)取到空字符串。第二检查 WiFi 频段。ESP32 只支持 2.4GHz不支持 5GHz。如果路由器是双频合一SSID 相同但设备连的是 5G 频段ESP32 可能连不上。解决办法是在路由器里把 2.4G 和 5G 的 SSID 分开。第三检查密码里的特殊字符。如果密码包含、、等 URL 特殊字符表单提交时可能被转义。WebServer库会自动 URL 解码但如果你自己拼 JSON 或者用其他方式传输要注意编码问题。第四检查 WiFi 认证方式。ESP32 支持 WPA/WPA2/WPA3但某些企业级认证WPA2-Enterprise需要额外配置简单的WiFi.begin(ssid, pass)连不上。5.2 AP 模式连不上或页面打不开AP 模式启动后手机搜不到热点或者连上了但访问192.168.4.1没反应。搜不到热点检查WiFi.softAP()是否成功。可以打印WiFi.softAPIP()如果返回0.0.0.0说明启动失败。常见原因是 WiFi 模式没设对必须先WiFi.mode(WIFI_AP)再softAP。连上了但打不开页面检查 HTTP 服务是否启动。server.begin()之后loop里必须调server.handleClient()否则请求不会被处理。另外检查手机是否自动切换到了移动数据有些手机在 WiFi 无外网时会自动用蜂窝网络导致访问不到局域网 IP。解决办法是关闭移动数据或者在手机 WiFi 设置里关掉自动切换。IP 地址不对ESP32 的 AP 默认 IP 是192.168.4.1但如果之前设置过可能变了。用WiFi.softAPIP()打印确认。5.3 NVS 写入失败或数据丢失prefs.putString()返回 0 表示成功非 0 表示失败。失败常见原因命名空间没打开prefs.begin()返回 false 时后续操作都会失败。检查是否在begin之前就调用了put。NVS 分区满了NVS 默认分区大小有限通常 20KB 左右存太多数据会满。WiFi 配置这种小数据不会满但如果你还存了别的东西要注意。可以用nvs_get_stats查看使用情况。Flash 操作冲突如果同时有别的任务在写 Flash比如 OTA 升级、文件系统写入NVS 操作可能失败。解决办法是加互斥锁或者错开操作时间。避坑技巧NVS 的写入有次数限制虽然 ESP32 的 Flash 有磨损均衡但频繁写同一个键仍然会加速老化。WiFi 配置这种低频写入没问题但不要把它当日志用每秒写一次。5.4 常见问题速查表现象可能原因排查方法解决保存后连不上SSID/密码错误串口打印读出的值检查表单字段名保存后连不上5G 频段路由器查看分开 2.4G/5G SSIDAP 搜不到模式未设置打印 softAPIP先 mode(WIFI_AP)页面打不开handleClient 未调用检查 loop加 server.handleClient()页面打不开手机用蜂窝网关闭移动数据手动切 WiFiNVS 写入失败命名空间未打开检查 begin 返回值确保 begin 成功数据丢失未 commitESP-IDF 下检查调 nvs_commit扫描卡顿同步扫描观察响应时间用异步扫描5.5 安全性考虑这个方案有个明显的安全弱点AP 模式下的配置页面没有认证。任何人连上热点都能改配置。如果设备部署在公共场所这是个风险。几个加固思路给 AP 设密码WiFi.softAP(ssid, password)密码至少 8 位。这样只有知道密码的人才能连上。配置页面加简单认证HTTP Basic Auth或者表单里加一个 PIN 码字段服务端校验。PIN 码可以硬编码在固件里或者首次启动时随机生成并显示在串口。配置完成后关闭 AP保存配置后立即重启AP 只存在几秒钟减少暴露窗口。运行模式下的/config端点加认证不要裸奔至少加个 token 校验。实操心得我在一个户外项目里用过这个方案AP 密码设了但配置页面还是被人扫到过。后来加了一个逻辑AP 模式只维持 5 分钟超时自动重启回 STA 模式。这样即使有人连上窗口期也很短。6. 进阶扩展与实战建议6.1 配置页面做成单页应用上面的 HTML 是内嵌在固件里的字符串简单但不好维护。如果配置项多了比如还要配 MQTT 服务器、传感器阈值可以做成一个单页应用用fetch调 API页面资源存在 SPIFFS/LittleFS 里。这样固件里只放 API页面用 HTML/CSS/JS 写改页面不用重新编译固件直接上传文件系统就行。开发效率高很多。6.2 配置版本迁移如果以后配置结构变了比如加了新字段旧设备升级固件后读不到新字段会出问题。解决办法是在 NVS 里存一个版本号int cfgVersion prefs.getInt(ver, 0); if (cfgVersion CURRENT_VERSION) { // 执行迁移逻辑 prefs.putInt(ver, CURRENT_VERSION); }迁移逻辑可以是给新字段设默认值或者从旧字段转换。这样固件升级不会导致配置丢失。6.3 多套配置切换有些场景需要在多个 WiFi 之间切换比如设备在办公室和家里都用。可以存多套配置用数组或者多个命名空间。启动时依次尝试连上哪个用哪个。for (int i 0; i MAX_PROFILES; i) { String ns wifi_ String(i); prefs.begin(ns.c_str(), true); String s prefs.getString(ssid, ); String p prefs.getString(pass, ); prefs.end(); if (s.length() 0) continue; WiFi.begin(s.c_str(), p.c_str()); // 等待连接... }这个方案在移动设备上很实用比如车载 ESP32 或者便携传感器。6.4 与 OTA 升级配合既然已经能通过浏览器改配置了顺便把 OTA 升级也加上。ESP32 的ArduinoOTA库或者Update库支持通过 HTTP 上传固件。配置页面加一个文件上传表单选择.bin文件上传后自动升级重启。这样设备部署后完全不需要物理接触配置和固件都能远程更新。对于批量部署的项目这是刚需。注意OTA 升级有风险升级失败可能导致设备变砖。建议保留一个恢复模式——如果启动时检测到连续多次启动失败自动进入 AP 模式等待救援。ESP32 的 RTC 内存可以在重启间保持数据可以用来记录启动次数。6.5 实际部署中的经验我在几个项目里用过这套方案总结几条经验AP 的 SSID 加上设备标识。如果周围有多台同型号设备都用ESP32-Config会分不清。可以在 SSID 里加 MAC 地址后几位比如ESP32-Config-A1B2。配置页面适配手机。大部分时候是用手机配置的页面要做响应式输入框要大按钮要好点。上面示例里的 CSS 已经做了基本适配。保存后给明确反馈。用户点了保存页面要显示保存成功设备重启中然后倒计时。不要直接白屏或者无响应用户会以为坏了。串口日志保留。配置过程中的关键步骤都打日志出问题时能通过串口看到。量产时可以关掉但开发阶段一定要有。测试不同路由器。有些路由器对 ESP32 兼容性不好比如某些老款或者企业级设备。部署前在目标环境测试一下避免现场翻车。这套方案的核心价值在于把配置从固件里解耦出来。固件负责逻辑NVS 负责数据浏览器负责交互。三者独立各司其职。一旦你习惯了这种模式以后做任何 ESP32 项目都会默认加上这个能力——毕竟谁也不想为了改个密码去拆机刷固件。