执行 ESP32 固件的空中升级



假设您在现场有 1000 台物联网设备。现在,如果有一天,您在生产代码中发现了一个错误,并且希望修复它,您会召回所有 1000 台设备并在其中刷入新的固件吗?可能不会!您更希望有一种方法可以远程更新所有设备,通过无线方式。OTA 更新如今非常普遍。您时不时地会收到 Android 或 iOS 智能手机的软件更新。就像软件更新可以远程发生一样,固件更新也可以。在本章中,我们将了解如何远程更新 ESP32 的固件。

OTA 更新过程

这个过程非常简单。设备首先分块下载新的固件并将其存储在内存的另一个区域。让我们将此区域称为“OTA 空间”。让我们将存储当前代码或应用程序代码的内存区域称为“应用程序空间”。一旦整个固件下载并验证完成,设备引导加载程序就会开始工作。将引导加载程序视为写入内存单独区域(让我们将其称为“引导加载程序空间”)的代码,其唯一目的是在每次设备重启时将正确的代码加载到应用程序空间中。

因此,每次设备重启时,引导加载程序空间中的代码都会首先执行。大多数情况下,它只是将控制权传递给应用程序空间中的代码。但是,在下载了较新的固件后,当设备重启时,引导加载程序会注意到有较新的应用程序代码可用。因此,它会将该较新的代码从 OTA 空间闪存到应用程序空间,然后将控制权交给应用程序空间中的代码。结果将是设备固件将被升级。

现在,稍微偏离一下,如果应用程序代码已损坏或发送了恢复出厂设置命令,引导加载程序还可以将“恢复出厂设置空间”中的恢复出厂设置代码闪存到应用程序空间。此外,通常,OTA 代码和恢复出厂设置代码存储在外部存储设备上,例如 SD 卡或外部 EEPROM 或 FLASH 芯片,如果微控制器没有足够的存储空间。但是,在 ESP32 的情况下,OTA 代码可以存储在微控制器的内存本身中。

代码演练

我们将使用本章中的示例代码。您可以在 File -> Examples -> Update -> AWS_S3_OTA_Update 中找到它。它也可以在 GitHub 上找到。

这是 Arduino 上可用的 ESP32 最详细的示例之一。该草图的作者甚至在注释中提供了草图的预期串口监视器输出。因此,虽然大部分代码通过注释不言而喻,但我们将回顾主要思想并涵盖重要细节。此代码利用了 **Update** 库,该库与许多其他库一样,使 ESP32 的使用变得非常容易,同时将繁重的工作隐藏在后台。

在此特定示例中,作者将新固件的二进制文件保存在 AWS S3 存储桶中。详细介绍 AWS S3 超出了本章的范围,但从广义上讲,S3(简单存储服务)是 Amazon Web Services (AWS) 提供的云存储服务。可以将其想象成 Google Drive。您将文件上传到您的云端硬盘并与他人共享链接以共享它。类似地,您可以将文件上传到 S3 并通过链接访问它。S3 更加流行,因为许多其他 AWS 服务可以与之无缝交互。AWS S3 的入门非常简单。您可以通过快速 Google 搜索获得多个可用资源的帮助。草图开头处的注释中也提到了开始使用的一些步骤。

需要注意的重要建议是,您应该为此代码使用自己的二进制文件。草图顶部的注释建议您可以使用作者使用的相同二进制文件。但是,下载在另一台机器/另一个版本的 Arduino IDE 上编译的二进制文件有时会导致 OTA 过程中出现错误。此外,使用您自己的二进制文件将使您的学习更加“完整”。您可以通过转到 Sketch -> Export Compiled Binary 导出任何 ESP32 草图的二进制文件。二进制文件 (.bin) 将保存在 Arduino (.ino) 文件所在的同一文件夹中。

Saving binary

保存二进制文件后,您只需将其上传到 S3 并将存储桶的链接和二进制文件的地址添加到代码中即可。您保存的二进制文件应该包含一些打印语句以指示它与您在 ESP32 中刷写的代码不同。例如,“Hello from S3”语句。此外,不要按原样将 S3 存储桶链接和 bin 地址保留在代码中。

好了!不要再说了!现在让我们开始演练。我们将从包含 WiFi 和 Update 库开始。

