☰
ESP32免刷机更新WiFi配置:NVS与内置Web服务器实战
2026/10/30 6:04:28 网站建设 项目流程

1. 从一个真实痛点说起:为什么改个 WiFi 密码要折腾半天

搞过 ESP32 的朋友大概率都经历过这个场景:板子焊好、外壳装好、部署到现场,跑了大半年,突然要换个路由器,或者 WiFi 密码改了。这时候你怎么办?老老实实把设备拆下来,插上 USB 线,打开 Arduino IDE 或者 ESP-IDF,改一行ssid和password,重新编译,重新烧录,再装回去。整个过程少说二十分钟,多的话半天就没了,要是设备装在吊顶里、配电箱里,那更是灾难。

问题的根源在于,绝大多数入门教程里,WiFi 账号密码是硬编码在固件里的字符串常量。编译那一刻,这些值就被写死进了二进制文件,烧进 Flash 之后想改,只能整包重刷。这在实验室里无所谓,一旦到了真实项目,就是纯粹的运维负担。

其实 ESP32 从设计之初就考虑过这个问题。它内置了一块叫NVS(Non-Volatile Storage,非易失性存储)的区域,专门用来存键值对形式的配置数据,掉电不丢。WiFi 凭据、设备参数、校准系数这些东西,本来就该放在 NVS 里,而不是塞进代码。更进一步,ESP32 还能跑一个轻量的 Web 服务器,你用手机浏览器连上它,打开一个页面,输入新的 WiFi 名字和密码,点保存,设备重启后自动连上新网络。整个过程不需要数据线,不需要编译环境,甚至不需要装任何 App。

这篇内容就是围绕这个思路展开的:用浏览器直接修改 ESP32 的 NVS 键值,实现 WiFi 配置的免刷机更新。我会把方案选型、NVS 的底层机制、Web 服务器的搭建、页面与接口的设计、以及实际调试中踩过的坑,全部拆开讲清楚。适合已经能点亮 ESP32、跑通过基础 WiFi 例程,但还没系统接触过 NVS 和内置 Web 服务的开发者。看完你应该能直接把这套东西移植到自己的项目里。

2. 方案整体设计:为什么是 NVS 加内置 Web,而不是别的路子

2.1 三种常见配置方案的横向对比

在动手之前,先把可选路径理清楚。给 ESP32 做运行时配置,业内常见的有这么几种做法,各有各的适用面。

方案实现方式优点缺点适用场景
硬编码 + 重刷固件字符串常量写死在代码最简单,零依赖改配置必须重刷,运维成本极高一次性demo、配置永不变化的场景
SD 卡 / 外部存储读配置配置文件放 TF 卡改配置方便,拔卡即可需要额外硬件,卡座易松动,工业环境不可靠有存储扩展、非密封设备
NVS + 内置 Web 配置页键值存 Flash,网页改无需拆机、无需额外硬件、掉电保存需要写 Web 服务和接口逻辑量产设备、现场部署、密封外壳

从表格能看出来,只要设备是部署到现场、外壳封死的,第三种几乎是唯一合理的选择。它把"改配置"这件事从"物理接触设备"降级成了"网络可达即可",这是质的变化。

2.2 NVS 到底是个什么东西

很多人把 NVS 和 SPIFFS、LittleFS 搞混。简单说,SPIFFS 和 LittleFS 是文件系统,你按文件名读写一整块数据;NVS 是键值数据库,你按 key 读写 value,更像一个极简的 Redis 或者手机里的 SharedPreferences。

NVS 的物理位置在 Flash 的一个独立分区里,默认大小通常是 0x5000(20KB),可以在分区表里调整。它内部把数据分成一个个 32 字节的条目(entry),支持的类型包括:

  • 整数类型:int8_t、uint8_t、int16_t、uint16_t、int32_t、uint32_t、int64_t、uint64_t
  • 字符串:以\0结尾的变长字符串
  • 二进制大对象:BLOB,任意字节流

