ESP32-S3 OTA 空中升级:不拆机不拔线,通过网络给设备刷固件

前言:每次改代码都要插线烧录,太痛苦了

公众号 “坏柠编程”
学到这里,我已经能让 ESP32-S3 配网、HTTP 通信、MQTT 上云了。但有个问题一直困扰着我——每次更新固件都要用数据线连电脑烧录

开发阶段还好,设备就在手边。但如果设备已经装到了天花板上的智能灯里?装到了工厂的产线设备上?装到了客户家里?总不能每次改个 bug 都上门拆机吧?

这就是 OTA(Over-The-Air)空中升级 要解决的问题——让设备通过网络自动下载新固件并完成升级,全程不需要物理连接。

今天就来记录我从零实现 OTA 的完整过程,包括分区表设计、本地 HTTPS 服务器搭建,以及设备端代码实现。


一、OTA 升级的原理

在动手之前,我先搞明白了一个关键问题:设备正在运行的固件,怎么能"自己升级自己"?

答案是:不是替换自己,而是写到另一个分区,然后切换启动。

1.1 分区设计

OTA 需要一个特殊的分区表,至少包含四个关键分区:

# Name,   Type, SubType, Offset,   Size, Flags
nvs,      data, nvs,     ,        0x4000,
otadata,  data, ota,     ,        0x2000,
phy_init, data, phy,     ,        0x1000,
factory,  app,  factory, ,        1M,
ota_0,    app,  ota_0,   ,        1M,
ota_1,    app,  ota_1,   ,        1M,
分区 作用
factory 出厂固件,永远不会被覆盖,是最后的"保底"
ota_0 OTA 升级用的第一个分区
ota_1 OTA 升级用的第二个分区
otadata 记录"当前应该从哪个分区启动"

1.2 升级流程

第一次烧录
┌──────────┐  ┌──────────┐  ┌──────────┐
│ factory  │  │  ota_0   │  │  ota_1   │
│ 出厂固件  │  │  (空)    │  │  (空)    │
│ ★ 运行中 │  │          │  │          │
└──────────┘  └──────────┘  └──────────┘

第一次 OTA 升级
┌──────────┐  ┌──────────┐  ┌──────────┐
│ factory  │  │  ota_0   │  │  ota_1   │
│ 出厂固件  │  │ 新固件 v2 │  │  (空)    │
│          │  │ ★ 运行中 │  │          │
└──────────┘  └──────────┘  └──────────┘

第二次 OTA 升级
┌──────────┐  ┌──────────┐  ┌──────────┐
│ factory  │  │  ota_0   │  │  ota_1   │
│ 出厂固件  │  │ 旧固件 v2 │  │ 新固件 v3 │
│          │  │          │  │ ★ 运行中 │
└──────────┘  └──────────┘  └──────────┘

第三次 OTA 升级
┌──────────┐  ┌──────────┐  ┌──────────┐
│ factory  │  │  ota_0   │  │  ota_1   │
│ 出厂固件  │  │ 新固件 v4 │  │ 旧固件 v3 │
│          │  │ ★ 运行中 │  │          │
└──────────┘  └──────────┘  └──────────┘

核心思路:ota_0 和 ota_1 轮流写入,factory 永远不动。

这个设计非常巧妙:

  • 升级过程中如果断电,原来的固件还在另一个分区,不会变砖
  • 出厂固件永远保留,是最后的安全网
  • otadata 分区记录了当前该从哪个分区启动

二、ESP-IDF 提供的两种 OTA 方式

方式 说明 复杂度
原生 APIapp_update 组件) 底层 API,灵活但代码量大
简化 APIesp_https_ota 组件) 封装好的 HTTPS OTA,几行代码搞定 简单

我选了简化 API——esp_https_ota,因为它把 HTTP 下载 + 固件写入 + 分区切换全封装好了,只需要一个函数调用就能完成升级。


三、搭建本地 HTTPS 服务器