#include <WiFi.h>
#include <Update.h>

接下来,我们定义一些变量、常量以及 WiFiClient 对象。请记住添加您自己的 WiFi 凭据和 S3 凭据。

WiFiClient client;

// Variables to validate
// response from S3
long contentLength = 0;
bool isValidContentType = false;

// Your SSID and PSWD that the chip needs
// to connect to
const char* SSID = "YOUR−SSID";
const char* PSWD = "YOUR−SSID−PSWD";

// S3 Bucket Config
String host = "bucket−name.s3.ap−south−1.amazonaws.com"; // Host => bucket−name.s3.region.amazonaws.com
int port = 80; // Non https. For HTTPS 443. As of today, HTTPS doesn't work.
String bin = "/sketch−name.ino.bin"; // bin file name with a slash in front.

接下来,定义了一个辅助函数 **getHeaderValue()**,它基本上用于检查特定标头的值。例如,如果我们获得标头“Content-Length: 40”并将其存储在名为 headers 的字符串中,getHeaderValue(headers,“Content−Length: ”) 将返回 40。

// Utility to extract header value from headers
String getHeaderValue(String header, String headerName) {
   return header.substring(strlen(headerName.c_str()));
}

接下来是主函数 **execOTA()**,它执行 OTA。此函数包含与 OTA 相关的整个逻辑。如果您查看 Setup,我们只需连接到 WiFi 并调用 execOTA() 函数。

void setup() {
   //Begin Serial
   Serial.begin(115200);
   delay(10);

   Serial.println("Connecting to " + String(SSID));

   // Connect to provided SSID and PSWD
   WiFi.begin(SSID, PSWD);

   // Wait for connection to establish
   while (WiFi.status() != WL_CONNECTED) {
      Serial.print("."); // Keep the serial monitor lit!
      delay(500);
   }

   // Connection Succeed
   Serial.println("");
   Serial.println("Connected to " + String(SSID));

   // Execute OTA Update
   execOTA();
}

因此,您应该已经理解,理解 execOTA 函数意味着理解整个代码。因此,让我们开始该函数的演练。

我们首先连接到我们的主机,在本例中是 S3 存储桶。连接后,我们使用 GET 请求从存储桶中获取 bin 文件(有关 GET 请求的更多信息,请参阅 HTTP 教程)

