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.7 磨损均衡——让每一个书架都均匀老化

你是一片土地的领主,土地只能耕种十万次

想象一个场景:你是一个农业社会的领主,领地上有128块田。你的佃农需要在这128块田上种植粮食。但有一项严酷的自然规律:每块田只能耕种100,000次。种满10万次之后,田就废了——再也长不出庄稼。

而你的佃农有一个习惯:他们总是去离自己最近的田——1号田。1号田天天被耕、被播、被收,一年365天,一天不落。100号田太远了,没人去,荒草丛生。

100,000次 ÷ 365天 ≈ 274年。看起来你不需要担心。但你的佃农不是一天只耕一次——他们一天要进出1号田几十次。很快,1号田就被耕了80,000次,开始出现裂缝。而100号田只被耕了300次,几乎是处女地。

你的1号田快死了。一旦1号田彻底废了,原本在1号田上的庄稼就要移栽到其他田上,增加了所有田的负担。而且——你的领地总共128块田。1号田废了,就只剩127块了。然后2号田也开始出现裂缝……

这就是没有磨损均衡的Flash文件系统的死亡剧本。


热数据和冷数据:Flash上的阶级分化

在Flash的世界里,数据有冷热之分。这不是比喻——这是精确的工程分类。

热数据 vs 冷数据
=======================

热数据(Hot Data):
  - 频繁修改:日志文件、配置文件、系统状态
  - 每秒钟可能被写入数次
  - 占据的物理块被频繁擦除 → 磨损快
  - 示例:ECU的运行日志、系统时钟同步记录

冷数据(Cold Data):
  - 几乎不修改:固件、标定数据、字体文件
  - 可能写了之后再也没被改过
  - 占据的物理块几乎不被擦除 → 磨损慢
  - 示例:bootloader、产品序列号

温数据(Warm Data):
  - 偶尔修改:用户配置、标定参数修正
  - 介于冷热之间
  - 示例:标定数据表、用户偏好设置

在没有磨损均衡的情况下,热数据所在的块会被“烧死“——在数天内达到P/E Cycle上限,而冷数据所在的块几乎还是全新的。

无磨损均衡的热数据死亡图
=======================

块0 (SB0):     ██████████████████████████████████████ 99,500次
块1 (SB1):     █████████████████████████████████████  98,200次
块2 (数据):     █████████████████████████████████      88,000次
块3 (数据):     █████████████████████████             61,000次
块4 (配置日志):  █████████████████████████████████████████ 100,000次 ← 阵亡!
块5 (固件块):   █ 300次
块6 (固件块):   █ 100次
...
块127 (字体):   █ 5次

磨损失衡比 = 100,000 ÷ 5 = 20,000 倍

这种极端的不均衡意味着:整个Flash芯片的寿命 = 最热那块数据的块的寿命。 你买到的是一颗标称100,000次P/E的Flash,但你实际得到的使用寿命是被热数据限制的——可能只用到了总擦写容量(100,000 × 128 = 12,800,000次擦写)的不到1%。


磨损均衡的两种流派

磨损均衡的工程技术分为两大流派。

流派一:动态磨损均衡(Dynamic Wear Leveling)

动态磨损均衡算法
=======================

find_free_block():
  1. 遍历空闲位图(free_bitmap)
  2. 对所有空闲块(bit=0的块),查它们的wear_count
  3. 返回 wear_count 最小的那个块
  4. 分配成功后,该块的wear_count +1

示例:
  空闲块列表: 块7(wear=500), 块12(wear=200), 块89(wear=6800)
  → find_free_block() 返回 块12(wear最小)
  → 块12被写入,wear变为201
  
原则:每次分配新块时,选磨损最少的。
这确保空闲块池中的所有块均匀老化。

动态磨损均衡是被动的——它不主动搬移数据,只是在分配新块时做“最优选择“。它的优点是:

  • 简单:只需要在分配时多一次比较
  • 低开销:不产生额外的擦写操作
  • RAM需求小:只需要wear_count数组(128 × 4 = 512字节)

