ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ESP32 WiFi配置不再硬编码:NVS存储与Web配置页面实战

ESP32 WiFi配置不再硬编码:NVS存储与Web配置页面实战 1. 为什么改个 WiFi 密码要动固件搞过 ESP32 产品落地的朋友大概都遇到过这个场景设备已经装到客户现场了客户说WiFi 密码换了你帮我改一下。如果你在代码里把 SSID 和密码写成了const char*硬编码那恭喜你只能带着笔记本和烧录器跑一趟现场重新编译、重新烧录。更麻烦的是如果设备装在吊顶里、配电箱里、或者某个够不着的地方这一趟的成本可能比设备本身还贵。这个问题的本质是配置数据和程序代码没有分离。硬编码的 WiFi 凭据属于运行时配置它天然应该可变、可持久化、可远程修改而固件属于程序逻辑它应该是稳定的、不轻易动的。把两者混在一起就等于每次改配置都要重新发布一版程序这在工程上是典型的反模式。ESP32 的 NVSNon-Volatile Storage非易失性存储就是为解决这类问题而生的。它是乐鑫在 ESP-IDF 里提供的一套键值存储系统底层跑在 SPI Flash 的某个分区上支持字符串、整数、二进制 blob 等多种类型掉电不丢。WiFi 库本身其实已经在用 NVS 存凭据了——你用WiFi.begin(ssid, pass)连过一次之后ESP32 会把凭据写进 NVS下次上电自动重连。问题在于普通用户没有方便的手段去改这个 NVS 里的值。标题里提到的浏览器工具思路就是在 ESP32 上跑一个轻量 HTTP 服务暴露一个网页界面用户用手机或电脑浏览器打开填新的 SSID 和密码提交后由固件把新值写进 NVS然后重启生效。整个过程不需要数据线、不需要编译环境、不需要懂代码。这篇文章就把这套方案的完整实现拆开讲清楚包括 NVS 的读写原理、Web 服务的搭建、表单处理、安全边界以及我在实际部署中踩过的那些坑。适合阅读的人群正在做 ESP32 联网产品、需要现场改配置的开发者想理解 NVS 机制但一直没搞明白的初学者以及手上有几台自己 DIY 的 ESP32 设备、懒得每次插线改 WiFi 的折腾党。2. NVS 到底存了什么怎么读怎么写2.1 NVS 的物理位置与命名空间机制NVS 不是一个抽象概念它在 Flash 上有实实在在的分区。打开 ESP-IDF 的partitions.csv你会看到类似这样一行nvs, data, nvs, 0x9000, 0x6000,这表示从 Flash 偏移0x9000开始划出0x600024KB的空间给 NVS 用。这个大小是可以调的如果你要存的东西多比如多组 WiFi 配置、设备证书可以加大到0x10000甚至更多。分区表改完之后记得idf.py partition-table重新生成否则烧录的还是旧布局。NVS 内部用命名空间namespace来隔离不同的键值集合。你可以把它理解成数据库里的表或者文件系统里的文件夹。WiFi 库默认用的命名空间是nvs.net80211里面存的键名是sta.ssid、sta.pswd这类。你自己写代码时可以另起一个命名空间比如wifi_cfg避免和系统库打架。nvs_handle_t handle; esp_err_t err nvs_open(wifi_cfg, NVS_READWRITE, handle); if (err ! ESP_OK) { ESP_LOGE(TAG, nvs_open failed: %s, esp_err_to_name(err)); return; }这里有个细节值得说nvs_open的第三个参数是打开模式NVS_READWRITE表示可读可写NVS_READONLY表示只读。如果你只是读配置用只读模式更安全能防止误写。另外nvs_open返回的 handle 用完必须nvs_close否则会占用 NVS 的内部资源开多了会失败。2.2 写入字符串与提交事务写一个字符串键值标准流程是三步nvs_set_str、nvs_commit、nvs_close。err nvs_set_str(handle, ssid, new_ssid); if (err ! ESP_OK) { /* 处理错误 */ } err nvs_set_str(handle, password, new_password); if (err ! ESP_OK) { /* 处理错误 */ } err nvs_commit(handle); if (err ! ESP_OK) { /* 处理错误 */ } nvs_close(handle);nvs_commit这一步很多人会漏掉以为nvs_set_str写完就落盘了。实际上nvs_set_str只是把数据写进 NVS 的缓存页nvs_commit才真正触发 Flash 写入。如果你不 commit 就重启数据大概率丢失。我在早期项目里就吃过这个亏——测试时改完密码立刻断电结果配置没保存排查了半天才发现是漏了 commit。注意nvs_commit是有 Flash 擦写寿命代价的。Flash 的擦写次数通常在 10 万次量级如果你在循环里频繁 commit会加速分区磨损。正确做法是攒一批修改一次性 commit。2.3 读取时的长度陷阱读字符串比写要小心因为你要先知道长度。常见写法是先用nvs_get_str传NULL拿到长度再分配缓冲区size_t len 0; err nvs_get_str(handle, ssid, NULL, len); if (err ESP_OK) { char *buf malloc(len); nvs_get_str(handle, ssid, buf, len); // 使用 buf free(buf); }这里len包含结尾的\0所以malloc(len)是够的。如果你图省事直接用一个固定大小的栈数组比如char buf[32]而实际存的字符串超过 31 字节nvs_get_str会返回ESP_ERR_NVS_INVALID_LENGTH不会溢出但你会读不到值。WiFi 密码最长 63 字节SSID 最长 32 字节所以缓冲区至少给 64 和 33 比较稳妥。2.4 为什么不用 Preferences 库Arduino 环境下有个Preferences库封装了 NVS用起来更简单Preferences prefs; prefs.begin(wifi_cfg, false); prefs.putString(ssid, new_ssid); prefs.putString(password, new_password); prefs.end();它确实省事但有两个限制一是它默认的命名空间长度限制是 15 字符超了会截断二是它对错误处理比较粗糙底层出错时你拿不到具体的esp_err_t。如果你做的是产品级固件建议还是用原生 NVS API可控性更强。DIY 项目用 Preferences 完全没问题。3. 把配置页面塞进 ESP32Web 服务怎么搭3.1 选 WebServer 还是 ESPAsyncWebServerArduino 环境下有两个主流选择内置的WebServer库和第三方的ESPAsyncWebServer。前者是同步阻塞的后者是异步的。同步WebServer的逻辑是主循环里调server.handleClient()有请求就处理处理期间其他事情都停下。对于改配置这种低频操作完全够用代码也简单。异步库的优势在于能同时处理多个连接、不阻塞主循环但引入的依赖多还要配AsyncTCP编译体积也大一些。我的建议是如果这个 Web 服务只是用来改配置、看状态用同步WebServer就够了别为了异步而异步。下面代码基于同步库。#include WebServer.h WebServer server(80); void setup() { // ... WiFi 连接等 server.on(/, handleRoot); server.on(/save, HTTP_POST, handleSave); server.begin(); } void loop() { server.handleClient(); // 其他任务 }3.2 表单页面为什么要放在 PROGMEM 里配置页面的 HTML 如果直接写成String拼接会占用宝贵的 RAM。ESP32 虽然 RAM 比单片机宽裕但一个几百字节的 HTML 字符串常驻内存也不划算。正确做法是放进 Flashconst char CONFIG_PAGE[] PROGMEM Rrawliteral( !DOCTYPE html html headmeta charsetutf-8titleWiFi 配置/title/head body h2修改 WiFi 配置/h2 form action/save methodpost labelSSID: input typetext namessid maxlength32/labelbr label密码: input typepassword namepassword maxlength63/labelbr button typesubmit保存并重启/button /form /body /html )rawliteral;PROGMEM是 AVR 时代留下的宏在 ESP32 上它实际被定义为空但配合Rrawliteral(...)原始字符串字面量编译器会把这个常量放到 Flash 的 rodata 段不占 RAM。Rrawliteral(...)的好处是里面的引号、反斜杠都不用转义写 HTML 特别舒服。3.3 处理 POST 表单与参数校验handleSave里要做几件事取参数、校验、写 NVS、返回结果、延时重启。void handleSave() { if (!server.hasArg(ssid) || !server.hasArg(password)) { server.send(400, text/plain, missing params); return; } String ssid server.arg(ssid); String pass server.arg(password); if (ssid.length() 0 || ssid.length() 32) { server.send(400, text/plain, invalid ssid length); return; } if (pass.length() 63) { server.send(400, text/plain, invalid password length); return; } if (saveToNVS(ssid.c_str(), pass.c_str())) { server.send(200, text/html, meta charsetutf-8h3保存成功设备将在 3 秒后重启/h3); delay(3000); ESP.restart(); } else { server.send(500, text/plain, save failed); } }校验这一步不能省。SSID 长度 0 到 32 字节密码 8 到 63 字节WPA2 规范超出范围直接拒绝。我见过有人不校验用户填了个 100 字符的密码写进 NVS 后连接一直失败还以为是硬件问题。提示server.arg()返回的是String对象在 ESP32 上频繁创建String会造成堆碎片。如果追求极致稳定可以用server.arg(ssid).c_str()直接取 C 字符串或者用server.argName()配合索引遍历。DIY 场景下String够用产品级建议避开。3.4 重启前为什么要 delayESP.restart()是立即重启如果前面server.send的数据还在 TCP 缓冲区没发出去浏览器就会看到连接被重置用户以为保存失败了。加一个delay(3000)给数据发送留时间同时页面上提示3 秒后重启体验更顺。这个 3 秒不是随便定的——局域网内一个 HTTP 响应通常几十毫秒就发完了3 秒是留足余量。4. 现场部署时那些没人告诉你的坑4.1 改完密码连不上设备变砖怎么办这是最要命的问题用户填错了 SSID 或密码保存重启后设备连不上网Web 配置页面自然也访问不了设备就成了砖。解决方案是加一个配置模式Config Mode的兜底逻辑。思路是设备启动后先尝试用 NVS 里的凭据连接如果 15 秒内没连上就自动切换成 AP 模式自己开一个热点比如 SSID 叫ESP32-Config用户连上这个热点后访问192.168.4.1就能重新配置。bool connectWithSaved() { Preferences prefs; prefs.begin(wifi_cfg, true); String ssid prefs.getString(ssid, ); String pass prefs.getString(password, ); prefs.end(); if (ssid.length() 0) return false; WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long start millis(); while (WiFi.status() ! WL_CONNECTED millis() - start 15000) { delay(500); } return WiFi.status() WL_CONNECTED; } void setup() { if (!connectWithSaved()) { startConfigAP(); // 开启 AP 模式 Web 服务 } else { startWebServer(); // 正常模式也开 Web 服务方便改配置 } }这个兜底逻辑是产品能不能交付的分水岭。没有它一次误操作就要返厂有了它用户自己就能救回来。4.2 AP 模式和 STA 模式能不能同时开可以。ESP32 支持 APSTA 共存也就是一边连着路由器一边自己开热点。这个特性在配置场景下很有用设备正常联网时你仍然可以通过它开的热点连上去改配置不用断网。WiFi.mode(WIFI_AP_STA); WiFi.softAP(ESP32-Config, 12345678); WiFi.begin(ssid, pass);但要注意AP 和 STA 共用同一个射频同时工作时吞吐会下降而且 AP 的 IP 段默认192.168.4.x和 STA 的 IP 段可能冲突。如果路由器也是192.168.4.x网段就会出问题。稳妥做法是改 AP 的 IPIPAddress apIP(10, 10, 10, 1); WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));4.3 NVS 分区满了会怎样NVS 不是无限写的。每个键值对占用的空间和键名长度、值长度有关24KB 的分区大概能存几百个键值对。如果你在代码里反复写不同的键名比如用时间戳当键名很快就会写满nvs_set_str返回ESP_ERR_NVS_NOT_ENOUGH_SPACE。排查方法是用nvs_get_stats看剩余空间nvs_stats_t stats; nvs_get_stats(NULL, stats); ESP_LOGI(TAG, used%d free%d total%d, stats.used_entries, stats.free_entries, stats.total_entries);如果确实需要频繁写考虑用 blob 类型存一个结构体比多个字符串键值省空间。另外NVS 有磨损均衡机制但前提是你别老写同一个键。4.4 浏览器缓存导致页面不更新改完固件重新烧录后浏览器可能还在用缓存的旧页面导致你看到的功能和实际不符。解决办法是在 HTTP 响应头里加Cache-Control: no-cacheserver.sendHeader(Cache-Control, no-cache, no-store, must-revalidate); server.send(200, text/html, CONFIG_PAGE);这个坑我在调试时踩过——明明改了表单字段浏览器提交的还是旧字段名查了半天以为是固件没烧进去。5. 安全边界这个工具能开多大口子5.1 无认证的配置页面等于把家门钥匙挂门上如果你的 ESP32 和一堆设备在同一个局域网里任何人访问http://设备IP/都能改 WiFi 密码这显然不安全。最低限度的防护是加一个简单的认证。HTTP Basic Auth 是最省事的方案bool checkAuth() { if (!server.authenticate(admin, your_password)) { server.requestAuthentication(); return false; } return true; } void handleRoot() { if (!checkAuth()) return; server.send(200, text/html, CONFIG_PAGE); }server.authenticate会检查请求头里的Authorization字段不匹配就返回 401 并弹出浏览器登录框。密码可以存在 NVS 里首次启动时用默认值用户登录后可以改。注意Basic Auth 的密码是 Base64 编码传输的不是加密。在局域网内够用但别在公网上裸奔。如果设备要暴露到公网必须上 HTTPS而 HTTPS 在 ESP32 上会吃掉大量 RAM 和 Flash需要仔细权衡。5.2 表单里的 SSID 要不要转义用户输入的 SSID 可能包含特殊字符比如、、。如果你把这些值直接回显到 HTML 页面里比如当前 SSIDxxx就会造成 XSS。虽然 ESP32 的配置页面通常只有自己人访问但养成转义习惯没坏处。String htmlEscape(const String s) { String out; for (char c : s) { if (c ) out lt;; else if (c ) out gt;; else if (c ) out amp;; else if (c ) out quot;; else out c; } return out; }5.3 配置接口要不要限流如果设备在公网恶意请求可能把 NVS 写爆。加一个简单的频率限制记录上次保存的时间戳两次保存间隔小于 5 秒就拒绝。unsigned long lastSave 0; void handleSave() { if (millis() - lastSave 5000) { server.send(429, text/plain, too frequent); return; } lastSave millis(); // ... 正常保存逻辑 }这个措施在局域网里意义不大但作为产品级固件的习惯加上没坏处。6. 从能用到好用几个提升体验的细节6.1 配置页面显示当前连接状态用户打开配置页面时最想知道的是现在连的是哪个 WiFi、信号怎么样。在页面顶部显示这些信息能减少很多困惑。String statusHtml() { String s p当前 SSID: htmlEscape(WiFi.SSID()) /p; s pIP: WiFi.localIP().toString() /p; s p信号强度: String(WiFi.RSSI()) dBm/p; return s; }WiFi.RSSI()返回的是负值-30 到 -50 是强信号-70 以下就比较弱了。用户看到 -85 就知道该换个位置放设备。6.2 保存后不重启行不行技术上可以。改完 NVS 后直接调WiFi.disconnect()再WiFi.begin(新凭据)不用重启。但这样有个风险如果新凭据连不上设备会卡在重连循环里Web 服务可能响应不了。重启的好处是回到一个干净的初始状态兜底逻辑连不上就开 AP能正常触发。所以我的建议是保存后重启让兜底逻辑接管。6.3 多组 WiFi 配置的轮询连接有些设备需要在多个地点使用比如办公室和家里。可以存多组 SSID/密码启动时依次尝试。// NVS 里存 ssid0/pass0, ssid1/pass1, ... for (int i 0; i MAX_PROFILES; i) { char key[16]; snprintf(key, sizeof(key), ssid%d, i); // 读取并尝试连接 }这个方案会增加 NVS 占用和启动时间但对移动设备很实用。实现时注意给每组配置一个超时别在一组上死等。6.4 用 mDNS 代替记 IP每次都要查设备 IP 很烦。ESP32 支持 mDNS配置后可以用http://esp32.local/访问不用记 IP。#include ESPmDNS.h MDNS.begin(esp32); MDNS.addService(http, tcp, 80);Windows 10 以上、macOS、Linux 都原生支持 mDNS。手机端 Android 支持不一iOS 支持较好。如果目标用户是手机mDNS 可能不太靠谱还是显示 IP 更稳。7. 我在实际项目里踩过的三个坑第一个坑是NVS 命名空间冲突。我一开始图省事直接用了nvs.net80211这个系统命名空间去写自己的键结果和 WiFi 库打架连接行为变得诡异。后来改成独立的wifi_cfg命名空间问题消失。教训是永远别碰系统库的命名空间。第二个坑是AP 模式下的 DNS 劫持。用户连上ESP32-Config热点后浏览器可能不会自动跳转到配置页面因为手机默认会去探测网络连通性。解决办法是跑一个极简 DNS 服务把所有域名解析到192.168.4.1这样用户随便输个网址都能跳到配置页。这个功能叫 Captive PortalESP32 上有现成库可以用但会增加代码复杂度。如果用户群是技术人员直接告诉他们访问192.168.4.1就够了不必上 Captive Portal。第三个坑是Flash 写入期间的看门狗复位。NVS commit 是同步操作如果 Flash 正在擦除可能耗时几百毫秒。如果这段时间主循环被阻塞任务看门狗Task WDT可能触发复位。解决办法是在写 NVS 前调vTaskDelay(1)让出 CPU或者把写操作放到独立任务里。Arduino 环境下默认看门狗比较宽松一般不会触发但如果你改过看门狗配置就要注意。这套方案我从最简单的硬编码改 NVS一路迭代到带认证、带兜底、带状态显示的版本前后改了七八版。核心其实就一句话把配置从代码里拿出来放进 NVS再用一个 Web 界面去操作它。剩下的都是围绕这句话做的工程打磨。如果你手上正好有 ESP32 设备被 WiFi 配置问题困扰照着这个思路搭一遍基本能解决 90% 的现场改配置需求。
返回列表