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)时,优先级是:
- 垃圾最多的块(最大化回收效率)
- 如果垃圾量相同 → 选磨损次数最高的(帮助静态均衡)
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的时候怎么判断?