嵌入式面试真题第 09 题:非易失性存储局部失效下的通用持久化容错架构设计

在这里插入图片描述

问题

在一款需要长期运行、频繁保存配置、日志、计量数据、资源索引、模型参数、升级状态或业务文件的嵌入式设备中,底层非易失性存储可能是片内 Flash、外部 SPI NOR、原始 NAND、QSPI/OSPI Flash,也可能是带内部控制器的 eMMC、UFS、SD 卡或其他块设备。

产品在量产、老化、运输、现场升级和多年使用过程中,可能出现以下任一种异常:

  • 某个擦除单元无法擦成全擦除态;
  • 某个编程单元写入后读回不一致;
  • 某页出现不可纠正 ECC;
  • 某次写入被掉电、复位或看门狗打断,只完成了一部分;
  • 某个元数据块长期高频更新后提前磨损;
  • 底层控制器偶发超时、忙状态不退出或返回瞬态错误;
  • 映射表、目录、超级块或日志头部自身损坏;
  • 可用备用空间持续下降,最终无法完成垃圾回收或数据迁移。

如果这些故障直接透传给上层,轻则丢失单条配置或日志,重则整个分区无法挂载、固件无法启动、设备反复重启甚至永久“变砖”。

你会如何设计一套通用的持久化容错架构,使局部介质失效不会立即演变成卷级损坏,并做到:

  1. 已提交的旧数据在新写入失败时仍然可读;
  2. 失效物理单元能够被识别、隔离并替换;
  3. 映射、坏块表和事务元数据自身也具备冗余和掉电恢复能力;
  4. 设备在备用空间不足时能够进入受控降级,而不是死机或无限重启;
  5. 上层业务尽量无感,同时系统保留可观测、可诊断和可维护能力;
  6. 架构可以根据配置存储、日志存储、文件系统、固件升级和原始块设备等不同业务进行裁剪。

回答

结论:不要把“写失败后重试几次”当成完整容错方案,也不要让文件系统直接依赖一组永远可靠的物理地址。更通用的做法是在硬件驱动与业务数据模型之间建立一个介质可靠性层,将物理擦除单元转换为可迁移、可校验、可回收的逻辑存储单元;上层再通过 copy-on-write、追加日志、双副本元数据、事务提交和版本选择保证一致性。

这套架构的核心不只是“记录坏块”,而是同时满足四个不变量:

  1. 旧版本不提前失效:新数据未完成校验和提交之前,旧数据及其索引必须保持有效。
  2. 物理位置不等于逻辑身份:业务对象、逻辑页、逻辑块或文件偏移不能永久绑定某个物理块。
  3. 任何关键元数据都不能只有一个可写副本:映射表、坏块表、超级块、事务头和启动选择信息必须可恢复。
  4. 故障必须收敛:瞬态错误可以有限重试,确定性错误必须快速隔离;不能反复擦写同一个失效块,更不能无限循环重启。

从分层角度看,推荐结构如下。

总体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart TB
A[业务模块\n配置/日志/文件/升级/模型] --> B[持久化服务 API]
B --> C[事务与对象层\nKV / Log / File / Image]
C --> D[一致性层\nCOW / Journal / Append-only / 双副本]
D --> E[逻辑存储层\nL2P 映射 / GC / 磨损均衡 / 备用池]
E --> F[介质可靠性层\n校验 / 重试 / 隔离 / 迁移 / Scrub]
F --> G[Flash/MTD/Block Driver]
G --> H[片内 Flash / SPI NOR / Raw NAND / eMMC / SD]

I[坏块表 BBT] --> E
J[健康度与统计] --> F
K[故障注入与恢复扫描] --> D
K --> E
E --> I
F --> J

业务模块只看到“读取对象、提交事务、追加记录、切换固件镜像”等逻辑操作,不直接依赖物理擦除块。物理块是否已被替换、某次写入是否重定位、某个扇区是否进入隔离区,应由介质可靠性层和逻辑存储层处理。

这与 Linux 原始闪存栈中的分层思想相同:MTD 暴露原始闪存特性,UBI 在其上提供逻辑擦除块、卷管理、磨损均衡和透明 I/O 错误处理,UBIFS 再在 UBI 上实现文件系统一致性。对于 MCU,不一定需要完整复刻 UBI/UBIFS,但应保留同样的职责边界。

先纠正一个常见概念:并非所有 Flash 都以同样方式暴露“坏块”

面试中直接说“内部 Flash 形成坏块”容易把不同介质混为一谈。更准确的抽象是“物理擦除单元失效”或“介质局部失效”。

介质类型 常见失效模型 坏块是否通常显式暴露 软件关注点
MCU 片内 NOR Flash 擦除失败、编程失败、ECC 告警、保持特性下降、局部磨损 通常没有 NAND 式出厂坏块标记 驱动错误码、读回校验、冗余页、逻辑重映射、容量预留
外部 SPI/QSPI NOR 扇区擦除失败、页编程失败、位翻转、寿命耗尽 多数器件不采用原始 NAND 式 BBT 接口 擦除后检查、写后校验、故障扇区隔离、磨损均衡
原始 NAND 出厂坏块、使用中新增坏块、读扰、不可纠正 ECC 是,通常要求软件或控制器管理 BBT ECC、BBT、逻辑到物理映射、Scrub、保留块池
eMMC/UFS/SD 内部 NAND 由设备控制器管理,主机看到逻辑块 通常被内部 FTL 隐藏 关注块 I/O 错误、寿命指标、整盘只读或失联,不应自行管理物理坏块
EEPROM/FRAM/MRAM 写入粒度和寿命模型不同,部分无需擦除 一般不使用 Flash 坏块概念 原子记录、校验、双副本、写次数与保持特性

因此,架构应把不同介质统一抽象为:

1
2
3
4
5
6
read(logical_or_physical_unit)
program(unit, data)
erase(erase_unit)
sync()
get_status()
get_geometry()

底层再通过明确错误码区分:瞬态忙、超时、对齐错误、保护错误、可纠正 ECC、不可纠正 ECC、擦除失败、编程失败和校验失败。上层只有在得到足够语义后,才能决定重试、迁移、隔离还是进入只读。

故障模型必须先定义

容错设计的第一步不是选文件系统,而是列出系统需要承受的故障。若故障模型不完整,所谓“掉电安全”或“坏块安全”往往只覆盖了正常路径。

1. 瞬态 I/O 故障

例如控制器忙、总线竞争、DMA 超时、电压瞬间跌落、缓存未刷新或驱动状态机短暂异常。它们可能在重试后恢复。

处理原则:

  • 采用有限次数重试;
  • 重试前清除控制器错误状态,必要时重新初始化外设;
  • 记录首次错误和最终结果;
  • 使用退避或切换到较低时钟,避免紧密循环反复冲击介质;
  • 重试成功不代表可以完全忽略,应累积健康度计数。

2. 确定性擦除或编程失败

同一物理单元在合理重试后仍失败,或写后读回始终不一致。继续使用该单元只会放大损坏。

