手里那块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 三步定位法:三分钟判断手里的板子是真砖还是假砖
先靠操作判断,不动手排查都是瞎猜。
- 板子断电。
- 按住BOOT键(或者把GPIO0和GND短接),上电,再松开BOOT。
- 打开串口助手,或者直接运行
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的做法:
- 写一个
partitions_ota.csv,内容参照上面的表。 - 在
menuconfig里进入 "Partition Table" → "Custom partition table CSV",填上文件路径。 - 编译,烧录时除了烧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()会在内部触发重启,调用后不要再做任何事,否则可能产生竞态。我见过有人调用完还去关外设、存日志,结果重启时机不确定,反而把状态写坏了。关掉中断、直接标记、立即重启,是最干净的流程。
完整升级流程的顺序也要记牢:
- 下载新固件到另一分区:
esp_ota_begin+esp_ota_write+esp_ota_end。 esp_ota_set_boot_partition(new_partition),这一步才真正把引导指向新分区。esp_restart()重启。- 新固件里自检通过后
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 最后的抢救流程:强制下载模式与全片擦除
真到需要"重装系统"的一步,最保险的顺序是:
- 强制进入下载模式:按住BOOT,按一下EN,松开BOOT。
- 用
esptool.py --port COMxx read_chip_info确认通道正常。 - 如果担心flash有脏数据,先
erase_flash,再重新烧bootloader、分区表、app。 - 烧录完成后按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那种操作失误,所以刷机时的地址意识还是要刻在脑子里。