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.8 Copy-on-Write——没确认就不撤旧书

Copy-on-Write 的原理在第三章已经充分讨论:永远不在原地修改,先写新数据,确认后再释放旧数据。这里看 KnotFS 的实现。

CoW的两阶段协议

KnotFS的写操作是一次严格的两阶段事务。把它理解为原子的“提交协议“:

阶段一:数据准备
  ├── 1. 分配新块(pick_lowest_wear,选磨损最小的空块)
  ├── 2. 写入新数据到新块(fdev_write,模拟Flash编程)
  └── 3. 内存中的file entry已指向新块

阶段二:元数据落地
  ├── 4. 在SB Log中记录旧块释放(LT_BITMAP, val=0)
  ├── 5. 在SB Log中记录新块占用(LT_BITMAP, val=1)
  ├── 6. Compact:把新的SB内容(含新file entry)写入Flash
  └── 7. Compact完成,旧块正式归还给free_bitmap

核心的原子性保证在阶段一和阶段二的断层上:如果在阶段一完成之后、阶段二完成之前断电,SB仍然指向旧数据。Flash上确实有新的数据块占着空间,但文件系统在重启mount时并不认识它们——它们不在任何file entry的blocks[]数组里,也不在free_bitmap的FREE列表里。换句话说,它们是“孤儿块“。

但,KnotFS的log replay会在mount时重新执行一遍SB Log中的操作记录。如果compact已经写入但未commit,在下次mount时SB仍然读取的是旧副本——那个compact写入的是inactive_sb_slot(),sequence号还没来得及+1。

让我们看KnotFS里写操作的初始化代码:

/* knotfs.c: ST_WRT_INIT — 写操作的起点 */
case ST_WRT_INIT:
    nd = find_node(cur.name);
    oldcnt = 0;
    if (nd) {
      for (i = 0; i < nd->block_count && i < KNOTFS_DIRECT_BLKS; i++)
        oldb[i] = nd->blocks[i];
      oldcnt = nd->block_count;
      nd->size = 0; /* invalidate old entry so alloc_node won't skip it */
    }
    need = (cur.w_sz + KNOTFS_BLOCK_SIZE - 1) / KNOTFS_BLOCK_SIZE;
    if (need > KNOTFS_DIRECT_BLKS) {
      cur.result = ERR_TOO_MANY;
      cur.st = ST_ERR;
      return;
    }
    nd = alloc_node(cur.name);
    if (!nd) {
      cur.result = ERR_NO_NODE;
      cur.st = ST_ERR;
      return;
    }
    nd->size = cur.w_sz;
    nd->block_count = (uint32_t)need;

    scratch[SCR_OLD_CNT] = oldcnt;
    for (i = 0; i < oldcnt && i < KNOTFS_DIRECT_BLKS; i++)
      scratch[SCR_OLD_BLK(i)] = oldb[i];
    scratch[SCR_NEW_NEED] = need;
    subst = 0;
    cur.st = ST_WRT_ALLOC;
    return;

注意这一段里的精妙之处:先记录旧块,再清零旧entry,最后分配新entry。 nd->size = 0这一行让alloc_node不再跳过这个slot——因为在alloc_node的实现里,size == 0的slot才被认为空闲:

/* knotfs.c: alloc_node — 找到第一个空闲file entry */
static knode_t *alloc_node(const char *name) {
  for (uint32_t i = 0; i < KNOTFS_MAX_FILES; i++) {
    if (sb.nodes[i].size == 0) {
      memset(&sb.nodes[i], 0, sizeof(knode_t));
      {
        uint32_t l = 0;
        while (l < KNOTFS_NAME_LEN - 1 && name[l])
          l++;
        memcpy(sb.nodes[i].name, name, l);
        sb.nodes[i].name[l] = '\0';
      }
      return &sb.nodes[i];
    }
  }
  return NULL;
}

所以同一个文件名可以保持,但底层已经是一个全新的knode_t了。旧块号被安全地保存在scratch[]数组中,等待CoW成功后去释放。


为什么旧块不立刻释放

你可能想:分配新块的时候,顺手把旧块free掉不就行了吗?为什么非要等到compact完成?

