基于ESP32S3与Nginx实现Arduino OTA无线升级方案

一、 系统概述与重要概念

目标:ESP32-S3设备上电后,从存储介质读取预设的Wi-Fi凭据,连接网络。在线状态下,定期从远程服务器检查版本文件。若发现新版本,则在后台自动下载固件至空闲的应用分区,完成写入后设置启动项并重启。新固件首次正常启动后,需进行有效性确认,防止错误回滚。

核心组件说明

  • Bootloader:芯片启动时运行的底层程序,负责根据 ota_data 分区中的信息,决定从哪个应用分区(Partition A 或 B)启动。分区切换的实际操作由它完成。
  • FreeRTOS:ESP32运行的实时操作系统。你的主要应用逻辑运行在 FreeRTOS 的任务中。本方案的OTA下载过程被封装在一个独立的、低优先级的后台任务中,因此不会阻塞您的主循环(loop)、中断服务程序(ISR)或定时器。

二、 Arduino项目代码实现

1. 头文件:OtaUpdater.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma once
#include <Arduino.h>
#include <WiFi.h>

// 请在主程序文件(main.ino)中定义以下常量
extern const char* OTA_VERSION_URL; // 例如 "http://192.168.137.200/ota/version.txt"
extern const char* OTA_BIN_URL; // 例如 "http://192.168.137.200/ota/firmware.bin"
extern const char* CURRENT_VERSION; // 例如 "1.0.0"

// 功能接口
void otaBeginBackgroundTask(); // 启动OTA后台检查任务
void otaCheckOnce(); // 触发一次立即检查(主线程调用)
bool otaIsBusy(); // 查询OTA任务是否正忙

2. 实现文件:OtaUpdater.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#include "OtaUpdater.h"
#include <HTTPClient.h>
#include <Update.h>
#include <esp_ota_ops.h>
#include <esp_ota.h>
#include <esp_system.h>

TaskHandle_t otaTaskHandle = NULL;
static volatile bool g_updatePending = false;
static volatile bool g_otaBusy = false;

#define OTA_BUFFER_SIZE 2048 // 下载缓冲区大小
#define OTA_CHECK_INTERVAL_MS 60000UL // 检查间隔(ms)

// 内部函数:HTTP GET获取字符串
static String httpGetString(const char* url) {
HTTPClient http;
http.begin(url);
int code = http.GET();
String payload = "";
if (code == HTTP_CODE_OK) {
payload = http.getString();
payload.trim();
}
http.end();
return payload;
}

// 主线程调用,仅设置检查标志
void otaCheckOnce() {
g_updatePending = true;
}

