news 2026/10/6 15:35:55

ESP32 OTA双分区与自动回滚实战:让设备自己救自己

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32 OTA双分区与自动回滚实战:让设备自己救自己

1. 从一次刷机翻车说起:ESP32 到底会不会变砖

很多人第一次给 ESP32 刷固件,手都是抖的。尤其是用 USB-TTL 接上开发板、敲下esptool.py write_flash回车的那一刻,脑子里想的全是“万一刷坏了怎么办”。我当年也是这么过来的,第一次把一块 ESP32-WROOM-32 刷到一半拔了线,重新上电后串口一片乱码,当时真以为板子废了。

先说结论:ESP32 在绝大多数情况下不会真正变砖。它和某些一次性烧录的芯片不一样,ESP32 的 Bootloader 固化在芯片内部的 ROM 里,这段 ROM 代码是出厂就写死的,你刷固件刷的是外部 Flash 里的内容,动不了 ROM。只要 ROM 还在,芯片就能进入下载模式,就能重新烧录。真正意义上的“砖”,只有极少数情况才会出现,比如把 eFuse 里的加密位、下载禁用位烧错了,或者供电异常导致 Flash 物理损坏。

但“不会变砖”不等于“不会出事”。固件刷坏之后,设备可能起不来、可能反复重启、可能连不上 WiFi,对于已经部署到现场、装在天花板或者配电箱里的设备来说,跑一趟现场重新插线烧录的成本,远比“砖”这个字本身更让人头疼。所以真正要解决的问题不是“会不会变砖”,而是怎么让设备在固件出问题时自己救自己。

这就是标题里“双分区”和“自动回滚”要干的事。ESP32 的 OTA 机制天生支持多分区,配合 ESP-IDF 里的回滚逻辑,可以让设备在新固件启动失败时自动退回上一个能用的版本。我这篇文章就把这套东西从头到尾拆一遍:分区表怎么设计、OTA 状态怎么标记、回滚在什么条件下触发、代码怎么写、坑在哪里。看完你就能给自己的项目加上这套“后悔药”。

2. 双分区与自动回滚的整体设计思路

2.1 为什么单分区 OTA 是个危险动作

先说说最朴素的 OTA 做法:设备只有一个 app 分区,新固件下载下来直接覆盖旧的。这个方案在 demo 里跑得通,但放到真实产品里就是定时炸弹。原因很简单——覆盖是不可逆的。如果新固件下载过程中断电,或者新固件本身有 bug 起不来,那旧固件已经被擦掉了,设备就卡在“既没有新固件能跑,也没有旧固件可退”的状态。

我见过不少团队在项目初期图省事用单分区,结果量产之后每次发版都提心吊胆。有一次一个客户的项目,设备装在户外灯杆上,一次 OTA 推了个有内存泄漏的固件,设备跑两天就死机重启,最后只能派人爬杆子插串口线。那次之后他们才老老实实改成双分区。

双分区的核心思想是新旧固件同时存在。Flash 里划出两个 app 分区,比如ota_0和ota_1,当前运行的在ota_0,新固件写到ota_1,写完之后把启动目标切到ota_1再重启。如果ota_1跑不起来,Bootloader 还能回到ota_0。旧固件始终没被破坏,这就是“后悔药”的物理基础。

2.2 自动回滚靠的是什么机制

光有两个分区还不够,还得有人判断“新固件到底行不行”。ESP-IDF 提供了一套OTA 状态机,配合esp_ota_mark_app_valid_cancel_rollback()这个接口来工作。逻辑是这样的:

新固件第一次启动时,Bootloader 会把它标记为“待验证”状态。此时如果设备重启,Bootloader 会认为这个固件没通过验证,自动切回上一个分区。只有当新固件在代码里主动调用esp_ota_mark_app_valid_cancel_rollback(),告诉系统“我跑起来了,没问题”,这个固件才会被标记为有效,回滚状态才会被取消。

这个设计非常巧妙。它把“固件是否健康”的判断权交给了固件自己。你可以在调用这个接口之前做各种自检:WiFi 能不能连上、传感器能不能读到数据、关键任务有没有正常启动。任何一项不通过,你就不调用,让设备重启,Bootloader 自然帮你回滚。

注意:回滚的触发条件是“新固件启动后没有标记有效就发生了重启”。所以如果你在自检里发现异常,正确的做法是主动调用esp_restart(),而不是死循环卡在那里。

2.3 分区表怎么规划才合理

分区表是整个方案的地基,规划不好后面全是坑。一个典型的支持双分区 OTA 的分区表长这样:

