ARTICLE DETAIL

资讯详情

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

ESP32 OTA双分区与自动回滚机制:从原理到实战

ESP32 OTA双分区与自动回滚机制:从原理到实战 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设成1MBota_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 分区表改了但没擦FlashBootloader读不到新布局第一次做双分区的时候我改了分区表编译烧录结果设备一直重启。串口日志显示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可以做到非常可靠。
返回列表