OTA 升级需要从某个服务器下载固件文件。用第三方服务器不太靠谱(随时可能关停),所以我先在本地搭一个。

3.1 生成 SSL 证书

因为用的是 HTTPS,所以需要一个 SSL 证书。设备在升级时会验证服务器证书是否合法。

openssl req -x509 -newkey rsa:2048 -keyout huaining_key.pem -out huaining_cert.pem -days 3650 -nodes

执行后会让你填一些信息(国家、省份、组织名等),随便填就行,不影响后续操作。

这条命令会生成两个文件:

  • huaining_key.pem — 私钥(服务器用)
  • huaining_cert.pem — 证书(服务器和设备都要用)
    在这里插入图片描述

3.2 搭建 HFS 文件服务器

我用的是开源工具 HFS(HTTP File Server),下载对应平台的版本,解压后双击打开。
在这里插入图片描述

配置步骤:

① 设置 HTTPS 端口

我设成了 8088,只要电脑上没被占用就行。记住这个端口号,后面代码要用。

② 上传证书和密钥

把刚才生成的 huaining_cert.pemhuaining_key.pem 上传到 HFS 的 SSL 配置中,上传完一定要点保存!
在这里插入图片描述
③ 查看电脑 IP

在 Windows 命令行输入 ipconfig,找到本机 IP,比如我的是 192.168.1.242记住这个 IP。

④ 添加固件文件

把要升级的固件文件(比如 hello_world.bin)添加到服务器中。固件文件在工程编译后的 build 目录里:

hello_world/build/hello_world.bin

在这里插入图片描述
⑤ 验证

在浏览器中访问 https://192.168.1.242:8088/,如果能看到文件列表,说明配置成功。

浏览器可能会提示"不安全",因为是自签名证书,忽略就好。
在这里插入图片描述

如果打不开页面,排查清单:

  • 端口是否被占用?
  • IP 是否正确?
  • HFS 配置是否点了保存?

四、设备端代码实现

4.1 准备工作

放置证书文件:huaining_cert.pem 拷贝到工程的 main 目录下。
在这里插入图片描述

修改 CMakeLists.txt:

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS "."
    EMBED_TXTFILES huaining_cert.pem
)

4.2 配置分区表

打开 idf.py menuconfig

选择 OTA 分区表:

Partition Table → Factory app, two OTA definitions

在这里插入图片描述

修改 Flash 大小: 因为 OTA 分区表需要 3MB 以上(factory 1M + ota_0 1M + ota_1 1M + 数据分区),默认的 2MB 不够用:

Serial flasher config → Flash size → 4 MB

在这里插入图片描述

配置好后可以查看分区表:

idf.py partition-table

在这里插入图片描述

# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,16K,
otadata,data,ota,0xd000,8K,
phy_init,data,phy,0xf000,4K,
factory,app,factory,0x10000,1M,
ota_0,app,ota_0,0x110000,1M,
ota_1,app,ota_1,0x210000,1M,

4.3 OTA 核心代码

OTA 的核心代码出奇地简单,就一个函数调用:

extern const uint8_t server_cert_pem_start[] asm("_binary_huaining_cert_pem_start");
extern const uint8_t server_cert_pem_end[]   asm("_binary_huaining_cert_pem_end");

void simple_ota_run()
{
    ESP_LOGI(TAG, "Starting OTA...");

    // ① 配置 HTTP 客户端参数
    esp_http_client_config_t config = {
        .url = "https://192.168.1.242:8088/hello_world.bin",  // ★ 改成你的 IP 和文件名
        .cert_pem = (char *)server_cert_pem_start,           // 证书验证
        .event_handler = _http_event_handler,
        .skip_cert_common_name_check = true,  // 自签名证书没有域名信息,跳过检查
    };

    // ② 配置 OTA 参数
    esp_https_ota_config_t ota_config = {
        .http_config = &config,
    };

    // ③ 一行代码完成 OTA!
    ESP_LOGI(TAG, "Downloading from %s", config.url);
    esp_err_t ret = esp_https_ota(&ota_config);

    if (ret == ESP_OK) {
        ESP_LOGI(TAG, "OTA Succeed, Rebooting...");
        esp_restart();  // 升级成功,重启加载新固件
    } else {
        ESP_LOGE(TAG, "Firmware upgrade failed");
    }
}

