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));
逻辑如下:
- 如果SB0的magic对不上或CRC校验失败 →
ok0 = false→ 用SB1 - 如果SB1的magic对不上或CRC校验失败 →
ok1 = false→ 用SB0 - 两者都有效 → 比较sequence,选大的那个
- 两者都无效 → 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(重新誊写新目录)时,管理员会:
- 把新目录誊写到对面那个柜子里(
inactive_sb_slot()判断) - 新目录誊写完毕后,它就成了当前活跃的
- 旧的那本自动退位为“备份“
这样循环交替:前台变后台→后台变前台→前台再变后台……
最坏的情况:誊写新目录誊到一半,灯灭了。
前台柜子里的新目录刚誊写到第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做出了一个激进的选择:遇到校验失败立刻停止回放。它放弃了可能有效的后续便签。为什么这是正确的?