ARTICLE DETAIL

资讯详情

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

ESP32固件双分区与自动回滚:OTA升级防变砖实战

ESP32固件双分区与自动回滚:OTA升级防变砖实战 1. 从一次“刷废了”的深夜事故说起凌晨两点我盯着串口监视器上不断滚动的乱码手里的ESP32开发板已经第三次重启失败了。那天晚上我只是想给一个跑了半年的温湿度采集节点加个蓝牙配网功能结果一次不小心的分区表写错让这块板子彻底“失联”——USB线插上去电脑能识别串口芯片但ESP32本体毫无反应连bootloader都进不去。那一刻我脑子里只有一个念头这玩意儿是不是真的变砖了后来我花了整整一个周末研究ESP32的启动流程和分区机制才搞明白一件事ESP32的“变砖”绝大多数情况下是假砖真正物理层面写坏Flash的概率极低。而真正让我从“每次OTA都提心吊胆”变成“随便刷、刷坏了自动回来”的是**双分区Dual OTA Partition加自动回滚Rollback**这套机制。这篇文章就是把我踩过的坑、验证过的方案、以及实际跑在几十个节点上的配置经验完整地摊开来讲。如果你手里有ESP32正在用Arduino或者ESP-IDF做项目尤其是涉及到OTA升级、远程维护、或者设备部署在够不着的地方那这篇内容值得你花时间看完。我会从分区表的底层逻辑讲起一直讲到怎么配置自动回滚、怎么验证回滚真的生效、以及那些文档里不会写的实操细节。核心关键词ESP32、固件、双分区、自动回滚、OTA这几个词贯穿全文每一个我都会给出可复现的操作路径。先说结论ESP32不会因为你刷错固件就变成废铁只要分区表里预留了OTA分区并且启用了回滚机制最坏的情况也只是设备自动退回上一个能跑的版本。真正会“变砖”的场景是你把bootloader或者分区表本身写坏了而且没有留恢复通道。这两种情况的处理方式完全不同后面会详细拆解。2. 搞懂ESP32的启动链路才能判断什么叫“真砖”2.1 从上电到app_main四级跳转的启动流程很多人一遇到ESP32不跑代码就喊“变砖”其实根本没搞清楚芯片上电之后到底发生了什么。我用一个生活化的类比来解释ESP32的启动过程就像一家公司早上开门营业分四道工序任何一道卡住公司就开不了张。第一道工序是ROM Bootloader。这段代码固化在芯片内部的ROM里你永远改不了它也刷不坏它。上电后它最先运行负责最基本的硬件初始化和判断从哪里加载下一段代码。它就像公司大门的保安不管里面发生什么保安永远在岗。第二道工序是二级Bootloader存放在Flash的0x1000偏移处。这段代码是可以被你刷写的它负责读取分区表、选择启动哪个app分区、以及初始化一些外设。如果这段代码被写坏芯片就找不到分区表表现就是串口不断输出“invalid header”之类的错误。第三道工序是分区表解析。分区表固定在0x8000偏移处它告诉二级Bootloaderapp分区在哪、OTA数据分区在哪、NVS存在哪、SPIFFS在哪。分区表一旦损坏或者偏移对不上Bootloader就不知道该加载谁。第四道工序才是应用程序app启动最终跳到app_main。我们平时OTA升级的其实就是这一层。注意真正意义上的“变砖”只有ROM Bootloader物理损坏才会发生而这段代码在芯片出厂时就固化在掩膜ROM里正常使用根本碰不到。你刷写操作能触及的最底层是二级Bootloader和分区表。2.2 什么情况会“假砖”什么情况会“真砖”我把常见的故障场景整理成一张表方便你对照判断故障现象损坏层级是否可恢复恢复方式串口乱码、不断重启app分区可恢复重新烧录或触发回滚串口输出invalid header二级Bootloader可恢复手动进入下载模式重刷分区表偏移错误分区表可恢复擦除Flash重新烧录上电完全无输出ROM或硬件基本不可恢复更换芯片OTA后设备失联app分区可恢复自动回滚或串口重刷从这张表能看出来99%的“变砖”都是app分区或者分区表层面的问题完全可以通过串口重新烧录救回来。真正麻烦的是那些部署在现场、没有物理串口接触的设备这时候自动回滚就成了唯一的救命稻草。2.3 为什么双分区是OTA的前提条件ESP32的OTA升级本质上是一个“写另一个分区然后切换启动目标”的过程。如果你的Flash里只有一个app分区那OTA的时候就必须先擦除当前运行的代码再写入新代码——这个过程中一旦断电或者写入失败设备就彻底没有可运行的固件了。双分区的设计思路很简单准备两个app分区ota_0和ota_1当前跑在ota_0新固件就写到ota_1写完之后修改OTA数据分区里的标记下次重启就从ota_1启动。如果ota_1的新固件启动失败回滚机制会把启动目标切回ota_0。这就像你家里有两把钥匙一把在用一把备用。换锁的时候先把备用钥匙配好确认能开门了再把常用钥匙换掉。而不是把唯一的钥匙熔了重新打一把。分区表里跟OTA相关的核心分区有三个otadata记录当前应该从哪个app分区启动以及每个分区的状态new、valid、invalid等ota_0第一个app分区ota_1第二个app分区在ESP-IDF里这套机制是原生支持的Arduino-ESP32也封装了对应的API。关键在于你的分区表要正确配置并且代码里要主动调用回滚相关的接口。3. 双分区方案的分区表设计与参数计算3.1 分区表到底怎么排从4MB Flash说起我手上最常用的模组是ESP32-WROOM-32标配4MB Flash。很多人拿到默认分区表就直接用结果发现OTA的时候空间不够。这里我把实际在用的分区表配置贴出来并解释每个参数是怎么算出来的。# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x140000, app1, app, ota_1, 0x150000,0x140000, spiffs, data, spiffs, 0x290000,0x160000, coredump, data, coredump,0x3F0000,0x10000,先看关键数字每个app分区大小是0x140000也就是1.25MB。两个app分区加起来2.5MB加上otadata的8KB、nvs的20KB、spiffs的1.375MB、coredump的64KB总共约3.96MB刚好塞进4MB Flash。为什么app分区要留1.25MB因为我的固件里包含了WiFi、BLE、MQTT、JSON解析、以及一个内嵌的Web服务器编译出来大约900KB左右。留1.25MB是给后续功能扩展留余量。如果你只是做个简单的传感器节点app分区可以缩到0x1000001MB把省下来的空间给SPIFFS。提示app分区大小必须是0x1000064KB的整数倍这是Flash扇区对齐的要求。计算的时候别拍脑袋用编译输出的bin文件大小乘以1.3左右作为参考值。3.2 otadata分区的状态机回滚的决策中心otadata分区只有8KB但它决定了设备每次启动时加载哪个app。它的内部结构是两个512字节的扇区ota_0和ota_1各一个记录每个记录包含ota_seq序列号用来判断哪个记录更新state分区状态取值包括ESP_OTA_IMG_NEW、ESP_OTA_IMG_PENDING_VERIFY、ESP_OTA_IMG_VALID、ESP_OTA_IMG_INVALID、ESP_OTA_IMG_ABORTED、ESP_OTA_IMG_UNDEFINED回滚的核心逻辑就藏在这个状态机里。当你通过OTA写入新固件后新分区的状态被标记为NEW。第一次启动时Bootloader把它改成PENDING_VERIFY然后把控制权交给新固件。新固件必须在规定时间内调用esp_ota_mark_app_valid_cancel_rollback()把状态改成VALID。如果新固件启动后崩溃了、或者没有在规定时间内标记自己为有效下次重启时Bootloader发现状态还是PENDING_VERIFY就会判定这个固件有问题自动切回另一个分区。这个“规定时间”默认是5秒可以通过menuconfig里的CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE相关选项调整。我一般会把它设成10秒给WiFi连接和自检逻辑留足时间。3.3 空间不够怎么办分区表裁剪的取舍逻辑如果你用的是2MB Flash的模组双分区就有点紧张了。2MB减去bootloader、分区表、nvs、otadata的开销剩下大约1.9MB给app和文件系统。两个app分区各分0.9MBSPIFFS就只剩100KB左右基本干不了什么。这种情况下我的建议是如果不需要文件系统砍掉SPIFFS两个app分区各分0.95MB如果必须保留文件系统考虑用单分区加“压缩固件”方案但这样就失去了自动回滚能力最稳妥的做法是换4MB或8MB的模组现在4MB和8MB的模组价格差很小没必要在空间上抠我实测过一个纯MQTT上报的固件关闭BLE和Web服务器后编译出来只有620KB0.9MB的分区完全够用。所以关键还是看你固件里塞了多少东西。4. 自动回滚的代码实现与验证方法4.1 在ESP-IDF里启用回滚三行配置加一个函数调用ESP-IDF对回滚的支持是开箱即用的但默认没打开。你需要做三件事第一在menuconfig里开启回滚功能idf.py menuconfig # 进入 Bootloader config - Enable app rollback support # 勾选 Enable app rollback support第二在代码里新固件启动并完成自检后调用标记有效的接口#include esp_ota_ops.h void app_main(void) { // 初始化外设、连接WiFi、启动服务 // ... 你的初始化代码 ... // 自检通过后标记当前固件为有效取消回滚 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) { // 这里可以加一些健康检查比如WiFi是否连上、传感器是否响应 if (self_test_passed()) { esp_ota_mark_app_valid_cancel_rollback(); } else { esp_ota_mark_app_invalid_rollback_and_reboot(); } } } }第三确保你的分区表里有otadata分区并且两个app分区的subtype分别是ota_0和ota_1。这三步做完回滚机制就生效了。新固件如果启动后10秒内没有调用esp_ota_mark_app_valid_cancel_rollback()或者主动调用了esp_ota_mark_app_invalid_rollback_and_reboot()设备就会自动退回旧固件。4.2 自检逻辑怎么写别让回滚误触发回滚机制最怕的是“误判”——新固件其实没问题但因为自检逻辑写得太严格导致设备反复回滚。我踩过的一个坑是自检里要求WiFi必须在5秒内连上结果现场路由器响应慢设备每次升级都回滚折腾了一晚上。后来我把自检逻辑改成“分级判断”一级检查系统能否正常启动到app_main这个由Bootloader自动判断二级检查关键外设初始化是否成功I2C、SPI、传感器三级检查网络是否连通这个设为非必须连不上也标记为有效只是上报状态异常具体实现上我会在app_main开头记录一个时间戳然后在自检通过后计算耗时确保不超过回滚超时时间。如果某项检查失败不是立即回滚而是重试两次两次都失败才调用回滚接口。static bool self_test_with_retry(void) { for (int i 0; i 3; i) { if (check_sensors() check_storage()) { return true; } vTaskDelay(pdMS_TO_TICKS(1000)); } return false; }注意回滚超时时间是从Bootloader把控制权交给app开始算的不是从app_main开始算。如果你的app在启动阶段有大量初始化操作要确保在超时前完成标记。4.3 验证回滚真的生效三种实测方法配置写完不代表回滚就能用我一般会用三种方法验证方法一主动触发回滚。在代码里临时加一句esp_ota_mark_app_invalid_rollback_and_reboot()烧录后观察设备是否退回上一个固件。这个方法最直接但需要你有两个可启动的固件。方法二模拟启动崩溃。在新固件里加一个abort()或者空指针访问让它在启动后几秒内崩溃。如果回滚生效设备重启后会回到旧固件。这个方法能验证Bootloader的自动判断逻辑。方法三断电测试。在OTA写入过程中直接拔电然后重新上电观察设备是否能正常启动。这个方法验证的是otadata的原子性——ESP32的otadata设计保证了即使写入过程中断电也不会出现两个分区状态都无效的情况。我实测下来方法二和方法三最能反映真实场景。尤其是方法三我反复拔电十几次设备每次都能正常启动到旧固件说明otadata的扇区交替写入机制是可靠的。4.4 Arduino-ESP32下的回滚配置差异如果你用的是Arduino框架回滚的API名字不太一样但逻辑是相通的。Arduino-ESP32从2.0版本开始支持Update库和回滚接口#include Update.h #include esp_ota_ops.h void setup() { // 初始化代码 // ... // 标记固件有效 esp_ota_mark_app_valid_cancel_rollback(); }需要注意的是Arduino框架默认的分区表可能没有otadata分区你需要手动选择带OTA的分区方案。在Arduino IDE里通过“工具 - Partition Scheme”选择“Minimal SPIFFS (1.9MB APP with OTA)”或者自定义分区表。我个人的习惯是不管用哪个框架都先把分区表打印出来确认一遍const esp_partition_t *p esp_ota_get_running_partition(); ESP_LOGI(TAG, Running partition: %s, offset: 0x%x, size: 0x%x, p-label, p-address, p-size);这行日志能告诉你当前到底跑在哪个分区是ota_0还是ota_1一目了然。5. OTA升级流程的完整实操与避坑指南5.1 从服务器下载固件到写入分区的完整链路一个完整的OTA流程包含这些步骤设备上报当前版本 - 服务器返回新固件URL和校验值 - 设备下载固件 - 校验固件完整性 - 写入备用分区 - 切换启动分区 - 重启 - 自检 - 标记有效。我用得最多的方案是HTTPS 差分校验。固件放在对象存储上设备通过HTTPS下载下载完成后计算SHA256跟服务器返回的校验值比对。比对通过才写入Flash不通过直接丢弃。写入过程用ESP-IDF的esp_https_ota接口最省事esp_http_client_config_t config { .url firmware_url, .timeout_ms 10000, }; esp_https_ota_config_t ota_config { .http_config config, }; esp_err_t ret esp_https_ota(ota_config); if (ret ESP_OK) { esp_restart(); } else { ESP_LOGE(TAG, OTA failed: %s, esp_err_to_name(ret)); }这段代码看起来简单但背后做了很多事建立HTTPS连接、分块下载、写入otadata标记、校验镜像头。如果你要自己实现下载逻辑记得在写入前调用esp_ota_begin()写入过程中调用esp_ota_write()最后调用esp_ota_end()和esp_ota_set_boot_partition()。5.2 下载过程中的断网、断电处理OTA最怕的就是下载到一半断网或者断电。ESP-IDF的OTA接口在设计上考虑了这一点esp_ota_write()是分块写入的每次写入一个扇区。如果中途失败已经写入的部分不会影响当前运行的分区因为写入的是备用分区。但有一个细节要注意如果下载失败要调用esp_ota_abort()释放资源否则下次OTA可能会因为分区被占用而失败。我在代码里会加一个重试机制连续失败三次才放弃并且把失败原因上报到服务器。断电的情况更极端但因为写入的是备用分区当前运行的分区不受影响。重新上电后Bootloader还是会从当前有效的分区启动。唯一的影响是备用分区里可能残留了半截固件下次OTA时会重新擦除再写入不会有副作用。5.3 版本号管理与回滚决策的配合自动回滚解决的是“新固件跑不起来”的问题但还有一种情况是“新固件能跑但有严重bug”。这种时候回滚机制不会自动触发需要你在应用层做版本管理。我的做法是在NVS里存一个boot_count和last_good_version。每次启动时boot_count加一如果连续启动失败超过3次就主动回滚到上一个版本。同时设备每次成功连接服务器后上报当前版本和启动次数服务器可以根据版本分布决定是否推送新固件。版本号的格式我用的是语义化版本major.minor.patch存在app分区的一个固定偏移处OTA前后都能读取。这样即使设备回滚了服务器也能知道它现在跑的是哪个版本。5.4 实测数据回滚耗时与成功率统计我在一个部署了30个节点的测试环境里跑了三个月的OTA统计了一些数据指标数值OTA成功率96.7%回滚触发次数3次回滚成功率100%平均OTA耗时1MB固件42秒回滚后重新升级成功率100%三次回滚的原因分别是一次是新固件里WiFi初始化失败一次是传感器I2C地址配错导致自检不通过一次是固件写入过程中路由器重启导致下载中断。这三次回滚都发生在设备重启后的10秒内设备退回旧固件后继续正常工作没有出现“卡在中间状态”的情况。这个数据让我对双分区加回滚的方案有了足够的信心。后来我把这套机制用在了所有现场设备上再也没有因为OTA失败而跑现场的经历。6. 常见问题排查与独家避坑经验6.1 回滚不生效的五个典型原因原因一分区表里没有otadata分区。这是最常见的很多人用了默认的单app分区表根本没有ota_0和ota_1回滚自然无从谈起。检查方法idf.py partition-table打印分区表看有没有otadata。原因二menuconfig里没开回滚支持。CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE默认是关闭的不开这个选项Bootloader不会检查PENDING_VERIFY状态。原因三新固件启动后立即标记了有效。有些示例代码在app_main第一行就调用esp_ota_mark_app_valid_cancel_rollback()这样即使后面崩溃了回滚也不会触发。正确的做法是在自检通过后再标记。原因四回滚超时时间设得太短。默认5秒对于有WiFi连接和传感器初始化的固件来说可能不够建议设成10到15秒。原因五两个app分区大小不一致。如果ota_0和ota_1的大小不一样Bootloader在切换时可能会因为空间不足而失败。分区表里两个app分区的size必须完全相同。6.2 串口救砖的完整操作步骤即使回滚失效了只要串口还能连上就有救。我整理了一套标准救砖流程用USB线连接ESP32的UART口确保电脑识别到串口芯片按住BOOT键点按EN键松开BOOT键让芯片进入下载模式用esptool.py擦除Flashesptool.py --port /dev/ttyUSB0 erase_flash重新烧录bootloader、分区表、appidf.py flash如果idf.py flash失败检查串口权限和波特率降低到115200再试注意有些开发板的自动下载电路设计有问题需要手动按住BOOT键才能进入下载模式。如果一直连不上试试换一根USB线或者换一个USB口。6.3 那些文档里不会写的实操细节细节一otadata分区的擦除次数是有限的。每次OTA都会写一次otadata虽然Flash的擦写寿命有10万次但频繁OTA还是会加速老化。我的做法是如果只是小版本更新尽量合并多次更新为一次。细节二回滚后的设备需要重新触发OTA。回滚只是让设备回到旧固件不会自动重试新固件。你需要在服务器端检测到设备回滚后重新推送或者暂停推送避免设备陷入“升级-回滚-升级”的死循环。细节三coredump分区对排查回滚原因很有用。开启coredump后如果新固件崩溃了崩溃信息会存到coredump分区下次启动时可以读取出来分析。我一般会在回滚后把coredump上传到服务器方便定位问题。细节四OTA时的WiFi信号强度会影响成功率。实测下来RSSI低于-75dBm时OTA失败率明显上升。如果设备部署在信号弱的地方建议在OTA前先检查信号强度低于阈值就推迟升级。6.4 常见问题速查表问题现象可能原因排查方法解决方案OTA后设备不断重启新固件崩溃查看串口日志触发回滚或重刷回滚后还是跑新固件otadata未更新打印otadata状态检查set_boot_partition调用串口无输出分区表损坏进入下载模式重刷擦除Flash重新烧录OTA下载到一半失败网络不稳定检查RSSI和超时设置增加重试次数两个分区版本混乱版本号未管理读取分区版本信息引入版本号管理机制7. 关于固件安全与长期维护的几点个人体会这套双分区加回滚的机制我从最初的单节点测试到后来部署到几十个现场设备前后迭代了五六个版本。最大的体会是回滚不是万能的但没有回滚是万万不能的。它解决的是“升级失败后设备还能用”的问题但解决不了“新固件本身有逻辑bug”的问题。后者需要靠完善的测试流程和灰度发布策略来兜底。另一个体会是分区表的设计要留余量。我一开始把app分区卡得刚刚好结果后来加了个Web服务器固件体积超了不得不重新规划分区表所有设备都得重新烧录。从那以后我至少留30%的空间余量宁可浪费一点Flash也不要后期折腾。还有一点是关于固件加密的。ESP32支持Flash加密和安全启动如果你的设备部署在不可控的环境里建议开启这两项功能。但要注意开启加密后OTA流程会多一步签名校验分区表也需要相应调整。这个展开讲又是一大篇这里先记着以后有机会再细说。最后分享一个我常用的小技巧在设备启动时把当前运行的分区、固件版本、启动次数打印到串口同时通过MQTT上报到服务器。这样你随时能知道每个设备跑在哪个分区、是第几次启动、有没有发生过回滚。这个信息在排查问题时特别有用比翻日志快多了。
返回列表