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;
}
这一段做了几件重要的事:
-
数据切分:把用户buffer按块边界切成4KB的片段。
off = subst * KNOTFS_BLOCK_SIZE算出当前块的起始偏移,rem = cur.w_sz - off算出还剩多少数据。最后一个块可能不足4KB。 -
0xFF填充:Flash擦除后是全1(0xFF)。KnotFS保持这个约定——如果最后一个块不足4KB,剩余字节填0xFF。这是NOR Flash上“未编程区域“的标准状态。
-
fdev_write有2个tick的延迟:fdev_write设置fdev.ticks_left = 2。这意味着在下一个knotfs_run()和再下一个knotfs_run()时,fdev_tick()都会continue(因为ticks_left > 0)。直到第三个tick,数据才真正memcpy到flash数组。 -
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()做三件事:
- 把内存中的SB完整复制到
compact_buf - 把
compact_buf.sequence设为sb.sequence + 1 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顶部重新进入。
这种模式有几个优点:
- 无栈切换:不需要OS的线程/协程支持,纯C的函数调用+switch/case就能实现。
- 可观测:每个tick的行为是确定的——你可以精确知道当前在哪个状态、Flash在做什么操作。
- 可测试:测试程序可以通过控制
knotfs_run()的调用次数来精确控制时间推进。 - 可移植:替换
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拼装——读取的“三段式“。