改个WiFi密码就得把设备拆开、接串口、重新编译烧录,这种窝囊事我干过不止一次。说实话,设备只有一块ESP32、代码也没啥大改动,纯粹为了改两个字符就要折腾大半天,太不值了。后来我仔细翻了ESP-IDF的文档才反应过来——WiFi配置根本就没烧死在固件里,它住在NVS(Non-Volatile Storage,非易失性存储)分区里,就是一块以键值对形式保存数据的Flash区域。既然密码只是NVS里的两个键值,那改密码根本用不着动固件,直接在浏览器里访问设备内置的配置页面,把NVS里的“sta.ssid”和“sta.password”改掉就能搞定。
这篇文章就把我做的这个浏览器配置工具完整梳理一遍,重点讲清楚实现原理、代码结构、实操步骤和那些坑。适合手里一堆ESP32、经常要去现场调WiFi参数的人;做物联网产品、想让设备部署后还能被非技术同事改配置的开发者;以及单纯想搞懂NVS分区到底怎么玩的学习者。无论你用ESP-IDF还是Arduino框架,核心思路都一样,可以直接复用。
1. 改个密码就要重刷固件?问题出在哪儿
1.1 密码不在固件里,它在NVS分区里
不少初学者的第一反应是:WiFi密码不是写死在代码里的吗?确实,很多示例代码里会把SSID和密码直接硬编码,比如常见的#define WIFI_SSID "MyHome",那只是“源码里写死了”。但实际运行时,如果你的代码调用的是esp_wifi_set_config(),并且没有指定WIFI_STORAGE_RAM,ESP-IDF默认会把WiFi配置写入Flash里的NVS分区。也就是说,你烧进芯片的固件里并没有这个密码,真正保存密码的是NVS。
那NVS到底是个什么东西?ESP32的Flash会被分区表分成多个块,常见的有nvs、phy_init、factory或其他app分区。NVS就是其中一个专门用来存键值对的分区。它的定位有点像单片机上的EEPROM,但底层是基于Flash实现的,读取和写入都有自己的一套API。你可以把它理解成一个迷你版的小型数据库,程序运行过程中可以随时往里面存数据,断电后数据也不丢失。ESP32的WiFi协议栈会在这个区域里保存STA模式下的SSID和密码,还会保存校准数据、配网结果等很多运行期参数。
所以重刷固件为什么能改密码?因为重刷后如果代码里写死了新的SSID和密码,系统启动时会用新的配置覆盖NVS里的旧值。但这其实是“用杀牛刀杀鸡”——明明只需要涂改NVS里两条记录,非要把整本书重印一遍。而且重刷固件还牵涉到编译环境、串口驱动、接线、断电风险等一系列破事,现场调试时特别烦人。
1.2 浏览器方案为什么比串口方案更实用
可能有人会说,不想重刷固件,那我用串口工具连上去改NVS不就行了?确实可以,而且很多专业设备就是这么做的——通过UART命令行或厂商专用上位机来修改配置。但这套做法对普通用户来说门槛偏高:你得知道设备当前的波特率、得有一根USB转TTL线、得会驱动CH340或CP2102、得打开某个串口调试工具,还得理解命令格式。
现场场景往往更苛刻。设备可能装在天花板、机柜或者某个角落,预留的接口可能只有电源,根本没有调试串口。就算把设备拆下来,你可能手头也没带电脑,只有一部手机。这时候如果设备自己开了一个WiFi热点,你用手机浏览器连上去,打开一个网页就能改密码,这个体验差距就不是“一点点”了。这也是很多量产物联网模组默认自带Web配网功能的原因——不是厂商懒得做桌面工具,而是Web配网对用户学习成本最低、对部署环境要求最少。
所以当我做这个浏览器工具时,目标就很明确:设备本身内置一个轻量HTTP服务器,开机后如果没有成功连上现有WiFi,就自动进入AP配置模式,放出热点;用户连上热点后在浏览器里改SSID和密码,提交后设备写入NVS,然后重启并尝试连接新WiFi。整个过程不依赖电脑、不依赖UART线、不需要重新编译。这个方案的核心不是多高深的技术,而是一个很朴素的思路——把NVS当配置中心,把浏览器当操作界面。
2. 整体方案设计:一个很朴素的NVS键值编辑器
2.1 先搞懂NVS键值存储的基本原理
NVS的核心模型就是“命名空间 + 键值对”。命名空间类似于分组逻辑,你可以开一个名为wifi的命名空间,在里面放sta.ssid、sta.password、sta.power这些键;也可以开一个名为sensor的命名空间,放threshold、interval等参数。这样各个模块之间的数据不会互相干扰。
用ESP-IDF操作NVS的流程一般是这样:先初始化Flash与NVS子系统,再打开某个命名空间获得一个句柄,接着用nvs_get_str、nvs_set_str、nvs_set_i32等API读写具体键值,所有写入操作最后要通过nvs_commit提交才会真正落到Flash里。要注意的是,NVS的键长度最长15个字符,值类型支持整数、字符串、Blob等,字符串长度上限取决于分区大小和剩余空间。大多数情况下,存WiFi凭据这种字符串绰绰有余。
为什么这个机制重要?因为它决定了我们“改密码”这件事的本质。所谓改WiFi密码,其实就是执行三步:打开命名空间、写入sta.password这个键、提交。和修改EEPROM里的一个字节没有本质区别。但WiFi生效还差一步——你得让协议栈重新读取NVS里的新配置,所以修改后要么重启系统,要么手动调用esp_wifi_set_config。很多初学者改完NVS发现没效果,就是漏掉了这一步“通知WiFi驱动去读新值”。
2.2 方案选型:UART工具、手机App、还是内置Web?
动手之前我列了几种可选实现方案,简单对比一下:
| 方案 | 优点 | 缺点 | 使用场景 |
|---|---|---|---|
| UART命令行工具 | 实现简单,调试方便 | 需要连线、需要专业工具,现场极不友好 | 开发期调试 |
| 手机BLE App | 无需连线,体验现代 | 需要额外开发App,iOS权限麻烦 | 面向消费者的高端配置 |
| 内置Web Server | 无需连线,手机电脑都能用,学习成本低 | 代码量略多,需注意服务器安全 | 最适合快速改NVS、现场维护 |
我做这个工具选了第三项。理由很现实:做一套网页,无非就是GET一个HTML表单、再写一个handler解析用户提交的参数,标准库自带esp_http_server组件,不需要额外移植第三方库。而如果做一个BLE App,你得处理MTU、服务UUID、连接管理一堆事,纯粹为了改密码有点重;做UART命令行工具倒是轻松,但使用对象如果有非研发人员,他们根本不想碰串口终端。
顺带一提,内置Web Server并不是什么新概念。很多路由器、网络摄像头、智能插座都有一个本地管理页面,原理都是设备自己跑一个轻量HTTP服务。你在ESP32上做的这套东西其实就是一个迷你路由器管理界面,只是管理对象从网络设置变成了NVS键值。
2.3 整体架构与工作流程
整个工具的运行流程可以分成两条线,一条是启动时的状态判断,一条是HTTP请求处理。
启动时:系统先初始化NVS,再尝试读取已保存的SSID和密码。如果读取成功,就进入STA模式连接原来的WiFi;如果读取失败或连接超时,就进入AP模式,开放一个名称为ESP32-WebConfig的热点。AP模式下HTTP服务器启动,用户通过浏览器访问192.168.4.1。
HTTP请求处理:浏览器打开根路径/时,服务器返回一个配置表单,表单里预填当前NVS中的SSID和密码(密码可以回显,方便确认当前配置)。用户填写新值提交到/save,服务器解析参数,把SSID和密码写入NVS,提交后延时几百毫秒再调用esp_restart()重启设备。重启后设备用新配置去连接WiFi,如果连上了就开心工作;连不上则会重新退回到AP配置模式,让你再试一次。
数据流设计上有个注意点:我把“读取NVS键值”和“让WiFi生效”分成了两个独立逻辑块。读取NVS靠nvs_open和nvs_get_str,只在需要回显表单时调用;而WiFi生效靠esp_wifi_set_config或重启。这样分层的思路也方便以后扩展,比如增加其他环境参数的NVS配置项时,改起来不会牵一发动全身。
3. 核心代码实现:给ESP32装一个能改NVS键值的Web页面
3.1 NVS读写的基础API
先看最底层的NVS操作。初始化NVS时我通常写成这样:
#include <stdio.h> #include <string.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include "nvs_flash.h" #include "nvs.h" #include "esp_wifi.h" #include "esp_event.h" #include "esp_netif.h" #include "esp_http_server.h" static const char *TAG = "nvs_config"; void nvs_init(void) { esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_LOGW(TAG, "NVS need erase, erasing now..."); ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } ESP_ERROR_CHECK(err); }之所以要判断ESP_ERR_NVS_NO_FREE_PAGES,是因为有时候NVS分区里旧数据占满了页面,或者固件升级后NVS布局版本变了,直接初始化会失败。这种时候要先清一次分区再初始化。注意这个erase操作会把所有NVS数据都清掉,如果你的设备还有其他业务数据存在NVS里,一定得谨慎,不能上来就无脑erase。
真正读写键值是这几个API:
esp_err_t nvs_get_sta_config(char *ssid, size_t *ssid_len, char *password, size_t *pass_len) { nvs_handle_t handle; if (nvs_open("wifi", NVS_READONLY, &handle) != ESP_OK) return ESP_FAIL; esp_err_t err = nvs_get_str(handle, "sta.ssid", ssid, ssid_len); if (err == ESP_OK) { err = nvs_get_str(handle, "sta.password", password, pass_len); } nvs_close(handle); return err; } esp_err_t nvs_set_sta_config(const char *ssid, const char *password) { nvs_handle_t handle; if (nvs_open("wifi", NVS_READWRITE, &handle) != ESP_OK) return ESP_FAIL; ESP_ERROR_CHECK(nvs_set_str(handle, "sta.ssid", ssid)); ESP_ERROR_CHECK(nvs_set_str(handle, "sta.password", password)); ESP_ERROR_CHECK(nvs_commit(handle)); nvs_close(handle); return ESP_OK; }有几个细节值得注意。第一,nvs_open的第二个参数区分为只读和读写,读取配置时用只读就能避免误操作;写入时必须NVS_READWRITE。第二,所有写入操作要显式调用nvs_commit,不然数据只出现在内存缓存里,没落到Flash。第三,键名sta.ssid里的点号是合法的,它只是普通字符,不代表层级关系。第四,字符串缓冲区长度要提前给定,因为nvs_get_str会把实际长度写入参数,如果缓冲区不够会返回ESP_ERR_NVS_INVALID_LENGTH。
3.2 HTTP服务器与表单处理
ESP-IDF自带esp_http_server组件,用它起一个HTTP服务器非常省事。核心代码分三块:注册URI handler、定义GET handler放页面、定义POST handler接表单数据。我为了简化操作,让HTML表单用了GET方式提交,因为这样参数都在URL里,解析起来最直接。
启动服务器的代码:
void start_web_config_server(void) { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 8192; config.max_uri_handlers = 8; httpd_handle_t server = NULL; if (httpd_start(&server, &config) == ESP_OK) { httpd_uri_t root_uri = { .uri = "/", .method = HTTP_GET, .handler = config_get_handler, .user_ctx = NULL }; httpd_register_uri_handler(server, &root_uri); httpd_uri_t save_uri = { .uri = "/save", .method = HTTP_GET, .handler = save_get_handler, .user_ctx = NULL }; httpd_register_uri_handler(server, &save_uri); } }这里config.stack_size我设成8192,因为处理HTTP请求时会有一定的局部变量和缓冲区需求,用默认的4096偶尔会栈溢出,尤其是解析URL和读取NVS字符串时。别小看这个配置,我最早直接在默认栈上声明了一个512字节的URL buffer都没问题,后来加了几个临时数组就崩了,扩到8192之后整个世界都清净了。
GET根路径的handler返回一个内嵌HTML页面:
static esp_err_t config_get_handler(httpd_req_t *req) { char ssid[32] = {0}; char password[64] = {0}; size_t ssid_len = sizeof(ssid); size_t pass_len = sizeof(password); esp_err_t err = nvs_get_sta_config(ssid, &ssid_len, password, &pass_len); if (err != ESP_OK) { ssid[0] = 0; password[0] = 0; } char html[1024]; snprintf(html, sizeof(html), "<html>" "<head><meta name='viewport' content='width=device-width, initial-scale=1'></head>" "<body>" "<h2>ESP32 WiFi Config</h2>" "<form action='/save' method='get'>" "SSID: <input name='ssid' value='%s'><br><br>" "Password: <input name='password' type='password' value='%s'><br><br>" "<input type='submit' value='Save and Reboot'>" "</form>" "</body>" "</html>", ssid, password); httpd_resp_set_type(req, "text/html"); httpd_resp_send(req, html, strlen(html)); return ESP_OK; }保存参数的handler是这样:
static esp_err_t save_get_handler(httpd_req_t *req) { char url[256]; char ssid[33] = {0}; char password[65] = {0}; if (httpd_req_get_url_query_str(req, url, sizeof(url)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad query"); return ESP_OK; } httpd_query_key_value(url, "ssid", ssid, sizeof(ssid)); httpd_query_key_value(url, "password", password, sizeof(password)); if (strlen(ssid) == 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "ssid empty"); return ESP_OK; } nvs_set_sta_config(ssid, password); const char *resp = "<html><body><h2>Saved, rebooting...</h2></body></html>"; httpd_resp_send(req, resp, strlen(resp)); vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); return ESP_OK; }这里有一个非常关键的点:在调用esp_restart()之前,一定要给HTTP响应一个发送的机会。因为我用httpd_resp_send同步发送,如果发送完马上重启,内核不一定来得及把数据真正推给WiFi协议栈,浏览器端就会看到连接被重置,页面显示不出来。我加了一个500毫秒的vTaskDelay,实测很稳。更讲究一点的做法是设置一个标志位,在后台任务里延时后再重启,但500毫秒延迟足够解决大多数浏览器,没必要搞复杂。
3.3 写入后如何让WiFi配置真正生效
写NVS只是把键值存进了Flash,不代表WiFi协议栈立刻就知道。让配置生效有两种路径。
路径一是重启整个系统。esp_restart()之后,应用从app_main重新跑,初始化WiFi时读取NVS里的新配置,自然就按新密码连接。这是最简单也最安全的方式,代价是重启会造成几秒钟中断连接。在一个改配置场景里,这个中断完全可接受。
路径二是运行时调用esp_wifi_set_config。这种方式不用重启,在代码里构造一个wifi_config_t结构体,填充SSID和密码,然后调用:
wifi_config_t wifi_config = {0}; strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid)); strncpy((char *)wifi_config.sta.password, password, sizeof(wifi_config.sta.password)); esp_wifi_set_config(WIFI_IF_STA, &wifi_config);之后协议栈会重新请求DHCP并连接新网络。这个方案速度快、不影响其他任务运行,但有个隐患:如果你的设备正在AP模式下给浏览器提供配置页面,而这个页面本身依赖WiFi API,直接改STA配置可能造成底层状态矛盾。实测下来表现是“改完偶尔能连上,偶尔WiFi驱动报错”。所以我的工具选择了简单粗暴的重启方式。重启后如果新网络连不上,设备又会自动回到AP模式,用户能再次进入配置页继续尝试,这个回退逻辑反而提高了容错率。
3.4 前端页面:只用一个字符串的极简方案
我见过有人为了ESP32的Web配置页搞了一整套Vue/React打包流程,最后生成几百KB的JS文件塞进SPIFFS,在我看来完全没有必要。这个工具的核心场景就是输入两个字段点一个按钮,内联HTML字符串足够。
上面的例子已经展示了页面代码。注意两点:一是viewport标签,不加的话手机上打开页面会显示成特别小的桌面版,点输入框还要放大半天,用户体验很差;二是密码输入框用了type='password',同时回显当前密码,这样用户看到旧密码才能确认当前连的是哪个WiFi,不然现场很容易忘记密码。
我用的HTML页面里没有引入任何CSS框架,也没有JavaScript校验。为什么不加JS?因为ESP32的HTTP服务器对并发连接支持有限,如果你再加入一堆AJAX、动态刷新,很容易触发服务器处理不过来。而且配置界面本身只在配置模式下使用,几分钟就完事,没必要做得花里胡哨。一个极简页面换来的是低资源消耗和高稳定性,我挺满意这种取舍。
4. 实操过程与避坑实录
4.1 从零部署这个工具的全过程
我的开发环境是ESP-IDF 5.x。你完全可以用任何你熟悉的版本,核心API差别不大。部署步骤我整理成一条清晰路线:
- 创建工程,拷贝上面的代码进
main.c。 - 检查
menuconfig里的Flash大小和分区表设置。默认的factory分区表完全够用,NVS分区不用特意扩大,因为只存两个键值,默认大小绰绰有余。 - 编译烧录:
idf.py build flash monitor。 - 第一次上电,板子读不到NVS里的WiFi配置,会进入AP模式,热点名是我代码里指定的
ESP32-WebConfig,密码无或设成12345678,根据你代码决定。 - 手机或电脑连接这个热点,浏览器打开
192.168.4.1,输入家里路由器的SSID和密码,点击提交。 - 设备重启,WPS灯或串口日志显示它成功连上了路由器。
这套流程跑通之后,我日常给设备改密码的步骤从“开电脑+接串口+重编译+烧录”缩减成“掏手机+连热点+开网页+改字段”,大概20秒搞定。说句实在话,我第一次现场用的时候真的有一种“这工具造晚了”的懊悔感。
项目里我还做了一块板子,上面有指示WiFi状态的LED:常亮表示连上路由器,闪烁表示正在连接,熄火表示处于配置模式。这个LED状态纯属个人需求,但它能帮你在现场快速判断设备当前处于什么状态,建议有动手能力的都加一个。
4.2 我踩过的几个坑
第一个坑是NVS键值写进去之后,重启发现又是老配置。排查了半天,发现是代码里有两处调用esp_wifi_set_config的地方,一处是读NVS的值,另一处是写死的默认值,初始化顺序里写死的覆盖了NVS的值。这个问题的教训是:初始化WiFi配置时,必须明确指定存储介质为Flash,并且把“读NVS配置”和“回退默认配置”两条路径分开,不能混在一起。
第二个坑是密码里包含&、#这类特殊字符导致解析失败。HTML表单GET方式提交时,参数会被URL编码,比如空格变成%20,&变成%26。如果直接按字符串截取逻辑解析,密码里一旦有&就会被当成参数分隔符,后半截密码全丢了。解决办法是用httpd_query_key_value这种现成API,它会自动做URL解码,或者你在后端自己写解码函数。我现在虽然用了现成API,但在前端页面上还是加了一行提示,让用户尽量避免用特殊字符,至少能少踩一个坑。
第三个坑是Flash写入次数,“NVS磨损”是一个真实存在的东西。Flash的擦写寿命虽然有几万到十万次,但你在配置页里频繁改参数、反复重启,NVS写入了很多中间数据。当然我正常使用完全不用担心,可如果把这个工具交给不懂事的人反复点保存,还是有可能加速Flash老化。后来我在代码里增加了“写入前比较新旧值”的逻辑,如果新值和NVS里存的旧值完全相同,就直接跳过写入和commit,既省Flash寿命又省时间。这个优化很小,但对工业环境长期运行有实打实的好处。
第四个坑是HTTP服务器栈溢出。我前面提过config.stack_size要调到8192,这里再强调一下。ESP32的HTTP server创建任务时,如果栈不够,表现不是崩溃而是随机重启或httpd_resp_send发送失败。这类问题很难查,因为日志里没有明显的段错误。我排查的时候一度以为WiFi信号问题,后来把栈调到8192才稳定下来。
5. 常见问题与排查技巧
5.1 改了键值,WiFi还是连不上
遇到最多的情况就是:在配置页里明明输入了新WiFi的密码,设备重启后却始终找不到路由。先别急着怀疑代码,按下面几个点排查:检查密码是否带空格,很多人在手机上输入密码时末尾多了一个空格,肉眼看不见,但NVS字符串会原样存进去;检查路由器SSID是否隐藏,sta.ssid存放的是精确名称,隐藏SSID的网络可能需要额外配置;检查设备有没有真的进入STA模式,如果启动时读取NVS失败,设备会一直停在AP模式,你看到的“连不上”实际是“没去连”。
最快的排查手段是看串口日志。我在初始化WiFi的代码里加了一行ESP_LOGI,打印当前读取到的SSID和密码长度(注意别把密码明文打出来,只打长度),一眼就能判断NVS里的值是否正确。日志里如果显示ssid_len=0,说明NVS读取失败或根本没写入;如果显示长度正常但连接失败,那问题基本出在密码内容或路由器侧。
5.2 NVS初始化失败或键值写不进去
NVS最多被芯片当一块独立的Flash区域操作,它有自己的初始化逻辑。如果你看到nvs_flash_init返回ESP_ERR_NVS_NO_FREE_PAGES,大概率是之前擦除不干净或分区表设置有问题。处理方式就是我代码里写的那样:nvs_flash_erase()之后再初始化。但要注意,erase会清掉所有NVS数据,不仅是你自己的键值,还包括WiFi校准数据等系统数据。清理后系统会重新校准,这是正常现象。
写入失败的另一个原因是键的长度超限。NVS键名最多15个字符,如果命名太长会报ESP_ERR_NVS_KEY_TOO_LONG。我见过有人把键名写成"WiFi_AP_STA_SSID_Password_Value"这种超长命名,结果存不进去。解决方法是把键缩短,比如sta.ssid和sta.password,语义清晰又满足长度限制。如果确实需要存大量数据,应该考虑用Blob类型或单独的文件系统分区,不要硬塞在NVS里。
5.3 安全提醒:配置页面不能随便露
这个浏览器工具本质是个不加密的HTTP服务,任何人都能通过一个网页看到你的WiFi配置,而且密码还是明文回显在输入框里的。如果你把这个工具长期跑在设备上,并且设备连着一个公共网络或开放热点,那就等于把WiFi密码摊开给人看。
我的做法是:只有设备处于AP配置模式时,才启动HTTP服务器。一旦设备成功连上工作网络,就立刻关闭AP和HTTP服务。这样不同时存在“工作网络+配置服务”的局面,风险就小了一大截。如果以后你想在设备正常工作状态下也从浏览器改配置,那至少要加一个认证密码,最好再加上HTTPS。但说实话,在ESP32上弄HTTPS证书又是一堆事,目前我觉得“只在配置模式开启”这个策略最划算。
最后再分享一个小技巧
这个浏览器工具做到后面,我给它扩展了不少NVS键值项,除了WiFi凭据,还加入了设备名称、上报间隔、传感器阈值等配置项,整个配置页可以一次读取和写入多个键值。其实NVS本来就可以存各种业务参数,只要把HTTP表单和NVS读写做成类似“键值对照表”的结构,以后每加一个配置项,前端加一个输入框,后端加一次get/set就可以。底层的NVS读写逻辑和HTTP服务器完全不用动。
我在实际使用中还养成了一个习惯:改完配置后不急着断开设备热点,先看串口日志确认重启后成功连上目标WiFi再离开。别小看这一步,它能帮你把很多“现场手忙脚乱找路由器热点名”的尴尬提前消化掉。如果你准备给手头的ESP32项目做这个工具,建议先在一台开发板上跑通全流程,再考虑是不是要移植到正式项目里,毕竟NVS擦除和分区表动作虽然常见,但如果你不熟悉,仍然值得提前演练一遍,免得现场手抖清空了不该清的数据。