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.9 写入流程——全链路状态机走读

先看全剧,再看每一幕

在进入逐行代码之前,先看一眼这出戏的完整节目单。KnotFS 的一次写入,从 API 调用到完成,共 6 个状态

状态机全景图
=======================

ST_WRT_INIT    —— 查找同名文件、记录旧块、分配新文件条目
    ↓
ST_WRT_ALLOC   —— 为新数据分配空闲块(pick_lowest_wear)
    ↓
ST_WRT_DATA    —— 把用户数据写入 tmp 缓冲区,发起 Flash 写入(CoW 核心)
    ↓
ST_WRT_META    —— 标记旧块为空闲(log_push),更新文件条目,compact 持久化
    ↓
ST_WRT_META_FLUSH —— 等待擦除完成,compact_write 写入新 SB
    ↓
ST_WRT_COMPACT —— compact_finish 更新 sequence,log_commit 确认提交
    ↓
ST_OK → 通知调用者

这 6 个状态不是 6 个函数调用——它们是 6 帧画面。每帧之间,knotfs_run() 被调用一次,推进一个 Tick。Flash 的擦除和写入在帧与帧之间异步完成。

下面是每一幕的细节。

你不是在跑一个函数,你是在演一出话剧

你调用了 knotfs_write("hello.txt", buf, 11)。你期望这个函数执行完,文件就写好了。

它没有。它只是往请求队列里塞了一张纸条,就返回了。

然后 knotfs_run() 被反复调用——在KnotFS的测试程序里是 while(knotfs_run()) 空转——每一次调用都是一幕戏。状态机从ST_WRT_INIT跳转到ST_WRT_ALLOC,再跳到ST_WRT_DATA……每一跳之间,Flash模拟器都在执行读写延迟。这不是同步的函数调用,这是一部七幕话剧,每一幕结束后,演员要等道具组把下一场的布景搬上来

让我们跟踪这出戏的剧本。


第一幕:ST_WRT_INIT —— 找到旧书架,申请新书架

ST_WRT_INIT 要做的事:
  1. 查找同名文件是否存在
  2. 如果存在,记录它的旧块号(准备CoW释放用)
  3. 清零旧entry的size(让alloc_node空出这个slot)
  4. 计算新数据需要多少块
  5. 分配新entry,填入文件名、size、block_count
  6. 把旧块信息存入scratch[]数组(跨状态传递用)

这里有一个反直觉的操作:nd->size = 0。它清零的是旧entry,但nd这个指针指向的是sb.nodes[]里的那个slot——alloc_node在遍历sb.nodes[]时碰到size==0的就会复用。所以清零旧entry的size不是为了放弃这个slot,恰恰相反——是为了让alloc_node能够“回收利用“这个slot来放同名的新entry信息:

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;

scratch[]数组是KnotFS状态机里跨状态传递参数的桥梁。因为状态机每次进入只能通过cur.st知道“我在哪个状态“,而各状态之间共享的是一个static uint32_t scratch[16]数组——一个全局的便签纸。scratch[0]存旧块计数,scratch[1..8]存旧块号,scratch[9]存新块需求数。这不是优雅的设计,但它透明。


第二幕:ST_WRT_ALLOC —— 磨损均衡的选块

case ST_WRT_ALLOC:
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_COPY;
      cur.st = ST_ERR;
      return;
    }
    need = scratch[SCR_NEW_NEED];
    if (subst >= need) {
      subst = 0;
      cur.st = ST_WRT_DATA;
      return;
    }

    {
      uint32_t blk = pick_lowest_wear();
      if (blk >= KNOTFS_BLOCK_COUNT) {
        cur.result = ERR_NO_BLK;
        cur.st = ST_ERR;
        return;
      }
      mark_used(blk);
      sb.wear[blk]++;
      nd->blocks[subst] = blk;
    }
    subst++;
    return;

注意subst的用法:它既是块序号索引,又是“已经分配了几个块“的计数器。每调用一次pick_lowest_wear()subst++,然后return。下一个tick时,状态机重新进入ST_WRT_ALLOC,subst继续递增——直到它到达need

这里有一个异步的关键:pick_lowest_wear本身是同步的(它只是遍历内存数组),但它之后的状态跳转是异步的。 KnotFS的状态机设计原则是:每个状态只做“一件事“,做完就return,把控制权交还给事件循环。下一个tick进来时,状态机从这个状态的顶部重新进入。如果事情还没做完(比如还有块要分配),就再做一次,再return。

pick_lowest_wear的实现简单但有效:

static uint32_t pick_lowest_wear(void) {
  uint32_t best = KNOTFS_BLOCK_COUNT;
  uint32_t best_w = 0xFFFFFFFFU;
  for (uint32_t i = KNOTFS_DATA_START; i < KNOTFS_BLOCK_COUNT; i++) {
    if (!(sb.free_map & (1U << i)) && sb.wear[i] < best_w) {
      best_w = sb.wear[i];
      best = i;
    }
  }
  return best;
}

它遍历数据区(block 2~15),只考虑free_bitmap中标记为FREE的块(!(sb.free_map & (1U << i))),然后选wear_count最小的那个。每次分配后sb.wear[blk]++。wear_count是32位无符号整数,远在溢出之前,NOR Flash的典型寿命已经到头了(10万次擦写)。


第三幕:ST_WRT_DATA —— Flash编程的异步延迟

case ST_WRT_DATA: {
    nd = find_node(cur.name);
    if (!nd) {
      cur.result = ERR_RD_COPY;
      cur.st = ST_ERR;
      return;
    }
    need = scratch[SCR_NEW_NEED];
    if (subst >= need) {
      cur.st = ST_WRT_META;
      return;
    }

    uint32_t blk = nd->blocks[subst];
    uint32_t off = subst * KNOTFS_BLOCK_SIZE;
    uint32_t rem = cur.w_sz - off;
    uint32_t n = (rem < KNOTFS_BLOCK_SIZE) ? rem : KNOTFS_BLOCK_SIZE;
    const uint8_t *s = (const uint8_t *)cur.w_buf + off;

    memcpy(tmp, s, n);
    if (n < KNOTFS_BLOCK_SIZE)
      memset(tmp + n, 0xFF, KNOTFS_BLOCK_SIZE - n);
    fdev_write(blk, tmp, 0, KNOTFS_BLOCK_SIZE);
    subst++;
    return;
}

这一段做了几件重要的事:

  1. 数据切分:把用户buffer按块边界切成4KB的片段。off = subst * KNOTFS_BLOCK_SIZE算出当前块的起始偏移,rem = cur.w_sz - off算出还剩多少数据。最后一个块可能不足4KB。

  2. 0xFF填充:Flash擦除后是全1(0xFF)。KnotFS保持这个约定——如果最后一个块不足4KB,剩余字节填0xFF。这是NOR Flash上“未编程区域“的标准状态。

  3. fdev_write有2个tick的延迟fdev_write设置fdev.ticks_left = 2。这意味着在下一个knotfs_run()和再下一个knotfs_run()时,fdev_tick()都会continue(因为ticks_left > 0)。直到第三个tick,数据才真正memcpy到flash数组。

  4. subst++后return:ST_WRT_DATA每次写一个块,写完后状态机return,下个tick再进入ST_WRT_DATA——但如果subst >= need(所有块都写完了),就跳到ST_WRT_META。

异步写入的时间线(以3块为例):

tick 0: ST_WRT_DATA(subst=0) → fdev_write(块A) [ticks_left=2]
tick 1: fdev_tick → ticks_left=1 → 设备忙,不推进状态机
tick 2: fdev_tick → ticks_left=0 → memcpy完成,设备闲
tick 3: ST_WRT_DATA(subst=1) → fdev_write(块B) [ticks_left=2]
tick 4: fdev_tick → ticks_left=1
tick 5: fdev_tick → memcpy完成
tick 6: ST_WRT_DATA(subst=2) → fdev_write(块C) [ticks_left=2]
tick 7: fdev_tick → ticks_left=1
tick 8: fdev_tick → memcpy完成
tick 9: ST_WRT_DATA(subst=3) → subst>=need → 跳转 ST_WRT_META

三个块的数据写入,消耗了10个tick。这就是为什么测试程序的 drive() 函数里会有 tick % 15 == 0 的进度打印——因为异步延迟让一切变慢了,但换来了可控的状态。


第四幕:ST_WRT_META —— 写Log、启Compact

ST_WRT_META 要做的事:
  1. log_reset() — 清空Log缓冲区
  2. 遍历旧块列表,log_push(LT_BITMAP, blk, 0) + mark_free — 释放旧块
  3. 遍历新块列表,log_push(LT_BITMAP, blk, 1) — 占用新块
  4. compact_erase() — 擦除非活跃SB槽位
  5. 等待compact完成
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_WRT_NODE;
      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_erase();
    comp_wait = true;
    cur.st = ST_WRT_META_FLUSH;
    return;
}

log_push只是往内存缓冲区log_buf[]里追加一条8字节记录,不涉及Flash操作。compact_erase()启动擦除流程(3个tick延迟)。comp_wait = true告诉事件循环等待compact完成。

这里有一个容易被忽略的细节:mark_free(blk)直接修改了内存中的sb.free_map。这意味着从此刻起,旧块在内存视角里已经是FREE的了。但由于compact还没完成(Flash上的SB还是旧版本),如果现在断电,下次mount时会加载旧SB——旧块仍然是USED的。所以这个mark_free的“提前“操作是安全的——它只在compact成功后才真正生效。


