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.4 超级块双副本——馆藏总目录一式两份

所有元数据的“单一真相来源“

如果把文件系统比作一座图书馆,超级块(Superblock)就是那本馆藏总目录。它记载着:

  • 哪些书架已经放了书、哪些还是空的(free_map
  • 每本书放在哪个书架的第几格(nodes[]
  • 每个书架被翻阅过多少次(wear[]
  • 目录当前的“版本号“(sequence

没了这本总目录,你的图书馆只剩一排排书架——你知道上面有书,但不知道哪本是《三体》、哪本是《时间简史》、哪本已经被读者借走了。

KnotFS的超级块结构体如下:

// knotfs.c — 超级块完整定义
typedef struct {
  uint32_t magic;
  uint32_t version;
  uint32_t sequence;
  uint32_t log_offset;
  uint32_t log_count;
  uint32_t free_map; /* bitmask: 16 blocks -> fits in 1 uint32 */
  uint32_t wear[KNOTFS_BLOCK_COUNT];
  knode_t nodes[KNOTFS_MAX_FILES];
  uint32_t crc32;
} knot_sb_t; /* superblock (different from SimpleFS's simplefs_superblock_t) */

这个结构体恰好668字节(sizeof(knot_sb_t)在代码中被定义为SB_HEADER_SZ):

knot_sb_t 字段大小计算
========================

magic               4 bytes  (uint32_t)
version             4 bytes  (uint32_t)
sequence            4 bytes  (uint32_t)
log_offset          4 bytes  (uint32_t)
log_count           4 bytes  (uint32_t)
free_map            4 bytes  (uint32_t)
wear[16]           64 bytes  (16 × uint32_t)
nodes[8]          576 bytes  (8 × 72-byte knode_t)
crc32               4 bytes  (uint32_t)
────────────────────────────
合计              668 bytes  = SB_HEADER_SZ

正好是4+4+4+4+4+4+64+576+4 = 668。剩余的3428字节(4096 - 668)是Log追加区。


magic:这是本馆的目录还是废纸?

Magic(魔数)是文件系统识别自己的方式。KnotFS的magic是0x4B4E4653——在Little-Endian系统中以字节序读出时是'K' 'N' 'F' 'S'

为什么需要magic?想象你接管了一座图书馆。你走进目录室,桌上放着一本翻开的册子。你怎么知道这本册子是不是这座图书馆的馆藏目录——而不是前任管理员留下的私人笔记本?

你翻开册子,看第一页的前四个字母。如果是KNFS,你就知道“这是KnotFS格式的目录“,可以继续往下读。如果第一页写着FAT1,你就知道这可能是前任管理员在其他图书馆用过的FAT格式目录,你读不懂它的编码方式,应该拒绝挂载。

在mount流程中,magic是第一道过滤:

// knotfs.c — mount中检查magic和CRC
bool ok0 = (sb.magic == KNOTFS_MAGIC && sb.crc32 == sb_checksum(&sb));
bool ok1 =
    (sb_alt.magic == KNOTFS_MAGIC && sb_alt.crc32 == sb_checksum(&sb_alt));
if (!ok0 && !ok1) {
  cur.result = ERR_MNT_FAIL;
  cur.st = ST_ERR;
  return;
}

如果两份目录的magic都不对——要么这座图书馆从没被编目过,要么它根本不是KnotFS管理的馆藏——mount失败,返回错误码-10。


sequence:哪本目录是最新版本?

Sequence(序列号)是一个单调递增的uint32_t。每次compact(重新誊写目录)后,sequence加1:

// knotfs.c — compact结束后sequence递增
static void compact_finish(void) {
  sb.sequence++;
  sb.log_offset = SB_HEADER_SZ;
  sb.log_count = 0;
}

这个序列号解决了双副本的核心问题:两本目录,哪本是最新版?

如果你走进目录室,发现桌上有两本一模一样的《馆藏总目录》——一本封面写着“第3版“,另一本写着“第7版“。你不需要对比内页,只看封面就知道应该相信第7版。

Mount时,KnotFS读取SB0和SB1,比较它们的sequence:

// knotfs.c — 根据sequence选择较新的SB
if (!ok0)
  memcpy(&sb, &sb_alt, sizeof(sb));
else if (ok1 && sb_alt.sequence > sb.sequence)
  memcpy(&sb, &sb_alt, sizeof(sb));

逻辑如下:

  1. 如果SB0的magic对不上或CRC校验失败 → ok0 = false → 用SB1
  2. 如果SB1的magic对不上或CRC校验失败 → ok1 = false → 用SB0
  3. 两者都有效 → 比较sequence,选大的那个
  4. 两者都无效 → mount失败

有一个微妙之处:如果SB0的sequence恰好和SB1相等(格式刚完成时两者都是0),代码会选SB0。这不是Bug——序列号相同时两者内容确实一样,选哪本目录都行。


CRC32:这本目录有没有缺页?

CRC32(循环冗余校验)是KnotFS数据完整性的最后一道防线。在超级块的末尾(最后一个uint32_t字段),存放着对超级块其他667个字节(sizeof(knot_sb_t) - sizeof(uint32_t))计算的CRC32值。

// knotfs.c — SB的CRC计算(排出crc32字段自身)
static uint32_t sb_checksum(const knot_sb_t *sb) {
  return k_crc32(sb, sizeof(knot_sb_t) - sizeof(uint32_t));
}

CRC32多项式是标准的0xEDB88320

// knotfs.c — CRC32计算核心
static uint32_t k_crc32(const void *data, uint32_t len) {
  uint32_t c = 0xFFFFFFFFU;
  const uint8_t *p = (const uint8_t *)data;
  for (uint32_t i = 0; i < len; i++) {
    c ^= p[i];
    for (int j = 0; j < 8; j++)
      c = (c >> 1) ^ ((c & 1U) ? 0xEDB88320U : 0U);
  }
  return c ^ 0xFFFFFFFFU;
}

CRC保护的是什么场景?想象管理员正在誊写新版目录——抄到第300条书目的时候,突然停电了。笔停在半空,纸上的墨迹还没干。来电之后,你拿起这本目录:前299条是新版的内容,第300条只写了一半(可能是乱码),后面的条目还是旧版的残留。

如果管理员拿着这本“半新不旧“的目录去给读者找书——他可能会告诉读者《三体》在7号书架——但实际上7号书架早就被清空了,因为停电前管理员确实清理了7号书架,但这条清理记录没写进目录。

CRC不会让停电不发生。但CRC让你能在来电后检测到目录被写坏了——然后扔掉损坏的那本,用完好的一本。

这就是为什么不是存“一“本目录,而是存“两“本。一本被写到一半,另一本大概率是完好的。


mount流程:4步状态机

KnotFS的挂载过程是一个精巧的4步状态机(加 ST_OK 共 5 步):

Mount 状态机流程
===================

┌──────────────────────┐
│  ST_MNT_RD_SB0       │  → fdev_read(SB0)        从书架上取出目录A
│  (状态=20)           │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│  ST_MNT_RD_SB1       │  → fdev_read(SB1)        从书架上取出目录B
│  (状态=21)           │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│  ST_MNT_SEL          │  → 验证magic+CRC         选择较新的那本
│  (状态=22)           │   → 比较sequence         (两本都损坏则失败)
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│  ST_MNT_REPLAY       │  → replay_log()          重放便签上的记录
│  (状态=23)           │   → mounted = true        恢复未誊写到目录中的变更
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│  ST_OK               │  挂载成功,图书馆开门营业
│  (状态=90)           │
└──────────────────────┘

完整代码:

// knotfs.c — mount状态机
static void do_mount(void) {
  knot_sb_t sb0;

  switch (cur.st) {
  case ST_MNT_RD_SB0:
    fdev_read(KNOTFS_SB0_BLK, tmp, KNOTFS_BLOCK_SIZE);
    cur.st = ST_MNT_RD_SB1;
    return;
  case ST_MNT_RD_SB1:
    fdev_read(KNOTFS_SB1_BLK, &sb_alt, sizeof(sb_alt));
    cur.st = ST_MNT_SEL;
    return;
  case ST_MNT_SEL: {
    memcpy(&sb0, tmp, sizeof(knot_sb_t));
    bool ok0 = (sb0.magic == KNOTFS_MAGIC && sb0.crc32 == sb_checksum(&sb0));
    bool ok1 =
        (sb_alt.magic == KNOTFS_MAGIC && sb_alt.crc32 == sb_checksum(&sb_alt));
    if (!ok0 && !ok1) {
      cur.result = ERR_MNT_FAIL;
      cur.st = ST_ERR;
      return;
    }
    if (!ok0) {
      memcpy(&sb, &sb_alt, sizeof(sb));
      fdev_read(KNOTFS_SB1_BLK, tmp, KNOTFS_BLOCK_SIZE);
      cur.st = ST_MNT_RD_LOG;
    } else if (ok1 && sb_alt.sequence > sb0.sequence) {
      memcpy(&sb, &sb_alt, sizeof(sb));
      fdev_read(KNOTFS_SB1_BLK, tmp, KNOTFS_BLOCK_SIZE);
      cur.st = ST_MNT_RD_LOG;
    } else {
      memcpy(&sb, &sb0, sizeof(sb));
      cur.st = ST_MNT_REPLAY;
    }
    return;
  }

  case ST_MNT_RD_LOG:
    cur.st = ST_MNT_REPLAY;
    return;

  case ST_MNT_REPLAY:
    replay_log();
    mounted = true;
    cur.st = ST_OK;
    return;
  default:
    return;
  }
}

注意:ST_MNT_RD_SB0读取完毕后不直接进入分析——它转而发起fdev_read(SB1)ST_MNT_RD_SB1完成后才进入ST_MNT_SEL进行验证和选择。

为什么要先读完两本再比较?因为管理员不能假设哪本是好的。如果你拿起目录A翻了两页觉得没问题就开始用它,而目录B其实版本更新——那你就会丢失上次闭馆前做的所有书目更新。

在两本目录都取出来之前,管理员不做任何决定。


图书馆隐喻:目录一式两份,分别锁在两个柜子里

一座正规的图书馆,馆藏总目录不会只存一份。管理员会把目录抄写两份:一本锁在前台目录柜,日常查阅用;另一本锁在后台保险柜,只在火灾或盗窃后启用。

SB0就是前台柜子里的目录,SB1就是后台保险柜里的备份。日常运营时,KnotFS使用的是当前活跃的那份(通过sequence判断)。当需要compact(重新誊写新目录)时,管理员会:

  1. 把新目录誊写到对面那个柜子里(inactive_sb_slot()判断)
  2. 新目录誊写完毕后,它就成了当前活跃的
  3. 旧的那本自动退位为“备份“

这样循环交替:前台变后台→后台变前台→前台再变后台……

最坏的情况:誊写新目录誊到一半,灯灭了。

前台柜子里的新目录刚誊写到第668个条目(CRC还没算),台灯灭了。来电之后,管理员从黑暗中爬起来,走到后台保险柜,取出备份目录——它完好无损,sequence虽然比誊写到一半的新版小1,但所有书目都是对的。

管理员拿着这本备份目录,重新誊写了一份新的。图书馆继续营业。

这就是双超级块的本质:不防止灾难,但保证你在灾难后能站起来。


⚠️ 教学简化提示:KnotFS的sequence是一个简单的uint32_t,每次compact加1。生产级方案会使用了更严格的比较逻辑——当sequence相等时还会比较CRC有效性作为tiebreaker。此外,生产级方案的超级块除了sequence,还有一个generation字段,用于区分“序列号回绕“的情况(uint32_t写到最大值后回绕到0)。KnotFS的64KB教学场景下,compact次数远达不到2³²次,所以这些边缘情况被省略了。


下集预告

馆藏总目录选出了最新版本,但最新版本里夹着一沓“待办便签“——Log区域。在闭馆之前,可能有几十条操作(新书上架、旧书下架、书架标记更新)已经写在便签上但还没誊进目录正文。挂载的最后一步——replay_log(),就是把这沓便签逐条处理,把目录恢复到闭馆前的完整状态。

下一节,我们进入SB Log的世界,看看这个“贴在目录后面的便签“如何以每条8字节的代价,换来了掉电安全。

悬念留给:Log回放到某一条时,发现它的CRC不对。这是正常的——说明停电正好发生在贴这张便签的中途。但KnotFS做出了一个激进的选择:遇到校验失败立刻停止回放。它放弃了可能有效的后续便签。为什么这是正确的?