1.5 文件系统——与不可靠介质的和解协议
如果你直接操作Flash
在进入文件系统之前,我们先做一次思想实验。以下是你——假设没有文件系统——直接操作一片NOR Flash时需要做的事情。你有一个512KB的Flash,128个块,每块4KB。
你想存一个文件——比如“config.txt“,内容是一段JSON配置,大约1200字节。
第一步:找到可以写入的块。Flash不能直接覆写——写之前必须擦除。所以你需要知道哪个块是已擦除的(全“1“)。你需要一个数据结构来跟踪128个块中每一个块的状态——哪些是空的(已擦除)、哪些有数据、哪些是坏的。你得自己维护这个:
128个块的状态跟踪(最小化方案)
块0:[已擦除] 块1:[有数据] 块2:[坏块] 块3:[已擦除] ……
第二步:在选中的块上写入数据。但你需要决定:数据写在哪里?块内偏移量多少?你还需要问自己:我要怎么记住“config.txt在块3,偏移128字节,长度1200字节“这个映射关系?你要自己设计一个数据结构来存储这些映射——这就是元数据的原始形态。
第三步:如果config.txt需要更新——比如改了一个配置项。你不能直接在原来那个位置改写(Flash不支持原位改写),你必须把新版本写到另一个块,然后把旧块标记为“待擦除“,找个时机擦掉它。
第四步:擦除不是免费的。擦除耗时5-10毫秒,而且——如前所述——擦除中途断电是灾难。你需要在擦除之前确保旧数据还没被销毁(至少还有一个可恢复的副本)。你需要一个掉电安全策略。你自己实现它。
第五步:经过一段时间的正常工作后,某些块被擦除了太多次——你开始需要磨损均衡。你不能让块7被反复擦除(因为它恰好总是在config.txt更新时被选为目标),而块42从未被使用过(因为它上面存的是从来不动的只读logo数据)。你需要一个算法——跟踪每个块的历史擦除次数,选择磨损最少的块来写入。
第六步:某个块意外地出现了无法擦除的物理故障——变成了坏块。你需要识别它,将它从可用块池中移除,并且确保它上面没有活跃数据——如果有,你需要能从其他副本恢复。
第七步:你做到一半发现——不对,你的配置现在不是1200字节了,它变成了1400字节(新加了一个功能开关)。但是块只有4KB,而config.txt现在加上别的文件数据刚好穿透了块的边界——你需要跨块存储。你需要设计一种方式,能把一个跨越两个物理块的逻辑文件拼合在一起,在上层读出时无缝重组。
如果你真的做完了上面所有这些事情——写了磨损均衡的代码、管理了坏块映射、实现了掉电安全的元数据更新、支持了跨块数据存储——那么恭喜你。
你已经写了一个文件系统。
只是它很难用。而且只有你自己会用它。
文件系统替你做了什么?
让我们从上面那个绝望的思想实验中抽身出来。文件系统在替你做的事情,可以用一句话概括:
把“管理物理介质的复杂性“转换成“操作逻辑对象的便利性“。
你不需要知道config.txt在块3的偏移128处。你只需要调用一个叫write()的函数,告诉它文件叫什么名字、内容是什么。文件系统在底层把“文件“翻译成物理块地址、擦除操作、磨损均衡的调度——而你完全不需要关心其中任何一个细节。
用户看到的:
write_file("config.txt", json_data, data_len);
→ 返回 OK
文件系统在做的:
┌─────────────────────────────────────────────────┐
│ 1. 检查参数有效性(文件名非空、数据指针非空) │
│ 2. 在文件表中查找"config.txt"是否已存在 │
│ 3. 如果存在:需要更新 → 触发CoW写时复制 │
│ 4. find_free_block() → 从free_map选磨损最小的块 │
│ 5. 更新SB Log → 记录"开始写config.txt" │
│ 6. 擦除目标块 → 等待擦除完成(异步) │
│ 7. 逐页编程数据 → 等待编程完成(异步) │
│ 8. 更新文件表条目 → 记录文件名、块号、长度 │
│ 9. 更新SB Log → 记录"config.txt写入完成" │
│ 10. Compact SB(将文件表和日志合并写入备用槽位) │
│ 11. 回收旧块到free_map(旧版本数据不再需要) │
│ 12. 返回 OK 给用户 │
└─────────────────────────────────────────────────┘
你看到的一个write调用,背后是至少十二步操作,跨越了多个物理块的擦除和编程,包含了两次日志写入和可能的垃圾回收。而这些操作对于你——调用write的人——是完全透明的。
这是文件系统的第一个、也是最关键的存在理由:抽象。 把物理复杂度封装在一个清晰的接口后面——让使用者看到的是“文件“而不是“块“,是“名字“而不是“地址“。
文件系统的三个核心抽象
文件系统的设计可以从无数个角度分析,但抛开所有具体实现——FAT、ext4、NTFS、ZFS、APFS、到现代的日志结构化设计——所有文件系统都在提供三个核心抽象。这三个抽象是文件系统之所以成为“文件系统“的最低必要要件。
抽象一:文件——一个有名字的字节序列
这是最基础的一个。文件是一个命名的、有序的字节序列。注意这个定义有多么抽象——它不关心这些字节在物理介质上的实际排布方式。一个1200字节的文件可能在物理上被分成了三个不连续的Flash页,中间被擦除间隙和逻辑块边界隔开。但当你调用read(fd, buf, 1200)时,你得到的是一个连续的字节流——文件系统在读取时完成了物理碎片的透明拼接。
"文件" —— 逻辑抽象 vs 物理现实
逻辑层(你看到的):
config.txt → [byte0][byte1][byte2]...[byte1199]
一个连续的、有序的字节流
物理层(Flash上实际的样子):
块3, 页0, 偏移0-255 → [byte0.. byte255]
块3, 页1, 偏移0-255 → [byte256..byte511]
块3, 页2, 偏移0-255 → [byte512..byte767]
块3, 页3, 偏移0-255 → [byte768..byte1023]
块7, 页0, 偏移0-175 → [byte1024..byte1199] ← 换了物理块!
文件 = 字节序列 + 名字 + 大小属性
文件 ≠ 某个固定的物理位置
文件这个抽象解决的核心问题是位置独立性——数据的位置可以动态变化而不影响上层使用者。你今天把config.txt从块3移到了块7(垃圾回收、磨损均衡导致的重新布局),调用read("config.txt")的人永远察觉不到这个变化。
抽象二:目录——文件的层次化组织
如果你的Flash上只有五个文件,你可以给它们逐个起名字然后记住这些名字。但如果有一千个文件呢?一万个呢?人类的认知系统不能直接处理“一千个名字的平铺列表“——我们需要分层,需要分类,需要一种空间结构来组织信息。
目录(Directory)就是这种空间结构。“图库“下面有“2024“和“2025”,“2025“下面有“东京“和“巴黎”——这是一个树形结构。你可以把成百上千个文件分配到语义上有意义的子空间中,大幅降低每次查找时的认知负载。
但目录在底层的物理实现可能非常简单——也可能极其复杂:
目录的底层实现谱系
最简单的实现: 文件表里一个"路径"字段
文件名 = "/photos/2025/tokyo/img001.jpg"
→ 查找时字符串比较路径前缀
FAT、早期嵌入式的实现对目录的支持都这样
中等实现: 专门的目录项,每个目录是一个特殊文件
目录文件内容 = [条目1:名称+inode号] [条目2:名称+inode号] …
ext2/ext3/ext4 的实现方式
高等实现: B-tree / B+tree 索引的目录
索引键 = 文件名的哈希值
HFS+(苹果)、NTFS、btrfs、ZFS
百万文件级目录也能O(log n)查找
不管用的是字符串前缀匹配还是B-tree索引,目录向上层呈现的是一致的接口:一个“文件夹“里面可以放“文件“或“子文件夹“。底层物理结构的差异被抽象完美地隐藏了。
抽象三:权限和属性——谁知道这个文件的存在?
Flash芯片不知道“用户“是什么。你找一个NOR Flash芯片问它:芯片,你这个块上的数据,是谁写的,谁可以读?芯片不回答你——因为它没有“谁“这个概念。Flash只认电信号——地址、命令、数据。没有用户,没有权限。
但文件系统——为多用户操作系统设计的文件系统——引入了“所有者“、“组”、“读/写/执行权限“这些概念。在Unix和Linux系统中,每一个文件都有一个uid(用户ID)和一个gid(组ID),以及一个9位的权限掩码。
这些东西在Flash上不存在。它们只存在于文件系统的元数据结构中——存储在Flash块上的某些特定的字节里,由文件系统代码解释,在open()调用时做权限比对:
权限检查流程(简化)
open("/etc/shadow", O_RDONLY)
→ 文件系统:查找/etc/shadow的inode
→ inode中的uid = 0(root)
→ inode中的权限位 = 0600(owner可读写,其他无权限)
→ 当前进程的euid = 1000(普通用户)
→ euid != uid → 不是owner
→ "other"位包含读权限?→ 否
→ 返回 -EACCES(Permission Denied)
整个过程没有任何“硬件支持“——完全是文件系统代码在CPU上跑的逻辑判断。Flash只负责忠实地存储和返回那几十个字节的元数据,它既不知道也不会执行权限检查。文件系统在物理介质原有的“哑巴“存储之上,凭空构建了一层语义——谁拥有数据的所有权、谁有权访问这个数据。
文件系统 = 翻译官
把这三个抽象合在一起,文件系统的角色就非常清晰了:
文件系统是用户的高级语义(文件、目录、权限)和物理介质的低级语义(擦除、编程、校验)之间的“翻译官“。
用户说:“打开 /data/config.json。”
文件系统翻译成:
- 读Superblock → 找到文件表的位置
- 搜索文件表 → 找到
config.json对应的条目 - 从条目取出块号和偏移量
- 给Disk驱动发读命令 → “读块5,偏移0,长度2048”
- Disk驱动发Flash命令 → 地址计算、读定时、ECC校验
- 返回数据给用户
每一步的翻译都在跨越一个抽象层。在用户和Flash之间,存在着至少四层翻译:
文件系统抽象栈
应用层: open/write/close ← 用户交互的语义
│
文件系统层: inode管理、目录查找、文件分配 ← 逻辑到物理的映射
│
块层: 块/页寻址、磨损均衡、坏块管理 ← 物理资源管理
│
设备驱动层: Flash命令序列(读/写/擦除/状态) ← 硬件原语
│
物理层: 浮栅晶体管阈值电压 ← 硅原子的电子状态
每一层只和它的上下邻居对话。用户永远不知道驱动层在做什么,驱动层永远不知道用户在读什么文件。分层隔离是管理复杂性的唯一途径——而文件系统正是这个隔离策略的中心枢纽。
回到那个50岁的接口
POSIX标准定义的open/read/write/close接口,已经活了超过五十年。它诞生于1970年代的UNIX——当Ken Thompson和Dennis Ritchie在贝尔实验室写UNIX V1时,他们不可能预见到五十年后会有嵌入式系统、有手机、有在NOR Flash上跑的文件系统。
但他们设计的接口到今天还在用。为什么?
不是因为他们是先知。是因为他们把接口的语义定义到了恰到好处的抽象层级——足够笼统,可以适应任何物理介质;又足够具体,可以在各种平台上高效实现。
POSIX核心文件I/O接口
int fd = open(const char *path, int flags, mode_t mode);
// "给我一个文件" —— 不关心文件在哪、什么介质、怎么存的
ssize_t n = read(int fd, void *buf, size_t count);
// "读n个字节" —— 不关心需要翻多少个页、跨多少块
ssize_t n = write(int fd, const void *buf, size_t count);
// "写n个字节" —— 不关心要不要擦除、要分配几个块
int ret = close(int fd);
// "我写完了" —— 不关心缓存、日志、压缩、清理
off_t pos = lseek(int fd, off_t offset, int whence);
// "跳到第N个字节去" —— 不关心寻址算法
这五个函数定义了文件I/O的全部操作。磁盘文件用它们,网络socket用它们,管道用它们。在Unix哲学中——“一切皆文件”——open/read/write/close不仅操作磁道和扇区上的数据,也操作TCP连接上的数据、进程间通信管道中的数据、甚至是硬件设备寄存器中的数据。同一个接口,同样的语义,无论背后的“物理现实“是什么。
对于嵌入式系统来说,标准的POSIX接口有时过于重量级——一个完整的VFS(虚拟文件系统)层+inode缓存+目录项缓存+dentry——需要的内存可能比整个系统的RAM还多。这就是为什么嵌入式文件系统往往定义自己的、更轻量的API:
嵌入式文件系统的API(异步文件系统 API 示例)
sint32 fs_mount(void);
// 替代"超级块加载+空闲块扫描"的复杂引导序列
sint32 write_file(const char *name, const uint8 *buf, uint32 len);
// 文件写入的异步入口——参数直接传文件名+数据+长度
// 没有文件描述符、没有偏移量、没有复杂标志
// 嵌入式场景里往往一次操作一个文件、写完即关
sint32 read_file(const char *name, uint8 *buf, uint32 buf_size, uint32 *out_len);
// 异步读取——用户分配好缓冲区,驱动填充
sint32 delete_file(const char *name);
// 删除文件——底层触发的是SB/FT日志更新+旧块回收
bool fs_is_idle(void);
// 嵌入式RTOS的关键查询:当前有没有正在进行的I/O操作?
// 同步API在等这个信号
乍一看,write_file(name, buf, len)和write(fd, buf, len)差异不小——前者用的是文件名字符串而不是文件描述符,一次调用完成打开+写入+关闭。但它们的语义本质是一样的:用户不说“给我块号→擦除→编程“,用户只说“存下这个“。
POSIX是五十年积淀下来的“标准答案“。嵌入式API是在资源约束下的“最佳实践“简化版。但核心原则不变:文件系统是翻译官,用户说的必须是用户自己的语言,而不是介质的语言。
嵌入式文件系统:当资源不够时
回头再从嵌入式工程师的眼睛里看看这个问题。你的平台上运行的是FreeRTOS,没有Linux VFS,没有一个庞大的页面缓存,没有几百KB的RAM来做inode缓存。你只有64KB的RAM和一些内核对象(任务、信号量、队列)来构建你的文件系统。
更重要的,你的存储介质不是磁盘。
磁盘是一个旋转的盘子,上面有磁道和扇区,磁头可以精确地定位到每一个扇区的最小可写单位是512字节。磁盘控制器处理了坏扇区重映射,操作系统看到的是一个完美的块设备的抽象。
Flash不是这样。Flash的最小写入单位是页(Page,256字节或512字节),但最小擦除单位是块(Block,4KB或更大)。你不能只擦除一个页——你必须擦除整个块。Flash需要磨损均衡(因为每个存储单元有有限的擦除循环寿命)。Flash会悄悄地产生坏块。Flash的写入和擦除时间远长于读取时间,而且是不确定的(取决于芯片内部的算法)。
磁盘 vs Flash:物理特性对比
HDD(机械硬盘) NAND Flash
最小读写单位 512B 扇区 256B~16KB 页
最小擦除单位 N/A(可直接覆写) 4KB~数MB 块
写前是否需要擦除 不需要 必须
写入延迟 ~5ms(寻道+旋转) ~200μs(页编程)
擦除延迟 N/A ~2-10ms(块擦除)
每单元擦除寿命 近乎无限(磁介质) 1K~100K次
原地覆写 支持 不支持
坏块 少(物理损伤) 相对常见(氧化层缺陷)
数据保持时间 磁化稳定,数十年 电荷泄漏,~10年
这就是为什么你不能直接把针对磁盘设计的文件系统(ext4、FAT32)搬到一个原始Flash上,然后期望它正常工作。传统的磁盘文件系统假设——原地覆写、无擦除概念、无限写入寿命——在Flash上全不成立。
嵌入式文件系统必须解决一个磁盘文件系统不需要考虑的额外问题:Flash Translation Layer(闪存转换层,FTL)。
FTL包含:
- 地址映射:将文件系统的逻辑块号翻译为Flash的物理块号(写前擦除导致的物理位置频繁变化)
- 磨损均衡:确保所有物理块的擦除次数均匀分布
- 垃圾回收:擦除已经标记为“无效“的数据单元,回收空间
- 坏块管理:识别物理缺陷块,从可用块池中移除,确保数据不落在上面
- 掉电恢复:日志机制确保元数据在任意时刻断电后能恢复到一致状态
对于有完整操作系统的平台(Android、iOS),FTL通常做在Flash控制器的固件层(eMMC/UFS内部的NAND控制器)——文件系统看到的是一个完美模拟的“块设备“,不用管底层是Flash。但在嵌入式裸机或RTOS平台上——比如你的Cortex-R5跑FreeRTOS的场景——文件系统和FTL是手拉手的一家人。你的fs_block.c在管理磨损均衡和坏块,你的fs_sblog.c在实现日志结构元数据更新——文件系统和FTL的边界在嵌入式领域往往是模糊的,甚至是不存在的。
协奏:文件系统和Flash的交往守则
有了上面的铺垫,我们现在可以总结文件系统和不可靠物理介质之间的“交往守则“了。这些守则不是某一份官方标准——它们是几十年来嵌入式存储工程师用血泪教训换来的实践准则。
守则一:永远不要原地覆盖(Copy-on-Write)。
原地覆盖是文件系统中最危险的操作——如果覆盖操作在中途被中断,旧数据和新数据都不可用。
CoW的做法是:永远把新版本写到新位置,旧版本原封不动。 只有当新版本完全写完、校验通过、元数据更新完之后——才把旧版本的位置标记为“可回收“。在整个过程中,至少有一个完整、可用的数据副本存在——要么是旧的(新版本没写完),要么是新的(完成了版本切换)。
Copy-on-Write 示例
写入前:
块3:[Version 1: config.txt = {"port": 8000}] ← 旧版本,完整可用
块7:[已擦除] ← 空闲空间
写入过程(CoW):
1. 新数据写入 块7 → [Version 2: config.txt = {"port": 8080}]
2. 校验块7写入无误(可选:CRC32比对)
3. 更新文件表:config.txt → 块7(不再是块3)
4. 标记块3为"待擦除回收"
如果第3步之前断电:
→ 文件表仍然指向块3 → Version 1 完好无损
→ 重启后,系统看到的是旧版本 ✓
如果第3步之后断电:
→ 文件表指向块7 → Version 2 已生效
→ Version 1在块3等待回收 ✓(回收是可延迟的安全操作)
守则二:元数据的更新必须是原子的。
文件系统的一致性取决于元数据——Superblock和文件表——的正确性。如果元数据损坏,文件可能丢失、块可能泄露、整个文件系统可能无法挂载。
实现元数据原子更新的标准手段是日志。
元数据日志结构(SB Log + 嵌入式文件表)
块0(SB0):
┌────────────────────────────────────┐
│ SB Header: magic, seq, free_map… │
│ nodes[8]: 文件表(嵌入在SB Header中) │
│ SB Log: │
│ [Rec1: file_create "a.txt"] │ ← 追加写入,永不原地覆盖
│ [Rec2: file_write "a.txt" 1024]│
│ [Rec3: file_delete "a.txt"] │
│ [Rec4: file_create "b.txt"] │
│ … │
│ 当Log达到50%块容量 → Compact │
└────────────────────────────────────┘
Compact(日志压缩):
将Log中所有记录 + 内存中的文件表节点合并成当前最新状态
→ 写入备用SB槽位(块0↔块1交替)
→ 新SB包含最新的文件表 + 清空的日志区
→ 旧SB槽位被标记为待擦除
日志的原子性来自它的追加写入(Append-only)模式。你永远不在原来那条记录上覆盖一个新值——你在日志的尾部追加一条新记录。如果追加过程中断电——尾部最多有一个不完整的、未校验通过的“半“记录。重启时扫描日志,校验每一条记录的CRC——CRC对的保留,CRC不对的丢弃(它是断电中途的废料)。元数据总能恢复到上一个一致的版本。
守则三:校验一切你能校验的东西。
在不可靠的物理介质之上构建可靠系统,你必须假设“任何数据都可能出错“——包括你刚刚才写入的数据。
CRC32或者类似校验码是嵌入式文件系统中的标配(对于防止无意错误,CRC32绰绰有余;对于防止恶意篡改或追求极致完整性,需升级为加密哈希):
- Superblock Header 有CRC
- SB Log 每一条记录有CRC
每一次读取Flash上的关键数据结构,你都要做校验——读Superblock→校验CRC→CRC对才信任→CRC错则尝试另一个副本(SB1)。每一步做验证,然后用验证通过后的数据决策下一步。这不是“过度谨慎“——这是在物理介质不可靠的前提下,唯一正确的工作方式。
守则四:为掉电做设计,不要事后补救。
“确定在断电后能恢复“应该是一种设计约束,而不是一种灾难恢复策略。在你写代码的时候,你就应该假设每一行对Flash的操作都可能被断电打断。
具体落实到嵌入式文件系统上,这个原则的体现是:
- 把擦除操作放在新数据写完之后(不要先擦旧块再写新数据——万一中间断电,新数据还没写,旧数据已经没了)
- 元数据更新顺序:先写Data Block → 再更新内存中的文件表条目 → 最后Compact整个SB到备用槽位(SB Log只是操作记录,文件表状态的“生效“需要Compact)
- Compact操作必须是事务性的:compact过程中如果断电,要么回到compact前的旧SB状态,要么已经完成compact后的新SB状态——不存在“半compact“的中间态
这个原则在KnotFS里叫做Commit顺序。SB Log是操作意图的记录——它告诉你“曾经发生了什么“。但文件系统的真实状态——文件到底存不存在、占哪个块——由SB Header中的文件表(nodes[8])决定。Compact把SB Log中的操作意图“落实“到文件表中,然后写入备用SB槽位。一旦Compact完成且新SB校验通过,旧SB槽位被标记为可回收。如果Compact中途断电——旧SB仍然完好可用,重启后读旧SB,恢复到最后一次成功Compact的状态。
一个极小文件系统的解剖
让我们通过一个具体的示例来落实所有这些抽象概念。假设你有一个最小可行文件系统——它只提供“挂载、写入、读取、删除“四个操作,跑在FreeRTOS上,存储介质是128块×4KB的NOR Flash。
它的内部结构是这样的:
微小文件系统的内存+Flash布局
Flash布局(64KB 教学版):
┌──────────┬──────────┬──────────────────────────────────┐
│ SB0 │ SB1 │ 数据块 │
│ (块0) │ (块1) │ (块2-15) │
│ Superblk │ Superblk │ 文件内容存放区 │
│ +nodes[] │ +nodes[] │ │
│ +SBLog │ +SBLog │ │
└──────────┴──────────┴──────────────────────────────────┘
内存中的核心数据结构:
┌────────────────────────────┐
│ g_free_map │ ← "馆藏总目录":16位位图,标记每个块是否空闲
│ g_wear[16] │ ← 每个块的擦除次数(磨损均衡)
│ g_sb_log_pending │ ← 是否需要compact
│ g_current_req │ ← 当前正在处理的异步请求
└────────────────────────────┘
当用户调用write_file("hello.txt", "Hello World", 11)时,系统经历的状态机流程:
状态机流程(简化)
STATE_IDLE → 收到请求,参数校验
↓
STATE_LOAD_SB → 读Superblock,校验CRC,获取文件表(nodes[])
↓
STATE_SEARCH_FILE → 在文件表中搜索"hello.txt"
↓
STATE_FIND_FREE_BLOCK → 从free_map选磨损最小的空闲块
↓
STATE_START_SB_LOG → SB Log追加"开始写hello.txt"记录
↓
STATE_ERASE_TARGET → 擦除选中的目标块(异步等待)
↓
STATE_WRITE_DATA → 逐页编程用户数据(异步等待每页完成)
↓
STATE_UPDATE_META → 更新内存中的文件表条目
↓
STATE_COMPLETE_SB_LOG → SB Log追加"hello.txt写入完成"
↓
STATE_COMPACT → Compact到备用SB槽位(持久化文件表+日志)
↓
STATE_MARK_OLD → 旧版本数据块标记为待擦除回收
↓
STATE_COMPLETE → 唤醒等待的同步API调用者,回到IDLE
这不是一个“函数调用“——这是一个运行在FreeRTOS任务中的状态机循环。每一步都可能跨越多个RTOS tick(等待Flash异步操作的完成信号),当前请求的状态保存在g_current_req->state中,每次任务被唤醒后继续从上次中断的状态往下走。
这看起来很复杂。但如果你把每个状态看作独立的、不可分割的“原子操作“——在这个粒度上,系统和物理介质的互动变得可控,中断带来的不一致被限制在单个状态内,恢复过程可以精确地从已知安全点重新开始。
图书馆:读者看到的和馆员管理的,是两个世界
让我们用“图书馆“的完整隐喻来收束本节。
你是一个图书管理员。你签了一份合同——要把一片荒地(512KB的NOR Flash地址空间)变成一座可以正常借阅的图书馆(一个可用的文件系统),并且保证来借书的人(用户)看不见一面裸露的铁架、一卷胶带、一盒标签纸。
图书馆的物理层:你打的每一面书架。
Flash的每一个块都是你的书架格子。128面书架,有些已经清空好了(已擦除,等着排书),有些排着书了(已编程,存着数据),有些坏掉了(坏块,永不再用)。你要在馆藏总目录(free_bitmap)上精确标注每一面书架的状态。
图书馆的结构层:你是怎么编排的。
SB0和SB1是你图书馆的东西两翼——两个一模一样的入口。每个入口的大厅墙上贴着馆藏总目录(SB Header中的free_map、nodes[]文件表),走廊上挂着借阅记录簿(SB Log)。任一个入口完好读者就能进,另一个是备份。数据块是你真正的藏书区,文件就放在那里。
每一次有新书要入库(写入新文件),你不是把旧书扔到过道然后搬新书进来——Copy-on-Write告诉你:保留旧书架、立一面新书架、把新书放进去、在登记卡上改一下索书号、然后旧书架可以另作他用。你永远有一个完整的可用藏区:要么是新书架、要么是旧书架。
图书馆的运维层:你的记录簿和重新编目。
SB Log是你贴在入口走廊墙上的借阅记录簿——“今天开始编目3号书架→3号书架已上架→3号书架正式启用”。所有状态变化都写在上面。一旦停电(掉电),你看记录簿就知道图书馆的准确状态:哪面书架编好了、哪面还在整理中、哪面的图书可能是乱的。
Compact(重新编目)是你发现记录簿贴了半面墙的时候做的:把所有“最后的有效状态“合并成一份简洁的记录,誊写在墙的另一个位置,然后旧的地方空出来以后用。重新誊写的过程是事务性的——要么旧记录簿仍旧有效(你誊写到一半断电了,重启后看的还是旧记录簿),要么新记录簿已经生效了(誊写完、校验通过、指针切换到新位置)。不存在“一半新一半旧“的记录簿,因为设计保证了重新誊写的原子性。
图书馆的持久层:馆藏总目录永远不会丢。
Superblock里记录着图书馆的核心信息(图书馆的馆藏版本号seq number、总共有多少面书架、当前有多少面空闲书架),所有这些都是双重冗余存储。SB0在块0,SB1在块1。如果一场电涌烧坏了SB0,你用SB1恢复,完全不受影响。你甚至定期用更大的seq号“重新整理“其中一个副本——如果停电发生在这个重新整理的过程中,seq号保证了只有“最新且校验通过“的那个副本会被信任。
而读者呢?读者看到的和馆员管理的,是两个世界。
读者走进你的图书馆,看到的是:一排整洁的书架、一张索书号、一本书。阅览区有书桌和台灯。他们不知道书架后面是安排得密密麻麻的Flash块、索书号后面是冗长的擦除编程校验流程、书架的每一层是日志记录和元数据映射下的逻辑抽象。他们不需要知道。他们只需要“借书“(调用write),书就到位了(返回OK)。
文件系统的全部工艺,就是让读者永远不需要走进设备间。
下集预告
到这里,我们从绳结出发,走过泥板、纸张、Flash,看清了存储的原始矛盾,也理解了文件系统如何在用户和不可靠物理介质之间建立一堵保护墙。但文件系统不是凭空工作的——它运行在一块真实的 Flash 芯片之上。这块芯片的物理本质,决定了文件系统能做什么、不能做什么。
第二章,我们将从一颗浮栅晶体管开始,逐层向上:一个存储单元如何捕获电子?NOR 和 NAND 两种架构在物理上有什么区别?一个单元塞进两个、三个、四个比特后,可靠性要付出什么代价?Flash 的三个物理原罪——写前擦除、单向编程、有限寿命——是如何从量子力学一路传导到数据结构设计的?
下一章,我们不谈文件系统。我们只谈硅——从一颗浮栅晶体管,到一整片 Flash 芯片。看懂物理,才能理解后面所有的工程妥协。