5.2 Flash布局——16块×4KB的棋盘
一张64KB的地图
你拿到一块NOR Flash芯片。它上面有65536个可以独立寻址的字节——也就是64KB。
但你不会把这64KB看作65536个独立的格子。你会把它切成16块,每块4096字节。这不是随意切的——NOR Flash的最小擦除单位就是“块“(block,也叫sector)。你可以在块内的任意字节位置写入(编程),但要把已经写过的地方重新变成可写状态,必须以块为单位擦除——整块重置为0xFF。
擦除是Flash的呼吸。你不能把写过的地方直接覆盖成新值——你必须先把整块擦成0xFF,再往里面写。 这不是Bug,这是物理定律。浮栅晶体管里的电子被囚禁后,只有通过擦除操作(施加反向高压)才能被释放。
所以文件系统的第一道设计题就是:这16个块怎么分?
KnotFS的答案是:
KnotFS Flash布局(16块 × 4096字节 = 64KB)
=====================================================
块0 [████████████████████████████████████████] SB0 超级块A
┌───────────────────────────────────────┐
│ magic=KNFS version=1 sequence=... │ ← 668字节头部
│ free_map wear[16] nodes[8] crc32 │
├───────────────────────────────────────┤
│ log[0] log[1] log[2] ... │ ← 3428字节Log区 = 428条记录
│ (每条8字节:tag/blk/val/crc32) │
└───────────────────────────────────────┘
块1 [████████████████████████████████████████] SB1 超级块B
同SB0结构——这是"备份目录"
块2 [ ] ← 数据块
块3 [ ] ← 数据块
块4 [ ] ← 数据块
块5 [ ] ← 数据块
块6 [ ] ← 数据块
块7 [ ] ← 数据块
块8 [ ] ← 数据块
块9 [ ] ← 数据块
块10 [ ] ← 数据块
块11 [ ] ← 数据块
块12 [ ] ← 数据块
块13 [ ] ← 数据块
块14 [ ] ← 数据块
块15 [ ] ← 数据块
块2~15 = 14个数据块 = 56KB可用空间
每个文件最多8个直接块 = 32KB
这个布局在代码中被硬编码为几个宏:
// knotfs.h — Flash几何常量
#define KNOTFS_BLOCK_SIZE (4096U) /* bytes per flash block */
#define KNOTFS_BLOCK_COUNT (16U) /* total blocks (16 x 4K = 64 KB) */
#define KNOTFS_SB0_BLK (0U) /* superblock A lives in block 0 */
#define KNOTFS_SB1_BLK (1U) /* superblock B lives in block 1 */
#define KNOTFS_DATA_START (2U) /* first data block */
#define KNOTFS_DATA_BLKS (KNOTFS_BLOCK_COUNT - KNOTFS_DATA_START) /* 14 */
#define KNOTFS_DIRECT_BLKS (8U) /* max data blocks per file */
#define KNOTFS_FILE_LIMIT (KNOTFS_DIRECT_BLKS * KNOTFS_BLOCK_SIZE) /* 32K */
注意KNOTFS_DATA_BLKS的计算:16 - 2 = 14——“两个块被扣掉做了目录备份”。
为什么超级块要单独占两个块?
你可能会问:超级块只有4096字节——我能不能把SB0放在块0的前2048字节,SB1放在块0的后2048字节?这样不就能省出一个数据块吗?
不能。因为Flash的最小擦除单位是块。如果你把SB0和SB1放在同一个块里,每次需要更新其中一份(比如重写SB1做compact),你必须擦除整个块——这会把SB0也一起干掉。而擦除过程如果遇到掉电,你就损失了两份超级块,文件系统就挂了。
把两份超级块放在不同的物理块上,是Flash文件系统的第一条保命法则。
还有一个更深层的原因:SB0和SB1不仅是备份,它们会交替使用。KnotFS有一对互斥的槽位选择函数:
// knotfs.c — 活跃槽位(Log写入的目标)与非活跃槽位(compact的目标)
static uint32_t active_sb_slot(void) {
return (sb.sequence & 1U) ? KNOTFS_SB1_BLK : KNOTFS_SB0_BLK;
}
static uint32_t inactive_sb_slot(void) {
return (sb.sequence & 1U) ? KNOTFS_SB0_BLK : KNOTFS_SB1_BLK;
}
sequence 的奇偶性决定了当前活跃槽位:偶数→块0活跃/块1非活跃,奇数→相反。每次 compact 结束时 sb.sequence++,两个槽位乒乓切换,轮流承担“当前有效副本“的角色。这有两个好处:
- 掉电安全:写新SB时即便断电,旧SB依然完好
- 磨损均匀:SB0和SB1的擦写次数大致相等,不会出现“SB0擦写10万次、SB1擦写10次“的情况
超级块内部的“版面“怎么划分?
一个超级块是4096字节。它不是扁平的数据结构,而是分为两个区域:
Superblock 内部布局 (4096 bytes)
=====================================
字节 0···667 [header 头部区] 668 bytes
magic (4B) ← "KNFS" 魔数
version (4B) ← 版本号
sequence (4B) ← 单调递增序列号
log_offset (4B) ← Log区当前写入位置
log_count (4B) ← 已写入的Log条目数
free_map (4B) ← 16位块分配位图
wear[16] (64B) ← 每个块的擦写计数
nodes[8] (576B) ← 8个文件条目(每个72字节)
crc32 (4B) ← 头部校验和
字节 668···4095 [log 日志区] 3428 bytes
log[0] (8B) ← 第1条日志
log[1] (8B) ← 第2条日志
log[2] (8B) ← 第3条日志
... ← 最多428条(3428/8)
(剩余空间填0xFF) ← 未使用的Flash默认状态
代码中的常量:
// knotfs.c — SB内部空间计算
#define SB_HEADER_SZ (sizeof(knot_sb_t))
#define LOG_ENTRY_SZ (8U)
#define LOG_ENTRIES_MAX ((KNOTFS_BLOCK_SIZE - SB_HEADER_SZ) / LOG_ENTRY_SZ) /* 428 */
#define LOG_THRESHOLD (LOG_ENTRIES_MAX * KNOTFS_LOG_THRESHOLD / 100U) /* 214 */
668字节的头部 + 3428字节的Log区 = 4096字节,恰好填满一个块。Log压缩的触发条件是使用了50%的Log容量,即214条记录。
knode_t:一个文件条目的解剖
KnotFS的文件条目叫knode_t:
// knotfs.c — 文件条目结构体
typedef struct {
char name[KNOTFS_NAME_LEN];
uint32_t size;
uint32_t block_count;
uint32_t blocks[KNOTFS_DIRECT_BLKS];
} knode_t; /* file entry (the name is deliberately different from SimpleFS) */
总计72字节。8个文件条目 = 576字节,正好是SB头部中nodes[8]的空间。
每个knode_t的核心是blocks[8]数组——这就是文件的“块映射表“。假设一个文件“hello.txt“有14KB,它需要4个数据块(14KB / 4KB = 3.5,向上取整为4)。blocks[0]到blocks[3]分别记录了这4个数据块的物理块号。
读文件时,KnotFS根据偏移量÷4096算出应该读第几个块,然后去Flash上读那个块:
// knotfs.c — 读操作中的块索引计算
case ST_RD_LOOKUP:
nd = find_node(cur.name);
/* ... */
{
uint32_t end = cur.r_off + cur.r_sz;
if (end > nd->size)
end = nd->size;
sblk = cur.r_off / KNOTFS_BLOCK_SIZE;
eblk = (end + KNOTFS_BLOCK_SIZE - 1) / KNOTFS_BLOCK_SIZE;
if (eblk > nd->block_count)
eblk = nd->block_count;
}
scratch[SCR_RD_SBLK] = sblk;
scratch[SCR_RD_EBLK] = eblk;
subst = sblk;
prog = 0;
cur.st = ST_RD_DATA;
return;
注意,size和block_count是独立的字段。一个文件可能是100字节,但占一个完整的4KB块(因为分配单位是块)。也可能一个文件是0字节(刚创建、未写入),此时block_count = 0,blocks[]全部为0。
free_map:16位搞定全部块的分配
KnotFS的块分配位图是一个简单的uint32_t:
// knot_sb_t 中的字段
uint32_t free_map; /* bitmask: 16 blocks -> fits in 1 uint32 */
每一位代表一个块的分配状态:
- bit 0 → 块0(SB0)——永远为“已使用“
- bit 1 → 块1(SB1)——永远为“已使用“
- bit 2 → 块2(数据块)
- …
- bit 15 → 块15(数据块)
set bit = 已使用,clear bit = 空闲。
操作free_map的函数极其简单:
// knotfs.c — 位图操作
static void mark_free(uint32_t b) { sb.free_map &= ~(1U << b); }
static void mark_used(uint32_t b) { sb.free_map |= (1U << b); }
static bool is_free(uint32_t b) { return !(sb.free_map & (1U << b)); }
在format时,块0和块1被标记为已使用:
// knotfs.c — format中标记SB块
memset(&sb, 0, sizeof(sb));
sb.magic = KNOTFS_MAGIC;
sb.version = KNOTFS_VERSION;
sb.sequence = 0;
sb.log_offset = SB_HEADER_SZ;
for (uint32_t i = 0; i < KNOTFS_DATA_START; i++)
mark_used(i);
循环i < KNOTFS_DATA_START即i < 2,所以块0和块1被标记为已使用。其他14个数据块初始时全部空闲。
图书馆隐喻:馆藏总目录和书架
一座图书馆的藏管有个规矩:馆藏总目录存两份,一份放前台目录柜,一份放后台保险柜——为的是防火灾。这和KnotFS的双SB设计完全一致。
想象你要在一片64个格子(=64KB)的书库空间里管理一座图书馆。第0格和第1格(正对着大门的两格),你不放书架,而是放两个一模一样的“目录柜“。每个柜子里放一张馆藏分布图:哪些格子已经放了书架(free_map的set bit)、每个书架翻阅过多少次(wear[])、一共有多少排书架(nodes[])。
剩下的14个格子才是你能用的。你可以在上面放书架(数据块),每个书架最多存4096本书(一个块的大小)。一张借阅登记卡(knode_t)最多登记8个书架(blocks[8]),所以你的借阅区最大存储量是32千册。
重新编目的时候(compact),你清空旧的目录柜,在新柜子里放进最新的馆藏分布图。这个新柜子比旧柜子高一个序列号——访客来了,看一眼两个柜子,就知道哪个是新的。
如果重新编目过程中停电(掉电),至少还有一个柜子完好。来电后,你从完好的柜子里找到馆藏图,继续管理你的图书馆。
14个数据块够用吗?
对教学来说,够了。14个块 × 4KB = 56KB。测试用例中最大的文件是8KB(t3_large_file),占2个块。之后还有12个块空闲。
但对生产来说,不够。一个有真实需求的嵌入式系统可能需要几十甚至上百MB的存储。这就是为什么生产级方案会引入了间接块和更大的块数——支持128个块(512KB),通过间接块突破直接块的数量限制。
⚠️ 教学简化提示:KnotFS的
free_map是一个uint32_t,正好覆盖16个块。生产级方案的free_bitmap需要覆盖128个块,所以用了一个4×32位的数组。KnotFS的文件表直接用nodes[8]放在SB中,而生产级方案会有独立的File Table块(FT0在块2、FT1在块3)——每个FT块有独立的Log追加空间,减少SB磨损。
下集预告
你知道了Flash的布局——16个块、两本馆藏总目录、14个书架。但这一切都建立在一个“谎言“之上:我们根本没有真正的Flash硬件。KnotFS用一个 64KB 的 RAM 数组骗过了整个文件系统。而且,为了让异步行为“肉眼可见“,我们给这个假Flash注入了人工延迟——读1个tick、写2个tick、擦除3个tick。
下一节,我们走进这个精巧的骗局,看看fdev_tick()怎么让一个内存拷贝“假装需要三个tick“。
悬念留给:这个骗局里有一个真正的Bug——compact_write曾经用了一个栈变量传给异步写操作,导致compact写入的永远是过期数据。这个问题是怎么被发现的?又是怎么被修好的?