动态磨损均衡的致命弱点是:它无法移动冷数据。 如果块5存了一份固件(冷数据,几乎从不修改),块5的wear_count可能永远停留在300次。而其他块在频繁地被分配和擦除。你不碰块5,块5就不参与均衡。

动态磨损均衡的盲区
=======================

块5 (固件, wear=300):    █                                  ← 冷数据,不参与均衡
块6 (固件, wear=100):    █                                  ← 冷数据
...
块40 (日志, wear=80,000):████████████████████████████████████
块41 (日志, wear=82,000):████████████████████████████████████
块42 (空闲, wear=78,000):███████████████████████████████████
块43 (空闲, wear=79,500):███████████████████████████████████

动态磨损均衡只能让 空闲块池(块42, 43...)均匀
但对 已占用的冷数据块(块5, 6)无能为力
它们占据了低磨损的位置,却不参与写入循环

流派二:静态磨损均衡(Static Wear Leveling)

静态磨损均衡算法
=======================

周期性检查:
  1. 找出 wear_count 最小的已占用块(冷数据块)
  2. 找出 wear_count 最大的块(通常是空闲池中的)
  3. 如果差异超过阈值(比如 max_wear - min_wear > 10,000):
     a. 将冷数据块的数据搬迁到一个高磨损的空闲块
     b. 将冷数据块的物理位置释放 → 进入空闲池
     c. 冷数据块(现在是空闲的,磨损低)可以被热数据使用

示例:
  冷块5 (固件):  wear=300,   数据="firmware_v2.1.bin"
  热块99(空闲):   wear=85,000
  
  搬迁:
    1. 分配一个缓冲区,读取块5的数据
    2. 将数据写入块99(新位置)
    3. 更新文件表:固件现在在块99
    4. 擦除块5 → 块5成为空闲块(wear=301)
    5. 块5现在可以被热数据使用了 → 磨损率开始上升

静态磨损均衡是主动的——它不惜代价地搬移冷数据,迫使它们进入磨损循环。代价很明显:

  • 额外的擦写操作(搬移数据 = 擦除一次 + 编程一次)
  • 算法更复杂(需要周期性扫描全局磨损分布)
  • 可能在不恰当的时机触发(增加系统延迟的不确定性)
静态磨损均衡的代价
=======================

搬移 4KB 冷数据块的物理成本:
  1. 读冷数据块 → 82微秒(NOR Flash 读延迟)
  2. 擦除目标块 → 200毫秒(NOR Flash 擦除延迟)
  3. 编程目标块 → 58毫秒(NOR Flash 编程延迟)
  4. 更新元数据 → 1次SB Log追加 + 必要时触发Compact
  
  总物理开销: ~258 毫秒 + 读时间
  P/E开销: 目标块磨损+1, 冷数据块磨损+1 (擦除)
  
  纯为了"均衡"而做了额外的磨损。
  这被称为 磨损均衡税(Wear Leveling Tax)。

KnotFS的磨损均衡设计:动态为主,静态为辅

在KnotFS里,磨损均衡的实现分为三个层面。

第一层:wear_count数组的持久化

这是磨损均衡的基础设施——你必须知道每个块被擦了多少次。

// fs_data.h (简化版)
#define FS_BLOCK_COUNT  (128)

// 每个块对应一个32位计数器
extern uint32 g_wear_count[FS_BLOCK_COUNT];

// 关键问题:wear_count 本身也要存到Flash上
// 但存wear_count也需要擦写 → 更新wear_count的过程会产生磨损!
// 这是一个"反身性问题"

正是这个“反身性问题“——记录磨损信息的操作本身也产生磨损——让磨损均衡的工程实现变得微妙。

KnotFS的策略是:磨损计数嵌入在SB Log的元数据更新中,不单独存一个块。 每次元数据更新(mount事件、format事件、一次文件事务完成),磨损计数作为SB Log的一部分随其他元数据一起持久化。

wear_count 的持久化策略
=======================