问题出在时序上。如果在分配新块时就释放旧块,free_bitmap里旧块变FREE了。假如此刻断电,下次mount时SB显示:file entry指向新块(但新块数据可能不完整),旧块却已经在free_bitmap里标记为FREE——下次写别的文件时,pick_lowest_wear可能选中这块“旧书架“,覆盖掉原本可以恢复的数据。

正确的做法是:在compact成功之前,旧块始终保持USED状态。 只有当新SB完整地写到了Flash上,旧块才在SB Log中标记为FREE。这就是KnotFS在ST_WRT_META阶段做的事情:

/* knotfs.c: ST_WRT_META — 释放旧块,记录新块 */
case ST_WRT_META: {
    log_reset();
    oldcnt = scratch[SCR_OLD_CNT];
    for (i = 0; i < oldcnt && i < KNOTFS_DIRECT_BLKS; i++) {
      uint32_t blk = scratch[SCR_OLD_BLK(i)];
      if (blk < KNOTFS_BLOCK_COUNT) {
        log_push(LT_BITMAP, (uint8_t)blk, 0);
        mark_free(blk);
      }
    }
    need = scratch[SCR_NEW_NEED];
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_COPY;
      cur.st = ST_ERR;
      return;
    }
    for (i = 0; i < need && i < KNOTFS_DIRECT_BLKS; i++)
      log_push(LT_BITMAP, (uint8_t)nd->blocks[i], 1);

    /* compact SB to persist file entries + log */
    compact_erase();
    comp_wait = true;
    cur.st = ST_WRT_META_FLUSH;
    return;
}

这里log_push(LT_BITMAP, blk, 0)释放旧块,log_push(LT_BITMAP, nd->blocks[i], 1)占用新块。两条log记录都被推入内存缓冲区log_buf[],然后通过compact流程写入Flash。


Compact:把整本目录誊写到新纸上

KnotFS的compact不是增量更新,而是整本SB重写。当Log记录数量超过阈值(50%,即214条),compact流程启动:

static void compact_erase(void) {
  uint32_t tgt = inactive_sb_slot();
  fdev_erase(tgt);
}

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));
}

static void compact_finish(void) {
  sb.sequence++;
  sb.log_offset = SB_HEADER_SZ;
  sb.log_count = 0;
}

整个compact三步骤:①erase目标槽位(SB0或SB1中不活跃的那个) ②把完整SB写入那个槽位 ③compact_finish更新内存中的sequence号,证明“换到了新目录“。

这里的关键:compact写入的是非活跃槽位,如果中途断电,活跃槽位的旧SB不受任何影响。 下次mount时,会挑选CRC校验通过且sequence号更大的那个SB。compact_write把新SB的sequence设为sb.sequence + 1,因此新SB的sequence比旧SB大1。如果掉电发生在新SB写入完成之后但compact_finish尚未执行——新SB已完整写入Flash且CRC校验可通过(sequence更大),mount会选择新SB。如果掉电发生在新SB写入中途——新SB数据不完整(CRC校验失败),mount会选择旧SB(sequence更小但完整有效)。旧SB里的file entry仍然指向旧数据。无论哪种情况,至少有一个完整的SB副本存在——这就是双槽位Compact的原子性保障。

如果compact成功完成,内存中的sb.sequence被+1,下次mount时sequence更大的那个SB胜出。新SB里的file entry指向新块,旧块被Log标记为FREE。旧数据依然在Flash上,但它已经不在任何元数据引用的范围里——它成为了一个可以被垃圾回收利用的“空书架格子“。

同时,compact还顺带完成了另一个隐藏操作:Log清零。 compact_buf.log_count = 0compact_buf.log_offset = SB_HEADER_SZ意味着新SB上没有任何log记录。所有在compact之前积累的log变化(bitmap变更、used_bytes更新)都已经“固化“到SB的字段里了。这就是log-structured元数据的精髓:log是暂存区,compact是持久化。


CoW的代价:空间放大

CoW不是免费的。写一个1字节的文件,KnotFS也会分配一整块(4096字节)。写一个4097字节的文件,需要2块。空间浪费率在最坏情况下接近100%(每个文件浪费一整个块)。

