☰
ESP32 WiFi配置新方案:用浏览器改NVS,告别固件重烧
2026/10/7 15:46:38 网站建设 项目流程

1. 为什么改个 WiFi 密码要动固件

搞过 ESP32 联网项目的朋友大概率都经历过这个场景:设备已经装到现场了,外壳封好、螺丝拧死,结果甲方一个电话打过来——“WiFi 密码换了,你帮忙改一下”。这时候你脑子里第一反应是什么?打开 Arduino IDE 或者 ESP-IDF,找到代码里那两行ssid和password,改完重新编译,然后抱着笔记本跑到现场,拆壳、接线、按住 BOOT 键、插 USB、烧录、等重启。一套流程下来,半小时没了,如果设备装在吊顶或者配电箱里,那酸爽程度还得翻倍。

这个问题的根源在于:很多人把 WiFi 凭据硬编码在了固件里。代码里写死const char* ssid = "MyHome";这种方式,编译期就确定了,运行时根本改不了。固件一旦烧进去,凭据就跟着固件一起被“焊死”在 Flash 的代码段里。想改?只能重新编译重新烧。

但 ESP32 本身其实提供了一个非常合适的存储区域——NVS(Non-Volatile Storage,非易失性存储)。它是 ESP-IDF 里专门用来存键值对的一块 Flash 分区,掉电不丢,读写方便,天生就是放 WiFi 凭据、设备配置、校准参数这类东西的地方。把 SSID 和密码存进 NVS,固件启动时从 NVS 读取,那么改密码就变成了“改一个键值对”这么简单的事,完全不需要碰固件。

那问题来了:设备在现场,怎么改 NVS 里的值?总不能每次都写个串口命令工具吧。答案就是标题里说的——用浏览器直接改。ESP32 可以跑一个轻量 HTTP 服务器,手机或电脑连上它开的热点,打开浏览器访问一个页面,填个表单提交,NVS 里的键值就更新了。整个过程不需要装任何 App,不需要串口线,不需要重新烧录。

这篇文章就是把这个方案的完整实现思路拆开讲清楚:NVS 到底怎么用、HTTP 服务器怎么搭、表单怎么和 NVS 键值对接、以及实际部署时会踩到哪些坑。适合已经会用 Arduino 或 ESP-IDF 点灯、连 WiFi,但还没系统搞过 NVS 和内置 Web 配置页的开发者。看完你就能把这套东西移植到自己的项目里,以后改配置再也不用拆壳了。

2. NVS 到底是个什么东西,为什么它适合存 WiFi 凭据

2.1 NVS 和普通 Flash 读写、SPIFFS 的区别

ESP32 的 Flash 上可以划分出好几个分区,常见的有 app 分区(存固件代码)、NVS 分区、SPIFFS/LittleFS 分区(存文件)、OTA 分区等。很多人第一次接触 NVS 会把它和 SPIFFS 搞混,觉得都是“存东西的地方”。但两者的设计目标完全不同。

SPIFFS 或者 LittleFS 是文件系统,适合存网页文件、日志、图片这类“文件形态”的数据,你需要用fopen、fwrite这种文件操作接口。而 NVS 是键值数据库,你给它一个字符串 key,它返回对应的 value,底层帮你处理了 Flash 的擦写均衡、掉电保护、数据校验。它不适合存大块数据(单个 value 建议不超过 4000 字节),但存 SSID、密码、设备 ID、阈值参数这种小配置简直完美。

和直接操作 Flash 裸地址相比,NVS 的优势更明显。裸读写 Flash 你得自己算地址、自己处理擦除块(Flash 写之前必须先擦除整个扇区)、自己做磨损均衡,稍不注意就把数据写坏了。NVS 把这些脏活全包了,你只管nvs_set_str和nvs_get_str。

2.2 NVS 的命名空间机制

NVS 里有一个很重要的概念叫命名空间(namespace)。你可以把它理解成数据库里的“表”。不同的功能模块用不同的命名空间,互不干扰。比如 WiFi 配置放wifi_cfg,设备参数放dev_cfg,MQTT 配置放mqtt_cfg。

这样做的好处是:清除某一类配置时,直接nvs_erase_all对应的命名空间就行,不会误伤其他数据。命名空间名字最长 15 个字符,key 最长 15 个字符,这个限制在实际命名时要注意,别起太长的名字。

一个典型的 WiFi 配置命名空间里可能存这些键:

Key类型说明
ssidstringWiFi 名称
passstringWiFi 密码
modeu80=STA 1=AP 2=APSTA
dhcpu8是否启用 DHCP
ipstring静态 IP(可选)

