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.3 用RAM骗过文件系统——flash数组与异步延迟模拟

文件系统以为自己在操作Flash

让我们玩一个心理游戏。

你是一段C代码。你以为自己在操作NOR Flash——你在一个叫fdev_write的函数里,把一个4KB的数据块“写入“Flash的第7个块。你做完这件事,要等2个“硬件tick“才能知道写操作有没有成功。

但实际上,你只是在往一片uint8_t数组里做memcpy

// knotfs.c — 整个Flash就是一个数组
static uint8_t flash[KNOTFS_BLOCK_COUNT * KNOTFS_BLOCK_SIZE];

这行代码是整个KnotFS物理层的根基。16 × 4096 = 65536字节的静态数组。所有“Flash操作“——读、写、擦除——最后都落到了这片内存上。

这是KnotFS教学哲学的核心:把物理介质替换为可控的模拟层,让学习者可以在任何一台电脑上观察文件系统的内部行为。 你不需要一棵开发板、一根JTAG线、一块Flash芯片。你需要的是GCC和一个终端。


fdev:Flash设备的软件模拟

fdev(flash device)是一个全局状态结构体,一次只能有一个操作在飞:

// knotfs.c — Flash模拟器状态
static struct {
  fop_t op;
  uint32_t blk_idx;
  uint32_t blk_off;
  void *buf;
  uint32_t size;
  int ticks_left;
  int result;
} fdev;

其中op字段有四种值:

// knotfs.c — 操作类型
typedef enum { FOP_NONE, FOP_RD, FOP_WR, FOP_ER } fop_t;

op == FOP_NONE时,意思是“闪存空闲,可以接受下一个操作“。这是异步架构的关键信号——在操作进行中,文件系统不能提交新操作。

但这里有一个关键简化:KnotFS的fdev一次只能接受一个操作。 没有操作队列,没有DMA链表,没有中断优先级。每次fdev_read/fdev_write/fdev_erase的调用会直接覆盖fdev结构体中的内容:

// knotfs.c — 三个发起函数:直接覆盖fdev
static void fdev_read(uint32_t blk, void *dst, uint32_t sz) {
  fdev.op = FOP_RD;
  fdev.blk_idx = blk;
  fdev.blk_off = 0;
  fdev.buf = dst;
  fdev.size = sz;
  fdev.ticks_left = FDEV_TICK_RD;
}

static void fdev_write(uint32_t blk, const void *src, uint32_t off,
                       uint32_t sz) {
  fdev.op = FOP_WR;
  fdev.blk_idx = blk;
  fdev.blk_off = off;
  fdev.buf = (void *)src;
  fdev.size = sz;
  fdev.ticks_left = FDEV_TICK_WR;
}

static void fdev_erase(uint32_t blk) {
  fdev.op = FOP_ER;
  fdev.blk_idx = blk;
  fdev.ticks_left = FDEV_TICK_ER;
}

注意延迟常数:读=1 tick,写=2 ticks,擦=3 ticks。


为什么是1、2、3?

这不是随意选的。在真实的NOR Flash上:

  • 读取一个块(4KB):约0.5-2毫秒(取决于SPI时钟频率和接口模式)
  • 写入一个页(256字节):约50-100微秒,写满4KB约需10毫秒(16页)
  • 擦除一个块(4KB):约50-150毫秒

擦除比写入慢约5-15倍,写入比读取慢约5-20倍——这是两个数量级的差距。

KnotFS故意保留了这种不对称性:擦除最慢(3 tick),写入中等(2 tick),读取最快(1 tick)。虽然具体比例被大幅缩放了(否则你等擦除完成需要等待数百个tick),但“擦除比写慢,写比读慢“的相对关系被保留了。这让读者在运行时能直观感受到:格式化(需要擦除16个块)确实比挂载(只需要读2个块)慢很多。

观测一下./knotfs_test的输出:

[format]     tick 15...
    tick 30...
    tick 45...
    tick 60...
    tick 69... done
OK
  [mount]     tick 4... done
OK

