Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 会给你答案。