5.5 SB-Log——只追加的借阅记录簿
SB Log 的原理(Write-Ahead Log、追加写入、通过 Compact 固化)已经在第三章讨论过了。这里直接看 KnotFS 中每条 Log 记录的数据结构。
Log记录的解剖学:一条8字节的便签条
// knotfs.c
typedef struct {
uint8_t tag; /* LT_BITMAP or LT_USED */
uint8_t blk;
uint16_t val;
uint32_t crc32;
} knot_log_t; /* 8-byte log record (different from SimpleFS's
simplefs_sb_log_record_t) */
字段布局:
Log Record (8 bytes)
╔═══════╤═══════╤══════════╤══════════════════╗
║ tag │ blk │ val │ crc32 ║
║ 1B │ 1B │ 2B │ 4B ║
╚═══════╧═══════╧══════════╧══════════════════╝
有两种log类型:
// knotfs.c
#define LT_BITMAP (0U) /* log entry type: free_bitmap change */
#define LT_USED (1U) /* log entry type: block_used_bytes */
- LT_BITMAP:修改块的分配状态。
val=1表示标记为已使用,val=0表示标记为空闲。这是KnotFS中使用最频繁的Log类型——每次分配新块或释放旧块,都会产生一条LT_BITMAP记录。 - LT_USED:记录块的使用量。在KnotFS中定义但当前几乎未使用——它预留给将来可能的块内容校验和功能。生产级方案中有类似的LT_USED记录,用于跟踪块内有效数据的字节数。
每条记录自己携带一个校验和——计算前4个字节(tag+blk+val)的CRC32:
// knotfs.c
static uint32_t log_checksum(const knot_log_t *e) {
return k_crc32(e, sizeof(knot_log_t) - sizeof(uint32_t));
}
为什么每条记录都要有自己的CRC?因为掉电可能发生在写某条Log记录的中途。如果你只对整个Log区做一次CRC,掉电损坏了中间某条记录,你无法判断是哪条坏了——于是整块Log都不可信。每条记录独立校验,坏的跳过,好的继续。
追加Log:永不覆盖的承诺
Log的写入是通过log_push函数完成的。它把新记录追加到一个内存缓冲区log_buf[32]中:
// knotfs.c — 追加一条Log到内存缓冲区
static int log_push(uint8_t tag, uint8_t blk, uint16_t val) {
if (log_cnt >= LOG_BUF_CAPACITY)
return ERR_Q_FULL;
knot_log_t *e = &log_buf[log_cnt];
e->tag = tag;
e->blk = blk;
e->val = val;
e->crc32 = log_checksum(e);
log_cnt++;
return 0;
}
注意log_cnt >= 32的限制。log_buf[32]是一个静态缓冲区(knotfs.c),一次最多缓存32条Log记录。这是KnotFS的一个限制——如果一次操作产生的Log超过32条(例如删除一个占满8个块的文件,每条块释放产生1条LT_BITMAP + 1条LT_USED,最多16条),缓冲区会溢出。在实际使用中,一次操作产生的Log不超过10条,32条的容量足够。
当一批Log记录写好,需要持久化到Flash时,调用log_flush:
// knotfs.c — 将缓冲区中的Log刷写到Flash
static void log_flush(void) {
if (log_cnt == 0)
return;
uint32_t sz = log_cnt * LOG_ENTRY_SZ;
fdev_write(active_sb_slot(), log_buf, sb.log_offset, sz);
sb.log_offset += sz;
}
active_sb_slot() 根据 sb.sequence 的奇偶性返回当前活跃的 SB 槽位——偶数对应块 0,奇数对应块 1。每次 compact 结束时 sb.sequence++,活跃槽位就在块 0 和块 1 之间乒乓切换。Log 永远追加到当前活跃的 SB 上,compact 永远覆盖到对面非活跃的槽位上。设计上和双副本的乒乓逻辑完全一致。
这里还有一个细节:log_flush使用了fdev_write将Log写入Flash。由于fdev_write是异步的(需要2个tick),而log_buf[32]是静态的——这意味着在fdev_tick真正执行写入之前,你不能再次调用log_push修改log_buf的内容。在KnotFS的实践中,log_flush之后立即进入compact流程,compact会触发一次fdev_erase(需要清空对面SB块才能写入新SB),这自然提供了足够的tick间隔来保证Log写入完成。
回放Log:把便签条上的修改“兑现“
挂载的最后一步是replay_log():
// knotfs.c — 逐条回放Log
static void replay_log(void) {
knot_log_t e;
for (uint32_t i = 0; i < sb.log_count; i++) {
uint32_t pos = SB_HEADER_SZ + i * LOG_ENTRY_SZ;
memcpy(&e, tmp + pos, sizeof(e));
if (e.tag == 0xFF)
break;
if (e.crc32 != log_checksum(&e))
break;
if (e.tag == LT_BITMAP) {
if (e.val)
mark_used(e.blk);
else
mark_free(e.blk);
}
}
}
回放逻辑非常直接:
- 从Log区头部开始,逐条读取8字节记录
- 如果
tag == 0xFF——说明这是一个被擦除过的区域(Flash擦除后的默认值是0xFF),后面的都是空数据,停止回放 - 如果CRC校验不通过——说明掉电发生在这条记录写入中途,数据不可靠,停止回放
- 如果tag是LT_BITMAP —— 根据val更新free_map
一个微妙的设计决策:遇到CRC错误就停止回放,而不是跳过坏的继续。
你可能会想:“为什么不停下这条坏的,试试下一条?掉电可能只破坏了这一条,后面的记录可能是好的呀。”
这个设计决策的背后是一个逻辑推理:如果掉电使得一条Log记录的CRC损坏,那么问题可能不只是这一条。Flash写入中断可能造成电压不稳定,影响后续若干字节的写入。为了安全起见,宁可丢失几条有效Log,也不要冒险使用可能损坏的数据。
更根本的原因是:Log记录中可能存在依赖关系。假设记录5是“释放块7”,记录6是“把块7分配给文件foo”。释放和分配是顺序依赖的——必须先释放才能重新分配。如果记录5损坏被跳过,记录6执行时块7仍处于“已使用”状态,分配会失败(系统会认为空间不足)。这不是状态矛盾,而是操作序列的不完整——你丢失了一个必要的中间步骤,后续操作的前提条件不再满足。与其冒险执行可能错误的操作,不如在第一次遇到CRC错误时停止回放,把剩下的Log全部丢弃。
CRC失败立即停止,这是一个保守但安全的选择。
那丢失的Log会导致什么?举个例子:如果上一次compact后,你做了3次write操作(共产生30条Log),在第25条Log写入时掉电。mount回放时,只能恢复到第24条Log的状态。这意味着最后一个write操作的部分块分配可能丢失——文件大小会恢复为compact时的值。数据丢失了,但文件系统的一致性保住了。
这就是Log的取舍:它不能保证你不丢数据,但保证你不丢一致性。
Compact:便签条太多了,重抄整本目录
Log不能无限增加。每追加一条记录,sb.log_offset就往后挪8字节。当前实现采用无条件compact策略——每次写/追加/删除操作结束后都执行一次compact(为了简化教学逻辑,始终将元数据原子化持久化)。生产实现可按Log条数触发(LOG_THRESHOLD = 214,即50%满),以减少不必要的擦写。
// knotfs.c — compact触发条件
#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 */
compact的过程:
- 选择对面的SB槽位(
inactive_sb_slot()) - 擦除那个块
- 把当前的SB(包括已经固化的文件条目nodes[]和free_map)复制到compact缓冲区
- 清除log区域(
log_offset = SB_HEADER_SZ,log_count = 0) - 写入新的SB
为什么是50%才触发而不是每次修改都触发?因为你每一次compact等于一次整块擦除+写入。如果一个块擦写寿命是10万次,而每次文件修改都触发compact,这个SB块只能撑10万次文件操作。KnotFS上每个文件操作平均产生约10条Log,50%阈值意味着约214条Log才触发一次compact——相当于大约21次文件操作才擦写一次SB块。
不过,这引出了KnotFS的一个教学限制。
⚠️ 教学简化提示:KnotFS没有单独的FT(File Table)和FT Log。生产级方案会有独立的FT块(FT0在块2、FT1在块3),每个FT都有自己独立的Log追加区。这意味着修改文件条目时,只需要在FT Log中追加一条记录,不需要compact整个SB。KnotFS把文件条目
nodes[8]放在SB中——每次文件操作(write/append/delete)都必须通过compact重写整个SB来持久化文件条目。这导致SB槽位的擦写次数比生产级设计高约200倍。生产级方案的compact触发条件是“SB Log超过50% 或 FT Log超过50%“。独立的FT+FT Log彻底解决了这个磨损问题。
下集预告
你掌握了超级块和Log——信息怎么组织、怎么恢复。但文件系统的真正工作是用数据块存文件。16个块,14个可用,怎么分配?哪个块优先用?用过的块怎么回收?
下一节,我们进入块管理和磨损均衡的世界——看看pick_lowest_wear()如何从14个空闲书架格子中选出“最年轻“的那一个——以及,它面对冷数据时的无能为力。
悬念留给:每次选最年轻的书架看起来公平。但如果有一本书从放上去就再也没被改过——它占着的那排书架磨损次数永远是 3 次,而隔壁被频繁换书的书架已经被擦了 8 万次。pick_lowest_wear 能解决这个问题吗?