第五幕:ST_WRT_META_FLUSH —— 写入Compact后的新SB

case ST_WRT_META_FLUSH:
    compact_write();
    cur.st = ST_WRT_COMPACT;
    return;

compact_write()做三件事:

  1. 把内存中的SB完整复制到compact_buf
  2. compact_buf.sequence设为sb.sequence + 1
  3. fdev_write(target_slot, &compact_buf, 0, sizeof(knot_sb_t)) —— 启动2个tick的写入延迟

第六幕:ST_WRT_COMPACT —— 宣告新目录生效

case ST_WRT_COMPACT:
    compact_finish();
    log_commit();
    cur.st = ST_OK;
    return;

compact_finish()

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

sb.sequence++宣告:“从现在开始,新SB是权威版本”。log_offset回到初始位置,log_count清零。log_commit()也是一行:sb.log_count += log_cnt; log_cnt = 0;——把累积的log条目数记入SB。


第七幕:ST_OK —— 可以处理下一个请求了

cur.st = ST_OK。事件循环检测到cur.st == ST_OK,从队列中pop下一个请求。如果没有新请求,cur.op = OP_NONE。文件系统恢复空闲。


状态机总览图

knotfs_write("hello.txt", data, 8192)
      │
      ▼
    [入队 ──→ 事件循环开始调度]
      │
      ▼
  ST_WRT_INIT ────── 查找旧entry, 记录旧块, 分配新entry
      │
      ▼
  ST_WRT_ALLOC ───── 分配块0 (pick_lowest_wear, 同步)
      │ return (下一tick继续)
      ├── ST_WRT_ALLOC ── 分配块1
      │ return
      └── subst>=need → 跳转
      │
      ▼
  ST_WRT_DATA ─────── 写块0 (fdev_write, 2 ticks异步)
      │ …等待 ticks_left=0…
      ├── ST_WRT_DATA ── 写块1
      │ …等待 ticks_left=0…
      └── subst>=need → 跳转
      │
      ▼
  ST_WRT_META ─────── 写Log(释放旧块+占用新块), compact_erase(3 ticks)
      │ …等待 ticks_left=0…
      ▼
  ST_WRT_META_FLUSH ─ compact_write(2 ticks)
      │ …等待 ticks_left=0…
      ▼
  ST_WRT_COMPACT ──── compact_finish + log_commit
      │
      ▼
  ST_OK ──────────── 文件写入完成 ✓

状态机的“异步哲学“

KnotFS的每一个状态都是非阻塞的。没有一个状态会“等待“——它要么完成任务后跳转到下一个状态,要么提交一个异步操作后return,把控制权交还给事件循环。当异步操作完成(fdev_idle() == true),事件循环重新进入状态机,状态机从同一个case顶部重新进入。

这种模式有几个优点:

  1. 无栈切换:不需要OS的线程/协程支持,纯C的函数调用+switch/case就能实现。
  2. 可观测:每个tick的行为是确定的——你可以精确知道当前在哪个状态、Flash在做什么操作。
  3. 可测试:测试程序可以通过控制knotfs_run()的调用次数来精确控制时间推进。
  4. 可移植:替换fdev_read/write/erase的底层实现,就可以把KnotFS迁移到任何支持异步I/O的平台。

⚠️ 教学简化提示:这里KnotFS的write状态机有6个状态(INIT→ALLOC→DATA→META→META_FLUSH→COMPACT)。生产级方案的write状态机有9个子状态用于metadata update——因为它除了SB Log还有FT Log(文件表日志),需要额外的状态处理:SB compact → FT compact → log prepare → flush → commit。而且它的alloc和data阶段也需要处理间接块(indirect blocks)的加载和保存,KnotFS没有间接块所以不用管。


如果你来设计

现在试着回答:如果fdev_write的延迟是10个tick而不是2个tick,写操作的状态机会怎么变化?

答案:状态机本身不需要任何改动——它只负责提交写请求(fdev_write设置ticks_left),然后return。延迟的长短只影响“从下一个tick到数据真正写入“之间经过的tick数。事件循环在fdev_tick()中递减ticks_left,每次递减后检查是否为0。这就是异步编程的魅力——延迟参数和业务逻辑解耦


下集预告

写操作走了7个状态、6次异步等待。读操作呢?它只需要3个状态——但它的偏移计算和跨块拼装比写操作要复杂。下一节,我们跟踪knotfs_read("data.bin", buf, 100, 2000, &br),看它是怎么从“块3的第2000字节偏移处“开始,拼接一个跨越块边界的读取结果。按块寻址、tmp暂存、memcpy拼装——读取的“三段式“。