引言

在嵌入式系统中,OTA 更新是远程维护的核心功能,但网络中断、固件损坏或电源故障都可能导致更新失败。若设备无法启动,将造成严重后果。为此,引入双分区(A/B)策略:将 Flash 划分为两个独立区域,分别存储当前固件和备份固件。通过改写 Bootloader,在启动时检测固件有效性,若 OTA 失败则自动回滚到备份分区,从而保证设备永不“变砖”。

原理讲解

双分区架构

  • 分区 A(Active):当前运行的固件,OTA 更新目标。
  • 分区 B(Backup):出厂固件或上次稳定版本,作为回滚源。
  • 元数据区:存储启动计数、固件状态标志(如有效/无效)。

Bootloader 在每次上电时检查元数据:

  1. 若当前分区固件有效(如 CRC 校验通过),则正常启动。
  2. 若无效(如 OTA 写入不完整),则切换启动分区到备份,并递增回滚计数。

回滚触发条件

  • 启动失败:固件崩溃或无法通过 CRC 校验。
  • 看门狗超时:若应用未在设定时间内喂狗,Bootloader 判定启动失败。
  • 用户手动触发:通过特定 GPIO 或命令强制回滚。

分区表规划(以 Arduino Mega 2560 为例)

  • Flash 总容量:256KB。
  • Bootloader 区域:8KB(地址 0x00000 - 0x01FFF)。
  • 分区 A:120KB(0x02000 - 0x1DFFF)。
  • 分区 B:120KB(0x1E000 - 0x3BFFF)。
  • 元数据区:1KB(0x3C000 - 0x3FFFF)。

配置步骤

1. 修改 Bootloader

使用 Arduino IDE 或 AVR-GCC 编译自定义 Bootloader。核心逻辑:

// bootloader.c 片段
#include <avr/io.h>
#include <avr/boot.h>
#include <avr/wdt.h>

#define APP_A_START 0x02000
#define APP_B_START 0x1E000
#define META_ADDR   0x3C000

// 元数据结构
struct meta_t {
    uint8_t magic;
    uint8_t active_partition;
    uint8_t boot_count;
    uint8_t reserved;
};

void read_meta(struct meta_t *meta) {
    // 从 EEPROM 或 Flash 读取,此处用 EEPROM 简化
    eeprom_read_block(meta, (void*)META_ADDR, sizeof(struct meta_t));
}

void write_meta(struct meta_t *meta) {
    eeprom_write_block(meta, (void*)META_ADDR, sizeof(struct meta_t));
}

int check_firmware(uint16_t start_addr) {
    // 简单 CRC 校验:读取前 4 字节作为长度,计算整个固件 CRC
    uint16_t length = pgm_read_word(start_addr);
    uint16_t crc = 0;
    for (uint16_t i = 0; i < length; i++) {
        crc += pgm_read_byte(start_addr + 2 + i);
    }
    return (crc == pgm_read_word(start_addr + length + 2));
}

void boot_main() {
    struct meta_t meta;
    read_meta(&meta);
    
    if (meta.magic != 0xA5) {
        // 首次启动,初始化元数据
        meta.magic = 0xA5;
        meta.active_partition = 0; // A 分区
        meta.boot_count = 0;
        write_meta(&meta);
    }
    
    uint16_t app_start = (meta.active_partition == 0) ? APP_A_START : APP_B_START;
    
    if (check_firmware(app_start)) {
        // 固件有效,启动应用
        meta.boot_count = 0;
        write_meta(&meta);
        // 跳转到应用复位向量
        void (*app_reset)(void) = (void (*)(void))pgm_read_word(app_start);
        app_reset();
    } else {
        // 固件无效,回滚
        meta.active_partition = (meta.active_partition == 0) ? 1 : 0;
        meta.boot_count++;
        write_meta(&meta);
        // 重置看门狗并重启
        wdt_enable(WDTO_15MS);
        while(1);
    }
}

2. 应用固件支持 OTA

应用需包含 OTA 写入函数,将新固件写入非活动分区,并更新元数据。

// OTA 更新示例(Arduino 代码)
#include <Arduino.h>
#include <EEPROM.h>

#define META_ADDR 0x3C000
#define APP_A_START 0x02000
#define APP_B_START 0x1E000

struct meta_t {
    uint8_t magic;
    uint8_t active_partition;
    uint8_t boot_count;
    uint8_t reserved;
};

