☰
ESP32 OTA升级防变砖:双分区与自动回滚机制全解
2026/10/4 20:19:40 网站建设 项目流程

手里那块ESP32刷完新固件后突然没了动静,串口要么一片死寂,要么循环打印abort,那一瞬间多少人脑子里蹦出两个字:变砖。我在刚碰这块芯片时也这么慌过,后来把机制研究明白,再配上双分区和自动回滚,现在OTA升级翻车我基本不慌。这篇就把"ESP32会不会因刷固件变砖、双分区怎么设计、自动回滚怎么落地"一次讲透,结论先放这儿:只要处理得当,ESP32刷固件极难变成永久砖头。适合用Arduino或ESP-IDF做产品的朋友,也适合刚接触OTA的爱好者边看边实操。

1. 先搞清楚"变砖"到底坏在了哪一层

1.1 三层启动链路:芯片ROM里那点东西,谁也刷不坏

ESP32的启动不是"上电就跑你的程序",而是分三个阶段:

第一层是芯片内部ROM里固化的引导代码,叫ROM bootloader。它出厂就焊死在硅片里,不可擦写,任何烧录工具、任何误操作都影响不到它,除非你把芯片物理砸烂。第二层是flash 0x1000处的二级bootloader,这一层可写,经常被各种"全量镜像"错误覆盖,掉坑往往在这。第三层才是真正放业务代码的应用分区。

所以结论先行:绝大多数"刷坏"只是第二、三层坏了,第一层还健在。而只要ROM bootloader还在,芯片就不可能"永久变砖",因为复位后总能进入下载模式。

这里值得停下来想一下:为什么ROM bootloader是"复活甲"?因为它掌握着两条路——按住复位进入串口下载模式,或者跳转到二级bootloader。你在Arduino IDE里点上传,esptool做的事情正是通过ROM bootloader把新代码写进flash。ROM这层在,烧录通道就在。

1.2 三步定位法:三分钟判断手里的板子是真砖还是假砖

先靠操作判断,不动手排查都是瞎猜。

  1. 板子断电。
  2. 按住BOOT键(或者把GPIO0和GND短接),上电,再松开BOOT。
  3. 打开串口助手,或者直接运行esptool.py --port 串口 read_mac,看能不能读到芯片信息。

能读到MAC地址、芯片型号,说明ROM bootloader还在工作,板子没真砖。下面这张表是我平时判断板子状态的对照清单:

板子现象串口表现esptool反应结论
能进下载模式,APP也正常运行有正常业务日志需手动复位后正常读到设备信息没砖
只能进下载模式,APP起不来无业务日志,复位后提示等待下载正常读到设备信息假砖(固件层损坏)
设备信息完全读不到无任何回应超时或无法连接硬件层问题,概率极低

注意:如果烧录时选了错误的flash模式(比如DIO/DOUT搞错),可能出现"能下载但启动不了"的现象,这也是软问题,重刷正确模式就好。

1.3 顺着启动日志走一遍,就知道刚才那一刷到底动了什么

举一个我经历过的情况:固件升级后板子无限重启,日志显示Guru Meditation Error: Core 1 panic。翻日志找到abort() was called at PC 0x...,定位后发现新固件有野指针,启动阶段把堆写崩了。这种不需要重刷,按住BOOT进下载模式,烧一个稳定版本回去即可。

如果是升级过程中断电,flash里的app分区写入了一半,ROM bootloader跳到二级bootloader时发现bootloader的magic byte还在,而bootloader加载app时发现app区不是完整固件、校验失败,就会一直复位。看上去也是死循环,但复位时能进下载模式,重刷即可。

记住一件事:看到启动失败日志别急着格式化,先看是bootloader在复位,还是app在复位。这两层出了问题,抢救手段完全不同。

2. 双分区是怎么做到"打不死"的:分区表与otadata的设计逻辑

2.1 一张分区表=一张楼盘规划图

分区表就是一张地址规划图:哪个区块给NVS、哪个区块放应用、哪个区块放文件系统。ESP-IDF在4MB flash上常用的双分区布局长这样:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000, ota_0, app, ota_0, 0x110000, 0x100000, ota_1, app, ota_1, 0x210000, 0x100000, spiffs, data, spiffs, 0x310000, 0x0F0000,

