# 基于 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 区轮换写入)。 ```c // 元数据结构体 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。 ```c 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 的映射表分区。 ```c #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 分区中磨损较少的块与下载分区中磨损较多的块交换,平衡整体磨损。 ```c 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 下载线程的核心逻辑,整合了断点续传与磨损均衡。 ```c #include #include #include #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 设计提供参考。