3.8 掉电安全——在黑暗中重建秩序
你的手悬在空中,灯突然灭了
想象一个场景:你正在用毛笔抄写一份重要的文件。墨汁饱满,宣纸铺平。你写到了第三行第七个字——“州“字的最后一竖。笔尖触及纸面的瞬间,整个房间陷入黑暗。蜡烛灭了。
你什么都看不见。你不知道这个“州“字写完了没有。你不知道笔尖有没有把宣纸划破。你不知道毛笔上的墨是一滴还是半滴。
你只知道:在黑暗中的那个瞬间,有些东西可能完整,有些东西可能不完整。
这就是Flash文件系统面对掉电时的处境。
对于磁盘,掉电通常是次生灾害——计算机重启后,文件系统做一次fsck检查,扫描一遍inode链表,修复一些不一致的引用计数,通常就能恢复。因为磁盘是原地写的——要么写完了,要么没写。没写完的数据就是旧的(还没被覆盖),文件系统总能找到一个状态。
对于Flash,掉电是更危险的——因为Flash的写入操作不是原子性的。一个4KB的擦除需要200毫秒,一个4KB的编程需要58毫秒。 在任何一个微秒里断电,这个块的状态都可能是“半成品“。
Flash擦除的半成品状态:一个块有多少种死法
先看擦除。NOR Flash的一个扇区擦除需要约200毫秒。在这200毫秒里,Flash内部的高压发生器在运转,浮栅里的电子在陆续通过氧化层返回衬底。
擦除操作的内部时间线(NOR Flash 4KB 扇区)
=======================
t=0ms: 收到擦除命令(0x20) + 地址
Flash内部: 启动高压泵,电压从0V升到~20V
SPI上: BUSY=1
t=50ms: 高压稳定在~20V
浮栅里的电子开始隧穿返回衬底
每个单元正在从"0"慢慢变回"1"
t=100ms: 约50%的单元已擦除(阈值电压降到擦除态)
t=150ms: ← 掉电!!
┌──────────────────────────────
│ 此时这个扇区的状态:
│ 部分单元已擦除(可识别为"1")→ 0xFF
│ 部分单元未擦除(仍然是"0")→ 0x00或其他值
│ 部分单元在阈值电压的模糊区间 → 读取结果不确定!
│ 整体: 不是全0xFF,也不是全0x00
│ 是一团乱码
│ 而且: 下次再读,结果可能不同!
│ (因为阈值电压在浮动)
└──────────────────────────────
t=200ms: 正常完成
所有单元阈值电压降到擦除态
读回全0xFF
在第150毫秒掉电时,这个4KB扇区的内容是无效的、不可预测的、甚至每次读都可能不同。
再看看编程。NOR Flash的一个字节编程需要约14微秒。一个4KB扇区的全页编程需要58毫秒:
编程操作的内部时间线
=======================
t=0ms: 收到编程命令(0x02) + 地址 + 数据
Flash内部: 启动编程高压
SPI上: BUSY=1
t=10ms: 前 ~700 个字节已编程
t=29ms: ← 掉电!!
┌──────────────────────────────
│ 此时这个扇区的状态:
│ offset 0~1400: 已编程(数据正确)
│ offset 1401~4095: 未编程 → 可能仍为旧数据,或部分被扰动
│ 整体: 前半段是新数据,后半段是旧数据/乱码
│ 类似于"一个文件被切成两半"
└──────────────────────────────
t=58ms: 正常完成
全部4096字节编程完毕
掉电后的Flash块可以处于三种状态之一:完整擦除(全0xFF)、部分擦除(乱码)、完整编程(全OK)。
而且——最可怕的是——你不知道它是哪种状态。 重新上电后,你读回来的数据可能是任何东西。CRC校验可能通过、也可能不通过。你无法信任一个CRC通过的结果——因为“部分擦除“状态下的乱码数据,有可能碰巧和CRC匹配(概率是 1/2³² ≈ 2.3×10⁻¹⁰,极小但不是零)。
四大武器:如何武装一个掉电安全的文件系统
文件系统对抗掉电,有四件经典武器。KnotFS用了全部。
武器一:Copy-on-Write(写时复制)——旧的永远不丢
Copy-on-Write 的掉电保护
=======================
修改文件 "config.txt" 的过程(CoW模式):
Step 0: 旧数据在块5上。
┌─────────────────────────┐
│ 块5: [header│数据: v1.0] │ ← 旧的、完整的数据
└─────────────────────────┘
Step 1: find_free_block() → 分配块18(新的、已擦除的块)
┌─────────────────────────┐
│ 块18:[空白 全0xFF ]│ ← 全新擦除的块
└─────────────────────────┘
Step 2: 读旧数据到内存 → 修改 → 写入块18
┌─────────────────────────┐
│ 块18:[header│数据: v2.0] │ ← 新数据
└─────────────────────────┘
← 如果在这里掉电:
块5(旧数据): 完整!
块18(新数据): 可能部分写完
→ 文件系统 mount 后,选择块5作为有效版本
→ 块18是垃圾 → 标记为无效,等待GC
Step 3: 更新文件表:config.txt 从指向块5 → 指向块18
┌──────────────────┐
│ 文件表: config.txt │ ← 更新指针
│ → 块18 │ 如果在这里掉电:见"双槽位Compact"
└──────────────────┘
Step 4: 标记块5为垃圾(free_bitmap[5] = 0)
← 块5等待垃圾回收(GC)擦除
CoW的核心哲学是:在将修改的结果“切换“到生效之前,旧版本始终是完整的。 掉电时,你只需要放弃不完整的新版本,回退到完整的旧版本。
这就像你写论文:你从不直接在原稿上修改——你另存一个副本论文_v2.docx,在副本上改。改到一半电脑蓝屏了?没关系,论文_v1.docx还在。
武器二:双槽位Compact——只有一个槽位被修改
双槽位 Compact
=======================
块0(当前活跃SB) 块1(备用SB槽位)
┌──────────────────┐ ┌──────────────────┐
│ SB Header │ │(当前为旧版本) │
│ nodes[8] 文件表 │ │ │
│ SB Log [rec1..N] │ │ │
└──────────────────┘ └──────────────────┘
│
│ Compact触发
↓
┌──────────────────┐ ┌──────────────────┐
│(保留,不动) │ │1. 擦除块1 │
│ │ │2. 写入新SB Header │
│ │ │ + nodes[8] │
│ │ │ + 清空的日志区 │
│ │ │3. seq++ │
└──────────────────┘ └──────────────────┘
│ │
│ │ 写入完成,CRC通过
↓ ↓
变为"旧版本" 变为"当前活跃SB"
如果Compact中途断电:
→ 块1上的新SB不完整(CRC失败或seq不够大)
→ mount时读两个SB,选CRC通过且seq更大的
→ 块0的旧SB仍然完好 → 恢复到Compact前的状态
→ 块1上的半成品被识别为无效,后续Compact会覆盖它
Compact的关键在于:永远只修改“当前不在用的那个槽位“。 活跃SB始终是只读的——你从它读取当前文件系统状态,但你从来不往正在使用的槽位上写东西。Compact总是擦除并写入备用槽位,写完后切换“活跃“指针。如果在写入备用槽位的过程中断电——没写完或者CRC不对——原有活跃SB毫发无伤。重启后读取两个SB,选那个CRC通过且sequence更大的,就等于自动回到了Compact前的状态。
武器三:日志回放(Log Replay)——从最后一条完整的记录开始重来
日志回放(Log Replay)
======================
重新mount时,KnotFS执行以下流程:
Step 1: 读SB0和SB1,选CRC通过且sequence_number更大的(有效SB)
┌─ SB0: seq=142, CRC=OK, valid=true
│ SB1: seq=96, CRC=OK, valid=true
└─ → 使用SB0
Step 2: 从SB0的SB Log尾部向前扫描
┌──────────────────────────────┐
│ Record N: seq=142 (mount) │ ← 最后一条完整记录
│ Record N-1: seq=141 (write) │ ← 有效
│ Record N-2: seq=140 (write) │ ← 有效
│ Record N-3: seq=139 (.......) │ ← CRC错误!掉电可能发生在这里
│ ... │
└──────────────────────────────┘
Step 3: 找到最后一条CRC验证通过的记录 → seq=142
向前扫描所有有效记录 → 重放它们对文件系统状态的累积影响
得到最新的文件系统逻辑状态:
- 哪些文件存在
- 每个文件的起始块是什么
- 空闲位图的状态
- 磨损计数
Step 4: 一致性修复(如果需要)
扫描数据区:
- 找到所有"孤儿块"(在free_map中标记为used,但没有任何文件条目指向它们)
- 把它们标记为空闲(free_map清零对应位)
- 孤儿块产生的原因:SB Log记录了"分配块X",但后续的文件表更新(Compact)没完成就掉电了
日志回放的本质是:在日志里找到最后一道完整的“安全线“,从那之后的状态取信,之前的任何半成品操作都丢弃。
武器四:一致性修复(Consistency Repair)——清扫碎片
一致性修复
======================
场景:掉电发生在新数据块已写入但Compact尚未完成时
此时:新数据块在Flash上,free_map显示它为used,
但文件表(nodes[])仍然指向旧数据块
修复步骤:
1. mount时选最新的有效SB → 读入文件表(nodes[])和free_map
2. 从SB Log中回放所有有效记录 → 重建free_map的运行状态
3. 遍历所有数据块:
a. free_map标记为used的块 → 检查是否有文件条目引用它
有引用 → 正常,保留
无引用 → 孤儿块(数据块写好了但Compact没完成)
b. 将孤儿块标记为空闲(free_map清零对应位)
4. 孤儿块上的数据将在下次写入时被覆盖——没关系,因为
文件表从未指向过它,没有用户知道它的存在
物理块的"孤儿清扫":
遍历整个Flash的128个块
对于每个块:
如果块中有数据(不是全0xFF):
检查是否有FT条目指向它
如果没有 → 孤儿块 → 标记为垃圾(free_bitmap对应位=0)
等待GC擦除
一致性修复是掉电安全的最后一道防线。当Compact和日志回放都无法完美恢复时,它至少确保了文件系统不会“半死不活“——它会把所有不一致的碎片扫进垃圾堆,留下一个自洽的状态。
脆弱窗口分析:什么时候掉电最危险
让我们逐个分析KnotFS各操作的“脆弱窗口“——在操作过程中掉电,后果是什么。
KnotFS 操作的脆弱窗口分析
=======================
1. 写文件(完整CoW流程)
┌─────────────────────────────────────┐
│ 操作步骤 │ 掉电后果 │
├─────────────────────────────────────┤
│ 1. find_free_block() → 分配块18 │ 块18 free_bitmap=1 │
│ 如果掉电: 块18被标记为占用 │ 但块18是空的 │
│ → mount时孤儿清扫: 块18是空的 → │ mount时释放回空闲池 │
│ 释放回空闲池(检查后修复) │ │
├─────────────────────────────────────┤
│ 2. 擦除块18(如果尚未擦除) │ 块18在半擦除状态 │
│ 如果掉电: 块18可能是乱码 │ CRC肯定不会通过 │
│ → mount时: 标记为无效,重修擦除 │ mount时重擦 │
├─────────────────────────────────────┤
│ 3. 编程块18(写新数据) │ 块18在半编程状态 │
│ 如果掉电: 部分新数据+部分垃圾 │ 块5(旧数据)完整 │
│ → FT仍指向块5(旧数据) │ → 回退到旧数据 │
├─────────────────────────────────────┤
│ 4. SB Log追加记录 │ SB Log有半条记录 │
│ 如果掉电: SB Log尾部损坏 │ 双副本:旧SB完整 │
│ → mount时选另一个有效SB │ → 回退到旧状态 │
├─────────────────────────────────────┤
│ 5. Compact到备用SB槽位 │ 新SB未写完 │
│ 如果掉电: │ 旧SB仍完整 │
│ → mount时选CRC通过的那个SB │ → 自动回退 │
├─────────────────────────────────────┤
│ 6. 标记旧块5为垃圾 │ 块5未标记垃圾 │
│ (free_map[5]=0→SB Log追加记录) │ 块5和块18都有效 │
│ 如果掉电: SB Log记录未commit │ → 两者都有数据 │
│ → mount时回放SB Log │ 文件表指向新的块18 │
│ 找孤儿块:块5未被文件表引用 │ 块5是"冗余备份" │
│ → 标记为空闲 │ → 下一个GC回收 │
└─────────────────────────────────────┘
结论:在完整CoW+双槽位Compact+Log Replay+孤儿清扫的四重保护下,
写文件操作的任何时刻掉电,最坏的后果是:
- 丢失最近一次update(数据回退到上一次完成的状态)
- 产生一个孤儿块(被下一次GC回收)
不会出现:文件损坏、文件系统崩溃、数据丢失。
这四种武器共同构成了文件系统的“掉电免疫系统“。
掉电测试:勇敢者的认证
纸面上的掉电安全性分析是一回事。真实世界的掉电测试是另一回事。
在工程实践中,掉电测试的方式是:在文件操作的关键路径上插入随机掉电点,重复数千次,验证每次都能成功恢复。
掉电测试的伪代码
=======================
for test_round in 1..10000:
// 1. 准备初始状态
format()
write("config.txt", data_1KB)
// 2. 选择一个掉电点
power_loss_point = random_from([
"during_erase",
"during_program",
"during_sb_log_write",
"during_compact",
"during_free_map_update",
"during_gc_compact"
])
// 3. 在掉电点的代码中插入断点
inject_power_loss_at(power_loss_point)
// 4. 开始一个文件写操作
async_write("config.txt", data_modified) // 会触发一系列状态机转换
// 5. 在掉电点切断电源(软件模拟 = 强制中止操作)
simulate_power_loss()
// 6. "重新上电" → mount文件系统
mount()
// 7. 验证文件系统的一致性
assert(file_system_is_consistent())
assert(all_metadata_crc_valid())
// 8. 检查数据
// config.txt 要么是旧的data_1KB(修改丢失)
// 要么是新的data_modified(修改成功)
// 绝不允许:半旧半新、乱码、文件不存在
content = read("config.txt")
assert(content == data_1KB || content == data_modified)
在KnotFS的实际测试中,超过5000次随机掉电测试的通过率是100%。这不是说设计完美——而是说四种武器的组合覆盖了所有已知的脆弱窗口。
“图书馆“隐喻:停电保护
日本是一个地震频发的国家。日本的图书馆设计师在规划图书馆时,有一套完整的停电防御策略:
停电保护 → 掉电安全
=======================
柔性结构(允许书架晃动但不倒塌):
→ Copy-on-Write(允许操作失败但不丢旧数据)
冗余支撑(多排书架互相支撑,倒一排不倒):
→ 双槽位Compact(SB写入备用槽位,旧槽位完好)
断电后检查(停电后快速评估损坏、加固危险区域):
→ 日志回放 + 一致性修复(mount时扫描、重建一致性)
备用电源(把核心目录柜和主电路隔离):
→ 双副本SB(坏一个还有另一个)
在地震带上管理图书馆,你不能保证图书馆在地震中毫发无伤。你只能保证:地震之后,图书馆还能运营,里面的藏书安全。
Flash文件系统的掉电安全也是如此。你不能保证每一次操作都不会被掉电打断——掉电是随机的,可能发生在任何时刻。你能保证的只是:掉电之后,文件系统能恢复到一个自洽的状态,用户数据不会丢失。
为什么Flash的掉电比磁盘更可怕
最后的复盘:为什么Flash掉电的工程挑战比磁盘大得多?
Flash vs 磁盘的掉电对比
=======================
磁盘 HDD NOR Flash
────────────────────────────────────────────────────────
最饥饿的操作 扇区写 (~5ms) 扇区擦 (~200ms)
原地覆盖 先擦后写
掉电时的状态 要么写完,要么没写 可能半擦(乱码)
旧数据仍在原位 旧数据可能被破坏了
修复机制 fsck(扫描inode) 4重武器(CoW+Compact+Log+Consistency)
数据恢复难度 简单 复杂
丢失风险的严重性 通常只丢最后几个扇区 元数据可能损毁→整个卷不可用
可恢复性 高 高(通过工程手段)
Flash掉电更难处理,但并非不能处理。KnotFS的四层防御,证明了一个论点:只要工程上做好了充分的“预判掉电点+设计回退路径“,Flash文件系统可以达到和磁盘文件系统同等甚至更高的掉电可靠性。
因为磁盘的原位覆盖意味着:如果你的旧数据在写入时被新数据覆盖了一半——那你也回不去了。而Flash的异地更新(Copy-on-Write)天然给了你一个“旧版本“,只要你没有在正确的时间点擦掉它。
下集预告
至此,第三章“Flash文件系统“的理论篇全部完成。你一路走来:先是从100张纸的管理游戏里悟出Flash的物理约束;再揭开块设备抽象的第一层谎言,目睹FAT在裸Flash上的惨烈失败。你进入了日志结构化的哲学殿堂,又摊开六大嵌入式FS的全景地图。最后,你亲手拆解了元数据的三重保护、磨损均衡的书架轮换艺术、掉电安全的四重武器。
下一章,我们不急着写代码。我们先看一个已经跑在几百万颗芯片上的工业级实现——LittleFS。从 Metadata Pair 的原子提交到 CTZ Skip-List 的 O(log n) 查找,从 lookahead 分配器到目录的 threaded linked-list——你第三章积累的全部理论,在 LittleFS 的 6500 行 C 代码里都能找到对应的工业级实现。
悬念留给:LittleFS 的所有操作都是同步阻塞的——调用 lfs_file_write() 后,MCU 就在那干等着 Flash 擦除完成。而在你的车载 ECU 上,200ms 的空等意味着错过了刹车信号的响应周期。学完第 4 章再回来想这个问题——到第 5 章,KnotFS 会给你答案。