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

2.4 Flash的三原罪——写前擦除·单向编程·有限寿命

你是一个被困在浮栅里的电子

想象一个场景:你不是在写代码。你是一个电子,被Fowler-Nordheim隧穿效应强行塞进了一块Flash存储单元的浮栅里。

你的四周是10纳米厚的二氧化硅。这层墙,你出不去。你被关在这里的唯一目的,是告诉外面的读出放大器一个信息:“1“或者“0”。具体来说——如果你和你的同伴们足够多(大约几千个电子),浮栅的阈值电压就被拉高了,读出放大器判断这是“0“。如果你们很少,阈值电压低,判为“1“。

你是“0“。

你想出去吗?你以为擦除操作会让你们出去?

是的。擦除是唯一的出路。当擦除脉冲来临——一个大约20V的强电场反向施加在氧化层两端——你和你的同伴们会通过F-N隧穿效应,原路返回衬底。浮栅空了,阈值电压降下来,“0“变回了“1”。

但这里有一个关键限制:你只能等人来开门。你不能自己出去。而且开门的人必须把整个牢房区一起打开。

这就是Flash的第一原罪。


原罪一:写前擦除(Erase-Before-Write)

在磁盘上,修改一个字节就是修改一个字节。磁头飞到目标扇区,翻转一个磁畴,完成。新数据覆盖旧数据,原地更新。

在Flash上,修改一个字节——不可能。

为什么?

因为Flash的物理编程操作只能做一件事:把“1“变成“0“。更精确地说,是向浮栅注入电子,拉高阈值电压。反向操作——把“0“变回“1“——需要擦除。而擦除不是以字节为单位的。擦除的粒度是一个扇区(sector,4KB)或者一个块(block,64KB甚至256KB)。

这意味着什么?我们来看一个具体场景。

假设你有一个64KB的NOR Flash块,里面存着一段固件的标定数据。你只需要修改其中4个字节(一个校准参数)。你的思路是什么?

朴素的想法(磁盘思维——错误!)
=======================

修改前:  [ABCDEFGHIJKLMNOPQRSTUVWXYZ...]  64KB数据
                ↑
          要改这4个字节

