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_ER和fdev.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版“。你该信哪一本?有没有可能两本都是假的?