1. 项目缘起:一个让人抓狂的日常痛点
做过 ESP32 产品落地的朋友大概率都遇到过这个场景:设备已经装到现场了,客户突然说"WiFi 密码换了,你帮我改一下"。这时候你怎么办?如果代码里 WiFi 账号密码是写死在固件里的,那就只能重新编译、重新烧录,运气好现场有 USB 口还能接电脑,运气不好设备封在壳子里、装在吊顶上,那就只能拆机。我见过最离谱的一次,同事为了改一个 WiFi 密码,开车来回两百公里去客户现场,就为了插一根 USB 线。
这个问题的根源在于:很多人把 WiFi 配置当成了"代码"而不是"数据"。代码是编译进固件的,改代码就得重刷固件;但 WiFi 账号密码本质上是配置数据,它应该存在一个可以独立于固件被读写的地方。ESP32 恰好提供了这么一个地方——NVS(Non-Volatile Storage,非易失性存储)。
NVS 是 ESP-IDF 里的一套键值存储系统,跑在 SPI Flash 的一个独立分区上,掉电不丢数据。你可以把它理解成 ESP32 内部的一个"小数据库",用 key-value 的形式存东西,比如wifi_ssid = "MyHome"、wifi_pass = "12345678"。固件启动的时候从 NVS 里读这些值去连 WiFi,而不是用写死的常量。这样一来,改 WiFi 密码就变成了"改 NVS 里的一个键值",跟固件本身没关系了。
那问题又来了:怎么在不拆机、不接 USB 的情况下改 NVS?答案就是标题里说的——浏览器工具。ESP32 本身可以跑一个轻量级的 HTTP 服务器,手机或电脑连上它的热点(或者它连上局域网后通过 IP 访问),打开浏览器就能看到一个配置页面,填好新的 WiFi 账号密码点保存,工具通过 HTTP 接口把值写进 NVS,重启后设备就用新密码连网了。整个过程不需要任何上位机软件,不需要数据线,一部手机就搞定。
这篇文章我会把整套方案的来龙去脉讲清楚:为什么选 NVS 而不是其他存储方式、浏览器工具怎么和 ESP32 通信、NVS 读写有哪些坑、完整的实操步骤和代码骨架、以及我在实际项目中踩过的那些坑。适合正在做 ESP32 联网产品、被现场配置问题折磨过的开发者,也适合刚接触 ESP-IDF、想搞明白 NVS 到底怎么用的朋友。哪怕你之前只会用 Arduino 写WiFi.begin(ssid, password),看完也能把这套方案落地。
2. 方案整体设计:为什么是 NVS + 浏览器工具
2.1 存储选型:NVS、SPIFFS、EEPROM 到底选哪个
ESP32 上能存配置数据的地方不止一个,常见的有三种:NVS、SPIFFS/LittleFS 文件系统、以及模拟 EEPROM。很多人第一反应是用 EEPROM,毕竟 Arduino 时代就是这么干的。但在 ESP32 上,EEPROM.h其实是对 Flash 的一层模拟,底层还是走 NVS 或者直接操作 Flash 扇区,性能和可靠性都不如直接用 NVS。
我把三者的对比整理成一张表,方便你按场景选:
| 存储方式 | 数据结构 | 适合场景 | 读写粒度 | 磨损均衡 | 推荐度 |
|---|---|---|---|---|---|
| NVS | 键值对 | 配置参数、WiFi 凭据、校准值 | 单键读写 | 内置 | 高 |
| SPIFFS/LittleFS | 文件 | 网页资源、日志、大块数据 | 文件级 | 需自行处理 | 中 |
| 模拟 EEPROM | 字节数组 | 简单小数据、兼容老代码 | 字节级 | 无 | 低 |
选 NVS 的核心理由有三个。第一,它是为配置数据设计的,天生就是 key-value 模型,读写单个键非常方便,不用像文件系统那样打开、定位、写入、关闭一整套流程。第二,NVS 内置了磨损均衡和掉电保护,Flash 的擦写寿命是有限的(一般 10 万次左右),NVS 会自动把写操作分散到不同扇区,避免某个扇区被写坏。第三,NVS 支持命名空间(namespace),你可以把 WiFi 配置放一个命名空间,把设备参数放另一个,互不干扰,读取的时候也不会串。
注意:NVS 单个 value 有大小限制,字符串类型(
nvs_set_str)最大约 4000 字节, blob 类型(nvs_set_blob)单个分区内也有限制。存 WiFi 账号密码这种几十字节的数据完全够用,但别拿它存图片或者大段 JSON。
2.2 通信方式:为什么用浏览器而不是 App 或串口
改 NVS 的方式有好几种,我逐一分析一下为什么最终选了浏览器工具这条路。
串口方式:最直接,接 USB 用串口终端发命令。但问题就是标题里吐槽的——现场设备不一定有 USB 口,就算有,你也得带电脑、装驱动、开终端软件。对现场施工人员来说门槛太高。
专用 App 方式:写个手机 App 通过蓝牙或局域网连设备改配置。功能是强,但你要维护 Android 和 iOS 两个版本,还要处理权限、兼容性、上架审核,成本太高。而且客户手机装个来路不明的 App,心理上也会抵触。
浏览器方式:设备自己跑一个 HTTP 服务器,手机连上后打开浏览器输入 IP 就能访问配置页。零安装、跨平台、任何有浏览器的设备都能用。这是它最大的优势。你甚至可以用手机热点让 ESP32 连上,然后手机浏览器直接访问,连局域网都不用配。
浏览器方案的技术栈也很清晰:ESP-IDF 自带esp_http_server组件,起一个 HTTP 服务只需要几十行代码;前端页面可以用最朴素的 HTML + JavaScript,不需要任何框架,因为功能就是"填表单、点保存"这么简单。整个页面的 HTML 可以直接以字符串形式编译进固件,或者放到 SPIFFS 里按需读取。
2.3 整体架构:从浏览器点击到 NVS 落盘
把整个链路串起来看,数据流是这样的:
- 设备上电,先尝试从 NVS 读取 WiFi 凭据,读到就连接,读不到就进入配网模式(开热点或等待配置)。
- 设备启动 HTTP 服务器,监听某个端口(比如 80)。
- 用户手机连上设备热点或同一局域网,浏览器访问设备 IP。
- 设备返回一个 HTML 配置页面,页面上有 SSID 和密码输入框。
- 用户填写后点击保存,前端 JS 把数据以 POST 请求发给设备。
- 设备的 HTTP handler 收到请求,解析出 SSID 和密码,调用 NVS API 写入。
- 写入成功后返回响应,前端提示"保存成功,设备即将重启"。
- 设备重启,重新从 NVS 读取新凭据,连接新 WiFi。
这个架构的关键设计点是:配置写入和 WiFi 连接是解耦的。HTTP 服务器和 NVS 操作不依赖 WiFi 是否连上,所以即使当前 WiFi 密码错了、连不上网,你依然能通过设备自己的热点访问配置页改密码。这一点非常重要,否则一旦密码错了就变成死锁,只能重刷固件。
3. 核心细节解析:NVS 读写与 HTTP 服务的实操要点
3.1 NVS 初始化与命名空间管理
用 NVS 之前必须先初始化。ESP-IDF 里 NVS 的初始化分两步:先初始化分区,再打开命名空间。
#include "nvs_flash.h" #include "nvs.h" // 第一步:初始化 NVS 分区 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_handle_t my_handle; ret = nvs_open("wifi_cfg", NVS_READWRITE, &my_handle); if (ret != ESP_OK) { printf("打开 NVS 命名空间失败: %s\n", esp_err_to_name(ret)); }这里有几个细节值得展开说。nvs_flash_init()返回ESP_ERR_NVS_NO_FREE_PAGES是新手最常遇到的错误,意思是 NVS 分区里没有空闲页了。这通常发生在你反复擦写、或者分区表里 NVS 分区设得太小的时候。处理办法就是擦除整个 NVS 分区重新初始化,但要注意擦除会丢失所有已存数据,所以生产环境里要谨慎,最好在擦除前先尝试读取并备份关键配置。
命名空间的名字最长 15 个字符,这是 NVS 的硬性限制,超了会返回错误。我一般用wifi_cfg、dev_param这种简短又表意的名字。命名空间一旦打开,后续所有读写都通过这个 handle 进行,用完记得nvs_close(my_handle)释放资源。
实操心得:如果你的项目里多个任务都要读写 NVS,建议用一个全局的 handle 加互斥锁保护,或者干脆把 NVS 操作封装成一个单独的模块,所有读写都走这个模块的接口。我见过因为两个任务同时写 NVS 导致数据错乱的案例,排查了大半天。
3.2 字符串读写:WiFi 凭据的存取
WiFi 的 SSID 和密码都是字符串,用nvs_set_str和nvs_get_str最合适。
// 写入 SSID nvs_set_str(my_handle, "ssid", "MyHomeWiFi"); // 写入密码 nvs_set_str(my_handle, "pass", "12345678"); // 提交更改(必须调用,否则不落盘) nvs_commit(my_handle);读取的时候有个坑:nvs_get_str需要你先提供一个缓冲区,并且传入缓冲区大小的指针。如果缓冲区太小,函数会返回ESP_ERR_NVS_INVALID_LENGTH,同时把实际需要的长度写回你传入的长度变量。所以正确的做法是先查长度,再分配缓冲区:
size_t len = 0; // 第一次调用,len 传 0,函数会返回所需长度 esp_err_t err = nvs_get_str(my_handle, "ssid", NULL, &len); if (err == ESP_OK && len > 0) { char *ssid = malloc(len); nvs_get_str(my_handle, "ssid", ssid, &len); // 使用 ssid... free(ssid); }很多人图省事直接开一个 64 字节的数组,大部分情况没问题,但 SSID 理论上最长 32 字节、密码最长 63 字节,加上结束符,64 字节其实刚好卡在边界上。我建议缓冲区至少给 128 字节,留足余量。
nvs_commit()这一步绝对不能省。NVS 的写入是先在内存里改,调用 commit 才真正写到 Flash。如果你写完不 commit,掉电后数据就没了。我早期就犯过这个错,调试的时候发现"明明写进去了,重启就没了",查了半天才发现漏了 commit。
3.3 HTTP 服务器搭建:从注册路由到处理请求
ESP-IDF 的esp_http_server用起来很顺手,核心就是注册 URI handler。
#include "esp_http_server.h" static esp_err_t config_get_handler(httpd_req_t *req) { const char *html = "<html><body>" "<h2>WiFi 配置</h2>" "<form action='/save' method='post'>" "SSID: <input name='ssid'><br>" "密码: <input name='pass' type='password'><br>" "<input type='submit' value='保存'>" "</form></body></html>"; httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN); return ESP_OK; } static esp_err_t config_post_handler(httpd_req_t *req) { char buf[256]; int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); if (ret <= 0) return ESP_FAIL; buf[ret] = '\0'; // 解析 buf 里的表单数据,写入 NVS... httpd_resp_send(req, "保存成功,设备即将重启", HTTPD_RESP_USE_STRLEN); return ESP_OK; } httpd_uri_t uri_get = { .uri = "/", .method = HTTP_GET, .handler = config_get_handler }; httpd_uri_t uri_post = { .uri = "/save", .method = HTTP_POST, .handler = config_post_handler }; httpd_handle_t server = NULL; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_start(&server, &config); httpd_register_uri_handler(server, &uri_get); httpd_register_uri_handler(server, &uri_post);这里的关键点是表单数据的解析。浏览器提交表单时,Content-Type 是application/x-www-form-urlencoded,数据格式是ssid=MyHome&pass=12345678。你需要自己解析这个字符串:按&分割成键值对,再按=分割键和值,还要处理 URL 编码(比如空格会变成%20,中文会变成%XX形式)。ESP-IDF 没有内置的表单解析函数,得自己写,或者用httpd_query_key_value这个辅助函数。
char ssid[128], pass[128]; if (httpd_query_key_value(buf, "ssid", ssid, sizeof(ssid)) == ESP_OK && httpd_query_key_value(buf, "pass", pass, sizeof(pass)) == ESP_OK) { // 写入 NVS nvs_set_str(my_handle, "ssid", ssid); nvs_set_str(my_handle, "pass", pass); nvs_commit(my_handle); }httpd_query_key_value会自动处理 URL 解码,省了不少事。但要注意它只能解析key=value&key=value这种格式,如果你的前端用 JSON 提交,就得换cJSON之类的库来解析。
注意:HTTP 服务器的接收缓冲区大小要设够。默认配置下
httpd_req_recv一次能收的数据有限,如果表单数据超过缓冲区,需要循环接收。WiFi 配置这种小表单一般不会超,但如果你以后要传更大的配置,记得处理分片接收。
3.4 前端页面设计:极简但要好用
前端页面不需要花哨,但有几个细节能大幅提升体验。第一,密码框用type='password',避免明文显示。第二,加一个"显示密码"的复选框,方便用户核对输入。第三,保存后给明确的反馈,比如弹一个提示或者跳转到一个"保存成功"页面,然后自动重启。
<form action="/save" method="post"> <label>WiFi 名称</label> <input name="ssid" maxlength="32" required> <label>WiFi 密码</label> <input name="pass" type="password" maxlength="63"> <label><input type="checkbox" onclick="togglePwd(this)"> 显示密码</label> <button type="submit">保存并重启</button> </form> <script> function togglePwd(cb) { var pwd = document.querySelector('input[name=pass]'); pwd.type = cb.checked ? 'text' : 'password'; } </script>页面可以直接以字符串形式嵌在固件里,优点是简单、不依赖文件系统;缺点是改页面要重新编译。如果页面比较复杂,建议放到 SPIFFS 里,通过httpd_resp_send读取文件发送。我一般用嵌入字符串的方式,因为配置页就那么点内容,没必要引入文件系统。
4. 完整实操流程:从零搭一个可用的配置系统
4.1 分区表配置:给 NVS 留足空间
NVS 是跑在 Flash 分区上的,所以第一步是在分区表里定义 NVS 分区。ESP-IDF 默认的分区表里已经有一个nvs分区,大小通常是 24KB(0x6000)。对于只存 WiFi 配置的场景,24KB 绰绰有余,因为 NVS 一个键值对也就几十字节。但如果你还要存其他参数,或者担心磨损均衡,可以适当加大到 64KB 甚至 128KB。
自定义分区表的话,在项目根目录建一个partitions.csv:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000,然后在menuconfig里把Partition Table设为Custom partition table CSV,并指定文件名。改完分区表一定要idf.py erase-flash全擦一次,否则旧分区表和新分区表冲突,会出现各种奇怪的错误。
4.2 代码骨架:一个最小可用的实现
把前面的片段整合起来,一个最小可用的 WiFi 配置系统大概长这样:
void app_main(void) { // 1. 初始化 NVS esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } // 2. 尝试读取 WiFi 凭据 nvs_handle_t handle; nvs_open("wifi_cfg", NVS_READWRITE, &handle); char ssid[128] = {0}, pass[128] = {0}; size_t len = sizeof(ssid); bool has_cfg = (nvs_get_str(handle, "ssid", ssid, &len) == ESP_OK); len = sizeof(pass); nvs_get_str(handle, "pass", pass, &len); // 3. 启动 WiFi wifi_init_sta(ssid, pass); // 4. 启动 HTTP 服务器(无论 WiFi 是否连上都启动) start_http_server(handle); }这里有个设计决策:HTTP 服务器在 WiFi 连接之前就启动。这样即使 WiFi 连不上,用户依然能通过设备热点访问配置页。具体做法是设备同时开一个 AP 模式(热点),SSID 类似ESP32_Config,用户连上这个热点后访问192.168.4.1就能看到配置页。这个"STA + AP 共存"的模式是现场配置的标准做法。
4.3 参数计算:NVS 分区大小怎么定
很多人不知道 NVS 分区该给多大,这里给一个估算方法。NVS 的存储单位是"页",一页 4096 字节。每个键值对占用的空间包括:条目头(约 32 字节)+ key 字符串 + value 数据。假设你存 10 个配置项,每项平均 100 字节,总共 1000 字节,一页都用不满。但 NVS 需要预留磨损均衡的空间,一般建议实际数据量的 4 到 8 倍。所以 10 个配置项给 8KB 到 16KB 就够了,默认的 24KB 完全够用。
如果你要存大量数据(比如设备日志、历史记录),那就不适合用 NVS 了,应该用文件系统。NVS 的定位就是"少量、频繁读、偶尔写"的配置数据。
4.4 现场操作流程:一部手机搞定
代码烧进去之后,现场操作流程是这样的:
- 设备上电,如果没有存过 WiFi 凭据,自动进入 AP 模式,热点名类似
ESP32_Config_XXXX。 - 施工人员用手机连上这个热点(默认无密码或固定密码)。
- 手机浏览器打开
192.168.4.1,看到配置页面。 - 填写客户现场的 WiFi 名称和密码,点保存。
- 设备收到配置,写入 NVS,返回"保存成功",1 秒后自动重启。
- 重启后设备读取新凭据,连接客户 WiFi,连接成功后关闭 AP 模式。
- 如果连接失败,设备重新开启 AP 模式,等待重新配置。
整个过程不需要电脑、不需要数据线、不需要装任何 App。我实测下来,从连热点到配置完成,熟练的话 30 秒搞定。
实操心得:AP 模式的热点名建议带上设备 MAC 后四位,比如
ESP32_Config_A1B2,这样现场有多个设备时不会连错。另外,配置页面最好显示当前设备的一些信息(比如 MAC、固件版本),方便施工人员确认连对了设备。
5. 常见问题与排查技巧实录
5.1 NVS 相关的高频问题
问题一:nvs_flash_init返回ESP_ERR_NVS_NO_FREE_PAGES
这是最常见的 NVS 错误,原因是分区里没有空闲页了。触发场景通常是反复擦写导致页耗尽,或者分区表里 NVS 分区太小。解决办法是擦除后重新初始化,但要注意数据会丢。预防措施是合理设置分区大小,并且避免高频写 NVS(比如不要每秒写一次)。
问题二:写入成功但重启后数据丢失
九成是漏了nvs_commit()。NVS 的写入是两阶段的,nvs_set_xxx只改内存,nvs_commit才落盘。另一个可能是写入后立刻断电,Flash 写入需要时间,虽然 NVS 有掉电保护,但极端情况下仍可能丢数据。建议写入后延时 100ms 再重启。
问题三:读取字符串返回ESP_ERR_NVS_INVALID_LENGTH
缓冲区太小。解决办法是先传 NULL 查长度,再分配足够大的缓冲区。或者干脆给一个足够大的固定缓冲区(128 字节起步)。
问题四:命名空间打开失败
命名空间名字超过 15 字符,或者 NVS 分区没初始化。检查名字长度,确认nvs_flash_init已调用。
5.2 HTTP 服务的典型故障
问题一:浏览器访问超时
先确认设备和手机在同一个网络。如果是 AP 模式,确认手机连的是设备热点而不是其他 WiFi。再确认 HTTP 服务器确实启动了,可以在串口日志里看有没有 "httpd started" 之类的输出。还要检查防火墙,有些手机浏览器会拦截局域网访问。
问题二:表单提交后没反应
大概率是 POST handler 没注册,或者 URI 不匹配。检查httpd_register_uri_handler注册的 URI 和表单action是否一致。另外,如果 handler 里处理时间太长(比如写 NVS 卡住),浏览器会超时,建议先返回响应再执行耗时操作。
问题三:中文 SSID 乱码
URL 编码问题。浏览器提交中文时会做百分号编码,httpd_query_key_value会自动解码,但如果你的解析是自己写的,就要手动处理%XX。建议直接用httpd_query_key_value,省心。
5.3 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| NVS 初始化失败 | 分区满/分区表错误 | 擦除重试、检查分区表 |
| 数据重启后丢失 | 漏 commit/写入后立即断电 | 补 commit、加延时 |
| 读取长度错误 | 缓冲区太小 | 先查长度再分配 |
| 浏览器打不开页面 | 网络不通/服务器没启动 | 检查网络、看串口日志 |
| 表单提交无响应 | URI 不匹配/handler 未注册 | 核对 URI 和注册代码 |
| 中文乱码 | URL 编码未处理 | 用 httpd_query_key_value |
5.4 独家避坑技巧
第一个技巧:给配置页加一个"恢复出厂设置"按钮。现场经常遇到密码改错了、连不上的情况,如果有个按钮能一键清空 NVS 并重启,能省很多事。实现就是调用nvs_erase_all(handle)然后重启。
第二个技巧:配置写入后不要立即重启,先返回响应再延时重启。如果先重启,浏览器可能收不到响应,用户以为没保存成功,会重复提交。正确做法是返回"保存成功"页面,页面里用 JavaScript 延时 2 秒后跳转或提示重启。
第三个技巧:在 NVS 里存一个配置版本号。固件升级后如果配置结构变了,可以通过版本号判断是否需要迁移或重置配置。这个习惯在长期维护的产品里特别有用。
第四个技巧:AP 模式的热点加个简单密码。虽然配置页本身没什么敏感信息,但开放热点容易被路人连上乱改配置。设个固定密码(比如设备序列号后六位)就能挡住大部分误操作。
6. 方案扩展与个人体会
这套 NVS + 浏览器工具的方案,核心价值在于把配置和固件解耦。一旦你习惯了这种模式,会发现它能扩展出很多玩法。比如把设备的所有可调参数(采样频率、上报间隔、阈值)都放进 NVS,配置页做成多个 tab,现场调试就不用反复烧固件了。再比如加一个 OTA 升级入口,配置页上直接上传固件文件,连升级都不用接线。
我在实际项目里还做过一个变种:设备连上 WiFi 后,把配置页也暴露在局域网里,这样在办公室就能通过设备 IP 访问配置页,不用连热点。实现上就是 HTTP 服务器同时监听 STA 和 AP 两个接口,ESP-IDF 默认就支持,不需要额外配置。
踩过的坑里,最深刻的一次是 NVS 分区设太小,设备跑了三个月后突然起不来,串口日志显示 NVS 初始化失败。后来查出来是某个参数被高频写入,把页耗尽了。从那以后我养成了两个习惯:一是 NVS 分区至少给 64KB,二是任何高频写入的数据都不放 NVS,改用内存缓存加定期落盘。这个教训分享出来,希望你别再踩一遍。
最后再分享一个小技巧:调试 NVS 的时候,可以用nvs_tool.py这个脚本(ESP-IDF 自带)直接读取分区内容,不用写代码就能看到里面存了什么。命令是python $IDF_PATH/components/nvs_flash/nvs_partition_tool/nvs_tool.py --partition <分区文件>,配合esptool.py read_flash把 NVS 分区读出来,排查问题非常方便。