// OTA后台任务主函数
void otaTask(void* param) {
Serial.println("[OTA] Task started");
for (;;) {
if (!g_updatePending) {
vTaskDelay(1000 / portTICK_PERIOD_MS);
continue;
}
g_updatePending = false;

if (!WiFi.isConnected()) {
Serial.println("[OTA] WiFi 未连接,跳过检测");
continue;
}

g_otaBusy = true;
Serial.println("[OTA] 检查版本...");
String remoteVer = httpGetString(OTA_VERSION_URL);
if (remoteVer.length() == 0) {
Serial.println("[OTA] 获取版本失败或为空");
g_otaBusy = false;
continue;
}

Serial.printf("[OTA] 本地版本: %s 服务器版本: %s\n", CURRENT_VERSION, remoteVer.c_str());
if (remoteVer == String(CURRENT_VERSION)) {
Serial.println("[OTA] 已是最新版本");
g_otaBusy = false;
continue;
}

Serial.println("[OTA] 检测到新版本,准备下载固件...");
HTTPClient http;
http.begin(OTA_BIN_URL);
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[OTA] 固件下载请求失败 code=%d\n", httpCode);
http.end();
g_otaBusy = false;
continue;
}

int contentLen = http.getSize();
WiFiClient *stream = http.getStreamPtr();
Serial.printf("[OTA] 固件大小: %d bytes\n", contentLen);

// 获取下一个可用的OTA分区
const esp_partition_t* update_partition = esp_ota_get_next_update_partition(NULL);
if (!update_partition) {
Serial.println("[OTA] 没有可用的更新分区!");
http.end();
g_otaBusy = false;
continue;
}
Serial.printf("[OTA] 写入分区: %s\n", update_partition->label);

// 初始化更新
if (!Update.begin(contentLen)) {
Serial.println("[OTA] Update.begin 失败");
http.end();
g_otaBusy = false;
continue;
}

// 分块下载并写入Flash
uint8_t buf[OTA_BUFFER_SIZE];
int written = 0;
unsigned long lastPrint = millis();
while (written < contentLen) {
if (!http.connected()) {
Serial.println("[OTA] HTTP 连接中断");
break;
}
size_t available = stream->available();
if (available) {
size_t toRead = (available > OTA_BUFFER_SIZE) ? OTA_BUFFER_SIZE : available;
int r = stream->readBytes(buf, toRead);
if (r > 0) {
size_t w = Update.write(buf, r);
if (w != (size_t)r) {
Serial.println("[OTA] 写入 flash 长度不匹配");
break;
}
written += r;
}
} else {
vTaskDelay(10 / portTICK_PERIOD_MS); // 无数据时让出CPU
}
// 每2秒打印进度
if (millis() - lastPrint > 2000) {
Serial.printf("[OTA] 进度: %d / %d\n", written, contentLen);
lastPrint = millis();
}
}

bool success = false;
if (written == contentLen) {
if (Update.end(true)) { // true 表示进行CRC校验
Serial.println("[OTA] Update.end() 成功");
// 设置下次启动分区
esp_err_t err = esp_ota_set_boot_partition(update_partition);
if (err == ESP_OK) {
Serial.println("[OTA] 设置启动分区成功,重启系统以应用新固件");
success = true;
} else {
Serial.printf("[OTA] 设置启动分区失败 err=%d\n", err);
}
} else {
Serial.printf("[OTA] Update.end() 失败,err=%d\n", Update.getError());
}
} else {
Serial.printf("[OTA] 下载写入不完整 written=%d contentLen=%d\n", written, contentLen);
}
http.end();

if (success) {
vTaskDelay(500 / portTICK_PERIOD_MS);
esp_restart(); // 重启进入新固件
} else {
Serial.println("[OTA] 更新失败,保持当前分区运行");
}
g_otaBusy = false;
vTaskDelay(2000 / portTICK_PERIOD_MS);
}
}

// 启动OTA后台任务
void otaBeginBackgroundTask() {
if (otaTaskHandle != NULL) return;
xTaskCreatePinnedToCore(otaTask, "otaTask", 8192, NULL, 1, &otaTaskHandle, 1);
Serial.println("[OTA] 后台任务已创建");
}

// 查询OTA是否繁忙
bool otaIsBusy() {
return g_otaBusy;
}

3. 主程序示例:main.ino

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <Arduino.h>
#include <WiFi.h>
#include <Preferences.h> // 替代EEPROM,更可靠
#include "OtaUpdater.h"
#include <esp_ota_ops.h>
#include <esp_ota.h>

Preferences prefs;

// ==== 配置区域 ====
const char* OTA_VERSION_URL = "http://192.168.137.200/ota/version.txt";
const char* OTA_BIN_URL = "http://192.168.137.200/ota/firmware.bin";
const char* CURRENT_VERSION = "1.0.0"; // 发布新固件时务必更新此版本号
// ==================

// 从Preferences读取Wi-Fi配置 (可替换为您自己的EEPROM读取逻辑)
String loadWifiSSID() {
prefs.begin("wifi", true);
String s = prefs.getString("ssid", "");
prefs.end();
return s;
}
String loadWifiPass() {
prefs.begin("wifi", true);
String p = prefs.getString("pass", "");
prefs.end();
return p;
}

// 连接Wi-Fi
bool connectWiFiWithEEPROM(unsigned long timeout_ms = 15000UL) {
String ssid = loadWifiSSID();
String pass = loadWifiPass();
if (ssid.length() == 0) {
Serial.println("[WIFI] 无SSID配置");
return false;
}
Serial.printf("[WIFI] 连接至: %s\n", ssid.c_str());
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long start = millis();
while (millis() - start < timeout_ms) {
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[WIFI] 已连接,IP: %s\n", WiFi.localIP().toString().c_str());
return true;
}
delay(200);
}
Serial.println("[WIFI] 连接超时,进入离线模式");
return false;
}

