基于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>
extern const char* OTA_VERSION_URL; extern const char* OTA_BIN_URL; extern const char* CURRENT_VERSION;
void otaBeginBackgroundTask(); void otaCheckOnce(); bool otaIsBusy();
|
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
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; }
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);
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; }
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); } if (millis() - lastPrint > 2000) { Serial.printf("[OTA] 进度: %d / %d\n", written, contentLen); lastPrint = millis(); } }
bool success = false; if (written == contentLen) { if (Update.end(true)) { 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); } }
void otaBeginBackgroundTask() { if (otaTaskHandle != NULL) return; xTaskCreatePinnedToCore(otaTask, "otaTask", 8192, NULL, 1, &otaTaskHandle, 1); Serial.println("[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> #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";
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; }
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);
bool wifiOk = connectWiFiWithEEPROM(8000); if (!wifiOk) { Serial.println("[MODE] 离线模式运行"); otaBeginBackgroundTask(); return; }
otaBeginBackgroundTask(); otaCheckOnce();
}
void loop() {
if (millis() - lastCheckMillis > 60000UL) { if (WiFi.isConnected() && !otaIsBusy()) { otaCheckOnce(); } lastCheckMillis = millis(); } delay(10); }
|
三、 服务器端配置 (Ubuntu 24.04 + Nginx)
假设服务器IP为:192.168.137.200
安装Nginx
1 2
| sudo apt update sudo apt install nginx -y
|
创建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上传文件。
上传固件文件
将编译好的固件和版本文件上传至服务器:
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面板直接拖拽上传。
验证访问
在终端中测试文件是否可以正常访问:
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头。
配置防火墙 (如启用)
1 2
| sudo ufw allow 'Nginx HTTP' sudo ufw reload
|
四、 编译与生成 firmware.bin
Arduino IDE
- 选择开发板:
ESP32S3 Dev Module (根据您的正点原子具体型号选择)。
- 点击 项目 -> 导出已编译的二进制文件。
- 编译完成后,会在项目目录下生成一个
.bin 文件,即为所需固件。
- 在
platformio.ini 中正确配置开发板,例如 board = esp32-s3-devkitc-1。
- 执行
pio run 编译。
- 编译后的固件位于:
.pio/build/<你的板子型号>/firmware.bin。
重要:将生成的 firmware.bin 上传到服务器的 /var/www/html/ota/ 目录,并同步更新 version.txt 中的版本号。
五、 测试流程与串口观察
- 烧录基础固件:将版本号为
1.0.0 的固件烧录到设备,确认其能正常启动并连接Wi-Fi。
- 更新服务器文件:在服务器上,用新版本(如
1.0.1)的 firmware.bin 替换旧文件,并将 version.txt 内容改为 1.0.1。
- 观察设备串口日志 (波特率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] 设置启动分区成功,重启系统以应用新固件
|
- 设备重启:设备将自动重启,并由Bootloader引导至新的应用分区运行。
- 确认新固件:在新固件的
setup() 函数中适当位置(如关键初始化完成后)调用 markRunningAppValid() 函数,将其标记为有效。
六、 回滚机制与安全建议
ESP32的Bootloader具备回滚保护功能。如果新固件在设定的时间或启动次数内未被标记为有效,Bootloader会自动回滚至旧版本分区。
安全操作建议:
- 及时标记:在新固件的
setup() 函数中,完成所有关键硬件初始化、自检并通过后,立即调用 esp_ota_mark_app_valid_cancel_rollback()。
- 失败回滚:如果新固件存在严重缺陷导致频繁崩溃或无法启动,它将无法执行标记操作。Bootloader在数次尝试失败后会自动回滚到稳定的旧版本,这是一个重要的安全网。
七、 与现有系统功能的兼容性
- 非阻塞设计:OTA下载运行在独立的FreeRTOS后台任务中,循环内包含
vTaskDelay(),不会阻塞您的主循环 (loop)、硬件中断 (ISR) 或定时器。
- 写Flash影响:仅在调用
Update.write() 写入Flash时,会短暂占用系统。请确保Flash空间充足。
- 网络带宽:下载固件时会占用部分网络带宽,可能影响MQTT等大量数据上行。必要时可在OTA期间暂停或降低其他网络任务的频率。
- 实时性:对于有极苛刻实时性要求(亚毫秒级)的应用,可以进一步降低OTA任务的优先级,或增加写入数据块之间的延迟。
八、 常见问题排查 (Q&A)
下载/写入失败
- 检查Nginx:确保服务器返回正确的
Content-Length。使用 curl -I 命令检查。
- 查看错误码:代码中已打印
Update.getError() 信息,根据其排查。
- 分区大小:确认固件体积未超过单个OTA分区的大小(通常约1.5MB)。如果固件过大,需要重新定制分区表。
检测不到新版本
- **检查
version.txt**:确认其内容无多余空格或换行,且可通过浏览器直接访问。
- 检查URL:确认
OTA_VERSION_URL 和 OTA_BIN_URL 配置正确,HTTP/HTTPS、端口无误。
- 网络连通性:确保设备能Ping通服务器。
下载缓慢或中断
- 检查虚拟机、宿主机、路由器之间的网络连接稳定性。
- 检查设备Wi-Fi信号强度 (
WiFi.RSSI())。
- 确认Nginx服务器没有带宽限制配置。
更新成功但新固件无法启动
- 在新固件的
setup() 开头增加串口打印,观察卡在何处。
- 检查新固件是否使用了不兼容的库或配置。
- 如果多次启动失败,Bootloader可能会执行回滚,观察启动日志判断。
如何查看当前运行的分区?
1 2
| const esp_partition_t* running = esp_ota_get_running_partition(); Serial.printf("当前运行分区: %s\n", running->label);
|
九、 高级:调整分区表
如果您的固件体积超过了默认的OTA分区大小(约1.5MB),需要自定义分区表。
- 在项目目录下创建
partitions.csv 文件,定义更大的分区(例如2.5MB)。
- 在Arduino IDE中,通过
工具 -> Partition Scheme -> Custom Partition Table 选择该文件,并指定 Custom CSV 路径。
- 重要:更改分区表后,需要重新烧录整个固件(包括Bootloader和分区表本身),此操作会清空Flash。之后再进行OTA。
注意:本方案提供了一个稳定、实用的OTA更新框架。请根据您的具体应用场景,妥善处理网络异常、电源中断等边界情况,并在生产环境中进行充分测试。