ESP32 NVS 浏览器编辑器:免重刷固件动态修改 WiFi 配置
1. 从一个真实痛点说起:改个 WiFi 密码为什么要重刷固件
搞 ESP32 的朋友大概率都遇到过这个场景:设备已经装到现场了,外壳封好、螺丝拧死,甚至灌了胶,结果甲方一个电话打过来——“WiFi 密码换了,你过来改一下”。你心里清楚,改密码这事在代码里就是改两行字符串,但问题是这两行字符串被编译进了固件里,要改就得重新编译、重新烧录,人还得跑一趟现场。
更难受的是有些设备装在吊顶里、配电箱里、户外立杆上,拆一次的成本远超改密码本身。我见过最夸张的一个案例,某环境监测项目,二十多个节点分布在三栋楼里,就为了改个 WiFi 密码,两个工程师跑了两天。
这个问题的根源在于:很多人把 WiFi 配置硬编码在了源码里,比如const char* ssid = "MyWiFi";这种写法。编译之后这些字符串就固化在 flash 的代码段里了,运行时只读,改不了。而 ESP32 其实自带了一套非易失性存储机制——NVS(Non-Volatile Storage),专门用来存这种需要掉电保存、又要能动态修改的键值对数据。WiFi 的 SSID 和密码完全可以存在 NVS 里,程序启动时读出来用,改密码的时候只改 NVS 就行,根本不用碰固件。
那怎么改 NVS?传统做法是串口发命令、或者自己写个 Web 服务器、或者用蓝牙配网。这些方案各有各的麻烦:串口要接线,Web 服务器要占代码空间还得处理各种请求,蓝牙配网对普通用户来说门槛太高。而今天要聊的这个思路,是用浏览器直接读写 ESP32 的 NVS 键值——电脑或手机打开一个网页,连上设备,就能看到当前存了哪些键、值是什么,直接改完保存,重启生效。整个过程不需要重新编译,不需要烧录工具,甚至不需要装任何 App。
这篇文章我会把这个方案的来龙去脉讲透:NVS 到底是怎么工作的、为什么它能被浏览器访问、整个工具链怎么搭、代码怎么写、踩过哪些坑。不管你是刚接触 ESP32 的新手,还是已经做过几个项目的老手,应该都能从中拿到能直接用的东西。
2. NVS 到底是个什么东西:先搞懂原理再动手
2.1 NVS 在 ESP32 里的定位和存储结构
NVS 是 ESP-IDF 提供的一个键值存储库,底层跑在 SPI Flash 上,通过分区表划出一块独立区域(通常叫nvs分区,默认大小 0x6000 即 24KB)。它跟文件系统(SPIFFS、LittleFS、FATFS)是并列关系,但定位完全不同:文件系统适合存大块数据、日志、网页资源;NVS 适合存小块的、结构化的、频繁读写的配置项,比如 WiFi 凭据、设备 ID、校准参数、用户偏好。
它的存储结构可以类比成一个“带索引的抽屉柜”。每个键值对叫一个 entry,键是字符串(最长 15 字符),值支持多种类型:uint8_t、int8_t、uint16_t、int16_t、uint32_t、int32_t、uint64_t、int64_t、字符串、二进制 blob。命名空间(namespace)相当于抽屉的分层,不同命名空间下的同名键互不干扰。比如 WiFi 配置放在wifi_config命名空间,设备参数放在device_cfg命名空间,各管各的。
为什么 NVS 能掉电保存又支持频繁改写?因为它用了日志式写入 + 页擦除的机制。Flash 的物理特性是只能把 1 写成 0,要把 0 变回 1 必须整页擦除。NVS 的做法是:写入新值时,不覆盖旧值,而是在后面追加一条新记录,旧记录标记为失效。当一页写满了,才触发一次垃圾回收,把有效数据搬到新页,擦除旧页。这样既延长了 flash 寿命,又保证了掉电时数据不会丢——因为任何时刻至少有一份完整数据在。
注意:NVS 的键名长度上限是 15 个字符(不含结尾的
\0),命名空间名上限也是 15 字符。超了会直接报错,这个坑我在第一次用的时候踩过,键名写了个wifi_configuration,编译能过但运行时报ESP_ERR_NVS_KEY_TOO_LONG,排查了半天。
2.2 为什么把 WiFi 配置放 NVS 而不是硬编码
硬编码的问题前面说了,改一次要重刷。但还有几个隐性好处值得说清楚。
第一是多环境适配。同一份固件,出厂时 NVS 是空的,设备第一次启动进入配网模式,用户填好 WiFi 信息存进 NVS,之后就一直用。这样一份固件可以发给所有客户,不用为每个客户单独编译。
第二是恢复出厂设置变得简单。只要擦除 NVS 分区(或者只擦除某个命名空间),设备就回到未配置状态,重新配网即可。比在代码里写一堆#ifdef判断要干净得多。
第三是参数可远程下发。除了 WiFi,很多运行参数(上报间隔、阈值、灵敏度)也可以放 NVS,配合 OTA 或者配置接口动态调整,不用为了改个阈值就发新固件。
第四是调试友好。设备出问题时,直接读 NVS 看当前配置是什么,比翻代码猜“到底编译进去的是哪个值”要靠谱得多。
2.3 浏览器访问 NVS 的技术可行性分析
浏览器要读写 ESP32 的 NVS,中间必须有一条通信链路。ESP32 本身是个 MCU,没有屏幕没有键盘,浏览器跑在电脑或手机上,两者怎么连?
常见方案有几种:串口(Web Serial API)、蓝牙(Web Bluetooth API)、WiFi(HTTP/WebSocket)。串口和蓝牙都需要浏览器支持对应的 Web API,Chrome 系支持得还行,但 Safari 和 Firefox 就够呛,而且用户要手动授权、选设备,体验一般。WiFi 方案最通用——只要设备和浏览器在同一个局域网,打开网页就能用,手机电脑都行,不挑浏览器。
所以这个工具的核心思路是:ESP32 上跑一个轻量 HTTP 服务器,提供读写 NVS 的接口;浏览器打开一个网页,通过 HTTP 请求调用这些接口,把 NVS 的键值渲染成表单,用户改完提交,服务器写回 NVS。整个链路就是标准的 Web 技术,没有任何黑魔法。
这里有个关键点:HTTP 服务器本身要占资源。ESP32 的 RAM 有限(通常几百 KB 可用),跑一个完整的 Web 服务器 + 网页资源,得精打细算。好在 ESP-IDF 自带的esp_http_server组件足够轻量,网页资源可以放在 flash 里(用EMBED_FILES或者 SPIFFS),不占 RAM。实测下来,一个功能完整的 NVS 编辑器,固件增量大概 100~200KB,RAM 占用 20~30KB,对大多数项目来说可以接受。
3. 方案选型:为什么是 HTTP + 浏览器,而不是别的
3.1 几种常见 NVS 修改方案的横向对比
在动手之前,我把能想到的方案都列出来对比了一遍,这样你选型的时候心里有数。
| 方案 | 实现难度 | 用户体验 | 资源占用 | 跨平台性 | 适用场景 |
|---|---|---|---|---|---|
| 串口命令行 | 低 | 差,要接线装驱动 | 极低 | 差 | 开发调试 |
| 蓝牙配网 | 中 | 中,要装 App | 中 | 中 | 消费级产品 |
| 自建 Web 服务器 | 中 | 好,浏览器直接开 | 中 | 好 | 局域网设备 |
| 物理按键 + LED 指示 | 低 | 差,操作繁琐 | 极低 | 好 | 简单设备 |
| 浏览器工具(本方案) | 中 | 好,功能完整 | 中 | 好 | 需要频繁改配置 |
串口方案最直接,但现场设备往往没有串口引出,而且普通用户不会用。蓝牙配网体验不错,但需要开发 App 或者用现成的配网库,而且蓝牙和 WiFi 共存时射频资源紧张,容易出问题。物理按键方案适合只有一两个参数要改的场景,参数一多就没法用了。
浏览器方案的优势在于:零安装、跨平台、界面可以做得很好看、能同时管理多个参数。缺点是设备必须和浏览器在同一局域网,如果设备已经连不上 WiFi(比如密码错了),那就得先想办法让设备进入 AP 模式,浏览器连上设备的热点再改。这个后面会讲怎么处理。
3.2 HTTP 服务器的选型:esp_http_server vs 其他
ESP-IDF 里做 HTTP 服务器,主流选择是官方的esp_http_server。也有用libesphttpd或者自己撸 socket 的,但没必要。esp_http_server的优势是:
- 官方维护,跟 IDF 版本同步更新
- 支持 URI 注册、GET/POST、WebSocket、文件上传
- 内存占用可控,可以配置最大连接数、URI 数量
- 跟 NVS、WiFi 组件集成度高
配置上主要关注几个参数:max_uri_handlers(注册多少个接口)、max_open_sockets(同时几个连接)、stack_size(服务器任务栈大小)。我一般设max_uri_handlers=8、max_open_sockets=4、stack_size=8192,够用且不浪费。
实操心得:
esp_http_server默认的 URI 匹配是精确匹配,如果你要处理/api/nvs/*这种通配,得用httpd_uri_match_wildcard注册。我第一次写的时候没注意,注册了/api/nvs结果/api/nvs/get一直 404,查文档才发现要单独注册或者用通配匹配。
3.3 前端页面的组织方式:内嵌 vs SPIFFS
网页资源(HTML、CSS、JS)放哪里,有两个选择:编译进固件(内嵌)或者放 SPIFFS 分区。
内嵌的好处是简单,用EMBED_FILES把文件转成 C 数组,直接返回。缺点是改网页要重新编译固件,而且大文件会撑大固件体积。SPIFFS 的好处是网页可以单独更新(通过文件上传接口),固件体积小。缺点是要多管理一个分区,而且 SPIFFS 的读写速度比直接读 flash 慢一点。
我的建议是:如果网页不常改,用内嵌;如果网页要频繁迭代,用 SPIFFS。这个 NVS 编辑器的网页其实不复杂,一个 HTML 文件加一点 JS,压缩后 10KB 左右,内嵌完全没问题。而且内嵌的话,整个工具就是一个固件,烧进去就能用,不用额外上传网页文件,对用户更友好。
4. 核心实现:从 NVS 读写到 HTTP 接口的完整链路
4.1 NVS 初始化与命名空间管理
先看 NVS 的初始化。ESP-IDF 里用 NVS 之前必须先调用nvs_flash_init(),而且要考虑 flash 版本升级、分区损坏等情况。
esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret);这段代码几乎是标配:如果 NVS 分区满了或者版本不匹配,先擦除再初始化。擦除会丢失所有已存数据,所以只在初始化失败时做。
命名空间的管理上,我建议按功能划分。WiFi 配置用一个命名空间wifi_cfg,里面存ssid、password、static_ip等键。设备参数用dev_cfg,存device_id、report_interval等。这样读写的时候逻辑清晰,也方便单独擦除某个命名空间。
nvs_handle_t handle; esp_err_t err = nvs_open("wifi_cfg", NVS_READWRITE, &handle); if (err != ESP_OK) { ESP_LOGE(TAG, "打开命名空间失败: %s", esp_err_to_name(err)); return; } // 读字符串 size_t len = 0; nvs_get_str(handle, "ssid", NULL, &len); // 先获取长度 char* ssid = malloc(len); nvs_get_str(handle, "ssid", ssid, &len); // 写字符串 nvs_set_str(handle, "ssid", "NewWiFi"); nvs_commit(handle); // 必须 commit 才真正写入 nvs_close(handle);这里有个容易忽略的点:nvs_set_*只是把数据写到缓存,必须调用nvs_commit才真正落盘。我见过有人写完没 commit,重启后数据没了,还以为是 flash 坏了。
4.2 枚举 NVS 中所有键值的实现细节
浏览器工具要展示“当前存了哪些键”,就得枚举 NVS。NVS 提供了迭代器接口:
nvs_iterator_t it = NULL; esp_err_t res = nvs_entry_find("nvs", "wifi_cfg", NVS_TYPE_ANY, &it); while (res == ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, &info); // info.key 是键名,info.type 是类型 // 根据类型读取值 res = nvs_entry_next(&it); } nvs_release_iterator(it);注意nvs_entry_find的第一个参数是分区名(通常是"nvs"),第二个是命名空间(传NULL表示所有命名空间)。这个接口在不同 IDF 版本里签名有变化,5.0 之后是nvs_entry_find(partition, namespace, type, &it),老版本是nvs_entry_find(namespace, type, &it),移植代码的时候要注意。
枚举出来之后,根据类型分别读取。字符串和 blob 要先查长度再分配内存,整数直接读。这里要处理一个边界情况:如果某个键的值特别大(比如一个几 KB 的 blob),返回给浏览器的时候要限制大小,不然 HTTP 响应会爆掉。我一般限制单个值不超过 1KB,超了就只返回“值过大,请用其他方式查看”。
4.3 HTTP 接口设计:RESTful 风格的读写接口
接口设计上,我用了最朴素的 RESTful 风格:
| 方法 | 路径 | 功能 | 请求体 | 响应 |
|---|---|---|---|---|
| GET | /api/nvs/list | 列出所有键值 | 无 | JSON 数组 |
| GET | /api/nvs/get?ns=xxx&key=yyy | 读单个键 | 无 | JSON 对象 |
| POST | /api/nvs/set | 写单个键 | JSON | 状态码 |
| POST | /api/nvs/erase | 擦除命名空间 | JSON | 状态码 |
| GET | / | 返回网页 | 无 | HTML |
为什么用 JSON 而不是表单?因为 NVS 的值有类型,JSON 能自然表达字符串、数字、布尔。而且前端用fetch发 JSON 很方便,不用处理表单编码。
写接口的实现要注意:先解析 JSON,再根据类型调用对应的nvs_set_*。ESP-IDF 自带cJSON库,解析起来不费劲。
cJSON* root = cJSON_Parse(req_body); cJSON* ns = cJSON_GetObjectItem(root, "ns"); cJSON* key = cJSON_GetObjectItem(root, "key"); cJSON* value = cJSON_GetObjectItem(root, "value"); cJSON* type = cJSON_GetObjectItem(root, "type"); nvs_handle_t handle; nvs_open(ns->valuestring, NVS_READWRITE, &handle); if (strcmp(type->valuestring, "str") == 0) { nvs_set_str(handle, key->valuestring, value->valuestring); } else if (strcmp(type->valuestring, "i32") == 0) { nvs_set_i32(handle, key->valuestring, value->valueint); } else if (strcmp(type->valuestring, "u8") == 0) { nvs_set_u8(handle, key->valuestring, (uint8_t)value->valueint); } nvs_commit(handle); nvs_close(handle);注意:
nvs_set_str的键名和值都不能包含\0以外的特殊字符,而且值如果是空字符串,某些 IDF 版本会报错。我一般在前端做校验,空值直接拒绝提交。
4.4 前端页面:把 NVS 键值渲染成可编辑表单
前端部分其实不复杂,核心就三件事:加载时拉取键值列表、渲染成表格、提交时发 POST 请求。
async function loadNVS() { const resp = await fetch('/api/nvs/list'); const data = await resp.json(); const tbody = document.querySelector('#nvs-table tbody'); tbody.innerHTML = ''; data.items.forEach(item => { const tr = document.createElement('tr'); tr.innerHTML = ` <td>${item.ns}</td> <td>${item.key}</td> <td>${item.type}</td> <td><input value="${item.value}">idf_component_register( SRCS "main.c" "nvs_api.c" INCLUDE_DIRS "." EMBED_FILES "www/index.html" )这样编译后,index.html的内容会变成_binary_index_html_start和_binary_index_html_end两个符号,可以直接当字符串用。
5.2 WiFi 连接与 HTTP 服务器启动
设备启动后,先连 WiFi。这里有个鸡生蛋蛋生鸡的问题:如果 NVS 里没存 WiFi 信息,或者存的信息连不上,设备就上不了网,浏览器也访问不了它。所以要有 fallback 机制——连不上就开 AP 模式,自己当热点。
void wifi_init(void) { // 先从 NVS 读 WiFi 配置 char ssid[32] = {0}, password[64] = {0}; nvs_handle_t handle; if (nvs_open("wifi_cfg", NVS_READONLY, &handle) == ESP_OK) { size_t len = sizeof(ssid); nvs_get_str(handle, "ssid", ssid, &len); len = sizeof(password); nvs_get_str(handle, "password", password, &len); nvs_close(handle); } if (strlen(ssid) == 0) { // 没配置,开 AP 模式 start_ap_mode(); } else { // 有配置,尝试连接 if (connect_sta(ssid, password) != ESP_OK) { // 连不上,也开 AP 模式 start_ap_mode(); } } }AP 模式的 SSID 我一般设成ESP32_Config_XXXX(XXXX 是 MAC 后四位),密码留空或者设个简单的。用户连上这个热点,浏览器打开192.168.4.1,就能进 NVS 编辑器,把正确的 WiFi 信息填进去,保存后重启,设备就能连上正常 WiFi 了。
HTTP 服务器在 WiFi 连上之后启动:
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.max_uri_handlers = 8; config.max_open_sockets = 4; config.stack_size = 8192; config.uri_match_fn = httpd_uri_match_wildcard; httpd_handle_t server = NULL; if (httpd_start(&server, &config) == ESP_OK) { httpd_register_uri_handler(server, &uri_list); httpd_register_uri_handler(server, &uri_get); httpd_register_uri_handler(server, &uri_set); httpd_register_uri_handler(server, &uri_erase); httpd_register_uri_handler(server, &uri_root); }5.3 关键接口的代码实现与参数说明
/api/nvs/list的实现是核心,因为它要枚举所有命名空间和键。这里有个细节:nvs_entry_find传NULL作为命名空间时,会遍历所有命名空间,但返回的info.ns字段在有些 IDF 版本里是空的。稳妥的做法是先枚举所有命名空间,再逐个枚举键。
// 枚举所有命名空间(通过遍历所有 entry 收集) static esp_err_t list_handler(httpd_req_t *req) { cJSON* root = cJSON_CreateArray(); nvs_iterator_t it = NULL; esp_err_t res = nvs_entry_find("nvs", NULL, NVS_TYPE_ANY, &it); while (res == ESP_OK) { nvs_entry_info_t info; nvs_entry_info(it, &info); cJSON* item = cJSON_CreateObject(); cJSON_AddStringToObject(item, "ns", info.namespace_name); cJSON_AddStringToObject(item, "key", info.key); cJSON_AddStringToObject(item, "type", type_to_str(info.type)); // 读取值 char value_str[256] = {0}; read_value_as_string(info.namespace_name, info.key, info.type, value_str, sizeof(value_str)); cJSON_AddStringToObject(item, "value", value_str); cJSON_AddItemToArray(root, item); res = nvs_entry_next(&it); } nvs_release_iterator(it); char* out = cJSON_PrintUnformatted(root); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, out, strlen(out)); free(out); cJSON_Delete(root); return ESP_OK; }read_value_as_string是个辅助函数,根据类型调用对应的nvs_get_*,然后转成字符串。整数用snprintf,字符串直接拷贝,blob 转成 hex 字符串(限制长度)。
/api/nvs/set的实现前面给过片段,完整版要处理错误:JSON 解析失败返回 400,NVS 打开失败返回 500,写入失败返回 500 并带上错误码。响应体统一用 JSON,方便前端处理。
5.4 网页端交互与实时反馈
网页端除了基本的列表和编辑,我还加了几个实用功能:
搜索过滤:键多了之后,找某个键很麻烦。加个搜索框,输入关键字实时过滤表格行。
类型提示:鼠标悬停在类型列上,显示这个类型的取值范围。比如u8显示 0~255,i32显示 -2147483648~2147483647。
批量保存:改多个键的时候,一个个点保存太累。加个“全部保存”按钮,遍历所有修改过的输入框,依次发请求。
危险操作确认:擦除命名空间是不可逆的,点按钮后弹个确认框,要求输入命名空间名才能执行。这个设计借鉴了 GitHub 删除仓库的交互,能有效防止误操作。
async function eraseNamespace(ns) { const input = prompt(`输入命名空间名 "${ns}" 以确认擦除:`); if (input !== ns) { alert('命名空间名不匹配,操作取消'); return; } await fetch('/api/nvs/erase', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ns: ns}) }); loadNVS(); }6. 踩坑实录:那些文档里不会写的问题
6.1 NVS 键名长度与字符限制的坑
前面提过键名最长 15 字符,但还有个更隐蔽的限制:键名不能包含空格和特殊字符。我试过用wifi ssid(带空格)当键名,nvs_set_str返回ESP_ERR_NVS_KEY_TOO_LONG,但这个错误码其实有误导性,实际原因是字符不合法。后来查源码才发现,NVS 对键名的校验是strlen(key) > 15 || strchr(key, ' ') != NULL之类的逻辑,错误码没细分。
所以键名规范建议:只用小写字母、数字、下划线,长度控制在 15 以内。比如wifi_ssid、dev_id、report_int。这样既安全又易读。
6.2 字符串值长度与内存分配的陷阱
nvs_get_str的标准用法是调两次:第一次传NULL获取长度,第二次分配内存再读。但有个坑:如果第一次调用时键不存在,返回的是ESP_ERR_NVS_NOT_FOUND,长度变量不会被设置。如果不检查返回值就直接malloc(len),len 可能是 0 或者随机值,导致 malloc 失败或者分配过小。
正确做法:
size_t len = 0; esp_err_t err = nvs_get_str(handle, key, NULL, &len); if (err != ESP_OK) { // 键不存在或其他错误,直接返回 return err; } char* buf = malloc(len); if (buf == NULL) return ESP_ERR_NO_MEM; err = nvs_get_str(handle, key, buf, &len); // 用完 free(buf)6.3 HTTP 请求体过大导致的解析失败
esp_http_server默认的请求体缓冲区大小有限(HTTPD_DEFAULT_CONFIG里recv_wait_timeout和缓冲区相关)。如果前端提交的 JSON 特别大(比如批量保存几十个键),可能一次收不完,导致cJSON_Parse失败。
解决办法有两个:一是前端分批提交,每批不超过 10 个键;二是服务器端用httpd_req_recv循环接收,拼完整再解析。我选了前者,因为实现简单,而且用户体验上分批提交还能显示进度。
// 服务器端循环接收的写法 int total = req->content_len; int received = 0; char* buf = malloc(total + 1); while (received < total) { int ret = httpd_req_recv(req, buf + received, total - received); if (ret <= 0) { free(buf); return ESP_FAIL; } received += ret; } buf[total] = '\0';6.4 并发访问与 NVS 句柄冲突
esp_http_server是多任务的,每个请求跑在独立的任务里。如果两个请求同时打开同一个 NVS 命名空间,可能会冲突。NVS 本身对同一命名空间的并发写不是线程安全的,需要加锁。
我的做法是用一个全局互斥锁,所有 NVS 操作前先xSemaphoreTake,操作完xSemaphoreGive。锁的粒度不用太细,因为 NVS 操作本身很快,锁竞争不激烈。
static SemaphoreHandle_t nvs_mutex = NULL; // 初始化时创建 nvs_mutex = xSemaphoreCreateMutex(); // 操作前 xSemaphoreTake(nvs_mutex, portMAX_DELAY); // ... NVS 操作 ... xSemaphoreGive(nvs_mutex);实操心得:这个锁一定要在 NVS 初始化之后、HTTP 服务器启动之前创建。我有一次把创建锁的代码放在 HTTP 服务器启动之后,结果第一个请求进来时锁还是 NULL,直接崩了。这种初始化顺序问题在嵌入式里很常见,建议把初始化流程写成一个明确的序列,每步检查返回值。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 网页打不开 | 设备没连上 WiFi | 串口看日志,确认 IP | 检查 NVS 里 WiFi 配置,或进 AP 模式 |
| 接口返回 404 | URI 没注册或路径不对 | 看服务器日志的 URI 匹配 | 检查httpd_register_uri_handler |
| 保存后重启丢失 | 没调nvs_commit | 代码审查 | 每次nvs_set_*后必须 commit |
| 键名报错 | 键名超长或含非法字符 | 打印键名长度 | 限制 15 字符,只用字母数字下划线 |
| 值读出来是乱码 | 类型不匹配 | 对比写入和读取的类型 | 确保nvs_set_*和nvs_get_*类型一致 |
| 并发写崩溃 | 没加锁 | 看是否多请求同时写 | 加互斥锁保护 NVS 操作 |
| 固件太大 | 网页资源没压缩 | 看 map 文件 | 压缩 HTML,或用 SPIFFS 外置 |
7. 进阶玩法:让这个工具更好用
7.1 加个 OTA 升级入口
既然已经有了 HTTP 服务器和网页,加个 OTA 升级功能是顺手的事。ESP-IDF 的esp_https_ota或者简单的esp_ota_ops都能用。网页上加个文件选择框,上传固件 bin,服务器收到后写入 OTA 分区,重启切换。
这样整个设备维护就闭环了:改配置用 NVS 编辑器,升级固件用 OTA,都不用跑现场。不过 OTA 要注意分区表要留两个 app 分区(ota_0和ota_1),而且固件要签名校验,防止刷入错误固件变砖。
7.2 配置导入导出与批量部署
NVS 里的配置可以导出成 JSON 文件,下载到本地。批量部署的时候,先在一台设备上配好,导出 JSON,然后其他设备导入,省得一个个填。
导出接口就是/api/nvs/list的完整版,把所有命名空间和键值打包成 JSON 返回。导入接口接收 JSON,遍历写入 NVS。注意导入前要先擦除现有配置,或者做合并策略(覆盖同名的,保留不同的)。
这个功能在多设备项目里特别实用。我之前做的一个项目有 50 台设备,就是靠导出导入,半天配完了所有设备。
7.3 安全加固:加个简单的认证
默认情况下,局域网内任何人都能访问这个编辑器,改你的 NVS。如果设备部署在不可信的局域网,建议加个简单的认证。
最简单的做法是 HTTP Basic Auth:服务器检查Authorization头,用户名密码存在 NVS 里(初始默认admin/admin,用户可改)。前端在fetch请求里带上Authorization头。
// 服务器端校验 char auth[128]; if (httpd_req_get_hdr_value_str(req, "Authorization", auth, sizeof(auth)) != ESP_OK) { httpd_resp_set_status(req, "401 Unauthorized"); httpd_resp_set_hdr(req, "WWW-Authenticate", "Basic realm=\"NVS Editor\""); httpd_resp_send(req, NULL, 0); return ESP_OK; } // 解析 base64,对比用户名密码Basic Auth 的安全性一般(base64 明文传输),但在局域网内够用。如果要更安全,可以上 HTTPS,但 ESP32 跑 TLS 会占不少资源,而且证书管理麻烦,看项目需求权衡。
7.4 与配网流程的整合
这个 NVS 编辑器其实可以跟配网流程整合。设备第一次启动,NVS 为空,自动开 AP 模式,用户连上热点,浏览器打开编辑器,填入 WiFi 信息,保存后设备自动重启连 WiFi。之后如果 WiFi 密码变了,设备连不上,又自动回到 AP 模式,用户再改。
整个流程对用户来说就是:连热点、开网页、填密码、保存。不需要任何专业知识,也不需要装 App。这个体验比很多商业 IoT 设备的配网流程都顺畅。
8. 一些个人体会和后续扩展方向
这个 NVS 编辑器我从去年开始在自己的项目里用,前后迭代了七八个版本,现在算是比较稳定了。最大的感受是:嵌入式开发里,很多看似麻烦的问题,其实用 Web 技术能优雅地解决。ESP32 有 WiFi、有 HTTP 服务器、有足够的 flash 和 RAM,完全可以把配置管理做成一个 Web 应用,用户体验直接上一个台阶。
踩过的坑里,最值得记住的是 NVS 的键名限制和 commit 机制。这两个问题在文档里都有写,但很容易忽略,而且出错时的错误码不够直观,排查起来费时间。建议第一次用 NVS 的时候,先写个最简单的读写测试,把键名长度、类型匹配、commit 这些点都验证一遍,再往项目里集成。
后续我打算加的功能:一是配置版本管理,每次修改记录时间和修改人,方便追溯;二是配置模板,常用配置存成模板,一键应用;三是跟 MQTT 结合,配置变更时发消息通知,方便远程监控。
如果你也在做 ESP32 项目,被配置管理困扰过,不妨试试这个方案。代码量不大,核心逻辑几百行,但带来的便利是实实在在的。尤其是设备已经部署到现场之后,能远程改配置和升级固件,省下的跑腿时间够你多做好几个项目了。