处理原则:

  • 将其从可分配集合移入 SUSPECTRETIRED
  • 若原块含有仍有效数据,尝试从旧副本、ECC 或冗余副本恢复后迁移;
  • 使用备用块替换逻辑地址;
  • 持久化更新坏块表或映射日志;
  • 不允许该块再次进入空闲池。

3. Torn Write 或 Torn Erase

掉电可能发生在写入头部、数据、CRC、提交标记、映射更新、旧块回收或擦除过程中。此时不能假设一次底层 API 调用具备原子性。

处理原则:

  • 只承认带完整校验且具有有效提交标记的记录;
  • 使用“先数据、后提交”顺序;
  • 元数据更新使用双副本、日志或 COW;
  • 启动扫描时根据代数、序列号和 CRC 选择最后一个完整版本;
  • 无法证明完整的记录一律视为未提交,而不是尝试猜测。

4. 静默数据损坏

底层返回成功,但数据已发生位翻转、保持特性下降、总线错误或软件越界覆盖。如果只看驱动返回值,系统会把错误数据当成有效数据。

处理原则:

  • 关键记录必须有端到端 CRC;
  • 需要抵抗恶意篡改时使用认证标签或签名,CRC 不能替代安全校验;
  • 定期读取长期静态数据并执行 Scrub;
  • 可纠正 ECC 次数上升时提前搬迁,而不是等待不可纠正错误。

5. 元数据单点损坏

坏块表、逻辑映射表、超级块、目录根、空闲位图、事务序列号和启动镜像选择状态比普通数据更关键。单个元数据块损坏可能让整个卷无法恢复。

处理原则:

  • 至少双副本或日志化保存;
  • 副本分散到不同擦除单元;
  • 每份包含 generation、长度、版本、CRC 和提交标记;
  • 更新时先写新副本,成功后再淘汰旧副本;
  • 必须能从数据区重建部分索引,避免“元数据坏一页、整个卷报废”。

6. 空间耗尽与 GC 失败

系统可能还有名义上的空闲字节,却没有足够的连续擦除单元完成一次 COW、垃圾回收或映射更新。这是许多轻量级存储在压力下突然失效的根本原因。

处理原则:

  • 区分“可写空间”和“可恢复空间”;
  • 永远保留一部分应急块,不参与普通分配;
  • 在达到危险水位前主动 GC;
  • 备用池耗尽时先进入只读或删减非关键数据,而不是继续进行不可完成的事务。

设计目标与系统不变量

通用方案应在设计文档中明确以下不变量。

1
2
3
4
5
6
7
I1: 已提交版本在新事务提交成功前始终可读取。
I2: 任一物理擦除单元失效不会直接使逻辑卷不可挂载。
I3: 映射表和坏块表至少存在一个可验证的完整版本。
I4: 系统重启后只恢复到“最后完整提交”,不会恢复到半写状态。
I5: 任一失败路径都有有界退出条件,不出现无限重试或无限重启。
I6: 可用空间低于安全阈值时,系统进入预定义降级状态。
I7: 所有介质错误都能被计数、定位和上报,而不是静默吞掉。

这些不变量比“是否使用某个文件系统”更重要。LittleFS、NVS、UBI、LevelX、FlashDB 等组件只是实现这些目标的不同工程取舍。

分层职责

1. Flash/MTD 驱动层

驱动层只做硬件操作和精确错误上报,不应擅自把失败伪装成成功。

建议至少提供:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* @brief Read data from non-volatile media.
*
* @param[in] address Physical byte address.
* @param[out] buffer Destination buffer.
* @param[in] length Number of bytes to read.
*
* @return 0 on success, or a negative media-specific error code.
*/
int media_read(uint32_t address, void *buffer, size_t length);

/**
* @brief Program data into a previously erased region.
*
* @param[in] address Physical byte address.
* @param[in] buffer Source buffer.
* @param[in] length Number of bytes to program.
*
* @return 0 on success, or a negative media-specific error code.
*/
int media_program(uint32_t address, const void *buffer, size_t length);

/**
* @brief Erase one physical erase unit.
*
* @param[in] erase_unit Physical erase-unit index.
*
* @return 0 on success, or a negative media-specific error code.
*/
int media_erase(uint32_t erase_unit);

驱动层还应公开几何信息:最小读取粒度、最小编程粒度、擦除单元大小、擦除值、是否支持 ECC 状态、是否支持暂停/恢复擦除、是否 XIP、是否需要 cache invalidate,以及写擦期间读取是否受限。

2. 介质可靠性层

该层负责:

  • 有界重试;
  • 擦除后检查;
  • 编程后读回;
  • ECC 与 CRC 结果归一化;
  • 物理块健康状态;
  • 故障单元隔离;
  • Scrub 与预防性搬迁;
  • 错误统计和健康度上报。

它不必理解“文件名”或“配置项”,但必须知道一个物理单元是否仍可继续分配。

3. 逻辑存储与映射层

该层把逻辑块、逻辑页或对象 ID 映射到物理位置,并负责:

  • L2P 映射;
  • 空闲块与备用块池;
  • 垃圾回收;
  • 动态和静态磨损均衡;
  • 数据迁移;
  • 映射元数据持久化;
  • 挂载恢复。

对于几十到几百个擦除块的小 MCU,可以使用简单数组或位图;对于大容量原始 NAND,需要日志、树或分层映射,避免 RAM 与挂载时间线性失控。

4. 一致性与事务层

该层解决“多条记录、数据与索引、镜像与状态必须一起生效”的问题。常用机制包括:

  • copy-on-write;
  • append-only log;
  • journal;
  • 双缓冲 A/B;
  • 版本号 + CRC + commit marker;
  • 原子根指针切换。

5. 业务存储层

根据业务选择不同数据模型:

  • 少量配置:KV 或双页参数区;
  • 高频日志:环形追加日志;
  • 通用文件:LittleFS、UBIFS 等文件系统;
  • 固件升级:A/B slot、swap 或 overwrite-with-recovery;
  • 大对象:分片、哈希树、manifest;
  • 计量数据:只追加记录 + 周期检查点。

物理块状态机

不要只用一个 bad=true/false 标志。实际工程中至少需要区分空闲、活动、可疑、隔离和报废状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
stateDiagram-v2
[*] --> FREE
FREE --> PREPARED: 擦除并验证成功
PREPARED --> ACTIVE: 写入并提交
ACTIVE --> OBSOLETE: 新版本已提交
OBSOLETE --> FREE: GC 擦除成功

FREE --> SUSPECT: 擦除/校验异常
PREPARED --> SUSPECT: 编程/读回异常
ACTIVE --> SUSPECT: ECC/CRC/读错误
SUSPECT --> FREE: 复检通过且策略允许
SUSPECT --> RETIRED: 超过阈值或确定性失败
RETIRED --> [*]

推荐的状态语义如下。