// 新固件启动后标记为有效,防止回滚
void markRunningAppValid() {
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
if (err == ESP_OK) {
Serial.println("[OTA] 标记当前固件为有效,取消回滚");
} else {
Serial.printf("[OTA] 标记失败 err=%d\n", err);
}
}

unsigned long lastCheckMillis = 0;

void setup() {
Serial.begin(115200);
delay(100);

// 新固件首次稳定运行后,尽早调用以下函数
// markRunningAppValid();

// 初始化您的硬件:RS485、屏幕、继电器等
// initRS485(); initScreen(); initRelays();

// 连接Wi-Fi
bool wifiOk = connectWiFiWithEEPROM(8000);
if (!wifiOk) {
Serial.println("[MODE] 离线模式运行");
otaBeginBackgroundTask(); // 仍可启动任务,等待网络恢复
return;
}

// 在线模式:启动OTA任务并立即检查一次
otaBeginBackgroundTask();
otaCheckOnce();

// 如果是更新后的首次启动,在此处调用:
// markRunningAppValid();
}

void loop() {
// 您的主业务逻辑
// handleRS485(); handleScreen(); handleMQTT();

// 周期性触发OTA检查(例如每60秒)
if (millis() - lastCheckMillis > 60000UL) {
if (WiFi.isConnected() && !otaIsBusy()) {
otaCheckOnce();
}
lastCheckMillis = millis();
}
delay(10);
}

三、 服务器端配置 (Ubuntu 24.04 + Nginx)

假设服务器IP为:192.168.137.200

  1. 安装Nginx

    1
    2
    sudo apt update
    sudo apt install nginx -y
  2. 创建OTA文件目录并设置权限

    1
    2
    3
    sudo mkdir -p /var/www/html/ota
    sudo chown -R $USER:$USER /var/www/html/ota
    chmod -R 755 /var/www/html/ota

    提示:将目录所有者设为当前用户 ($USER) 便于通过SFTP上传文件。

  3. 上传固件文件
    将编译好的固件和版本文件上传至服务器:

    • version.txt: 内容仅为版本号,例如 1.0.1 (无引号,单行)。
    • firmware.bin: Arduino或PlatformIO编译生成的二进制固件文件。

    使用scp命令上传 (在您的开发机上执行):

    1
    2
    scp firmware.bin 您的用户名@192.168.137.200:/var/www/html/ota/firmware.bin
    scp version.txt 您的用户名@192.168.137.200:/var/www/html/ota/version.txt

    您也可以使用MobaXterm等工具的SFTP面板直接拖拽上传。

  4. 验证访问
    在终端中测试文件是否可以正常访问:

    1
    2
    curl http://192.168.137.200/ota/version.txt
    curl -I http://192.168.137.200/ota/firmware.bin

    确保version.txt能返回正确版本号,firmware.bin能返回Content-Length头。

  5. 配置防火墙 (如启用)

    1
    2
    sudo ufw allow 'Nginx HTTP'
    sudo ufw reload

四、 编译与生成 firmware.bin

Arduino IDE

  1. 选择开发板:ESP32S3 Dev Module (根据您的正点原子具体型号选择)。
  2. 点击 项目 -> 导出已编译的二进制文件
  3. 编译完成后,会在项目目录下生成一个 .bin 文件,即为所需固件。

PlatformIO

  1. platformio.ini 中正确配置开发板,例如 board = esp32-s3-devkitc-1
  2. 执行 pio run 编译。
  3. 编译后的固件位于:.pio/build/<你的板子型号>/firmware.bin

重要:将生成的 firmware.bin 上传到服务器的 /var/www/html/ota/ 目录,并同步更新 version.txt 中的版本号。