你希望:  [ABCDEFGH####MNOPQRSTUVWXYZ...]  直接覆盖4个字节
                ↑
          写入新数据

现实:    ✗ 做不到。
         Flash编程只能把1→0,不能把0→1。
         这4个字节里,有些位需要从0→1,
         你做不到。

你只能擦除整个64KB块,把它全部变成“1“,然后重新编程:

Flash的被迫做法(Copy-on-Write)
=======================

Step 1: [ABCDEFGHIJKLMNOPQRSTUVWXYZ...]  读整个块到RAM缓冲区

Step 2: [ABCDEFGH####MNOPQRSTUVWXYZ...]  在缓冲区里修改那4个字节

Step 3: [████████████████████████████████]  ← 擦除整个64KB块
         ████████████████████████████████     耗时~200ms(NOR)或 ~3ms(NAND)
         ████████████████████████████████
         全部变成 0xFF 0xFF 0xFF...

Step 4: [ABCDEFGH####MNOPQRSTUVWXYZ...]  把缓冲区数据编程回Flash
                                        ← 耗时 ~几毫秒
总耗时: ~200ms + 读时间 + 写时间

你为了修改4个字节,擦除了65536个字节,然后重写了65536个字节。效率是4/65536 = 0.006%。

这不是bug。这是Flash的物理设计决定的。NAND的擦除粒度更大——通常是一个block(几MB)。擦除用时的绝对值小一些(~3ms),但粒度更大。无论哪种Flash,这个问题都绕不开。

所以Flash文件系统必须采用一种完全不同的策略:异地更新(Out-of-Place Update)

异地更新策略
=======================

旧位置:  [ABCDEFGHIJKLMNOPQRSTUVWXYZ...]  ← 标记为"垃圾"
         垃圾 垃圾 垃圾 垃圾 垃圾 ...

新位置:  [ABCDEFGH####MNOPQRSTUVWXYZ...]  ← 写入新数据到一个
                                             空闲的擦除过的块
        ↑
   只有这4个字节不同

旧位置不是立即被擦除的。它只是被标记为"无效"。
当积攒了足够多的垃圾后,
垃圾回收(Garbage Collection)批量擦除它们。

这意味着:Flash文件系统没有“原地修改“这个概念。 你看到的每一个“修改文件“操作,在物理层都是一次全新的写入。旧数据成为垃圾,等待回收。

像不像你在写论文时,每次修改不是直接改段落,而是把整篇论文从头抄一遍,把要改的地方改了,然后把旧版扔进废纸篓?

这就是Copy-on-Write的哲学。它不是Flash文件系统设计的“选择“,而是被迫的唯一解


原罪二:单向编程

让我们更深入地看Flash编程的物理过程。

编程一个Flash单元,是在做这个操作:1 → 0。你给浮栅加电子。

擦除一个Flash单元,是在做这个操作:0 → 1。你把浮栅里的电子移除。

这两个方向的操作,机制完全不同:

操作方向机制电压粒度
编程1→0CHE (沟道热电子注入) 或 F-N隧穿~10-20V pulses字节/页
擦除0→1F-N隧穿(反向)~20V扇区/块

NOR Flash的编程使用沟道热电子注入(CHE)——速度快但功耗高;NAND Flash的编程使用F-N隧穿——功耗低但速度慢。 注意“粒度“这一列。编程的最小单位通常是字节(NOR)或页(NAND,通常2KB-16KB)。擦除的最小单位是扇区(4KB-64KB)或块(几十KB到几MB)。

擦除和编程的粒度不对称。这就是全部问题的根源。

你可以在一个已经擦除的块里,一个字节一个字节地编程。没问题。但如果其中某个字节已经写了“0“,你想把它改成“1“——对不起,你必须擦除整个块。

编程 vs 擦除的粒度不对称
=======================

编程粒度(NOR):  █ ← 1 字节
编程粒度(NAND):  ████████████████ ← 1 页 (2KB~16KB)

擦除粒度(NOR):    ████████████████████████████████████████████████
                    ← 1 扇区 (4KB~64KB)

擦除粒度(NAND):   ████████████████████████████████████████████████
                    ████████████████████████████████████████████████
                    ████████████████████████████████████████████████
                    ← 1 块 (通常64~256页,即几MB)


                        编程粒度        擦除粒度         不对称比例
        NOR             1B              4KB ~ 64KB       4096:1 ~ 65536:1
        NAND            2KB ~ 16KB      64页 ~ 256页      64:1 ~ 256:1

这个不对称比例直接决定了Flash文件系统的数据结构设计。你不能像FAT32那样用链表把扇区串起来,因为“修改一个FAT表项“就意味着一次擦除-重写。你必须把元数据也做成log-structured——把修改追加到末尾,而不是原地覆盖。

Flash的“写前擦除”原罪,催生了Flash文件系统设计中两条截然不同的技术路线:

1.FTL(Flash Translation Layer)路线:在Flash之上加一层逻辑-物理映射,让上层文件系统(如FAT32、ext4)以为自己可以原地修改,实际上FTL在背后做异地更新。代价是映射表的内存开销和写放大。

2.原生Flash文件系统路线:文件系统自身感知Flash的物理特性,直接在上面构建日志结构化(log-structured)的数据结构。后面要讲的KnotFS走的就是这条路——它的SB Log、所有数据块都只追加、不覆盖,每一次“修改”都是一次新的写入。

两条路都是对“原地修改不可能”的回应,只是分工不同:FTL把复杂性藏在驱动层,原生文件系统把复杂性暴露在文件系统层。


原罪三:有限寿命

Flash会死。

不是比喻。它会死。每一次擦除操作,都在物理上磨损隧道氧化层。

擦除时,你用一个强电场(~20V)把电子从浮栅拉出来。电子穿过氧化层的这个过程,会造成陷阱(traps)——氧化层-硅界面的缺陷态。每一个陷阱,都是一个新的泄漏通道。陷阱多了,电子逃逸就快了。电子逃逸快了,阈值电压就漂移了。阈值电压漂移了,读取结果就错了。

这是一个不可逆的退化过程。

Flash 单元寿命退化过程
=======================

P/E = 0(全新):
  ┌─────────────────┐
  │ 浮栅 ██████████ │  氧化层光滑完整
  │ ████████████████│  ← 电子乖乖待在浮栅里
  │ ████████████████│  几乎没有泄漏
  └─────────────────┘

P/E = 10,000(NOR 10%寿命):
  ┌─────────────────┐
  │ 浮栅 ██████████ │  氧化层开始出现陷阱
  │ █████X████X██████│  ← X = 陷阱,电子从这里泄漏
  │ ██████X██████████│  泄漏速率开始上升
  └─────────────────┘

P/E = 100,000(NOR 寿命极限):
  ┌─────────────────┐
  │ 浮栅 ██████████ │  氧化层布满陷阱
  │ ████X████X██X██ │  ← 电子泄漏失控
  │ X████X████X█████ │  阈值电压无法稳定
  └─────────────────┘
  单元表现:
    - 编程时间变长(需要更强的脉冲才能达到目标电压)
    - 擦除时间变长(氧化层退化导致F-N隧穿效率下降)
    - 数据保持时间骤降(电子从陷阱中快速泄漏)
    - 最终:无法编程或无法擦除 → 坏块

对于NOR Flash,这个寿命极限大约是100,000次P/E周期。对于消费级TLC NAND,大约是1,000到3,000次

10万次听起来很多,对吧?我们算一笔账。

假设你有一个NOR Flash块,你的应用每小时擦除它一次:

100,000 次 ÷ 24小时 ÷ 365天 ≈ 11.4 年

看起来还好。但问题在于磨损不均衡

你所有的文件操作不是均匀分布在所有块上的。设想一个场景:你的系统有一个小配置文件(比如几百字节),每次系统启动都更新一次时间戳。这个文件可能只占一个扇区。如果系统每次启动都擦写同一个扇区——

一天启动10次 × 365天 = 3,650 次/年
100,000 次 ÷ 3,650 次/年 ≈ 27 年

看起来还行。但如果是车载应用——

引擎点一次火 = 一次启动
每天100次启动 × 365天 = 36,500 次/年(比较极端的假设)
100,000 ÷ 36,500 ≈ 2.7 年

一块专门用来存“系统启动时间戳“的NOR Flash块,在车上的寿命不到3年。

这就是为什么你需要磨损均衡(Wear Leveling)

磨损均衡的核心思想很简单:不要让热数据一直钉在同一块上。 每次写入都选一个磨损次数最少的空闲块。这就需要对所有块的擦除次数进行全局跟踪。

磨损均衡示意
=======================

无磨损均衡:
  块0:  ████████████████████████████ 99999次(快死了)
  块1:  █ 1次
  块2:  ██ 2次
  ...
  块127: 0次(完全没用过)

  问题:块0提前死亡

有磨损均衡:
  块0:  ████ 800次
  块1:  ████ 790次
  块2:  ████ 810次
  ...
  块127: ████ 795次

  所有块均匀老化,整体寿命最大化

这又把我们带回了文件系统里的磨损计数数组和自由块选择函数。它们不是锦上添花的优化——它们是Flash文件系统的基本生存策略。


次原罪们

除了这三大原罪,Flash还有几个“次原罪“——它们不像前三个那样决定文件系统的根本架构,但在工程实践中同样必须应对。

次原罪一:读干扰(Read Disturb)

我们在上一节已经聊过。反复读取一个位置的单元,会在相邻字线上造成轻微的软编程。这对于NAND的平面架构尤其严重,因为同一个块里的所有单元共享字线。解决方案是:跟踪每个块的读取次数,超过阈值后把数据迁移到新块,擦除旧块。

次原罪二:编程干扰(Program Disturb)

当你给一个单元编程时——也就是在字线上加高压——同一条字线上其他不需要编程的单元也会感受到这个高压。它们可能被“意外编程“——阈值电压轻微上移,擦除态的“1“慢慢向编程态的“0“漂移。厂商的解决方案是:在编程算法中使用自增压抑制技术(Self-Boosted Program Inhibit),在不需要编程的单元沟道上施加一个自增压电场来抵消编程电压。

但工程上还是需要文件系统做额外防护:写数据后验证、记录错误位置、避免对同一条字线反复部分编程。

次原罪三:数据保持(Data Retention)

我们已经详细讨论过电荷泄漏。这里补充一个冷知识:数据保持能力不仅取决于温度,还取决于上一次擦除到现在的时间。 一块Flash芯片,出厂时(P/E=0)的数据保持最好,经历了10万次擦除(P/E=100,000)之后,数据保持能力大约只有出厂时的十分之一。

这意味着:在芯片生命周期的后期,你的ECC必须更强。 LDPC软判决解码的计算负载在芯片年迈时显著上升——这也是为什么老SSD读速度比新SSD慢的原因之一。


和磁盘比:Flash设计的另一种哲学

让我们把Flash和磁盘放在一起对比,你会看到Flash文件系统设计的全部动机。

Flash vs 磁盘:一个对比框架
=======================

                       磁盘 HDD                NOR Flash              NAND Flash
────────────────────────────────────────────────────────────────────────────────
读粒度                 512B/4KB                字节                    页 (2KB-16KB)
写粒度                 512B/4KB                字节 (1→0)              页 (2KB-16KB)
擦除粒度               无(直接覆盖)           扇区 (4KB-64KB)          块 (几十KB~几MB)
擦除时间               不需要                   50ms-200ms              1ms-5ms
原地修改               ✓                       ✗                       ✗
P/E寿命                无限(理论上)            100,000                  500-100,000
随机读延迟             5-10ms(寻道)           70-100ns                 ~25μs
随机写延迟             5-10ms(寻道)           需要GC,延迟极不确定      需要GC,延迟极不确定
碎片化影响             寻道时间增加             无(随机访问平坦)        无
是否需要磨损均衡       不需要                   需要                     需要
是否需要垃圾回收       不需要                   需要                     需要
是否需要ECC            不需要(物理CRC即可)    简单ECC即可              BCH/LDPC强ECC

磁盘只需要考虑一个问题:寻道时间。 Flash需要考虑擦除粒度、编程方向性、P/E磨损、读干扰、编程干扰、数据保持……

磁盘文件系统的核心问题是:“数据放在哪些扇区,可以减少寻道?”

Flash文件系统的核心问题是:“数据放在哪些块,可以让磨损均匀、垃圾最少、GC最省、掉电最安全?”

完全是两种哲学。

磁盘的速度瓶颈是机械运动。Flash的速度瓶颈是物理退化。

磁盘的可靠性靠物理冗余(RAID)。Flash的可靠性靠数学冗余(ECC)。

磁盘会坏,通常是磁头撞盘或者马达故障——直观、物理。Flash会坏,是电子从10纳米宽的氧化层里慢慢泄漏——微观、量子、不可见。

你理解了这些区别,你就理解了为什么从磁盘移植一个文件系统到Flash上(比如FAT32直接跑在NAND上)是个灾难——因为它假设了“可以原地修改“和“没有磨损“,而这两个假设在Flash上都是错的。

如果FAT32直接跑在Flash上(噩梦场景)
=======================

FAT32的FAT表是一个频繁修改的热数据区域。
每次文件写入,都要更新FAT表项。

在磁盘上:
  FAT表项更新 → 磁头移到FAT区域 → 覆盖写入 → 完成
  成本:一次寻道

在Flash上:
  FAT表项更新 → 擦除FAT所在的整个扇区 → 重写整个扇区
  成本:一次擦除 + 写放大 × 扇区大小

如果每秒钟发生10次FAT更新:
  磁盘:10次寻道
  Flash:10次擦除,每秒钟烧掉一个扇区
  一天 = 864,000次擦除 → 超出NOR Flash寿命(100,000次)8.6倍
  一个星期 = Flash死亡

这就是为什么FTL(Flash Translation Layer)存在——它在FAT32和Flash之间插了一层逻辑-物理映射,让FAT32以为自己在“原地修改“,实际上FTL在背后做Copy-on-Write和垃圾回收。

而你设计的文件系统,是一个“知道自己在Flash上“的文件系统。它不需要FTL,因为它本身就是为Flash的三个原罪而生的。


写放大:原罪之下的原罪

三个原罪联手,产生了一个更具破坏性的次生效应:写放大(Write Amplification)

写放大的定义很简单:

写放大系数 = 实际写入Flash的字节数 ÷ 文件系统请求写入的字节数

在理想世界里(磁盘的理想世界),写放大系数 = 1。你写1KB数据,磁盘就写1KB。但Flash不是理想世界。

来看一个具体的TLC NAND SSD场景:

一次小写入如何被放大
=======================

应用层:write("log.txt", 512字节)
  ↓
文件系统:找到文件所在的4KB逻辑页
  ↓
FTL:该逻辑页映射到物理块A的第3页
     ← 但NAND的最小编程单元是页(16KB)!
     ← 你不能只写512字节到一页上
  因此:
      1. 读取物理块A第3页的全部16KB到缓冲区
      2. 在缓冲区中修改那512字节
      3. 分配一个新的空闲块B
      4. 将修改后的16KB数据写入块B的第0页
      5. 将块A中其他有效页的数据复制到块B
      6. 更新映射表:逻辑块→物理块B
      7. 擦除块A(等待GC时机)
  
  实际写入量:至少16KB(新页) + 其他页的复制
  写放大系数:> 32倍(16KB ÷ 512B)

这还只是一次操作。如果持续大量写入,GC垃圾回收会进一步放大写放大:

GC的二次放大效应
=======================

场景:一个NAND块有128页
  其中:100页是有效数据,28页是垃圾(已标记无效)
  可用空间不足 → 触发GC

GC过程:
  1. 选择牺牲块(victim block)——选垃圾最多的块
  2. 读出牺牲块中100页有效数据 → 写入另一个空闲块
  3. 擦除牺牲块 → 它现在变成了空闲块

  实际擦除:1个块(128页)
  实际写入:100页
  ──────────────────────
  GC本身造成了存储介质的额外磨损。
  在几乎写满的SSD上,持续写入的写放大可以
  轻易超过 10 倍。

写放大是衡量一个Flash文件系统设计好坏的核心指标。 一个好的Flash文件系统,它的GC策略、冷热数据分离、写入批量聚合——所有设计,都指向同一个目标:尽可能让写放大系数接近1。

在你的实现里,间接块(indirect block)的存在就是为了减少写放大:只修改间接块中被改动的指针条目,而不是整个文件数据重写。这是原始的“冷热分离“思想的微缩实现。


为什么NAND有坏块,NOR几乎没有

你可能注意到过一个现象:NAND Flash数据手册上明确写着“允许不超过2%的初始坏块“,而NOR Flash数据手册上几乎没有坏块规格。

这是为什么?

答案还是在这三个原罪的组合之中。

NAND的制造工艺为了极致降低成本,每一个单元的尺寸被压缩到极限(15nm-20nm工艺节点)。在这个尺寸上,一块300mm晶圆上的die数量惊人,但每个die的缺陷概率也更高——一个纳米级的微粒落在氧化层上,这个单元就是坏的。

NOR的单元面积大约是NAND的3到5倍(因为每个单元需要独立的位线接触)。更大的面积意味着更低的缺陷密度。同时NOR更保守的工艺节点(通常65nm-45nm)也意味着更高的良率。

更深层的原因在于NOR和NAND的架构差异:

  • NOR的擦除是均匀的——整扇区同时擦除,擦除后每个单元的阈值电压分布很整齐。
  • NAND的擦除是逐块的,但块内部的位线和字线架构导致边缘单元和中央单元的擦除速度不一致。有些单元擦得慢(under-erase),有些擦得快(over-erase)。擦除后的电压分布比NOR宽,部分单元可能天生不达标。

因此NAND在出厂时就允许最多2%(有些规格是1%)的初始坏块。这些坏块在工厂探针测试时就被发现、记录下来,存储在芯片的一个保留区域里(Initial Bad Block Mark)。Flash文件系统在初始化时必须读取这个坏块表,把它们从可用块池中排除。

而NOR——在同样的探针测试中——坏块率通常低于万分之几。这是NOR“可靠“形象的又一个物理基础。


Flash原罪的工程适配清单

Flash的三个原罪不是理论——它们决定了每一个Flash文件系统的数据结构。看看这些应对策略:

Flash原罪 → 工程应对
=======================

写前擦除   → Out-of-Place Update(异地更新,不原地覆盖)
单向编程   → Copy-on-Write(所有"修改"操作都是新建)
有限寿命   → 磨损均衡 + 坏块管理
读干扰     → 读取计数 + 超过阈值时迁移数据

这些策略不是锦上添花的优化——它们是Flash文件系统的基本生存条件。后面你会看到,KnotFS的每一个刻意设计的细节——SB Log的追加写入、Copy-on-Write的数据块、磨损计数数组的全局跟踪——都是这些原罪的直接产物。

理解Flash的原罪,就是理解Flash文件系统设计的充分必要条件。


反常识:原罪的“原罪“

Flash的三个原罪,真正的“原罪“可能不是Flash——而是磁盘。

之所以说“反常识“,是因为我们一直用磁盘的便利去评判Flash的缺陷。但换一个视角看:

如果你是一个数据库工程师,天天跟PostgreSQL的WAL(Write-Ahead Log)打交道,你会觉得“异地更新“和“追加写入“再自然不过了——这正是数据库的事务日志机制。

Flash的三个原罪,实际上迫使文件系统采用了一种和数据库设计几乎同构的数据管理策略

  • 数据库的WAL = Flash文件系统的log-structured metadata
  • 数据库的MVCC(多版本并发控制)= Flash文件系统的Copy-on-Write
  • 数据库的VACUUM/压缩 = Flash文件系统的垃圾回收
  • 数据库的块磨损(SSD寿命)= Flash文件系统的磨损均衡

数据库没有“坏“——它从一开始就假设物理介质不可靠,所以用日志和事务来保护数据。磁盘却给了你一个相反的教育:你可以原地修改,你不必担心磨损,物理是透明的、可靠的。

所以Flash的三个原罪——写前擦除、单向编程、有限寿命——其实不是Flash的缺陷。它们是物理介质的真实面目。磁盘只是把这个面目藏起来了:它的磁头可以飞到任何扇区直接覆写,它的磁介质寿命几乎无限,它的坏道由控制器透明重映射——你看到的“完美块设备“,是磁盘用机械精密性和冗余扇区给你造的假象。

Flash的三原罪撕掉了这层假象。它告诉你的真相是:物理介质从来不是完美的。磁盘只是把它不完美的一面隐藏得更深而已。

这也许就是我在这本书里选择从Flash出发、从底层出发的原因:不是在学“怎么写文件系统“,而是在学“如何在不可靠的物理世界上构建可靠的抽象“。

而这份学习,从你理解了Flash的三个原罪开始,就已经完成了最重要的第一步。


下集预告

我们理解了Flash与生俱来的三个原罪——也理解了这些原罪如何决定了Flash文件系统的全部设计哲学。但故事还缺最后一段:承载着这么多物理约束的Flash芯片,是怎么从晶圆厂出来的一片裸 die,变成焊在ECU上、跑在引擎舱里的一块可靠器件的?

下一节,我们走进封装车间,走完一片Flash从裸片到引擎舱的最后旅程。

悬念留给:你知道Flash封装需要键合25微米粗的金线、回流焊要经受250°C的炙烤吗?从封装到贴装,从SPI通信到掉电瞬间——旅途的每一步,都可能出问题。