ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32 OTA升级防变砖:双分区与自动回滚机制全解

ESP32 OTA升级防变砖:双分区与自动回滚机制全解 手里那块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会识别它的SubTypefactory、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区适合OTA2MB 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()状态变成INVALIDbootloader重启后自动改从另一个分区加载。新固件没来得及标记就崩溃同时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_beginesp_ota_writeesp_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-OKB版本故意在启动后第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 invalidbootloader回滚到旧分区回到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烧0x1000partition-table.bin烧0x8000app烧分区表里的偏移。如果已经出现这种问题先把整个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升到BOTA反复失败能耗、流量、重启次数都会飙升现场还会出现设备频繁重启的假变砖。解决思路是在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那种操作失误所以刷机时的地址意识还是要刻在脑子里。
返回列表