ESP32 实战教程:做一个带校验和回退思路的网页 OTA 升级

本文由 AI 协助整理,示例适合可信局域网学习。HTTP 上传页没有自动变成公网安全服务,正式部署还需要 HTTPS、强认证、固件签名、权限控制、分区回滚和审计。第一次测试请使用身边的备用板,不要把唯一一块重要设备当实验品。

ESP32 网页 OTA 更新链路

OTA 解决的是维护问题,不是烧录问题

开发阶段插 USB 烧录很方便,设备装进盒子、挂到高处或部署到客户现场以后,重新接线就不再轻松。OTA 让电脑通过网络把固件写进设备,但它也把“升级失败后怎么办”带到了现场。

一个可用的网页 OTA 至少要回答:谁可以上传?上传多大的文件?当前固件是多少?上传过程中掉线怎么办?写完以后如何验证?新固件启动失败能不能回到旧版本?

本文先做一个局域网网页上传示例,再说明双分区和回退的工程边界。Arduino IDE 可以从“导出已编译二进制”得到 .bin 文件,浏览器把它提交给 ESP32。

分区表是回退的基础

最简单的单分区方案可能覆盖正在运行的固件,升级中断后设备直接失去启动能力。支持 OTA 的分区表通常要有两个应用分区:当前运行的分区和备用分区。

在 Arduino IDE 的工具菜单中选择带 OTA 分区的 Partition Scheme,具体名称因核心版本和 Flash 容量而异。确认方案后再编译和导出固件,不能拿一个超出分区大小的二进制硬塞进去。

分区只是提供空间,不自动替你完成安全回退。正式项目还应在新固件启动后自检,确认 WiFi、传感器、关键任务正常,再标记当前镜像为有效。

网页上传页面

#include <WiFi.h>
#include <WebServer.h>
#include <Update.h>

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* OTA_USER = "admin";
const char* OTA_PASSWORD = "CHANGE_THIS_PASSWORD";

WebServer server(80);
const char* FIRMWARE_VERSION = "1.0.0";

网页提供文件选择和上传按钮:

const char OTA_PAGE[] PROGMEM = R"rawliteral(
<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<title>ESP32 OTA</title>
<style>body{font-family:system-ui;max-width:620px;margin:40px auto;padding:20px}button{padding:10px 18px}</style>
<h1>ESP32 固件升级</h1>
<p>当前版本:%VERSION%</p>
<form method="POST" action="/update" enctype="multipart/form-data">
  <input type="file" name="firmware" accept=".bin" required>
  <button type="submit">上传并升级</button>
</form>
)rawliteral";

生产环境不要只靠隐藏的网页地址。这里的页面只是一个学习入口,真正的认证在服务器处理函数中完成。

登录保护和上传处理

void setupOtaRoutes() {
  server.on("/", HTTP_GET, []() {
    if (!server.authenticate(OTA_USER, OTA_PASSWORD)) {
      return server.requestAuthentication();
    }

    String page = OTA_PAGE;
    page.replace("%VERSION%", FIRMWARE_VERSION);
    server.send(200, "text/html; charset=utf-8", page);
  });

  server.on("/update", HTTP_POST,
    []() {
      if (!server.authenticate(OTA_USER, OTA_PASSWORD)) {
        return server.requestAuthentication();
      }

      bool ok = !Update.hasError();
      server.send(200, "text/plain; charset=utf-8",
                  ok ? "Update complete, rebooting" : "Update failed");
      delay(500);
      if (ok) ESP.restart();
    },
    []() {
      HTTPUpload& upload = server.upload();

      if (upload.status == UPLOAD_FILE_START) {
        if (!server.authenticate(OTA_USER, OTA_PASSWORD)) return;

        Serial.printf("OTA start: %s\n", upload.filename.c_str());
        if (!upload.filename.endsWith(".bin")) {
          Serial.println("Only .bin files are accepted");
          return;
        }

        if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
          Update.printError(Serial);
        }
      } else if (upload.status == UPLOAD_FILE_WRITE) {
        if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
          Update.printError(Serial);
        }
      } else if (upload.status == UPLOAD_FILE_END) {
        if (Update.end(true)) {
          Serial.printf("OTA size=%u\n", upload.totalSize);
        } else {
          Update.printError(Serial);
        }
      } else if (upload.status == UPLOAD_FILE_ABORTED) {
        Update.abort();
        Serial.println("OTA aborted");
      }
      yield();
    });
}