# 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, 0x20000, 0x180000, ota_1, app, ota_1, 0x1A0000, 0x180000,

几个关键点值得说清楚。otadata分区是 OTA 状态机的存储位置,它记录当前该从哪个 app 分区启动、哪个分区是待验证状态。这个分区不大,8KB 足够,但绝对不能省。ota_0和ota_1大小必须一致,而且都要能装下你最大的固件。我一般会留 20% 余量,因为固件会随着功能迭代慢慢变大,分区划得太紧后面改起来很痛苦。

Flash 总容量决定了你能怎么划。4MB 的 Flash 划两个 1.5MB 的 app 分区基本就到顶了,如果你的固件超过 1.5MB,就得考虑换 8MB 或 16MB 的模组。我实测过一个带 WiFi、MQTT、JSON 解析和 OTA 功能的固件,编译出来大概 1.1MB,所以 1.5MB 分区对中等复杂度的项目是够用的。

3. 核心细节解析与实操要点

3.1 otadata 分区的状态流转

otadata分区里存的是两个esp_ota_select_entry_t结构,每个 4KB,轮流写入。每个 entry 记录了ota_seq(序列号)、ota_state(状态)和 CRC 校验。状态主要有几种:ESP_OTA_IMG_NEW、ESP_OTA_IMG_PENDING_VERIFY、ESP_OTA_IMG_VALID、ESP_OTA_IMG_INVALID、ESP_OTA_IMG_ABORTED。

新固件刚写完时是NEW,第一次启动后变成PENDING_VERIFY。这时候如果重启,Bootloader 读到PENDING_VERIFY就会判定验证失败,切回另一个分区,并把失败的分区标记为ABORTED。如果固件调用了确认接口,状态变成VALID,回滚取消。

理解这个流转很重要,因为很多“回滚没生效”的问题,根源都在状态没搞对。比如你在新固件里调用了确认接口,但调用时机太早——WiFi 还没连上就确认了——那后面 WiFi 挂了也不会回滚,因为系统认为这个固件已经有效了。

3.2 确认接口的调用时机

这是整个方案里最需要动脑子的地方。esp_ota_mark_app_valid_cancel_rollback()调早了,回滚形同虚设;调晚了,设备可能因为自检逻辑本身有 bug 而反复回滚。

我的经验是分两层自检。第一层是硬性自检,在app_main启动后尽快做,比如关键外设初始化、NVS 读写、看门狗喂狗是否正常。这些是固件能不能跑的基本条件,不通过就直接重启。第二层是业务自检,比如 WiFi 连接、服务器握手、传感器数据有效性,这些可以给一个超时窗口,比如 30 秒内必须完成,完不成就重启触发回滚。

具体代码大概长这样:

