1. 从一次OTA翻车说起:ESP32到底会不会变砖
很多人第一次给ESP32做OTA升级的时候,心里都会悬着一块石头:万一固件刷到一半断电了怎么办?万一新固件本身有bug起不来怎么办?板子是不是就彻底废了,只能上电烙铁换芯片?我当初也是这么想的,直到有一次在实验室里真的把一块ESP32刷成了“半死不活”的状态——串口能连上,但程序跑不起来,反复重启,那一刻我才认真去研究ESP32的启动机制和分区表设计。
先说结论:ESP32在绝大多数情况下不会真正变砖。所谓“变砖”,通常指的是芯片彻底无法通过正常手段重新烧录,只能靠硬件手段(比如JTAG或者换芯片)救回来。而ESP32的ROM Bootloader是固化在芯片内部ROM里的,出厂就存在,用户怎么刷都刷不掉。只要ROM Bootloader还在,你就能通过串口重新烧录固件。真正意义上的“砖”,在ESP32上极其罕见,除非你把eFuse里的下载模式禁用位给烧了,或者把Flash加密密钥搞丢了又没备份——这些属于自己作死型操作,不在常规讨论范围内。
那为什么大家还是怕?因为“程序跑不起来”和“变砖”在体感上差不多:设备不工作,串口一堆乱码,OTA推不上去,现场设备又拆不下来。这种状态我管它叫“软砖”——芯片没坏,但你没法远程把它救回来,只能跑现场插USB线。对于部署在楼顶、配电箱、农业大棚里的设备来说,跑现场的成本比换芯片还高。所以真正要解决的不是“会不会变砖”,而是如何让设备在固件出问题时能自己恢复。
这就是双分区加自动回滚要干的事。ESP32的OTA机制本身就支持多分区,配合ESP-IDF里的esp_ota_ops组件,可以做到新固件启动失败后自动切回旧固件。这套机制不是玄学,是写在分区表和Bootloader逻辑里的确定性行为。下面我从分区表怎么设计、回滚怎么触发、代码怎么写、实测中遇到哪些坑,一步步拆开讲。
2. 分区表不是随便填的:双分区OTA的布局逻辑
2.1 为什么单分区OTA一定会出问题
先看一个典型的单分区OTA方案:Flash里只有一个factory应用分区,OTA的时候把新固件写到另一个临时区域,然后擦掉factory再写进去。这个过程中如果断电,factory分区就是半擦半写的状态,Bootloader找不到有效的应用镜像,设备直接卡在启动阶段。更麻烦的是,有些方案连临时区域都没有,直接原地覆盖,那风险更大。
ESP32的Bootloader在启动时会检查应用分区头部的一个魔术字和校验字段,如果校验不过,它会尝试下一个可启动分区。但如果你只有一个应用分区,下一个就是空的,Bootloader只能报错重启。这就是“软砖”的典型场景。
2.2 双分区OTA的分区表长什么样
双分区OTA的核心思路是:Flash里保留两个应用分区ota_0和ota_1,再加一个otadata分区记录当前该启动哪个。新固件永远写到“非当前运行”的那个分区,写完之后更新otadata,然后重启。重启后Bootloader读otadata,决定启动新分区还是旧分区。
一个最小可用的分区表大概是这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x180000, ota_1, app, ota_1, 0x190000,0x180000,这里有几个关键点。otadata分区大小是0x2000,也就是8KB,足够存两份ota记录(每份4KB),对应两个应用分区。ota_0和ota_1的大小必须一致,而且不能小于实际固件的大小。0x180000是1.5MB,对于大多数带WiFi和蓝牙的ESP32应用来说够用,但如果你塞了文件系统、大量字库或者AI模型,就得换更大Flash的模组,比如16MB的ESP32-WROVER。
注意:
otadata分区的偏移和大小必须严格按照ESP-IDF的要求来,不能随便改。改错了Bootloader读不到ota状态,回滚逻辑直接失效。
2.3 分区大小算不对,回滚就是空谈
我见过有人把ota_0设成1MB,ota_1设成2MB,觉得“反正新固件大一点”。这是错的。OTA切换的时候,Bootloader不关心分区大小是否对称,但esp_ota_get_next_update_partition会按顺序找下一个可用的OTA分区。如果两个分区大小不一致,某些IDF版本会报错,或者写入的时候直接越界。
正确的做法是:先编译一次完整固件,看生成的bin文件多大,然后两个OTA分区都留出至少1.5倍余量。比如固件是900KB,那两个分区各给1.5MB,剩下空间给NVS和SPIFFS。Flash总大小至少4MB起步,8MB更稳妥。
另外,otadata分区虽然小,但它是整个回滚机制的核心。每次OTA完成、每次启动成功确认,都会往这里写数据。Flash擦写寿命大概10万次,正常使用完全够,但如果你在代码里频繁调用esp_ota_mark_app_valid,那就是在加速消耗。这个后面讲回滚触发的时候会细说。
3. 自动回滚的触发链条:从Bootloader到应用确认
3.1 Bootloader怎么判断该启动哪个分区
ESP32上电后,第一段执行的代码是ROM里的Bootloader,它负责加载Flash里的二级Bootloader。二级Bootloader会读otadata分区,里面有两个ota_select条目,每个条目记录了一个序列号和分区状态。Bootloader选序列号更大、状态为“有效”或“新固件待验证”的那个分区来启动。
如果otadata是空的(比如第一次烧录),Bootloader就启动factory分区,或者默认启动ota_0。如果两个OTA分区的状态都是“无效”,Bootloader会尝试进入恢复模式,但ESP32没有内置的恢复控制台,所以实际上就是反复重启。
关键点在于:新固件第一次启动时,它的状态是“待验证”。Bootloader会正常启动它,但会在otadata里标记“这个分区还没被确认”。如果应用在启动后没有主动调用确认函数,下一次重启时Bootloader就会认为这个固件有问题,自动切回上一个有效分区。这就是自动回滚的底层逻辑。
3.2 应用层怎么“确认自己没问题”
在ESP-IDF里,确认固件有效的函数是esp_ota_mark_app_valid_cancel_rollback()。你需要在应用启动后、确认网络、外设、关键任务都正常之后,调用这个函数。调用之后,当前分区的状态变成“有效”,回滚计数器清零,下次重启就不会再切回去了。
但这里有个坑:你不能一开机就调用它。如果新固件有bug,比如WiFi连不上、传感器初始化失败,你一开机就确认,那回滚机制就形同虚设。正确的做法是设置一个“观察期”,比如启动后30秒,或者等到某个关键事件(比如成功连上MQTT服务器)之后再确认。
我一般的做法是:在app_main里创建一个esp_timer,延迟60秒调用确认函数。同时在这60秒内,如果检测到致命错误(比如连续重启超过3次),就主动调用esp_ota_mark_app_invalid_rollback_and_reboot(),立刻回滚。这样既能保证正常固件及时确认,又能在异常时快速切回。
3.3 回滚的边界条件:什么情况会触发,什么情况不会
自动回滚不是万能的。它只能处理“应用启动后没有确认”这种情况。如果新固件能正常启动、能调用确认函数,但运行一段时间后死机,回滚机制不会自动触发,因为Bootloader已经认为这个固件是有效的。这种“运行期崩溃”需要靠看门狗或者自定义的健康检查来处理。
另外,如果新固件在启动阶段就卡死,比如在app_main之前就挂了,那确认函数永远不会被调用,下次重启Bootloader会自动回滚。这是最典型的回滚场景。
还有一种情况:OTA写入过程中断电。这时候otadata还没更新,Bootloader仍然启动旧分区,新写入的分区数据不完整,但不会被启动。下次OTA的时候会重新擦写那个分区。所以写入中断不会导致设备变砖,只是浪费了一次OTA机会。
提示:如果你用的是ESP-IDF的
esp_https_ota组件,它内部已经处理了写入中断和校验失败的情况,但确认逻辑还是需要你自己加。
4. 代码落地:从OTA写入到回滚确认的完整实现
4.1 OTA写入的核心流程
先看OTA写入的代码骨架。假设你已经通过HTTP或者BLE拿到了新固件的bin文件,接下来要做的是:
esp_ota_handle_t ota_handle; const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle); while (data_remaining) { esp_ota_write(ota_handle, data_chunk, chunk_size); } esp_ota_end(ota_handle); esp_ota_set_boot_partition(update_partition); esp_restart();这几行代码看起来简单,但每一步都有讲究。esp_ota_get_next_update_partition(NULL)会自动选择“非当前运行”的那个OTA分区。比如当前跑在ota_0,它就返回ota_1。esp_ota_begin会擦除目标分区,如果擦除过程中断电,下次上电Bootloader还是启动旧分区,因为otadata没变。
esp_ota_write可以分多次调用,每次写一小块数据。这里建议每次写4KB对齐,因为Flash的扇区是4KB,不对齐会导致额外的读改写操作,速度慢还容易出错。esp_ota_end会校验写入数据的完整性,如果校验失败,直接返回错误,不会更新otadata。
最后esp_ota_set_boot_partition才是真正更新otadata的地方。这一步之后,下次重启就会启动新分区。注意,这个函数只是写otadata,不会立即重启,你可以选择在合适的时机调用esp_restart。
4.2 确认与回滚的代码模板
下面是我常用的确认与回滚代码模板,直接可以抄:
static void ota_confirm_timer_callback(void *arg) { esp_ota_mark_app_valid_cancel_rollback(); ESP_LOGI(TAG, "Firmware confirmed valid"); } void app_main(void) { // 检查是否是OTA启动 const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { // 新固件待验证,启动确认定时器 esp_timer_create_args_t timer_args = { .callback = ota_confirm_timer_callback, .name = "ota_confirm" }; esp_timer_handle_t timer; esp_timer_create(&timer_args, &timer); esp_timer_start_once(timer, 60 * 1000000); // 60秒 } } // 正常业务初始化 wifi_init(); mqtt_init(); // ... }这段代码的逻辑是:如果当前运行的分区状态是PENDING_VERIFY,说明这是OTA后的第一次启动,启动一个60秒的定时器。60秒后如果系统还活着,就确认固件有效。如果60秒内系统崩溃重启,定时器没触发,下次Bootloader就会回滚。
但这里有个细节:esp_ota_mark_app_valid_cancel_rollback必须在所有关键初始化完成之后调用。如果你在WiFi还没连上、MQTT还没订阅的时候就确认了,那新固件即使网络功能有问题也会被标记为有效,回滚就失效了。我一般会把确认点放在“成功连上MQTT并发布一条上线消息”之后,这样能确保网络链路是通的。
4.3 主动回滚:什么时候该放弃新固件
有些错误是“可检测但不可恢复”的,比如传感器初始化失败、配置文件损坏、关键任务创建失败。这种情况下,与其等60秒超时,不如主动回滚。ESP-IDF提供了esp_ota_mark_app_invalid_rollback_and_reboot(),调用后立即重启并切回旧分区。
我通常会在这些地方加主动回滚:
- 连续重启计数器超过3次(存在NVS里)
- 关键外设初始化返回错误
- 看门狗在确认前触发
连续重启计数器的实现很简单:每次启动时从NVS读一个计数器,加一写回去。确认固件有效后清零。如果计数器超过阈值,说明新固件反复重启,直接回滚。
nvs_handle_t handle; nvs_open("ota", NVS_READWRITE, &handle); uint32_t boot_count = 0; nvs_get_u32(handle, "boot_count", &boot_count); boot_count++; nvs_set_u32(handle, "boot_count", boot_count); nvs_commit(handle); if (boot_count > 3) { esp_ota_mark_app_invalid_rollback_and_reboot(); }这段代码要放在app_main的最开始,越早越好。如果新固件在app_main之前就挂了,那这段代码根本执行不到,但那种情况下Bootloader会自动回滚,因为确认函数没被调用。
5. 实测中踩过的坑:回滚不是每次都灵
5.1 分区表改了但没擦Flash,Bootloader读不到新布局
第一次做双分区的时候,我改了分区表,编译烧录,结果设备一直重启。串口日志显示Bootloader找不到otadata分区。原因是:分区表变了之后,必须整片擦除Flash再烧录,否则旧的otadata数据还在原来的偏移,Bootloader按新分区表去读,读到的全是乱码。
正确的操作是:idf.py erase_flash然后idf.py flash。如果你用Arduino IDE,那就得用esptool.py erase_flash手动擦。这个坑我踩了两次,每次都是浪费半小时看日志。
5.2 确认函数调用太早,回滚机制形同虚设
有一版固件,我在app_main第一行就调用了确认函数,想着“反正能启动就没问题”。结果那版固件的WiFi驱动有bug,连上AP后几分钟就崩溃。因为固件已经被标记为有效,Bootloader不会回滚,设备反复重启,只能跑现场插USB。
后来我把确认点改到“MQTT上线成功”之后,并且加了60秒延迟。这样即使WiFi驱动有问题,60秒内崩溃也不会确认,下次重启自动回滚。实测下来,这个策略救了好几次现场设备。
5.3 OTA写入速度太慢导致看门狗超时
ESP32的Flash写入速度受限于SPI频率和擦除操作。如果你在OTA写入的时候没有喂看门狗,任务看门狗会触发重启。更麻烦的是,如果写入过程中重启,otadata没更新,设备还是跑旧固件,但新分区里是半截数据。
解决办法有两个:一是把OTA写入任务放到独立的任务里,定期调用vTaskDelay让看门狗有机会喂;二是调大看门狗超时时间。我一般用第一种,因为改看门狗超时会影响其他任务的监控。
另外,OTA写入的时候最好关掉WiFi的省电模式,否则网络抖动会导致写入速度忽快忽慢,增加超时风险。
5.4 回滚后的旧固件也不一定靠谱
自动回滚的前提是旧固件是好的。但如果旧固件本身就有问题,比如旧固件也有内存泄漏,那回滚只是从一个坑跳到另一个坑。所以每次OTA之前,最好确保当前运行的固件是经过验证的稳定版本。我一般会在NVS里记录“最后一次确认有效的固件版本”,回滚后如果旧固件也反复重启,那就只能进入恢复模式,等待手动烧录。
ESP-IDF没有内置的恢复模式,但你可以自己实现一个:在分区表里加一个factory分区,里面放一个最小化的恢复固件,只提供串口或者WiFi的烧录接口。当两个OTA分区都无效时,Bootloader会启动factory分区。这个方案稍微复杂一点,但对于无人值守的设备来说很值得。
6. 把回滚做成习惯:几个工程上的建议
6.1 版本号要写进固件,回滚后能看出来
每次OTA的固件都应该在编译时嵌入版本号,比如用git describe或者编译时间戳。回滚之后,串口日志或者MQTT上线消息里带上版本号,你就能立刻知道当前跑的是哪个版本。没有版本号的话,回滚了你都不知道,还以为新固件生效了。
我一般在app_main里打印一行:ESP_LOGI(TAG, "Firmware version: %s", APP_VERSION);,然后在MQTT上线消息里也带上。这样远程就能看到设备当前版本。
6.2 回滚事件要上报,不能悄悄发生
自动回滚是好事,但如果回滚了你不知道,那就可能反复推同一个有问题的固件。我通常会在回滚发生后,通过MQTT或者HTTP上报一个事件,带上回滚原因和当前版本。服务端收到之后,可以暂停对这个设备的OTA推送,等人工介入。
上报的时机可以在app_main里检测到PENDING_VERIFY状态时,说明这是OTA后的第一次启动,如果之前有回滚记录,就一起上报。
6.3 测试回滚不能靠断电,要用代码模拟
很多人测试回滚就是OTA到一半拔电,这只能测试写入中断,不能测试“新固件启动失败”。正确的测试方法是:故意在新固件里加一个while(1)或者不调用确认函数,然后OTA,观察设备是否在60秒后自动回滚。这个测试我每次发版前都会跑一遍,确保回滚链路是通的。
另外,测试的时候要把串口日志打开,观察Bootloader打印的“Rollback”信息。ESP-IDF的Bootloader在回滚时会打印类似Rollback to ota_0的日志,看到这行就说明回滚成功了。
6.4 Flash空间不够的时候,优先保OTA分区
如果你的应用需要文件系统、字库、AI模型,Flash空间会很紧张。这时候不要压缩OTA分区的大小,宁可换更大Flash的模组。OTA分区不够大,固件写不进去,回滚机制再完善也没用。我一般建议:4MB Flash是最低配,8MB起步比较舒服,16MB可以随便造。
如果实在只能用4MB,那就把文件系统放到外部SPI Flash或者SD卡上,内部Flash只留OTA分区和NVS。这样虽然成本高一点,但可靠性提升很多。
7. 最后分享几个实战中的小技巧
第一个技巧:在otadata分区里除了ESP-IDF自己用的数据,还可以在NVS里存一份“回滚历史”,记录每次回滚的时间、原因、版本号。这样即使设备离线,你也能通过串口读出来,方便定位问题。
第二个技巧:OTA写入的时候,把新固件的MD5或者SHA256校验值一起传过来,写入完成后先校验再更新otadata。ESP-IDF的esp_ota_end会做镜像校验,但如果你自己分块传输,最好再加一层整体校验,防止传输过程中数据被篡改。
第三个技巧:如果你的设备是通过BLE做OTA,注意BLE的MTU限制。默认MTU是23字节,实际可用载荷只有20字节,传1MB固件要几万包,速度很慢还容易断。建议协商更大的MTU,比如247或者512,能把速度提升十倍以上。
第四个技巧:回滚之后,旧固件的NVS数据可能和新固件不兼容。比如新固件改了NVS的键名或者数据结构,回滚后旧固件读不到数据,可能也会崩溃。所以NVS的键名和数据结构要尽量保持向后兼容,或者每次OTA前备份NVS,回滚后恢复。
这些经验都是我在实际项目中一点点攒出来的,有些是踩坑之后才明白的。双分区加自动回滚不是银弹,但它能把“设备变砖”的概率降到极低。只要分区表设计对、确认逻辑写对、测试做到位,ESP32的OTA可以做到非常可靠。