状态 含义 是否可分配 是否允许擦除
FREE 可供分配,但可能尚未完成本轮预擦除验证
PREPARED 已擦除并通过抽样或完整校验 不需要
ACTIVE 含有当前有效数据或元数据
OBSOLETE 内容已被新版本替代,可由 GC 回收
SUSPECT 出现一次或多次异常,等待复检或迁移 受控允许
RETIRED 已确认不可再使用
RESERVED 应急、元数据或恢复专用 仅特权路径 受控允许

SUSPECT 的价值在于避免一次总线瞬态错误就永久损失容量,也避免出现错误后仍立即重新投入普通分配。最终是否转为 RETIRED 应结合错误类型、重试结果、ECC 统计和产品安全要求。

标准写入事务流程

通用的安全写入路径应遵循“分配新位置、写完整、校验、提交、发布、回收旧位置”的顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sequenceDiagram
participant App as 业务层
participant Tx as 事务层
participant Map as 映射层
participant Media as 介质层

App->>Tx: write(object, new_data)
Tx->>Map: allocate_candidate()
Map->>Media: erase(candidate)
Media-->>Map: erase result
Map->>Media: verify erased state
Media-->>Map: verify result
Tx->>Media: program header + payload + CRC
Media-->>Tx: program result
Tx->>Media: read-back verify
Media-->>Tx: verify result
Tx->>Media: program commit marker
Media-->>Tx: commit result
Tx->>Map: publish new mapping/generation
Map-->>App: success
Map->>Map: old location ->> OBSOLETE

若在任意一步失败:

  • 候选块进入 SUSPECTRETIRED
  • 原有映射不变;
  • 旧数据仍然是当前版本;
  • 事务返回可诊断错误,或由存储层自动换一个候选块继续;
  • 只有在新记录和新映射都完成提交后,旧块才可回收。

为什么不能原地覆盖

原地更新会把“旧数据删除”和“新数据建立”耦合在同一个物理位置。只要擦除或写入中断,就可能同时失去新旧两个版本。

COW 和追加日志的代价是写放大和空间占用,但它们把失败从“覆盖当前真相”改成“产生一个未提交候选版本”。这正是掉电恢复和坏块容错的基础。

记录格式设计

一个通用记录可以采用如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+----------------------+ 低地址
| Magic |
| Format version |
| Record type |
| Logical object ID |
| Generation/sequence |
| Payload length |
| Header CRC |
+----------------------+
| Payload |
| ... |
+----------------------+
| Payload CRC / Hash |
| Commit marker |
+----------------------+ 高地址

关键规则:

  1. commit marker 最后写;
  2. 未写提交标记的记录永远不算有效;
  3. generation 用于在多个完整副本间选择新版本;
  4. header 与 payload 分别校验,便于快速扫描;
  5. 长度和版本必须先校验范围,防止损坏字段导致越界扫描;
  6. 记录头与提交标记尽量落在可原子编程的最小单元内;
  7. 若介质只能将位从 1 写到 0,应设计状态编码保证状态单向变化。

一种常见单向状态编码为:

1
2
3
4
5
ERASED      = 0xFF
ALLOCATED = 0x7F
DATA_WRITTEN= 0x3F
COMMITTED = 0x1F
OBSOLETE = 0x0F

具体编码需结合编程粒度和硬件限制,不能假设单字节写一定原子。

擦除后校验与写后校验

擦除后校验

最严格做法是检查整个擦除单元是否为擦除值,但这会增加读开销。可根据可靠性等级选择:

  • 全块校验;
  • 每页抽样;
  • 首尾和伪随机位置抽样;
  • 首次异常后升级为全块校验;
  • 安全关键元数据块始终全块校验。

仅检查底层 erase() 返回成功是不够的,因为部分控制器可能只表示命令已完成,不代表每一位均达到预期状态。

写后校验

写后校验可以是:

  • 逐字节读回比较;
  • CRC32;
  • 硬件 ECC 状态 + CRC;
  • 大对象使用 SHA-256 或分块哈希;
  • 固件镜像使用签名校验。

CRC 用于检测随机损坏,不提供抗恶意篡改能力;安全启动或受攻击场景必须使用签名或认证哈希。

Zephyr 的 Stream Flash 接口支持写后回调,回调可对读回数据执行 SHA 等校验;NVS 的元数据 CRC 用于判断写入是否完成,并可选择启用数据 CRC。这些机制都体现了“驱动成功不等于端到端数据完整”的原则。

重试策略

“重试”必须有边界和分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if error == BUSY or TRANSIENT_TIMEOUT:
controller_recover()
retry with bounded count and backoff
elif error == PROGRAM_VERIFY_FAIL or ERASE_VERIFY_FAIL:
mark block SUSPECT
relocate to another block
elif error == ECC_CORRECTED:
increment health counter
schedule scrub/relocation
elif error == ECC_UNCORRECTABLE or CRC_FAIL:
read redundant copy
quarantine source block
else:
return explicit error

建议将重试次数控制在 1~3 次量级,再依据硬件手册调整。无限重试不仅不能修复硬失效,还可能触发看门狗、阻塞实时任务、加剧电源压力并造成重复磨损。

重试还应避免在原地址重复写同一数据。如果第一次编程已经让部分位改变,第二次直接编程可能违反介质的编程约束。更安全的做法是废弃候选单元,换一个已擦除单元重新执行完整事务。

坏块表与退休单元表

坏块表 BBT 不应只是 RAM 数组,也不能只存一份在固定扇区。推荐采用“周期快照 + 追加增量日志”。

1
2
3
4
5
6
7
flowchart LR
A[BBT Snapshot A\ngen=100 CRC OK] --> C[增量日志\nretire block 27]
C --> D[增量日志\nretire block 81]
B[BBT Snapshot B\ngen=99 CRC OK] --> E[恢复备用]
D --> F[生成 Snapshot B\ngen=101]
F --> G[验证并提交]
G --> H[旧 Snapshot A 可回收]

BBT 记录至少包含

1
2
3
4
5
6
7
8
9
10
format_version
media_uuid
snapshot_generation
physical_unit_count
retired bitmap or entry list
reason code
first failure count
last failure count
CRC
commit marker

BBT 更新规则

  1. 先追加“退休事件”;
  2. 事件写入并校验成功后才从分配器移除该块;
  3. 事件过多时生成新快照;
  4. 新快照提交成功后才回收旧快照和旧日志;
  5. 启动时选择 generation 最大且 CRC 正确的快照,再重放后续完整事件;
  6. BBT 所在块自身失败时,必须能够迁移到备用元数据块。

对于原始 NAND,还需要导入器件出厂坏块标记并保留原标记规则。对于 NOR 或片内 Flash,软件维护的更准确名称可以是 retired erase unit table,但原理相同。

逻辑到物理映射

物理块一旦可能失效或迁移,逻辑地址就不能等同于物理地址。

