
1. 为什么改个 WiFi 密码要动固件搞过 ESP32 联网项目的朋友大概率都经历过这个场景设备已经装到现场了外壳封好、螺丝拧死结果甲方一个电话打过来——“WiFi 密码换了你帮忙改一下”。这时候你脑子里第一反应是什么打开 Arduino IDE 或者 ESP-IDF找到代码里那两行ssid和password改完重新编译然后抱着笔记本跑到现场拆壳、接线、按住 BOOT 键、插 USB、烧录、等重启。一套流程下来半小时没了如果设备装在吊顶或者配电箱里那酸爽程度还得翻倍。这个问题的根源在于很多人把 WiFi 凭据硬编码在了固件里。代码里写死const char* ssid MyHome;这种方式编译期就确定了运行时根本改不了。固件一旦烧进去凭据就跟着固件一起被“焊死”在 Flash 的代码段里。想改只能重新编译重新烧。但 ESP32 本身其实提供了一个非常合适的存储区域——NVSNon-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_cfgMQTT 配置放mqtt_cfg。这样做的好处是清除某一类配置时直接nvs_erase_all对应的命名空间就行不会误伤其他数据。命名空间名字最长 15 个字符key 最长 15 个字符这个限制在实际命名时要注意别起太长的名字。一个典型的 WiFi 配置命名空间里可能存这些键Key类型说明ssidstringWiFi 名称passstringWiFi 密码modeu80STA 1AP 2APSTAdhcpu8是否启用 DHCPipstring静态 IP可选2.3 为什么不用 Preferences 库而用原生 NVSArduino 环境下有个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 服务器塞进 ESP32Web 配置页的实现路径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 开发直接用这个如果用 ArduinoWebServer或ESPAsyncWebServer更顺手。本文的方案不依赖特定框架核心逻辑是通用的注册一个 GET 路由返回配置页面 HTML注册一个 POST 路由接收表单数据并写入 NVS。你用哪个库都能实现。3.2 配置页面的 HTML 该怎么设计配置页面不需要多漂亮但要满足几个硬性要求手机浏览器能正常显示、表单字段清晰、提交后有反馈、能显示当前配置值。一个最小可用的页面结构大概是这样的!DOCTYPE html html head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title设备配置/title /head body h2WiFi 配置/h2 form action/save methodPOST labelWiFi 名称/label input typetext namessid value{{SSID}}br labelWiFi 密码/label input typetext namepass value{{PASS}}br button typesubmit保存/button /form /body /html注意viewport这个 meta 标签一定要加不然手机上打开会显示成桌面版布局字小得看不清。{{SSID}}和{{PASS}}是占位符服务端返回页面时替换成 NVS 里读出来的当前值这样用户打开页面就能看到现在配的是什么改的时候只改需要变的部分。3.3 表单提交后数据怎么流进 NVSPOST 请求进来后处理流程分四步解析表单参数从请求体里提取ssid和pass字段的值。WebServer库有hasArg和arg方法直接取ESP-IDF 需要自己解析application/x-www-form-urlencoded格式的 body。打开 NVS 命名空间nvs_open(wifi_cfg, NVS_READWRITE, handle)。写入键值nvs_set_str(handle, ssid, ssid_value)密码同理。提交并关闭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 整体架构与启动流程先把整个方案的运行逻辑理清楚。设备启动后初始化 NVS 分区。从 NVS 读取 WiFi 配置。如果读到了尝试以 STA 模式连接如果没读到或者连接失败切换到 AP 模式自己开一个热点。启动 HTTP 服务器注册配置页面路由和保存路由。在 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 htmlhtmlheadmeta charsetUTF-8; html meta nameviewport contentwidthdevice-width,initial-scale1; html /headbodyh2WiFi 配置/h2form action/save methodPOST; html 名称input namessid value ssid br; html 密码input namepass value pass br; html button typesubmit保存/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 密码字段的安全处理密码用typetext明文显示其实不太合适旁边有人看着就泄露了。改成typepassword又有个问题用户打开页面时看不到当前密码不知道现在配的是什么。折中方案是默认显示为密码框但提供一个“显示密码”的复选框勾选后切换成文本框。这需要一点 JavaScriptinput typepassword namepass idpass value{{PASS}} input typecheckbox onclickdocument.getElementById(pass).typethis.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 nameviewport contentwidthdevice-width, initial-scale1.0就能解决。还有一个坑是中文字符编码。如果 HTML 里没声明meta charsetUTF-8中文会显示成乱码。ESP32 返回的响应头里也要带上Content-Type: text/html; charsetutf-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 和 STAWIFI_MODE_APSTA两个接口的 IP 段不能重叠。默认 AP 是192.168.4.xSTA 从路由器拿到的可能是192.168.1.x一般不冲突。但如果路由器本身就是192.168.4.x网段那就撞了。这种情况要么改 AP 的 IP 段要么让用户换个路由器网段。最后一个经验配置页的 HTML 尽量精简。ESP32 的内存有限如果 HTML 里塞了大量 CSS 和 JavaScript字符串拼接时会占用不少堆内存。我见过有人把整个 Bootstrap 框架嵌进去结果设备一返回页面就内存不足重启。配置页嘛能填表能提交就行别追求花哨。