NVS 的一个关键特性是命名空间(namespace)。你可以理解成数据库里的"表",不同命名空间下的同名 key 互不干扰。比如 WiFi 凭据放wifi_cfg命名空间,设备参数放dev_cfg,互不影响。这个设计在项目变大之后特别有用,不会出现 key 名冲突。

还有一个容易被忽略的点:NVS 有磨损均衡和掉电保护机制。它不是简单地原地覆盖写,而是写到新位置再标记旧条目失效,所以 Flash 的擦写寿命不会被某个频繁写的 key 快速耗尽。这一点对于需要频繁记录运行状态的场景很重要。

2.3 为什么选内置 Web 而不是蓝牙配网

ESP32 支持蓝牙,理论上也能用蓝牙 App 配网。但实际项目里我更倾向 Web 方案,原因有几个。

第一,零客户端依赖。Web 方案只需要一个浏览器,手机、电脑、平板都自带,不用装 App,不用考虑 iOS 和 Android 的兼容性。蓝牙方案你得开发或维护一个 App,成本高得多。

第二,调试直观。Web 页面可以直接在浏览器里看到返回结果,出错了 F12 一看就知道是接口问题还是前端问题。蓝牙的调试链路要长得多。

第三,跨平台一致性。浏览器是标准化的,蓝牙协议栈各家实现差异大,尤其是不同手机厂商对 BLE 的省电策略不一样,经常出现连不上的情况。

当然 Web 方案也有前提:设备得先能被访问到。这就引出了配网模式的设计,后面会详细讲。

2.4 整体架构拆解

把整个方案拆成模块,大概是这么几层:

  • 存储层:NVS,负责持久化 WiFi 凭据和其他配置
  • 网络层:WiFi STA 模式(连路由器)+ AP 模式(自己当热点),二选一或共存
  • 服务层:基于esp_http_server或WebServer库的 HTTP 服务,提供配置页面和读写接口
  • 应用层:前端 HTML 页面,表单提交,AJAX 调用接口

工作流程是这样的:设备上电,先从 NVS 读 WiFi 凭据。读到了就尝试连接,连上后启动 Web 服务,正常干活。读不到或者连不上,就切到 AP 模式,自己发一个热点,用户连上热点后访问配置页面,填入 WiFi 信息,保存到 NVS,设备重启,用新凭据连接。

这个"读不到就进配网模式"的逻辑,是整个方案能落地的关键,它保证了设备永远不会因为配置错误而彻底失联。

3. 核心细节解析:NVS 读写与 Web 服务的实操要点

3.1 NVS 初始化的正确姿势

NVS 用之前必须先初始化,这一步很多人会漏掉错误处理。标准流程是这样:

#include "nvs_flash.h" 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);

这里有个坑必须说清楚:ESP_ERR_NVS_NO_FREE_PAGES表示 NVS 分区写满了,ESP_ERR_NVS_NEW_VERSION_FOUND表示 NVS 数据格式版本不匹配(比如你升级了 IDF 版本)。这两种情况都需要先nvs_flash_erase()擦除整个分区再重新初始化。如果你不做这个判断,直接ESP_ERROR_CHECK(nvs_flash_init()),遇到这两种情况设备会直接重启,而且会陷入重启循环,非常难排查。

注意:nvs_flash_erase()会清空所有 NVS 数据,包括你存的其他配置。所以生产环境里,擦除之后要有重新写入默认配置的逻辑,不能擦完就不管了。

3.2 读写字符串键值的完整代码

NVS 的读写 API 是成对的,写用nvs_set_str,读用nvs_get_str。读的时候有个细节:nvs_get_str需要你先传一个长度参数,第一次调用时传NULL和长度指针,它会告诉你实际需要多大空间。