1
2
3
4
5
6
flowchart LR
L0[Logical Block 0] --> P17[Physical Block 17]
L1[Logical Block 1] --> P4[Physical Block 4]
L2[Logical Block 2] --> P31[Physical Block 31]
P31 -. failure .-> BAD[Retired]
L2 --> P8[Physical Block 8\nreplacement]

小容量系统

若只有几百个擦除块,可在 RAM 中维护完整映射:

1
2
3
4
5
6
struct logical_map_entry {
uint32_t physical_block;
uint32_t generation;
uint16_t flags;
uint16_t crc;
};

映射持久化仍需使用日志或双快照。RAM 数组只是运行时索引,不是唯一真相。

中大容量系统

若块数达到数千或数万,完整映射表可能占用过多 RAM。可采用:

  • 分段映射;
  • 映射页缓存;
  • append-only 映射日志;
  • B-tree、radix tree 或类似结构;
  • 启动时读取 checkpoint,再重放增量;
  • 按需加载映射页。

UBI 对上层提供逻辑擦除块并透明映射到物理擦除块,就是这类设计的成熟实例。LevelX 也向上层呈现逻辑扇区,并在内部映射到物理闪存。

备用块池

备用块不是“剩余空间”,而是故障恢复所必需的系统资源。

1
2
3
4
5
N_spare >= N_expected_retired
+ N_max_relocation_inflight
+ N_metadata_redundancy
+ N_gc_emergency
+ N_margin

其中:

  • N_expected_retired:设计寿命内预计新增退休块;
  • N_max_relocation_inflight:一次最复杂事务或 GC 同时需要的候选块;
  • N_metadata_redundancy:映射、BBT、超级块的冗余需求;
  • N_gc_emergency:卷接近满时仍能完成一次回收的保底块;
  • N_margin:制造偏差和不可预期故障余量。

不存在适用于所有产品的固定百分比。原始 NAND 应遵循器件和控制器建议;片内 Flash 或 SPI NOR 的自定义方案则要根据擦除单元数量、寿命目标、写放大和可接受的降级概率计算。经验比例只能用于初版估算,必须通过寿命和故障注入测试验证。

备用池水位

水位 条件 系统动作
正常 备用块充足 正常写入与 GC
预警 低于预警阈值 上报健康告警,降低非关键写入,增加 Scrub
危险 只够少量恢复事务 禁止大事务,优先保存关键配置,清理日志
临界 无法保证下一次原子更新 切换只读或受限写模式
耗尽 无可用替换块 保持可读、拒绝写入、进入维护流程

“用户无感”不等于永远隐藏所有问题。正常阶段应自动修复;危险阶段必须受控降级并留下诊断信息。继续假装一切正常,往往会把可恢复故障拖成不可恢复损坏。

元数据冗余

双副本元数据

适合配置根、超级块、活动镜像选择等小型元数据。

1
2
3
4
flowchart LR
A[Meta A\ngen=42\nCRC OK\nCOMMITTED] --> C{启动选择}
B[Meta B\ngen=43\nCRC OK\nCOMMITTED] --> C
C --> D[选择 B]

更新步骤:

  1. 读取 A/B,选择当前 generation 最大的完整版本;
  2. 将新内容写入较旧或空闲副本;
  3. 读回校验;
  4. 写提交标记;
  5. 下次启动选择新 generation;
  6. 旧副本延后擦除。

多副本与仲裁

对于安全等级更高的场景,可用三副本并通过多数一致或签名选择,但要考虑共因失效。三个副本若都在同一擦除块、同一电源域或同一写路径上,冗余价值有限。

日志化元数据

当元数据更新频繁时,固定 A/B 仍会集中磨损。可将更新追加到多个块形成循环日志,周期性合并快照。LittleFS 的 metadata pair 由两个块构成,可在块内追加提交,并在需要时在两个块之间 compaction;其设计目标包括掉电恢复、动态磨损均衡、坏块绕过和有界 RAM。

数据一致性机制如何选择

机制 适用场景 优点 代价
A/B 双页 少量关键配置、启动标志 简单、恢复快、易验证 高频更新会集中磨损,容量利用率低
Append-only log 日志、计量记录、事件流 写路径简单、掉电恢复清晰 需要 GC 和索引
Copy-on-write 文件、树、映射表、复杂对象 旧版本天然保留、原子切换 写放大、需要额外空闲空间
Journal 多处原地结构的一致性更新 可重放、适合传统文件结构 日志本身需保护,设计复杂
Shadow paging 页式数据库或映射树 根指针原子切换,恢复明确 页复制和空间管理复杂
冗余副本 极关键小对象 读取恢复直接 容量翻倍,需处理副本选择

小型 MCU 不必追求“完整通用文件系统”。如果业务只有几十个配置项,KV 日志或双页参数区通常比移植大文件系统更可控。若需要目录、文件、随机读写和掉电恢复,LittleFS 更合适。若是大容量原始 NAND,应考虑 UBI/UBIFS 或控制器 FTL,而不是用一个简单扇区列表硬撑。

磨损均衡

坏块容错与磨损均衡必须一起设计。只做坏块替换、不分散擦写,会让固定元数据块快速退休,备用池被连续消耗。

动态磨损均衡

只在发生写入和 GC 时选择擦除次数较低的空闲块。实现简单,适合频繁变化的数据。

静态磨损均衡

定期搬迁长期不变的“冷数据”,释放其低擦除次数块,使热数据不会永远集中在少数块上。静态均衡能缩小最大与最小擦除次数差,但会增加额外写放大。

选择 victim block

可综合以下指标:

1
2
3
4
score = obsolete_ratio_weight * obsolete_ratio
- wear_weight * normalized_erase_count
- hotness_weight * recent_access
- risk_weight * ecc_warning_count
  • 垃圾比例高的块优先回收;
  • 已经高磨损的块应减少继续擦除;
  • ECC 告警多的块应优先迁移后退休;
  • 含关键元数据的块应使用更严格策略。

不要为了追求绝对均衡而造成巨大写放大。目标是让最坏块寿命满足产品周期,而不是让每个块擦除次数完全相同。

垃圾回收

追加日志和 COW 都会产生旧版本,因此必须有 GC。

1
2
3
4
5
6
7
8
9
10
flowchart TD
A[选择 victim block] --> B[扫描有效记录]
B --> C[为有效记录分配新位置]
C --> D[复制 + 校验]
D --> E[提交新映射]
E --> F[确认旧记录均已替代]
F --> G[擦除 victim]
G --> H{擦除校验成功?}
H -->|是| I[回到 FREE/PREPARED]
H -->|否| J[标记 RETIRED]

GC 不能在卷已完全写满后才启动。至少要保留完成“搬迁有效数据 + 更新映射 + 擦除旧块”所需的空间。

前台 GC 与后台 GC

  • 正常水位:后台逐步回收,限制单次耗时;
  • 低水位:提高 GC 优先级,暂停非关键写;
  • 危险水位:前台事务协助 GC,但必须有超时;
  • 临界水位:拒绝非关键写入,避免进入不可恢复死锁。

GC 的掉电恢复

