☰
ESP32固件防砖指南:双分区OTA与自动回滚机制详解
2026/10/30 3:55:19 网站建设 项目流程

1. 从一次“刷废了”的深夜事故说起

但凡玩过 ESP32 的人,大概率都经历过那种心跳骤停的瞬间:串口连上,烧录进度条走到 87%,手一抖拔了线,或者电源纹波一抖,再上电,串口只剩下一行反复重启的rst:0x3 (SW_RESET),连boot日志都刷不出来。那一刻脑子里只有一个念头——这板子是不是废了,是不是得重新买一片。

我手上这块 ESP32-WROOM-32 开发板,三年前就经历过这么一次。当时在做一个小型的温湿度采集节点,用 Arduino IDE 直接烧录,图省事没做任何分区规划,结果一次 OTA 中途断电,板子直接卡在 bootloader 循环里。后来折腾了大半天,用 esptool 手动擦除 flash 才救回来。那次之后我就下定决心,把双分区加自动回滚这套机制彻底吃透,再也没让任何一块板子真正“变砖”过。

这篇内容就是把这套东西从头到尾讲清楚。核心关键词是ESP32、固件、双分区、自动回滚、OTA。我会讲清楚为什么双分区能防砖、自动回滚是怎么触发的、分区表怎么配、OTA 流程怎么走、代码怎么写、踩过哪些坑。适合已经上手 ESP32、准备做 OTA 升级或者被刷机事故折磨过的朋友。哪怕你只用 Arduino IDE,也能照着落地。

先说结论:ESP32 固件刷坏了,绝大多数情况下不会真变砖,前提是你提前把双分区和回滚机制配好。真正意义上的“砖”是指 bootloader 本身被破坏且无法通过串口重新烧录,这种情况在 ESP32 上极其罕见,因为它的 bootloader 固化在 ROM 里,串口下载模式几乎不可能被软件彻底锁死。我们平时说的“变砖”,99% 是应用分区被写坏、启动校验失败、或者分区表错乱导致系统找不到可启动镜像。这些问题,双分区加自动回滚就是专门用来兜底的。

2. 为什么单分区 OTA 是“裸奔”

2.1 单分区升级的致命逻辑

很多人第一次做 OTA,思路很朴素:设备里就一个应用分区,新固件下载下来,直接覆盖写进去,写完重启。这个流程在理想环境下没问题,但它有一个无法回避的窗口期——写入过程中,旧固件已经被部分覆盖,新固件还没写完。这时候如果断电、复位、或者写入校验失败,设备里剩下的就是一个半成品镜像,bootloader 校验不过,直接卡死。

我用一个生活化的类比来解释:单分区 OTA 就像你只有一间房子,要换家具,必须先把旧家具搬出去,再把新家具搬进来。搬到一半停电了,房子里空空荡荡,你连睡觉的地方都没有。双分区则是你有两间房,新家具先搬进空房间,布置好了再换门牌,旧房间原封不动留着,随时能退回去。

ESP32 的 flash 分区表就是这个“房间分配图”。默认的 Arduino 分区方案里,很多是default或者minimal,只给了一个app0分区,根本没有app1。你在这个基础上做 OTA,本质上就是在裸奔。

2.2 分区表到底长什么样

ESP32 的分区表是一张写在 flash 固定偏移0x8000处的表格,每行描述一个分区的名称、类型、子类型、偏移和大小。一个支持双分区 OTA 的典型分区表大概是这样:

名称类型子类型偏移大小用途
nvsdatanvs0x90000x5000键值存储
otadatadataota0xE0000x2000OTA 状态记录
app0appota_00x100000x140000应用分区 A
app1appota_10x1500000x140000应用分区 B
spiffsdataspiffs0x2900000x160000文件系统

这里最关键的两个角色是otadata和两个app分区。otadata只有 8KB,但它记录了当前应该从哪个 app 分区启动、哪个分区是“待验证”状态、启动失败了几次。bootloader 每次上电都会先读otadata,再决定跳转到app0还是app1。