不是:
  每次擦除 → 立即更新wear_count → 写入Flash
  这样做的话:wear_count自己的写入频率 = 擦除频率
  磨损计数操作本身的磨损和正常操作一样多!

而是:
  累积一段时间的磨损变化
  在元数据事务提交时(比如写文件完成后)
  将整个 wear_count[] 的快照作为一条SB Log记录写入
  精确定义磨损计数损失 ≤ 最后一次提交以来的擦除次数

第二层:find_free_block() 的动态均衡

这是KnotFS磨损均衡的主力。

// find_free_block() 的简化伪代码
uint32 find_free_block(void) {
    uint32 best_block = FS_INVALID_BLOCK;
    uint32 min_wear = 0xFFFFFFFF;  // 初始化一个巨大值
    
    for (uint32 i = 0; i < FS_BLOCK_COUNT; i++) {
        // 只考虑空闲块(free_bitmap[i] == 0)
        if (g_free_bitmap[i] == 0) {
            if (g_wear_count[i] < min_wear) {
                min_wear = g_wear_count[i];
                best_block = i;
            }
        }
    }
    
    if (best_block != FS_INVALID_BLOCK) {
        g_wear_count[best_block]++;  // 分配时就计入磨损
        g_free_bitmap[best_block] = 1;  // 标记为已占用
    }
    
    return best_block;
}

这个算法的复杂度是O(n),n=128。在每次需要分配新块时(写文件、垃圾回收、元数据compact),都会调用它。128次比较对于Cortex-R5来说大约是几微秒的开销,完全可以接受。

第三层:gc_recycle() 的静态均衡副作用

KnotFS的垃圾回收(GC)在选择牺牲块(victim block)时,优先级是:

  1. 垃圾最多的块(最大化回收效率)
  2. 如果垃圾量相同 → 选磨损次数最高的(帮助静态均衡)
GC Victim 选择策略
=======================

候选块A: 80%垃圾, wear=50,000
候选块B: 80%垃圾, wear=12,000

先按垃圾量排序 → 都是80% → 平局
再按磨损量排序 → A(50,000) > B(12,000)
选择块A作为victim → 搬迁有效数据到新块 → 擦除块A
块A擦除后进入空闲池,wear=50,001 → 正常水平

这个策略保证了高磨损的块优先被回收,从而参与了磨损循环。这是一种低成本的“准静态均衡“——不需要主动搬移冷数据,只是在GC时机到来时,优先收拾“老书架“。


磨损记录的反身性:一个微妙的工程问题

现在面对磨损均衡中最微妙的问题:磨损记录的反身性(Reflexivity of Wear Recording)。

反身性问题
=======================

每擦除一个块 → wear_count[块号]++ → 这个自增操作最终要写入Flash

但Flash上一共有128个块。wear_count数组大小为 128 × 4 = 512字节。

如果每次擦除后立即将wear_count写回Flash:
  擦除频率假设为 1次/秒
  每次更新512字节(一个扇区的编程量)
  每天 = 86,400 次擦除 + 86,400次wear_count写入
  写放大 ≈ 2倍
  而且wear_count的物理存储位置(SB块或FT块的一部分)会变成新的热区!

如果不立即写回:
  掉电时,最后一次写入以来的磨损计数丢失
  128个块的磨损记录中,有部分"未记账的磨损"
  磨损记录不准确 → 磨损均衡决策可能不优

KnotFS的折中方案是:磨损计数在内存中实时更新(volatile),在元数据事务提交时批量持久化。

KnotFS 的磨损计数策略
=======================

内存层(实时):
  g_wear_count[block]++   ← 每次擦除时立即自增
  这个数组在64KB SRAM中常驻,更新成本 = 1条ARM指令

持久化层(批量):
  文件写入完成 → 触发元数据提交
  → SB Log 追加一条记录:更新free_bitmap + wear_count快照
  → SB块上的追加写入 ~8字节 log + ~512字节 wear快照
  
  成本分担:
    一次SB Log追加 = 写入约 ~520 字节
    覆盖了过去N次擦除的磨损记录(N = 此次提交周期内的擦除次数)
    单次擦除的磨损记录成本 ≈ 520 / N 字节
    N越大 → 每条磨损记录的平均成本越低