2.3 为什么不用 Preferences 库而用原生 NVS

Arduino 环境下有个Preferences库,它其实是对 NVS 的封装,用起来更简单,prefs.putString("ssid", "xxx")一行搞定。那为什么很多项目还是直接用原生 NVS API?

原因在于灵活性和控制粒度。Preferences库每次begin都要指定一个命名空间,而且它的错误处理比较粗糙。原生 NVS 可以精确控制打开模式(只读、读写)、可以遍历命名空间里的所有键、可以处理各种错误码。如果你的配置项比较多,或者需要做一个“配置管理”模块,原生 API 更合适。

不过对于只想快速存个 WiFi 密码的场景,Preferences完全够用。我的建议是:小项目用 Preferences,配置复杂或者需要动态遍历键值的时候用原生 NVS。本文后面的代码示例会以原生 NVS 为主,因为要展示完整的错误处理逻辑。

2.4 NVS 分区的容量规划

默认情况下,ESP32 的 Arduino 分区表里 NVS 分区通常是 20KB 左右(0x5000)。这个容量存几百个键值对绰绰有余。但如果你用的是自定义分区表,或者要存的东西比较多,记得给 NVS 留够空间。

一个键值对大概占用多少空间?NVS 底层是按 32 字节的条目来管理的,一个字符串 key 加一个字符串 value,算上头部开销,大概 50 到 100 字节。20KB 能存几百条,日常配置完全够。但如果你打算往 NVS 里塞 JSON 大字符串,那就要小心了,单个 value 超过 4000 字节会失败。

提示:NVS 分区一旦写满,nvs_set_str会返回ESP_ERR_NVS_NOT_ENOUGH_SPACE。做配置写入时一定要检查返回值,别写完就不管了。

3. 把 HTTP 服务器塞进 ESP32:Web 配置页的实现路径

3.1 两种技术路线:WebServer 库 vs ESP-IDF httpd

在 ESP32 上跑 HTTP 服务器,主要有两条路。一条是 Arduino 环境下的WebServer库(或者ESPAsyncWebServer),另一条是 ESP-IDF 原生的esp_http_server组件。

WebServer库的优点是上手快,几行代码就能注册路由,适合快速原型。缺点是它是同步阻塞的,处理请求时会卡住主循环,如果同时还要跑其他任务(比如传感器采集、MQTT 通信),体验会比较差。ESPAsyncWebServer是异步版本,基于 AsyncTCP,不阻塞主循环,但它的依赖关系稍微复杂一点,而且和某些库会有兼容性问题。

ESP-IDF 的esp_http_server是官方组件,性能最好,支持并发连接,配置项也最全。缺点是 API 相对底层,需要手动处理请求结构体、发送响应头这些。如果你用 ESP-IDF 开发,直接用这个;如果用 Arduino,WebServer或ESPAsyncWebServer更顺手。

本文的方案不依赖特定框架,核心逻辑是通用的:注册一个 GET 路由返回配置页面 HTML,注册一个 POST 路由接收表单数据并写入 NVS。你用哪个库都能实现。

3.2 配置页面的 HTML 该怎么设计

配置页面不需要多漂亮,但要满足几个硬性要求:手机浏览器能正常显示、表单字段清晰、提交后有反馈、能显示当前配置值。

一个最小可用的页面结构大概是这样的:

<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>设备配置</title> </head> <body> <h2>WiFi 配置</h2> <form action="/save" method="POST"> <label>WiFi 名称:</label> <input type="text" name="ssid" value="{{SSID}}"><br> <label>WiFi 密码:</label> <input type="text" name="pass" value="{{PASS}}"><br> <button type="submit">保存</button> </form> </body> </html>

注意viewport这个 meta 标签一定要加,不然手机上打开会显示成桌面版布局,字小得看不清。{{SSID}}和{{PASS}}是占位符,服务端返回页面时替换成 NVS 里读出来的当前值,这样用户打开页面就能看到现在配的是什么,改的时候只改需要变的部分。

3.3 表单提交后数据怎么流进 NVS

POST 请求进来后,处理流程分四步:

  1. 解析表单参数:从请求体里提取ssid和pass字段的值。WebServer库有hasArg和arg方法直接取,ESP-IDF 需要自己解析application/x-www-form-urlencoded格式的 body。
  2. 打开 NVS 命名空间:nvs_open("wifi_cfg", NVS_READWRITE, &handle)。
  3. 写入键值:nvs_set_str(handle, "ssid", ssid_value),密码同理。
  4. 提交并关闭:nvs_commit(handle)确保数据落盘,然后nvs_close(handle)。