void write_firmware_to_flash(uint16_t start_addr, uint8_t *data, uint16_t len) {
    // 使用 bootloader 的写入函数,此处省略具体实现
    // 注意:需要关闭中断,并调用 boot_page_erase/write 等
}

void ota_update(uint8_t *new_firmware, uint16_t len) {
    struct meta_t meta;
    EEPROM.get(META_ADDR, meta);
    
    // 目标分区为当前活动分区的另一个
    uint16_t target_start = (meta.active_partition == 0) ? APP_B_START : APP_A_START;
    
    // 擦除并写入新固件
    write_firmware_to_flash(target_start, new_firmware, len);
    
    // 更新元数据:切换活动分区
    meta.active_partition = (meta.active_partition == 0) ? 1 : 0;
    EEPROM.put(META_ADDR, meta);
    
    // 重启设备
    void (*reset)(void) = 0;
    reset();
}

3. 编译与烧录

  • 使用 Arduino IDE 编译 Bootloader,并通过 ISP 烧录。
  • 应用固件需链接到正确地址(如 0x02000),可在 IDE 中设置 bootloader 选项。
  • 测试时,可故意写入损坏固件验证回滚。

完整代码示例

以下是一个简化的 Bootloader 完整代码(基于 Arduino Mega 2560):

// CustomBootloader.c
#include <avr/io.h>
#include <avr/boot.h>
#include <avr/eeprom.h>
#include <avr/wdt.h>

#define APP_A_START 0x02000
#define APP_B_START 0x1E000
#define META_ADDR   0x3C000

struct meta_t {
    uint8_t magic;
    uint8_t active_partition;
    uint8_t boot_count;
    uint8_t reserved;
};

void read_meta(struct meta_t *meta) {
    eeprom_read_block(meta, (void*)META_ADDR, sizeof(struct meta_t));
}

void write_meta(struct meta_t *meta) {
    eeprom_write_block(meta, (void*)META_ADDR, sizeof(struct meta_t));
}

uint16_t compute_crc(uint16_t start) {
    uint16_t len = pgm_read_word(start);
    uint16_t crc = 0;
    for (uint16_t i = 0; i < len; i++) {
        crc += pgm_read_byte(start + 2 + i);
    }
    return crc;
}

int check_firmware(uint16_t start) {
    uint16_t len = pgm_read_word(start);
    if (len == 0 || len > 0x1D000) return 0;
    uint16_t crc = compute_crc(start);
    return (crc == pgm_read_word(start + len + 2));
}

void jump_to_app(uint16_t start) {
    void (*app_reset)(void) = (void (*)(void))pgm_read_word(start);
    cli();
    // 设置栈指针为应用栈顶
    asm volatile("ldi r16, 0x00\n\t"
                 "out 0x3E, r16\n\t"
                 "ldi r16, 0x02\n\t"
                 "out 0x3D, r16");
    app_reset();
}

int main(void) {
    struct meta_t meta;
    read_meta(&meta);
    
    if (meta.magic != 0xA5) {
        meta.magic = 0xA5;
        meta.active_partition = 0;
        meta.boot_count = 0;
        write_meta(&meta);
    }
    
    uint16_t app_start = (meta.active_partition == 0) ? APP_A_START : APP_B_START;
    
    if (check_firmware(app_start)) {
        // 固件有效,正常启动
        meta.boot_count = 0;
        write_meta(&meta);
        jump_to_app(app_start);
    } else {
        // 固件无效,回滚
        meta.active_partition = (meta.active_partition == 0) ? 1 : 0;
        meta.boot_count++;
        write_meta(&meta);
        wdt_enable(WDTO_15MS);
        while(1);
    }
    
    return 0;
}

注意事项

  • Flash 磨损:频繁回滚会擦写 Flash,建议限制回滚次数(如 3 次),超过则进入安全模式。
  • CRC 校验:确保固件头部包含长度和 CRC,避免误判。
  • 看门狗:应用启动后需及时初始化看门狗,防止 Bootloader 误判。
  • 分区大小:根据实际 Flash 容量调整,确保应用不超过分区大小。
  • 测试:务必在真实硬件上模拟 OTA 失败场景,验证回滚逻辑。

总结

通过 Bootloader 实现双分区回滚,能显著提升 Arduino 设备的 OTA 可靠性。本文提供了完整的原理、配置和代码,开发者可根据具体硬件调整。此策略不仅适用于 Arduino,也可推广到其他嵌入式平台,是构建健壮固件更新机制的重要参考。