ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

不重刷固件改ESP32 WiFi配置:浏览器直接操作NVS键值

不重刷固件改ESP32 WiFi配置:浏览器直接操作NVS键值 1. 从一次改WiFi密码的折腾说起上个月帮朋友调一套基于 ESP32 的智能灌溉控制器硬件已经装在院子角落的防水盒里固件跑了大半年一直很稳。结果他家换了路由器WiFi 名称和密码全变了设备自然连不上。按常规思路我得把设备拆下来、接上 USB 转串口、打开开发环境、改代码里的 SSID 和密码、重新编译烧录再装回去。整个过程光拆装防水盒就得半小时更别提现场还得带笔记本和烧录线。当时我就在想ESP32 的 WiFi 凭据本来就是存在 NVSNon-Volatile Storage非易失性存储里的本质上就是几个键值对为什么非得重刷固件NVS 是 ESP32 分区表里一块独立的数据分区跟应用程序固件是分开的。改 NVS 里的键值理论上根本不需要动固件本身。问题在于怎么在不拆机、不接线的前提下把新的 SSID 和密码写进去。答案就是标题里说的那个思路用一个浏览器工具直接改 NVS 键值。这个工具跑在浏览器里通过串口或者设备自己开的配置页面把 NVS 里的wifi_ssid、wifi_password这类键值读出来、改掉、再写回去。整个过程不需要重新编译固件也不需要烧录器。这篇文章就把这套方案的原理、实现细节、踩过的坑以及实际落地时的注意事项完整地讲一遍。不管你是刚接触 ESP32 的新手还是已经做过几个项目的老手只要你的设备有改配置不想重刷的需求这套思路都能直接抄作业。2. NVS 到底存了什么为什么它能绕开固件重刷2.1 NVS 分区的物理位置与读写机制要理解为什么改 NVS 不用重刷固件得先搞清楚 ESP32 的 Flash 是怎么分区的。ESP32 外挂的 SPI Flash 通常 4MB 起步通过分区表partition table划分成若干区域bootloader、partition table、nvs、phy_init、factory或ota_0/ota_1等。应用程序固件烧在factory或ota_x分区而 NVS 是单独一块默认大小 0x500020KB也可以调到 0x9000 甚至更大。关键点在于NVS 分区和应用程序分区是物理隔离的。你烧录固件时烧录工具只写factory分区不会碰nvs。反过来你改 NVS 里的数据也不会影响factory里的程序代码。这就是改配置不重刷固件的物理基础。NVS 内部用的是键值对存储键是字符串最长 15 字符值可以是整数、字符串、二进制 blob 等类型。WiFi 凭据通常存成两个字符串键ssid和password命名空间namespace一般是wifi_config或者nvs.net80211。命名空间相当于 NVS 里的文件夹避免不同模块的键名冲突。2.2 为什么大多数人还是选择重刷固件既然 NVS 可以单独改为什么大家还是习惯重刷原因有几个。第一改 NVS 需要工具而大多数人手头只有esptool.py和idf.py这些工具默认操作的是固件分区不直接提供改某个键值的命令。第二NVS 的二进制格式不是纯文本直接拿十六进制编辑器改容易把整个分区搞坏。第三很多人根本不知道 NVS 和固件的分区关系以为配置是编译进代码里的。实际上如果你在代码里用nvs_set_str()写入 WiFi 凭据那这些数据就落在 NVS 分区。只要你能在运行时调用nvs_set_str()就能改。浏览器工具的本质就是通过串口或者网络让设备在运行时执行这段写入逻辑或者直接对 NVS 分区做符合格式的读写。2.3 浏览器工具切入的两个技术路径浏览器工具改 NVS落地时有两条路径各有适用场景。第一条是Web Serial 路径。Chrome、Edge 等基于 Chromium 的浏览器支持 Web Serial API网页可以直接调用navigator.serial.requestPort()拿到串口句柄然后跟 ESP32 的串口通信。设备端跑一个简单的串口命令解析器收到特定指令就调用nvs_set_str()写 NVS写完回复确认。网页端负责把用户输入的 SSID 和密码打包成指令发下去。这条路适合设备已经拆下来、能接 USB 的场景但比传统烧录快得多因为不用编译。第二条是设备自建配置页路径。设备首次启动时如果连不上 WiFi就进入 AP 模式自己开一个热点手机或电脑连上去后访问192.168.4.1设备返回一个配置页面。用户在页面上填新的 SSID 和密码提交后设备调用nvs_set_str()写入然后重启连新网络。这条路完全不用接线适合设备已经装在现场的情况。标题里说的浏览器工具两条路都算但更贴近第二条的体验。3. 用 Web Serial 直接改 NVS 的完整实现3.1 设备端串口命令解析器的写法设备端需要一段代码监听串口输入识别特定格式的命令然后操作 NVS。我用的是 ESP-IDF 环境核心逻辑放在一个独立任务里避免阻塞主循环。命令格式设计成SETWIFI ssid password\n简单直接解析起来不容易出错。#include nvs_flash.h #include nvs.h #include driver/uart.h #include freertos/FreeRTOS.h #include freertos/task.h #include string.h #include stdio.h #define UART_NUM UART_NUM_0 #define BUF_SIZE 256 static void nvs_write_wifi(const char *ssid, const char *pass) { nvs_handle_t handle; esp_err_t err nvs_open(wifi_config, NVS_READWRITE, handle); if (err ! ESP_OK) { printf(NVS_ERR open %d\n, err); return; } nvs_set_str(handle, ssid, ssid); nvs_set_str(handle, password, pass); nvs_commit(handle); nvs_close(handle); printf(NVS_OK\n); } static void uart_task(void *arg) { uint8_t buf[BUF_SIZE]; int len 0; while (1) { int n uart_read_bytes(UART_NUM, buf len, 1, pdMS_TO_TICKS(100)); if (n 0) continue; if (buf[len] \n) { buf[len] 0; if (strncmp((char *)buf, SETWIFI , 8) 0) { char *ssid (char *)buf 8; char *space strchr(ssid, ); if (space) { *space 0; nvs_write_wifi(ssid, space 1); } } len 0; } else { len; if (len BUF_SIZE - 1) len 0; } } } void app_main(void) { nvs_flash_init(); uart_driver_install(UART_NUM, BUF_SIZE * 2, 0, 0, NULL, 0); xTaskCreate(uart_task, uart_task, 4096, NULL, 5, NULL); }这段代码有几个细节值得说。nvs_flash_init()必须在任何 NVS 操作之前调用否则nvs_open会返回错误。nvs_commit()也不能省不 commit 的话数据可能还在缓存里掉电就丢。串口读取用逐字节方式虽然效率不高但解析命令行足够用而且不会因为一次读太多导致粘包。3.2 网页端 Web Serial 的调用逻辑网页端要做的事很明确打开串口、发命令、等回复。Web Serial API 的调用需要用户手势触发不能自动弹窗所以页面上得有个连接设备按钮。let port, writer, reader; async function connect() { port await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); writer port.writable.getWriter(); reader port.readable.getReader(); readLoop(); } async function readLoop() { const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value); if (buffer.includes(NVS_OK)) { document.getElementById(status).textContent 写入成功; buffer ; } } } async function setWifi() { const ssid document.getElementById(ssid).value; const pass document.getElementById(pass).value; const cmd SETWIFI ${ssid} ${pass}\n; await writer.write(new TextEncoder().encode(cmd)); }这里有个坑port.readable.getReader()拿到的 reader 是独占的读循环里不能中途再调reader.read()之外的操作。另外串口返回的数据可能分多次到达所以要用 buffer 累积不能假设一次 read 就能拿到完整回复。3.3 命令格式设计里的几个讲究命令格式看着简单但设计不好会埋雷。我最初用的是SETWIFI:ssid:password结果遇到 SSID 里带冒号的情况就解析错了。后来改成空格分隔并且约定 SSID 和密码里不能有空格——实际上家用 WiFi 的 SSID 和密码确实很少带空格这个约束可以接受。如果非要支持空格就得用长度前缀或者转义复杂度上去了对这个小工具来说不划算。另一个讲究是结束符。用\n而不是\r\n因为串口终端里\n更通用而且设备端解析时只需要判断一个字符。命令回显也要有NVS_OK和NVS_ERR两个状态足够网页端判断成败。4. 设备自建配置页不拆机改 WiFi 的落地方式4.1 首次配网与运行中改配的状态机设计设备自建配置页的核心是一个状态机。上电后先读 NVS 里的 WiFi 凭据尝试连接。连上了就进正常工作模式连不上或者 NVS 里没有凭据就进 AP 配置模式。这个逻辑用esp_wifi的事件回调实现最自然。static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data) { if (base WIFI_EVENT id WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (base WIFI_EVENT id WIFI_EVENT_STA_DISCONNECTED) { retry_count; if (retry_count 5) { start_ap_config(); } else { esp_wifi_connect(); } } else if (base IP_EVENT id IP_EVENT_STA_GOT_IP) { retry_count 0; stop_ap_config(); } }retry_count这个计数器很关键。如果只重试一次就进 AP 模式遇到路由器临时重启会误判如果重试太多次用户等得着急。我实测下来 5 次、每次间隔 2 秒比较合适总共 10 秒左右既给了路由器恢复时间又不至于让用户等太久。4.2 AP 模式下 HTTP 服务的轻量实现AP 模式下设备要跑一个 HTTP 服务返回配置页面并接收表单提交。ESP-IDF 自带esp_http_server组件用起来不复杂。配置页面本身就是一个内嵌的 HTML 字符串不用外部文件系统省事。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 形如 ssidMyWiFipass12345678 char ssid[64], pass[64]; httpd_query_key_value(buf, ssid, ssid, sizeof(ssid)); httpd_query_key_value(buf, pass, pass, sizeof(pass)); nvs_write_wifi(ssid, pass); httpd_resp_send(req, OK, rebooting..., HTTPD_RESP_USE_STRLEN); vTaskDelay(pdMS_TO_TICKS(1000)); esp_restart(); return ESP_OK; }httpd_query_key_value是 IDF 提供的工具函数直接从 URL 编码的表单数据里取值省得自己写解析。提交后延迟 1 秒再重启是为了让 HTTP 响应先发出去否则浏览器会显示连接中断。4.3 配置页的 HTML 与表单细节配置页的 HTML 不用多复杂一个表单两个输入框一个提交按钮就够。但有几个细节能显著提升体验。第一meta nameviewport contentwidthdevice-width, initial-scale1必须有否则手机上显示会很小。第二输入框加autocapitalizeoff和autocorrectoff避免手机自动首字母大写把 SSID 改错。第三密码框用typepassword但加一个显示密码的勾选框方便用户核对。form methodPOST action/config labelWiFi 名称/label input namessid autocapitalizeoff autocorrectoff required labelWiFi 密码/label input namepass typepassword idpass required labelinput typecheckbox onclicktogglePass() 显示密码/label button typesubmit保存并重启/button /form script function togglePass() { const p document.getElementById(pass); p.type p.type password ? text : password; } /script这个页面在手机浏览器里打开就能用不需要装任何 App。设备开的热点默认没有密码连上后访问192.168.4.1即可。如果担心安全可以给 AP 设个固定密码写在代码里。5. 实测中踩过的坑与排查链路5.1 NVS 写入后不生效commit 与命名空间的双重陷阱第一次测试时网页显示写入成功设备重启后还是连旧 WiFi。排查过程分三步。第一步确认nvs_set_str返回值发现是ESP_OK说明写入调用没报错。第二步怀疑没 commit检查代码发现nvs_commit确实调了。第三步用nvs_get_str读回来验证发现读出来的还是旧值。问题出在命名空间上。写入时用的是wifi_config但读取时用的是nvs.net80211——这是 ESP-IDF 内部 WiFi 驱动自己用的命名空间。两个命名空间互不相通写进去的键值在另一个命名空间里当然读不到。解决办法是统一命名空间要么全用自己定义的wifi_config要么直接操作nvs.net80211并遵循驱动的键名约定。我选择前者代码可控性更强。提示ESP-IDF 的 WiFi 驱动在nvs.net80211命名空间里存了sta.ssid、sta.pswd等键格式是二进制而非纯字符串。自己写配置逻辑时建议另起命名空间避免和驱动内部数据打架。5.2 串口命令被截断缓冲区与读取节奏的配合Web Serial 测试时偶尔出现命令只发了一半的情况。抓包看串口数据发现SETWIFI MyWiFi 12345678\n被拆成了两次发送。原因是网页端writer.write()虽然一次调用但底层串口驱动可能分片。设备端逐字节读取本身没问题问题在于我的 buffer 在遇到\n之前如果满了会重置导致长命令被丢弃。修复方法是把 buffer 加大到 256 字节并且在重置前打印一条警告日志方便定位。另外网页端发送前可以加一个极短的延迟或者分两次 write 中间加await让底层有时间刷新。实测下来buffer 加大后就没再出现截断。5.3 AP 模式下的 DNS 劫持与页面自动弹出设备开 AP 后用户连上热点默认不会自动弹出配置页得手动输192.168.4.1。为了体验更好可以实现一个极简 DNS 服务器把所有域名解析到192.168.4.1这样手机连上后会自动弹出登录网络的提示。ESP-IDF 有esp_netif的 DHCP 和 DNS 相关 API但实现完整的 captive portal 有点重。我的做法是折中在配置页的 HTML 里加一段说明文字告诉用户访问192.168.4.1。同时在 AP 的 DHCP 配置里把 DNS 服务器指向自己部分安卓手机会因此触发网络检测并弹出提示。这个不是 100% 可靠但比什么都不做强。如果项目对体验要求高可以引入esp_wifi的 captive portal 示例代码那个是官方维护的稳定性有保障。5.4 重启后连不上信道与路由器兼容性有一次改完配置重启设备死活连不上新 WiFi。用串口日志看一直报WIFI_EVENT_STA_DISCONNECTEDreason code 是 201。查资料发现 201 表示NO_AP_FOUND但手机明明能搜到那个 WiFi。后来发现路由器开了 5GHz 和 2.4GHz 双频合一SSID 相同而 ESP32 只支持 2.4GHz。设备尝试连接时可能被引导到 5GHz 频段导致失败。解决办法是在路由器里把 2.4GHz 和 5GHz 的 SSID 分开设备连 2.4GHz 那个。或者在代码里设置esp_wifi_set_band_mode(WIFI_BAND_MODE_2G_ONLY)强制只扫 2.4GHz。后者更省事但需要 IDF 版本支持。这个坑很隐蔽因为现象是连不上但原因在路由器侧不看 reason code 很难定位。6. 把这套方案用稳的几个经验6.1 配置写入的原子性与掉电保护NVS 写入过程中如果掉电可能导致分区数据损坏。虽然 NVS 本身有磨损均衡和掉电保护机制但nvs_set_str加nvs_commit之间如果断电数据可能只写了一半。对于 WiFi 凭据这种关键配置我的做法是写入前先备份旧值到另一个键写入成功后再删除备份。这样即使新值写坏重启后还能回退到旧值。// 写入前备份 nvs_set_str(handle, ssid_bak, old_ssid); nvs_set_str(handle, pass_bak, old_pass); nvs_commit(handle); // 写入新值 nvs_set_str(handle, ssid, new_ssid); nvs_set_str(handle, password, new_pass); nvs_commit(handle); // 成功后删除备份 nvs_erase_key(handle, ssid_bak); nvs_erase_key(handle, pass_bak); nvs_commit(handle);这套逻辑多几次 NVS 操作但换来的是配置可靠性。对于装在户外、拆装困难的设备这点开销完全值得。6.2 配置页的访问控制与超时退出AP 配置模式如果一直开着既费电又有安全风险。我的做法是加一个超时进入 AP 模式后启动一个 5 分钟定时器如果期间没有收到配置提交就自动重启并重试连接。这样即使误入 AP 模式设备也能自己恢复。另外配置页提交后立即重启不给二次提交的机会避免并发写入。如果设备有物理按键还可以加一个长按 5 秒进入配置模式的入口这样正常运行时不会开 AP只有用户主动触发才进配置。这个设计比连不上就开 AP更可控适合对安全性有要求的场景。6.3 串口工具与 Web Serial 的浏览器兼容性Web Serial API 目前只在 Chromium 内核浏览器里可用Firefox 和 Safari 不支持。如果用户用的是 Firefox网页会报navigator.serial is undefined。所以页面上要做特性检测不支持时给出提示引导用户换 Chrome 或 Edge。另外Web Serial 需要 HTTPS 或者 localhost 环境本地打开 HTML 文件file://是不行的得用本地服务器或者部署到 HTTPS 站点。如果不想依赖浏览器特性备选方案是用 Python 脚本调pyserial发同样的命令效果一样只是少了图形界面。对于批量生产或者产线测试脚本方案反而更合适。6.4 从改 WiFi延伸到其他 NVS 配置项这套思路不只适用于 WiFi 密码。任何存在 NVS 里的配置项比如 MQTT 服务器地址、设备 ID、采样间隔、校准参数都可以用同样的方式改。设备端把命令解析器做成通用的SET namespace key value格式网页端做成一个通用的键值编辑表格就能覆盖大部分配置需求。我在另一个项目里就把这套逻辑扩展成了设备配置中心网页端列出所有可配置项用户改完一次性提交设备批量写入 NVS 后重启。这样现场调试时不用带笔记本和烧录线手机连上设备热点就能改所有参数。对于部署在屋顶、田间、机房深处的设备这个便利性是实打实的。最后分享一个小技巧NVS 的键名尽量用短字符串因为键名本身也占存储空间而且 NVS 对键名长度有限制15 字符。像wifi_config这种命名空间名如果项目里命名空间不多可以缩成wc省下的空间虽然不多但在 NVS 分区只有 20KB 的情况下积少成多。
返回列表