真实项目应在开始上传时限制文件大小,并在服务端确认目标分区能够容纳固件。上面使用 UPDATE_SIZE_UNKNOWN 是为了展示 API,部署代码应结合分区容量和上传回调实际限制。

让程序运行起来

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  unsigned long started = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) {
    delay(200);
  }

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi failed");
    return;
  }

  setupOtaRoutes();
  server.begin();
  Serial.print("OTA URL: http://");
  Serial.println(WiFi.localIP());
}

void loop() {
  server.handleClient();
  delay(2);
}

浏览器打开串口显示的 IP,输入账号密码,选择新导出的 .bin 文件。上传期间不要断电、不要让程序执行长时间阻塞操作,也不要关闭网络。网页只显示成功不代表新固件一定能正常运行,重启后的自检才是第二道关。

版本号和固件身份

版本号至少包含在网页、串口日志和设备状态里。可以用编译宏传入:

#define FW_VERSION "1.0.1"

String firmwareInfo() {
  return String(FW_VERSION) + " build=" + __DATE__ + " " + __TIME__;
}

生产升级系统还应有设备型号、硬件版本、分区信息和构建提交号。避免把 ESP32-S3 的固件上传给普通 ESP32;文件名相同并不代表硬件兼容。

固件完整性与真实性

文件扩展名 .bin、文件大小和 HTTP 认证都不能证明固件来自可信构建。高风险设备需要固件签名,设备只接受用受信任私钥签名的镜像;传输过程使用 TLS,避免网络中间人替换文件。

即使是学习项目,也可以先计算 SHA-256 并记录构建产物哈希:

固件文件:env-node-1.0.1.bin
版本:1.0.1
目标:ESP32-WROOM-32
SHA-256:由构建流程生成并保存

设备端校验哈希与签名需要配合分区和启动验证机制,不是把一行字符串显示在网页上就算完成。文章里的网页认证只适合可信局域网实验,不能作为公网安全方案。

启动自检与回退思路

新固件启动后可以检查:

  • NVS 配置是否能读取。
  • WiFi 是否在限定时间内连接。
  • 关键传感器是否响应。
  • 主任务是否正常运行。
  • 看门狗和错误计数是否清零。

如果自检通过,再把当前分区标记为有效;如果持续重启或关键功能失败,应让启动流程回退到上一版本。具体 API 依赖 ESP-IDF/Arduino 核心版本和分区配置,正式项目应参考当前官方 OTA 与回滚文档,不要凭一篇旧教程复制函数名。

在没有实现完整回退前,保留串口烧录通道和物理恢复入口。OTA 的第一条工程原则是:远程升级失败后,仍然要有办法把设备救回来。

升级前后的兼容策略

固件升级可能同时改变 NVS 配置、MQTT 主题和网页接口。升级前先考虑:

  • 新固件能否读取旧配置。
  • 配置版本是否需要迁移。
  • MQTT 消息是否向后兼容。
  • 升级失败时旧固件是否仍能使用旧字段。
  • 是否支持分批设备和灰度升级。

数据库迁移的思路同样适用于设备配置:读取旧版本,转换成新结构,写入新版本号;不要看到字段少了就清空所有配置。

分阶段验证

  1. 用串口首次烧录带 OTA 页面的小固件。
  2. 在同一局域网访问页面,确认登录保护。
  3. 导出一个只改版本字符串的 .bin。
  4. 上传并观察进度、串口和重启。
  5. 验证新版本功能与配置仍在。
  6. 上传错误文件名,确认不会开始写入。
  7. 测试中途断网和断电,确认有物理恢复路径。
  8. 使用不同分区方案验证文件大小限制。

常见问题

现象 优先检查
页面打不开 WiFi、IP、HTTP 服务和局域网隔离
上传立即失败 分区方案、文件格式、容量和 Update 初始化
上传成功但无法启动 固件目标板不匹配、分区或依赖问题
认证形同虚设 只保护首页,未保护上传接口
上传中设备重启 供电、网络、看门狗和阻塞任务
重启后仍是旧版本 实际没有写入备用分区,或启动分区未切换
设备变砖 没有回退、串口恢复或可靠电源

总结

网页 OTA 的代码并不长,真正的难点是升级流程:身份验证、固件大小、版本与型号、完整性、断电、启动自检和恢复路径。

先在局域网和备用板上做最小上传,再逐步增加签名、TLS、回滚和灰度发布。能远程升级是一种能力,升级失败后还能找到设备、恢复它,才是工程上的完整能力。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-28 23:43  潇洒石头  阅读(27)  评论(0)    收藏  举报

导航