void execOTA() {
   Serial.println("Connecting to: " + String(host));
   // Connect to S3
   if (client.connect(host.c_str(), port)) {
   // Connection Succeed.
   // Fecthing the bin
   Serial.println("Fetching Bin: " + String(bin));

   // Get the contents of the bin file
   client.print(String("GET ") + bin + " HTTP/1.1\r\n" +
      "Host: " + host + "\r\n" +
      "Cache-Control: no-cache\r\n" +
      "Connection: close\r\n\r\n");

接下来,我们等待客户端连接。我们最多给连接建立 5 秒的时间,否则我们会说连接超时并返回。

unsigned long timeout = millis();
while (client.available() == 0) {
   if (millis() - timeout > 5000) {
      Serial.println("Client Timeout !");
      client.stop();
      return;
   }
}

假设代码在上一步中没有返回,我们就建立了成功的连接。服务器的预期响应在注释中提供。我们首先解析该响应。响应逐行读取,每行存储在一个名为 line 的变量中。我们特别检查以下 3 件事 -

  • 如果响应状态代码为 200(OK)

  • 内容长度是多少

  • 内容类型是否为 application/octet-stream(这是二进制文件所需的类型)

第一个和第三个是必需的,第二个仅供参考。

while (client.available()) {
   // read line till /n
   String line = client.readStringUntil('\n');
   // remove space, to check if the line is end of headers
   line.trim();

   // if the the line is empty,
   // this is end of headers
   // break the while and feed the
   // remaining `client` to the
   // Update.writeStream();
   if (!line.length()) {
   //headers ended
   break; // and get the OTA started
   }

   // Check if the HTTP Response is 200
   // else break and Exit Update
   if (line.startsWith("HTTP/1.1")) {
      if (line.indexOf("200") < 0) {
         Serial.println("Got a non 200 status code from server. Exiting OTA Update.");
         break;
      }
   }

   // extract headers here
   // Start with content length
   if (line.startsWith("Content-Length: ")) {
      contentLength = atol((getHeaderValue(line, "Content-Length: ")).c_str());
      Serial.println("Got " + String(contentLength) + " bytes from server");
   }

   // Next, the content type
   if (line.startsWith("Content-Type: ")) {
      String contentType = getHeaderValue(line, "Content-Type: ");
      Serial.println("Got " + contentType + " payload.");
      if (contentType == "application/octet-stream") {
         isValidContentType = true;
      }
   }
}

这样,检查与服务器的连接是否成功的 if 代码块就结束了。后面跟着 else 代码块,它只是打印我们无法建立与服务器的连接。

} else {
   // Connect to S3 failed
   // May be try?
   // Probably a choppy network?
   Serial.println("Connection to " + String(host) + " failed. Please check your setup");
   // retry??
   // execOTA();
}

接下来,如果我们希望从服务器收到了正确的响应,我们将获得一个正数 contentLength(请记住,我们最初将其初始化为 0,因此如果我们以某种方式没有到达解析 Content−Length 标头的行,它仍然为 0)。此外,我们将 isValidContentType 设置为 true(请记住,我们将其初始化为 false)。因此,我们检查这两个条件是否都为真,如果是,则继续执行实际的 OTA。请注意,到目前为止,我们仅使用 WiFi 库与服务器交互。现在,如果服务器交互结果正常,我们将开始使用 Update 库,否则,我们只需打印服务器响应中没有内容并刷新客户端。如果响应确实正确,我们首先检查内存中是否有足够的空间来存储 OTA 文件。默认情况下,大约 1.2 MB 的空间保留用于 OTA 文件。因此,如果 contentLength 超过该值,Update.begin() 将返回 false。此 1.2MB 数字可能会根据 ESP32 的分区而改变。

// check contentLength and content type
if (contentLength && isValidContentType) {
   // Check if there is enough to OTA Update
   bool canBegin = Update.begin(contentLength);

现在,如果我们确实有空间将 OTA 文件存储在内存中,我们将使用 **Update.writeStream()** 函数开始将字节写入为 OTA 保留的内存区域(OTA 空间)。如果没有,我们只需打印该消息并刷新客户端,并退出 OTA 过程。**Update.writeStream()** 函数返回写入 OTA 空间的字节数。然后,我们检查写入的字节数是否等于 contentLength。如果 Update 完成,在这种情况下 **Update.end()** 函数将返回 true,我们检查它是否已正确完成,即所有字节是否都已写入,使用 **Update.isFinished()** 函数。如果它返回 true,表示所有字节都已写入,我们将重启 ESP32,以便引导加载程序可以将新代码从 OTA 空间闪存到应用程序空间,并且我们的固件得到升级。如果它返回 false,我们将打印收到的错误。

   // If yes, begin
   if (canBegin) {
      Serial.println("Begin OTA. This may take 2 − 5 mins to complete. Things might be quite for a while.. Patience!");
      // No activity would appear on the Serial monitor
      // So be patient. This may take 2 - 5mins to complete
      size_t written = Update.writeStream(client);

      if (written == contentLength) {
         Serial.println("Written : " + String(written) + " successfully");
      } else {
         Serial.println("Written only : " + String(written) + "/" + String(contentLength) + ". Retry?" );
         // retry??
         // execOTA();
      }

      if (Update.end()) {
         Serial.println("OTA done!");
         if (Update.isFinished()) {
            Serial.println("Update successfully completed. Rebooting.");
            ESP.restart();
         } else {
            Serial.println("Update not finished? Something went wrong!");
         }
      } else {
         Serial.println("Error Occurred. Error #: " + String(Update.getError()));
      }
   } else {
      // not enough space to begin OTA
      // Understand the partitions and
      // space availability
      Serial.println("Not enough space to begin OTA");
      client.flush();
   }
}

当然,您现在应该已经意识到,我们不需要在这里循环中做任何事情。

就是这样。您已成功远程升级了 ESP32 芯片的固件。如果您对 Update 库的每个函数的作用更感兴趣,可以参考 Update.h 文件中的注释。

参考

广告