void app_main(void) { // 硬性自检 esp_err_t ret = nvs_flash_init(); if (ret != ESP_OK) { esp_restart(); } // 启动业务逻辑 xTaskCreate(business_task, "biz", 4096, NULL, 5, NULL); // 等待业务自检结果,最多 30 秒 EventBits_t bits = xEventGroupWaitBits(s_biz_event, BIZ_READY_BIT, pdFALSE, pdTRUE, pdMS_TO_TICKS(30000)); if (bits & BIZ_READY_BIT) { // 自检通过,确认固件有效 esp_ota_mark_app_valid_cancel_rollback(); } else { // 自检失败,重启触发回滚 esp_restart(); } }

提示:esp_ota_mark_app_valid_cancel_rollback()只需要调用一次,重复调用不会报错但也没意义。建议在确认成功后打个日志,方便现场排查。

3.3 写入新固件时的注意事项

OTA 写入过程本身也有讲究。esp_ota_begin()之后,你要分块调用esp_ota_write(),最后esp_ota_end()。这里有几个坑:

第一,写入前必须确保新固件大小不超过目标分区。esp_ota_begin()会检查,但如果你的分区表划得不合理,可能到写入一半才发现空间不够。我一般会在下载前先读一下 HTTP 头里的Content-Length,和分区大小比一下,超了直接放弃。

第二,写入过程中要喂看门狗。OTA 写 Flash 是耗时操作,如果任务优先级设置不当,可能触发看门狗复位。建议把 OTA 任务优先级设高一点,或者在写入循环里定期vTaskDelay(1)。

第三,写完必须校验。esp_ota_end()会做镜像校验,但如果你下载的固件本身在传输中损坏了,校验会失败。这时候不要切换启动分区,保持当前固件继续运行,等下次重试。

4. 完整实操流程与关键环节实现

4.1 环境准备与分区表配置

我用的环境是 ESP-IDF v5.1,Arduino 环境下也能做但分区表配置没那么灵活,建议还是用 IDF。先创建工程:

idf.py create-project ota_rollback_demo cd ota_rollback_demo

然后在工程根目录新建partitions.csv,内容就是前面那个分区表。接着在menuconfig里配置:

idf.py menuconfig

进入Partition Table菜单,把Partition Table设为Custom partition table CSV,文件名填partitions.csv。再进入Component config -> ESP System Settings,确认Bootloader config里的Bootloader log verbosity至少是Info,方便看回滚日志。

4.2 OTA 下载与写入的代码实现

我用的是 HTTP OTA,从服务器拉固件。核心代码如下:

esp_err_t do_ota_update(const char *url) { esp_http_client_config_t config = { .url = url, .timeout_ms = 10000, }; esp_http_client_handle_t client = esp_http_client_init(&config); esp_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { esp_http_client_cleanup(client); return err; } int content_len = esp_http_client_fetch_headers(client); if (content_len <= 0) { esp_http_client_cleanup(client); return ESP_FAIL; } const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); if (content_len > update_partition->size) { esp_http_client_cleanup(client); return ESP_ERR_INVALID_SIZE; } esp_ota_handle_t ota_handle; err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle); if (err != ESP_OK) { esp_http_client_cleanup(client); return err; } char buf[1024]; int total_read = 0; while (total_read < content_len) { int read_len = esp_http_client_read(client, buf, sizeof(buf)); if (read_len <= 0) break; err = esp_ota_write(ota_handle, buf, read_len); if (err != ESP_OK) { esp_ota_abort(ota_handle); esp_http_client_cleanup(client); return err; } total_read += read_len; vTaskDelay(1); // 喂狗 } esp_http_client_close(client); esp_http_client_cleanup(client); if (total_read != content_len) { esp_ota_abort(ota_handle); return ESP_FAIL; } err = esp_ota_end(ota_handle); if (err != ESP_OK) return err; err = esp_ota_set_boot_partition(update_partition); if (err != ESP_OK) return err; esp_restart(); return ESP_OK; }

这段代码里esp_ota_get_next_update_partition(NULL)会自动返回当前没在运行的那个 app 分区,省得你自己算。esp_ota_set_boot_partition()把启动目标切过去,然后重启,新固件就开始跑了。

4.3 回滚验证的实测过程

为了验证回滚真的有效,我故意做了一个“坏固件”:在app_main里直接进入死循环,不调用确认接口。烧录流程是这样的:

先烧一个正常固件到ota_0,确认设备能正常跑。然后通过 OTA 推一个坏固件到ota_1。设备重启后跑坏固件,因为没调确认接口,看门狗超时触发重启。Bootloader 检测到ota_1处于PENDING_VERIFY状态且发生了重启,判定验证失败,自动切回ota_0。

串口日志大概是这样:

I (30) boot: Loaded app from partition at offset 0x1A0000 I (30) boot: Set actual ota_seq=2 ... I (5000) boot: ota_1 app is pending verify I (5000) boot: Rollback to ota_0 I (5000) boot: Loaded app from partition at offset 0x20000

看到Rollback to ota_0这行日志的时候,心里那块石头就落地了。整个过程不需要人工干预,设备自己完成了救援。

4.4 分区大小与 Flash 容量的匹配计算

这里补充一个实际项目里的计算过程。假设你用 ESP32-WROOM-32E,4MB Flash。Bootloader 占 0x8000,分区表占 0x1000,NVS 给 0x5000,otadata 给 0x2000,phy_init 给 0x1000。这些加起来大概 0x11000,也就是 68KB。剩下约 4MB - 68KB ≈ 3.93MB 给两个 app 分区,每个约 1.96MB。

但实际不能这么满打满算,因为 Flash 末尾通常还要留一点给文件系统或者参数存储。我一般会留 256KB 给 SPIFFS 或 LittleFS,这样每个 app 分区大概 1.8MB。如果你的固件编译出来超过 1.8MB,就得考虑换 8MB Flash 的模组,或者裁剪功能。

注意:分区表里的 offset 必须 4KB 对齐,否则idf.py build会报错。我见过有人把 offset 写成 0x21000 这种非对齐值,编译直接失败。

5. 常见问题与排查技巧实录

5.1 回滚没生效的几种典型情况

情况一:确认接口调用太早。有人在app_main第一行就调了确认接口,结果后面 WiFi 连不上也不回滚。解决办法是把确认接口放到业务自检通过之后。

情况二:otadata 分区被擦除。如果你在代码里调了nvs_flash_erase()或者手动擦了整个 Flash,otadata 里的状态就丢了,Bootloader 会按默认分区启动,回滚逻辑失效。解决办法是擦 Flash 时避开 otadata 区域。

情况三:分区表里没有 otadata。有些人为了省空间把 otadata 删了,结果 OTA 状态机没法工作,回滚自然不生效。otadata 这 8KB 绝对不能省。

情况四:新固件启动后没重启,而是卡死。回滚的触发条件是“重启”,如果固件卡在死循环里连看门狗都不触发,Bootloader 没机会介入。解决办法是确保看门狗正常工作,或者在自检失败时主动esp_restart()。

5.2 OTA 下载失败的排查思路

OTA 下载失败的原因很多,我整理了一个速查表:

现象可能原因排查方法
连接超时服务器地址错、网络不通ping 服务器、检查 URL
返回 404固件路径错浏览器直接访问 URL
写入一半失败Flash 空间不足检查分区大小和固件大小
校验失败固件损坏对比服务器和本地文件 MD5
重启后回滚新固件自检没过看串口日志确认自检哪步失败

我遇到最多的是“写入一半失败”,基本都是分区划小了。有一次一个项目固件 1.6MB,分区只给了 1.5MB,esp_ota_begin没报错,写到 1.5MB 的时候esp_ota_write返回ESP_ERR_OTA_VALIDATE_FAILED,查了半天才发现是空间不够。

5.3 现场部署的避坑经验

设备一旦部署到现场,OTA 就成了远程操作,出问题的代价很高。我总结了几条经验:

第一,首次 OTA 一定要留后路。新设备出厂时烧的固件要确保能正常 OTA,并且分区表要支持回滚。我见过出厂固件分区表就是错的,后面想 OTA 都 OTA 不了。

第二,OTA 推送要分批。不要一次性把所有设备都推新固件,先推 5% 观察一天,没问题再全量。ESP32 的回滚虽然可靠,但批量出问题的时候,回滚日志能把你淹了。

第三,保留串口日志输出。即使设备部署了,也要留 UART 测试点,万一出问题能接串口看日志。纯靠 WiFi 日志有时候不够,因为网络本身可能就有问题。

第四,otadata 状态要能远程查询。我在固件里加了一个接口,可以返回当前运行的分区和 OTA 状态,这样远程就能判断设备是不是回滚过。

5.4 关于固件加密与安全启动的补充

有些项目会用到 Flash 加密和安全启动,这两个功能会影响 OTA 和回滚。Flash 加密之后,OTA 写入的固件必须是加密的,否则 Bootloader 解不开。安全启动则要求固件签名,签名不对直接拒绝启动,连回滚机会都没有。

如果你要用这两个功能,建议先在开发板上把 OTA 和回滚跑通,再开加密和签名。因为一旦开了,烧录和调试都会变麻烦,出问题排查成本很高。我一般建议中小项目先不上加密,等产品成熟了再考虑。

6. 写在最后的一些个人体会

这套双分区加自动回滚的方案,我从 2019 年用到现在,经手的项目少说也有十几个,真正因为固件问题需要现场救援的次数,从之前的每年好几次降到了零。不是说固件就不出 bug 了,而是出了 bug 设备自己能退回去,用户甚至感知不到。

最让我印象深刻的一次,是一个农业大棚里的环境监测项目,设备装在棚顶,夏天棚内温度能到 50 度。有一次推了个新固件,结果 WiFi 驱动在高温下不稳定,设备连不上服务器。因为加了回滚,设备重启两次之后自动退回了旧固件,继续正常上报数据。后来我们分析日志才发现是驱动问题,修好之后重新推,全程没派人去现场。

如果你正在做 ESP32 的 OTA 功能,我强烈建议把双分区和回滚加上。多花半天时间配置分区表和写自检逻辑,能省掉后面无数个提心吊胆的夜晚。分区表规划的时候宁可多留点余量,确认接口的调用时机多想想业务场景,看门狗一定要确保正常工作。这几件事做到位,ESP32 就真的很难变砖了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 15:35:55

高精度变形监测实战:从基准点稳定到预警判读的全链路解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 15:35:19

DW06/DW07锂电保护IC原理与SOT-23-6实战设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 15:34:06

Vivado与VCS联合仿真实战:从库编译到Verdi调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 15:33:24

超宽输入电压PWM控制器VPC2187B/VPC2188B的设计实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 15:31:43

从引脚到建链:QSFP28光模块调试实战笔记

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 15:30:02

DeepSeek长文本处理实战:电子病历分析落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华