ARTICLE DETAIL

资讯详情

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

ESP32双分区OTA自动回滚:固件防变砖的完整实践指南

ESP32双分区OTA自动回滚:固件防变砖的完整实践指南 1. 先讲一个反直觉的事实ESP32 其实很难真正“变砖”先说结论绝大多数你听说的“刷固件变砖”在 ESP32 上根本不是永久性损坏而是因为引导程序找不到可用的应用程序分区导致芯片不断重启或者停在烧录模式。换句话说你手里那块板子大概率还是好的只是你把它“搞糊涂”了不知道接下来该执行哪一份固件。为什么会这样这就要从 ESP32 的启动方式说起。芯片上电后CPU 第一件事是执行固化在只读存储器ROM里的第一级引导程序这部分出厂时写好用户改不了也没法被擦除。它负责检查 eFuse 和 flash 头部的启动信息然后跳转到 flash 里的 bootloader也就是第二级引导程序。bootloader 再根据分区表partition table去找到对应的 app 分区把应用固件加载到内存里跑起来。所以“变砖”这个概念在 ESP32 上分为三种程度假砖最常见app 分区里的固件本身损坏、不完整或者分区表被清空/写错bootloader 找不到可用应用芯片会反复重启。但这种情况下ROM 引导程序依然存活你可以通过串口重新烧录用 ESP32 自带的下载模式按住 BOOT 键再上电就能救回来。半砖bootloader 被刷坏或者 flash 的启动参数被改错芯片无法进入正常的串口下载模式。这种情况稍微麻烦一点但不致命通常需要用 esptool 手动擦除整个 flash 再重新烧写。真砖极其罕见flash 芯片物理损坏或者关键 eFuse 被错误烧断。比如你把某些安全相关的 eFuse 永久锁死导致芯片拒绝启动任何未签名的固件这种才接近“救不回来”。我在项目里采用双分区和自动回滚的方案本质上就是把“假砖”变成“可以自己恢复的普通故障”。你不需要拆芯片、不需要外接编程器甚至不需要打开电脑重新烧录板子自己就能回到上一个能用的版本。2. 双分区到底分的是什么一张分区表把固件分成“主用”和“备用”双分区在 ESP32 上不是什么神秘技巧而是在分区表里同时安排两个可以存放应用固件的区域分别叫ota_0和ota_1。芯片启动时bootloader 会读取一个特殊的数据区通常叫 otadata里面记录了“当前应该从哪个分区启动”以及“上一次启动是否成功”。2.1 先看懂 ESP32 的分区表结构ESP32 的 flash 默认是 4MB分区表是一个定义了 flash 每个区域用途的清单。一个典型的分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1C0000, app1, app, ota_1, 0x1D0000, 0x1C0000, spiffs, data, spiffs, 0x390000, 0x70000,这里的关键信息nvs非易失性存储区用于保存 WiFi 配置、校准数据等键值对刷固件时一般不会动它。otadata只有 8KB但它记录了 ota_0 和 ota_1 两个分区的状态包括当前选中的分区、分区里的固件是否通过了完整性校验。app0 和 app1两个大小相同的应用分区。它们前面的 Type 是appSubType 是ota_0和ota_1这两个 SubType 是 ESP32 启动流程识别“这里可以放固件”的标记。spiffs文件系统区域用来存网页、配置、日志这类数据。当你在 Arduino IDE 里选择“Partition Scheme: Huge APP (3MB No OTA/1MB SPIFFS)”时实际上生成的分区表里根本没有ota_0和ota_1只有一个factory分区。这种方案的应用分区只有一个固件坏了只能重新串口烧录不存在回滚的可能。而选择“Partition Scheme: Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”时Arduino 默认会使用带 OTA 的分区表也就是会生成ota_0和ota_1两个应用区。这就是为什么我强烈建议哪怕你现在不做 OTA 空中升级也尽量选带 OTA 的分区方案。因为双分区不只是为了远程升级更是给固件上了一道“保险”。2.2 启动流程bootloader 怎么决定跑哪个固件芯片启动时bootloader 会先读 otadata 区域判断逻辑大致是这样的otadata 里如果记录了“当前启动分区是 ota_0状态是 pending verify”就说明上一次选中的是 ota_0 里的固件但还没确认它工作正常。如果状态是“confirmed”说明这个固件已经跑过一段时间确认没毛病下次继续用它。如果 bootloader 发现当前选中的分区里的固件校验失败比如 CRC 不对、固件头不合法它会自动切换到另一个分区。如果两个分区都失败才会真的卡住等你去串口救砖。这套逻辑给了我们一个非常重要的空间固件启动失败不等于死机bootloader 有机会在 app 真正跑起来之前帮你“换一个试试”。而我们需要做的就是让 app 在启动后尽快告诉系统“我还活着”把 otadata 里的状态从 pending verify 改成 confirmed。2.3 双分区和 OTA 升级的关系双分区最常见的应用场景是 OTA 升级。新固件下载后通常写入当前没有在运行的另一个分区写完之后把 otadata 指向新分区然后重启。如果新分区有问题bootloader 会自动回退到旧分区。这比“直接在原分区上覆盖写”安全得多因为覆盖写一旦中途断电旧的也没了新的又不完整那才是真的灾难。而在我的做法里双分区还有一个额外用途本地烧录时也可以主动把新固件写到备用分区而不是覆盖当前正在用的分区。这样即使新固件有 bug旧固件依然完好地躺在另一个分区里随时可以回切。3. 自动回滚的实现让板子在“开不了机”和“自动恢复”之间找到平衡有了双分区下一步就是让系统具备“自动回滚”能力。ESP-IDF 原生支持一套回滚机制Arduino 环境则要稍微手动一点。但它们的核心思路是一样的新固件启动后必须有一个“确认”动作如果在规定时间内没有确认系统就认为新固件有问题重启后自动回到旧固件。3.1 ESP-IDF 下的标准做法如果你用 ESP-IDF 开发回滚机制是框架自带的。用到的 API 集中在esp_ota_ops.h里关键函数有三个esp_ota_mark_app_valid_cancel_rollback()标记当前固件为“验证通过”。调用后otadata 会写入 confirmed 状态之后不会再自动回滚。esp_ota_mark_app_invalid_rollback_and_reboot()把当前固件标记为“无效”然后立刻重启bootloader 会切换到另一个分区。esp_ota_get_running_partition()获取当前正在运行的分区信息用来确认自己是从哪个分区启动的。一个基本安全的启动流程是这样的#include esp_ota_ops.h void app_main(void) { // 1. 先拿到当前运行的分区 const esp_partition_t *running esp_ota_get_running_partition(); ESP_LOGI(TAG, Running from %s, running-label); // 2. 初始化外设、WiFi、传感器等 init_hardware(); // 3. 关键等系统稳定运行一小段时间后再确认 vTaskDelay(pdMS_TO_TICKS(10000)); // 4. 确认当前固件有效 esp_err_t err esp_ota_mark_app_valid_cancel_rollback(); if (err ESP_OK) { ESP_LOGI(TAG, Firmware marked as valid); } else { ESP_LOGE(TAG, Failed to mark as valid: %s, esp_err_to_name(err)); } }这里有个细节值得展开不要一启动就立刻确认“固件有效”。如果硬件初始化还没完成或者核心传感器数据异常你就把它标记成 confirmed那后面出问题时就失去了回滚机会。我习惯的做法是初始化基础外设。延时 5 到 10 秒让系统跑一跑确认没有死锁、没有反复崩溃。检查关键传感器/网络的初始状态比如 WiFi 是否连上、ADC 读数是否在合理范围。全部通过后再调用esp_ota_mark_app_valid_cancel_rollback()。如果第 2、3 步里发现异常就调用esp_ota_mark_app_invalid_rollback_and_reboot()主动回滚。这样相当于给了自己一个“观察期”比单纯靠 bootloader 的被动校验可靠得多。3.2 Arduino 环境下的轻量级回滚思路Arduino 环境没有直接暴露 ESP-IDF 那套 OTA 确认 API但也可以通过操作 otadata 实现类似效果。比较常见的思路是用Preferences库在 nvs 里保存一个标志位每次启动时用这个标志位判断“上次固件是否正常退出”。实现逻辑如下#include Preferences.h #include esp_ota_ops.h Preferences prefs; void setup() { Serial.begin(115200); // 打开 nvs 里的“启动计数”键值对 prefs.begin(fw_check, false); int bootCount prefs.getInt(boot_count, 0); if (bootCount 3) { // 如果启动计数超过 3 次说明每次开机都在中途崩溃回滚 Serial.println(Too many failed boots, rollback!); esp_ota_mark_app_invalid_rollback_and_reboot(); } // 硬件初始化 initHardware(); // 运行一段时间后确认固件正常重置计数 delay(10000); prefs.putInt(boot_count, 0); // 如果走到这里说明系统稳定把当前分区标记为 valid const esp_partition_t *running esp_ota_get_running_partition(); if (running-subtype ESP_PARTITION_SUBTYPE_APP_OTA_0 || running-subtype ESP_PARTITION_SUBTYPE_APP_OTA_1) { esp_ota_mark_app_valid_cancel_rollback(); } } void loop() { // 正常业务逻辑 }还有一个更简单的方案在启动时开启看门狗定时器业务代码跑通之后再喂狗。如果新固件在启动阶段卡死看门狗会在超时后强制重启。但看门狗只能帮你重启不能帮你“切回旧固件”。所以最可靠的做法还是“启动计数 主动回滚”的组合每次上电boot_count 加 1。系统跑满 10 秒且业务自检通过把 boot_count 清零。如果 boot_count 连续几次都超过阈值说明这个固件每次都在验证成功前就崩溃了调用回滚函数切回旧固件。这个做法的好处是即使你的新固件引发的崩溃发生在非常早的阶段连 OTA 确认函数都没执行到也能通过连续多次重启识别出“这个固件有问题”。坏处是 nvs 的写入次数是有限寿命的频繁重启会加速 flash 磨损。不过一般开发板上这点写入量完全不用担心。3.3 OTA 下载过程中的回滚联动如果你不只是本地刷写还要支持 OTA 远程升级那回滚逻辑要跟下载流程联动。在 IDF 的 OTA 示例native_ota_example基础上我会额外做两件事第一下载固件前先检查新版本的版本号如果新版本号低于当前运行版本直接拒绝写入。这个判断要在业务层做不是 bootloader 能代劳的。第二下载过程中每写一块校验一块。esp_ota_write写入完后用 OTA 数据里的 digest 做比对。虽然 esp_image_format 在启动时也会做校验但提前发现坏块能省掉一次无谓的重启。完整流程大概是这样// 伪代码带回滚的 OTA 升级 esp_ota_handle_t ota_handle; const esp_partition_t *update_part esp_ota_get_next_update_partition(NULL); esp_ota_begin(update_part, OTA_SIZE_UNKNOWN, ota_handle); // 分块从服务器读取固件每读 4KB 写一次 while ((len http_read(buf, sizeof(buf))) 0) { esp_ota_write(ota_handle, buf, len); } esp_ota_end(ota_handle); // 设置新分区为待验证启动 esp_ota_set_boot_partition(update_part); // 重启 esp_restart();重启后如果新固件 10 秒内没确认有效bootloader 会切换回旧分区。这个方案下远程升级失败也只是“多重启一次”用户几乎感知不到。4. 实测我故意把固件刷坏看它怎么自救理论说得再多不如亲手制造一场事故。我在实验室里用一块 ESP32-WROOM-32 开发板做了三组实验分别验证“app 分区损坏”“运行中崩溃”“升级失败”三种情况下的回滚表现。4.1 实验一直接把 ota_0 分区写满垃圾数据先烧录一个版本号 1.0 的正常固件到 ota_0确认它能跑。然后用 esptool 直接往 ota_0 分区写入无意义的随机数据模拟“固件刷到一半断电”或者“固件文件损坏”# 先擦除 ota_0 分区地址 0x10000大小 0x1C0000 esptool.py --port /dev/cu.usbserial-0001 erase_region 0x10000 0x1C0000然后给板子重新上电。观察串口日志bootloader 打印了类似这样的信息I (30) boot: OTA[0] partition selected E (35) esp_image: image at 0x10000 has invalid magic byte (nothing) E (41) boot: OTA image 0 invalid I (46) boot: OTA[1] partition selected I (50) boot: Loading app partition at offset 0x1D0000 I (54) boot: Image loaded, jumps to run注意看bootloader 发现 ota_0 里的镜像文件头魔术字节不对直接判定这个分区失效然后自动跳到 ota_1 启动。整个过程不需要任何代码干预也不用人工按键。这就是分区表 SubType 设为ota_x带来的硬件级保护。4.2 实验二新固件能启动但在第 5 秒崩溃这次我在 ota_1 里烧录一个故意崩溃的固件——初始化完 WiFi 之后强制进入死循环并触发看门狗复位。otadata 里没有标记 valid所以每次启动都是 pending verify 状态。观察到的现象是新固件启动、崩溃、重启如此反复。在第 4 次重启时bootloader 的日志出现了W (123) boot: OTA[1] app is not valid, rollback to OTA[0] I (127) boot: OTA[0] partition selected也就是说ESP32 在一段时间内检测到 ota_1 的固件始终没有被确认有效自动回退到了 ota_0。这个过程完全自动我只做了上电操作。这验证了 bootloader 层的“被动回滚”机制是有效的。4.3 实验三OTA 下载新固件后无法正常启动第三个实验模拟真实场景设备当前运行 ota_0 的 1.0 版本通过网络下载了 2.0 版本到 ota_1 并设置为启动分区。2.0 版本固件在初始化时读取一个不存在的配置文件导致空指针异常启动后立即重启。由于我在 2.0 版本代码里加入了前面说的“启动计数 延时确认”逻辑虽然 app 崩溃太快计数逻辑没来得及清零 boot_count但在连续复位三次后系统通过esp_ota_mark_app_invalid_rollback_and_reboot()主动切回了 ota_0。之后我特意在串口里加了区分日志[1.0] App started, rollback protection armed [2.0] App started but config missing, panic... [2.0] App started but config missing, panic... [2.0] App started but config missing, panic... [1.0] App started, rollback protection armed看到最后一行回到 1.0我就知道这套方案经受住了实测。1.0 固件在启动时发现了 nvs 里的回滚标记还额外打印了一行提示。整台设备“自愈”完成的耗时在 15 秒以内用户在手机上只会看到设备短暂离线然后恢复正常。4.4 一个需要警惕的例外分区表被破坏双分区和自动回滚能覆盖“app 固件损坏”的大部分场景但有一个盲区如果你刷坏了 bootloader 或者分区表本身双分区方案也无能为力。比如你把 flash 0x1000 处的内容覆盖了bootloader 没了芯片根本走不到“读取分区表、对比 ota_0 / ota_1”这一步。这种情况就是我开头说的“半砖”等级需要串口进入下载模式重新烧录 bootloader。以下命令可以恢复esptool.py --port /dev/cu.usbserial-0001 write_flash 0x1000 bootloader.bin esptool.py --port /dev/cu.usbserial-0001 write_flash 0x8000 partition-table.bin esptool.py --port /dev/cu.usbserial-0001 write_flash 0xe000 otadata.bin只要 flash 芯片物理上没问题这套组合基本能救回来。所以我在项目里专门备份了一份 bootloader 和 partition-table 的 bin 文件放在固定目录就是防止哪天手滑把整个 flash 清了。5. 踩过的坑回滚不生效的 6 个原因说实话双分区 自动回滚方案我第一次跑通时根本没有想象中顺利前后折腾了两三天。问题不在于机制本身而在于对 ESP32 启动流程的理解有几个盲区。下面这些坑我都实际踩过按严重程度排个序。5.1 分区表必须重新烧写否则固件写不进预期位置如果你原本用的是“No OTA”分区的项目后来改成了带 OTA 的分区表只重新编译烧录 app 固件是不够的。Arduino IDE 通常会自动帮你烧分区表但如果你用了自定义烧写脚本很可能漏掉partition-table.bin。后果是app 固件被写到了 flash 里一个新的偏移地址但 flash 里旧的分区表还在bootloader 按旧表去加载结果读到的是无效数据表现为“刷了新固件后反复重启而且串口烧录也时好时坏”。解决办法在烧写命令里显式加上分区表文件或者干脆用esptool.py erase_flash把整个 flash 擦干净再一次性烧写 bootloader、分区表、otadata、app。虽然粗暴但能排除一切残留问题。5.2 Arduino 默认分区表里“app0”的长度限制我第一次在 Arduino 里启用 OTA 分区时没用默认分区分表而是自己手工写了一张很小的分区表只给ota_0和ota_1各分配了 1MB。结果我的固件编译出来 1.1MB烧写时直接报“Image size exceeds partition size”。这种问题不算回滚失效但也容易在测试时造成误判你会以为新固件刷坏了其实是编译产物根本塞不进预留分区。最好在工程里加一个编译后检查比如用脚本读取生成的.bin文件大小跟分区表里对应分区大小做比较超了就中止烧写。5.3 nvs 标志位和 otadata 的确认逻辑互相干扰有一版代码我在“启动计数”里用的Preferences键名和另一个功能模块冲突了导致每次启动时计数被意外重置回滚永远触发不了。排查了很久才发现原来是两个库用了同一个 nvs 命名空间namespace互相覆盖了键值。建议专门给回滚逻辑分配一个独立的 nvs 命名空间比如prefs.begin(otarollback, false);不要和 WiFi 配置、用户设置混在一起。这样可以避免键名冲突也方便以后统一清理。5.4 在确认有效之前断电导致每次开机都回滚这个坑比较隐蔽。我在实验里发现固件明明没毛病但每次开机都会回到旧版本压根进不到新固件。原因是“确认有效”的调用放在了业务逻辑的最末尾而设备启动后很快就进入休眠根本没等到确认函数执行电就断了。也就是说新固件一直处于“待验证”状态下次上电 bootloader 就认为它没通过验证自动切回旧分区。解决方式有两种把esp_ota_mark_app_valid_cancel_rollback()提前到 setup 早期比如外设初始化完成后立刻调用。如果确实需要更多启动时间可以在确认之前用esp_ota_mark_app_valid_cancel_rollback()先做一个“临时确认”然后再在程序末尾做一次彻底确认。我最终采用的是“延迟 10 秒后确认”既给了系统稳定期又不会因为休眠时序错过确认窗口。5.5 两个分区的固件版本相同回滚后看不出效果测试回滚时如果你两个分区烧的是同一个版本号的固件即使回滚成功你也很难从日志里判断到底跑的是哪个分区。所以我在固件里特意打印当前运行分区的 label 和自定义版本号。const esp_partition_t *running esp_ota_get_running_partition(); Serial.printf(Running from: %s, version: %s\n, running-label, FIRMWARE_VERSION);有了这两行输出回滚是否生效一眼就能判断。5.6 bootloader 版本太旧不支持自动回滚ESP32 早期的 bootloader 对 OTA 回滚的支持并不完善尤其是一些出厂就带旧 bootloader 的模组。如果你发现 bootloader 日志里没有出现rollback相关的字段很可能是 bootloader 太老不认识 otadata 里的 pending verify 状态。解决方法是升级 bootloader。最简单的方式是用较新版本的 ESP-IDF 重新编译一次整个工程Arduino 在烧录时也会同时烧录配套的 bootloader。不建议手工单独下载 bootloader 刷写因为不同芯片版本ESP32、ESP32-S2、ESP32-C3和 flash 模式DIO、QIO对应的 bootloader 不通用。6. 双分区之外的最后一层防线保留串口救砖通道双分区和自动回滚解决的是“软件层面自动恢复”但工程上永远要有“人工干预”的兜底方案。我所有的 ESP32 项目板上都保留了一组串口排针并且强制规定任何情况下都不能在代码里禁用 ROM 下载模式。ESP32 的 ROM 下载模式是通过上电时 BOOT 引脚的电平决定的。如果 BOOT 引脚被外部电路拉高或者被代码配置为输出模式可能导致无法进入下载模式这时候真砖的概率就大大增加了。所以设计电路时BOOT 引脚上要加一个上拉电阻并且留出按键到 GND 的路径。软件层面不要随意把 GPIO0 配置成其他功能后再断电——因为下一次上电时它必须保持高电平才能正常启动低电平则会进入下载模式。还有一个细节串口烧录线的质量直接决定了救砖的成功率。我吃过不少亏后来固定使用带 CP2102 芯片的 USB-TTL接线尽量短波特率烧录时降为 115200不追求快稳定第一。救砖场景下一次成功比十次快速失败更有意义。7. 我用这套方案后的真实体会项目上线到现在我通过 OTA 远程升级过十几轮固件期间故意注入过三次故障模拟配置错误、外设初始化失败、固件文件传输中途中断双分区 自动回滚每次都兜住了没有一次需要用户手动返厂或者重新烧录。如果要总结一条最重要的经验那就是回滚保护不是 OTA 专属功能哪怕你所有固件都靠串口烧录也应该把双分区作为默认配置。它相当于给你的“手滑”上了保险成本只是分区表里多划出一块区域换来的是刷机时的心理安全感和实际可恢复性。对我来说ESP32 的“变砖恐惧”在真正理解启动流程之后基本就消失了。芯片本身有足够的冗余设计我们要做的不是小心翼翼避免出错而是把可能出错的地方都变成可以自动恢复的路径。希望这套实践思路能帮你少走点弯路。
返回列表