GC 自身也是事务。恢复扫描必须能够识别:

  • 新副本尚未提交;
  • 新副本已提交但旧块未擦除;
  • 旧块正在擦除;
  • 映射已更新但 BBT 日志未完成;
  • 迁移中目标块写失败。

安全规则仍然是:只要旧块尚未确认可回收,就把旧版本视为恢复候选;新版本必须通过完整校验和 generation 比较后才能成为当前版本。

ECC、CRC、哈希与冗余的边界

机制 能解决什么 不能解决什么
ECC 检测并纠正一定数量的位错误 不能保证事务原子性,不能防恶意修改
CRC 检测随机数据损坏和半写记录 不能纠错,不能提供密码学完整性
Hash 检测大对象内容变化 普通哈希仍不能防攻击者替换数据与哈希
HMAC/签名 提供认证完整性或来源验证 不能自动完成坏块迁移和空间回收
多副本 一份损坏时从另一份恢复 副本选择、共因失效和同步仍需设计
COW/Journal 保证更新过程可恢复 不能修复物理位错误,仍需 CRC/ECC

一个成熟方案通常是组合:ECC 处理介质位错误,CRC 识别记录完整性,COW 保证事务原子性,冗余副本提供恢复来源,签名用于固件和安全数据。

Scrub 与预防性搬迁

只在读失败后处理可能太晚。对于长期静态数据、关键配置和原始 NAND,应定期做 Scrub:

  1. 读取记录;
  2. 检查 ECC 纠正计数和 CRC;
  3. 若纠正次数超过阈值或 CRC 异常,从可靠副本恢复;
  4. 写入新块并校验;
  5. 更新映射;
  6. 将旧块转为 SUSPECTRETIRED

Scrub 周期应基于介质保持特性、温度、写入频率和安全等级,不应无条件高频执行,否则会增加功耗和磨损。

启动恢复流程

启动阶段不能简单“找到第一个 Magic 就挂载”。推荐流程如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart TD
A[上电/复位] --> B[初始化介质与读取几何]
B --> C[读取 BBT 双快照]
C --> D[选择最新完整 BBT]
D --> E[重放退休事件]
E --> F[读取映射 checkpoint]
F --> G[重放映射/事务日志]
G --> H[验证根元数据和关键对象]
H --> I{是否满足可写不变量?}
I -->|是| J[正常读写挂载]
I -->|否,但可读| K[只读/受限挂载]
I -->|否,关键数据有冗余| L[执行恢复迁移]
I -->|不可恢复| M[维护模式/回滚/出厂恢复]

启动恢复必须有时间上界。若每次启动都全盘扫描数 GB 原始 NAND,产品体验和看门狗设计都不可接受。可通过 checkpoint、分段索引、增量日志和后台继续扫描控制启动时间。

业务降级策略

容错的最终目标不是“无论发生多少故障仍无限写入”,而是让系统在不同健康状态下保持可预测行为。

1
2
3
4
5
6
7
8
9
stateDiagram-v2
[*] --> NORMAL
NORMAL --> DEGRADED: 备用池低/错误率升高
DEGRADED --> NORMAL: 修复完成且健康恢复
DEGRADED --> READ_ONLY: 无法保证下一次原子提交
READ_ONLY --> MAINTENANCE: 需要维修/重置/更换介质
NORMAL --> RECOVERY: 挂载发现可恢复不一致
RECOVERY --> NORMAL: 恢复成功
RECOVERY --> READ_ONLY: 仅可保留旧数据

分级处理建议

数据类型 正常状态 备用空间不足 只读状态
设备身份、校准参数、密钥索引 双副本或更多冗余写入 禁止非必要更新,优先保护 继续读取,拒绝覆盖
用户配置 事务更新 合并写、减少历史版本 使用最后有效版本
运行日志 正常循环写 缩短保留时间或停止低等级日志 停止持久化,保留 RAM 环形日志
统计计数 批量聚合后写 增大合并周期 允许丢失非关键增量
固件升级 正常 A/B 或 swap 禁止开始新升级 继续从已验证镜像启动
缓存和可再生成数据 正常保存 优先删除 直接丢弃并重建

降级决策应由数据等级驱动。不能为了保存调试日志而挤占最后的元数据备用块,也不能因为日志写失败就让设备主功能重启。

并发与实时性

Flash 擦除可能持续毫秒到秒级,且某些器件写擦期间会阻塞读取或 XIP。容错层还会引入校验、迁移和 GC,因此必须处理并发。

推荐:

  • 单写者模型或集中式 Storage Worker;
  • 上层通过队列提交事务;
  • 读取可并发,但映射切换需使用版本或锁保护;
  • ISR 不直接擦写 Flash;
  • 实时任务只读取已缓存或已提交数据;
  • GC 和 Scrub 可分片执行,每片有时间预算;
  • 看门狗喂狗点不能放在无法退出的重试循环中;
  • 使用优先级和配额,避免日志写入饿死关键配置提交。
1
2
3
4
5
6
7
8
flowchart LR
A[实时任务] -->|读已提交数据| C[Storage Cache/API]
B[普通业务] -->|提交写请求| Q[事务队列]
D[日志模块] -->|低优先级追加| Q
Q --> W[Storage Worker]
W --> T[事务/COW]
T --> M[介质可靠性层]
G[GC/Scrub] -->|时间片与配额| W

容量与寿命量化

写放大

1
WA = 实际介质编程字节数 / 业务有效写入字节数

实际写入包括:数据、记录头、CRC、映射日志、BBT、GC 搬迁、对齐填充和冗余副本。

擦除放大

1
EA = 实际擦除单元次数 / 理论最少擦除次数

估算寿命

粗略模型:

1
2
T_life ≈ N_usable_blocks * Endurance_per_block * Erase_unit_size
/ (Business_write_rate * WA_effective)

这只是初步模型。实际还受温度、保持时间、写入分布、静态磨损均衡、坏块增长、GC 效率和厂商规格限制影响。

元数据热点

即使总写入量很小,固定地址计数器、启动次数或状态字也可能快速耗尽。应使用:

  • 多槽轮转;
  • Gray code 或单向位图计数;
  • 批量累计;
  • 日志式记录;
  • 周期快照;
  • 元数据块动态迁移。

开源组件与方案对比

下面列出的组件并非都能直接替换本题架构。应根据介质类型、数据模型、RAM、容量和可靠性目标选择。

1. LittleFS

项目地址:littlefs-project/littlefs

设计文档:DESIGN.md

磁盘格式:SPEC.md

原理

LittleFS 面向微控制器和原始块设备,强调:

  • 掉电恢复;
  • 动态磨损均衡;
  • 坏块检测与绕过;
  • 有界 RAM 与 ROM;
  • copy-on-write;
  • metadata pair;
  • CRC 提交记录。

其 metadata pair 由两个物理块组成,块内以日志方式追加 commit;需要压缩时在成对块之间迁移。文件数据使用 COW 思路,旧结构在新结构提交前仍然存在。块设备驱动需要正确返回错误,文件系统才能将损坏块识别并绕过。

