尝试使用IoT(第17篇:深入解析ESP32 OTA基础:从闪存布局到运行机制)
Back to Top
为了覆盖更广泛的受众,这篇文章已从日语翻译而来。
您可以在这里找到原始版本。
前回中,我们使用ESP32挑战了“OTA(Over The Air)”。
虽然顺序前后有所调整,但这次将再次详细解说OTA的基本机制。
简介
#ESP32标准支持通过无线方式重写程序的“Over The Air(OTA)”功能。
在本文中,我们将深入探讨闪存布局和OTA的运行机制,解答“为什么需要两个应用区?”、“如何选择并启动更新后的应用?”等疑问。
支撑 OTA 的机制
#闪存的分区结构
#ESP32的闪存通过“分区表”进行管理。
分区是闪存上区域的“划分”。
在 PlatformIO 中指定使用“Arduino框架”时,分区表信息包含在以下目录:
<用户文件夹路径>\.platformio\packages\framework-arduinoespressif32\tools\partitions
上述文件夹中存放有 default.csv
文件。
该文件中包含默认选用的分区表信息。
在我的环境中,其设置如下。
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 |
各分区的作用如下:
- nvs:NVS 存储(Non-Volatile Storage)。用于保存配置和用户数据等的持久化存储区,断电后数据依然保留。
- otadata:管理 OTA(Over-The-Air)更新的状态。指示应启动哪一个应用区(ota_0 或 ota_1)。
- app0 / app1:用于 OTA 更新的应用区域。OTA 更新时,app0 和 app1 交替使用,一方运行时,另一方作为更新目标。
- spiffs:使用 SPIFFS(Serial Peripheral Interface Flash File System)的文件存储区,用于保存配置文件、日志等数据。
- coredump:用于保存核心转储的区域。在系统故障(如崩溃)发生时保存内存转储,以便后续故障分析。
此外,闪存的内存映射如下所示。
为支持 OTA,应用区域准备了两个(app0 和 app1),并交替写入使用。
各区域将写入对应的应用或数据。
注意,通过 OTA 写入新应用时,会将其写入当前运行区域之外的另一个插槽(app0 或 app1),以便在失败时触发回退至前一状态的故障保护机制。
开始地址 | 结束地址 | 大小 | 名称 | 类型 | 子类型 | 备注 |
---|---|---|---|---|---|---|
0x00000000 | 0x00007FFF | 0x8000 | bootloader | - | - | 引导加载程序 |
0x00008000 | 0x00008FFF | 0x1000 | partition | - | - | 分区表 |
0x00009000 | 0x0000DFFF | 0x5000 | nvs | data | nvs | NVS 存储 |
0x0000E000 | 0x0000FFFF | 0x2000 | otadata | data | ota | OTA 数据 |
0x00010000 | 0x0014FFFF | 0x140000 | app0 | app | ota_0 | OTA 插槽 0 |
0x00150000 | 0x0028FFFF | 0x140000 | app1 | app | ota_1 | OTA 插槽 1 |
0x00290000 | 0x003EFFFF | 0x160000 | spiffs | data | spiffs | SPIFFS 文件系统 |
0x003F0000 | 0x003FFFFF | 0x10000 | coredump | data | coredump | 核心转储区域 |
(注:整个 Flash 通常假设为 4MB(= 0x400000))
在包含 Arduino 框架的 ESP32 开发环境中,分区表文件(partition-table.bin)默认始终写入到闪存的 0x8000。
引导加载程序的工作流程
#本文基于 Arduino 框架,ESP32 启动时,首先运行 ROM 内置的一级引导加载程序。
然后读取闪存中写入的用户自定义 bootloader.bin 并继续后续处理。
- 检查 UART 引导(开发模式)
首先确认是否有通过 UART(串口)发起的写入模式请求。如果检测到来自 PC 的写入请求,则进入固件刷写流程。 - 初始化闪存并读取分区表
闪存起始处(0x0000)已写入引导加载程序,引导加载程序会读取位于 0x8000 的分区表,并执行应用程序的启动流程。 - 读取 otadata(OTA 配置时)
从 otadata 分区(默认地址 0xE000)读取最后一次正常启动的应用信息以及下次应启动的应用信息。 - 决定合适的应用区域
根据读取的信息,通常选择 ota_0 或 ota_1 中的一个应用区域。 - 回退(替代)处理
如果 otadata 中无有效信息,或应用区域验证失败,将执行以下回退操作:- 如果存在 factory 分区(见后文),则启动该分区。
- 否则视为引导失败,停止或复位。
- 加载并执行应用
将选定的应用从闪存区域加载到 RAM 并开始执行。
关于 factory 分区的补充:
- 在 Arduino 中通常不使用 factory,但在不启用 OTA 功能的配置中,可以仅使用 factory 分区。
- factory 分区作为默认应用程序运行。
- 在 OTA 配置中,推荐使用 ota_0 / ota_1 的双分区结构,有时可省略 factory。
OTA 流程
#ESP32 的 OTA(Over-The-Air)更新按以下步骤进行。
- 接收新的固件
通过 Wi-Fi 或 HTTP 接收新的 .bin 文件(固件)。 - 确定写入目标
选择当前未运行的应用区域(例如当前运行于 ota_0,则选择 ota_1),并将固件写入该区域。 - 写入完成后更新 otadata
写入成功后,会更新 otadata 分区,使下次启动时使用新的区域。 - 重启并运行新固件
设备重启后,将使用新固件启动。 - 启动失败时的回滚
如果启动后检测到问题,系统会自动回滚到之前正常的固件区域。
为什么需要两个插槽?
#OTA 更新是一项“可能失败”的操作。由于断电或网络中断等原因,可能只能写入部分二进制,此时若保留了之前的应用,就能进行恢复。
如此通过“保留当前应用,同时写入新应用”的冗余方式,提升了 OTA 的可靠性。
这是固件更新中极为重要的设计理念。
常见误解与故障示例
#“factory 分区未使用” 问题
#一旦执行 OTA 后,之后基本在 ota_0 和 ota_1 之间切换。
factory 分区仅作为“首次启动”或“紧急恢复”用途。
“分区不足” 问题
#使用 OTA 时,需要为**两个 OTA 插槽(ota_0 和 ota_1)**预留区域。
如果区域不足,就无法写入 OTA 更新固件,会报错。
“启动循环” 问题
#即使写入了新固件,启动失败也可能陷入重启循环。
为防止这种情况,建议在设计中加入应用验证和回滚处理。
以下 ESP-IDF 函数非常有效。(该函数也可以在 Arduino 框架中使用)
esp_ota_mark_app_valid_cancel_rollback();
在应用能够判断“正常启动并运行”的时机调用上述函数,可清除回滚标记,并保证之后的启动继续使用当前固件。
但需满足以下条件。
- 分区表需支持 OTA(包含 ota_0 / ota_1)
使用 default_ota.csv 或自定义包含两个插槽。 - 在用 esp_ota_set_boot_partition() 等函数修改后,需要重启
在应用启动后的验证阶段需调用 esp_ota_mark_app_valid_cancel_rollback()。
示例程序
在 前回 创建的程序中,尝试集成了 esp_ota_mark_app_valid_cancel_rollback()
。
添加的源码如下两处。
#include "esp_ota_ops.h"
if (WiFi.waitForConnectResult() == WL_CONNECTED) {
esp_ota_mark_app_valid_cancel_rollback();
Serial.println("Marked as valid, rollback canceled");
}
集成回滚处理的完整程序如下。
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Update.h>
#include <ArduinoOTA.h>
#include "esp_ota_ops.h"
const char* ssid = "YOUR_SSID"; // 请根据各自的 Wi-Fi 环境指定 SSID
const char* password = "YOUR_PASSWORD"; // 请根据各自的 Wi-Fi 环境指定密码
WebServer server(80);
const char* upload_html = R"rawliteral(
<form method='POST' action='/update' enctype='multipart/form-data'>
<input type='file' name='update'>
<input type='submit' value='Update'>
</form>
)rawliteral";
// 更新成功后重定向的 HTML
const char* update_success_html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="5; url=/" />
</head>
<body>
<h1>Update Successful! Rebooting...</h1>
<p>You will be redirected to Home page in 5 seconds.</p>
</body>
</html>
)rawliteral";
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("WiFi connected");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
// 建议在 Wi-Fi 连接完成后等安全时机调用
if (WiFi.waitForConnectResult() == WL_CONNECTED) {
esp_ota_mark_app_valid_cancel_rollback();
Serial.println("Marked as valid, rollback canceled");
}
server.on("/", HTTP_GET, []() {
server.send(200, "text/html", upload_html);
});
server.on("/update", HTTP_POST, []() {
server.send(200, "text/html", update_success_html); // 返回更新成功后的重定向 HTML
delay(1000); // 用于显示消息的延迟时间
ESP.restart(); // 重置 ESP32
}, []() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.printf("Update: %s\n", upload.filename.c_str());
if (!Update.begin()) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) {
Serial.println("Update complete");
} else {
Update.printError(Serial);
}
}
});
ArduinoOTA.setPassword("admin"); // <- 与 platform.ini 中指定的 password 保持一致
ArduinoOTA.begin();
server.begin();
}
void loop() {
ArduinoOTA.handle();
server.handleClient();
}
程序启动后,将输出如下日志。
.WiFi connected
IP address: 192.168.0.65
Marked as valid, rollback canceled
面向实际应用的建议
#- 由于 OTA 会重写整个固件而非小补丁,因此需关注固件大小
- 为应对接收错误,需谨慎设计重试次数和超时设置
- 在应用中加入版本号,并对 OTA 对象与当前固件进行兼容性检查
- 为恢复出厂状态,需提供复位模式(如长按按钮)
默认以外的分区表
#最新的分区表信息可在以下 仓库 的 tools/partitions/
文件夹中查看。
文件名 | 内容 |
---|---|
minimal.csv |
最小配置(无 SPIFFS、无 OTA) |
no_ota.csv |
无 OTA 功能(仅一个应用) |
huge_app.csv |
针对大型应用(最大化应用区域) |
minimal_spiffs.csv |
含最小 SPIFFS(需要最小文件系统时) |
在 platformio.ini 中的指定方法
#若要使用默认分区表之外的分区表,在 platformio.ini 文件中进行如下设置:
(示例使用 huge_app.csv
)
board_build.partitions = huge_app.csv
之前在使用带相机的 ESP32 开发板(ESP32-WROVER-E)时,集成相机模块的程序体积相当大。
由于需要增大分区表中的应用区域,我不知情地使用了 huge_app.csv
,结果陷入了困境。
顺便提一下,huge_app.csv
的分区表如下所示。
Name | Type | SubType | Offset | Size | Flags |
---|---|---|---|---|---|
nvs | data | nvs | 0x9000 | 0x5000 | |
otadata | data | ota | 0xe000 | 0x2000 | |
app0 | app | ota_0 | 0x10000 | 0x300000 | |
spiffs | data | spiffs | 0x310000 | 0xE0000 | |
coredump | data | coredump | 0x3F0000 | 0x10000 |
该配置仅定义了 ota_0
,未定义 ota_1
,因此执行 OTA 会失败。
总结
#理解其机制后,ESP32 的 OTA 并不难。
关键在于“分区设计”和“错误恢复设计”。
若构建得当,将成为可稳定进行无线更新的强大系统。
希望对 IoT 的应用有所帮助。