每个分区偏移按顺序接续,两个OTA区各给1MB,尾部再放一个文件系统分区,总共正好铺满4MB flash。如果你用8MB、16MB flash,可以在尾部再加数据分区。

关键在Type那一列:app类型分区不只是"一块存固件的区域",bootloader会识别它的SubType(factory、ota_0、ota_1)。启动逻辑是:有factory分区就优先启动factory;没有factory,则根据otadata的指示启动ota_0或ota_1。

2.2 otadata:投给"谁是合法固件"的关键一票

otadata虽然只有8KB,但它相当于一张选票,记录两个应用分区的启动状态。bootloader每次启动去读otadata,看应该从哪个app分区加载,并且这个分区的固件处于什么状态、是否需要验证、是否已失效。

双分区不是简单地在两个地址上各放一份固件。真正的价值在于:otadata是"仲裁者",它可以让bootloader在启动时自动决定去哪个分区。因此,OTA写坏一个分区,otadata还能让系统跳回另一个分区。

2.3 为什么单分区加备份文件代替不了双分区

有人会想:那我用单分区方案,把旧固件备份到一个文件系统分区,升级失败再用esptool刷回来,不也行吗?

短期看可以,但有几个问题:

  • 应用分区在OTA运行时正在被擦除和写入,不可能原地替换。单分区升级时,新固件必须先下载到另一个临时位置,下载完再搬移。这个"搬运"过程中断电,旧固件已经没了,新固件还没就位,设备下次启动时没有可用的app,只能靠外部工具人工救援。
  • 双分区方案中,bootloader级别的切换是原子思路:先在ota_0写完,校验完好,再更新otadata指向它,然后重启。即使写入途中断电,otadata仍指向旧分区,启动时还是旧固件。下次升级重新下载就行。
  • 备份到文件系统还有flash容量和校验问题。双分区是硬件级别的双保险,应用层不用操心"抢救旧固件"这件事。

打个生活化的比方:单分区升级像你在装修唯一一套房,拆了旧墙才搬新家具进去,停电就住桥洞;双分区等于你先在隔壁样板间装修完,验收通过再挂上你家门牌,不满意还能换回来。

2.4 在ESP-IDF和Arduino里落地双分区

ESP-IDF的做法:

  1. 写一个partitions_ota.csv,内容参照上面的表。
  2. 在menuconfig里进入 "Partition Table" → "Custom partition table CSV",填上文件路径。
  3. 编译,烧录时除了烧app,还要烧分区表:partition-table.bin烧到0x8000。

提醒一句:分区表变更会影响后面所有OTA地址,设备已经量产的话,分区表不要随意改动,改了可能直接启动失败。

Arduino的做法更简单:在Tools菜单的Partition Scheme里选择带OTA的方案。常见选项与含义大致如下:

Partition Scheme说明
Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)单APP区,没有OTA分区,不能做双分区回滚
Dual APP with spiffs (1.2MB APP/1.5MB SPIFFS)双APP区,适合OTA
2MB APP + 2MB SPIFFS (Dual OTA)双APP区且数据区空间更大

选择带Dual APP/OTA的选项后,代码里用ArduinoOTA或Update类升级,库会自动把新固件写入另一个app分区并切换启动项。这里有个容易忽略的点:Arduino开发板的BOOT按钮和GPIO0排列并不统一,OTA真弄坏时,串口下载模式才是最后保险。

3. 自动回滚的状态机:从"新来的"到"转正"只需要一票

3.1 固件状态从哪来:NEW、VALID、INVALID是怎么流转的

当bootloader启动了一个新写入的ota_0分区固件时,otadata里这个分区的状态是NEW,表示"这固件是刚来的,还没被验证过"。接下来有几种结果:

  • 新固件运行后,业务初始化成功,调用esp_ota_mark_app_valid_correctly_after_boot(),状态变成VALID,下次启动还从它加载。
  • 新固件运行时发现异常,调用esp_ota_mark_app_invalid_and_reboot(),状态变成INVALID,bootloader重启后自动改从另一个分区加载。
  • 新固件没来得及标记就崩溃,同时bootloader层开了rollback选项,下一次重启时bootloader发现它还没转正,也会自动切回原分区。