空间放大示例:
  文件大小    | 分配块数  | 实际占用  | 浪费比例
  ────────────────────────────────────────────
  1 B         | 1块(4K)  | 4,096 B  | 99.97%
  100 B       | 1块(4K)  | 4,096 B  | 97.56%
  4,096 B     | 1块(4K)  | 4,096 B  | 0%
  4,097 B     | 2块(8K)  | 8,192 B  | 49.99%
  8,192 B     | 2块(8K)  | 8,192 B  | 0%

这是块设备的天然代价。在生产级版本里,通过“间接块“来存储大文件的块指针,减少小文件的空间浪费。KnotFS没有间接块,但我们有一个8块的直接块上限(KNOTFS_DIRECT_BLKS = 8),对应最大文件32KB。


CoW还是原地写?一场打了40年的架

在文件系统的历史上,CoW和in-place update之间的争论持续了几十年:

原地写(In-Place Update):
  代表:ext2/3, FAT32, NTFS
  优点:不浪费空间,写放大=1
  缺点:断电时元数据可能不一致,需要fsck
  经典事故:写inode到一半断电 → 文件丢失或目录损坏

写时复制(Copy-on-Write):
  代表:ZFS, Btrfs, WAFL, KnotFS
  优点:原子性强,天然支持快照和版本回滚
  缺点:写放大(修改1字节可能导致整块复制),碎片化
  经典场景:数据库、日志文件系统、嵌入式Flash

对于嵌入式NOR Flash来说,CoW是唯一的正确选项。原因有二:第一,NOR Flash不支持原地覆盖——写之前必须先擦除整块(4KB对齐),你不能单独修改一个字节。第二,嵌入式设备面临频繁断电(汽车熄火、设备掉电),原地覆盖在这种环境下是不可接受的。

KnotFS把CoW做对了,但你也要知道,生产级方案在这一点上会有更多考量。

⚠️ 教学简化提示:这里KnotFS选择了“全块复制+两阶段compact“的简化版CoW。生产级方案在这一点上会有更多考量:①is_old_block()保护机制——在mount后的log replay阶段标记所有被任一SB Log引用的旧块为“待释放“,除非compact确认否则不会被pick_lowest_wear重新分配,防止“孤儿块误伤“;②s_old_indirect_blocks[]数组追踪间接块——当文件使用间接块时,旧间接块也需要延迟释放,KnotFS没有间接块所以简化了这一点;③单独的文件表(FT0/FT1)——生产级方案的file entry不在SB里,而是独立存放在文件表块中,CoW的元数据更新需要同时compact SB和FT两套日志系统。


如果你来设计

现在你理解了CoW的核心思想。试着回答:如果一个写操作分配了2个新块,但在写第2个新块的数据时断电了,下次mount时会发生什么?

答案:SB指向旧数据(因为compact还没做)。新分配的2个块中,第1个块的数据已经写入了Flash,第2个块可能部分写入。但问题不大,因为下次mount时选中的仍然是旧SB,旧SB里的file entry仍然指向旧块,而这两个“孤儿块“在旧SB的free_bitmap里仍然标记为USED(因为是在compact之前的log中标记的)。所以它们不会被重新分配——但它们的空间会被浪费,直到下一次该文件的写入操作把它们释放。

这种“孤儿块“的空间浪费是可接受的——NOR Flash有512KB的空间,偶尔几个孤儿块不致命。相比之下,如果选择原地覆盖而丢了一个文件,代价就大多了。存储工程的第一条原则:宁可浪费空间,绝不丢失数据。


下集预告

CoW解决了“怎么安全地写“,但“写“本身不是一步完成的事——你需要找名字、分配块、传数据、调元数据、compact……每一件事都可能被Flash的异步延迟打断。下一节,我们跟踪一次完整的写入操作,从knotfs_write("hello.txt", buf, 11)一路走到ST_OK,看状态机里的每一个岔路口和每一次“等一等再回来“。从ST_WRT_INIT到ST_WRT_COMPACT,一共7个状态、5次异步等待——写入流程的完整走读。