引言

OTA 升级在物联网设备中至关重要,但固件传输过程中的数据损坏或中断可能导致系统变砖。自定义 bootloader 通过 CRC 校验检测固件完整性,失败时回滚到上一个可用版本。然而,一个隐蔽的陷阱是:当回滚过程中发生硬件复位(例如看门狗超时、外部复位引脚干扰),系统可能无法正确恢复,反而触发无限重启。本文以 Arduino(基于 STM32)为例,揭示这一陷阱的根源,并给出工程级解决方案。

1. 原理剖析:bootloader、OTA 与回滚机制

1.1 典型 OTA 流程

  • Bootloader 阶段:上电后执行,检查升级标志(如特定 Flash 地址的标记),若存在待升级固件,则进行 CRC 校验。
  • 应用阶段:校验通过后跳转至应用区执行;失败则回滚至备份区。
  • 回滚机制:通常将当前固件备份在另一 Flash 分区,失败时从备份区恢复。

1.2 硬件复位陷阱的根源

  • 看门狗(IWDG/WWDG):在 OTA 写入过程中,若耗时过长未喂狗,看门狗触发复位,此时 Flash 写入可能未完成,导致 CRC 校验再次失败。
  • 外部复位:用户按键或电源抖动可能产生复位信号,打断回滚过程。
  • 关键问题:复位后 bootloader 重新执行,但升级标志和回滚状态未持久化,导致系统反复尝试升级同一损坏固件。

2. 硬件与软件环境

  • 硬件:STM32F103C8T6(Arduino 兼容板),外部看门狗(如 TPL5010)或内部 IWDG。
  • 软件:Arduino IDE 1.8.19,STM32CubeProgrammer 烧录 bootloader,自定义 OTA 协议(基于串口或 LoRa)。
  • Flash 分区:Bootloader(0x08000000-0x08003FFF)、App1(0x08004000-0x08007FFF)、App2(备份,0x08008000-0x0800BFFF)、状态区(0x0800C000)。

3. 配置步骤与代码实现

3.1 状态区设计

使用 Flash 最后一个页(1KB)存储状态结构体,包含升级标志、CRC 结果、回滚计数等。

// status.h
typedef struct {
  uint32_t magic;          // 0xA5A5A5A5 表示有效
  uint8_t upgrade_pending; // 1=有待升级固件
  uint8_t crc_ok;          // 上次校验结果
  uint8_t rollback_count;  // 连续回滚次数
  uint32_t app1_crc;       // App1 的 CRC 值
  uint32_t app2_crc;       // App2 的 CRC 值
} Status;

3.2 Bootloader 核心逻辑

// bootloader.c
#include <Arduino.h>
#include <IWatchdog.h>  // 内部看门狗库

#define STATUS_ADDR 0x0800C000

void load_status(Status* s) {
  memcpy(s, (void*)STATUS_ADDR, sizeof(Status));
}

void save_status(Status* s) {
  // 先擦除页,再写入
  flash_unlock();
  flash_erase_page(STATUS_ADDR);
  flash_write(STATUS_ADDR, (uint8_t*)s, sizeof(Status));
  flash_lock();
}

void boot_app(uint32_t app_addr) {
  // 跳转前关闭中断,设置栈指针
  __disable_irq();
  void (*app_reset)(void) = (void (*)(void))(*(volatile uint32_t*)(app_addr + 4));
  __set_MSP(*(volatile uint32_t*)app_addr);
  app_reset();
}

void setup() {
  IWatchdog.begin(2000000); // 2秒超时
  Status st;
  load_status(&st);
  
  if (st.magic != 0xA5A5A5A5) {
    // 首次运行,初始化状态
    st.magic = 0xA5A5A5A5;
    st.upgrade_pending = 0;
    st.crc_ok = 0;
    st.rollback_count = 0;
    save_status(&st);
  }
  
  if (st.upgrade_pending) {
    // 计算待升级固件 CRC(假设存储在 App1 区)
    uint32_t crc = compute_crc(APP1_ADDR, APP1_SIZE);
    if (crc == st.app1_crc) {
      st.crc_ok = 1;
      st.upgrade_pending = 0;
      st.rollback_count = 0;
      save_status(&st);
      boot_app(APP1_ADDR);
    } else {
      // CRC 失败,回滚到 App2
      st.rollback_count++;
      if (st.rollback_count > 3) {
        // 连续失败,进入安全模式(例如 LED 闪烁)
        while(1) { digitalToggle(LED_BUILTIN); delay(100); }
      }
      st.upgrade_pending = 0;
      save_status(&st);
      boot_app(APP2_ADDR);
    }
  } else {
    // 正常启动,选择 CRC 正确的应用
    if (st.crc_ok) boot_app(APP1_ADDR);
    else boot_app(APP2_ADDR);
  }
}

void loop() {}

3.3 应用区 OTA 写入与复位处理

// app.ino
#include <IWatchdog.h>

void perform_ota() {
  // 接收固件数据,写入 App1 区
  // 写入完成后,更新状态区
  Status st;
  load_status(&st);
  st.upgrade_pending = 1;
  st.app1_crc = compute_crc(APP1_ADDR, APP1_SIZE);
  save_status(&st);
  
  // 关键:在复位前喂狗,并延迟确保状态写入完成
  IWatchdog.reload();
  delay(100); // 等待 Flash 写入稳定
  NVIC_SystemReset(); // 触发复位
}

3.4 看门狗与复位陷阱的规避

  • 在 bootloader 中定期喂狗:在 CRC 计算和 Flash 操作期间,每循环一次喂狗,避免超时。
  • 状态持久化:在每次状态变更后立即写入 Flash,并等待写入完成。
  • 复位原因检测:通过 RCC->CSR 寄存器判断复位源,若为看门狗复位,则清除升级标志,强制回滚。
// 复位源检测示例
if (RCC->CSR & RCC_CSR_IWDGRSTF) {
  // 看门狗复位,清除标志并回滚
  Status st;
  load_status(&st);
  st.upgrade_pending = 0;
  st.crc_ok = 0;
  save_status(&st);
  RCC->CSR |= RCC_CSR_RMVF; // 清除复位标志
}

4. 注意事项与调试技巧

  • Flash 写入时序:STM32 的 Flash 写入需要时间,务必在写入后读取验证,并确保在复位前完成。
  • 看门狗超时设置:应大于 OTA 写入最坏情况时间,建议设置 5-10 秒,并在写入循环中喂狗。
  • 回滚计数:限制连续回滚次数,避免无限循环。可结合 LED 或串口输出错误码。
  • 测试方法:使用串口助手模拟损坏固件(修改 CRC),观察回滚行为;用示波器监测复位引脚,模拟外部干扰。
  • 进阶优化:使用双 bank 启动(如 STM32F7),硬件自动回滚,减少软件复杂度。

5. 总结

硬件复位陷阱是 OTA 回滚机制中的隐形杀手,但通过状态持久化、看门狗协同和复位源检测,可以彻底规避。本文提供的代码框架可直接应用于 Arduino/STM32 项目,确保系统在恶劣环境下仍能可靠恢复。记住:稳健的 OTA 不只是校验和跳转,更是对异常情况的全面防御。