很多人只听说过"回滚"这个词,但不知道背后其实就是这几个状态在otadata里倒腾。理解了NEW、VALID、INVALID的流转,你就明白为什么新固件必须主动"投一票"证明自己能用。

3.2 ESP-IDF里那两个决定生死的API

在ESP-IDF中,业务初始化跑完、自检通过之后,在main函数末尾调用:

#include "esp_ota_ops.h" esp_err_t ret = esp_ota_mark_app_valid_correctly_after_boot(); if (ret != ESP_OK) { ESP_LOGE("APP", "mark valid failed"); }

反之,自检失败时:

esp_ota_mark_app_invalid_and_reboot();

注意:esp_ota_mark_app_invalid_and_reboot()会在内部触发重启,调用后不要再做任何事,否则可能产生竞态。我见过有人调用完还去关外设、存日志,结果重启时机不确定,反而把状态写坏了。关掉中断、直接标记、立即重启,是最干净的流程。

完整升级流程的顺序也要记牢:

  1. 下载新固件到另一分区:esp_ota_begin+esp_ota_write+esp_ota_end。
  2. esp_ota_set_boot_partition(new_partition),这一步才真正把引导指向新分区。
  3. esp_restart()重启。
  4. 新固件里自检通过后mark valid。

这个顺序千万别弄反。我踩过把set_boot_partition放在写入还差几KB就完成的时机上的坑,结果是新分区固件不完整,bootloader加载时直接失败回退。幸好用了双分区,才没有当场翻车。

3.3 Arduino默认没有回滚,我们自己装一个保险丝

Arduino的OTA升级默认行为比ESP-IDF简陋很多。很多第三方库只是把新固件写进另一个分区、重启,至于启动后要不要验证、失败要不要回滚,几乎没有处理。这也是为什么有人用Arduino做OTA经常遇到"升级成功但重启后还是旧固件"——因为新固件从没获得过VALID状态,bootloader按规则不认它。

解决办法是在Arduino项目里直接调IDF底层API。在setup()里做完关键自检后调用:

extern "C" { #include "esp_ota_ops.h" } void setup() { // 外设初始化、业务关键自检 if (boot_self_check()) { esp_ota_mark_app_valid_correctly_after_boot(); } else { esp_ota_mark_app_invalid_and_reboot(); while (1) { delay(1000); } } }

自检函数长什么样不重要,重要的是它必须覆盖"如果不满足就认为这次升级失败"的关键条件。比如设备必须能连上MQTT、或必须能读到某个传感器的正常值,再标记VALID。如果一上电什么都不做就标记,那回滚机制等于摆设。

还要注意,不同版本Arduino核心在OTA后的默认行为不完全一致,最稳妥的做法始终是显式调用这个API。把它封装成一个函数放进项目模板,后续所有设备统一调用。

3.4 别把回滚设想成"万能钥匙":启动超时与看门狗的作用

如果新固件卡在某个死循环里,它可能永远没有机会执行mark valid,但也没有崩溃重启。bootloader的rollback机制只会在"下次启动时"根据状态决定是否切回,如果应用一直不重启,设备就一直困在坏固件里。

这种情况需要在应用层配合看门狗。Arduino里可以用millis做超时判断:

const uint32_t kBootTimeout = 30000; uint32_t boot_start = millis(); while (!success_signal_received()) { if (millis() - boot_start > kBootTimeout) { esp_ota_mark_app_invalid_and_reboot(); } delay(100); }

我习惯的做法是:新刷入的固件不要一次性把全部业务做完再验证,而是先开一个"自检窗口",在定时器或任务里跑关键检查,没通过就软重启,让bootloader完成回滚。自检窗口设置在30秒到60秒比较合理,既不给用户明显卡顿,又给关键外设留足初始化时间。

4. 实战复盘:用一个故意写坏的固件走一遍完整回滚流程

4.1 准备两个固化版本,状态机上跑一遍

为了让全过程可观察,我做两个固件,跑在双分区上。A版本正常,串口每秒打印[LIVE] A-OK;B版本故意在启动后第3秒调用abort()模拟崩溃。然后从A升级到B,观察B崩了之后能不能回滚到A。

固件A直接下载到当前运行分区,固件B通过OTA方式写入另一个分区,然后重启。重点是要让B在mark valid之前就崩,这样才测得出回滚。如果你给B也提前mark valid了,它会被当成合法固件,自然不回滚。

4.2 日志读出来的回滚过程

用idf.py monitor或 minicom 打开串口,日志大致分成三段:

第一段:旧固件A正常启动,打印业务日志,此时它是当前合法槽位。第二段:OTA下载完成后我主动restart,日志结束于"正在重启",然后bootloader读取otadata,发现ota_0是新固件,加载ota_0。第三段:B固件启动后打了几行日志,然后panic重启。bootloader再次读取otadata,发现B还没VALID,于是启动ota_1的A固件,A又回来了。

日志里会看到类似这样的关键行:

I (32) boot: OTA slot 1 selected I (32) boot: Loading app from ota_1 at 0x110000 ... I (123) ota_ops: mark app valid

监听到 "Loading app from ota_1" 出现,说明回滚成功了。

4.3 崩溃、断电、自检失败……各种场景对照表

我整理了一个实测中容易遇到的现象对照表:

场景发生什么最终状态
OTA写一半断电otadata未指向新分区,启动时加载旧固件A还是A,升级可重试
OTA写完、重启,新固件启动即崩溃新固件崩溃,未mark valid,重启后bootloader切换回到A
新固件能跑但业务自检失败(主动mark invalid)bootloader回滚到旧分区回到A
业务卡死无崩溃,也没mark valid若无看门狗,设备一直卡死看起来像没回滚
新固件正常,已mark valid后再断电otadata为VALID,启动继续选新固件稳定停留在新版

如果你在Arduino环境用OTA发现"升级成功但重启后还是旧版",对照这个表基本可以诊断是mark valid缺失导致。

4.4 一个容易被忽略的小坑:分区表不匹配导致"升级成功但永远回滚"

如果你换了一个不含OTA的分区表刷进去,或者分区大小被改小,OTA写入的固件可能根本落不进正确的app分区。这种问题在日志上表现是"OTA完成、重启、又回到老版本",你查业务代码查半天也找不到问题。这是分区表的锅。

所以升级前先确认:当前设备的烧录分区表支持OTA,两个app分区容量足够放新固件。Arduino里选错Partition Scheme也会遇到同样的问题。

5. 双分区救不了的"真砖":这几个雷区一个都别踩

5.1 用错地址烧录:bootloader和分区表一起被盖了

一种非常常见的翻车方式:在下载模式里执行了错误的write_flash命令。比如把纯app的bin烧到了0x0,就会覆盖掉ROM bootloader要寻找的二级bootloader,设备上电后没有明确引导,串口可能毫无输出。

教训:esptool write_flash命令务必带对地址。bootloader.bin烧0x1000,partition-table.bin烧0x8000,app烧分区表里的偏移。

如果已经出现这种问题,先把整个flash擦掉,再按正确地址重刷:

esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 erase_flash esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash 0x1000 bootloader.bin 0x8000 partition-table.bin 0x10000 app.bin

看起来简单,但很多人救砖时栽在地址错误上:以为烧了全量包,实际地址是0x0。全量合并镜像可以烧0x0,但要确定这个镜像确实包含了正确的分区布局。

5.2 eFuse和安全配置:一旦烧下去就没有撤回键

比地址错误更危险的是动eFuse。ESP32有很多eFuse位,比如禁用下载模式、禁用JTAG、安全启动锁等。很多人想做固件加密、安全启动,在没有备份key的情况下直接烧写eFuse,结果把下载通道锁死了,或者secure boot key丢失,板子彻底变成只能看不能刷的砖。

这里不是反对安全功能,而是强调:eFuse是一次性的,烧之前一定要在普通板上完整验证流程、备份好key、确认救援方案。如果你只是做OTA稳定升级,完全不需要碰eFuse。双分区和自动回滚已经足够解决绝大多数工程问题。

5.3 供电和引脚引起的"伪砖",先别急着放弃

还有一种"伪砖"是硬件环境导致的:用质量很差的USB线供电,ESP32在Wi-Fi启动瞬间电流骤升、电压跌落,bootloader反复加载失败,看起来像死了。或者是GPIO0被外部电路拉住,导致无法进入下载模式。

排查手法:

  • 换一条好的USB线,或用独立5V供电板供电。
  • 确认EN引脚有外部上拉,没有被设备拉低。
  • 确认GPIO0没有被外部芯片持续拉住,成品设备里要看原理图确认。

很多情况下换一个电源、换一台电脑,板子又活了。不要一上来就烧flash,先排除供电和引脚问题。

5.4 最后的抢救流程:强制下载模式与全片擦除

真到需要"重装系统"的一步,最保险的顺序是:

  1. 强制进入下载模式:按住BOOT,按一下EN,松开BOOT。
  2. 用esptool.py --port COMxx read_chip_info确认通道正常。
  3. 如果担心flash有脏数据,先erase_flash,再重新烧bootloader、分区表、app。
  4. 烧录完成后按EN复位,观察启动日志。

注意:erase_flash会把整个flash清掉,包括校准数据、NVS、固件,需要重新烧完整四件套。不要在erase之后只烧app,那样必起不来。

6. 生产环境怎么部署这套方案:回滚不是目的,稳定才是

6.1 标记VALID的时机:自检颗粒度决定回滚的可靠性

双分区加自动回滚的底层逻辑是"新固件证明自己能用,才允许长期停留"。所以自检项目怎么选,直接决定这套机制靠不靠谱。

自检建议分两级:

  • 必须同步通过的项目:分区能否读取、核心外设初始化是否成功、关键GPIO电平是否正常、无线驱动能否正常初始化。
  • 可以异步验证的项目:网络连通、MQTT登录、传感器前后端数据一致。

同步自检通过就mark valid,但没必要把异步项目全做完才mark,否则用户会明显感到升级卡顿。我的做法是:先mark valid,再把异步验证放到后台任务。如果异步验证失败,由业务层决定是否上报异常,而不是立即回滚——因为有时只是服务器临时不可用,回滚反而亏。

用生活类比:新人入职先看身份证和学历,过几天发现业务水平不行再劝退。没必要让他先跑一个月业绩再办入职。

6.2 防回滚雪崩:连续失败N次就该熔断

一个容易忽视的问题:如果服务器上推送的固件本身有bug,设备会不断从A升到B、回滚到A、又从A升到B,OTA反复失败,能耗、流量、重启次数都会飙升,现场还会出现设备频繁重启的"假变砖"。

解决思路是在NVS区记录升级失败次数:

nvs_handle_t h; nvs_open("ota", NVS_READWRITE, &h); int fails = 0; nvs_get_i32(h, "fail_count", &fails); if (need_rollback) { fails++; nvs_set_i32(h, "fail_count", fails); if (fails >= 3) { // 暂停自动升级,等待人工干预 } } else { nvs_set_i32(h, "fail_count", 0); }

这个熔断机制在生产环境非常重要。我遇到过改了一个配置文件的默认值,导致新固件无法连接服务器,结果现场设备触发了一整轮回滚。有失败计数之后,连续失败3次自动停止升级,比盲目重试稳得多。

6.3 数据分区也要做版本兼容,别救了火却丢了配置文件

最后一个容易踩的坑:回滚能救固件,但救不了被新固件改写的NVS或SPIFFS。新固件升级后可能修改了NVS里的配置结构、SPIFFS里写了新版本的数据库,一旦回滚到旧固件,旧代码读到新结构数据,可能直接解析崩溃。

处理建议:

  • 关键配置尽量用key-value时带上版本字段,比如config_version: 3,不同版本固件读取时先检查版本,不兼容就重建默认配置。
  • 不要在升级过程中对NVS或SPIFFS做大范围原地改写,优先做迁移逻辑或双buffer。
  • 回滚后对数据分区做一次数据健康检查,发现不兼容就提示恢复出厂设置。

说白了,架构设计时要把"软件版本"和"数据版本"解耦,这样回滚才真正干净。

个人实操里,把双分区和自动回滚跑通后,我的开发节奏完全变了:不再是"改完代码小心谨慎地烧录、提心吊胆等结果",而是大胆提交、快速验证,因为翻车自动回到能跑的版本。最后再提醒一句,这套机制救的是OTA升级翻车,救不了烧错地址、烧坏eFuse那种操作失误,所以刷机时的地址意识还是要刻在脑子里。

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

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

立即咨询