ARTICLE DETAIL

资讯详情

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

不拆机不烧录:用浏览器直接读写ESP32的NVS分区

不拆机不烧录:用浏览器直接读写ESP32的NVS分区 1. 从一个让人抓狂的场景说起如果你玩过 ESP32大概率经历过这个场景设备已经焊好、装进壳子、挂在墙上跑了大半年突然要换个 WiFi 密码。你打开 Arduino IDE 或者 ESP-IDF改一行ssid和password编译插 USB烧录重启。整个过程五分钟但前提是——你得把设备拆下来找到那根 USB 线还得记得当初用的是哪个版本的库。更麻烦的是如果设备装在吊顶里、配电箱里或者已经用防水胶封死了那这五分钟就变成了半小时的体力活。我见过有人为了改一个 WiFi 密码把整个传感器节点从楼顶拆下来就为了插一次 USB。这个问题的本质是WiFi 凭据被编译进了固件而不是存在可运行时修改的地方。ESP32 其实早就给了我们答案——NVSNon-Volatile Storage一块专门用来存键值对的 Flash 分区。WiFi 的 SSID 和密码本来就存在 NVS 里只是我们习惯了在代码里写死然后靠烧录覆盖。那有没有办法不烧固件、不拆机直接改 NVS 里的键值有而且不需要装任何桌面软件一个浏览器就够了。这就是我今天要聊的东西用浏览器直接读写 ESP32 的 NVS 分区。先说清楚它适合谁如果你手上有 ESP32 设备跑着 Arduino 或 ESP-IDF 的固件想在不重新编译的情况下改 WiFi 配置、改设备参数、改运行时的键值那这篇内容就是给你写的。不需要你懂 Flash 底层协议但需要你会基本的串口操作和一点点命令行。2. 为什么改个 WiFi 密码这么费劲2.1 固件烧录的固有代价ESP32 的固件烧录流程本质上是一次全量覆盖。你用esptool.py或者 Arduino IDE 点“上传”的时候工具会把编译好的二进制文件写到 Flash 的app分区同时可能擦除nvs分区取决于烧录配置。这意味着两件事第一你改的那一行const char* ssid old_wifi;是编译期常量它被固化在.rodata段里跟着固件一起写进 Flash。运行时 WiFi 库读的是这个常量不是 NVS。第二即使你用的是WiFi.begin(ssid, password)这种运行时传参的方式参数来源还是代码里的字符串。除非你主动把值写进 NVS再从 NVS 读出来否则 NVS 里存的只是 WiFi 库自己维护的凭据副本。这里有个很多人忽略的细节ESP32 的 WiFi 库在第一次连接成功后会把 SSID 和密码存进 NVS 的nvs.net80211命名空间。下次上电时如果代码里调用WiFi.begin()不传参数它会尝试从 NVS 读取。但如果你传了参数它就用你传的NVS 里的旧值被忽略。所以问题的根源不是 NVS 不能改而是我们的代码没有把 NVS 当作配置源。2.2 NVS 到底存了什么NVS 是 ESP32 Flash 里一个独立的分区通常从0x9000开始大小 24KB 左右具体看分区表。它用键值对的方式存储数据支持多种类型u8、i8、u16、i16、u32、i32、u64、i64、string、blob。WiFi 相关的键值在nvs.net80211命名空间下常见的键包括键名类型含义sta.ssidstringstation 模式下的 SSIDsta.pswdstringstation 模式下的密码sta.configblob连接配置信道、BSSID 等ap.ssidstringAP 模式下的 SSIDap.pswdstringAP 模式下的密码这些键值就是我们要改的目标。改完之后只要固件在启动时从 NVS 读取 WiFi 配置就能生效完全不需要重新烧录。2.3 浏览器工具为什么能行浏览器本身不能直接访问串口但现代浏览器支持Web Serial API。这个 API 允许网页通过 JavaScript 打开串口、发送和接收数据。Chrome、Edge、Opera 都支持Firefox 和 Safari 目前还不支持。有了 Web Serial我们就可以在网页里实现一个串口终端然后通过串口向 ESP32 发送命令。但 ESP32 默认的固件不会解析“改 NVS”这种命令所以我们需要一个桥接固件——一个跑在 ESP32 上的小程序它监听串口接收特定格式的指令然后调用 NVS API 去读写键值。这个桥接固件可以是一个独立的 Arduino sketch也可以集成到你现有的固件里。它的核心逻辑很简单读串口解析命令操作 NVS返回结果。浏览器工具的角色是提供一个图形界面让你输入键名、键值然后通过 Web Serial 把命令发给 ESP32再把结果显示出来。整个过程不需要安装 Python、不需要 esptool、不需要编译环境。3. 核心思路与方案选型3.1 整体架构拆解整个方案分三层第一层浏览器前端。用 HTML JavaScript 写一个单页应用包含串口连接按钮、键值输入框、发送按钮、日志输出区。核心是navigator.serial.requestPort()和port.readable.getReader()。第二层串口通信协议。定义一套简单的文本协议比如GET key、SET key value、DEL key、LIST。ESP32 收到后解析并执行返回OK value或ERR message。第三层ESP32 桥接固件。用 Arduino 框架写依赖Preferences.h库ESP32 自带。这个库封装了 NVS 操作用起来比原生nvs_flashAPI 简单得多。三层之间的数据流是这样的你在浏览器输入SET sta.ssid MyWiFi前端把它转成SET sta.ssid MyWiFi\n通过串口发出ESP32 读到这一行调用prefs.putString(sta.ssid, MyWiFi)然后返回OK前端显示成功。3.2 为什么选 Preferences 库而不是原生 NVSESP-IDF 提供了nvs_flash.h和nvs.h功能完整但 API 比较底层需要处理命名空间打开、句柄管理、错误码。Arduino 框架下的Preferences.h是对原生 API 的封装用起来更顺手#include Preferences.h Preferences prefs; void setup() { Serial.begin(115200); prefs.begin(nvs.net80211, false); // 读写模式 } void loop() { if (Serial.available()) { String cmd Serial.readStringUntil(\n); // 解析并执行 } }prefs.begin(nvs.net80211, false)打开命名空间false表示可读写。然后prefs.putString(sta.ssid, value)写入prefs.getString(sta.ssid)读取。简单直接。但这里有个坑Preferences库默认的命名空间是nvs而 WiFi 库用的是nvs.net80211。如果你用默认命名空间改的键值 WiFi 库读不到。所以必须显式指定nvs.net80211。3.3 浏览器端的选型考量Web Serial API 是唯一的选择没有替代方案。但浏览器兼容性需要注意浏览器Web Serial 支持备注Chrome89完整支持Edge89完整支持Opera75完整支持Firefox不支持需要 flag不稳定Safari不支持无计划所以这个工具主要在 Chromium 内核的浏览器里用。如果你平时用 Firefox得临时开个 Chrome。另外Web Serial 需要 HTTPS 环境localhost 除外。如果你把网页部署到服务器必须配 SSL 证书。本地打开 HTML 文件也可以但某些浏览器会限制file://协议下的串口访问。最稳妥的方式是本地起一个简单的 HTTP 服务比如python -m http.server然后访问http://localhost:8000。4. 桥接固件的关键细节4.1 串口命令解析的实现桥接固件的核心是一个状态机逐字符读取串口数据遇到换行符就执行命令。不要用Serial.readStringUntil(\n)因为它在等待换行时会阻塞影响其他任务。更好的方式是String inputBuffer ; void loop() { while (Serial.available()) { char c Serial.read(); if (c \n) { processCommand(inputBuffer); inputBuffer ; } else if (c ! \r) { inputBuffer c; } } // 其他任务 }processCommand函数按空格分割命令支持GET、SET、DEL、LIST四种操作。SET命令的格式是SET key valuevalue 可以包含空格所以分割时只分前三段。void processCommand(String cmd) { cmd.trim(); if (cmd.startsWith(GET )) { String key cmd.substring(4); String value prefs.getString(key.c_str(), ); Serial.println(OK value); } else if (cmd.startsWith(SET )) { int firstSpace cmd.indexOf( , 4); if (firstSpace -1) { Serial.println(ERR missing value); return; } String key cmd.substring(4, firstSpace); String value cmd.substring(firstSpace 1); prefs.putString(key.c_str(), value); Serial.println(OK); } // DEL 和 LIST 类似 }4.2 NVS 命名空间的坑前面提到WiFi 库用的是nvs.net80211命名空间。但如果你在代码里调用prefs.begin(nvs.net80211, false)然后prefs.putString(sta.ssid, MyWiFi)写入的键名实际上是sta.ssid而不是nvs.net80211:sta.ssid。命名空间是独立的键名在命名空间内唯一。但这里有个陷阱WiFi 库在存储 SSID 时可能会对键名做处理。比如某些版本的 ESP-IDF 会把 SSID 存成sta.ssid但读取时可能用sta.ssid或sta.ssid的变体。实测下来ESP32 Arduino Core 2.0.0 之后的版本nvs.net80211命名空间下的sta.ssid和sta.pswd是标准键名直接改就行。另一个坑是改完 NVS 后WiFi 库可能不会立即重新读取。如果你在同一个固件里先WiFi.begin()再改 NVSWiFi 库用的还是旧值。正确的做法是改完 NVS 后重启 ESP32或者调用WiFi.disconnect()再WiFi.begin()不传参数。4.3 数据类型与编码问题Preferences库的putString写入的是 UTF-8 字符串但 NVS 底层存储的是二进制。对于 SSID 和密码这种 ASCII 字符串没问题。但如果你的密码包含特殊字符比如中文、emoji需要确保浏览器端和 ESP32 端的编码一致。浏览器端用TextEncoder把字符串转成 UTF-8 字节ESP32 端Preferences库会自动处理。实测下来ASCII 字符最稳特殊字符偶尔会有截断问题。建议密码用纯 ASCII。另外NVS 的 string 类型有长度限制最大 4000 字节左右。SSID 最长 32 字节密码最长 64 字节远低于限制不用担心。5. 浏览器端的实操实现5.1 Web Serial 的连接与读写浏览器端的第一步是请求串口权限let port; let reader; let writer; 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() { while (true) { const { value, done } await reader.read(); if (done) break; const text new TextDecoder().decode(value); appendLog(text); } }requestPort()会弹出浏览器原生的串口选择框用户选择 ESP32 对应的端口。baudRate必须和 ESP32 固件里的Serial.begin()一致通常用 115200。发送命令async function sendCommand(cmd) { const encoder new TextEncoder(); await writer.write(encoder.encode(cmd \n)); }注意要加换行符因为 ESP32 端是按行解析的。5.2 界面布局与交互设计界面不需要花哨但要清晰。我习惯分三个区域顶部连接区。一个“连接串口”按钮一个状态指示灯绿色表示已连接灰色表示未连接。中部操作区。两个输入框键名、键值四个按钮GET、SET、DEL、LIST。GET 和 DEL 只用键名SET 用两个LIST 不用输入。底部日志区。一个pre标签显示发送和接收的原始数据。每次操作后自动滚动到底部。交互上有个细节发送命令后按钮要短暂禁用防止重复点击。ESP32 处理命令需要几毫秒到几十毫秒如果连续发送串口缓冲区可能溢出。5.3 命令格式与错误处理前端发送的命令格式要和 ESP32 端严格对应。我定义的是操作发送格式成功返回失败返回GETGET key\nOK valueERR not foundSETSET key value\nOKERR missing valueDELDEL key\nOKERR not foundLISTLIST\nOK key1,key2,...ERR前端收到OK开头的行就显示成功ERR开头的行显示错误。日志区用不同颜色区分发送蓝色和接收绿色/红色。错误处理要覆盖几种情况串口未连接、写入失败、ESP32 无响应。无响应的情况最难排查通常是波特率不对或者 ESP32 没跑桥接固件。6. 完整实操流程与参数计算6.1 准备工作硬件与软件清单硬件方面你需要一块 ESP32 开发板任意型号ESP32-WROOM、ESP32-S3、ESP32-C3 都行一根 USB 数据线注意是数据线不是纯充电线一台电脑Windows、macOS、Linux 均可软件方面Arduino IDE 或 PlatformIO用来烧录桥接固件Chrome 或 Edge 浏览器用来打开工具页面一个文本编辑器用来写 HTML 文件不需要安装 esptool、不需要 Python 环境、不需要串口调试助手。6.2 烧录桥接固件的完整步骤第一步在 Arduino IDE 里新建一个 sketch粘贴桥接固件代码。代码的核心是Preferences库和串口解析逻辑完整代码如下#include Preferences.h Preferences prefs; String inputBuffer ; void setup() { Serial.begin(115200); delay(1000); prefs.begin(nvs.net80211, false); Serial.println(READY); } void loop() { while (Serial.available()) { char c Serial.read(); if (c \n) { processCommand(inputBuffer); inputBuffer ; } else if (c ! \r) { inputBuffer c; } } } void processCommand(String cmd) { cmd.trim(); if (cmd.length() 0) return; if (cmd LIST) { // Preferences 库不支持遍历键需要预定义键列表 Serial.println(OK sta.ssid,sta.pswd,ap.ssid,ap.pswd); return; } if (cmd.startsWith(GET )) { String key cmd.substring(4); String value prefs.getString(key.c_str(), ); if (value.length() 0) { Serial.println(ERR not found); } else { Serial.println(OK value); } return; } if (cmd.startsWith(SET )) { int firstSpace cmd.indexOf( , 4); if (firstSpace -1) { Serial.println(ERR missing value); return; } String key cmd.substring(4, firstSpace); String value cmd.substring(firstSpace 1); prefs.putString(key.c_str(), value); Serial.println(OK); return; } if (cmd.startsWith(DEL )) { String key cmd.substring(4); prefs.remove(key.c_str()); Serial.println(OK); return; } Serial.println(ERR unknown command); }第二步选择正确的开发板和端口。Arduino IDE 里开发板选“ESP32 Dev Module”端口选 ESP32 对应的串口。如果端口列表里没有检查 USB 驱动是否安装。第三步点击上传。上传过程中ESP32 会自动进入下载模式如果自动下载电路正常。如果报错“Failed to connect”按住 BOOT 键再点上传松开 BOOT 键后开始烧录。第四步上传完成后打开串口监视器波特率 115200。你应该看到READY字样。如果没有按一下 EN 键重启。6.3 浏览器工具的部署与使用把下面的 HTML 代码保存为esp32-nvs.html!DOCTYPE html html head meta charsetUTF-8 titleESP32 NVS Editor/title style body { font-family: monospace; max-width: 800px; margin: 20px auto; } button { padding: 8px 16px; margin: 4px; } input { padding: 8px; margin: 4px; width: 200px; } #log { background: #1e1e1e; color: #d4d4d4; padding: 12px; height: 300px; overflow-y: auto; } .sent { color: #569cd6; } .recv { color: #6a9955; } .err { color: #f44747; } /style /head body h2ESP32 NVS Editor/h2 button idconnectBtn连接串口/button span idstatus未连接/span hr input idkeyInput placeholder键名如 sta.ssid input idvalueInput placeholder键值如 MyWiFi br button idgetBtnGET/button button idsetBtnSET/button button iddelBtnDEL/button button idlistBtnLIST/button hr div idlog/div script let port, reader, writer; const log (msg, cls) { const el document.getElementById(log); const line document.createElement(div); line.className cls; line.textContent msg; el.appendChild(line); el.scrollTop el.scrollHeight; }; document.getElementById(connectBtn).onclick async () { try { port await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); writer port.writable.getWriter(); reader port.readable.getReader(); document.getElementById(status).textContent 已连接; readLoop(); } catch (e) { log(连接失败: e.message, err); } }; async function readLoop() { const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; log(decoder.decode(value).trim(), recv); } } async function send(cmd) { if (!writer) { log(未连接串口, err); return; } log( cmd, sent); const encoder new TextEncoder(); await writer.write(encoder.encode(cmd \n)); } document.getElementById(getBtn).onclick () { const key document.getElementById(keyInput).value; send(GET key); }; document.getElementById(setBtn).onclick () { const key document.getElementById(keyInput).value; const value document.getElementById(valueInput).value; send(SET key value); }; document.getElementById(delBtn).onclick () { const key document.getElementById(keyInput).value; send(DEL key); }; document.getElementById(listBtn).onclick () send(LIST); /script /body /html用 Chrome 打开这个文件。如果直接双击打不开串口就在文件所在目录起一个 HTTP 服务python -m http.server 8000然后访问http://localhost:8000/esp32-nvs.html。点击“连接串口”选择 ESP32 的端口。连接成功后状态变成“已连接”。在键名输入框填sta.ssid键值填你的新 WiFi 名称点 SET。日志区会显示 SET sta.ssid MyWiFi和OK。改密码同理键名填sta.pswd键值填新密码。改完后按一下 ESP32 的 EN 键重启或者断电重上电。如果固件在启动时从 NVS 读取 WiFi 配置它就会用新密码连接。6.4 验证与调试验证是否生效有两种方式第一种用浏览器工具 GET 一下sta.ssid看返回值是不是你刚设置的值。如果是说明 NVS 写入成功。第二种看 ESP32 的串口输出。如果固件里有WiFi.begin()不传参数的逻辑重启后它会打印连接状态。连上了就说明新配置生效了。如果没生效检查几个点固件是否真的从 NVS 读取 WiFi 配置WiFi.begin()是否传了参数如果传了NVS 里的值会被忽略。另外某些 ESP32 库版本在WiFi.begin()不传参数时会优先用 NVS 里的sta.ssid和sta.pswd但前提是这两个键存在且格式正确。7. 常见问题与排查技巧7.1 串口连接失败最常见的问题是浏览器找不到串口。原因通常是驱动没装。Windows 上 CH340、CP2102 需要单独装驱动。macOS 和 Linux 通常免驱。串口被占用。Arduino IDE 的串口监视器开着的时候浏览器打不开同一个端口。先关掉监视器。数据线问题。有些 USB 线只能充电不能传数据。换一根线试试。如果navigator.serial是 undefined说明浏览器不支持 Web Serial。换 Chrome 或 Edge。7.2 命令发送后无响应ESP32 收到命令但不返回通常是波特率不匹配。检查浏览器端和固件端的baudRate是否都是 115200。固件没跑起来。打开 Arduino 串口监视器看有没有READY。没有的话重新烧录。换行符问题。浏览器发送的是\nESP32 端按\n分割。如果发送的是\r\nESP32 端要处理\r。我踩过的一个坑是ESP32 在setup()里Serial.begin(115200)之后立刻prefs.begin()有时候 NVS 还没初始化完prefs.begin()返回 false。加一个delay(1000)就好了。7.3 写入成功但 WiFi 不生效这是最让人困惑的情况。NVS 里明明写进去了GET 也能读出来但 WiFi 就是连不上。原因可能有固件里WiFi.begin(ssid, password)传了硬编码的参数NVS 被忽略。WiFi 库在启动时从 NVS 读取但读取的键名不是sta.ssid。某些版本的库用sta.ssid存储但读取时用sta.ssid的哈希值。NVS 分区被擦除。烧录固件时如果勾选了“Erase Flash”NVS 会被清空。排查方法在固件里加一行Serial.println(WiFi.SSID())看它读到的 SSID 是什么。如果是空的说明 NVS 读取失败。7.4 常见问题速查表现象可能原因解决方法浏览器找不到串口驱动未装/端口被占用装驱动/关串口监视器navigator.serialundefined浏览器不支持换 Chrome/Edge发送命令无响应波特率不对/固件未跑检查 115200/重新烧录GET 返回空键不存在/命名空间错检查键名/用nvs.net80211SET 成功但 WiFi 不生效固件硬编码了参数改固件用 NVS 读取重启后配置丢失NVS 被擦除烧录时不勾选 Erase Flash7.5 独家避坑技巧第一个技巧在桥接固件里加一个Serial.println(READY)浏览器连接后先等这行输出确认 ESP32 在线。很多“无响应”问题其实是 ESP32 没跑起来。第二个技巧改 NVS 之前先 GET 一下旧值记下来。万一改错了还能改回去。我习惯把旧值复制到记事本里。第三个技巧如果 ESP32 的固件里已经有 WiFi 连接逻辑桥接固件可以做成一个独立的 sketch烧录到另一块 ESP32 上然后通过串口交叉连接。这样不影响主设备。第四个技巧NVS 的写入有寿命限制大约 10 万次擦写。频繁改 WiFi 密码不会很快耗尽但别在循环里写 NVS。8. 这个方案还能怎么扩展8.1 从 WiFi 配置到通用参数管理这套工具的核心价值不是改 WiFi 密码而是运行时配置管理。你可以把任何需要动态调整的参数存进 NVS然后用浏览器工具改。比如传感器采样间隔MQTT 服务器地址和端口设备名称和位置标签阈值和校准系数只要固件在启动时从 NVS 读取这些参数就能实现“不烧录改配置”。8.2 批量设备配置的思路如果你有几十台 ESP32 设备一台一台改效率太低。可以做一个批量模式浏览器端读取一个 CSV 文件每行包含设备标识和对应的配置然后依次连接每个串口自动发送 SET 命令。实现上用navigator.serial.getPorts()获取已授权的端口列表然后循环打开、发送、关闭。注意每次打开新端口前要关闭上一个。8.3 安全性的考量NVS 里的 WiFi 密码是明文存储的。如果设备被别人拿到串口一读就能看到密码。对于安全性要求高的场景可以在写入前加密读取后解密。但 ESP32 的 NVS 加密功能需要启用 Flash 加密配置比较复杂。一个折中方案是不存明文密码存密码的哈希值然后固件里用哈希值去连接。但 WiFi 协议本身需要明文密码所以这个方案行不通。实际项目中如果设备物理安全可控明文存储是可以接受的。8.4 与 OTA 的配合NVS 改配置和 OTA 升级是互补的。OTA 用来更新固件逻辑NVS 用来更新配置参数。两者结合可以实现完全远程的运维固件有 bug 就 OTA配置要调整就改 NVS。但 OTA 本身也需要 WiFi 连接所以如果 WiFi 密码错了OTA 也连不上。这时候还是得靠串口改 NVS。所以这套浏览器工具是最后的兜底手段。9. 我个人在实际操作中的体会这套方案我用了大半年改过十几台设备的 WiFi 配置最大的感受是把配置从代码里剥离出来是一个习惯问题不是技术问题。很多人知道 NVS 能存东西但还是习惯在代码里写死因为“就改一次”。但设备部署之后“就改一次”往往变成“怎么又要改”。浏览器工具的好处是零依赖。不用装 Python不用装 esptool不用记命令行参数。打开网页点几下改完。对于不熟悉命令行的同事这个工具降低了操作门槛。踩过的最大的坑是命名空间。一开始我用prefs.begin(nvs, false)写进去的键 WiFi 库读不到。后来改成prefs.begin(nvs.net80211, false)才生效。这个细节在官方文档里没有明确说是我用nvs_flash的原生 API 打印了所有命名空间才发现的。另一个体会是串口通信的稳定性比想象中差。USB 线质量、端口驱动、浏览器版本都会影响。所以工具里一定要有日志区把发送和接收的原始数据都显示出来。出问题的时候看日志比猜原因快得多。最后分享一个小技巧如果你不确定某个键是否存在先 LIST 一下。虽然Preferences库不支持遍历键但你可以在桥接固件里预定义一个键列表LIST 命令返回这个列表。这样至少知道哪些键是可用的。
返回列表