第三章 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);
}
我的小技巧: 批量擦除时,如果扇区数量多,建议每擦除几个扇区就喂一次看门狗。我曾经在一个项目里没注意,擦除64个扇区花了3秒多,直接触发了硬件看门狗复位,排查了半天。

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++;
        }
    }
}
注意: 坏块标记的位置因厂商而异。有些在页内偏移0,有些在偏移2048。一定要看数据手册!我吃过这个亏,用Samsung的NAND时按Toshiba的偏移读,结果把好块当坏块跳过了。

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;
    }
}
关键点: 磨损均衡需要配合垃圾回收机制。当所有块都用过一遍后,需要把有效数据整理到前面,擦除后面的块继续使用。这个过程叫"垃圾回收",在Flash文件系统(如FATFS、LittleFS)里都有实现。

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;
}
避坑指南: 我曾经在写入启动头时掉电,导致两个分区都标记为无效。后来我改成了"先写备份头,再写主头"的策略。具体做法是:在Flash末尾预留一个备份头区域,写入时先写备份头,再写主头。读取时如果主头损坏,就用备份头恢复。

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里落地的。