第三章 Flash驱动进阶:多扇区操作、坏块管理、磨损均衡策略、掉电保护机制
好,咱们继续往下走。上一章我们把Flash的基本读写擦操作搞定了,那都是单扇区、单页的玩法。但实际产品里,没人这么干。你想想看,一个OTA升级包动不动几百KB,你一个扇区一个扇区地搬,效率低不说,中间出点岔子怎么办?
这一章,咱们就聊聊真正工程化的东西。多扇区操作怎么组织?坏块怎么处理?磨损均衡怎么设计?掉电保护怎么做?这些都是我在做Bootloader时踩过坑的地方。
3.1 多扇区操作:别让CPU闲着
单扇区操作很简单,但多扇区操作就涉及一个核心问题:怎么让擦除和写入流水起来。
Flash擦除很慢,一个扇区擦除可能要几十毫秒。如果你擦一个、写一个,CPU大部分时间都在等。我个人习惯的做法是:先集中擦除所有需要更新的扇区,再集中写入数据。
举个例子,一个128KB的固件,分布在32个4KB的扇区里。我会这样做:
// 伪代码:多扇区批量操作
void flash_multi_sector_write(uint32_t start_addr, uint8_t *data, uint32_t len) {
uint32_t sector_count = (len + SECTOR_SIZE - 1) / SECTOR_SIZE;
uint32_t sectors[sector_count];
// 第一步:计算所有涉及的扇区
for (int i = 0; i < sector_count; i++) {
sectors[i] = start_addr + i * SECTOR_SIZE;
}
// 第二步:批量擦除(这里可以加进度回调)
for (int i = 0; i < sector_count; i++) {
flash_erase_sector(sectors[i]);
// 我习惯在这里加个看门狗喂狗,防止擦除时间太长复位
watchdog_feed();
}
// 第三步:批量写入
flash_write(start_addr, data, len);
}
3.2 坏块管理:NAND Flash的必修课
如果你用的是NOR Flash,坏块问题基本不用操心。但NAND Flash就不一样了——出厂就有坏块,用着用着还会产生新坏块。这是NAND的物理特性决定的,没办法。
坏块管理说白了就两件事:发现坏块和跳过坏块。
3.2.1 坏块标记与检测
NAND Flash出厂时,每个坏块会在某个特定位置(通常是第一个页的某个字节)标记为非0xFF。我见过很多新手直接忽略这个标记,结果写入数据后读出来全是错的。
我的做法是:上电初始化时,扫描所有块,建立一张坏块表。
// 坏块表结构
typedef struct {
uint32_t block_addr; // 块地址
uint8_t is_bad; // 1=坏块, 0=好块
} bad_block_entry_t;
#define MAX_BLOCKS 1024
bad_block_entry_t bad_block_table[MAX_BLOCKS];
uint32_t bad_block_count = 0;
void bad_block_scan(void) {
for (uint32_t i = 0; i < MAX_BLOCKS; i++) {
uint32_t block_start = i * BLOCK_SIZE;
uint8_t marker;
// 读取出厂坏块标记(通常在第一个页的2048字节偏移处)
flash_read(block_start + 2048, &marker, 1);
if (marker != 0xFF) {
bad_block_table[bad_block_count].block_addr = block_start;
bad_block_table[bad_block_count].is_bad = 1;
bad_block_count++;
}
}
}
3.2.2 坏块跳过策略
有了坏块表,写入时就要做地址映射。最简单的策略是线性跳过:遇到坏块就往后顺延一个块。
但这里有个坑:如果你做的是Bootloader,固件必须连续存放。线性跳过会导致逻辑地址和物理地址不一致。我建议的做法是:在Bootloader里预留一个坏块映射表,存放在Flash的固定区域。
// 逻辑块号 -> 物理块号映射
uint32_t logical_to_physical(uint32_t logical_block) {
uint32_t physical_block = logical_block;
uint32_t skip_count = 0;
// 遍历坏块表,计算需要跳过的数量
for (uint32_t i = 0; i < bad_block_count; i++) {
uint32_t bad_physical = bad_block_table[i].block_addr / BLOCK_SIZE;
if (bad_physical <= physical_block + skip_count) {
skip_count++;
}
}
return physical_block + skip_count;
}
3.3 磨损均衡:让Flash活得更久
Flash有擦写寿命,NOR一般是10万次,NAND是1万到10万次不等。如果你总是往同一个扇区写数据,那个扇区很快就挂了。
磨损均衡的核心思想很简单:把写操作分散到不同的物理块上。
3.3.1 动态磨损均衡
动态磨损均衡只针对频繁写入的数据。比如系统参数、日志等。我常用的方法是日志结构写入:每次写入都追加到新的位置,旧的标记为无效。
// 磨损均衡写入示例
typedef struct {
uint32_t current_write_pos; // 当前写入位置
uint32_t total_blocks; // 总块数
} wear_leveling_ctx_t;
void wear_leveling_write(wear_leveling_ctx_t *ctx, uint8_t *data, uint32_t len) {
// 找到下一个可用的写入位置
while (1) {
uint32_t block = ctx->current_write_pos / BLOCK_SIZE;
uint32_t offset = ctx->current_write_pos % BLOCK_SIZE;
// 检查这个块是否已写满
if (offset + len > BLOCK_SIZE) {
// 切换到下一个块
ctx->current_write_pos = (block + 1) * BLOCK_SIZE;
if (block + 1 >= ctx->total_blocks) {
// 所有块都用完了,需要垃圾回收
garbage_collect(ctx);
}
continue;
}
// 写入数据
flash_write(ctx->current_write_pos, data, len);
ctx->current_write_pos += len;
break;
}
}
3.4 掉电保护:别让数据毁于一旦
这是最让我头疼的部分。嵌入式设备随时可能掉电,如果在擦除或写入过程中断电,数据可能处于不一致状态。轻则固件损坏,重则设备变砖。
3.4.1 双备份策略
最可靠的方案是双备份:保留两份固件,一份是当前运行的,一份是待更新的。更新时先写备份区,写完后校验,校验通过再切换启动指针。
我做过一个Bootloader,就是用的这个方案:
// 双备份固件更新流程
typedef enum {
BOOT_PARTITION_A = 0,
BOOT_PARTITION_B = 1
} boot_partition_t;
typedef struct {
uint32_t magic; // 魔数,用于校验有效性
uint32_t firmware_size; // 固件大小
uint32_t crc32; // 固件CRC校验
boot_partition_t active; // 当前活动分区
} boot_header_t;
int update_firmware(uint8_t *new_fw, uint32_t size) {
boot_header_t header;
boot_partition_t target;
// 1. 确定目标分区(当前不用的那个)
flash_read(BOOT_HEADER_ADDR, &header, sizeof(header));
target = (header.active == BOOT_PARTITION_A) ? BOOT_PARTITION_B : BOOT_PARTITION_A;
// 2. 擦除目标分区
flash_erase(target * PARTITION_SIZE, PARTITION_SIZE);
// 3. 写入固件(每写一页就校验一页)
for (uint32_t offset = 0; offset < size; offset += PAGE_SIZE) {
flash_write(target * PARTITION_SIZE + offset, new_fw + offset, PAGE_SIZE);
// 即时校验:写完后读回来对比
uint8_t buffer[PAGE_SIZE];
flash_read(target * PARTITION_SIZE + offset, buffer, PAGE_SIZE);
if (memcmp(buffer, new_fw + offset, PAGE_SIZE) != 0) {
return -1; // 写入失败,掉电保护触发
}
}
// 4. 更新启动头,切换活动分区
header.active = target;
header.crc32 = calculate_crc32(new_fw, size);
flash_write(BOOT_HEADER_ADDR, &header, sizeof(header));
return 0;
}
3.4.2 事务日志
对于参数存储这类频繁写入的场景,双备份太浪费空间了。我推荐用事务日志的方式:每次写入前,先记录一条"准备写入"的日志,写入完成后,再记录一条"写入完成"的日志。掉电后重启时,扫描日志,回滚未完成的操作。
// 事务日志结构
typedef struct {
uint32_t transaction_id; // 事务ID
uint32_t target_addr; // 目标地址
uint8_t old_data[256]; // 旧数据(用于回滚)
uint8_t new_data[256]; // 新数据
uint8_t status; // 0=准备中, 1=已完成
} transaction_log_t;
void safe_parameter_write(uint32_t addr, uint8_t *data, uint32_t len) {
transaction_log_t log;
// 1. 读取旧数据
flash_read(addr, log.old_data, len);
// 2. 记录事务日志(状态=准备中)
log.transaction_id = get_next_transaction_id();
log.target_addr = addr;
memcpy(log.new_data, data, len);
log.status = 0; // 准备中
flash_write(TRANSACTION_LOG_ADDR, &log, sizeof(log));
// 3. 写入新数据
flash_write(addr, data, len);
// 4. 更新事务日志(状态=已完成)
log.status = 1;
flash_write(TRANSACTION_LOG_ADDR, &log, sizeof(log));
}
// 掉电恢复函数
void recovery_on_boot(void) {
transaction_log_t log;
flash_read(TRANSACTION_LOG_ADDR, &log, sizeof(log));
if (log.status == 0) {
// 事务未完成,回滚到旧数据
flash_write(log.target_addr, log.old_data, sizeof(log.old_data));
// 清除日志
flash_erase(TRANSACTION_LOG_ADDR, SECTOR_SIZE);
}
}
3.5 总结
这一章的内容,说白了就是让Flash驱动从"能用"变成"好用"。多扇区操作解决效率问题,坏块管理解决可靠性问题,磨损均衡解决寿命问题,掉电保护解决安全问题。
我个人觉得,这四个点里最难的是掉电保护。因为掉电场景千奇百怪——用户拔电、电池耗尽、电压不稳...你永远不知道设备会在哪个瞬间断电。所以我的原则是:宁可多写几行代码做保护,也不要让用户承担数据丢失的风险。
下一章,咱们开始真正进入Bootloader的世界。到时候你会看到,这些Flash驱动技术是怎么在Bootloader里落地的。