注意:分区表里所有分区的偏移必须对齐到 0x1000(4KB),大小也建议对齐。偏移算错会导致 bootloader 找不到分区,直接启动失败。

2.3 双分区如何把“变砖”概率压到极低

双分区的核心价值在于原子性切换。新固件永远写入“非当前运行”的那个分区,写完之后只改otadata里的一个标志位,告诉 bootloader“下次从新分区启动”。这个标志位的写入是一个很小的操作,几乎不会失败。即使失败,bootloader 读到的还是旧标志,继续从旧分区启动,设备照常工作。

这就把“升级失败导致设备不可用”的概率,从“整个固件写入过程都不能出错”降低到了“一个几字节的标志位写入不能出错”。两者可靠性完全不是一个量级。再加上自动回滚机制,即使新固件本身有 bug 导致启动后崩溃,系统也能在几次失败后自动切回旧分区。这套组合拳下来,ESP32 在 OTA 场景下的可用性可以做到非常接近“永不砖”。

3. 自动回滚的触发逻辑与状态机

3.1 otadata 里的状态到底怎么变

自动回滚不是魔法,它依赖otadata分区里记录的状态和 bootloader 的启动计数逻辑。ESP-IDF 的 OTA 状态机大致有这几个关键状态:

  • ESP_OTA_IMG_NEW:新固件刚写入,还没验证过
  • ESP_OTA_IMG_PENDING_VERIFY:bootloader 已经尝试启动它,等待应用自己确认“我没问题”
  • ESP_OTA_IMG_VALID:应用主动调用确认接口,标记为可用
  • ESP_OTA_IMG_INVALID:应用主动标记为无效,或者启动失败次数超限
  • ESP_OTA_IMG_ABORTED:升级过程被中断

流程是这样的:新固件写入app1后,otadata把app1标记为NEW。重启后 bootloader 看到NEW,把它改成PENDING_VERIFY,然后跳转过去启动。应用启动后,如果一切正常,需要在合适时机调用esp_ota_mark_app_valid_cancel_rollback(),把状态改成VALID。如果应用启动后崩溃、看门狗复位、或者根本没来得及调用确认接口,bootloader 在下次启动时发现它还是PENDING_VERIFY,就会把启动次数加一。连续失败达到阈值(默认是 3 次),bootloader 就把这个分区标记为INVALID,切回另一个分区。

3.2 回滚阈值和确认时机怎么定

默认的失败阈值是 3 次,这个值在menuconfig里可以调,对应CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和相关的重试次数配置。我的经验是,3 次是个比较稳妥的默认值。设成 1 次太激进,可能因为一次偶发的电源抖动就误回滚;设成 5 次以上又太迟钝,设备可能反复重启很久才恢复。

确认时机的选择更讲究。你不能在app_main一进来就立刻调用确认接口,因为那时候网络、外设、关键任务都还没初始化完,万一后面初始化失败,你已经把新固件标记成VALID了,回滚就失效了。我的做法是:在应用完成所有关键初始化、并且成功连上网络(或完成一次自检)之后,再调用确认接口。比如一个联网的传感器节点,我会等到 MQTT 连接成功、第一条数据上报成功之后,才标记固件有效。这样能最大程度保证“标记为有效”的固件确实是能正常工作的。

提示:调用esp_ota_mark_app_valid_cancel_rollback()之后,回滚窗口就关闭了。如果之后应用再崩溃,系统不会自动回滚,只会不断重启当前固件。所以确认时机宁晚勿早。

3.3 回滚发生时设备经历了什么

我实测过一次完整的回滚过程,记录下来的串口日志很有代表性。新固件因为一个空指针解引用,在初始化阶段就触发了 panic,重启。bootloader 日志里能看到类似这样的信息:

I (30) boot: Loaded app from partition at offset 0x150000 I (30) boot: Set actual ota_seq=2, ota_seq=1 W (30) boot: ota_seq=2 is pending verify ... I (120) boot: Loaded app from partition at offset 0x150000 W (120) boot: ota_seq=2 is pending verify, rollback count 1 ... I (210) boot: Loaded app from partition at offset 0x150000 W (210) boot: ota_seq=2 is pending verify, rollback count 2 ... I (300) boot: Rollback to ota_seq=1 I (300) boot: Loaded app from partition at offset 0x10000