esp_https_ota() 这一个函数就干了所有事情:

  1. 通过 HTTPS 连接服务器
  2. 下载固件数据
  3. 写入到空闲的 OTA 分区(ota_0 或 ota_1)
  4. 更新 otadata 分区,标记新分区为启动分区
  5. 返回结果

成功后调用 esp_restart() 重启,系统就会自动从新写入的分区启动了。

4.4 完整代码

#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_netif.h"
#include "esp_ota_ops.h"
#include "esp_http_client.h"
#include "esp_https_ota.h"

static const char *TAG = "simple_ota_example";
static EventGroupHandle_t s_wifi_event_group;
static const int CONNECTED_BIT = BIT0;
static bool is_connect_wifi = false;

#define WIFI_SSID     "hyx123"
#define WIFI_PASSWORD "123456789"

extern const uint8_t server_cert_pem_start[] asm("_binary_huaining_cert_pem_start");
extern const uint8_t server_cert_pem_end[]   asm("_binary_huaining_cert_pem_end");

// ==================== HTTP 事件回调 ====================
esp_err_t _http_event_handler(esp_http_client_event_t *evt)
{
    switch (evt->event_id) {
    case HTTP_EVENT_ERROR:
        ESP_LOGD(TAG, "HTTP_EVENT_ERROR");
        break;
    case HTTP_EVENT_ON_CONNECTED:
        ESP_LOGD(TAG, "HTTP_EVENT_ON_CONNECTED");
        break;
    case HTTP_EVENT_ON_DATA:
        ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA, len=%d", evt->data_len);
        break;
    case HTTP_EVENT_ON_FINISH:
        ESP_LOGD(TAG, "HTTP_EVENT_ON_FINISH");
        break;
    case HTTP_EVENT_DISCONNECTED:
        ESP_LOGD(TAG, "HTTP_EVENT_DISCONNECTED");
        break;
    default:
        break;
    }
    return ESP_OK;
}

// ==================== OTA 核心逻辑 ====================
void simple_ota_run()
{
    ESP_LOGI(TAG, "Starting OTA example task");
    esp_http_client_config_t config = {
        .url = "https://192.168.137.1:8088/hello_world.bin",  //  改成你的 IP
        .cert_pem = (char *)server_cert_pem_start,
        .event_handler = _http_event_handler,
        .skip_cert_common_name_check = true,
    };

    esp_https_ota_config_t ota_config = {
        .http_config = &config,
    };
    ESP_LOGI(TAG, "Attempting to download update from %s", config.url);
    esp_err_t ret = esp_https_ota(&ota_config);
    if (ret == ESP_OK) {
        ESP_LOGI(TAG, "OTA Succeed, Rebooting...");
        esp_restart();
    } else {
        ESP_LOGE(TAG, "Firmware upgrade failed");
    }
    while (1) {
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

// ==================== OTA 任务 ====================
static void ota_task(void *pvParameters)
{
    while (1) {
        if (is_connect_wifi) {
            simple_ota_run();
        }
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

// ==================== Wi-Fi 事件处理 ====================
static void event_handler(void *arg, esp_event_base_t event_base,
                          int32_t event_id, void *event_data)
{
    if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
        wifi_config_t wifi_config = {0};
        memcpy(wifi_config.sta.ssid, WIFI_SSID, strlen(WIFI_SSID) + 1);
        memcpy(wifi_config.sta.password, WIFI_PASSWORD, strlen(WIFI_PASSWORD) + 1);
        ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
        ESP_ERROR_CHECK(esp_wifi_connect());
        ESP_LOGI(TAG, "Connecting to WiFi %s...", WIFI_SSID);
    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
        is_connect_wifi = false;
        esp_wifi_connect();
        xEventGroupClearBits(s_wifi_event_group, CONNECTED_BIT);
    } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
        ESP_LOGI(TAG, "WiFi Connected");
        xEventGroupSetBits(s_wifi_event_group, CONNECTED_BIT);
        is_connect_wifi = true;
    }
}

// ==================== 主函数 ====================
void app_main(void)
{
    ESP_LOGI(TAG, "OTA example app_main start");

    // NVS 初始化(OTA 分区表的 NVS 分区可能比非 OTA 的小,需要处理兼容问题)
    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_ERROR_CHECK(esp_netif_init());
    s_wifi_event_group = xEventGroupCreate();
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
    assert(sta_netif);

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
    ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL));

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_start());

    xTaskCreate(&ota_task, "ota_task", 8192, NULL, 5, NULL);
}

