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

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++,两个槽位乒乓切换,轮流承担“当前有效副本“的角色。这有两个好处:

  1. 掉电安全:写新SB时即便断电,旧SB依然完好
  2. 磨损均匀: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;

注意,sizeblock_count是独立的字段。一个文件可能是100字节,但占一个完整的4KB块(因为分配单位是块)。也可能一个文件是0字节(刚创建、未写入),此时block_count = 0blocks[]全部为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_STARTi < 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写入的永远是过期数据。这个问题是怎么被发现的?又是怎么被修好的?