引言

OTA(Over-The-Air)升级是物联网设备的核心功能,但网络不稳定、固件损坏或断电常导致升级失败。若 bootloader 不具备校验与回滚能力,设备将变砖。本文以 Arduino(如 ESP32/STM32)为例,设计一个带 CRC 校验和双分区回滚机制的自定义 bootloader,确保升级失败时自动恢复旧固件。

原理:双分区与 CRC 校验

双分区布局

将 Flash 划分为两个应用区:

  • App A(当前运行区)
  • App B(待升级区)

Bootloader 根据标志位决定启动哪个分区。升级时,新固件写入 App B,校验通过后切换标志位;若失败,则保持 App A 运行。

CRC 校验

CRC(循环冗余校验)用于检测数据完整性。常见 CRC32 算法,计算固件二进制文件的校验值,存储在固件头部或单独区域。Bootloader 在启动或升级后校验,若 CRC 不匹配则触发回滚。

回滚机制

  • 标志位:在 Flash 的独立区域(如 EEPROM 或专用扇区)存储当前启动分区和升级状态。
  • 状态机
    • STATE_NORMAL:正常启动 App A。
    • STATE_UPDATE_PENDING:新固件已写入 App B,待校验。
    • STATE_UPDATE_SUCCESS:校验通过,切换启动分区。
    • STATE_ROLLBACK:校验失败,回滚至 App A。

配置步骤

1. 定义 Flash 分区

以 ESP32 为例,在 partitions.csv 中定义:

# Name,   Type, SubType, Offset,   Size, Flags
nvs,      data, nvs,     0x9000,   0x5000,
app_a,    app,  factory, 0x10000,  0x200000,
app_b,    app,  ota_1,   0x210000, 0x200000,
storage,  data, 0x01,    0x410000, 0x10000,

2. Bootloader 设计

Bootloader 负责:

  • 读取标志位。
  • 校验 App B 的 CRC(若处于更新状态)。
  • 根据结果跳转至相应分区。

3. 应用层 OTA 流程

应用层接收新固件,写入 App B,计算 CRC 并存储,然后设置标志位并重启。

完整代码示例

以下为简化版 bootloader 核心逻辑(以 ESP32 为例,使用 Arduino 框架):

#include <Arduino.h>
#include <esp_ota_ops.h>
#include <esp_partition.h>
#include <CRC32.h>

// 标志位存储地址(使用 NVS 或自定义分区)
#define FLAG_ADDR 0x410000

enum BootState {
    NORMAL = 0,
    UPDATE_PENDING = 1,
    UPDATE_SUCCESS = 2,
    ROLLBACK = 3
};

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

    // 读取标志位
    uint32_t flag;
    esp_partition_read(&boot_partition, FLAG_ADDR, &flag, sizeof(flag));

    if (flag == UPDATE_PENDING) {
        // 校验 App B 的 CRC
        const esp_partition_t* app_b = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
        if (app_b == NULL) {
            Serial.println("App B not found!");
            rollback();
            return;
        }

        // 读取固件头部中的 CRC 值(假设存储在偏移 0 处)
        uint32_t stored_crc;
        esp_partition_read(app_b, 0, &stored_crc, sizeof(stored_crc));

        // 计算实际 CRC(跳过头部 4 字节)
        uint32_t calc_crc = calculate_crc(app_b, 4, app_b->size - 4);

        if (calc_crc == stored_crc) {
            Serial.println("CRC OK, switching to App B");
            flag = UPDATE_SUCCESS;
            esp_partition_write(&boot_partition, FLAG_ADDR, &flag, sizeof(flag));
            esp_ota_set_boot_partition(app_b);
            esp_restart();
        } else {
            Serial.println("CRC failed, rolling back");
            rollback();
        }
    } else if (flag == UPDATE_SUCCESS) {
        // 正常启动 App B,但可再次校验(可选)
        // 此处直接启动
    } else {
        // NORMAL 状态,启动 App A
    }
}

void loop() {
    // Bootloader 不运行 loop
}

uint32_t calculate_crc(const esp_partition_t* part, size_t offset, size_t len) {
    CRC32 crc;
    uint8_t buf[128];
    size_t remaining = len;
    size_t pos = offset;

    while (remaining > 0) {
        size_t to_read = min(remaining, sizeof(buf));
        esp_partition_read(part, pos, buf, to_read);
        crc.update(buf, to_read);
        pos += to_read;
        remaining -= to_read;
    }
    return crc.finalize();
}

void rollback() {
    // 设置回滚标志,并启动 App A
    uint32_t flag = ROLLBACK;
    esp_partition_write(&boot_partition, FLAG_ADDR, &flag, sizeof(flag));
    const esp_partition_t* app_a = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL);
    esp_ota_set_boot_partition(app_a);
    esp_restart();
}

应用层 OTA 写入示例

// 假设通过 HTTP 接收固件
void perform_ota() {
    const esp_partition_t* app_b = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL);
    esp_ota_handle_t ota_handle;
    esp_ota_begin(app_b, OTA_SIZE_UNKNOWN, &ota_handle);

    // 写入固件数据(循环接收)
    while (receive_data(buf, len)) {
        esp_ota_write(ota_handle, buf, len);
    }

    // 计算 CRC 并写入固件头部(假设头部预留 4 字节)
    uint32_t crc = calculate_crc_from_received_data();
    esp_partition_write(app_b, 0, &crc, sizeof(crc));

    // 设置标志位为 UPDATE_PENDING
    uint32_t flag = UPDATE_PENDING;
    esp_partition_write(&boot_partition, FLAG_ADDR, &flag, sizeof(flag));

    esp_ota_end(ota_handle);
    esp_restart();
}

注意事项

  • 分区大小:App A 和 App B 必须大小一致,且足够容纳最大固件。
  • CRC 存储位置:建议放在固件头部,但需确保 bootloader 能读取到,且不影响固件启动(可跳过头部)。
  • 标志位原子性:写入标志位时,建议先擦除再写入,并考虑使用双备份或校验和防止标志位损坏。
  • 断电保护:在写入 App B 过程中断电,可能导致分区不完整。可增加“写入完成”标志,bootloader 检测到未完成时直接回滚。
  • 测试:务必在真实硬件上模拟升级失败(如中断传输、篡改数据)验证回滚。
  • 启动超时:若 App B 启动后崩溃,bootloader 应具备看门狗或超时机制,自动回滚至 App A。

结语

通过自定义 bootloader 实现 CRC 校验和双分区回滚,能显著提升 OTA 的可靠性。本文提供的方案适用于 Arduino 及类似平台,核心思想可移植至其他 MCU。记住,健壮的升级机制是产品稳定性的基石。