这里有个容易忽略的点:nvs_commit不是必须每次都调用的。NVS 在nvs_close时也会自动提交。但如果你写完想立刻确认数据已经持久化,显式调一次nvs_commit更稳妥。另外,写入失败一定要处理,比如 Flash 满了、key 太长、value 太长,这些都会返回错误码。

3.4 保存后要不要重启设备

这是个设计决策。保存完 WiFi 配置后,有两种做法:

做法一:立即重启。调用ESP.restart(),设备重新启动后从 NVS 读取新配置去连接 WiFi。优点是逻辑简单,不用处理“运行时切换 WiFi”的复杂情况。缺点是如果用户填错了密码,设备重启后连不上,又得重新进配置页(如果配置页是通过 AP 模式提供的,那还好;如果是 STA 模式下的页面,连不上就进不去了)。

做法二:不重启,运行时重连。保存后调用WiFi.disconnect()然后WiFi.begin(new_ssid, new_pass),动态切换。优点是用户体验好,不用等重启。缺点是要处理好重连失败的情况,而且如果配置页本身是通过当前 WiFi 访问的,切换后连接会断,页面可能加载不完。

我的经验是:如果配置页是通过设备自建的 AP 热点访问的,用做法一重启最省心。因为 AP 模式不依赖外部 WiFi,重启后 AP 还在,用户还能连上来改。如果配置页是在 STA 模式下通过局域网 IP 访问的,那用做法二,并且要在切换前先把响应发完。

4. 从零实现:一个可落地的 NVS Web 配置方案

4.1 整体架构与启动流程

先把整个方案的运行逻辑理清楚。设备启动后:

  1. 初始化 NVS 分区。
  2. 从 NVS 读取 WiFi 配置。如果读到了,尝试以 STA 模式连接;如果没读到或者连接失败,切换到 AP 模式,自己开一个热点。
  3. 启动 HTTP 服务器,注册配置页面路由和保存路由。
  4. 在 AP 模式下,用户连上热点后访问192.168.4.1就能看到配置页。

这个流程的关键在于降级策略:有配置就连,连不上就开热点让人来配。这样设备永远不会“失联”,现场人员总能通过热点进去改配置。

4.2 NVS 读写封装:把重复代码抽出来

直接在主逻辑里写一堆nvs_open、nvs_get_str、nvs_close会很乱。建议封装两个函数:

#include <nvs.h> // 读取字符串配置,不存在时返回默认值 String nvs_read_string(const char* ns, const char* key, const char* def) { nvs_handle_t handle; if (nvs_open(ns, NVS_READONLY, &handle) != ESP_OK) return String(def); size_t len = 0; if (nvs_get_str(handle, key, NULL, &len) != ESP_OK) { nvs_close(handle); return String(def); } char* buf = (char*)malloc(len); esp_err_t err = nvs_get_str(handle, key, buf, &len); String result = (err == ESP_OK) ? String(buf) : String(def); free(buf); nvs_close(handle); return result; } // 写入字符串配置 bool nvs_write_string(const char* ns, const char* key, const String& value) { nvs_handle_t handle; if (nvs_open(ns, NVS_READWRITE, &handle) != ESP_OK) return false; esp_err_t err = nvs_set_str(handle, key, value.c_str()); if (err == ESP_OK) err = nvs_commit(handle); nvs_close(handle); return err == ESP_OK; }

注意nvs_get_str第一次调用时传NULL作为缓冲区,是为了获取字符串长度。这是 NVS API 的标准用法,很多人第一次用会困惑为什么调两次。第一次拿长度,第二次拿内容。

4.3 HTTP 路由注册与表单处理

以 ArduinoWebServer库为例,核心代码结构:

#include <WebServer.h> WebServer server(80); void handleRoot() { String ssid = nvs_read_string("wifi_cfg", "ssid", ""); String pass = nvs_read_string("wifi_cfg", "pass", ""); String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>"; html += "<meta name='viewport' content='width=device-width,initial-scale=1'>"; html += "</head><body><h2>WiFi 配置</h2><form action='/save' method='POST'>"; html += "名称:<input name='ssid' value='" + ssid + "'><br>"; html += "密码:<input name='pass' value='" + pass + "'><br>"; html += "<button type='submit'>保存</button></form></body></html>"; server.send(200, "text/html", html); } void handleSave() { if (!server.hasArg("ssid") || !server.hasArg("pass")) { server.send(400, "text/plain", "参数缺失"); return; } bool ok1 = nvs_write_string("wifi_cfg", "ssid", server.arg("ssid")); bool ok2 = nvs_write_string("wifi_cfg", "pass", server.arg("pass")); if (ok1 && ok2) { server.send(200, "text/html", "保存成功,设备将在3秒后重启"); delay(3000); ESP.restart(); } else { server.send(500, "text/plain", "保存失败,请检查 NVS 空间"); } } void setup() { // ... NVS 初始化、WiFi 连接逻辑 ... server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); } void loop() { server.handleClient(); }