三次启动失败后,bootloader 自动切回了app0,设备恢复正常。整个过程不需要人工干预,也不需要串口连接。这就是自动回滚的价值——它让设备在无人值守的场景下也能自我修复。

4. 分区表配置与工程落地实操

4.1 在 Arduino IDE 里配置双分区

很多人以为双分区是 ESP-IDF 的专属功能,其实 Arduino IDE 里也能用,只是配置方式不太一样。Arduino-ESP32 核心自带了几种分区方案,在“工具”菜单的“Partition Scheme”里可以选。默认是Default 4MB with spiffs,这个方案只有一个 app 分区,不支持 OTA。你要选Minimal SPIFFS (1.9MB APP with OTA)或者Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)这类带 OTA 的方案。

但内置方案的分区大小是固定的,如果你的固件比较大,或者需要更大的文件系统,就得自定义分区表。做法是在项目目录下新建一个partitions.csv文件,然后在 Arduino IDE 里没法直接指定,需要改用 PlatformIO 或者在boards.txt里改配置。这也是为什么很多认真做 OTA 的项目最后都转到了 PlatformIO 或者纯 ESP-IDF。

4.2 PlatformIO 下的分区表配置

PlatformIO 的配置更灵活。在platformio.ini里可以指定分区表文件:

[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino board_build.partitions = partitions.csv

然后在项目根目录放一个partitions.csv,内容按前面表格的格式写。这里有个容易踩的坑:分区表的总大小不能超过 flash 容量。4MB flash 的话,所有分区的偏移加大小不能超过 0x400000。我见过有人把两个 app 分区都设成 0x200000,加上其他分区直接超了,编译能过但烧录后启动异常。

4.3 ESP-IDF 下的 menuconfig 关键项

如果你用 ESP-IDF,配置入口在idf.py menuconfig里。需要重点确认的几项:

  • Partition Table→Partition Table选择Custom partition table CSV,并指定文件名
  • Bootloader config→Enable app rollback support必须打开
  • Bootloader config→Number of app rollback attempts默认 3,可调
  • Component config→ESP32-specific→Support for external, SPI-connected RAM按需

打开回滚支持后,bootloader 才会去读otadata里的PENDING_VERIFY状态并执行计数逻辑。如果这项没开,即使你分区表配了双分区,自动回滚也不会生效,新固件启动失败后只会一直重启。

注意:修改分区表后,第一次烧录建议用idf.py erase-flash先全擦,再idf.py flash。因为旧的otadata内容可能和新分区布局不兼容,残留数据会导致启动行为异常。

5. OTA 升级代码的完整实现

5.1 基于 ESP-IDF 的 OTA 核心流程

ESP-IDF 提供了一套esp_ota_opsAPI,核心步骤可以概括为:获取当前分区、开始 OTA、分块写入、结束 OTA、设置启动分区、重启。下面是一段我实际项目里用的简化代码,去掉了业务逻辑,保留了骨架:

#include "esp_ota_ops.h" #include "esp_http_client.h" #include "esp_log.h" static const char *TAG = "ota"; void ota_task(void *pvParameter) { esp_http_client_config_t config = { .url = "http://your-server/firmware.bin", }; esp_http_client_handle_t client = esp_http_client_init(&config); esp_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to open HTTP connection: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); vTaskDelete(NULL); return; } int content_length = esp_http_client_fetch_headers(client); const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); ESP_LOGI(TAG, "Writing to partition subtype %d at offset 0x%x", update_partition->subtype, update_partition->address); esp_ota_handle_t update_handle = 0; err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); vTaskDelete(NULL); return; } char buf[1024]; int total_read = 0; while (1) { int data_read = esp_http_client_read(client, buf, sizeof(buf)); if (data_read <= 0) break; err = esp_ota_write(update_handle, buf, data_read); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_write failed: %s", esp_err_to_name(err)); esp_ota_abort(update_handle); esp_http_client_cleanup(client); vTaskDelete(NULL); return; } total_read += data_read; } err = esp_ota_end(update_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); vTaskDelete(NULL); return; } err = esp_ota_set_boot_partition(update_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); vTaskDelete(NULL); return; } ESP_LOGI(TAG, "OTA done, restarting..."); esp_http_client_cleanup(client); esp_restart(); }

这段代码里,esp_ota_get_next_update_partition(NULL)会自动返回“非当前运行”的那个 app 分区,这就是双分区切换的关键。esp_ota_set_boot_partition只改otadata标志,不碰旧分区内容。

5.2 应用有效性确认的代码位置

新固件启动后,必须在合适位置调用确认接口。我通常把它放在网络连接成功之后:

void app_main(void) { // 初始化 NVS、网络、外设... wifi_init_sta(); // 等待网络连接 if (wait_for_wifi_connected(30000)) { // 网络正常,标记固件有效 esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); if (err == ESP_OK) { ESP_LOGI(TAG, "Firmware marked valid, rollback cancelled"); } } else { ESP_LOGW(TAG, "Network not ready, rollback window still open"); } // 继续业务逻辑... }

如果网络一直连不上,我就不确认,让回滚窗口保持打开。这样如果新固件有网络相关的 bug,设备重启几次后会自动回到旧固件。这个策略在实际部署中救过我好几次。

5.3 Arduino 框架下的 OTA 实现差异

Arduino-ESP32 核心也封装了Update库,用法比 ESP-IDF 简单很多:

#include <Update.h> #include <HTTPClient.h> void performOTA(String url) { HTTPClient http; http.begin(url); int httpCode = http.GET(); if (httpCode == HTTP_CODE_OK) { int contentLength = http.getSize(); bool canBegin = Update.begin(contentLength); if (canBegin) { WiFiClient *client = http.getStreamPtr(); size_t written = Update.writeStream(*client); if (written == contentLength) { if (Update.end()) { if (Update.isFinished()) { ESP.restart(); } } } } } http.end(); }

但要注意,Arduino 的Update库默认也会用双分区,前提是你的分区方案支持 OTA。它内部同样会调用esp_ota_set_boot_partition。不过 Arduino 框架下对回滚确认的控制没有 ESP-IDF 那么细,Update.end()之后通常需要手动处理确认逻辑,或者依赖核心的默认行为。如果你对回滚时机有严格要求,还是建议用 ESP-IDF。

6. 常见问题与排查技巧实录

6.1 升级后设备反复重启,但不回滚

这是最常见的问题。原因通常是回滚支持没打开,或者应用在启动早期就调用了确认接口。排查步骤:

  1. 确认menuconfig里Enable app rollback support是打开的
  2. 检查代码里esp_ota_mark_app_valid_cancel_rollback()的调用位置,是否在关键初始化之前
  3. 看串口日志里有没有pending verify字样,如果没有,说明 bootloader 根本没进入回滚计数流程

还有一种可能是分区表里otadata分区缺失或大小不对。otadata必须是data类型、ota子类型,大小 0x2000。少了它,OTA 状态无处记录,回滚自然无从谈起。

6.2 OTA 写入到一半失败,设备还能启动吗

能。因为写入的是非当前分区,旧分区完好无损。esp_ota_abort()会清理未完成的写入状态,otadata里的启动标志没变,下次启动还是从旧分区走。我实测过多次中途断电,只要旧分区没被碰过,设备都能正常启动。这也是双分区最让人安心的地方。

但有一种情况例外:如果你在esp_ota_set_boot_partition之后、重启之前断电,而新分区其实写坏了,那下次启动会尝试新分区,失败后触发回滚。这个过程可能需要几次重启,但最终会回到旧分区。

6.3 分区表改了之后编译报错

常见报错是Region overflow或者partition table offset exceeds flash size。这时候要算一下总账:所有分区的偏移加大小,必须小于等于 flash 容量。另外,app分区的大小必须是 0x10000 的整数倍,因为 ESP32 的 flash 映射要求。我一般会留至少 0x10000 的余量,避免边界问题。

6.4 回滚后旧固件的 NVS 数据还在吗

在。NVS 分区是独立的,OTA 切换 app 分区不会动 NVS。但要注意,如果新旧固件对 NVS 里键值的结构定义不一致,回滚后旧固件读到新固件写的数据可能会解析异常。我的做法是给 NVS 数据加版本号,固件启动时检查版本,不匹配就重置相关键值。这个细节在频繁 OTA 的项目里很重要。

问题现象可能原因排查方法
升级后一直重启回滚未启用或确认过早查 menuconfig 和确认接口调用位置
找不到 OTA 分区分区表无 app1 或 otadata检查 partitions.csv
写入失败flash 空间不足或网络中断看串口错误码,确认分区大小
回滚不触发otadata 损坏或大小错误擦除 flash 重新烧录
回滚后数据异常NVS 结构不兼容加版本号校验

6.5 几个我踩过的坑

第一个坑是在中断里调用 OTA 相关函数。OTA 写入涉及 flash 操作,flash 操作在某些情况下会关中断,在中断上下文里调用会导致系统不稳定。所有 OTA 操作都应该放在独立任务里,给足栈空间。

第二个坑是HTTP 下载没有超时和重试。网络不稳的时候,esp_http_client_read可能长时间阻塞,看门狗直接复位。一定要设置timeout_ms,并且对失败做重试或回退处理。

第三个坑是固件版本号没管理好。OTA 服务器上放了新固件,但设备不知道版本,反复下载同一个文件。我在otadata之外,还在 NVS 里存了一个版本字符串,升级前先比对,相同就跳过。

7. 固件安全与升级策略的延伸思考

7.1 固件校验不能只靠 CRC

ESP-IDF 的 OTA 流程会对写入的镜像做 SHA-256 校验,esp_ota_end会验证镜像的完整性。但这只能保证“传输没出错”,不能保证“固件来源可信”。如果你的 OTA 走公网,强烈建议加上签名校验。ESP-IDF 支持 secure boot 和 flash encryption,配合 OTA 可以做到固件只有被私钥签名的才能启动。这套配置稍微复杂,但对于商用设备几乎是必选项。

7.2 分批升级与灰度发布

设备量大了之后,一次性全量推送 OTA 风险很高。我的做法是分批:先推 5% 的设备,观察 24 小时,确认没有异常回滚再扩大。ESP32 端可以在 OTA 请求里带上设备 ID 和当前版本,服务器根据策略决定是否下发。这样即使新固件有问题,影响面也可控。

7.3 回滚不是万能药

自动回滚能解决“新固件启动失败”的问题,但解决不了“新固件运行一段时间后才出问题”的情况。如果新固件启动正常、确认了有效、跑了三天才崩溃,回滚窗口早就关了。所以除了回滚,还需要运行时看门狗、异常上报、远程日志这些手段配合。我一般会在固件里加一个心跳机制,连续多次心跳失败就主动触发一次“标记无效并重启”,强制回到旧分区。

8. 我个人的实操体会

这套双分区加自动回滚的机制,我从三年前那次事故之后就开始用,前后部署过几百台设备,真正因为 OTA 导致设备完全失联的情况一次都没再出现过。最惊险的一次是新固件里有个内存泄漏,设备跑十几个小时后重启,但因为我在心跳逻辑里加了“连续三次异常重启就标记无效”,设备自动回滚到了旧版本,远程看日志才发现问题。

如果你现在还在用单分区做 OTA,我建议你花一个下午把分区表改了,把回滚支持打开。改动量不大,但带来的可靠性提升是数量级的。分区表配置和确认接口的调用位置是两个最容易出错的地方,多打日志、多测几次断电场景,心里就有底了。ESP32 这颗芯片在 OTA 方面的设计其实相当完善,把它的能力用起来,固件刷坏这件事,真的可以不再是噩梦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询