format 需要擦除 16 个块(16 × 3 = 48 tick)+ 写入 2 个 SB(2 × 2 = 4 tick)+ compact 等状态机调度开销,实测约 69 tick。mount 只需读 2 个 SB 块做校验和选择(2 × 1 = 2 tick)+ CRC 计算和 SB 选择逻辑开销,实测约 4 tick。

这个差距让你直观感受到:擦除是 Flash 上最慢的操作。 格式化之所以“慢“(69 tick),是因为它在擦除所有块;挂载之所以“快“(4 tick),是因为它只需要读。

每个 tick 的绝对时间由 knotfs.c 中的 TICK_NS 常量控制(当前 80ms)。按此计算:擦除一块 ≈ 240ms,写入一块 ≈ 160ms,读取一块 ≈ 80ms。格式化 16 个块耗时约 69 × 80ms ≈ 5.5s——与真实 NOR Flash 车规芯片的量级基本一致。调整 TICK_NS 可让演示跑得更快或更慢。


fdev_tick:异步的心脏

整个异步模拟的核心是fdev_tick()。事件循环每次进入都会调用它:

// knotfs.c — 每次tick减计数器,到零时执行操作
static void fdev_tick(void) {
  if (fdev.op == FOP_NONE)
    return;
  {
    struct timespec ts = {0, TICK_NS};
    nanosleep(&ts, NULL);
  }
  if (--fdev.ticks_left > 0)
    return;
  uint32_t base = fdev.blk_idx * KNOTFS_BLOCK_SIZE + fdev.blk_off;
  fdev.result = 0;
  switch (fdev.op) {
  case FOP_RD:
    memcpy(fdev.buf, &flash[base], fdev.size);
    break;
  case FOP_WR:
    memcpy(&flash[base], fdev.buf, fdev.size);
    break;
  case FOP_ER:
    memset(&flash[base], 0xFF, KNOTFS_BLOCK_SIZE);
    break;
  default:
    break;
  }
  fdev.op = FOP_NONE;
}

这个函数看起来简单得令人发指——它就是一个带倒计时的memcpy/memset。但它完成了两个核心教学目的:

1. 时序解耦。 调用fdev_erase(7)不会立刻让块7变成0xFF。它只是设置fdev.op = FOP_ERfdev.ticks_left = 3。真正的擦除发生在3次knotfs_run()调用之后。这模拟了真实硬件的行为——CPU把擦除命令发给Flash控制器后,Flash控制器自己花时间完成,CPU可以干别的事。

2. 状态可见。 因为操作不是瞬间完成的,状态机必须“等待“。在等待期间,fdev_idle()返回false,调用者(文件系统)不能提交新操作。这迫使上层代码把长流程拆成多个状态——这正是生产级版本中异步状态机的设计动机。

fdev_idle()的实现只要一行:

// knotfs.c
static bool fdev_idle(void) { return fdev.op == FOP_NONE; }

在事件循环中,这个条件被反复检查:

// knotfs.c — 事件循环中等待Flash空闲
if (!fdev_idle())
  return true;

如果Flash正忙,事件循环直接return true,等下一个tick再进来。这就是协作式多任务的精髓——不是用抢占式线程切换“挂起“一个任务,而是让任务自己说:“我还没完,你下次再来问我。”


当前瓶颈:一次只能飞一个操作

KnotFS的fdev有一个明显的局限:同一时间只能有一个操作在进行中。这意味着你不能在等待块7擦除时,同时去读块3的数据。整个文件系统被序列化了。

这是故意的。在生产级方案中,DiskAsync任务可以通过DMA链表一次提交多个操作。但由于教学目的是让读者看清每一步的状态转换——而不是展示DMA控制器的高级用法——KnotFS选择了最简单的单操作模型。

如果你觉得“这样很慢“,你说得对。但别忘了,这个“慢“是相对于什么而言的。在没有KnotFS的情况下,你要么读Linux内核的ext4源码(约5万行),要么读FreeRTOS+FAT的集成代码(把文件系统逻辑和操作系统调用搅在一起),要么直接上生产级方案(一万行AUTOSAR代码)。在这几者之间,KnotFS的“慢“——让你能一行一行单步调试整个write流程——是最快的学习路径。