这种“分层持久化“的核心洞察是:磨损计数不需要每擦一次就存一次——只要在关键决策点(文件写入完成、format完成、mount)留下快照即可。 掉电时丢失的磨损计数(未提交的那些)不影响安全,只影响磨损均衡精度。


从1800倍到1.002倍:一段工程故事

在该文件系统的早期版本中,磨损均衡是没有实现的(只实现了raw磨损记录)。测试数据让人震惊:

无磨损均衡的磨损分布(实际测试数据)
=======================

测试场景:循环写入和删除10个1KB文件
操作次数:50,000次文件写入

磨损分布:
  块0 (SB0):       42,300次   ← 元数据操作频繁
  块1 (SB1):       35,800次
  块2 (数据):       48,100次   ← 文件存储频繁
  块3 (数据):       39,500次
  块4 (日志数据):    1,800次
  块5 (日志数据):    1,600次
  块6 (固件):         200次
  ...
  块120+:            25次      ← 从未被用过的区域

最大磨损: 48,100次 (块2)
最小磨损: 25次 (块120+)
不平衡度 = 48,100 ÷ 25 = 1,924 倍

1924倍的磨损不均衡。这意味着在块2烧穿的当天,还有超过100个块几乎是新的。

引入动态磨损均衡(find_free_block + wear_count)后:

有磨损均衡的磨损分布(相同测试场景)
=======================

磨损分布:
  块0~127的平均磨损: ~3,920次
  标准差: ~80次
  最大值: 4,012次
  最小值: 3,849次

  不平衡度 = 4,012 ÷ 3,849 ≈ 1.042 倍

从1924倍降到了1.04倍。这是一次从“某些块先死“到“所有块一起老“的工程革命。

后续的优化(GC victim选择 + compact元数据冷热分离)进一步将不平衡度压到了1.002倍——所有块的磨损次数的极差不超过0.2%。


“图书馆“隐喻:书架轮换制

图书馆管理有一套策略叫书架轮换制:今年常用东边的书架,让它承受高频借阅;明年把热书换到西边的书架,让东边的书架“休息“。让每一面书架都能分担一定的使用频率,这样图书馆的整体寿命最大化。

Flash磨损均衡就是电子版的书架轮换制

书架轮换 → Flash 磨损均衡

东边的书架 (热度=经常借阅):
  今年: 存放热门小说 → 磨损加快
  明年: 热门小说搬到西边的书架,东边书架存冷门文献 → 磨损放缓
  后年: 重新轮换

Flash块 (wear_count[42] = 1,200):
  今天: 存日志数据 → 磨损+1
  明天: find_free_block() 选了磨损最小的块127 → 日志数据迁移到块127
  后天: 块42被回收(GC),成为空闲块,参与下一轮分配

书架轮换的目的是让每个块的"擦除"均衡。
两者在数学上同构。

没有一个人会把一整座图书馆的借阅流量全部压在同一面书架上,直到它散架。没有一个Flash文件系统应该让热数据永远钉在同一块块上,直到它擦穿。


下集预告

书架轮换制确保每一面书架均匀老去。但天有不测风云——在你写入文件的关键时刻,ECU突然断电了。Flash擦到一半,电压没了。编程编到一半,时钟停了。你的文件系统怎么知道哪些数据可信、哪些数据是半成品?

下一节,直面嵌入式领域最让人心惊肉跳的问题:掉电。看看Copy-on-Write、双槽位Compact、日志回放和一致性修复这“四大武器“如何在黑暗中重建秩序。

悬念留给:NOR Flash擦除需要200毫秒。如果在这200毫秒的第150毫秒掉电——被擦的那个块,是全0xFF(干净的),还是半擦的(乱码),还是什么都有?你的文件系统mount的时候怎么判断?