基于 RT-Thread 的 OTA 断点续传与 SPI Flash 磨损均衡实现细节

一、背景与挑战

在物联网设备中,OTA 升级常因网络波动或断电中断。若每次重传全量固件,不仅浪费带宽,更会加速 SPI Flash 的擦写磨损(典型 NOR Flash 擦写寿命约 10 万次)。因此,断点续传(记录升级进度)与磨损均衡(均匀分布擦写)成为嵌入式 OTA 的核心技术。

RT-Thread 提供了丰富的文件系统与 Flash 抽象层(如 SFUD、FAL),但 OTA 的断点与磨损均衡需自行设计。本文将基于 RT-Thread 的 FAL(Flash Abstraction Layer)与 DFS(设备文件系统),实现一个轻量级、可移植的方案。

二、整体架构设计

系统分为三层:

  • 应用层:OTA 管理线程,负责下载、校验、触发升级。
  • 中间层:断点续传模块(记录偏移量与 CRC),磨损均衡模块(动态映射逻辑块到物理块)。
  • 驱动层:SPI Flash 驱动(通过 SFUD 统一接口)。

分区规划(以 8MB Flash 为例):

  • bootloader 分区:0x000000 - 0x0FFFFF(1MB)
  • app 分区:0x100000 - 0x3FFFFF(3MB)
  • download 分区:0x400000 - 0x7FFFFF(4MB)
  • 元数据分区:0x800000 - 0x800FFF(4KB,存储续传信息)

三、断点续传实现

3.1 续传状态记录

每次写入数据块后,将当前偏移量、块序号、CRC32 校验值写入元数据分区。为避免频繁擦写,元数据采用双备份(A/B 区轮换写入)。

// 元数据结构体
struct ota_progress {
    uint32_t offset;      // 当前写入偏移
    uint32_t block_seq;   // 块序号
    uint32_t crc32;       // 已写入数据的 CRC32
    uint8_t  status;      // 0: 进行中, 1: 完成, 2: 失败
};

// 写入元数据(带磨损均衡:轮换 A/B 区)
int ota_save_progress(struct ota_progress *prog) {
    static uint8_t toggle = 0;
    uint32_t base_addr = (toggle == 0) ? META_A_ADDR : META_B_ADDR;
    
    // 擦除并写入
    fal_partition_erase(meta_part, base_addr, sizeof(struct ota_progress));
    fal_partition_write(meta_part, base_addr, prog, sizeof(struct ota_progress));
    
    toggle ^= 1;
    return 0;
}

3.2 续传恢复流程

系统启动时,读取元数据,若状态为“进行中”,则从记录的偏移量继续下载,并校验已写入数据的 CRC32。

int ota_resume(struct ota_progress *prog) {
    // 读取 A/B 区,取最新有效记录
    struct ota_progress prog_a, prog_b;
    fal_partition_read(meta_part, META_A_ADDR, &prog_a, sizeof(prog_a));
    fal_partition_read(meta_part, META_B_ADDR, &prog_b, sizeof(prog_b));
    
    // 比较时间戳或序号,选择较新的
    if (prog_a.block_seq > prog_b.block_seq) {
        *prog = prog_a;
    } else {
        *prog = prog_b;
    }
    
    // 校验已写入数据的 CRC
    uint32_t calc_crc = crc32_calc(download_part, prog->offset);
    if (calc_crc != prog->crc32) {
        // 数据损坏,从头开始
        prog->offset = 0;
        prog->block_seq = 0;
        prog->crc32 = 0;
    }
    return prog->offset;
}

四、SPI Flash 磨损均衡实现

4.1 磨损均衡原理

SPI Flash 以扇区(4KB)为擦除单位。若 OTA 每次写入固定物理地址,该区域会快速老化。磨损均衡通过逻辑地址到物理地址的动态映射,使擦写均匀分布。

常用算法:

  • 静态均衡:定期搬移冷数据(如固件),释放低磨损块。
  • 动态均衡:写入时选择磨损最少的块。

本文采用动态均衡 + 简单静态搬移(升级完成后触发)。