一个真实的Bug:compact_buf的栈变量陷阱

KnotFS开发过程中有一个值得一提的bug,它完美地展示了异步编程中最常见的陷阱:把局部变量的地址传给异步操作。

最早版本的compact_write大概是这样的:

/* 有Bug的版本(不要这样做) */
static void compact_write(void) {
  knot_sb_t sb_copy;
  memcpy(&sb_copy, &sb, sizeof(knot_sb_t));
  sb_copy.sequence = sb.sequence + 1;
  sb_copy.log_offset = SB_HEADER_SZ;
  sb_copy.log_count = 0;
  sb_copy.crc32 = sb_checksum(&sb_copy);

  fdev_write(inactive_sb_slot(), &sb_copy, 0, sizeof(sb_copy));
}

这个版本的问题在哪里?sb_copy是一个栈局部变量fdev_write把它记录在fdev.buf中:

static void fdev_write(uint32_t blk, const void *src, uint32_t off,
                       uint32_t sz) {
  fdev.op = FOP_WR;
  /* ... */
  fdev.buf = (void *)src;
  /* ... */
}

然后compact_write返回了——sb_copy所在的栈帧被销毁。2个tick之后,fdev_tick()执行memcpy(&flash[base], fdev.buf, fdev.size)——但fdev.buf指向的栈内存可能已经被后续的函数调用覆盖了。

这就像你把一封信交给邮递员,但你刚转身关上门,一阵风就把你门口的信箱吹翻了。邮递员回来取信时,找到的是一堆被风吹乱的白纸。

修复方案:用一个静态变量保存compact数据:

// knotfs.c — 静态分配的compact缓冲区
static knot_sb_t compact_buf; /* persistent buffer for async compact writes */

然后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的生命周期是整个程序的生命周期——它不会被回收,直到程序退出。

这个Bug是异步编程的一个缩影。 在同步编程中,“调用一个函数,函数返回后数据就不需要了“是常态。在异步编程中,“调用发起操作,操作在未来某个时刻完成“意味着你的数据必须活到操作完成的那个时刻。static变量、malloc分配的堆内存、全局缓冲区——这些都是解决“数据生命周期跨越函数调用“的工具。

KnotFS里有好几个这种静态缓冲区:

static uint8_t  tmp[KNOTFS_BLOCK_SIZE];   // knotfs.c — 数据块临时缓冲区
static knot_sb_t compact_buf;             // knotfs.c — compact写入缓冲区
static knot_log_t log_buf[LOG_BUF_CAPACITY]; // knotfs.c — 日志累积缓冲区

每一个都有相同的原因:它们需要在多个tick间保持有效,不能随着某次函数返回而被销毁。


⚠️ 教学简化提示:真实Flash的延迟是毫秒级的(擦除约100ms),且需要DMA+中断驱动——CPU发起操作后,Flash控制器通过DMA总线自主完成数据传输,完成后通过中断通知CPU。KnotFS用简单的计数器ticks_left模拟这一点。此外,真实Flash控制器可以同时保持多条命令在队列中(命令流水线),而KnotFS的fdev严格串行。生产级方案会使用AUTOSAR FLS MCAL驱动,支持异步作业提交、中断回调、以及缓存一致性维护(arch_clean_cache_range/arch_invalidate_cache_range/__DSB屏障)。


下集预告

你用RAM骗过了文件系统。它以为自己在读写Flash,其实只是在memcpy一个数组。但有一个东西不能骗——超级块。超级块是文件系统唯一的“真相来源“。如果你弄丢了它,或者在它写入一半时断电,整个文件系统就废了。

所以KnotFS把超级块存了两份。一份在块0,一份在块1。两份一模一样——但有一份比另一份“新“。怎么判断哪个新?怎么保证至少有一份是好的?

下一节,我们掀开超级块的面纱,看看“馆藏总目录一式两份“的奥秘。

悬念留给:你手头有两本馆藏总目录,一本写着“第3版“,另一本写着“第7版“。你该信哪一本?有没有可能两本都是假的?