1. 从一个让人抓狂的场景说起
如果你玩过 ESP32,大概率经历过这个场景:设备已经焊好、装进壳子、挂在墙上,跑了大半年,突然要换个 WiFi 密码。你翻出数据线,拆壳,插上电脑,打开 Arduino IDE 或者 ESP-IDF,改一行ssid和password,重新编译,重新烧录,再装回去。整个过程十分钟起步,如果设备装在不好够到的地方,那更是灾难。
问题的根源在于,很多人的 ESP32 项目把 WiFi 凭据写死在代码里,编译进固件。固件一烧,凭据就固定了,改一次就得重来一遍。而 ESP32 本身其实自带了一块非易失存储区域,叫NVS(Non-Volatile Storage),专门用来存这种需要掉电保存、又经常变动的小数据。WiFi 的 SSID 和密码,天生就该放在 NVS 里,而不是硬编码在固件里。
那有没有办法不重刷固件、不改代码,直接改 NVS 里的键值?有,而且不需要装任何桌面软件,浏览器就能干。这就是我这次要拆解的东西:一个基于浏览器 Web Serial API 的 NVS 键值读写工具,插上 USB 线,打开网页,点几下就能把 WiFi 密码改掉,设备重启后自动连上新网络。
这篇文章适合三类人看:一是手上已经有 ESP32 项目、被改配置折磨过的开发者;二是想搞清楚 NVS 到底怎么用、怎么在运行时管理配置的进阶玩家;三是想自己动手做一个类似浏览器工具的人。我会把原理、NVS 的存储结构、Web Serial 的通信机制、实操步骤、踩过的坑全部讲清楚,让你看完能直接复现一套自己的方案。
2. 为什么是 NVS,而不是别的存储方案
2.1 硬编码、SPIFFS、NVS 三条路的取舍
在 ESP32 上存 WiFi 凭据,常见有三条路,我一个个说清楚它们的适用边界。
硬编码进固件是最省事的做法,const char* ssid = "mywifi";一行搞定。但它的致命伤就是标题里说的:改一次配置就要重刷一次固件。对于量产设备或者已经部署到现场的设备,这完全不可接受。而且固件里明文存密码,谁把 bin 文件 dump 出来就能看到,安全性也差。
SPIFFS/LittleFS 文件系统是第二条路,把配置写成一个 JSON 文件存进去。这条路比硬编码灵活,运行时能改。但它有个问题:文件系统本身占用的 Flash 空间大,挂载需要时间,而且频繁读写小文件容易产生碎片。对于就存几个键值对的需求来说,属于杀鸡用牛刀。
NVS是 ESP-IDF 原生提供的键值存储系统,专门为这种场景设计。它把 Flash 的一个分区(通常是nvs分区)管理起来,以键值对的形式读写,支持多种数据类型(整数、字符串、二进制 blob),内部做了磨损均衡和掉电保护。ESP32 的 WiFi 驱动本身在底层就是用 NVS 存校准数据和 PHY 参数的,所以用 NVS 存 WiFi 凭据,等于顺着系统的设计走,最自然。
我个人的判断标准很简单:需要掉电保存、数据量小、读写不频繁、要能在运行时改,这四条满足就用 NVS。WiFi 凭据完美命中。
2.2 NVS 的存储结构,理解了这个才能改对
很多人用 NVS 只会调nvs_set_str和nvs_get_str,但不知道底层长什么样,一旦出问题就抓瞎。我简单拆一下。
NVS 把整个分区划分成若干个页(page),默认每页 4096 字节。每页又分成多个条目(entry),每个条目 32 字节。一个条目里存的是:命名空间(namespace)的索引、键(key)的哈希、数据类型、以及数据本身或者指向数据的引用。
这里有两个关键概念:
- 命名空间(namespace):相当于文件夹,用来隔离不同模块的键值。WiFi 相关的键通常放在
nvs.net80211这个命名空间下,这是 ESP-IDF 的 WiFi 驱动自己用的。你自己存的话,一般会建一个自定义命名空间,比如wifi_cfg。 - 键(key):命名空间内的唯一标识,最长 15 个字符。注意这个长度限制,超了会报错。
数据类型有几种:u8、i8、u16、i16、u32、i32、u64、i64、string、blob。WiFi 密码用string就行。
注意:NVS 的键名区分大小写,而且不能包含空格。命名空间名最长 15 字符,键名也是 15 字符。这个限制在写工具的时候必须校验,否则用户输入超长键名会直接失败。
理解了这层结构,你就明白为什么浏览器工具能直接改 NVS:它本质上是通过串口发送命令,让 ESP32 上的固件去调用 NVS 的读写 API,把值写进去。工具本身不直接操作 Flash,而是通过一个"代理固件"来干活。
2.3 浏览器工具的核心:Web Serial API
浏览器能直接和串口通信,靠的是Web Serial API。这是 Chrome 89 之后(以及 Edge 89+)支持的标准,允许网页在用户授权的前提下,通过 JavaScript 访问串口设备。
它的工作流程是这样的:网页调用navigator.serial.requestPort(),浏览器弹出设备选择框,用户选中 ESP32 对应的串口,网页拿到一个SerialPort对象,然后port.open({ baudRate: 115200 })打开端口,之后就能用port.readable和port.writable两个流来收发数据。
这个 API 有几个硬性前提,必须记住:
- 必须是 HTTPS 页面,或者
localhost。普通 HTTP 页面调不了,这是浏览器的安全策略。 - 必须由用户手势触发,也就是点击按钮才能调
requestPort(),不能在页面加载时自动弹。 - 目前只有 Chromium 内核支持,Firefox 和 Safari 不支持。所以这个工具在 Chrome、Edge、Opera 上能用,别的浏览器不行。
我实测下来,Chrome 的兼容性最稳,Edge 也可以。如果你要给别人用,最好在页面上做个浏览器检测,不支持的时候给个明确提示,别让人对着没反应的按钮干瞪眼。
3. 工具的整体设计思路
3.1 为什么不做成桌面软件
有人会问,为什么不用 Python 写个脚本,或者做个桌面 GUI,非要折腾浏览器?
我的考虑有三点。第一,零安装。桌面软件要打包、要分发、要考虑 Windows/Mac/Linux 三平台,用户还得下载安装。浏览器工具只要一个网址,打开就能用,对非技术用户友好太多。第二,跨平台天然解决。Web Serial 是浏览器标准,只要浏览器支持,操作系统是什么无所谓。第三,便于分享。一个链接发出去,同事、客户直接点开就能改配置,不用教他们装环境。
代价当然也有:必须用 Chromium 浏览器,必须 HTTPS,而且串口权限每次都要用户手动授权。但相比零安装带来的便利,这些代价我认为值得。
3.2 通信协议的设计
浏览器和 ESP32 之间要传数据,得约定一套协议。我用的是一套简单的文本行协议,每条命令以换行符结尾,ESP32 收到后解析、执行、返回结果。
命令格式设计成CMD:参数1:参数2\n这种形式,比如:
GET:wifi_cfg:ssid\n读取 wifi_cfg 命名空间下 ssid 的值SET:wifi_cfg:ssid:MyNetwork\n写入DEL:wifi_cfg:ssid\n删除LIST:wifi_cfg\n列出命名空间下所有键
返回格式统一成OK:值\n或者ERR:错误码\n。这样浏览器端解析起来简单,用split(':')就能拆。
为什么用文本协议而不是二进制?因为调试方便。你打开任意串口助手,手动敲命令就能测试,不用写解析器。而且文本协议对人类可读,出问题一眼就能看出是命令发错了还是设备返回错了。对于这种低频、小数据量的场景,文本协议的开销完全可以接受。
提示:命令里如果包含冒号(比如密码里有冒号),需要做转义或者用长度前缀。我一开始没考虑这点,结果用户密码里带冒号就解析错了。后来改成用
\x1f(单元分隔符)作为字段分隔符,问题解决。
3.3 ESP32 端固件的职责
浏览器工具再花哨,真正干活的还是 ESP32 上的固件。这个固件要做几件事:
- 初始化串口,波特率 115200,和浏览器端对齐。
- 初始化 NVS,调用
nvs_flash_init()。 - 循环读取串口数据,按行解析命令。
- 根据命令类型调用对应的 NVS API,把结果格式化后写回串口。
- 处理完写命令后,可选地触发一次 WiFi 重连,让新配置立即生效。
这个固件本身很小,核心逻辑不到 200 行。它和你的业务固件可以是同一个,也可以是一个独立的"配置模式"固件。我倾向于把它集成进主固件,通过一个特定的串口命令(比如ENTER_CFG_MODE)进入配置模式,这样不用来回刷两个固件。
4. 核心细节与实操要点
4.1 NVS 初始化的那些坑
nvs_flash_init()这个函数看着简单,但它会返回几种不同的错误码,处理不当会出问题。
最常见的是ESP_ERR_NVS_NO_FREE_PAGES和ESP_ERR_NVS_NEW_VERSION_FOUND。前者说明 NVS 分区满了,后者说明分区里的数据是旧版本格式。这两种情况的标准处理方式是先擦除再重新初始化:
esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } ESP_ERROR_CHECK(err);这段代码几乎是 ESP-IDF 项目的标配,但很多人直接抄了不知道为什么要这么写。原因就是上面说的:NVS 分区可能因为反复写入而耗尽空闲页,或者固件升级后 NVS 格式版本不匹配,这两种情况都必须擦除重来,否则后续所有 NVS 操作都会失败。
注意:
nvs_flash_erase()会清空整个 NVS 分区,包括 WiFi 驱动自己存的数据。擦完之后 WiFi 可能需要重新校准,第一次连接会慢一点。这是正常现象,不用担心。
4.2 读写字符串的正确姿势
NVS 读写字符串有个容易踩的坑:长度参数。
读的时候,nvs_get_str需要你先传一个缓冲区,并且把缓冲区大小传进去。如果缓冲区太小,函数会返回ESP_ERR_NVS_INVALID_LENGTH,同时把你需要的实际长度写回长度参数。所以正确的做法是分两步:先传NULL和长度指针拿到实际长度,再分配缓冲区读。
size_t len = 0; nvs_get_str(handle, "ssid", NULL, &len); // 第一次拿长度 char* buf = malloc(len); nvs_get_str(handle, "ssid", buf, &len); // 第二次读数据写的时候相对简单,nvs_set_str(handle, "ssid", value)直接传字符串就行。但写完必须调nvs_commit(handle),否则数据可能还在缓存里没落盘,掉电就丢了。这个commit很多人会忘,尤其是写完之后马上重启设备,结果发现值没存进去。
4.3 串口命令解析的边界处理
串口数据是流式的,可能一次收到半条命令,也可能一次收到多条。所以解析逻辑必须能处理"粘包"和"拆包"。
我的做法是维护一个行缓冲区,每次从串口读到数据就追加进去,然后扫描缓冲区里有没有换行符。有的话就把换行符之前的内容取出来当作一条完整命令处理,剩下的留在缓冲区等下次。这样无论数据怎么分片,都能正确解析。
缓冲区大小要设个上限,比如 256 字节。如果超过上限还没遇到换行符,说明数据异常,直接清空缓冲区并返回错误。否则恶意或者错误的数据可能把内存撑爆。
提示:串口读取建议用非阻塞方式,配合 FreeRTOS 的任务和队列。我一开始用阻塞读,结果配置模式下设备啥也干不了,只能等命令。后来改成独立任务 + 队列,主循环该干嘛干嘛,配置命令来了再处理。
4.4 浏览器端的流式读写
Web Serial 的读写都是基于 Streams API 的,和传统的read()/write()不太一样,需要点时间适应。
写数据相对简单:
const writer = port.writable.getWriter(); const encoder = new TextEncoder(); await writer.write(encoder.encode("GET:wifi_cfg:ssid\n")); writer.releaseLock();读数据要用ReadableStreamDefaultReader,配合一个循环持续读取:
const reader = port.readable.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; // value 是 Uint8Array,解码后追加到接收缓冲区 }这里有个关键点:读循环必须一直跑着,不能读一次就退出。因为 ESP32 的返回是异步的,你不知道它什么时候回。我的做法是开一个后台读循环,把收到的数据解码后按行拆分,每收到一行就 resolve 一个 Promise,上层用await等结果。
注意:
reader.read()返回的value是Uint8Array,不是字符串。必须用TextDecoder解码。而且一次read()可能只收到半个字符(多字节 UTF-8 被截断),所以解码器要用{ stream: true }选项,让它自己处理跨块的字符边界。
5. 完整实操流程
5.1 硬件准备与接线
你需要一块 ESP32 开发板(任意型号都行,ESP32、ESP32-S3、ESP32-C3 都可以),一根 USB 数据线。注意必须是数据线,不是纯充电线。我见过太多人拿着充电线折腾半天,以为是驱动问题,其实是线里根本没有数据线芯。
接线就是把开发板的 USB 口连到电脑。如果你的板子没有板载 USB 转串口芯片,需要外接一个 USB-TTL 模块,接线是:模块 TX 接 ESP32 RX,模块 RX 接 ESP32 TX,GND 对 GND。TX 和 RX 要交叉,这个新手经常接反。
5.2 烧录配置代理固件
用 Arduino IDE 或者 PlatformIO 都行。我用 PlatformIO 演示,因为依赖管理更清晰。
platformio.ini配置:
[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino monitor_speed = 115200核心代码结构:
#include <Arduino.h> #include <nvs_flash.h> #include <nvs.h> void setup() { Serial.begin(115200); esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } Serial.println("READY"); } void loop() { if (Serial.available()) { String line = Serial.readStringUntil('\n'); handleCommand(line); } }handleCommand里根据命令前缀分发到不同的处理函数。写命令处理完记得nvs_commit,然后回OK。
烧录完成后,打开串口监视器,应该能看到READY。这时候手动敲GET:wifi_cfg:ssid回车,如果返回ERR:NOT_FOUND,说明固件工作正常,只是键还不存在。
5.3 打开浏览器工具并连接
工具页面部署在 HTTPS 上(本地测试可以用localhost)。打开后点"连接设备"按钮,浏览器弹出串口选择框,选中 ESP32 对应的那个口(Windows 上通常是 COMx,Mac/Linux 上是 /dev/ttyUSBx 或 /dev/cu.usbserial-xxx)。
连接成功后,页面会显示设备已就绪。这时候在输入框里填命名空间、键、值,点"写入",浏览器就把命令发过去,ESP32 执行后返回结果,页面显示成功。
5.4 写入 WiFi 凭据并验证
假设你的业务固件约定从wifi_cfg命名空间读ssid和password,那么操作就是:
- 命名空间填
wifi_cfg,键填ssid,值填你的 WiFi 名,点写入。 - 命名空间填
wifi_cfg,键填password,值填密码,点写入。 - 点"重启设备"按钮(或者手动按板子上的复位键)。
- 观察串口输出,设备应该用新凭据连上 WiFi。
验证是否真的写进去了,可以点"读取",把值读回来对比。如果读回来和写进去的一致,说明 NVS 写入成功。
提示:WiFi 密码如果包含特殊字符(比如
:、\n),一定要确认协议里的转义处理正确。我建议密码里避免用换行符,因为换行符是命令分隔符,会直接把命令截断。
5.5 参数选择背后的计算
有人问波特率为什么选 115200。其实 ESP32 支持更高的波特率,比如 921600,理论上传输更快。但配置命令的数据量极小(几十字节),115200 下传输时间可以忽略不计。而高波特率对 USB 转串口芯片和线材质量要求更高,容易出错。所以 115200 是稳定性和速度的最佳平衡点,没必要调高。
NVS 分区大小呢?默认的nvs分区通常是 0x6000(24KB)或者 0x9000(36KB)。存几个 WiFi 凭据绰绰有余。一个字符串条目最多占 32 字节头部加数据,24KB 能存几百个键值对。除非你要存大量数据,否则不用动分区表。
6. 常见问题与排查技巧
6.1 连接不上串口
这是最高频的问题。排查顺序如下:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 浏览器不弹设备选择框 | 不是 HTTPS 或 localhost | 换 HTTPS 页面或本地测试 |
| 弹框里没有设备 | 驱动没装 | 装 CP210x 或 CH340 驱动 |
| 选了设备但打不开 | 串口被占用 | 关掉 Arduino IDE 串口监视器等占用程序 |
| 打开后收不到数据 | 波特率不对 | 确认固件和网页都是 115200 |
| 数据乱码 | 波特率不匹配或线材差 | 换线,核对波特率 |
驱动问题我单独说一句。ESP32 开发板常用的 USB 转串口芯片有 CP2102、CH340、CH9102 几种。Windows 10/11 一般能自动装 CP2102 驱动,CH340 有时候要手动装。Mac 上较新系统对 CH340 支持不好,建议优先选 CP2102 的板子。
6.2 写入成功但重启后没生效
这种情况通常是业务固件读取的命名空间或键名和工具写入的不一致。比如工具写进了wifi_cfg,但固件读的是nvs.net80211。这两个是完全不同的命名空间,读不到很正常。
排查方法:在业务固件里加日志,把读取的命名空间、键名、返回值都打出来。对比工具写入的,看是否一致。命名空间和键名都区分大小写,SSID和ssid是两个不同的键。
还有一种可能是写完之后没nvs_commit,数据没落盘。这个前面说过,加一行nvs_commit就好。
6.3 NVS 分区满了
如果你反复写入大量不同的键,或者频繁写入同一个键,NVS 分区可能被写满。表现是nvs_set_str返回ESP_ERR_NVS_NOT_ENOUGH_SPACE。
NVS 内部有垃圾回收机制,删除的条目会被标记为可回收,但不是立即回收。如果分区真的满了,最直接的办法是擦除整个 NVS 重新开始。但这样会丢掉所有配置,所以要谨慎。
更好的做法是:不要频繁写入。WiFi 凭据这种东西,改一次能用很久,不存在频繁写入的问题。如果你有高频写入的需求,那 NVS 可能不是合适的存储介质,考虑用别的方案。
6.4 浏览器兼容性
前面提过,只有 Chromium 内核支持 Web Serial。但即使是 Chrome,不同版本行为也有差异。我实测 Chrome 89 到最新版都能用,但建议用较新版本,老版本可能有 Streams API 的 bug。
另外,某些企业环境或者安全软件会禁用 Web Serial,表现是navigator.serial是undefined。这种情况没法绕过,只能换环境。
提示:在页面加载时检测
if (!navigator.serial),不支持就显示提示,引导用户换 Chrome 或 Edge。别让用户对着按钮点半天没反应。
6.5 独家避坑经验
分享几个我踩过的坑,文档里不会写。
第一,串口打开后要等一下再发命令。ESP32 复位后有几秒钟的启动时间,这期间串口可能还没准备好。如果你在页面加载后立刻发命令,可能丢。我的做法是连接成功后等 2 秒,或者等收到READY再发。
第二,命令要加超时。浏览器发命令后如果 ESP32 没响应(比如固件卡死),await会一直挂着。必须加超时,比如 3 秒没回就报错。否则用户以为卡了,反复点,命令堆积。
第三,写入后读回验证。不要相信写入返回的OK,因为OK只代表 API 调用没报错,不代表值真的落盘了。写完立刻读一次,对比一致才算成功。这个习惯能帮你发现很多隐蔽问题。
第四,密码里的特殊字符。前面提过冒号的问题。我的最终方案是用不可见字符\x1f做分隔符,并且在浏览器端对值做 URL 编码,ESP32 端解码。这样任何字符都能安全传输。
7. 这套方案还能怎么扩展
工具跑通之后,你会发现它的价值不止于改 WiFi 密码。任何存在 NVS 里的配置,都能用同样的方式改。比如设备的运行参数、阈值、校准数据、设备 ID,全都可以做成网页可配置。
我后来把它扩展成了一个通用的"设备配置面板":左边列出所有命名空间和键,右边是编辑区,支持增删改查,还能导入导出 JSON。这样现场调试的时候,不用带电脑装环境,找个能上网的浏览器就能干活。
再进一步,可以加个"配置模板"功能。把常用的配置存成模板,一键写入。对于批量部署的场景,这个能省大量时间。
还有一个方向是加权限控制。现在工具是裸的,谁拿到都能改。如果部署在公网,得加个简单的鉴权,比如设备端校验一个 token,或者网页端加密码。这个看你的安全需求,内网用的话可以省。
最后说个我自己的体会:把配置和固件解耦,是嵌入式项目走向工程化的第一步。硬编码看着省事,但每次改配置都要重新走一遍编译烧录流程,长期看是巨大的时间浪费。NVS 加浏览器工具这套组合,把配置这件事从"开发流程"里剥离出来,变成了"运维操作",谁都能干,不用等开发。这个思路上的转变,比工具本身更有价值。