4.2 映射表管理

在 RAM 中维护一张映射表(逻辑块号 -> 物理块号),并定期持久化到 Flash 的映射表分区。

#define BLOCK_SIZE 4096
#define LOGICAL_BLOCKS (DOWNLOAD_SIZE / BLOCK_SIZE)  // 例如 1024

static uint16_t mapping_table[LOGICAL_BLOCKS];  // 存储物理块号
static uint8_t  erase_count[PHYSICAL_BLOCKS];   // 物理块擦写次数

// 初始化:从 Flash 加载映射表,若无效则建立默认映射
void wear_leveling_init(void) {
    // 读取映射表分区,校验 CRC
    if (load_mapping_table() != 0) {
        // 默认映射:逻辑块 i -> 物理块 i
        for (int i = 0; i < LOGICAL_BLOCKS; i++) {
            mapping_table[i] = i;
        }
        // 初始化擦写计数为 0
        memset(erase_count, 0, sizeof(erase_count));
        save_mapping_table();
    }
}

// 写入逻辑块:选择磨损最少的物理块
int wear_leveling_write(uint32_t logical_block, const uint8_t *data) {
    uint16_t old_phys = mapping_table[logical_block];
    uint16_t new_phys = find_min_erase_block();  // 遍历 erase_count,找最小值
    
    // 将新数据写入新物理块
    fal_partition_erase(download_part, new_phys * BLOCK_SIZE, BLOCK_SIZE);
    fal_partition_write(download_part, new_phys * BLOCK_SIZE, data, BLOCK_SIZE);
    
    // 更新映射表
    mapping_table[logical_block] = new_phys;
    erase_count[new_phys]++;
    
    // 若旧物理块不再被引用,可擦除(此处简化,实际需引用计数)
    // 这里直接擦除旧块(注意:若旧块有数据被其他逻辑块引用则不能擦,需复杂管理)
    // 为简化,我们假设每个物理块只映射一个逻辑块(一对一),但实际需处理重叠。
    // 更稳妥:延迟擦除,或使用垃圾回收。
    
    return 0;
}

注意:上述代码为简化示例,实际一对一映射会导致旧块数据丢失。正确做法是:写入新块后,旧块标记为“待回收”,在空闲时擦除。但为了演示磨损均衡核心思想,此处简化。

4.3 静态搬移策略

OTA 完成后,将下载分区的固件复制到 app 分区。此时可触发静态搬移:将 app 分区中磨损较少的块与下载分区中磨损较多的块交换,平衡整体磨损。

void static_balance(void) {
    for (int i = 0; i < PHYSICAL_BLOCKS; i++) {
        if (erase_count[i] < MIN_ERASE_THRESHOLD) {
            // 寻找磨损高的块进行交换
            int j = find_max_erase_block();
            if (erase_count[j] - erase_count[i] > 100) {
                swap_blocks(i, j);  // 交换数据与映射
            }
        }
    }
}

五、完整代码示例(RT-Thread 环境)

以下为 OTA 下载线程的核心逻辑,整合了断点续传与磨损均衡。

#include <rtthread.h>
#include <fal.h>
#include <dfs_posix.h>

#define DOWNLOAD_PART_NAME "download"
#define META_PART_NAME    "meta"
#define BLOCK_SIZE        4096

static struct fal_partition *dl_part, *meta_part;
static struct ota_progress prog;

// 下载并写入一个数据块
static int ota_write_block(uint32_t offset, const uint8_t *buf, uint32_t len) {
    uint32_t logical_block = offset / BLOCK_SIZE;
    uint32_t block_offset = offset % BLOCK_SIZE;
    
    // 若跨块,需分块处理(此处假设 len <= BLOCK_SIZE)
    if (block_offset + len > BLOCK_SIZE) {
        rt_kprintf("Error: block crossing not supported\n");
        return -1;
    }
    
    // 读取当前块数据,修改后写回(因为磨损均衡以块为单位)
    uint8_t block_data[BLOCK_SIZE];
    fal_partition_read(dl_part, mapping_table[logical_block] * BLOCK_SIZE, block_data, BLOCK_SIZE);
    memcpy(block_data + block_offset, buf, len);
    
    // 磨损均衡写入
    wear_leveling_write(logical_block, block_data);
    
    // 更新进度
    prog.offset = offset + len;
    prog.block_seq++;
    prog.crc32 = crc32_update(prog.crc32, buf, len);
    ota_save_progress(&prog);
    
    return 0;
}