这段代码里有个细节:handleSave里先发响应再delay再重启。顺序不能反,如果先重启,响应发不出去,浏览器会显示连接错误,用户以为没保存成功。

4.4 密码字段的安全处理

密码用type='text'明文显示其实不太合适,旁边有人看着就泄露了。改成type='password'又有个问题:用户打开页面时看不到当前密码,不知道现在配的是什么。

折中方案是:默认显示为密码框,但提供一个“显示密码”的复选框,勾选后切换成文本框。这需要一点 JavaScript:

<input type="password" name="pass" id="pass" value="{{PASS}}"> <input type="checkbox" onclick="document.getElementById('pass').type=this.checked?'text':'password'"> 显示密码

另外,如果设备是通过 AP 模式配置的,建议给配置页加个简单的密码保护,或者至少限制 AP 的连接时间,配置完就关闭 AP。不然附近的人连上热点就能改你的 WiFi 配置。

5. 实测中踩到的坑和排查思路

5.1 保存后读出来是乱码或旧值

这个问题的典型原因是没有调用nvs_commit或者nvs_close。NVS 的写入是分两步的:nvs_set_str把数据写到内存缓存,nvs_commit才真正刷到 Flash。如果写完不提交就断电,数据就丢了。

另一个可能的原因是读取时缓冲区大小不对。nvs_get_str需要你先告诉它缓冲区多大,如果传的长度小于实际字符串长度,会返回ESP_ERR_NVS_INVALID_LENGTH,但有些人忽略了这个错误码,直接用未初始化的缓冲区,结果就是乱码。

排查方法:在写入后立刻读一次,打印出来对比。如果写入后读是对的,重启后读是错的,那就是 commit 的问题;如果写入后读就是错的,那就是缓冲区或 key 的问题。

5.2 配置页在手机浏览器上排版错乱

最常见的原因是没加 viewport meta 标签。手机浏览器默认按 980px 宽度渲染页面,然后缩放显示,导致字特别小。加上<meta name="viewport" content="width=device-width, initial-scale=1.0">就能解决。

还有一个坑是中文字符编码。如果 HTML 里没声明<meta charset="UTF-8">,中文会显示成乱码。ESP32 返回的响应头里也要带上Content-Type: text/html; charset=utf-8,两边都要对。

5.3 AP 模式下手机连不上或频繁掉线

ESP32 的 AP 模式默认最多支持 4 个客户端,一般够用。但如果手机连上后频繁掉线,可能是AP 的 IP 段和手机之前连的网络冲突。ESP32 默认 AP IP 是192.168.4.1,如果手机之前连的某个网络也是这个网段,路由会混乱。

解决办法是在WiFi.softAPConfig里指定一个不常用的网段,比如10.10.10.1。另外,AP 的信道也可以手动指定,避免和周围 WiFi 拥挤的信道重叠。

5.4 表单提交后页面卡住不跳转

如果handleSave里先ESP.restart()再server.send,响应还没发出去设备就重启了,浏览器一直等不到响应,就会一直转圈直到超时。正确的顺序是:先server.send把响应发完,再delay一小段时间让 TCP 包发出去,最后才重启。

delay的时间不能太短,500ms 可能不够,建议 2000 到 3000ms。如果用的是异步服务器,还要确保响应发送完成后再重启,可以用回调或者标志位来控制。

5.5 NVS 写入失败但没报错

nvs_set_str返回ESP_OK不代表数据一定写进去了。如果 Flash 分区满了,或者 NVS 分区被其他数据占满了,写入会失败但有些人没检查返回值。

养成习惯:每次 NVS 操作都检查返回值。ESP_ERR_NVS_NOT_ENOUGH_SPACE表示空间不足,ESP_ERR_NVS_INVALID_LENGTH表示长度问题,ESP_ERR_NVS_NOT_FOUND表示 key 不存在。把这些错误码打印出来,排查起来快很多。

6. 把这套方案用得更顺的几个进阶思路

6.1 配置项多了怎么组织

