ESP32 WiFi配置不再硬编码:浏览器直接读写NVS
1. 为什么改个 WiFi 密码要动固件
搞过 ESP32 联网项目的朋友大概率都经历过这个场景:设备已经装到现场了,外壳封好、螺丝拧紧、甚至打了防水胶,结果甲方一个电话打过来——“WiFi 密码换了,你帮忙改一下”。这时候你脑子里第一反应是什么?重新打开 Arduino IDE,找到那行const char* password = "xxxx";,改完重新编译、插 USB、按住 BOOT 键、烧录、等重启。如果设备在几十公里外的现场,那就更酸爽了,得跑一趟。
这个痛点的根源在于:大多数人把 WiFi 配置信息硬编码在了固件里。固件是编译产物,改一个字节就得整体重刷。而 ESP32 本身其实自带了一块非易失性存储区域——NVS(Non-Volatile Storage),它本来就是用来存这种“运行时可变的配置数据”的。WiFi 的 SSID 和密码,天然就应该放在 NVS 里,而不是写死在代码里。
那为什么很多人还是硬编码?因为改 NVS 需要额外的工具或接口。串口命令行是一种方式,但现场人员不一定有串口线;蓝牙配网是一种方式,但实现复杂度不低;AP 热点配网也常见,但每次都要切换网络、打开网页、输入密码,流程偏重。而这篇要聊的方案,核心思路是:在 ESP32 上跑一个轻量 HTTP 服务,浏览器打开一个页面就能直接读写 NVS 键值,改完 WiFi 密码重启生效,全程不需要碰固件。
这个方案特别适合以下几类人:做 IoT 设备部署的嵌入式开发者、需要频繁调整 WiFi 配置的测试人员、以及手上有 ESP32 想搞点实用小工具的爱好者。读完你至少能掌握三件事:NVS 的读写机制到底怎么回事、怎么在 ESP32 上搭一个能操作 NVS 的 Web 界面、以及怎么把 WiFi 连接逻辑改成从 NVS 读取而不是硬编码。
2. NVS 到底是个什么东西,为什么它适合存 WiFi 配置
2.1 NVS 的物理位置和逻辑结构
ESP32 的 NVS 并不是一块独立的物理芯片,它是SPI Flash 上划分出来的一段分区。在默认的分区表里,你会看到一个名为nvs的分区,大小通常是 0x5000(20KB)或者 0x6000(24KB),具体取决于你的分区表配置。这段区域由 ESP-IDF 提供的 NVS 库统一管理,对外表现为一个简单的键值存储系统。
它的逻辑结构可以这样理解:NVS 里面可以有多个命名空间(namespace),每个命名空间下面可以存多个键值对(key-value)。键是字符串,最长 15 个字符;值支持多种类型,包括整数、字符串、二进制大对象(blob)等。对于 WiFi 配置来说,你通常会建一个叫wifi_config的命名空间,里面存两个键:ssid和password,类型都是字符串。
用生活化的类比来说,NVS 就像你家门口的一个小储物柜。储物柜分了好几层(命名空间),每层贴了标签,里面放的东西可以是钥匙、纸条或者小盒子(不同类型的数据)。你要拿什么东西,报出层名和物品名就行,不需要知道它具体放在柜子的哪个角落。
2.2 NVS 和硬编码的本质区别
硬编码的 WiFi 信息在编译时就固定在了固件的.rodata段里,运行时从 Flash 直接读取,速度极快,但完全不可变。NVS 则是运行时可读写的,程序可以在任意时刻调用nvs_set_str()写入新值,调用nvs_get_str()读取当前值。写入操作会触发 Flash 的擦写,ESP32 的 NVS 库内部做了磨损均衡和掉电保护,正常使用完全不用担心寿命问题。
这里有一个关键点需要说清楚:NVS 的写入不是立即生效到 WiFi 连接上的。你改了 NVS 里的密码,当前已经建立的 WiFi 连接不会自动断开重连。你需要显式地调用esp_wifi_disconnect()然后重新esp_wifi_connect(),或者干脆重启设备。这也是为什么浏览器工具改完 NVS 之后,页面上通常会提供一个“重启生效”的按钮。
2.3 为什么不用 Preferences 库而直接用 NVS
Arduino 框架下有一个Preferences库,它是对 NVS 的封装,用起来更简单。但在这个场景下,我建议直接用 ESP-IDF 原生的 NVS API,原因有两个:一是Preferences库在 Arduino 环境下对命名空间的管理不够灵活,二是当你要在 Web 服务里动态列出所有键值对时,原生 NVS 的迭代器接口(nvs_entry_find/nvs_entry_next)更方便。当然,如果你只是存两个固定的键,用Preferences也完全没问题,看个人习惯。
3. 浏览器直接改 NVS 的整体架构怎么搭
3.1 三个核心模块的职责划分
整个方案可以拆成三层:NVS 读写层、HTTP 服务层、前端交互层。NVS 读写层负责封装nvs_open、nvs_get_str、nvs_set_str、nvs_commit这些底层调用,对外暴露read_wifi_config()和write_wifi_config()两个函数。HTTP 服务层用esp_http_server组件搭建,注册几个 URI handler,分别处理 GET 请求(返回当前配置页面)和 POST 请求(接收新配置并写入 NVS)。前端交互层就是一个嵌在固件里的 HTML 页面,包含输入框和提交按钮,通过 fetch API 和后端通信。
这三层之间的关系很清晰:浏览器发请求 → HTTP handler 解析参数 → 调用 NVS 读写函数 → 返回结果给浏览器。整个链路不涉及任何外部服务器,所有逻辑都在 ESP32 本地完成。
3.2 为什么选 HTTP 而不是串口或蓝牙
串口改 NVS 需要物理连接,现场部署后基本不可行。蓝牙改 NVS 需要手机装 App 或者用蓝牙调试助手,门槛偏高。HTTP 的优势在于:任何有浏览器的设备都能操作,手机、平板、笔记本都行,不需要装任何软件。而且 HTTP 的请求-响应模型天然适合“读取当前值-修改-提交”这种交互模式。
唯一需要注意的是,ESP32 作为 HTTP 服务器时,它的 IP 地址得让用户知道。常见做法是设备启动后在串口打印 IP,或者在 OLED 屏幕上显示。如果你的 ESP32 同时开了 AP 模式,那用户连上 AP 后访问固定 IP(比如 192.168.4.1)就行,体验最好。
3.3 内存和 Flash 占用的实际考量
有人可能会担心:在 ESP32 上跑 HTTP 服务器 + 嵌 HTML 页面,资源够不够?实测下来,一个精简的 HTTP 服务器加上一个几 KB 的 HTML 页面,占用的 RAM 大约在 20-30KB 左右,Flash 占用取决于 HTML 页面大小,通常 10-20KB 足够。对于 ESP32 动辄 4MB Flash、520KB RAM 的配置来说,这点开销完全可以接受。
如果你用的是 ESP32-C3 这类 RAM 较小的型号(400KB),也完全跑得动,只要注意 HTML 页面不要嵌太多图片和 CSS 框架就行。纯手写的精简 HTML + 内联 CSS,控制在 5KB 以内是很轻松的。
4. 从零实现 NVS 读写接口的完整步骤
4.1 初始化 NVS 分区
在app_main()或者 Arduino 的setup()里,第一件事是初始化 NVS。ESP-IDF 环境下调用nvs_flash_init(),如果返回ESP_ERR_NVS_NO_FREE_PAGES或ESP_ERR_NVS_NEW_VERSION_FOUND,需要先擦除再初始化。Arduino 环境下Preferences库会自动处理这些,但如果你用原生 API,代码大概长这样:
esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret);这段代码的逻辑是:先尝试初始化,如果发现 NVS 分区没有空闲页或者版本不匹配,就擦掉重新来。擦除操作会清空所有已存的键值对,所以只应该在初始化失败时执行,不要每次启动都擦。
4.2 封装读取函数
读取 WiFi 配置的函数需要处理几种情况:键不存在、值类型不匹配、缓冲区太小。一个健壮的实现大概是这样:
#define WIFI_NAMESPACE "wifi_config" #define MAX_SSID_LEN 32 #define MAX_PASS_LEN 64 esp_err_t read_wifi_config(char *ssid, char *password) { nvs_handle_t handle; esp_err_t err = nvs_open(WIFI_NAMESPACE, NVS_READONLY, &handle); if (err != ESP_OK) return err; size_t ssid_len = MAX_SSID_LEN; err = nvs_get_str(handle, "ssid", ssid, &ssid_len); if (err != ESP_OK) { nvs_close(handle); return err; } size_t pass_len = MAX_PASS_LEN; err = nvs_get_str(handle, "password", password, &pass_len); nvs_close(handle); return err; }注意nvs_get_str的第三个参数是输出缓冲区,第四个参数是输入输出参数——传入时表示缓冲区大小,返回时表示实际读取到的字符串长度(包含结尾的\0)。这个细节很容易踩坑,如果缓冲区不够大,函数会返回ESP_ERR_NVS_INVALID_LENGTH。
4.3 封装写入函数
写入函数相对直接,但要注意每次nvs_set_str之后必须调用nvs_commit才能真正写入 Flash:
esp_err_t write_wifi_config(const char *ssid, const char *password) { nvs_handle_t handle; esp_err_t err = nvs_open(WIFI_NAMESPACE, NVS_READWRITE, &handle); if (err != ESP_OK) return err; err = nvs_set_str(handle, "ssid", ssid); if (err != ESP_OK) { nvs_close(handle); return err; } err = nvs_set_str(handle, "password", password); if (err != ESP_OK) { nvs_close(handle); return err; } err = nvs_commit(handle); nvs_close(handle); return err; }这里有一个实操心得:nvs_commit是必须的,但不要每写一个键就 commit 一次。正确的做法是先把所有键都 set 完,最后统一 commit。这样能减少 Flash 擦写次数,延长寿命。另外,nvs_close在任何分支都要调用,否则句柄泄漏会导致后续操作失败。
4.4 首次启动的默认值处理
设备第一次上电时,NVS 里是空的,read_wifi_config会返回ESP_ERR_NVS_NOT_FOUND。这时候你需要决定策略:是让设备进入 AP 配网模式,还是用一组编译时的默认值写入 NVS。我通常的做法是:如果读取失败,就写入一组默认的 SSID 和密码(比如空字符串),然后启动 AP 模式,让用户通过浏览器工具填入正确的配置。这样既保证了首次启动不会卡死,又给了用户修改的入口。
5. HTTP 服务端和前端页面的对接细节
5.1 注册 URI handler 的正确姿势
esp_http_server的 URI 注册用httpd_register_uri_handler,每个 handler 需要指定 URI、method 和回调函数。对于配置页面,通常注册两个:
httpd_uri_t config_page = { .uri = "/config", .method = HTTP_GET, .handler = config_get_handler, .user_ctx = NULL }; httpd_uri_t config_save = { .uri = "/config", .method = HTTP_POST, .handler = config_post_handler, .user_ctx = NULL };GET 请求返回 HTML 页面,POST 请求处理表单提交。注意 URI 可以相同,靠 method 区分。这种 RESTful 风格的设计让前端代码很简洁。
5.2 GET handler 里怎么把当前值填进 HTML
最直接的方式是用snprintf把当前 NVS 里的值拼进 HTML 模板。比如页面里有一个<input id="ssid" value="%s">,你在 handler 里读取 NVS 得到当前 SSID,然后格式化输出。这里要注意 HTML 转义——如果 SSID 里包含"或<这类字符,直接拼进去会破坏 HTML 结构。虽然 WiFi SSID 通常不会包含这些字符,但严谨起见还是应该做一个简单的转义处理。
另一个细节是 Content-Type 要设成text/html,并且用httpd_resp_send发送完整响应。如果 HTML 页面比较大,可以考虑用 chunked 方式分块发送,但对于几 KB 的页面来说没必要。
5.3 POST handler 里解析表单数据
POST 请求的 body 是application/x-www-form-urlencoded格式,比如ssid=MyWiFi&password=12345678。解析这个格式可以用httpd_req_recv读取 body,然后手动按&和=分割。ESP-IDF 没有内置的表单解析函数,但自己写一个简单的解析逻辑也就十几行代码。关键是要处理 URL 编码——空格会变成+或%20,特殊字符会变成%XX。如果你的密码里包含这些字符,不做解码就会写错。
解析完成后调用write_wifi_config写入 NVS,然后返回一个简单的成功页面或者 JSON 响应。我通常返回一个 JSON,前端拿到后用 JavaScript 显示提示信息,体验更流畅。
5.4 前端页面的极简实现
前端不需要任何框架,纯 HTML + 内联 CSS + 几行 JavaScript 就够了。页面结构大概是:两个输入框(SSID 和密码)、一个保存按钮、一个重启按钮、一个状态显示区域。保存按钮触发fetch('/config', {method: 'POST', body: formData}),重启按钮触发fetch('/reboot')。
这里有一个实用技巧:密码输入框的 type 设为password,但旁边加一个“显示密码”的复选框,切换type属性在password和text之间。这个细节在现场调试时特别有用,能避免输错密码却看不出来的尴尬。
6. 把 WiFi 连接逻辑改成从 NVS 读取
6.1 启动流程的重新设计
原来的启动流程可能是:初始化 WiFi → 硬编码 SSID/密码 → 连接。改造后的流程应该是:初始化 NVS → 读取 WiFi 配置 → 如果配置为空则进入 AP 配网模式 → 否则尝试连接 → 连接成功则启动 HTTP 服务。这个流程的关键在于把“读取配置”和“连接”解耦,配置来源可以是 NVS,也可以是其他任何地方。
在 Arduino 环境下,WiFi.begin(ssid, password)的 ssid 和 password 参数就是从 NVS 读出来的字符串。在 ESP-IDF 环境下,你需要配置wifi_config_t结构体,把 ssid 和 password 填进去,然后调用esp_wifi_set_config。
6.2 连接失败后的重试策略
从 NVS 读取的密码可能已经过期(比如路由器改了密码但 NVS 里还是旧的),这时候连接会失败。一个健壮的实现应该包含重试逻辑:先尝试连接 3 次,每次间隔 5 秒,如果都失败就启动 AP 模式,让用户通过浏览器工具更新配置。这样即使密码错了,用户也有办法恢复,不会变成“砖头”。
重试次数的选择需要权衡:次数太少可能因为路由器启动慢而误判,次数太多则设备长时间无法进入可用状态。实测下来,3 次重试、每次间隔 5 秒是一个比较合理的平衡点。
6.3 运行时切换 WiFi 的处理
如果设备已经连接了 WiFi A,用户通过浏览器工具把配置改成了 WiFi B,这时候需要先断开当前连接,再连接新的。代码逻辑是:esp_wifi_disconnect()→ 等待断开事件 → 用新配置调用esp_wifi_set_config()→esp_wifi_connect()。注意在断开和重连之间要留一点延迟,否则可能因为状态机没切换过来而失败。
另外,如果新配置连接失败,应该回滚到旧配置吗?我的建议是不回滚,而是进入 AP 模式让用户重新配置。因为回滚会让用户困惑——“我明明改了密码,怎么又连回旧的了?”进入 AP 模式则明确告诉用户“新配置有问题,请重新设置”。
7. 实测中容易踩的坑和排查思路
7.1 NVS 写入后读出来是乱码
这个问题通常是因为缓冲区大小不对。nvs_get_str的length参数在传入时是缓冲区总大小,返回时是实际字符串长度。如果你传入的缓冲区大小是 32,但实际字符串长度是 33(包含结尾\0),函数会返回错误而不是截断。排查方法是:先用nvs_get_str(handle, key, NULL, &len)获取实际长度,再分配足够的缓冲区重新读取。
另一个可能的原因是命名空间或键名拼写错误。NVS 的键名区分大小写,ssid和SSID是两个不同的键。建议把命名空间和键名定义为宏,避免手写字符串时出错。
7.2 HTTP 请求返回 400 或 405
400 通常是请求格式有问题,比如 POST body 不是合法的表单编码。405 是 method 不匹配,比如你用 GET 请求了一个只注册了 POST 的 URI。排查时先在浏览器开发者工具的 Network 面板看请求的 method 和 body 是否正确,再检查 ESP32 端的 URI 注册代码。
还有一个隐蔽的坑:esp_http_server默认对 URI 的匹配是精确匹配,/config和/config/会被视为不同的 URI。前端请求的 URL 要和后端注册的完全一致,包括结尾的斜杠。
7.3 改了 NVS 但 WiFi 没重连
前面提到过,NVS 写入不会自动触发 WiFi 重连。如果你在 POST handler 里只写了 NVS 但没有调用重连逻辑,用户会以为改了没生效。解决方案是在 POST handler 里写入成功后,设置一个标志位,然后在一个独立的任务里执行断开重连。不要在 HTTP handler 里直接做重连,因为重连过程可能耗时几秒,会阻塞 HTTP 响应。
7.4 浏览器缓存导致页面不更新
如果你修改了 HTML 页面并重新烧录固件,但浏览器打开还是旧页面,那大概率是缓存问题。解决方法是在 HTTP 响应头里加上Cache-Control: no-cache,或者在请求 URL 后面加一个时间戳参数。这个坑在开发阶段特别常见,每次改完页面都要手动清缓存很烦,加上 no-cache 头就省事了。
7.5 AP 模式和 STA 模式共存时的 IP 冲突
如果你的设备同时开了 AP 和 STA,AP 的网段默认是 192.168.4.x,STA 连上路由器后可能是 192.168.1.x。这两个网段不冲突,但用户访问配置页面时要用 AP 的 IP(192.168.4.1),而不是 STA 获取到的 IP。建议在串口日志里把两个 IP 都打印出来,并在页面上明确提示用户应该访问哪个地址。
8. 几个让方案更实用的扩展思路
8.1 加一个 NVS 键值浏览页面
既然已经能读写 NVS 了,不妨再加一个页面,列出当前 NVS 里所有的命名空间和键值对。这个功能在调试时非常有用,能直观地看到哪些配置已经写入、哪些还是空的。实现上就是用nvs_entry_find和nvs_entry_next遍历,把结果拼成 HTML 表格返回。注意遍历时要处理nvs_entry_info_t结构体里的类型字段,不同类型的数据展示方式不同。
8.2 支持导出和导入配置
有时候你需要把一台设备的配置复制到另一台设备上。可以加一个“导出配置”按钮,把 NVS 里的所有键值对序列化成 JSON 下载到本地;再加一个“导入配置”功能,上传 JSON 文件后批量写入 NVS。这个功能在小批量部署时能省不少事。
8.3 加一个简单的身份验证
默认情况下,任何能访问到设备 IP 的人都能改 NVS,这在某些场景下不太安全。可以加一个最简单的 HTTP Basic Auth,用户名和密码也存在 NVS 里。虽然 Basic Auth 的安全性有限,但至少能挡住误操作。实现上就是在每个 handler 开头检查Authorization请求头,不匹配就返回 401。
8.4 把配置页面做成响应式
现场操作时很多人用手机浏览器,所以页面要做成响应式的。最简单的做法是在<head>里加<meta name="viewport" content="width=device-width, initial-scale=1">,然后用百分比宽度而不是固定像素。输入框和按钮的尺寸要足够大,方便手指点击。这些细节虽然小,但直接影响现场人员的使用体验。
9. 一些个人经验上的提醒
这个方案我从几个项目中总结下来,最核心的价值就是把“改配置”和“改固件”这两件事彻底分开了。固件只负责逻辑,配置全部走 NVS,现场人员通过浏览器就能完成所有调整。这不仅省了重新烧录的时间,更重要的是降低了操作门槛——不需要装开发环境、不需要懂编译、不需要接串口线。
有一个细节值得单独提一下:NVS 的命名空间和键名一旦确定,尽量不要改。因为改了之后,旧固件写入的数据在新固件里读不到,会导致设备“失忆”。如果确实需要改,要在代码里做兼容处理,比如先尝试读新键名,读不到再读旧键名,然后迁移到新键名。
另外,如果你用的是 Arduino 框架,Preferences库的begin()方法默认命名空间是"nvs",和 ESP-IDF 原生 NVS 的默认命名空间可能不一致。混用两套 API 时要注意命名空间的对应关系,否则会出现“用 A 写入、用 B 读不到”的情况。
最后说一个实际部署中的小技巧:在设备外壳上贴一张标签,写上配置页面的访问地址(比如http://192.168.4.1/config)。这样即使过了很久,现场人员也能快速找到入口,不用翻文档。这个习惯在多个项目里都帮我省了沟通成本。