
刷固件这件事说大不大说小不小。很多年前我第一次给ESP32刷错固件看到串口一片乱码心里第一个念头也是“这芯片是不是就这么报废了”。后来折腾多了才发现ESP32绝大多数“刷坏”的情况并不是物理损伤而是 Bootloader 或者应用分区没有正确引导甚至只是软件层面的循环崩溃。真正麻烦的是那些没有设计“后悔药”的项目每次升级固件都像在走钢丝新固件一有问题设备就一直重启看着就跟变砖一模一样。所以后来我再做 ESP32 项目特别是涉及到 OTA 远程升级或者现场固件更新的一定会把双分区和自动回滚放进去。说白了就是给固件上一份“保险”升级失败的时候系统自己切回上一个还能跑的分区而不是让你扛着设备跑现场去拆壳接串口。这个思路不是我的原创手机系统早就在用类似的 A/B 分区机制但ESP32上怎么设计、怎么配置、怎么验证里面的细节和坑其实不少。这篇内容不讲那些天花乱坠的概念全部按我自己在项目里的实际做法展开希望能给你一条可以直接照抄的路径。1. 整体设计方案与思路拆解1.1 先搞清楚什么情况才算真的“变砖”想设计回滚机制先得明白“坏”到底是什么状态。ESP32 的启动链路是 ROM Bootloader → 二级 Bootloaderflash 里的 boot_app0.bin→ 分区表 → 应用固件。出厂时 ROM 里的引导程序是掩膜在芯片内部的常规手段根本擦不掉所以只要这个环节没坏大多数刷机失败都有救。真正能导致“砖”的一般是这几种情况分区表被覆盖或者偏移算错了导致二级 Bootloader 找不到合法的应用分区误烧了引导程序区让整个启动链直接断掉还有 eFuse 熔断配置出错比如开了 Flash 加密但密钥又丢了芯片彻底失去了可支配的启动路径。碰到这些通常需要手动进入下载模式重新烧录有些极端情况甚至要用 JTAG 或外部工具才能救回来。但项目里更常见的“假砖”是应用固件本身能烧进去、分区表也正常但新固件一启动就崩溃。看起来就像变砖了实际上硬件没毛病只是这个应用没法正常工作而已。我们说的双分区和自动回滚解决的核心问题就是这个减少新固件异常时设备进入长期“假砖”状态的几率让它自动退回上一个已知良好的版本。1.2 为什么是双分区而不是直接把 Bootloader 做成智能的有些人会问Bootloader 为什么不直接把自己的逻辑做得更强大一点比如读取新固件后自己判断好坏再决定要不要启动答案很现实ESP32 的二级 Bootloader 为了安全性和资源占用本身被设计得很精简它并不负责跑业务代码也不懂你的业务逻辑。像“新固件能不能连上服务器”“传感器读数是否正常”这种事必须由应用自己向上反馈Bootloader 只能负责“谁负责启动”和“启动后是否被确认”。所以更合理的设计是把“判断”的权利交给应用本身。这就是双分区的价值flash 里保留两个独立的 app 分区一个运行旧固件一个用来接收新固件。升级时把新固件写入备用分区然后重启切换而不是直接覆盖当前正在跑的分区。虽然会多占用一份 flash 空间但换来的是极高的容错率运算成本几乎为零。在方案选型上我建议直接依托 ESP-IDF 自带的 OTA 机制而不是自己撸一套。ESP-IDF 的 otadata 分区专门记录运行哪个 app 分区还带有一套状态机能够支持“待验证”和“回滚”这些状态。自己去做这套设计不仅要折腾底层还容易在处理断电、重启、校验这些边界条件时翻车没必要重新造轮子。1.3 方案拆解从“坏了再修”到“坏了自己切回去”我把整套机制的流程拆成三段来理解。第一段是更新触发无论你用的是网络 OTA 还是串口烧录新固件都是写到备用分区不碰当前运行的那一份。第二段是切换引导写入完成后重启Bootloader 根据 otadata 里的标记从备用分区启动新固件。第三段也是我最看重的是“自证阶段”新固件启动后需要在正常情况下主动调用确认函数告诉系统“我现在状态健康你可以把这次切换正式确定为有效版本”。如果新固件没有完成自证就发生了崩溃、死机、自动重启Bootloader 就会检测到上次运行还是待验证状态于是自动切回旧分区。整个过程不需要任何人工干预设备端就自己完成了“升级 → 验证 → 回滚”的闭环。这个机制在远程维护场景下价值非常大不会再出现OTA一发出去现场上百台设备集体起不来的局面。2. 核心原理与关键细节解析2.1 启动流程里的蛛丝马迹搞懂自动回滚先看启动过程。ESP32 的 CPU 上电后会从内部 ROM 固件开始执行然后引导 flash 里偏移 0x1000 的位置那里存放着二级 Bootloader。二级 Bootloader 会去读取 0x9000 偏移处的分区表在分区表里找到适合当前启动模式的应用分区再校验应用镜像的文件头、CRC 或摘要信息。这里有个容易被忽略的细节Bootloader 校验通过的只是镜像本身是完整的并不代表这个应用的功能是正确的。比如一个固件编译成功了、烧录完整了但业务逻辑一开始就跑飞了Bootloader 检查不出这种问题。所以必须在应用层做状态确认告诉 Bootloader“我已经活着跑起来且运行正常”。这就是分区表里 otadata 存在的意义所在它是应用与 Bootloader 之间沟通状态的媒介。2.2 分区表与双分区的“物理基础”一个典型的分区表 CSV 大概长这样# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x4000 otadata, data, ota, 0xd000, 0x2000 app_0, app, ota_0, 0x10000, 0x200000 app_1, app, ota_1, 0x210000, 0x200000这里有两个关键分区otadata 存放当前 OTA 分区选择信息和回滚状态app_0 和 app_1 是两个应用分区大小一模一样具体大小根据 flash 容量调整。我一般用 4MB 以上的 flash 模组每个分区留 2MB既能放得下带大量组件的新固件也兼顾了未来扩展空间。没有双分区的时候升级固件是直接覆盖原分区一旦写入中断或者固件有问题原版本就没了。现在有了两个分区哪怕新固件把整个 app_1 写得乱七八糟旧固件在 app_0 里仍然完好这就为回滚提供了基础保障。2.3 自动回滚背后的状态机ESP-IDF 里自动回滚机制的本质是维护了一个状态机。Bootloader 根据 otadata 中的状态和计数器来决定下一次启动哪个分区。核心的设置开关是 menuconfig 里的CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE。开启之后每次通过 OTA 切换到一个新分区这个新分区会先处于“待验证”状态也就是说系统给了新固件一个“试用期”。在这个试用期内应用代码如果调用esp_ota_mark_app_valid_cancel_rollback()系统就会把这个分区标记为“有效”后续再重启都会优先选择它默认不再触发回滚。反过来如果新固件一直不确认有效或者直接发生了崩溃重启Bootloader 就会执行回滚把上一次可用的分区也就是原先那个 app_0作为启动对象。这个“试用期”不是永久的它通常和 watchdog、复位计数器配合在预定条件下触发判定。有一点需要特别强调这个机制并不能保证新固件在业务逻辑上“真是好的”它只保证“没崩溃”。所以我们要尽可能让确认时机有意义比如在 WiFi 连上、核心服务启动成功之后再进行确认而不是函数开头的第一行就急着把状态标为有效。3. 实操过程与核心环节实现3.1 工程框架选择与环境准备我自己主力用的是 ESP-IDF版本建议 4.4 或 5.x 以上。虽然 Arduino 也能实现自动回滚但它默认的 Update 库并没有把完整的回滚状态机暴露出来要自己改一部分底层对新手不够友好。ESP-IDF 则是把整套机制做成原生能力只要在分区表配置和 menuconfig 里开启对应选项再用两个 API 就能把流程跑通。环境准备部分比较常规idf.py set-target esp32、idf.py menuconfig确认 toolchain 和 USB 驱动都是通的。我建议在这之前就把CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和CONFIG_APP_ANTI_ROLLBACK的区别搞清楚。前者是启用回滚是我们要的后者是反回滚保护一旦启用固件版本只能升不能降开发调试阶段千万别顺手打开不然以后想刷回旧版本都得先清 eFuse等于自找麻烦。3.2 分区表配置与编译设置在工程目录下新建或者修改partitions.csv写入前面那段内容。要注意偏移不能胡填otadata 必须放在 NVS 之后两个 app 分区之间也不能重叠。通常我会让 app_0 从 0x10000 开始这是 ESP32 常见的应用起始偏移后边两个分区之间留出 2MB 的距离。然后进入 menuconfig 的 “Boot ROM Behavior” 或英文对应位置找到CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE把它打开。没有这个选项的话Bootloader 在 OTA 切换后只会无脑启动新分区即使新分区崩溃它也不会主动切回去。编译前建议再把工程里默认的sdkconfig文件过一遍确认PARTITION_TABLE_CUSTOM_FILENAME指向你刚才写的那个 CSV 文件名。3.3 关键代码开机“自证清白”自动回滚的关键动作全部集中在这个“自证”环节。我一般这样写核心逻辑#include esp_ota_ops.h #include esp_log.h static const char *TAG APP_BOOT; static bool sanity_check(void) { // 这里按项目实际情况检查核心服务是否就绪 // 比如网络是否连上、关键数据是否可读、传感器是否返回正常值 return true; } void app_main(void) { const esp_partition_t *running esp_ota_get_running_partition(); if (running NULL) { ESP_LOGE(TAG, get running partition failed); return; } esp_ota_img_states_t state; esp_err_t err esp_ota_get_state_partition(running, state); if (err ! ESP_OK) { ESP_LOGE(TAG, get ota state failed: %s, esp_err_to_name(err)); return; } if (state ESP_OTA_IMG_PENDING_VERIFY) { if (sanity_check()) { err esp_ota_mark_app_valid_cancel_rollback(); if (err ! ESP_OK) { ESP_LOGE(TAG, mark valid failed: %s, esp_err_to_name(err)); } else { ESP_LOGI(TAG, app marked as valid, rollback disabled); } } else { ESP_LOGE(TAG, sanity check failed, mark invalid); esp_ota_mark_app_invalid(); esp_restart(); } } // 正常业务逻辑从这里开始 }sanity_check()是个非常重要的关卡我强烈建议不要直接返回 true 敷衍了事。比如这个设备需要联网才能工作就检查一下 WiFi 是否已经拿到 IP需要读取校准数据的就检查 NVS 里的 key 是否完整需要外设正常的就检查 GPIO 读写是否返回预期值。只有这些核心依赖全部就绪才有资格确认新固件为有效版本。有一个调试小技巧在标记有效之前故意把日志打印和 LED 闪烁往下推一两个步骤这样你能从串口输出里明确看到“回滚已取消”的日志。如果代码一开始就急着标记有效后续出问题就抓不到回滚现场了。3.4 烧录、重启与故障模拟先把旧固件烧到 app_0通过idf.py flash monitor确认系统正常启动。然后修改代码编出新固件的 bin 文件注意 OTA 写入的目标分区是 app_1不能覆盖当前 app_0。这一步可以借助 ESP-IDF 的esp_ota_ops接口也可以直接用idf.py flash -p PORT 0x210000 build/your_app.bin这种命令写入对应偏移模拟 OTA 过程。烧录完成后重启观察启动日志。第一次会看到从 app_1 启动的迹象接下来关键就在于你的sanity_check()。我测试时故意让sanity_check()返回 false然后再重启日志里就会显示 Bootloader 选择回到 app_0。这里一定要真刀真枪地模拟崩溃现场别只在代码里看着逻辑对就觉得没问题实际跑一遍才能确认 otadata 的状态切换是符合预期的。3.5 加入网络 OTA 后的完整流程设备联网之后流程会变成这样设备先收到新固件分片通过esp_ota_write将数据写入 app_1写完后调用esp_ota_end完成校验然后设置 boot 分区为 app_1重启。注意每次 OTA 前最好检查一下 app_1 的剩余空间和固件包大小避免写入到一半空间不足。ESP-IDF 官方例程system/ota就是一个不错的起点我自己早期就是从这个例程扩展出来的。整个过程中还有几个要命的细节OTA 写入期间断电怎么办ESP-IDF 会在 otadata 写入前校验新固件完整性中断的写入不会影响当前 app_0 启动但如果你使用了“先擦除再写入”的方案擦除和写入之间有细微的窗口期一旦断电确实可能让两个分区都不可用。所以可靠的方案是先在临时分区或内存缓冲区接收完成并校验再一次性切换分区尽量缩短“半更新”状态的窗口。4. 常见问题与排查技巧实录4.1 问题速查表实战中我遇到过不少问题整理成表格方便你对照定位现象可能原因处理方式OTA 后一直反复重启不进系统新固件崩溃且回滚机制未生效检查CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE是否开启串口看是否有回滚日志启动日志提示分区表无效分区表 CSV 配置偏移错误或未烧录新分区表重新编译烧录整个 flash确认 0x9000 处分区表正确旧固件能被启动但新固件写入后无法引导app_1 分区大小不够镜像超限检查编译输出大小扩大 app_1 分区或压缩固件标记有效后仍然回滚调用确认函数过早业务代码后续崩溃调整esp_ota_mark_app_valid_cancel_rollback()调用位置放到核心服务启动完成后串口有数据但日志乱码波特率不匹配或烧录时读写冲突统一波特率拔掉烧录器后重新上电测试OTA 写入到一半空间不足没有检查目标分区剩余空间在esp_ota_end前获取分区信息比对固件大小4.2 调试中的小心得回滚机制的 debug核心就是看串口日志。Bootloader 自带日志并不算多但关键状态变化都会打出来比如从 app_0 切换到 app_1或者从 app_1 退回 app_0。我第一次调试的时候没接串口只看设备行为完全分不清它到底是“回滚成功”还是“彻底挂掉”后来老老实实把 USB-TTL 接上一次就定位到了问题。另外一个容易踩的坑是 NVS 和 otadata 的旧状态残留。开发阶段反复 OTAotadata 里的状态可能乱掉这时候不要只重烧 app而是执行一次idf.py erase-flash把分区表和 otadata 全部清干净再开始。我一开始嫌麻烦跳过这步后来发现怎么烧都从错误的 app 分区启动查了半天才发现是 otadata 里的脏数据在捣乱。4.3 回滚机制不能解决什么问题讲实话自动回滚不是万能的。如果新固件在 flash 加密、安全启动等底层配置上出了问题Bootloader 本身都可能无法验证镜像这时不一定能顺利完成回滚。再比如新固件在启动过程中把 NVS 里的校准数据写坏了即使回滚到旧版本数据已经损坏旧版本也救不回来。所以我把回滚定位成“尽可能降低固件升级事故影响面”的手段而不是替代备份和容错设计的方案数据安全该做备份就做备份配置文件该做校验就做校验。5. 从双分区到更完整的升级体系5.1 在 Arduino 上怎么“抄作业”如果你确实只能用 Arduino 环境也不是完全没办法。Arduino core 底层调用的还是 ESP-IDF 的 OTA 接口只是没有把全套状态机做成一键配置。你可以用 NVS 自己维护一个简单的启动计数器和“上次成功运行”标志每次启动计数加 1写进 NVS业务正常跑一段时间后把“上一次成功”标志置位每次开机先检查如果启动次数超过阈值且没有成功标志就主动ESP.restart()或者调用底层接口切换到另一个 app 分区。这种做法是我早期用 Arduino 做原型时的临时方案可靠性比 ESP-IDF 原生机制差一些但聊胜于无。生产环境我会强烈建议切到 ESP-IDF毕竟回滚这种和启动流程深度耦合的功能能用官方成熟机制就别自己硬造。5.2 把自动回滚和反回滚保护结合起来的思路在量产阶段除了自动回滚还可以考虑叠加反回滚保护也就是开启CONFIG_APP_ANTI_ROLLBACK。这个机制会把固件版本号写进 eFuse 或者可靠的 flash 区域Bootloader 启动时检测新固件版本是否低于当前记录版本如果低于就直接拒绝启动。它的好处是终端用户不能轻易把固件降级到旧版防止安全补丁被绕过。但这里面的坑在于启用反回滚保护是不可逆的一旦开启后期再想刷回旧版本会非常麻烦。所以我的建议是开发调试阶段完全不开等到产品进入正式量产、版本管理流程稳定了再启用。搭配双分区机制就是“既能自动回滚到上一个能用版本又不会允许刷入比当前版本更旧的固件”这套组合拳在带联网功能的产品上尤其常用。5.3 最后再分享一个小技巧升级流程里有一个容易被忽略的“现场确认”功能。很多设备升级后并不需要网管跑到现场去看但可以在新固件首次启动成功、标记为有效之后把确认信息主动上报给后端平台。这样后台能看到每次 OTA 的最终状态是成功还是回滚了方便追踪真正有问题的固件版本。我在某个规模化项目中就是这样做的结果两次 OTA 事故都被自动回滚兜住了后台日志也清楚记录了哪些设备回滚了处理起来省了很多事。回滚机制本身不是什么高深技术但确实需要在项目早期把它规划进系统设计里而不是等大规模升级出问题再亡羊补牢。ESP32 给了这么好的现成能力用起来也不复杂认真配置一次后面省心非常多。