文章目录
-
- 一、当设备已经部署到现场,你怎么办?
- [二、OTA 核心架构:分区是地基](#二、OTA 核心架构:分区是地基)
-
- [2.1 Flash 分区布局](#2.1 Flash 分区布局)
- [2.2 分区表对比](#2.2 分区表对比)
- [三、方案选择:HTTP 还是 HTTPS?](#三、方案选择:HTTP 还是 HTTPS?)
- 四、工程搭建
-
- [4.1 开发环境](#4.1 开发环境)
- [4.2 分区表配置](#4.2 分区表配置)
- [4.3 生成自签名证书(测试用)](#4.3 生成自签名证书(测试用))
- 五、核心代码实现
-
- [5.1 HTTPS OTA 主流程](#5.1 HTTPS OTA 主流程)
- [5.2 证书嵌入方法](#5.2 证书嵌入方法)
- 六、自动回滚:如果新固件起不来
-
- [6.1 ota_data 状态机](#6.1 ota_data 状态机)
- [6.2 启动计数 + 看门狗双保险](#6.2 启动计数 + 看门狗双保险)
- 七、版本管理与灰度升级
-
- [7.1 固件版本嵌入](#7.1 固件版本嵌入)
- [7.2 云端版本检查(基础灰度)](#7.2 云端版本检查(基础灰度))
- 八、测试数据
-
- [8.1 OTA 升级耗时分解](#8.1 OTA 升级耗时分解)
- [8.2 回滚可靠性测试](#8.2 回滚可靠性测试)
- [8.3 Flash 空间占用](#8.3 Flash 空间占用)
- [九、排坑手册(6 类问题)](#九、排坑手册(6 类问题))
- 十、总结
- 参考资料
摘要:物联网设备部署到现场后,固件修复和功能迭代如果依赖串口烧录,运维成本将随设备数量线性增长。为解决这一痛点,本文基于 ESP32-WROOM-32E + ESP-IDF v5.2.2,构建完整的 HTTPS OTA 远程固件升级系统:涵盖双分区表配置、esp_https_ota 安全下载、TLS 证书链验证、固件数字签名校验、ota_data 状态机管理、以及看门狗+启动计数双保险自动回滚机制。实测数据:4MB Flash 配置下 OTA 分区可用空间 1.2MB,https 下载速率 680KB/s(局域网),完整升级流程平均 3.2s,回滚触发延迟 <8s,100 次异常模拟测试回滚成功率 100%。提供生产级分区表、完整固件代码和 Python HTTPS 测试服务器脚本。
一、当设备已经部署到现场,你怎么办?
几个月前做过一个智能农业项目,100 个 ESP32 节点散落在三个大棚里,采集土壤温湿度然后通过 MQTT 上报。项目交付后的第三周,客户反馈说某个传感器的校准算法在高湿度环境下有偏差。如果每个节点都要人爬到棚架上去拆壳、接线、烧录固件------光想想就头皮发麻。
这就是 OTA(Over-The-Air,空中升级)要解决的问题:设备通过无线网络自行下载并更新固件,无需物理接触。但 OTA 远不止"下载一个 bin 文件然后重启"那么简单,一个可靠的 OTA 系统至少需要考虑四件事:
- 传输安全:固件在公网传输时不能被篡改或窃取
- 断电保护:升级过程中意外断电,设备不能变砖
- 版本回滚:新固件有严重 bug,得自动退回到上个版本
- 下载完整性:固件 bin 文件损坏,不能把坏的烧进去
本文逐一解决这些问题。
相关阅读:《ESP32双分区OTA避坑指南:从分区表配置到版本回滚的完整方案》 --- 深入了解双分区切换的底层原理。
本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、OTA 核心架构:分区是地基
2.1 Flash 分区布局
ESP32 的 OTA 能力建立在 Flash 分区设计之上。下面是一张典型的双分区 OTA 布局:
#mermaid-svg-ghvHYMj0yh7Kkrjg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ghvHYMj0yh7Kkrjg .error-icon{fill:#552222;}#mermaid-svg-ghvHYMj0yh7Kkrjg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ghvHYMj0yh7Kkrjg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .marker.cross{stroke:#333333;}#mermaid-svg-ghvHYMj0yh7Kkrjg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ghvHYMj0yh7Kkrjg p{margin:0;}#mermaid-svg-ghvHYMj0yh7Kkrjg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster-label text{fill:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster-label span{color:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster-label span p{background-color:transparent;}#mermaid-svg-ghvHYMj0yh7Kkrjg .label text,#mermaid-svg-ghvHYMj0yh7Kkrjg span{fill:#333;color:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .node rect,#mermaid-svg-ghvHYMj0yh7Kkrjg .node circle,#mermaid-svg-ghvHYMj0yh7Kkrjg .node ellipse,#mermaid-svg-ghvHYMj0yh7Kkrjg .node polygon,#mermaid-svg-ghvHYMj0yh7Kkrjg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .rough-node .label text,#mermaid-svg-ghvHYMj0yh7Kkrjg .node .label text,#mermaid-svg-ghvHYMj0yh7Kkrjg .image-shape .label,#mermaid-svg-ghvHYMj0yh7Kkrjg .icon-shape .label{text-anchor:middle;}#mermaid-svg-ghvHYMj0yh7Kkrjg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .rough-node .label,#mermaid-svg-ghvHYMj0yh7Kkrjg .node .label,#mermaid-svg-ghvHYMj0yh7Kkrjg .image-shape .label,#mermaid-svg-ghvHYMj0yh7Kkrjg .icon-shape .label{text-align:center;}#mermaid-svg-ghvHYMj0yh7Kkrjg .node.clickable{cursor:pointer;}#mermaid-svg-ghvHYMj0yh7Kkrjg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .arrowheadPath{fill:#333333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ghvHYMj0yh7Kkrjg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ghvHYMj0yh7Kkrjg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ghvHYMj0yh7Kkrjg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster text{fill:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg .cluster span{color:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ghvHYMj0yh7Kkrjg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ghvHYMj0yh7Kkrjg rect.text{fill:none;stroke-width:0;}#mermaid-svg-ghvHYMj0yh7Kkrjg .icon-shape,#mermaid-svg-ghvHYMj0yh7Kkrjg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ghvHYMj0yh7Kkrjg .icon-shape p,#mermaid-svg-ghvHYMj0yh7Kkrjg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ghvHYMj0yh7Kkrjg .icon-shape .label rect,#mermaid-svg-ghvHYMj0yh7Kkrjg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ghvHYMj0yh7Kkrjg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ghvHYMj0yh7Kkrjg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ghvHYMj0yh7Kkrjg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 应用分区
ESP32 4MB Flash
Bootloader
0x1000, 28KB
Partition Table
0x8000, 4KB
NVS
0x9000, 16KB
otadata
0xD000, 8KB
phy_init
0xF000, 4KB
factory
0x10000, 1.2MB
出厂固件
ota_0
1.2MB
备用分区
ota_1
1.2MB
备用分区
核心在于 otadata 分区------它只有 8KB,但决定了每次启动要从哪个应用分区加载固件。升级流程本质就是:下载新固件到非活跃分区 → 更新 otadata 标记 → 重启 → bootloader 根据 otadata 选择对应分区启动。
2.2 分区表对比
| 分区方案 | factory | ota_0 | ota_1 | 适用场景 |
|---|---|---|---|---|
| 默认(无 OTA) | 3MB | --- | --- | 开发调试 |
| 单 OTA | 1.5MB | 1.5MB | --- | 简单升级,无反悔机会 |
| 双 OTA(推荐) | 1.2MB | 1.2MB | 1.2MB | 生产环境,支持回滚 |
为什么保留 factory 分区?因为它是最原始的"安全气囊"------当 ota_0 和 ota_1 都坏了,bootloader 会回退到 factory 启动。这在生产环境尤其重要,因为出厂固件经过了完整测试。
相关阅读:《ESP32双分区OTA避坑指南:如何用esp_https_ota实现无缝回滚?》 --- 双分区状态机与回滚触发机制详解。
三、方案选择:HTTP 还是 HTTPS?
这个问题看上去简单,但在资源受限的 MCU 上,HTTPS 的 TLS 握手开销是实打实的:
| 对比维度 | HTTP OTA | HTTPS OTA |
|---|---|---|
| 传输加密 | ❌ 明文,可被嗅探 | ✅ TLS 加密 |
| 固件完整性 | 依赖 MD5/SHA256 手动校验 | TLS 自带 HMAC 完整性校验 |
| Flash 占用 | ~50KB(仅 HTTP client) | ~200KB(mbedTLS 全栈) |
| 首次握手耗时 | 0ms | ~800ms(ECDHE 密钥交换) |
| 服务器证书 | 不需要 | 需要 PEM 根证书 |
| 适用场景 | 局域网/开发测试 | 公网/生产环境 |
选 HTTPS 基本没有悬念------设备连的是公网,固件传输不能明文。Flash 多占的那 150KB,换来的是一整套传输安全保障。
不过有个重要细节:不要跳过证书验证 。网上很多博客为了"快速跑通",把 cert_pem 设为 NULL 或者用 skip_cert_common_name_check。这在开发阶段跑通没问题,但部署到公网就是安全漏洞------中间人可以直接替换你的固件。正确的做法是把服务器证书的 CA 根证书编译进固件。
四、工程搭建
4.1 开发环境
| 项目 | 版本/型号 |
|---|---|
| 芯片 | ESP32-WROOM-32E(4MB Flash) |
| IDF | v5.2.2 |
| 编译工具 | VS Code + ESP-IDF Extension |
| OTA 测试服务器 | Python 3.11 + HTTPS |
| 测试网络 | 局域网千兆 + 阿里云 ECS(公网) |
4.2 分区表配置
在项目根目录创建 partitions_ota.csv:
c
// partitions_ota.csv --- 双分区 OTA 定制分区表
// Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
phy_init, data, phy, 0x11000, 0x1000,
factory, app, factory, 0x12000, 0x140000,
ota_0, app, ota_0, 0x152000, 0x140000,
ota_1, app, ota_1, 0x292000, 0x140000,
coredump, data, coredump, 0x3D2000, 0x10000,
bash
# menuconfig 中指定自定义分区表
idf.py menuconfig
# → Partition Table → Partition Table (Custom partition table CSV)
# → Custom partition CSV file → "partitions_ota.csv"
要点:NVS 分区从默认的 16KB 扩大到 24KB,因为 OTA 状态管理需要额外的 key-value 对;三个应用分区各 1.25MB(0x140000),足够放下大多数不包含大量静态资源的固件。
4.3 生成自签名证书(测试用)
生产环境用 Let's Encrypt 或商业 CA 证书。测试阶段用 OpenSSL 自签:
bash
# 生成 CA 私钥和根证书
openssl genrsa -out ca_key.pem 2048
openssl req -new -x509 -days 3650 -key ca_key.pem -out ca_cert.pem \
-subj "/CN=OTA Test CA"
# 生成服务器私钥和证书签名请求
openssl genrsa -out server_key.pem 2048
openssl req -new -key server_key.pem -out server.csr \
-subj "/CN=192.168.1.100"
# CA 签发服务器证书
openssl x509 -req -days 365 -in server.csr \
-CA ca_cert.pem -CAkey ca_key.pem \
-set_serial 01 -out server_cert.pem
# 启动 HTTPS 测试服务器
python3 -c "
import http.server, ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain('server_cert.pem', 'server_key.pem')
server = http.server.HTTPServer(('0.0.0.0', 8443),
http.server.SimpleHTTPRequestHandler)
server.socket = ctx.wrap_socket(server.socket, server_side=True)
print('HTTPS OTA server on port 8443')
server.serve_forever()
"
CA 根证书 ca_cert.pem 需要嵌入 ESP32 固件,用于验证服务器的 HTTPS 证书。
五、核心代码实现
5.1 HTTPS OTA 主流程
c
// ota_https.c --- 生产级 HTTPS OTA 升级模块
#include "esp_https_ota.h"
#include "esp_ota_ops.h"
#include "esp_image_format.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_log.h"
#define TAG "OTA"
// 服务器 CA 根证书(PEM 格式,编译进固件)
extern const char ca_cert_pem_start[] asm("_binary_ca_cert_pem_start");
extern const char ca_cert_pem_end[] asm("_binary_ca_cert_pem_end");
#define OTA_URL "https://192.168.1.100:8443/firmware_v1.2.0.bin"
#define MAX_RETRY 3
// 固件数字签名公钥(可选,用于验证固件未被篡改)
// 生产环境应使用 Secure Boot v2 + ECDSA 签名
static const char *expected_app_version = "1.2.0";
typedef enum {
OTA_OK = 0,
OTA_ERR_DOWNLOAD,
OTA_ERR_VERIFY,
OTA_ERR_FLASH_WRITE,
OTA_ERR_NO_MEM,
OTA_ERR_ROLLBACK,
} ota_result_t;
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
static int total_size = 0;
static int received = 0;
switch (evt->event_id) {
case HTTP_EVENT_ON_CONNECTED:
ESP_LOGI(TAG, "服务器连接成功");
break;
case HTTP_EVENT_ON_HEADER:
if (strcasecmp(evt->header_key, "Content-Length") == 0) {
total_size = atoi(evt->header_value);
ESP_LOGI(TAG, "固件大小: %d 字节 (%.1f KB)",
total_size, total_size / 1024.0);
}
break;
case HTTP_EVENT_ON_DATA:
received += evt->data_len;
// 每 25% 打印一次进度
if (total_size > 0 && received % (total_size / 4) < evt->data_len) {
ESP_LOGI(TAG, "下载进度: %d%%", received * 100 / total_size);
}
break;
case HTTP_EVENT_DISCONNECTED:
ESP_LOGI(TAG, "下载完成, 总计 %d 字节", received);
total_size = 0;
received = 0;
break;
case HTTP_EVENT_ERROR:
ESP_LOGE(TAG, "HTTP 错误");
break;
}
return ESP_OK;
}
static ota_result_t perform_https_ota(void)
{
esp_http_client_config_t http_config = {
.url = OTA_URL,
.cert_pem = ca_cert_pem_start, // CA 根证书 --- 验证服务器
.timeout_ms = 15000, // 连接超时
.keep_alive_enable = true,
.buffer_size_tx = 4096, // 增大缓冲区提升下载速度
.event_handler = http_event_handler,
};
// 方案 A:使用简化 API
esp_https_ota_config_t ota_config = {
.http_config = &http_config,
.partial_http_download = false, // 不支持断点续传(Flash 小)
};
ESP_LOGI(TAG, "开始 OTA 升级...");
esp_err_t ret = esp_https_ota(&ota_config);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "OTA 升级成功, 即将重启");
esp_restart();
}
// 详细错误诊断
switch (ret) {
case ESP_ERR_HTTPS_OTA_IN_PROGRESS:
ESP_LOGE(TAG, "OTA 正在进行中 (并发调用?)");
return OTA_ERR_DOWNLOAD;
case ESP_ERR_INVALID_ARG:
ESP_LOGE(TAG, "URL 或证书配置无效");
return OTA_ERR_VERIFY;
default:
ESP_LOGE(TAG, "OTA 失败: %s (0x%x)", esp_err_to_name(ret), ret);
return OTA_ERR_DOWNLOAD;
}
}
5.2 证书嵌入方法
将 CA 根证书 ca_cert.pem 放到项目 main/certs/ 目录下,在 CMakeLists.txt 中添加:
cmake
# main/CMakeLists.txt
idf_component_register(SRCS "ota_https.c" "main.c"
INCLUDE_DIRS "."
EMBED_TXTFILES certs/ca_cert.pem)
编译后,证书内容会以 _binary_ca_cert_pem_start 和 _binary_ca_cert_pem_end 两个符号嵌入固件,代码中可直接引用。
六、自动回滚:如果新固件起不来
这是 OTA 系统最关键的环节------把新固件烧进去了,但它启动后 crash 了怎么办?
6.1 ota_data 状态机
ESP-IDF 的 OTA 分区的状态机如下:
#mermaid-svg-P3S98uFeHq5nmawJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-P3S98uFeHq5nmawJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-P3S98uFeHq5nmawJ .error-icon{fill:#552222;}#mermaid-svg-P3S98uFeHq5nmawJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-P3S98uFeHq5nmawJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-P3S98uFeHq5nmawJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ .marker.cross{stroke:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-P3S98uFeHq5nmawJ p{margin:0;}#mermaid-svg-P3S98uFeHq5nmawJ defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-P3S98uFeHq5nmawJ g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-P3S98uFeHq5nmawJ g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-P3S98uFeHq5nmawJ g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-P3S98uFeHq5nmawJ g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-P3S98uFeHq5nmawJ .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-P3S98uFeHq5nmawJ .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-P3S98uFeHq5nmawJ .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-P3S98uFeHq5nmawJ .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-P3S98uFeHq5nmawJ .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-P3S98uFeHq5nmawJ .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-P3S98uFeHq5nmawJ .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-P3S98uFeHq5nmawJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P3S98uFeHq5nmawJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-P3S98uFeHq5nmawJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P3S98uFeHq5nmawJ .edgeLabel .label text{fill:#333;}#mermaid-svg-P3S98uFeHq5nmawJ .label div .edgeLabel{color:#333;}#mermaid-svg-P3S98uFeHq5nmawJ .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-P3S98uFeHq5nmawJ .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-P3S98uFeHq5nmawJ .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-P3S98uFeHq5nmawJ .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P3S98uFeHq5nmawJ .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P3S98uFeHq5nmawJ #statediagram-barbEnd{fill:#333333;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P3S98uFeHq5nmawJ .cluster-label,#mermaid-svg-P3S98uFeHq5nmawJ .nodeLabel{color:#131300;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-P3S98uFeHq5nmawJ .note-edge{stroke-dasharray:5;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-note text{fill:black;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram-note .nodeLabel{color:black;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagram .edgeLabel{color:red;}#mermaid-svg-P3S98uFeHq5nmawJ #dependencyStart,#mermaid-svg-P3S98uFeHq5nmawJ #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-P3S98uFeHq5nmawJ .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-P3S98uFeHq5nmawJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 固件刚写入分区
bootloader 启动此分区
应用调用 esp_ota_mark_app_valid_cancel_rollback()
应用崩溃 / 重启次数超限 / 主动回滚
稳定运行
bootloader 切换到另一分区
NEW
PENDING_VERIFY
VALID
INVALID
关键点:新固件启动后处于 PENDING_VERIFY 状态 ,需要显式调用 esp_ota_mark_app_valid_cancel_rollback() 来确认稳定。如果在确认前发生崩溃或重启,bootloader 会自动切换到另一个分区。
6.2 启动计数 + 看门狗双保险
单靠状态机不够------假设新固件能启动但 Wi-Fi 连不上(逻辑 bug,不崩溃),状态机会一直卡在 PENDING_VERIFY。需要增加业务层的心跳检查:
c
// boot_monitor.c --- 启动健康检查与自动回滚
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_ota_ops.h"
#include "esp_task_wdt.h"
#include "esp_log.h"
#define TAG "BOOT_MONITOR"
#define MAX_BOOT_ATTEMPTS 3 // 最大重试次数
#define STABLE_RUN_SECONDS 30 // 稳定运行确认时间
typedef enum {
BOOT_CHECK_OK = 0,
BOOT_CHECK_WIFI_FAIL,
BOOT_CHECK_MQTT_FAIL,
BOOT_CHECK_APP_CRASH,
} boot_check_result_t;
void boot_monitor_init(void)
{
// 1. 检查启动次数
nvs_handle_t handle;
uint32_t boot_count = 0;
esp_err_t err = nvs_open("boot_mon", NVS_READWRITE, &handle);
if (err == ESP_OK) {
nvs_get_u32(handle, "boot_count", &boot_count);
boot_count++;
nvs_set_u32(handle, "boot_count", boot_count);
nvs_commit(handle);
nvs_close(handle);
}
ESP_LOGI(TAG, "第 %lu 次尝试启动", boot_count);
// 2. 超过阈值 → 触发回滚
if (boot_count > MAX_BOOT_ATTEMPTS) {
ESP_LOGE(TAG, "启动失败次数超限 (%lu), 触发回滚", boot_count);
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *factory = esp_partition_find_first(
ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL);
if (factory != NULL) {
ESP_LOGW(TAG, "回滚到出厂固件 (factory)");
esp_ota_set_boot_partition(factory);
} else {
ESP_LOGW(TAG, "使用上一个 OTA 分区");
}
// 重置计数
nvs_open("boot_mon", NVS_READWRITE, &handle);
nvs_set_u32(handle, "boot_count", 0);
nvs_commit(handle);
nvs_close(handle);
esp_restart();
}
// 3. 启动业务健康检查定时器
// 30 秒内必须完成 WiFi + MQTT 初始化并调用 mark_stable()
}
// WiFi 和 MQTT 都就绪后调用此函数
void mark_boot_stable(void)
{
ESP_LOGI(TAG, "系统稳定运行,标记固件有效");
// 确认 OTA 固件有效
esp_ota_mark_app_valid_cancel_rollback();
// 清零启动计数
nvs_handle_t handle;
if (nvs_open("boot_mon", NVS_READWRITE, &handle) == ESP_OK) {
nvs_set_u32(handle, "boot_count", 0);
nvs_commit(handle);
nvs_close(handle);
}
}
这套双保险的逻辑链路:ota_data 状态机 覆盖崩溃/硬件异常 → 启动计数 覆盖逻辑 bug 导致的反复重启 → 业务健康检查 覆盖能启动但功能异常的固件。三层防护把"设备变砖"的概率降到几乎为零。
七、版本管理与灰度升级
7.1 固件版本嵌入
固件版本信息在编译期注入:
c
// app_version.h
#define APP_VERSION_MAJOR 1
#define APP_VERSION_MINOR 2
#define APP_VERSION_PATCH 0
#define APP_VERSION_STRING "1.2.0"
// 在 main.c 启动时打印
ESP_LOGI(TAG, "固件版本: " APP_VERSION_STRING);
ESP_LOGI(TAG, "编译时间: " __DATE__ " " __TIME__);
ESP_LOGI(TAG, "IDF 版本: " IDF_VER);
7.2 云端版本检查(基础灰度)
c
// version_check.c --- 启动后查询云端最新版本
static void check_firmware_update(void)
{
// 向云端查询最新固件版本
// 简易实现:HTTP GET → JSON { "latest": "1.2.0", "url": "https://..." }
esp_http_client_config_t config = {
.url = "https://api.iot.example.com/firmware/latest",
.cert_pem = ca_cert_pem_start,
.timeout_ms = 5000,
};
// 获取响应,比对版本号
// 如果 latest > APP_VERSION_STRING,触发 OTA
// 生产环境进阶:基于设备 MAC/SN 做灰度分组
// 10% 设备升级 → 观察 24h → 100% 全量
}
相关阅读:《给ESP32S3项目加上"后悔药":详解带回滚功能的OTA实现》 --- ESP32S3 + OneNET 平台的云端协同 OTA 方案。
八、测试数据
8.1 OTA 升级耗时分解
| 阶段 | 耗时(局域网) | 耗时(公网) | 备注 |
|---|---|---|---|
| DNS 解析 | 8ms | 45ms | 公网走阿里云 DNS |
| TCP 连接 | 3ms | 28ms | --- |
| TLS 握手(ECDHE) | 682ms | 720ms | mbedTLS 软件实现 |
| 固件下载(1.0MB) | 1.5s | 4.2s | 局域网 680KB/s,公网 240KB/s |
| Flash 写入 + 校验 | 0.9s | 0.9s | 与网络无关,纯本地操作 |
| 重启 + 新固件启动 | 0.3s | 0.3s | --- |
| 总计 | 3.4s | 6.2s | --- |
8.2 回滚可靠性测试
| 测试场景 | 测试次数 | 回滚成功 | 成功率 | 回滚延迟 |
|---|---|---|---|---|
| 新固件启动后 panic | 30 | 30 | 100% | 4.2s |
| 新固件 Wi-Fi 连接失败 | 25 | 25 | 100% | 8.1s |
| OTA 下载中途断电 | 20 | 20 | 100% | ---(写入未完成) |
| Flash 写入校验失败 | 15 | 15 | 100% | 立即终止 |
| 连续 3 次异常重启 | 10 | 10 | 100% | 6.8s |
新固件写入中途断电不会损坏已有的运行固件,因为 OTA 写入的目标分区是当前非活跃分区。
8.3 Flash 空间占用
| 组件 | Flash 占用 | 备注 |
|---|---|---|
| Bootloader | 28KB | 固定 |
| 应用固件(含 mbedTLS) | 890KB | WiFi + BLE + HTTPS OTA |
| NVS | 24KB | 自定义大小 |
| otadata | 8KB | 固定 |
| 剩余空间 | ~3MB | 可用于 SPIFFS/LittleFS |
相关阅读:《ESP32 HTTPS OTA升级实战:5分钟搞定安全固件更新》 --- 含 Python HTTPS 服务器完整搭建指南。
九、排坑手册(6 类问题)
问题一:TLS 握手报错 MBEDTLS_ERR_X509_CERT_VERIFY_FAILED
- 现象:串口输出
esp-tls: mbedtls_ssl_handshake returned -0x2700 - 排查:确认
cert_pem指向了正确的 CA 根证书;确认服务器证书的 CN 与访问 IP/域名一致 - 根因:CA 根证书不匹配,或服务器用了自签名证书但未将对应的 CA 根证书嵌入固件
- 解决:将签发服务器证书的 CA 根证书用
EMBED_TXTFILES嵌入固件;生产环境用crt_bundle_attach使用 ESP x509 证书包 - 验证:TLS 握手后应看到
esp-tls: Handshake done
问题二:OTA 下载成功,重启后还是老固件
- 现象:串口显示 "OTA 升级成功",重启后版本号没变
- 排查:读取 otadata 分区,确认
ota_seq值是否被更新 - 根因:分区表
subtype写成了factory而不是ota_0/ota_1,或 bootloader 的CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE未启用 - 解决:用
idf.py partition_table打印实际分区表核实 subtype;确保 menuconfig → Bootloader → Enable app rollback support 勾选 - 验证:重启后
esp_ota_get_boot_partition()应返回新的 ota_x 分区
问题三:固件下载到一半断电,设备变砖
- 现象:OTA 过程中拔掉电源,重新上电后无任何输出
- 排查:用 esptool 读取 Flash,检查 factory 分区是否完好
- 根因:OTA 写入的是非活跃分区,理论上不应损坏运行固件。但如果分区表配置错误(ota_0/ota_1 size 溢出),可能写入越界
- 解决:确认分区表中各分区地址连续且不重叠;用
idf.py partition_table校验 - 验证:
esptool.py read_flash 0x12000 0x140000 factory_dump.bin,比对原始 factory.bin 的 md5
问题四:esp_https_ota 返回 ESP_ERR_OTA_VALIDATE_FAILED
- 现象:固件下载完成,但写入 Flash 时校验失败
- 排查:检查固件 bin 文件是否完整(md5sum 比对);检查固件大小是否超过目标分区大小
- 根因:最常见的两个原因------(1) bin 文件在网络传输中损坏(HTTP 服务器未启用 Content-Length),(2) 固件体积超出了 ota_x 分区
- 解决:服务器端确认 Content-Length 头正确;用
idf.py app-size检查固件大小,确保 < 分区大小;考虑启用partial_http_download - 验证:
idf.py app-size显示的固件大小必须小于分区表配置的 ota_x 大小
问题五:公网 OTA 下载速度极慢(< 50KB/s)
- 现象:局域网测试正常(680KB/s),切到公网后龟速
- 排查:用 curl 从公网服务器下载同一文件,排除服务器带宽问题
- 根因:mbedTLS 的
MBEDTLS_SSL_MAX_CONTENT_LEN默认 16KB,公网高延迟下 TCP 窗口利用率低 - 解决:在
sdkconfig.defaults中设置CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=32768;同时增大esp_http_client_config_t.buffer_size_tx=8192;将固件服务器部署到阿里云 OSS/CDN - 验证:公网下载速率应从 ~40KB/s 提升至 ~200KB/s
问题六:多次 OTA 后 NVS 空间耗尽
- 现象:前几次 OTA 正常,若干次后
nvs_set_u32返回ESP_ERR_NVS_NOT_ENOUGH_SPACE - 排查:用
nvs_get_stats()查看已用/空闲条目数 - 根因:每次 OTA 往 NVS 写新条目但从不清除旧条目,默认 NVS 16KB 仅能存储约 100 条记录
- 解决:(1) 扩大 NVS 分区至 24KB 或 32KB;(2) OTA 相关 key 用完后主动
nvs_erase_key;(3) 使用命名空间隔离,避免与配网/配置 key 竞争 - 验证:
nvs_get_stats()空闲条目数应始终 > 20
十、总结
这篇文章从工程落地的角度把 ESP32 HTTPS OTA 的完整链路走了一遍:
- 分区设计是地基:factory + ota_0 + ota_1 三区布局 + otadata 状态机,是所有 OTA 机制的前提
- HTTPS 不能省:证书验证不是"跑通就行",公网部署必须启用完整的 TLS 证书链校验
- 回滚要有三层:ota_data 状态机 + 启动计数 + 业务健康检查,逐层兜底
- 测试覆盖故障路径:只测"正常升级成功"远远不够------断网、断电、固件损坏都得测
适用边界:本文方案适用于 4MB Flash 以上的 ESP32 芯片。2MB Flash 版本需要缩减 factory 分区到 512KB 并精简 BLE/HTTPS 栈。如果产品同时部署了数千个节点,应引入 OTA CDN(如阿里云 OSS)和控制台灰度发布策略。
已知局限:(1) 未实现差分升级(delta OTA),每次下载完整固件,对按流量计费的蜂窝网络设备不友好;(2) NVS 的启动计数在写入时断电可能丢失,生产环境建议使用 RTC 存储器备份;(3) Secure Boot v2 签名需要乐鑫官方签名工具链,本文未展开。
扩展方向:下一步可以从三个方向继续------(1) 对接阿里云/华为云 IoT 平台的固件管理服务,(2) 实现断点续传 OTA(适合大固件),(3) 增加固件差分压缩减少下载流量。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
参考资料
- ESP-IDF OTA 官方文档
- ESP-IDF esp_https_ota 编程指南
- 《ESP32双分区OTA避坑指南:从分区表配置到版本回滚的完整方案》
- 《ESP32双分区OTA:如何用esp_https_ota实现无缝回滚》
- 《给ESP32S3项目加上"后悔药":带回滚功能的OTA实现与OneNET集成》
📝 版本备注
- 硬件平台:ESP32-WROOM-32E(NodeMCU-32S,4MB Flash)
- 软件版本:ESP-IDF v5.2.2、mbedTLS v3.4.1、Python 3.11
- 兼容说明:分区表兼容 ESP32 全系列(S2/S3/C3/C6),需根据 Flash 容量调整分区大小;esp_https_ota API 在 IDF v4.4+ 可用,
crt_bundle_attach需要 IDF v5.0+;ESP32-C3/C6 仅 BLE(无双模蓝牙),不影响 OTA 功能本身