适用

  • MCU 片内或外部 NOR;
  • 需要目录和文件 API;
  • RAM 有限;
  • 要求断电恢复;
  • 中小容量数据分区。

注意

LittleFS 不是万能 FTL。对原始 NAND 的 ECC、出厂坏块标记、读扰和大规模 BBT,通常仍需要更底层的 NAND 管理。集成时必须严格匹配 read_sizeprog_sizeblock_sizecache_size 和驱动同步语义,并做真实掉电测试。

2. Zephyr NVS

官方文档:Non-Volatile Storage

原理

Zephyr NVS 将分区划分为多个 sector,以 FIFO 管理的循环方式追加 ID/数据。数据从 sector 起始方向写入,元数据从 sector 末尾反向写入。元数据含 ID、偏移、长度和 CRC;元数据 CRC 用来判断一次写是否完整,数据还可选择 CRC32。空间不足时切换 sector 并回收旧 sector。

适用

  • 少量到中量 KV;
  • 配置、计数、状态;
  • 不需要目录层次;
  • Zephyr RTOS 项目。

注意

NVS 解决记录一致性和循环存储,不等价于完整坏块映射层。若底层介质存在可增长坏块,需要驱动或更低层提供可靠的逻辑地址,或者在项目中补充失效 sector 隔离机制。

3. Zephyr ZMS

官方文档:Zephyr Memory Storage

原理

ZMS 是面向多类非易失性存储的 KV 系统,采用 sector、ATE 与循环管理,并提供 sector cycle 统计相关能力。它适合希望在 Zephyr 中使用更通用 KV 存储抽象的场景。

适用

  • Zephyr 新项目;
  • KV 数据;
  • 希望统一适配不同 NVM 技术;
  • 需要观察 sector recycle 次数。

4. Zephyr / Apache Mynewt FCB

Zephyr 文档:Flash Circular Buffer

Apache Mynewt 文档:FCB

原理

FCB 将 Flash 抽象为 FIFO。记录包含长度、数据和 checksum,按追加顺序写入;空间不足时由用户选择擦除最旧 sector 或停止写入。它适合日志和事件,不适合通用随机更新文件。

适用

  • 运行日志;
  • 传感器历史;
  • 崩溃记录;
  • 顺序事件流。

注意

FCB 的核心是循环追加,不负责复杂事务和任意对象更新。若一个 sector 擦除失败,仍需要底层能够隔离或替换该物理 sector。

5. Zephyr Stream Flash

官方文档:Stream Flash

原理

Stream Flash 使用 RAM buffer 将输入数据按 Flash 写入粒度分块写入,可保存和恢复长时间流式写入进度,并可在写后回调中校验读回数据。它常用于 DFU 镜像写入。

适用

  • 固件下载;
  • 大对象顺序写;
  • 断点续写;
  • 写后 SHA/CRC 校验。

注意

它主要解决流式写入和进度,不替代 A/B 镜像、签名验证、坏块映射或事务根切换。

6. Linux MTD + UBI + UBIFS

内核文档:UBIFS

原理

  • MTD 暴露原始 Flash 的读、写、擦除和坏块特性;
  • UBI 提供卷管理、磨损均衡和逻辑擦除块到物理擦除块的透明映射;
  • UBIFS 工作在 UBI 卷之上,提供日志、索引、恢复和文件系统语义。

UBI 的重要价值是把坏块和磨损从上层隐藏,使上层看到稳定的逻辑擦除块。UBIFS 通过日志重放恢复异常断电,不依赖传统块文件系统的 fsck 模式。

适用

  • Linux;
  • 原始 NAND 或大容量原始 Flash;
  • 需要通用文件系统;
  • 需要成熟坏块与磨损管理。

注意

UBI/UBIFS 的资源和复杂度通常不适合小型裸机 MCU,但其“可靠性层与文件系统层分离”的架构非常值得参考。

7. Eclipse ThreadX LevelX

项目地址:eclipse-threadx/levelx

原理

LevelX 为 NAND 和 NOR 提供磨损均衡,将逻辑 sector 映射到物理 Flash,并允许上层直接使用逻辑 sector 或与 FileX 配合。项目说明其更新采用多步骤过程,可在任一步骤中断,并在后续操作中恢复到合适状态。

适用

  • ThreadX/FileX 生态;
  • 需要独立 Flash translation/wear-leveling 层;
  • NAND 或 NOR;
  • 希望上层使用逻辑扇区接口。

注意

任何组件都必须结合目标版本和实际介质验证。尤其是 ECC、坏块增长、驱动错误语义和掉电窗口,应通过项目自己的故障注入覆盖,而不能只依赖库的功能说明。

8. ESP-IDF NVS 与 Wear Levelling

NVS 文档:ESP-IDF NVS

Wear Levelling 文档:Wear Levelling API

原理

ESP-IDF NVS 提供键值数据存储;Wear Levelling 组件在 SPI NOR partition 上提供读写擦和地址映射相关能力,并常与 FAT 文件系统结合。它体现了“上层文件系统不应直接承受 NOR 擦写寿命与扇区更新”的分层思路。

适用

  • ESP-IDF;
  • 参数和 KV;
  • FAT 分区需要磨损均衡层;
  • 外部或主 SPI Flash 分区。

9. MCUboot

设计文档:MCUboot Design

原理

MCUboot 通过 primary slot、secondary slot、scratch 或其他交换策略完成固件升级。swap-using-scratch 使用 scratch 区在两个镜像槽之间可靠交换,并依赖持久化 swap 状态在复位后继续流程。其关键思想是:升级不是直接覆盖正在运行的唯一镜像,而是保留可验证的恢复路径。

适用

  • 安全启动;
  • A/B 镜像;
  • 可回滚升级;
  • Zephyr、Mynewt 等 MCU 生态。

注意

升级区的局部写失败仍需由底层 Flash 驱动、分区设计和介质可靠性层处理。必须保证至少存在一个已验证可启动镜像,并在空间不足或坏块增长时禁止开始无法完成的升级。

10. FlashDB

项目地址:armink/FlashDB

中文文档:FlashDB 文档

原理

FlashDB 提供轻量级 KVDB 和 TSDB,面向嵌入式 Flash,强调低资源占用、磨损均衡和掉电保护。KVDB 适合配置和参数,TSDB 适合按时间追加的日志、传感器数据和告警记录。

适用

  • RT-Thread 或裸机/RTOS;
  • KV 参数;
  • 时序日志;
  • 资源受限设备。

注意

应区分“数据库记录层可靠性”和“物理坏块映射能力”。如果底层 NOR 扇区永久失效,应用仍需确认 FlashDB、FAL 和底层驱动组合是否能够从该错误恢复,必要时增加分区级重映射。

开源方案横向比较