// 写入 nvs_handle_t handle; esp_err_t err = nvs_open("wifi_cfg", NVS_READWRITE, &handle); if (err != ESP_OK) { /* 处理错误 */ } err = nvs_set_str(handle, "ssid", "MyRouter"); err = nvs_set_str(handle, "password", "12345678"); err = nvs_commit(handle); // 必须 commit 才真正落盘 nvs_close(handle); // 读取 size_t len = 0; err = nvs_get_str(handle, "ssid", NULL, &len); // 先问长度 if (err == ESP_OK) { char *ssid = malloc(len); nvs_get_str(handle, "ssid", ssid, &len); // 使用 ssid free(ssid); }

nvs_commit这一步绝对不能省。nvs_set_str只是把数据写进内存缓存,nvs_commit才真正刷到 Flash。我见过有人写完不 commit,重启后数据全没了,查了半天以为是 Flash 坏了。

3.3 Web 服务器的选型:WebServer 还是 esp_http_server

ESP32 上跑 HTTP 服务有两条路。一条是 Arduino 环境下的WebServer库,封装好,上手快;另一条是 ESP-IDF 原生的esp_http_server,更底层,控制力强。

如果你用 Arduino IDE 开发,直接上WebServer,几行代码就能起服务:

#include <WebServer.h> WebServer server(80); void setup() { server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); } void loop() { server.handleClient(); }

如果你用 ESP-IDF,那就用esp_http_server,注册 URI handler:

httpd_uri_t save_uri = { .uri = "/save", .method = HTTP_POST, .handler = save_handler, .user_ctx = NULL }; httpd_register_uri_handler(server, &save_uri);

选哪个取决于你的开发环境,功能上都能满足需求。Arduino 的WebServer在处理并发请求时能力弱一些,但配网场景下同时只有一个用户在操作,完全够用。

3.4 前端页面的极简设计

配置页面不需要花哨,核心就是一个表单加一个提交按钮。但有几个细节要注意。

第一,密码框要用type="password",避免明文显示。第二,提交用 AJAX 而不是表单直接 POST,这样页面不用跳转,能原地显示"保存成功"的提示,体验好很多。第三,页面要自适应手机屏幕,加一行 viewport meta 标签。