如果设备配置项超过十个,一个页面堆所有字段会很乱。可以按功能分组,用多个页面或者标签页。NVS 这边用不同的命名空间隔离,比如wifi_cfg、mqtt_cfg、dev_cfg。每个页面只读写自己那个命名空间,逻辑清晰,也不容易误操作。

6.2 加一个恢复出厂设置的入口

配置页上加一个“恢复默认”按钮,点击后调用nvs_erase_all清空对应命名空间,然后重启。这样设备卖出去或者换环境时,一键清空所有配置,不用记着之前配了什么。

nvs_handle_t handle; nvs_open("wifi_cfg", NVS_READWRITE, &handle); nvs_erase_all(handle); nvs_commit(handle); nvs_close(handle);

注意nvs_erase_all只清空指定命名空间,不会影响其他命名空间的数据。如果想清空整个 NVS 分区,用nvs_flash_erase(),但这个要慎用,会把所有配置都清掉。

6.3 配置页的访问控制

AP 模式下,任何人连上热点都能访问配置页。如果设备部署在公共场所,这是个安全隐患。简单的做法是给配置页加一个访问密码,存在 NVS 里,页面提交时校验。或者更简单:AP 热点本身设个密码,只有知道热点密码的人才能连上来配置。

另一个思路是配置超时自动关闭 AP。设备进入 AP 模式后启动一个定时器,比如 5 分钟内没人访问配置页,就自动重启回到 STA 模式尝试连接。这样即使有人误触发了 AP 模式,也不会一直开着热点。

6.4 和 OTA 升级的配合

NVS 存配置和 OTA 升级是绝配。OTA 升级只替换 app 分区的固件,NVS 分区的数据不受影响。所以升级完固件后,WiFi 配置、设备参数都还在,设备能自动连上原来的网络。这也是为什么强烈建议把配置放 NVS 而不是硬编码——硬编码的配置在 OTA 后会被新固件里的值覆盖,而 NVS 里的配置是独立于固件的。

如果 OTA 升级后配置格式变了(比如新增了字段),可以在启动时检查 NVS 里有没有新字段,没有就写入默认值。这种“配置迁移”逻辑在固件迭代时很有用。

6.5 调试时怎么快速查看 NVS 内容

开发阶段想看看 NVS 里到底存了什么,有两个办法。一是写个串口命令,输入nvs_dump就遍历打印所有命名空间和键值。二是用 ESP-IDF 的nvs_flash工具,通过串口或者 JTAG 读取。Arduino 环境下没有现成的工具,自己写个遍历函数最方便:

void dump_nvs(const char* ns) { nvs_iterator_t it = NULL; esp_err_t res = nvs_entry_find("nvs", ns, NVS_TYPE_ANY, &it); while (res == ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, &info); Serial.printf("key: %s, type: %d\n", info.key, info.type); res = nvs_entry_next(&it); } nvs_release_iterator(it); }

这个函数在调试配置读写问题时特别有用,能直接看到 NVS 里实际存了哪些键、类型对不对。

7. 一些实际部署后的经验之谈

设备在现场跑起来之后,我发现几个在实验室里不会暴露的问题。第一个是手机浏览器的缓存。配置页返回的 HTML 如果被浏览器缓存了,用户第二次打开看到的还是旧值,以为没保存成功。解决办法是在响应头里加Cache-Control: no-cache,强制每次重新请求。

第二个是表单提交的编码问题。如果 WiFi 密码里包含&、=、+这些特殊字符,表单提交时会被 URL 编码,服务端解析时要正确解码。WebServer库的arg()方法会自动解码,但如果你用 ESP-IDF 手动解析 body,就要自己处理%XX和+的转换。我遇到过一个案例,用户密码里有+,结果存进去变成了空格,怎么都连不上 WiFi,排查了半天才发现是编码问题。

第三个是AP 模式和 STA 模式共存时的 IP 冲突。如果设备同时开启 AP 和 STA(WIFI_MODE_APSTA),两个接口的 IP 段不能重叠。默认 AP 是192.168.4.x,STA 从路由器拿到的可能是192.168.1.x,一般不冲突。但如果路由器本身就是192.168.4.x网段,那就撞了。这种情况要么改 AP 的 IP 段,要么让用户换个路由器网段。

最后一个经验:配置页的 HTML 尽量精简。ESP32 的内存有限,如果 HTML 里塞了大量 CSS 和 JavaScript,字符串拼接时会占用不少堆内存。我见过有人把整个 Bootstrap 框架嵌进去,结果设备一返回页面就内存不足重启。配置页嘛,能填表能提交就行,别追求花哨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询