1. 从一个让人抓狂的场景说起
如果你玩过 ESP32,大概率经历过这个场景:设备已经焊好、装进壳子、挂在墙上,突然要换个 WiFi 密码。你翻出 USB 线,拆壳,插上,打开 Arduino IDE 或者 PlatformIO,改一行ssid和password,编译,烧录,等半天,再装回去。整个过程十分钟起步,如果设备装在不好够到的地方,那更是折磨。
问题的根源在于:很多人把 WiFi 配置直接写死在代码里,编译进固件。固件是只读的,改配置就等于改固件,改固件就得重新烧录。但 ESP32 本身带了一块 NVS(Non-Volatile Storage,非易失性存储),它本来就是用来存这种运行时配置的。把 WiFi 信息放进 NVS,改配置就变成了改一个键值对,跟固件没关系。
那怎么改 NVS 里的键值?传统做法是串口发命令,或者自己写个 Web 服务器。串口要接线,Web 服务器要设备先联网——可你改 WiFi 密码的时候,设备恰恰就是连不上网。这就成了一个死循环。
我最近折腾的一个方案,是用浏览器直接读写 ESP32 的 NVS。原理不复杂:ESP32 通过串口把 NVS 内容吐出来,浏览器端用 Web Serial API 接收和发送,你在网页上填个表单点提交,新的键值就写进去了。整个过程不需要重新编译固件,不需要设备联网,只需要一根 USB 线和一个支持 Web Serial 的浏览器(Chrome、Edge 都行)。
这篇文章我会把这个方案的来龙去脉讲清楚:为什么选 NVS 而不是其他存储方式,Web Serial 到底怎么跟 ESP32 通信,NVS 的键值怎么组织才合理,固件端要写哪些代码,浏览器端怎么解析数据,以及我在实操中踩过的坑。如果你手上有 ESP32 项目,尤其是那种装好了就不想再拆的设备,这套思路能帮你省下大量重复烧录的时间。
2. 为什么是 NVS,而不是写死在代码里
2.1 写死配置的代价
先说说为什么很多人会把 WiFi 信息写死在代码里。原因很简单:省事。新建一个 Arduino 项目,WiFi.begin("MySSID", "MyPassword")一行搞定,编译烧录就能跑。对于一次性项目、实验性质的代码,这没什么问题。
但一旦设备要部署,问题就来了。WiFi 密码会变,路由器会换,设备可能要搬到不同的地方。每次变动都要重新烧录,这意味着:
- 需要物理接触设备,拆壳接线
- 需要一台装了开发环境的电脑
- 需要重新编译,ESP32 的编译速度你懂的,动辄一两分钟
- 如果设备在偏远位置或者封装严密,操作成本极高
更麻烦的是,如果你把固件交给别人用,别人想改 WiFi 就得找你要源码、装环境、重新编译。这显然不现实。
2.2 NVS 的本质:一块专门存配置的小分区
ESP32 的 Flash 里有一块专门划出来的区域叫 NVS 分区,默认大小通常是 24KB 左右(具体取决于分区表)。它的设计目标就是存键值对,类似一个小型的、掉电不丢的字典。键是字符串,值可以是整数、字符串、二进制 blob 等类型。
NVS 和代码是分开的。你烧录固件的时候,默认不会擦除 NVS 分区(除非你手动执行了全片擦除)。这就意味着:固件可以反复升级,NVS 里的配置可以保留。反过来,改 NVS 里的配置,完全不需要动固件。
这个特性正好解决了我们的问题。把 WiFi 的 SSID 和密码存进 NVS,代码启动时从 NVS 读取,连不上就进配网模式。改密码的时候,只需要改 NVS 里的两个键值,固件一行都不用动。
2.3 几种配置存储方案的对比
除了 NVS,ESP32 上还有几种存配置的方式,我列个表对比一下:
| 方案 | 掉电保存 | 改配置是否需重烧 | 读写速度 | 适合场景 |
|---|---|---|---|---|
| 代码写死 | 是 | 是 | 最快 | 一次性实验 |
| NVS | 是 | 否 | 快 | 运行时配置、WiFi 信息 |
| SPIFFS/LittleFS | 是 | 否 | 中等 | 文件、日志、网页资源 |
| SD 卡 | 是 | 否 | 慢 | 大量数据、可插拔 |
| EEPROM 模拟 | 是 | 否 | 慢 | 少量字节 |
NVS 的优势在于它是为键值对设计的,API 简单,读写快,而且 ESP-IDF 和 Arduino 框架都原生支持。SPIFFS 更适合存文件,用它存几个配置项有点杀鸡用牛刀。EEPROM 模拟在 ESP32 上其实底层也是用 Flash 模拟的,性能和便利性都不如 NVS。
所以结论很明确:WiFi 配置这种小量、结构化、需要频繁读写的场景,NVS 是最合适的。
3. Web Serial:让浏览器直接跟串口对话
3.1 Web Serial API 是什么
Web Serial API 是浏览器提供的一套接口,允许网页直接读写串口设备。你插上 ESP32,浏览器弹出一个授权框,你选中对应的串口,之后网页就能像串口助手一样收发数据。
这个 API 目前主要在 Chromium 内核的浏览器上支持,Chrome、Edge、Opera 都可以。Firefox 和 Safari 暂时不支持。所以用这个方案,得用 Chrome 或 Edge。
它的工作流程大致是:
- 网页调用
navigator.serial.requestPort(),浏览器弹出串口选择框 - 用户选中 ESP32 对应的串口,网页拿到
SerialPort对象 - 调用
port.open({ baudRate: 115200 })打开串口 - 通过
port.readable和port.writable读写数据
整个过程不需要安装任何驱动(除了 ESP32 本身的 USB 转串口芯片驱动,比如 CP2102 或 CH340),也不需要装串口助手软件。一个网页就够了。
3.2 为什么不用传统串口助手
你可能会问:我直接用串口助手发命令不就行了,为什么要搞个网页?
串口助手确实能发命令,但它有几个问题:
- 需要安装软件,不同平台还要找对应版本
- 命令格式要记,发错了可能把 NVS 写坏
- 没有表单界面,改个 WiFi 密码要手敲一串命令
- 没法做校验,比如 SSID 长度、密码格式
网页方案的好处是:界面友好,有表单校验,可以做成傻瓜式操作。你把网页发给别人,别人打开就能用,不需要装任何东西。对于要给非技术用户使用的场景,这一点很关键。
3.3 通信协议的设计
浏览器和 ESP32 之间通过串口传数据,得约定一个简单的协议。我用的方案是文本行协议,每行一条命令,以换行符结尾。这样解析简单,调试也方便。
命令格式大概是这样:
GET <key> // 读取某个键的值 SET <key> <value> // 设置某个键的值 LIST // 列出所有键 DEL <key> // 删除某个键ESP32 收到命令后,执行对应操作,然后返回结果。返回格式:
OK <value> // 操作成功,附带值 ERR <message> // 操作失败,附带错误信息比如读取 WiFi SSID:
发送: GET wifi_ssid 返回: OK MyHomeNetwork设置 WiFi 密码:
发送: SET wifi_pass MyNewPassword123 返回: OK这个协议足够简单,用 Arduino 的Serial.readStringUntil('\n')就能解析。浏览器端用TextDecoder把字节流转成字符串,按行分割即可。
注意:串口通信是字节流,不是消息流。ESP32 可能一次发多个字节,浏览器可能分多次收到。所以浏览器端要维护一个缓冲区,按换行符切分完整的行,不能假设一次
read()就是一条完整消息。
4. 固件端:NVS 读写与串口命令解析
4.1 NVS 的初始化与基本操作
在 Arduino 框架下,NVS 的接口是Preferences库。用法很直观:
#include <Preferences.h> Preferences prefs; void setup() { Serial.begin(115200); prefs.begin("wifi", false); // 命名空间 "wifi",false 表示可读写 }begin的第一个参数是命名空间,NVS 里不同命名空间的键是隔离的。第二个参数false表示以读写模式打开,true是只读。
读写操作:
// 写入字符串 prefs.putString("ssid", "MyHomeNetwork"); prefs.putString("pass", "MyPassword123"); // 读取字符串,第二个参数是默认值 String ssid = prefs.getString("ssid", ""); String pass = prefs.getString("pass", ""); // 删除某个键 prefs.remove("ssid"); // 清空整个命名空间 prefs.clear();这里有个细节:Preferences的putString在写入时会自动处理长度,但 NVS 对字符串长度有限制,单个值最大约 4000 字节。WiFi 密码和 SSID 远小于这个限制,不用担心。
4.2 串口命令解析的实现
固件端的主循环里,不断检查串口是否有数据,有就读取一行,解析命令:
void loop() { if (Serial.available()) { String line = Serial.readStringUntil('\n'); line.trim(); if (line.length() == 0) return; handleCommand(line); } } void handleCommand(String line) { int firstSpace = line.indexOf(' '); String cmd = (firstSpace == -1) ? line : line.substring(0, firstSpace); String args = (firstSpace == -1) ? "" : line.substring(firstSpace + 1); args.trim(); if (cmd == "GET") { String value = prefs.getString(args.c_str(), ""); Serial.println("OK " + value); } else if (cmd == "SET") { int sep = args.indexOf(' '); if (sep == -1) { Serial.println("ERR missing value"); return; } String key = args.substring(0, sep); String value = args.substring(sep + 1); prefs.putString(key.c_str(), value); Serial.println("OK"); } else if (cmd == "LIST") { // 列出所有键,见下一节 } else if (cmd == "DEL") { prefs.remove(args.c_str()); Serial.println("OK"); } else { Serial.println("ERR unknown command"); } }这段代码有几个要注意的地方:
readStringUntil('\n')会阻塞直到收到换行符或超时,默认超时是 1000ms。如果浏览器发的命令没有换行符,会卡住。所以浏览器端发送时必须加\n。trim()去掉首尾空白,防止\r\n里的\r干扰。- 参数解析用
indexOf(' ')找第一个空格,把命令和参数分开。SET 命令的参数里还有空格,所以再找一次空格分键和值。
4.3 列出所有键的实现
Preferences库没有直接提供“列出所有键”的 API,但可以用底层的 NVS 接口实现。ESP-IDF 提供了nvs_entry_find和nvs_entry_next来遍历:
#include <nvs.h> void listKeys() { nvs_iterator_t it = nullptr; esp_err_t res = nvs_entry_find("nvs", "wifi", NVS_TYPE_ANY, &it); while (res == ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, &info); Serial.print("KEY "); Serial.println(info.key); res = nvs_entry_next(&it); } nvs_release_iterator(it); Serial.println("OK"); }这里nvs_entry_find的第一个参数是分区名,通常是"nvs";第二个是命名空间,跟prefs.begin里的一致。遍历完记得nvs_release_iterator释放资源。
实操心得:
nvs_entry_find在 Arduino 环境下需要包含<nvs.h>,而且不同版本的 ESP32 Arduino 核心对这个接口的封装略有差异。如果编译报错,检查一下核心版本,2.0.0 以上基本都支持。
4.4 启动时从 NVS 读取 WiFi 配置
固件启动时,先从 NVS 读 WiFi 信息,有就直接连,没有就进配网模式:
void connectWiFi() { String ssid = prefs.getString("ssid", ""); String pass = prefs.getString("pass", ""); if (ssid.length() == 0) { Serial.println("NO_WIFI_CONFIG"); return; } WiFi.begin(ssid.c_str(), pass.c_str()); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(500); Serial.print("."); } if (WiFi.status() == WL_CONNECTED) { Serial.println("\nWIFI_CONNECTED " + WiFi.localIP().toString()); } else { Serial.println("\nWIFI_FAILED"); } }这里加了 15 秒超时,避免连不上时死等。连不上就打印WIFI_FAILED,浏览器端可以据此提示用户检查配置。
5. 浏览器端:Web Serial 的完整实现
5.1 打开串口与授权
浏览器端的第一步是请求串口权限:
let port; let reader; let writer; async function connectSerial() { port = await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); writer = port.writable.getWriter(); startReading(); }requestPort()会弹出浏览器原生的串口选择框,用户选中 ESP32 对应的串口后返回SerialPort对象。open时波特率要跟固件里Serial.begin的一致,通常 115200。
5.2 读取数据的缓冲区处理
串口数据是流式的,必须维护缓冲区按行切分:
let buffer = ''; async function startReading() { const decoder = new TextDecoder(); reader = port.readable.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); // 最后一段可能不完整,留到下次 for (const line of lines) { handleLine(line.trim()); } } }关键点是buffer = lines.pop()。split('\n')后最后一段可能是半条消息,不能立即处理,要留到下次数据到达时拼接。这是串口通信最容易出错的地方,很多人直接对每次read()的结果做split,结果消息被截断,解析失败。
5.3 发送命令与等待响应
发送命令很简单,把字符串转成字节写进去:
async function sendCommand(cmd) { const encoder = new TextEncoder(); await writer.write(encoder.encode(cmd + '\n')); }但发送后要等响应,这就需要一个机制把响应跟请求对应起来。我用的是 Promise + 回调的方式:
let pendingResolve = null; function handleLine(line) { if (line.startsWith('OK') || line.startsWith('ERR')) { if (pendingResolve) { pendingResolve(line); pendingResolve = null; } } else if (line.startsWith('KEY ')) { // 处理 LIST 返回的键 keyList.push(line.substring(4)); } } function sendAndWait(cmd) { return new Promise((resolve) => { pendingResolve = resolve; sendCommand(cmd); setTimeout(() => { if (pendingResolve) { pendingResolve('ERR timeout'); pendingResolve = null; } }, 3000); }); }加超时是必须的。如果 ESP32 没响应(比如固件没跑起来、串口选错了),Promise 会永远挂着,界面卡死。3 秒超时后返回错误,用户知道出了问题。
5.4 表单界面与校验
界面部分用原生 HTML 就行,不需要框架:
<div> <label>WiFi 名称</label> <input id="ssid" type="text" maxlength="32"> </div> <div> <label>WiFi 密码</label> <input id="pass" type="text" maxlength="64"> </div> <button onclick="saveWifi()">保存</button> <div id="status"></div>保存逻辑:
async function saveWifi() { const ssid = document.getElementById('ssid').value.trim(); const pass = document.getElementById('pass').value; if (ssid.length === 0) { showStatus('WiFi 名称不能为空'); return; } if (pass.length < 8) { showStatus('密码至少 8 位'); return; } let res = await sendAndWait(`SET ssid ${ssid}`); if (!res.startsWith('OK')) { showStatus('保存 SSID 失败: ' + res); return; } res = await sendAndWait(`SET pass ${pass}`); if (!res.startsWith('OK')) { showStatus('保存密码失败: ' + res); return; } showStatus('保存成功,重启设备生效'); }校验放在浏览器端,减少无效的串口通信。SSID 最长 32 字节,密码最短 8 位(WPA2 的要求),这些规则直接在前端拦掉。
注意:SSID 里可能有空格,而我们的协议用空格分隔参数。如果 SSID 是 "My Home",
SET ssid My Home会被解析成键ssid、值My,后面的Home丢了。解决办法是约定 SSID 和密码里不含空格,或者改用其他分隔符(比如|)。我在实际项目里用的是后者,协议改成SET <key>|<value>,更稳妥。
6. 实操中踩过的坑与排查技巧
6.1 串口被占用
最常见的问题:浏览器打不开串口,报Failed to open serial port。原因通常是串口被其他程序占用了,比如 Arduino IDE 的串口监视器还开着,或者 PlatformIO 的 monitor 没关。解决办法就是关掉所有可能占用串口的程序,再刷新网页重试。
还有一个情况是驱动问题。ESP32 开发板常用的 USB 转串口芯片有 CP2102、CH340、FTDI 等,Windows 上可能需要手动装驱动。设备管理器里如果看到带感叹号的未知设备,就是驱动没装好。
6.2 数据乱码
串口收到的数据是乱码,通常是波特率不匹配。检查三处:固件里Serial.begin的波特率、浏览器port.open的波特率、以及 ESP32 的晶振频率。大部分开发板是 115200,但有些定制板可能是 9600 或 921600。
另一个可能是数据位、停止位、校验位不匹配。Web Serial 默认是 8 数据位、1 停止位、无校验,这跟 Arduino 的默认设置一致,一般不用改。
6.3 NVS 写入失败
prefs.putString返回的是写入的字节数,如果返回 0,说明写入失败。常见原因:
- 命名空间没打开,或者以只读模式打开了
- NVS 分区满了(虽然 24KB 存几个字符串不太可能满)
- Flash 操作期间断电,导致 NVS 损坏
NVS 损坏的话,可以在代码里调用nvs_flash_erase()擦除整个 NVS 分区,然后重新初始化。但这会清掉所有配置,慎用。
6.4 命令没响应
浏览器发了命令,ESP32 没反应。排查顺序:
- 确认串口选对了,有些电脑有多个串口设备
- 确认固件在跑,看串口有没有启动日志
- 确认命令以
\n结尾,没有换行符readStringUntil会一直等 - 确认波特率一致
- 确认 ESP32 没有在
delay里卡太久,主循环要及时回到Serial.available检查
我遇到过一次,固件里有个delay(10000)的初始化等待,导致前 10 秒串口命令完全没响应。后来把长延时改成非阻塞的millis判断,问题解决。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 打不开串口 | 被其他程序占用 | 关闭串口监视器等程序 |
| 数据乱码 | 波特率不匹配 | 统一改为 115200 |
| 命令无响应 | 缺换行符 | 发送时加\n |
| 写入失败 | 命名空间只读 | begin第二参数设 false |
| 消息截断 | 缓冲区处理错误 | 按行切分,保留半条 |
| 连接超时 | 固件卡在延时 | 改非阻塞延时 |
| SSID 带空格出错 | 协议分隔符冲突 | 改用 ` |
7. 这套方案的扩展玩法
7.1 不止改 WiFi,任何配置都能改
NVS 里存的不只是 WiFi。设备名称、MQTT 服务器地址、上报间隔、阈值参数,这些都可以放 NVS,都可以通过这个网页工具改。你只需要在固件里把对应的键读写接上,浏览器端加几个表单字段就行。
我现在的做法是:固件启动时从 NVS 读一整套配置,每个配置项有默认值。网页工具列出所有键,用户可以逐个修改。这样一套工具适配多个项目,改改表单就行。
7.2 加个配置导出导入
既然能读能写,导出导入就是顺理成章的事。浏览器端把所有键值读出来,拼成 JSON,让用户下载。导入时解析 JSON,逐条写入。这样设备换新的时候,配置可以一键迁移。
async function exportConfig() { const keys = await listKeys(); const config = {}; for (const key of keys) { const res = await sendAndWait(`GET ${key}`); if (res.startsWith('OK ')) { config[key] = res.substring(3); } } const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'esp32-config.json'; a.click(); }7.3 做成离线网页
Web Serial 需要 HTTPS 或者 localhost 才能用(安全上下文限制)。如果你把网页部署到服务器,得配 HTTPS。但更简单的做法是做成单个 HTML 文件,用户下载到本地,双击用 Chrome 打开。本地文件file://协议下,Web Serial 也是可用的(Chrome 对file://有特殊处理)。
这样你发给别人的就是一个 HTML 文件,不需要服务器,不需要联网,打开就能用。对于内部工具来说,这是最省事的方案。
实操心得:单文件 HTML 把所有 JS 和 CSS 内联进去,方便分发。但要注意,如果用了外部库(比如某些 UI 框架),得把库也内联或者用 CDN。我建议尽量用原生 API,减少依赖,一个文件几百 KB,发微信都行。
7.4 安全上的考虑
这个工具能改 NVS,意味着能改设备的所有配置。如果设备部署在公共场所,USB 口暴露在外,任何人都能插上电脑改配置。所以:
- 敏感配置(比如密钥)不要明文存 NVS,至少做个简单加密
- 设备外壳设计上考虑 USB 口的物理防护
- 如果配置改动需要审计,可以在固件里加日志,记录每次 NVS 写入
当然,对于家用或者内部设备,这些顾虑不大。但如果是商用产品,得提前想清楚。
8. 我个人在实际操作中的几点体会
这套方案我用了大半年,部署了十几台设备,最大的感受是:把配置和固件分离,是嵌入式开发里性价比最高的一个习惯。前期多花一两个小时把 NVS 读写和串口命令框架搭好,后期每次改配置省下的时间都是纯赚。
几个具体的建议:
第一,NVS 的键名要有命名规范。我用的是模块_字段的格式,比如wifi_ssid、wifi_pass、mqtt_host、mqtt_port。这样在网页工具里列出来一目了然,也不容易跟其他模块冲突。
第二,固件里读 NVS 一定要给默认值。prefs.getString("ssid", "")的第二个参数不能省。设备第一次启动时 NVS 是空的,没有默认值会读到垃圾数据,导致各种奇怪问题。
第三,浏览器端的超时和错误处理要做足。串口通信不可靠,线松了、设备重启了、固件崩了,都会导致命令无响应。每个sendAndWait都要有超时,每个操作都要有失败提示,不然用户不知道发生了什么。
第四,协议设计留扩展余地。我一开始只支持字符串,后来要存整数(比如端口号、上报间隔),又加了SETINT和GETINT。如果一开始就用带类型前缀的协议(比如SET str wifi_ssid xxx、SET int mqtt_port 1883),后面就不用改协议了。
最后分享一个小技巧:在固件里加一个PING命令,返回OK PONG。浏览器连接串口后先发个PING,收到PONG就说明通信正常,可以开始操作。这个握手能快速判断串口是否选对、固件是否在跑,省去很多排查时间。