五、运行效果

编译烧录后,设备先配网(如果之前配过就直接连接),连上 Wi-Fi 后立刻开始 OTA:

I (1730) simple_ota_example: WiFi Connected to ap
I (2570) simple_ota_example: Starting OTA example task
I (2570) simple_ota_example: Attempting to download update from https://192.168.3.24:8088/hello_world.bin
I (3220) esp_https_ota: Starting OTA...
I (3220) esp_https_ota: Writing to <ota_0> partition at offset 0x110000
I (5690) esp_image: segment 0: paddr=00110020 vaddr=3c020020 size=0a328h ( 41768) map
...
I (5820) simple_ota_example: OTA Succeed, Rebooting...

在这里插入图片描述

重启后运行的就是新下载的 hello_world 固件了:

Hello world!
This is esp32s3 chip with 2 CPU core(s), WiFi/BLE, silicon revision v0.1, 4MB external flash
Minimum free heap size: 390340 bytes
Restarting in 10 seconds...

从下载到重启,整个过程只花了几秒钟。


六、OTA 失败排查清单

问题 排查方向
连接服务器超时 设备和电脑是否在同一网段?(如都是 192.168.3.x)
证书验证失败 xiaozhi_cert.pem 是否正确放到 main 目录并在 CMakeLists 中声明?
下载失败 先用浏览器访问 https://你的IP:端口/文件名 测试能否下载
端口连不上 检查 HFS 端口配置,检查防火墙是否放行
分区不够大 确认 Flash 大小设置 ≥ 4MB,确认选了 OTA 分区表
固件格式错误 确认上传的是 .bin 文件(build 目录下的),不是 .elf

七、总结

知识链路已经完整了

NVS(存储)
  ↓
SPIFFS(文件系统)
  ↓
分区表(Flash 空间规划)
  ↓
SmartConfig(Wi-Fi 配网)
  ↓
HTTP/HTTPS(网络通信)
  ↓
MQTT(实时通信,上云)
  ↓
OTA(远程升级)  ← 本篇

到这里,一个物联网设备从开发到产品化需要的核心技术栈就基本齐了。

关键收获

  1. OTA 的本质是"乾坤大挪移":不是覆盖正在运行的固件,而是写到另一个分区再切换
  2. esp_https_ota() 一个函数搞定一切:下载、写入、切换分区,全自动
  3. 分区表设计是 OTA 的基础:没有 ota_0、ota_1 和 otadata,OTA 无从谈起
  4. HTTPS + 证书验证是生产必备:确保固件来源可信,防止被篡改
  5. factory 分区是最后的保底:无论怎么升级,出厂固件永远在

一点感悟

回顾整个学习过程,从最开始的 GPIO 点灯,到现在能做到通过网络远程升级固件,ESP32-S3 的能力远超我最初的想象。它不仅仅是一个"单片机",更像是一个完整的物联网解决方案平台。

最让我感慨的是 ESP-IDF 的 API 设计——从 NVS 到 SPIFFS,从 HTTP 到 MQTT,再到 OTA,所有模块的使用套路都是一致的:配置结构体 → 初始化 → 注册回调 → 启动。掌握了这个模式,学新功能的速度会越来越快。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