组件 数据模型 主要介质 掉电一致性 磨损均衡 显式坏块/重映射定位 典型用途
LittleFS 文件系统 MCU NOR/块设备 动态 文件系统可绕过错误块,依赖驱动错误语义 通用小文件系统
Zephyr NVS KV Flash 循环 sector 通常依赖底层可靠地址 配置、状态、计数
Zephyr ZMS KV 多类 NVM sector 循环/周期统计 依赖具体后端 通用 KV
FCB FIFO 日志 Flash 记录级校验 循环擦除 依赖底层 日志、事件
Stream Flash 顺序大对象 Flash 可保存进度 无独立 FTL 依赖底层 DFU、大对象写入
UBI + UBIFS 卷 + 文件系统 原始 NAND/NOR UBI 负责 LEB/PEB 与坏块 Linux 原始 Flash
LevelX + FileX 逻辑 sector + 文件系统 NAND/NOR 多步骤恢复 LevelX 映射层 ThreadX 存储
ESP Wear Levelling + FAT 逻辑扇区 + FAT SPI NOR 取决于组合 由 WL 层抽象 ESP-IDF FAT 分区
MCUboot 固件镜像事务 Flash 强调可恢复升级 不负责通用均衡 依赖底层/分区 安全升级与回滚
FlashDB KV/TSDB Flash 需确认底层组合 配置、时序数据

表中的“有”不代表无需测试。真实可靠性取决于具体版本、配置、驱动、缓存策略、编译选项和硬件行为。

不同业务的推荐组合

场景一:少量关键配置

推荐:

1
2
3
4
5
6
7
双页或多槽参数区
+ generation
+ header/payload CRC
+ commit marker
+ 写后读回
+ 周期轮转
+ 备用元数据块

不一定需要完整文件系统。关键是旧页在新页提交前不擦除。

场景二:大量运行日志

推荐:

1
2
3
4
5
6
FCB / TSDB / append-only log
+ 每条记录 CRC
+ sector 序列号
+ 循环擦除
+ 低优先级 GC
+ 失效 sector 隔离

日志允许按优先级丢弃,不能占用最后的关键恢复块。

场景三:通用小文件

推荐:

1
2
3
4
5
6
LittleFS
+ 严格块设备驱动
+ 错误码透传
+ write/erase verify
+ 分区级健康统计
+ 掉电与坏块注入测试

场景四:Linux 原始 NAND

推荐:

1
MTD + UBI + UBIFS

不要在原始 NAND 上直接使用假设块永久可靠的传统块文件系统,除非已有硬件 FTL 把坏块和 ECC 完整隐藏。

场景五:固件升级

推荐:

1
2
3
4
5
6
MCUboot 或等价 A/B 方案
+ 镜像哈希/签名
+ primary/secondary slot
+ 可恢复 swap 状态
+ 至少一个已验证可启动镜像
+ 升级前空间与健康度检查

场景六:块设备已经有内部 FTL

例如 eMMC、UFS、SD。主机通常不管理物理坏块,应:

  • 使用成熟文件系统;
  • 检查块 I/O 错误;
  • 监控寿命与健康信息;
  • 对关键数据做应用层冗余与事务;
  • 防止断电导致文件系统和应用记录不一致;
  • 在设备进入只读或掉线前预警和迁移。

不要把主机侧“坏块表”强行套到已经管理型的块设备上。

参考实现框架

下面给出一个不绑定具体 RTOS 和文件系统的逻辑写入伪代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* @brief Atomically replace one logical record.
*
* The old mapping remains valid until the new record and mapping update have
* both been verified and committed.
*
* @param[in] object_id Logical object identifier.
* @param[in] data New record payload.
* @param[in] length Payload length in bytes.
*
* @return 0 on success, or a negative storage error code.
*/
int storage_replace(uint32_t object_id, const void *data, size_t length)
{
struct map_entry old_entry;
struct map_entry new_entry;
int ret;

ret = map_lookup(object_id, &old_entry);
if (ret < 0 && ret != STORAGE_ERR_NOT_FOUND) {
return ret;
}

for (unsigned int attempt = 0; attempt < STORAGE_ALLOC_RETRY_MAX; ++attempt) {
ret = allocate_prepared_unit(&new_entry.physical_unit);
if (ret < 0) {
return ret;
}

new_entry.generation = old_entry.generation + 1U;
new_entry.logical_id = object_id;

ret = record_program_and_verify(&new_entry, data, length);
if (ret < 0) {
quarantine_unit(new_entry.physical_unit, ret);
continue;
}

ret = record_commit(new_entry.physical_unit);
if (ret < 0) {
quarantine_unit(new_entry.physical_unit, ret);
continue;
}

ret = map_publish(&new_entry);
if (ret < 0) {
/* The old mapping is still authoritative. */
mark_obsolete_candidate(new_entry.physical_unit);
return ret;
}

if (old_entry.physical_unit != STORAGE_INVALID_UNIT) {
mark_obsolete(old_entry.physical_unit);
}

return 0;
}

return STORAGE_ERR_MEDIA_DEGRADED;
}

该伪代码的关键不是 API 名称,而是:

  • 旧映射始终保持到新映射发布;
  • 失败候选块被隔离;
  • 重试次数有界;
  • 映射发布失败不会破坏旧数据;
  • 返回“介质降级”而不是死循环。

错误码设计

不要把所有失败都压缩成 -1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum storage_error {
STORAGE_OK = 0,
STORAGE_ERR_IO_TRANSIENT = -1001,
STORAGE_ERR_TIMEOUT = -1002,
STORAGE_ERR_ERASE = -1003,
STORAGE_ERR_PROGRAM = -1004,
STORAGE_ERR_VERIFY = -1005,
STORAGE_ERR_ECC_CORRECTED = -1006,
STORAGE_ERR_ECC_UNCORRECTABLE = -1007,
STORAGE_ERR_CRC = -1008,
STORAGE_ERR_NO_SPARE = -1009,
STORAGE_ERR_READ_ONLY = -1010,
STORAGE_ERR_CORRUPT_META = -1011,
STORAGE_ERR_NOT_FOUND = -1012,
STORAGE_ERR_MEDIA_DEGRADED = -1013,
};

上层可以据此做不同处理:

  • IO_TRANSIENT:短暂重试;
  • VERIFY:换块;
  • NO_SPARE:进入降级;
  • READ_ONLY:继续提供读取;
  • CORRUPT_META:尝试冗余恢复;
  • ECC_CORRECTED:完成本次读取,但安排 Scrub。

可观测性与现场诊断

用户无感不等于工程师无信息。建议持久化或上报以下指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
total_read_ops
total_program_ops
total_erase_ops
read_retry_count
program_retry_count
erase_retry_count
verify_fail_count
corrected_ecc_count
uncorrectable_ecc_count
retired_unit_count
spare_unit_remaining
max_erase_count
min_erase_count
gc_move_bytes
write_amplification_estimate
last_storage_error
last_failed_physical_unit
mount_recovery_count
read_only_transition_count

健康数据本身也不能高频写固定地址。可在 RAM 聚合,按周期或事件阈值追加到低频健康日志中。

