基于 RT-Thread 的 OTA 断点续传与 SPI Flash 磨损均衡深度实现
👁 1 阅读 · 2026-08-27 · 嵌入式
在资源受限的嵌入式设备中,OTA 升级的稳定性与 Flash 寿命是核心痛点。本文深入剖析如何在 RT-Thread 环境下,结合断点续传机制与磨损均衡算法,实现高效可靠的 SPI Flash 固件升级。文章将详细讲解分区管理、续传状态机、动态磨损均衡策略,并给出可落地的 C 代码示例与关键注意事项,助力开发者构建工业级 OTA 方案。
# 基于 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 设计提供参考。