ARTICLE DETAIL

资讯详情

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

ESP32 OTA双分区自动回滚:打造刷不死的固件升级系统

ESP32 OTA双分区自动回滚:打造刷不死的固件升级系统 1. 从一次“刷废了”的深夜抢救说起凌晨两点我盯着串口监视器里不断滚动的乱码手里的开发板已经第三次重启失败了。那是我第一次给 ESP32 刷自定义固件手一抖把分区表写错了设备直接卡在 bootloader 阶段连串口都只剩下一堆看不懂的十六进制输出。当时脑子里只有一个念头这板子是不是废了后来我才明白ESP32 的“变砖”和传统单片机那种“一失足成千古恨”的变砖完全不是一回事。乐鑫在设计这颗芯片的时候就在 ROM 里固化了一段不可修改的引导程序只要这段 ROM 引导程序还在你就永远有机会把设备救回来。换句话说ESP32 几乎不可能真正“死透”除非你把芯片物理烧毁或者把 eFuse 里的关键位写死。但“能救回来”和“不用救”是两码事。每次刷坏都要拆机、接线、按住 BOOT 键手动进下载模式这种体验谁试谁知道。所以真正值得聊的不是“变砖了怎么救”而是“怎么设计一套机制让固件刷坏了也能自己滚回来”。这就是我今天要展开的内容用双分区加自动回滚把 ESP32 的 OTA 升级做成一个“刷不死”的系统。这套方案适合所有用 ESP32 做量产设备、远程部署或者经常需要迭代固件的开发者。不管你是用 Arduino IDE 还是 ESP-IDF不管你走的是 Wi-Fi OTA 还是蓝牙推送双分区加回滚的逻辑都是通用的。读完你至少能搞清楚三件事分区表到底该怎么划、回滚标志位在什么时机打、以及那些官方文档里不会写的坑到底长什么样。2. 双分区方案的整体设计与选型考量2.1 为什么单分区 OTA 是个危险游戏先说说最朴素的 OTA 做法设备只有一个 app 分区新固件下载下来之后直接覆盖旧固件。这种做法在网速稳定、固件经过充分测试的前提下勉强能用但它有一个致命缺陷——写入过程中一旦断电、断网或者固件本身有 bug设备就彻底失去了可运行的代码。你可能会说“那我写完再校验不就行了”问题是校验通过只代表数据完整不代表固件能正常启动。一个死循环的setup()或者一个空指针解引用照样能让设备在重启后卡死。我见过太多团队在早期为了省那几百 KB 的 Flash 空间选择单分区方案结果每次发版都提心吊胆。更麻烦的是设备部署在现场之后你根本没有物理接触的机会一旦刷坏就只能派人上门或者整机更换。这个成本远比多占一个分区的 Flash 要高得多。2.2 双分区的基本布局与空间计算双分区方案的核心思路很简单Flash 里同时存在两个 app 分区一个在运行另一个用来接收新固件。升级时把新固件写到“备用”分区写完之后修改启动标志下次重启就从新分区启动。如果新分区启动失败bootloader 会自动切回旧分区。以一块常见的 4MB Flash 的 ESP32 为例我通常这样划分分区名称类型偏移地址大小用途说明nvsdata0x90000x5000存储 Wi-Fi 配置和回滚标志otadatadata0xE0000x2000记录当前启动分区和状态phy_initdata0x100000x1000射频校准数据ota_0app0x110000x180000主固件分区 Aota_1app0x1910000x180000主固件分区 Bstoragedata0x3110000xE0000文件系统或用户数据这里有几个数字需要解释一下。ota_0 和 ota_1 各占 1.5MB加起来 3MB加上前面的引导区和后面的存储区刚好塞进 4MB Flash。如果你的固件比较大比如带了摄像头驱动或者语音模型那就得换 8MB 甚至 16MB 的模组。我一般建议固件体积不要超过分区大小的 70%留出足够的余量给未来的功能扩展。otadata 分区只有 8KB但它非常关键。它里面存了两个ota_select结构体每个结构体记录了对应 app 分区的序列号和状态。bootloader 每次启动时都会读这个分区决定从哪个 app 分区加载固件。这个设计的好处是切换分区的操作只是写几个字节的 Flash速度极快几乎不可能在写入过程中出错。2.3 自动回滚的判定逻辑自动回滚的触发条件不同版本的 ESP-IDF 有细微差别但核心逻辑是一致的。我以目前最常用的 ESP-IDF v4.x 和 v5.x 为例来说明。当设备从 ota_1 分区启动后bootloader 会把 otadata 里的状态标记为ESP_OTA_IMG_NEW。此时固件开始运行你的应用程序需要在初始化完成后主动调用esp_ota_mark_app_valid_cancel_rollback()来告诉系统“这个固件没问题”。如果你没有调用这个函数设备又重启了一次bootloader 就会把状态改成ESP_OTA_IMG_PENDING_VERIFY。再重启一次如果还是没有确认bootloader 就会判定新固件启动失败自动把启动分区切回 ota_0。这个“两次重启”的机制是一个安全缓冲。有些固件启动后需要连接网络、同步时间或者等待外设就绪这些操作可能需要几十秒甚至几分钟。在这段时间内如果发生意外重启系统不会立刻回滚而是给你第二次机会。只有连续两次都没有确认才会真正回滚。在 Arduino 环境下这个逻辑被封装得更简单。你只需要在setup()里调用esp_ota_mark_app_valid_cancel_rollback()剩下的交给底层。但要注意Arduino 的 OTA 库默认不会自动回滚你需要手动配置分区表并启用相关选项。3. 分区表配置与固件端代码实现细节3.1 手把手写一份可用的分区表分区表是一个 CSV 文件通常命名为partitions.csv放在项目根目录下。ESP-IDF 在编译时会自动读取这个文件Arduino IDE 则需要你在工具菜单里选择自定义分区表。下面是我在实际项目中反复使用的一份分区表针对 4MB Flash 优化过# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xE000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, ota_0, app, ota_0, 0x11000, 0x180000, ota_1, app, ota_1, 0x191000,0x180000, storage, data, spiffs, 0x311000,0xE0000,这里有几个容易踩坑的地方。第一偏移地址必须严格按照 Flash 的扇区对齐ESP32 的 Flash 扇区是 4KB所以所有偏移都必须是 0x1000 的整数倍。第二otadata 分区的大小必须是 0x2000不能多也不能少这是 bootloader 硬编码的要求。第三如果你用的是 ESP32-C3 或 S3Flash 布局可能略有不同建议先用esptool.py flash_id确认一下实际容量。写完之后在 ESP-IDF 里执行idf.py partition-table可以查看编译后的分区表是否正确。在 Arduino IDE 里你需要把这份 CSV 放到 sketch 目录下然后在Tools - Partition Scheme里选择Custom并在boards.txt或平台配置里指定文件路径。3.2 固件端确认逻辑的代码实现分区表配好之后接下来就是在固件里加入确认逻辑。我用 ESP-IDF 和 Arduino 两种环境分别说明你可以根据自己的技术栈选择。在 ESP-IDF 中典型的 OTA 升级流程是这样的#include esp_ota_ops.h #include esp_http_client.h void ota_task(void *pvParameter) { const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); const esp_partition_t *running_partition esp_ota_get_running_partition(); ESP_LOGI(TAG, 当前运行分区: %s, running_partition-label); ESP_LOGI(TAG, 目标写入分区: %s, update_partition-label); esp_ota_handle_t update_handle 0; esp_err_t err esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, update_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, OTA 初始化失败: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } // 这里省略 HTTP 下载和写入循环 // 实际项目中需要分块读取并调用 esp_ota_write() err esp_ota_end(update_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, OTA 结束失败: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } err esp_ota_set_boot_partition(update_partition); if (err ! ESP_OK) { ESP_LOGE(TAG, 设置启动分区失败: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } ESP_LOGI(TAG, OTA 成功准备重启); esp_restart(); }这段代码的关键在于esp_ota_set_boot_partition()它会把 otadata 里的启动标志指向新分区。重启之后bootloader 就会从新分区加载固件。然后在新固件的app_main()里你需要加入确认逻辑void app_main(void) { // 初始化外设、连接网络、启动服务 // ... // 确认固件有效取消回滚 esp_err_t err esp_ota_mark_app_valid_cancel_rollback(); if (err ESP_OK) { ESP_LOGI(TAG, 固件已确认回滚已取消); } else { ESP_LOGE(TAG, 确认固件失败: %s, esp_err_to_name(err)); } }注意这个确认操作最好放在所有关键初始化都完成之后。比如你的设备需要连接 Wi-Fi 才能正常工作那就等 Wi-Fi 连接成功、MQTT 订阅完成之后再调用。否则固件虽然启动了但网络功能是坏的用户照样用不了。在 Arduino 环境下代码更简洁#include WiFi.h #include HTTPClient.h #include Update.h void setup() { Serial.begin(115200); // 连接 Wi-Fi WiFi.begin(ssid, password); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(\nWi-Fi 已连接); // 确认固件有效 esp_ota_mark_app_valid_cancel_rollback(); Serial.println(固件已确认); // 其他初始化 }Arduino 的Update库会自动处理分区切换你只需要在合适的位置调用确认函数即可。但要注意Arduino 默认的分区方案可能不包含双 OTA 分区你需要在Tools - Partition Scheme里选择Minimal SPIFFS (1.9MB APP with OTA)或自定义分区表。3.3 回滚标志位的存储与读取otadata 分区的读写是由 bootloader 和esp_ota_ops库自动管理的你不需要手动去操作 Flash 地址。但理解它的存储结构对排查问题很有帮助。otadata 里有两个ota_select结构体每个结构体包含四个字段ota_seq序列号、ota_state状态、crc校验值和padding。序列号是一个递增的整数bootloader 会选择序列号更大的那个分区启动。状态字段则记录了当前分区的健康状态可能的值包括ESP_OTA_IMG_NEW、ESP_OTA_IMG_PENDING_VERIFY、ESP_OTA_IMG_VALID、ESP_OTA_IMG_INVALID等。当你调用esp_ota_mark_app_valid_cancel_rollback()时系统会把当前分区的状态改为ESP_OTA_IMG_VALID同时把另一个分区的状态标记为ESP_OTA_IMG_ABORTED。这样下次 OTA 时系统就知道哪个分区是安全的、哪个是可以覆盖的。如果你在调试过程中想手动查看 otadata 的内容可以用esptool.py read_flash把 otadata 分区读出来然后用十六进制工具分析。不过大多数情况下直接用esp_ota_get_state_partition()函数查询状态就够了。4. 完整 OTA 升级流程与回滚触发实测4.1 从服务器推送固件到设备重启的完整链路我把整个 OTA 流程拆成六个阶段每个阶段都有明确的输入和输出方便你在自己的项目里对照实现。第一阶段是固件打包。在 ESP-IDF 里idf.py build会生成一个.bin文件这个文件包含了引导头、分区表和应用程序。你需要把这个文件放到 HTTP 服务器或者对象存储上并记录它的版本号和 MD5 校验值。我通常会在服务器上维护一个 JSON 文件里面列出最新版本号、下载地址和校验值设备启动后先请求这个 JSON对比版本号决定是否升级。第二阶段是设备端检查更新。设备连接网络后向服务器发送当前固件版本号服务器返回最新版本信息。如果版本号不同设备就进入升级流程。这一步要注意加超时和重试机制网络不稳定的时候不能一直卡在这里。第三阶段是下载固件。设备用 HTTP 或 HTTPS 分块下载固件每下载一块就调用esp_ota_write()写入 Flash。这里的关键是不要一次性把整个固件读到内存里ESP32 的 RAM 只有几百 KB大固件根本放不下。我一般用 4KB 的缓冲区边下边写内存占用很小。第四阶段是校验和切换分区。下载完成后调用esp_ota_end()校验固件完整性然后调用esp_ota_set_boot_partition()切换启动分区。这两个操作都很快通常几十毫秒就能完成。第五阶段是重启并验证。设备重启后从新分区启动应用程序执行初始化确认关键功能正常后调用esp_ota_mark_app_valid_cancel_rollback()。如果一切顺利新固件就正式生效了。第六阶段是回滚处理。如果新固件启动失败bootloader 会自动切回旧分区。旧分区的固件启动后会发现自己被“复活”了此时应该上报一条回滚事件到服务器方便你排查问题。4.2 模拟固件崩溃并观察自动回滚光看代码不过瘾我实际做了一次破坏性测试把整个过程记录下来。我准备了两块相同的 ESP32 开发板一块烧录正常固件作为对照另一块用来测试回滚。测试固件里我故意在setup()里加了一个死循环void setup() { Serial.begin(115200); Serial.println(新固件启动); // 故意制造崩溃不调用 esp_ota_mark_app_valid_cancel_rollback() while (true) { delay(1000); Serial.println(卡在新固件里...); } }然后通过 OTA 把这份固件推送到设备。设备下载完成后重启串口输出显示它确实从 ota_1 分区启动了但因为没有调用确认函数otadata 里的状态一直是ESP_OTA_IMG_NEW。我手动按了一下复位键设备再次重启。这次 bootloader 把状态改成了ESP_OTA_IMG_PENDING_VERIFY但仍然从 ota_1 启动。串口里还是那行“卡在新固件里...”。我又按了一次复位键。第三次重启时bootloader 判定新固件连续两次未确认自动把启动分区切回了 ota_0。串口输出变成了旧固件的启动日志设备恢复了正常功能。整个过程用了不到十秒三次重启自动完成回滚。我完全没有碰任何接线也没有手动进下载模式。这就是双分区加自动回滚的威力。4.3 回滚过程中的串口日志分析为了让你更直观地理解回滚过程我把关键日志片段整理出来# 第一次从新分区启动 I (30) boot: Loaded app from partition at offset 0x191000 I (33) boot: Set actual ota_seq2 in otadata[0] I (45) app_main: 新固件启动 I (1045) app_main: 卡在新固件里... # 第二次重启状态变为 PENDING_VERIFY I (30) boot: Loaded app from partition at offset 0x191000 I (33) boot: Set actual ota_seq2 in otadata[0] I (45) app_main: 新固件启动 I (1045) app_main: 卡在新固件里... # 第三次重启触发回滚 I (30) boot: Loaded app from partition at offset 0x11000 I (33) boot: Set actual ota_seq1 in otadata[0] I (45) app_main: 旧固件启动 I (1045) app_main: 系统正常运行从日志里可以清楚地看到bootloader 在第三次启动时把加载地址从0x191000换成了0x11000也就是从 ota_1 切回了 ota_0。同时 otadata 里的序列号也从 2 变回了 1。这里有一个细节值得注意回滚之后ota_1 分区里的固件并没有被删除它只是被标记为“不可启动”。下次 OTA 时系统会优先选择 ota_1 作为写入目标因为它的序列号更低。这个设计很巧妙既保留了回滚能力又不会浪费 Flash 空间。5. 常见问题排查与避坑经验实录5.1 OTA 升级失败的高频原因速查表在实际项目中OTA 失败的原因五花八门我整理了一份速查表覆盖了八成以上的常见问题现象可能原因排查方法解决方案下载到一半卡死网络不稳定或服务器限速查看串口日志中的 HTTP 状态码增加超时重试分块下载写入 Flash 报错分区大小不够检查固件体积和分区表扩大 ota 分区或裁剪固件重启后仍从旧分区启动otadata 未正确写入读取 otadata 分区内容检查esp_ota_set_boot_partition返回值新固件启动后立即回滚未调用确认函数查看串口是否有确认日志在初始化完成后调用确认函数回滚后旧固件也起不来旧分区被意外覆盖检查分区表和写入地址确保 OTA 只写入备用分区固件校验失败下载数据损坏对比服务器和设备的 MD5增加 HTTPS 和校验机制这张表里的每一行都是我实际踩过的坑。比如“回滚后旧固件也起不来”这一条我曾经因为分区表偏移写错导致 OTA 写入时覆盖了 ota_0 分区结果两个分区都废了。后来我养成了一个习惯每次修改分区表之后先用esptool.py read_flash把整个 Flash 读出来备份确认分区布局无误再烧录。5.2 那些官方文档不会告诉你的实操心得第一个心得是关于确认函数的调用时机。官方示例通常把esp_ota_mark_app_valid_cancel_rollback()放在app_main()的开头但这其实有风险。如果你的固件启动后需要连接 Wi-Fi、同步时间、初始化传感器这些操作可能需要几秒钟甚至更久。如果在这段时间内设备因为电源波动重启了而确认函数已经调用过了系统就会认为新固件是好的不会回滚。但实际上固件可能根本没完成初始化。我的做法是把确认函数放在所有关键初始化之后并且加一个“健康检查”逻辑。比如设备需要连接 MQTT 服务器那就等 MQTT 连接成功、订阅完成、收到第一条消息之后再确认。这样虽然回滚的判定时间变长了但准确性高得多。第二个心得是关于电源的。OTA 写入 Flash 的时候电流波动比较大如果电源质量不好很容易导致写入失败甚至设备重启。我在一个项目里遇到过 OTA 成功率只有 70% 的情况后来换了一个输出电流更大的电源适配器成功率直接拉到 99% 以上。所以如果你在做量产设备电源设计一定要留足余量最好在 OTA 期间关闭其他高功耗外设。第三个心得是关于版本号的。很多团队用简单的递增整数作为版本号比如 1、2、3。这种做法在单设备上没问题但如果你有多个设备、多个固件分支很快就会乱套。我建议用语义化版本号比如1.2.3并且在固件里嵌入编译时间戳。这样即使版本号相同也能通过时间戳判断哪个更新。5.3 如何验证回滚机制真的生效了写完代码不代表万事大吉你必须实际测试回滚机制。我通常用三种方法验证第一种是“死循环测试”就是前面演示的那样在新固件里故意不调用确认函数观察设备是否会自动回滚。这种方法最直接但需要物理接触设备来按复位键。第二种是“看门狗测试”在新固件里故意触发看门狗复位模拟固件崩溃。ESP32 的看门狗默认在 5 秒内没有喂狗就会复位你可以用esp_task_wdt_init()配置更短的超时时间。这种方法更接近真实场景因为现场固件崩溃往往就是看门狗触发的。第三种是“断电测试”在 OTA 写入过程中直接拔掉电源然后重新上电观察设备是否能从旧分区启动。这种测试最残酷但也最能暴露问题。我建议在量产前至少做十次断电测试确保任何时间点断电都不会导致设备变砖。5.4 关于 Flash 寿命的一点提醒双分区方案会频繁擦写 Flash而 Flash 的擦写次数是有限的。普通的 NOR Flash 大概能擦写 10 万次左右听起来很多但如果你每天 OTA 一次十年也就三千多次完全在安全范围内。真正需要注意的是 otadata 分区它每次启动都会写入状态频率比 app 分区高得多。不过乐鑫在设计的时候已经考虑到了这一点。otadata 的写入是均衡的两个ota_select结构体交替使用而且只有在状态变化时才会真正写入。正常使用情况下otadata 的寿命足够支撑设备运行十年以上。如果你实在担心可以在 NVS 里记录 OTA 次数超过一定阈值就提醒用户更换设备。6. 从双分区延伸到更可靠的固件更新策略双分区加自动回滚解决的是“刷坏了能回来”的问题但它没有解决“怎么知道刷坏了”的问题。在实际项目中我还会加一层应用层的健康检查。比如设备启动后主动向服务器发送心跳如果服务器在预期时间内没有收到心跳就认为设备异常下次设备上线时强制推送旧版本固件。另外对于部署在偏远地区或者网络不稳定的设备我会建议加入“差分升级”的支持。差分升级只传输新旧固件之间的差异部分体积可能只有完整固件的十分之一大大降低了下载失败的概率。ESP-IDF 从 v4.3 开始支持差分 OTA配合esp_delta_ota组件使用效果很好。还有一个容易被忽视的点是固件加密。如果你的设备涉及商业机密或者用户隐私固件在 Flash 里应该是加密存储的。ESP32 支持 Flash 加密和安全启动可以在 eFuse 里烧录密钥防止固件被读取或篡改。但要注意一旦启用了 Flash 加密OTA 升级的固件也必须是加密的否则 bootloader 会拒绝加载。我在实际使用中发现双分区加自动回滚这套机制最大的价值不是技术本身而是它改变了团队的开发节奏。以前发版要挑时间、要有人值守、要准备回滚方案现在可以随时发、随时回心理负担小了很多。当然这不意味着你可以随便发未经测试的固件该做的单元测试、集成测试一个都不能少。回滚只是最后一道防线不是第一道。最后再分享一个小技巧在 otadata 里除了系统自动管理的字段你还可以在 NVS 里存一个自定义的“升级日志”记录每次 OTA 的时间、版本号、结果。这样设备出问题的时候你只要读一下 NVS 就能知道它经历过什么比翻串口日志方便得多。
返回列表