5.7 Compact——重新誊写目录
Compact 的原理在第三章已经讲过了:当 SB Log 堆积过多时,把内存中的完整状态(nodes[] + free_map + wear[])写入备用 SB 槽位,切换活跃指针。这里直接看代码。
compact的触发条件:50%满
// knotfs.c
#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) /* (4096-668)/8 = 428 */
#define LOG_THRESHOLD (LOG_ENTRIES_MAX * KNOTFS_LOG_THRESHOLD / 100U) /* 214 */
Log区有428个槽位。KnotFS在log_count达到214时触发compact。为什么是50%而不是80%或90%?
原因有两个:
-
掉电安全:compact本身需要一个完整的擦除+写入周期。如果compact过程中掉电,新SB损坏,旧SB仍然完好(因为它还没被擦除)。但如果Log区被填到90%再compact,compact的频率会降低,但每次掉电丢失的未compact的Log越多——在掉电发生前,可能有更多变更只在Log中而没在SB header中,replay的“窗口“更大。
-
写入缓冲:compact需要把当前SB完整写入目标块(668字节)。如果Log区太满,compact时SB的数据量接近一个整块(4096字节),写入时间更长,掉电窗口更大。50%是一个经验上的平衡点。
但要注意:在KnotFS中,compact的实际触发不是由“log_count超过阈值“自动触发的,而是每次需要持久化文件条目的操作(write/append/delete)结束后手动触发的。
看write操作的末尾:
// knotfs.c — write操作触发compact
case ST_WRT_META: {
/* ... 释放旧块、分配新块的Log记录 ... */
compact_erase();
comp_wait = true;
cur.st = ST_WRT_META_FLUSH;
return;
}
case ST_WRT_META_FLUSH:
compact_write();
cur.st = ST_WRT_COMPACT;
return;
case ST_WRT_COMPACT:
compact_finish();
log_commit();
cur.st = ST_OK;
return;
这段代码暴露了KnotFS的关键简化:它在每次write/append/delete操作后都触发一个完整的compact流程。不是因为Log满了,而是因为必须把文件条目的变更(nodes[]中的name、size、block_count、blocks[])持久化到Flash。
Compact流程:三步舞曲
Compact是一个三步异步流程:
Compact 异步状态机
====================
开始
↓
┌──────────────────────────┐
第一步: │ compact_erase() │
compact │ → fdev_erase(tgt) │ 擦除对面的SB槽位
_erase │ → ticks_left = 3 │ (需要3个tick)
└────────────┬─────────────┘
│ 等待fdev_idle()...
↓
┌──────────────────────────┐
第二步: │ compact_write() │
compact │ → memcpy(&compact_buf, │ 把当前SB复制到compact缓冲区
_write │ &sb, ...) │ → 递增sequence
│ → 清理log_offset/count │ → 写4KB到目标块
│ → fdev_write(tgt, ...) │ (需要2个tick)
└────────────┬─────────────┘
│ 等待fdev_idle()...
↓
┌──────────────────────────┐
第三步: │ compact_finish() │
compact │ → sb.sequence++ │ 更新内存中的序列号
_finish │ → sb.log_offset = 668 │ → 重置Log区
│ → sb.log_count = 0 │
└──────────────────────────┘
↓
完成
代码逐个来看。
第一步:compact_erase
// knotfs.c
static void compact_erase(void) {
uint32_t tgt = inactive_sb_slot();
fdev_erase(tgt);
}
inactive_sb_slot()选择的是当前活跃SB的对面的块,与active_sb_slot()互补:
// knotfs.c — 活跃与惰性槽位的一对互斥选择器
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是奇数,说明上次compact是写到SB1的 → 当前活跃的是SB1 → 空闲(compact目标)是SB0。偶数则反过来。Log往活跃槽位追加(log_flush用active_sb_slot()),compact往非活跃槽位覆盖。通过在SB0和SB1之间交替,均匀化了两个SB块的磨损。
第二步:compact_write
// knotfs.c
static void compact_write(void) {
memcpy(&compact_buf, &sb, sizeof(knot_sb_t));
compact_buf.sequence = sb.sequence + 1;
compact_buf.log_offset = SB_HEADER_SZ;
compact_buf.log_count = 0;
compact_buf.crc32 = sb_checksum(&compact_buf);
uint32_t tgt = inactive_sb_slot();
fdev_write(tgt, &compact_buf, 0, sizeof(knot_sb_t));
}
这里有一个关键细节:compact_buf是一个全局静态变量,不是栈变量。原因在第5.3节已经讲过——fdev_write是异步的,栈变量会在函数返回后失效。如果compact_buf是栈变量,2个tick后fdev_tick中的memcpy会读到垃圾数据。
compact_buf.sequence = sb.sequence + 1——新SB的序列号比旧SB大1。这是后面mount时选择“哪个更新“的依据。
compact_buf.log_count = 0——新SB的Log区是空的。所有之前Log中累积的变更(块分配/释放)已经通过write/append/delete流程中调用的sb.free_map和sb.nodes[]操作固化进了SB的header。
第三步:compact_finish
// knotfs.c
static void compact_finish(void) {
sb.sequence++;
sb.log_offset = SB_HEADER_SZ;
sb.log_count = 0;
}
这步更新的是内存中的sb(当前活跃的SB镜像),让它与已经写到Flash上的内容保持一致。如果不做这一步,内存中的sb会认为自己是旧版本,下次compact可能会重复写到同一个槽位。
事件循环中的compact协调
compact的三个步骤不是在一个函数中连续执行的。它们被拆成三步,每一步都需要等待fdev_idle()。这个协调工作在事件循环中完成:
// knotfs.c — 事件循环中处理compact完成
if (comp_wait && fdev_idle()) {
comp_wait = false;
}
comp_wait在compact_erase或compact_write发起后被设为true。事件循环检测到Flash空闲(操作完成)后,清除comp_wait标志,回到状态机执行下一步。
状态机中的下一个case会自动进入下一步(例如ST_WRT_META → ST_WRT_META_FLUSH → ST_WRT_COMPACT),继续推进compact流程。
为什么compact写入和普通write的紧凑状态分开管理?
注意write操作末尾的三个状态:
ST_WRT_META → 发起compact_erase() → 转入ST_WRT_META_FLUSH
ST_WRT_META_FLUSH → 发起compact_write() → 转入ST_WRT_COMPACT
ST_WRT_COMPACT → 调用compact_finish() → 转入ST_OK
为什么compact要占用write状态机中的三个状态,而不是独立一个状态让事件循环去调度?
因为在KnotFS的简化架构中,compact是write/append/delete操作的一个子步骤,不是独立的后台任务。当write操作进入ST_WRT_META时,它知道:Log已经累积好了、元数据已经更新了、现在需要持久化整个超级块。它必须亲自驱动compact完成,不能在compact完成前就宣布write操作成功。
生产级方案不同。它有独立的GC(垃圾回收)模块,compact作为一个后台任务运行,不阻塞文件操作。操作只需要确保它的Log记录已经追加到SB Log中——剩下的compact(将Log固化为header并释放Log空间)由GC在后台完成。这大幅减少了文件操作的延迟。
图书馆隐喻:重新誊写目录不是拆了重建
你管理一座百年老馆。书皮磨损、空气潮湿。你需要重新编目——但你不能把整座图书馆拆了,因为里面还有读者。
compact就是这个重新编目的过程:
-
保留索引结构(文件条目nodes[])。 重新编目不改变馆藏结构——原来有3个借阅区(3个文件),重新编目后还是3个。每个区的藏书量(文件大小)也不变。
-
更新目录和标签(重写SB)。 把旧标签撕掉(擦除目标块),重新贴上新的标签(写入新SB)。此时标签是新的——但里面的借阅区和布局和原来一样。
-
换一个目录柜工作(交替SB0/SB1)。 如果你总是在同一个柜子上贴标签,那个柜子会比其他柜子更早磨损。所以这次用前台柜(SB0),下次用后台柜(SB1),交替使用。两个柜子一起老化。
-
注销旧的(旧SB变为新的“对面“)。 新SB写入后,旧SB所在的柜子变成了下一次compact的目标柜。这样每次compact都在两个SB柜之间“乒乓“。
重新编目和重建的区别:重新编目的成本是O(SB块的一次擦写),重建的成本是O(所有块的一次擦写)。KnotFS永远只做compact(重新编目),不做rebuild(重建)。
⚠️ 教学简化提示:KnotFS的compact触发频率远高于生产级方案。KnotFS在每次write/append/delete操作后都触发compact(为了持久化nodes[]),而生产级方案的compact触发条件是“SB Log超过50% 或 FT Log超过50%“。由于它有独立的FT块和FT Log,90%以上的文件操作不需要compact——它们只需要在FT Log中追加一条记录。这意味着KnotFS的SB槽位磨损比生产级设计高约200倍。对于教学场景(12个测试用例,总共不到100次compact),这不是问题。但如果你想把这个代码部署到真实Flash上,SB块最多能撑大约500次文件操作(100,000 / 200 ≈ 500)。此外,生产级方案的compact(在GC模块中)是一个独立的后台任务,不阻塞前台文件操作——这得益于SB Log的异步追加和独立FT Log的设计。
下集预告
compact 解决了“元数据怎么安全落地“——但文件系统最核心的操作是“写文件“。KnotFS 写文件不是简单的“打开→写入→关闭“——它是一套 Copy-on-Write 原子事务:先在新书架上排好书,等全部排好、校验通过,才把旧书架上的书撤下来。
下一节,我们走进 CoW 的世界——看 KnotFS 如何用“没确认就不撤旧书“的原则,保证任何一个时刻断电,文件要么完整地回到旧版本,要么完整地跳到新版本,绝不存在“半个文件“。
悬念留给:CoW 听起来很完美,但它有一个代价——你需要额外的空闲书架来放新版本的数据。如果你的图书馆快满了,CoW 会失败——哪怕你只是想把文件改一个字。