故障注入测试

没有系统性的故障注入,无法证明“用户无感”。测试应覆盖每一个持久化状态转换,而不是只在应用层随机断电。

1. API 级错误注入

对第 N 次操作返回:

  • 擦除失败;
  • 编程失败;
  • 读取失败;
  • 超时;
  • 可纠正 ECC;
  • 不可纠正 ECC;
  • 成功但数据不落盘;
  • 成功但部分字节错误。

2. 掉电点遍历

在以下位置逐点复位:

1
2
3
4
5
6
7
8
9
10
候选块分配后
擦除命令前/中/后
数据头写入后
payload 写入一半
CRC 写入后
commit marker 写入前/中/后
映射日志写入后
旧块标记 obsolete 前/后
GC 复制过程中
BBT 快照生成过程中

每次重启验证:

  • 最后完整提交是否可读;
  • 未提交版本是否被忽略;
  • 卷是否可挂载;
  • 是否出现空间泄漏;
  • 是否重复使用已退休块;
  • 恢复时间是否有界。

3. 坏块增长测试

逐步增加失效块,观察系统从正常、降级到只读的转换。必须验证:

  • 单块失败不影响卷;
  • 连续多个块失败不会破坏 BBT;
  • 元数据块失败可迁移;
  • 备用池低水位会触发告警;
  • 备用池耗尽后拒绝写入而非崩溃。

4. 长期磨损测试

通过缩小模拟介质耐久次数或使用 RAM/file-backed block device 加速:

  • 热点 KV 反复更新;
  • 日志循环写;
  • GC 高压力;
  • 冷热数据混合;
  • 多线程并发;
  • 温度与低电压组合测试。

LittleFS 项目本身包含 power-loss、bad-block、relocation 等测试思路,可用于参考测试模型;但目标产品仍要在自己的驱动和硬件上执行。

5. 元数据破坏测试

主动破坏:

  • BBT 主副本;
  • 映射 checkpoint;
  • 最新事务日志;
  • 超级块;
  • generation 字段;
  • CRC;
  • 长度字段;
  • commit marker。

验证系统不会越界解析、不会选择旧于当前的错误版本,也不会因单副本损坏直接格式化全盘。

量产与现场策略

量产阶段

  • 对目标分区执行擦除/编程/读回抽检或全检;
  • 记录初始坏块或退休块;
  • 初始化 BBT 和元数据双副本;
  • 写入介质 UUID、格式版本和产品代次;
  • 保留工厂校准和设备身份的独立恢复副本;
  • 不把“格式化成功”作为唯一存储验收标准。

老化阶段

  • 执行循环写、掉电和温度测试;
  • 统计错误块增长和擦除计数离散度;
  • 检查是否出现固定元数据热点;
  • 验证看门狗和异常复位下恢复时间。

现场阶段

  • 对退休块、备用池和 ECC 趋势设置告警;
  • 升级前检查介质健康和可用空间;
  • 升级失败保持旧版本可启动;
  • 允许导出诊断信息;
  • 在只读状态下仍保留核心功能和可维护通道。

常见错误方案

错误一:失败后无限重试

问题:硬失效不会被重试修复,反而阻塞系统并增加磨损。

正确做法:有限重试后隔离、换块、降级。

错误二:只维护 RAM 坏块表

问题:重启后失效块重新进入分配池。

正确做法:BBT 持久化、冗余、带 generation 和 CRC。

错误三:BBT 永远放在固定两个块

问题:BBT 自身成为写热点和单点。

正确做法:快照轮转、增量日志、元数据块可迁移。

错误四:写新数据前先擦旧数据

问题:任何中断都会同时失去新旧版本。

正确做法:COW 或 A/B,先新后旧。

错误五:只做 CRC,不做事务

问题:CRC 能发现半写,却不能恢复旧版本或保证多对象原子更新。

正确做法:CRC + 提交协议 + 冗余或 COW。

错误六:把 ECC 当成文件系统一致性

问题:ECC 只能处理一定数量的位错误,无法解决目录、映射和提交顺序。

正确做法:ECC 与事务层分别负责介质可靠性和结构一致性。

错误七:卷快满时才开始 GC

问题:没有空间搬迁有效数据,形成 GC 死锁。

正确做法:保留恢复空间并提前触发 GC。

错误八:任何挂载异常都自动格式化

问题:一个可恢复的元数据损坏会被格式化放大为永久数据丢失。

正确做法:先尝试副本、日志重放、只读挂载和导出,格式化应是最后且受控的动作。

错误九:把用户无感理解成不记录错误

问题:缺少趋势数据,无法在备用池耗尽前维护。

正确做法:业务路径自动恢复,诊断路径完整记录并上报。

面试回答组织方式

回答这类题时,可以按以下顺序展开:

  1. 先抽象问题:不是单个 Flash Block,而是非易失性介质局部失效、半写和元数据损坏。
  2. 明确原则:不原地覆盖、旧版本先保留、物理位置与逻辑地址解耦。
  3. 给出分层:驱动、介质可靠性、L2P/FTL、事务、业务存储。
  4. 描述写路径:分配备用块、擦除校验、写入、读回、提交、更新映射、回收旧块。
  5. 描述坏块管理:SUSPECT/RETIRED、BBT 冗余、备用池和迁移。
  6. 描述一致性:COW、journal、双副本、generation、CRC、commit marker。
  7. 描述寿命:磨损均衡、GC、Scrub、写放大和元数据热点。
  8. 描述降级:备用池不足进入受限写或只读,核心数据优先。
  9. 描述验证:掉电点遍历、错误注入、坏块增长、长期磨损和元数据破坏。
  10. 列出开源参考:LittleFS、Zephyr NVS/ZMS/FCB、UBI/UBIFS、LevelX、ESP Wear Levelling、MCUboot、FlashDB。

最终回答

可以将完整答案压缩为下面这段:

我不会让文件系统或业务直接绑定物理 Flash Block,也不会在原位置覆盖唯一有效数据。底层首先把擦除失败、编程失败、ECC 和校验错误转换成明确的介质错误;介质可靠性层进行有限重试、写后读回、故障块隔离和健康统计;逻辑存储层通过 L2P 映射、备用块池、GC 和磨损均衡把失效物理块替换掉;上层通过 copy-on-write、追加日志、双副本元数据、generation、CRC 和最后写入的 commit marker 保证掉电一致性。新版本未校验并提交前,旧版本始终有效。坏块表和映射表自身也采用双快照或日志化保存,不能成为单点。备用块不足时,系统优先清理日志和缓存,随后进入受限写或只读,而不是死机或自动格式化。最终再用掉电点遍历、擦写失败注入、ECC/CRC 破坏、坏块增长和长期磨损测试证明恢复路径有效。

一句话概括:把物理介质的不可靠性封装在可迁移的逻辑存储层,把任何写入都设计成可验证、可提交、可回滚的事务,并为元数据、备用空间和降级状态保留独立的恢复路径。

参考链接