<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> body { font-family: sans-serif; padding: 20px; max-width: 400px; margin: auto; } input { width: 100%; padding: 10px; margin: 8px 0; box-sizing: border-box; } button { width: 100%; padding: 12px; background: #2c7; color: #fff; border: none; } </style> </head> <body> <h2>WiFi 配置</h2> <input id="ssid" placeholder="WiFi 名称"> <input id="pwd" type="password" placeholder="WiFi 密码"> <button onclick="save()">保存并重启</button> <p id="msg"></p> <script> function save() { fetch('/save', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'ssid=' + encodeURIComponent(document.getElementById('ssid').value) + '&pwd=' + encodeURIComponent(document.getElementById('pwd').value) }).then(r => r.text()).then(t => { document.getElementById('msg').innerText = t; }); } </script> </body> </html>

这段代码可以直接用,改改样式就能上线。注意encodeURIComponent,WiFi 密码里如果有&、=、+这些特殊字符,不编码会导致后端解析出错,这是个高频坑。

3.5 后端接口的参数解析

后端收到 POST 请求后,要把ssid和pwd从 body 里解析出来。Arduino 的WebServer库提供了server.arg("ssid")直接取值,很方便。ESP-IDF 下需要自己解析 body,或者用httpd_req_recv读出来再手动分割。

void handleSave() { String ssid = server.arg("ssid"); String pwd = server.arg("pwd"); if (ssid.length() == 0) { server.send(200, "text/plain", "SSID 不能为空"); return; } save_wifi_to_nvs(ssid.c_str(), pwd.c_str()); server.send(200, "text/plain", "保存成功,设备将在 3 秒后重启"); delay(3000); ESP.restart(); }

这里delay(3000)是为了让 HTTP 响应先发出去,再重启。如果直接ESP.restart(),响应可能还没发完设备就重启了,浏览器会显示连接失败,用户以为没保存成功。

4. 完整实操流程:从零搭一个可用的配置系统

4.1 分区表的确认与调整

NVS 分区在分区表里定义。默认的单应用分区表(partitions_singleapp.csv)里已经包含了一个 NVS 分区,大小 0x5000。如果你要存的东西比较多,比如还要存证书、日志,可以调大。

# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x5000, phy_init, data, phy, 0xe000, 0x1000, factory, app, factory, 0x10000, 1M,

改完分区表要重新编译烧录,注意nvs分区的 offset 和前面 bootloader、分区表本身不能重叠。0x9000 是默认起始,前面 0x9000 字节留给 bootloader 和分区表。

4.2 配网模式的触发逻辑

设备启动后的判断逻辑,是整个系统的核心。我一般这么写:

void setup() { Serial.begin(115200); init_nvs(); String ssid = read_nvs_str("wifi_cfg", "ssid"); String pwd = read_nvs_str("wifi_cfg", "password"); if (ssid.length() > 0) { WiFi.begin(ssid.c_str(), pwd.c_str()); int retry = 0; while (WiFi.status() != WL_CONNECTED && retry < 20) { delay(500); retry++; } } if (WiFi.status() == WL_CONNECTED) { start_normal_server(); // 正常业务 + 配置页 } else { start_ap_config(); // 进入 AP 配网模式 } }

retry < 20意味着最多等 10 秒。这个时间要权衡:太短,路由器启动慢的时候连不上;太长,用户等得着急。10 秒是个比较舒服的值。

4.3 AP 配网模式的实现

AP 模式下,ESP32 自己发一个热点,SSID 可以设成设备型号加 MAC 后几位,方便区分多台设备。

void start_ap_config() { WiFi.mode(WIFI_AP); String ap_ssid = "ESP32_Config_" + String((uint32_t)ESP.getEfuseMac(), HEX); WiFi.softAP(ap_ssid.c_str(), "12345678"); // 默认密码 Serial.println("AP IP: " + WiFi.softAPIP().toString()); start_config_server(); }

AP 的默认密码建议设一个简单的,比如12345678,因为用户连上就是为了改配置,不需要太强的安全性。但如果你在公共场合部署,建议改成设备序列号之类的,避免被路人连上乱改。

4.4 保存后重启的完整链路

用户点保存,后端写 NVS,然后重启。重启后设备从 NVS 读到新凭据,连上新 WiFi,启动正常服务。这条链路要保证每一步都有反馈。

步骤操作预期结果异常处理
1用户提交表单浏览器发出 POST前端校验非空
2后端解析参数拿到 ssid/pwd参数缺失返回错误
3写入 NVSnvs_commit成功失败返回错误码
4返回响应浏览器显示"保存成功"延迟 3 秒再重启
5设备重启用新凭据连接连不上则回 AP 模式

这个表格里的每一步都要有日志输出,方便现场排查。尤其是第 3 步,NVS 写失败的原因可能是分区满了、Flash 坏了,日志里要能看出来。

4.5 正常模式下的配置页保留

设备连上 WiFi 之后,配置页不应该关掉。万一用户想改 WiFi,还得能访问。所以正常模式下,Web 服务器继续提供/config页面,只是这时候设备已经有 IP 了,用户通过 IP 访问即可。

void start_normal_server() { server.on("/", handleBusiness); // 业务接口 server.on("/config", handleConfigPage); // 配置页 server.on("/save", HTTP_POST, handleSave); server.begin(); Serial.println("Config page: http://" + WiFi.localIP().toString() + "/config"); }

把配置页的 URL 打印到串口,用户第一次部署时记下来,以后就能直接访问。如果设备有屏幕,也可以显示在屏幕上。

5. 常见问题与排查技巧实录

5.1 NVS 写入失败的那些原因

NVS 写失败最常见的原因是分区满了。NVS 的条目是 32 字节一个,字符串会占用多个条目。如果你频繁写入不同的 key,或者写入很长的字符串,分区很快会满。解决办法有两个:一是调大分区,二是定期清理不用的 key。

第二个原因是命名空间没打开成功。nvs_open如果返回ESP_ERR_NVS_NOT_FOUND,说明命名空间不存在,需要先用NVS_READWRITE模式打开,它会自动创建。

第三个原因是Flash 操作冲突。如果 NVS 写入的时候正好有别的任务在操作 Flash(比如 OTA 升级),会返回ESP_ERR_NVS_NOT_ENOUGH_SPACE或者超时。这种情况要用互斥锁保护。

5.2 浏览器访问配置页打不开

AP 模式下访问192.168.4.1打不开,先检查三件事。第一,手机是不是真的连上了 ESP32 的热点,有些手机会自动切回原来的 WiFi。第二,ESP32 的 AP 模式是不是启动成功了,串口有没有打印 IP。第三,防火墙或者浏览器的安全策略有没有拦截,换个浏览器试试。

还有一个隐蔽的坑:AP 模式下 DHCP 分配 IP 慢。有些手机连上热点后要等十几秒才能拿到 IP,这时候访问 192.168.4.1 是打不开的。解决办法是在页面上加个提示,或者让用户手动设置静态 IP。

5.3 保存后连不上新 WiFi

这是最让人头疼的问题。用户填了新的 WiFi 信息,保存重启,结果设备连不上,又回到 AP 模式。原因通常有几个:

  • SSID 或密码填错了:这个最常见,前端加个"显示密码"的勾选框能减少误输入。
  • WiFi 是 5GHz 的:ESP32 只支持 2.4GHz,如果路由器只开了 5G,连不上。这个要在页面上明确提示。
  • 路由器开了 MAC 过滤:设备被拉黑了,连不上。
  • 信号太弱:设备离路由器太远,握手失败。

排查的时候,串口日志是关键。WiFi.begin之后打印WiFi.status(),能看到具体卡在哪一步。如果一直返回WL_NO_SSID_AVAIL,说明没找到这个 SSID;如果返回WL_CONNECT_FAILED,说明密码错了或者被拒绝。

5.4 常见问题速查表

现象可能原因排查方法解决方式
NVS 写入返回错误分区满 / 未 commit打印esp_err_t调大分区 / 补 commit
AP 页面打不开未连热点 / DHCP 慢检查手机 WiFi / 串口 IP手动设静态 IP
保存后连不上SSID 错 / 5G / MAC 过滤串口看WiFi.status()核对信息 / 换 2.4G
重启后配置丢失未 commit / 分区被擦读 NVS 验证补 commit / 检查擦除逻辑
页面样式错乱浏览器缓存强制刷新加版本号或禁用缓存

5.5 几个我踩过的坑

第一个坑:NVS 的 key 长度限制。NVS 的 key 最长 15 个字符,超过会返回ESP_ERR_NVS_KEY_TOO_LONG。我一开始用了wifi_password这种长名字,结果写不进去,查了半天才发现是长度问题。后来改成pwd就好了。

第二个坑:字符串里的中文。NVS 存字符串是按字节存的,中文占多个字节,读取的时候长度要算对。如果 SSID 里有中文(有些路由器默认 SSID 带中文),要确保编码一致,否则会乱码。

第三个坑:AP 模式和 STA 模式切换。ESP32 可以同时开 AP 和 STA,但功耗会高一些。如果设备是电池供电的,配网完成后要记得关掉 AP 模式,只留 STA。

第四个坑:Web 服务器的并发。Arduino 的WebServer是单线程的,如果用户在页面加载过程中又点了保存,可能会卡住。解决办法是在保存的时候禁用按钮,等响应回来再启用。

6. 进阶玩法:把这套机制用到更多场景

6.1 不止 WiFi,任何配置都能这么改

NVS 加 Web 这套机制,本质上是一个通用的"运行时配置系统"。WiFi 只是最典型的应用,其他参数一样可以这么搞。比如:

  • MQTT 服务器地址和端口:换个 broker 不用重刷
  • 设备名称和分组:现场部署时批量配置
  • 采样间隔和阈值:根据实际工况调整
  • 校准系数:传感器换批次后重新标定

把这些参数都做成配置页上的表单项,后端统一写 NVS,设备重启后读取。这样一个固件可以适配多种部署场景,不用为每个客户单独编译。

6.2 配置项的版本管理

当配置项变多之后,要考虑版本兼容。比如 v1 固件只有ssid和pwd,v2 固件增加了mqtt_host。如果用户从 v1 升级到 v2,NVS 里没有mqtt_host,读取的时候要能处理"key 不存在"的情况,给个默认值。

String read_nvs_str_or_default(const char* ns, const char* key, const char* def) { nvs_handle_t h; if (nvs_open(ns, NVS_READONLY, &h) != ESP_OK) return String(def); size_t len = 0; if (nvs_get_str(h, key, NULL, &len) != ESP_OK) { nvs_close(h); return String(def); } char* buf = malloc(len); nvs_get_str(h, key, buf, &len); String result(buf); free(buf); nvs_close(h); return result; }

这个函数封装了"读不到就用默认值"的逻辑,所有配置项都走它,代码会干净很多。

6.3 安全性的一点考虑

配置页如果暴露在公网上,任何人都能改你的 WiFi 密码,这显然不行。几个基本的防护措施:

  • AP 模式加密码:默认密码不要用12345678,用设备序列号或者随机生成。
  • 配置页加简单认证:HTTP Basic Auth,用户名密码写在代码里,或者存 NVS。
  • 限制访问来源:只允许内网 IP 访问配置页,公网请求直接拒绝。
  • 保存操作加确认:前端弹个确认框,避免误触。

这些措施不能防住专业攻击,但能挡住 99% 的误操作和路人。对于大多数物联网设备来说,够用了。

6.4 和 OTA 升级的配合

配置系统和 OTA 升级是天生一对。设备部署到现场后,固件要升级,配置要保留。OTA 升级只替换 app 分区,NVS 分区不动,所以升级后配置还在。但要注意,如果新固件的配置项结构变了,升级后要做一次迁移。

我一般的做法是:NVS 里存一个cfg_version的整数,固件启动时读出来,如果小于当前版本,就执行迁移逻辑,迁移完更新cfg_version。这样无论用户从哪个版本升上来,配置都能正确转换。

6.5 一个完整的项目结构参考

最后给一个我常用的项目结构,供参考:

project/ ├── src/ │ ├── main.cpp # 入口,配网逻辑 │ ├── nvs_helper.cpp # NVS 读写封装 │ ├── web_server.cpp # HTTP 服务和接口 │ └── wifi_manager.cpp # WiFi 连接和 AP 模式 ├── data/ │ └── index.html # 配置页面 ├── partitions.csv # 分区表 └── platformio.ini # 构建配置

把 NVS、Web、WiFi 分成独立的模块,每个模块只暴露必要的接口,主逻辑就清晰了。以后要加新的配置项,只需要在nvs_helper里加读写函数,在web_server里加接口,在页面上加表单项,三处改动,互不影响。

这套东西我从几年前开始用,陆续迭代了好几个版本,现在基本成了我所有 ESP32 项目的标配。最开始只是为了省去拆机重刷的麻烦,后来发现它带来的灵活性远超预期——设备部署出去之后,参数调整、功能开关、甚至简单的逻辑变更,都能通过配置页搞定,不用再跑现场。如果你还在用硬编码的方式管理 WiFi 配置,真的建议花半天时间把这套机制搭起来,后面省下的时间远不止半天。

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

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

立即咨询