五、 测试流程与串口观察

  1. 烧录基础固件:将版本号为 1.0.0 的固件烧录到设备,确认其能正常启动并连接Wi-Fi。
  2. 更新服务器文件:在服务器上,用新版本(如 1.0.1)的 firmware.bin 替换旧文件,并将 version.txt 内容改为 1.0.1
  3. 观察设备串口日志 (波特率115200)。正常情况下会看到如下日志:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    [OTA] Task started
    [OTA] 检查版本...
    [OTA] 本地版本: 1.0.0 服务器版本: 1.0.1
    [OTA] 检测到新版本,准备下载固件...
    [OTA] 固件大小: 1024000 bytes
    [OTA] 写入分区: ota_1
    [OTA] 进度: ...
    [OTA] Update.end() 成功
    [OTA] 设置启动分区成功,重启系统以应用新固件
  4. 设备重启:设备将自动重启,并由Bootloader引导至新的应用分区运行。
  5. 确认新固件:在新固件的 setup() 函数中适当位置(如关键初始化完成后)调用 markRunningAppValid() 函数,将其标记为有效。

六、 回滚机制与安全建议

ESP32的Bootloader具备回滚保护功能。如果新固件在设定的时间或启动次数内未被标记为有效,Bootloader会自动回滚至旧版本分区。

安全操作建议

  1. 及时标记:在新固件的 setup() 函数中,完成所有关键硬件初始化、自检并通过后,立即调用 esp_ota_mark_app_valid_cancel_rollback()
  2. 失败回滚:如果新固件存在严重缺陷导致频繁崩溃或无法启动,它将无法执行标记操作。Bootloader在数次尝试失败后会自动回滚到稳定的旧版本,这是一个重要的安全网。

七、 与现有系统功能的兼容性

  • 非阻塞设计:OTA下载运行在独立的FreeRTOS后台任务中,循环内包含 vTaskDelay()不会阻塞您的主循环 (loop)、硬件中断 (ISR) 或定时器。
  • 写Flash影响:仅在调用 Update.write() 写入Flash时,会短暂占用系统。请确保Flash空间充足。
  • 网络带宽:下载固件时会占用部分网络带宽,可能影响MQTT等大量数据上行。必要时可在OTA期间暂停或降低其他网络任务的频率。
  • 实时性:对于有极苛刻实时性要求(亚毫秒级)的应用,可以进一步降低OTA任务的优先级,或增加写入数据块之间的延迟。

八、 常见问题排查 (Q&A)

  1. 下载/写入失败

    • 检查Nginx:确保服务器返回正确的 Content-Length。使用 curl -I 命令检查。
    • 查看错误码:代码中已打印 Update.getError() 信息,根据其排查。
    • 分区大小:确认固件体积未超过单个OTA分区的大小(通常约1.5MB)。如果固件过大,需要重新定制分区表。
  2. 检测不到新版本

    • **检查version.txt**:确认其内容无多余空格或换行,且可通过浏览器直接访问。
    • 检查URL:确认 OTA_VERSION_URLOTA_BIN_URL 配置正确,HTTP/HTTPS、端口无误。
    • 网络连通性:确保设备能Ping通服务器。
  3. 下载缓慢或中断

    • 检查虚拟机、宿主机、路由器之间的网络连接稳定性。
    • 检查设备Wi-Fi信号强度 (WiFi.RSSI())。
    • 确认Nginx服务器没有带宽限制配置。
  4. 更新成功但新固件无法启动

    • 在新固件的 setup() 开头增加串口打印,观察卡在何处。
    • 检查新固件是否使用了不兼容的库或配置。
    • 如果多次启动失败,Bootloader可能会执行回滚,观察启动日志判断。
  5. 如何查看当前运行的分区?

    1
    2
    const esp_partition_t* running = esp_ota_get_running_partition();
    Serial.printf("当前运行分区: %s\n", running->label);

九、 高级:调整分区表

如果您的固件体积超过了默认的OTA分区大小(约1.5MB),需要自定义分区表。

  1. 在项目目录下创建 partitions.csv 文件,定义更大的分区(例如2.5MB)。
  2. 在Arduino IDE中,通过 工具 -> Partition Scheme -> Custom Partition Table 选择该文件,并指定 Custom CSV 路径。
  3. 重要:更改分区表后,需要重新烧录整个固件(包括Bootloader和分区表本身),此操作会清空Flash。之后再进行OTA。

注意:本方案提供了一个稳定、实用的OTA更新框架。请根据您的具体应用场景,妥善处理网络异常、电源中断等边界情况,并在生产环境中进行充分测试。