// OTA 下载线程
static void ota_download_thread(void *param) {
    int fd;
    uint8_t buf[1024];
    uint32_t offset = 0;
    
    // 初始化分区与磨损均衡
    dl_part = fal_partition_find(DOWNLOAD_PART_NAME);
    meta_part = fal_partition_find(META_PART_NAME);
    wear_leveling_init();
    
    // 断点续传
    offset = ota_resume(&prog);
    if (offset == 0) {
        // 全新升级,擦除下载分区(逻辑擦除,物理擦除由磨损均衡处理)
        for (int i = 0; i < LOGICAL_BLOCKS; i++) {
            wear_leveling_erase(i);  // 实际擦除物理块
        }
    }
    
    // 模拟从网络读取固件(实际为 HTTP 或 MQTT)
    fd = open("/net/firmware.bin", O_RDONLY);
    lseek(fd, offset, SEEK_SET);
    
    while (1) {
        int len = read(fd, buf, sizeof(buf));
        if (len <= 0) break;
        
        // 写入下载分区(带磨损均衡)
        if (ota_write_block(offset, buf, len) != 0) {
            rt_kprintf("Write failed\n");
            break;
        }
        offset += len;
        
        // 每写入 1MB 打印进度
        if ((offset % (1024*1024)) == 0) {
            rt_kprintf("Progress: %d KB\n", offset/1024);
        }
    }
    
    close(fd);
    
    // 校验整体 CRC,然后触发升级(复制到 app 分区)
    if (crc32_verify(dl_part, prog.crc32) == 0) {
        prog.status = 1;
        ota_save_progress(&prog);
        // 触发升级:将 download 分区内容复制到 app 分区(同样使用磨损均衡)
        ota_commit();
    } else {
        prog.status = 2;
        ota_save_progress(&prog);
    }
}

// 创建线程
static int ota_init(void) {
    rt_thread_t tid = rt_thread_create("ota", ota_download_thread, RT_NULL, 4096, 10, 10);
    if (tid) rt_thread_startup(tid);
    return 0;
}
INIT_APP_EXPORT(ota_init);

六、注意事项与优化建议

  • 元数据写入频率:每次写块都更新元数据会加剧磨损。可改为每写 N 块(如 16 块)更新一次,但需考虑断电丢失进度。折中方案:每块更新,但元数据分区使用大容量(如 64KB)并轮换。
  • 磨损均衡的映射表持久化:映射表本身也需存储,建议使用独立的映射表分区,并定期保存。若掉电丢失,可重建映射(但会丢失磨损信息)。
  • 垃圾回收:当物理块被替换后,旧块需擦除。若旧块数据仍被其他逻辑块引用(如多对一映射),则不能立即擦除。建议使用引用计数或标记-清除算法。
  • CRC 校验:断点续传的 CRC 校验应覆盖已写入的所有数据,建议使用增量 CRC 算法(如 CRC32 的拼接特性)。
  • RT-Thread 的 FAL 抽象:FAL 提供了分区读写接口,但底层擦写仍需注意 Flash 的时序(如写使能、忙等待)。建议使用 SFUD 驱动,它已处理这些细节。
  • 测试:务必进行断电测试(在写入过程中随机断电),验证断点续传的正确性。可使用 QEMU 或真实硬件模拟。

七、总结

本文基于 RT-Thread 实现了 OTA 断点续传与 SPI Flash 磨损均衡的完整方案。断点续传通过元数据记录进度,磨损均衡通过动态映射分散擦写。实际项目中,还需结合具体 Flash 型号调整块大小、磨损阈值等参数。希望本文能为你的嵌入式 OTA 设计提供参考。