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

第五章 从零实现一个文件系统——KnotFS

5.1 KnotFS项目概述——教学级异步日志结构化文件系统

你手里有一块64KB的虚拟Flash,没有操作系统

假设你是一名嵌入式工程师新人,被分配了一个任务:在一个只有64KB NOR Flash的单片机上,实现一个能安全读写的文件系统。

没有FreeRTOS。没有DMA。没有中断。没有硬件。

你的工位上只有一台笔记本电脑,上面装着GCC,还有一本铺满咖啡渍的C语言编程指南。

你开始想:文件系统得有什么?超级块、inode、目录、读写接口、原子写入、掉电保护、磨损均衡……每一个词背后都藏着几千行C代码和几十年工程实践积累的坑。

你现在是什么感觉?困惑?不知所措?还是兴奋?

别担心。这就是KnotFS存在的意义。

KnotFS是一个教学级文件系统。它的目标不是跑在真实的Cortex-R5上,而是跑在你的大脑里。它用约1760行纯C代码(核心约1150行),不依赖任何外部库(除了libc),在一块用RAM数组模拟的64KB NOR Flash上,完整地实现了一个异步、日志结构化、掉电安全的文件系统。

你可以在一分钟内编译它,在十秒内跑完它的12个自测用例,然后——慢慢读它的源码。每一行,都值得你停下来想一想。


KnotFS是什么

KnotFS的全称是“Knot Filesystem“——“绳结“文件系统。这个名字不是随便起的。

还记得我们第一章讲过的结绳记事吗?绳结是最原始的信息编码系统。KnotFS是这个编码系统在现代C语言中的化身:它把信息(你的文件)用数据块(绳结)编入一个线性地址空间(绳子),用元数据(绳结的位置和大小规则)组织检索,用校验和(印加人的校验绳结)保护完整性。

KnotFS是本书的“教学伴侣“。 你读到的每一个概念——超级块双副本、Log追加、Copy-on-Write、磨损均衡——在KnotFS里都有直截了当的实现。你不需要交叉编译工具链,不需要JTAG调试器,甚至不需要Linux内核源码。

你需要的东西只有三样:

KnotFS源码结构
==================

knotfs.h          (132行)  —— 公共接口、常量定义、类型声明
knotfs.c          (1150行) —— 核心实现:状态机×8、Flash模拟器、CRC32
knotfs_test.c     (474行)  —— 12个自测用例,包括掉电恢复
Makefile          (71行)   —— 单文件编译,支持x86/ARM64/ARM32三架构

总共约1670行代码。如果你有C语言基础,一个下午就能通读一遍。但理解它为什么这样写——为什么状态机要这么设计,为什么超级块要存两份,为什么compact要在50%满时触发——这需要你读完本章剩下的几节。


构建和运行:一分钟上手

在终端里敲三行命令:

$ cd knotfs
$ make
$ make test

你会看到这样的输出:

  _  __            _   _____ ____
 | |/ /_ __   ___ | |_|  ___/ ___|
 | ' /| '_ \ / _ \| __| |_  \___ \
 | . \| | | | (_) | |_|  _|  ___) |
 |_|\_\_| |_|\___/ \__|_|   |____/

  Teaching File System — Async + Log-Structured + Power-Loss Safe
  Simulated NOR Flash: 64 KB

============================================================
  Test 1: Format & Mount  (64 KB NOR Flash)
============================================================
  Flash layout: 16 blocks x 4096 B = 65536 bytes
  Data region: blocks 2–15  (56 KB)
  [format]     tick 15...    tick 30...    tick 45...
    tick 48... done
  [mount] OK
  [stats] OK

注意那个tick 15... tick 30...。这就是异步文件系统的“心跳“——每15个tick打印一次进度,让你“看到“Flash操作在慢慢推进。如果没有这个tick模拟,现代CPU会在0.1毫秒内跑完全部操作,你什么也看不到。

KnotFS故意的“慢“,是为了让你看清异步的本质。

Makefile是怎么做到零依赖的?看这几行:

// knotfs.h — 唯一的系统头文件
#include <stdint.h>
#include <stdbool.h>

就这两个。<string.h>knotfs.c中用于memcpymemset。没有<stdio.h>,没有<stdlib.h>,没有<pthread.h>。KnotFS是一个纯计算单元——它只管理数据和元数据,把所有I/O都抽象成对flash[]数组的读写。

Makefile支持三种架构:

# Makefile:14-25 — 多架构支持
ARCH    ?= native

ifeq ($(ARCH),arm64)
    CC    = aarch64-linux-gnu-gcc
    CFLAGS += -static
else ifeq ($(ARCH),arm32)
    CC    = arm-linux-gnueabihf-gcc
    CFLAGS += -static
else ifeq ($(ARCH),arm32-lite)
    CC    = arm-linux-gnueabi-gcc
    CFLAGS += -static
endif

这意味着同一个源码,可以在你的x86笔记本上跑、在树莓派上跑、在ARM Cortex-A上跑。如果安装了交叉编译器,甚至可以在一个Makefile里编译出三个平台的二进制(make all-arch)。

KnotFS的“可移植性“不是“我们适配了多个平台“,而是“我们根本没有依赖任何平台“。


异步架构:事件循环 + 状态机

KnotFS的核心架构只有两个概念:事件循环状态机

事件循环是knotfs_run()

// knotfs.c — 事件循环的核心
bool knotfs_run(void) {
  fdev_tick();

  /* handle compact completion */
  if (comp_wait && fdev_idle()) {
    comp_wait = false;
  }

  if (!fdev_idle())
    return true;

  /* if current request done, dequeue next */
  if (cur.op != OP_NONE && (cur.st == ST_OK || cur.st == ST_ERR)) {
    if (!q_empty())
      q_pop(&cur);
    else
      cur.op = OP_NONE;
  }

  /* dequeue if idle */
  if (cur.op == OP_NONE && !q_empty()) {
    q_pop(&cur);
    prog = 0;
    subst = 0;
    memset(scratch, 0, sizeof(scratch));
  }

  /* advance state machine */
  if (cur.op != OP_NONE && cur.st != ST_OK && cur.st != ST_ERR) {
    switch (cur.op) {
    case OP_FMT: do_format(); break;
    case OP_MNT: do_mount();  break;
    case OP_WRT: do_write();  break;
    case OP_RD:  do_read();   break;
    case OP_APP: do_append(); break;
    case OP_DEL: do_delete(); break;
    case OP_LS:  do_list();   break;
    case OP_ST:  do_stats();  break;
    default:     break;
    }
  }

  return !knotfs_is_idle();
}

这个函数就像老式游戏机的主循环——每一“帧“(tick),检查输入、更新状态、渲染输出。区别是游戏机渲染的是像素,KnotFS渲染的是文件系统操作。

状态机呢?每个文件系统操作(format、mount、write、read、append、delete、list、stats)都是一个switch(cur.st)的巨型分支:

// knotfs.c — 操作类型和状态常量
typedef enum {
  OP_NONE = 0,
  OP_FMT,
  OP_MNT,
  OP_WRT,
  OP_RD,
  OP_APP,
  OP_DEL,
  OP_LS,
  OP_ST,
} knot_op_t;

typedef enum {
  /* format */
  ST_FMT_ERASE = 10,
  ST_FMT_ERASE_WAIT = 11,
  ST_FMT_WR_SB1 = 12,
  ST_FMT_DONE = 13,
  /* mount */
  ST_MNT_RD_SB0 = 20,
  ST_MNT_RD_SB1 = 21,
  ST_MNT_SEL = 22,
  ST_MNT_REPLAY = 23,
  /* write */
  ST_WRT_INIT = 30,
  ST_WRT_ALLOC = 31,
  ST_WRT_DATA = 32,
  ST_WRT_META = 33,
  ST_WRT_META_FLUSH = 34,
  ST_WRT_COMPACT = 35,
  /* ... */
  ST_OK = 90,
  ST_ERR = 91,
} knot_st_t;

每一个状态只做一件事,然后返回事件循环,等待下一次被调用。这看起来“慢“,但这是嵌入式系统中唯一可行的方式——你不能在Flash擦除的100毫秒里阻塞CPU,因为你还有一个CAN总线要响应、一个电机要控制、一个串口要收发。

KnotFS让你在笔记本电脑上用while(knotfs_run())模拟这个场景。每一次函数返回,就相当于一次任务切换。你看着tick数字增长,渐渐理解:哦,原来异步不是多线程,是把一个长操作切成了很多个小步骤。


保留了哪些生产级特性?

KnotFS虽然简单,但它从生产级方案中保留了五个核心设计:

1. 双超级块 + 日志结构化元数据

超级块(Superblock)是文件系统的“馆藏总目录“,记录了Flash上所有块的分配情况、文件目录、磨损计数。KnotFS存了两份:SB0在块0,SB1在块1。元数据的变更不是原地更新,而是以8字节的Log记录追加到超级块的末尾。这保证了“在最坏的情况下——写入过程中断电——你总能找到一份完好的超级块“。

2. Copy-on-Write原子写入

当你覆盖一个文件时,KnotFS不会直接修改原数据块。它分配新的块、写入新数据、然后才更新元数据指向新块。原子性由此实现:要么新数据写入元数据更新(成功),要么旧数据没被动过(失败),永远不会出现“一半新一半旧“的中间态。

3. 磨损均衡(Wear Leveling)

NOR Flash的每个块有擦写寿命(典型值约10万次)。如果反复在同一个块上写文件,那块会提前报废。KnotFS的find_free_block(在KnotFS中叫pick_lowest_wear)总是选择磨损次数最少的空闲块,让写操作均匀分布。

4. CRC32元数据校验

Flash上的数据可能因为掉电、老化、辐射而发生比特翻转。KnotFS用CRC32保护每一个超级块和每一条Log记录。挂载时,CRC校验不通过的超级块会被丢弃——就像奇普鉴定员跳过磨损的绳结。

5. 请求队列 + 异步完成

上层应用程序可以连续提交格式、写、读、删除等操作,而不用等待前一个完成。KnotFS内部维护一个容量为4的请求队列,由事件循环逐个消费。


相比生产级方案,KnotFS 简化了什么?

生产级方案预估在数千到一万行,跑在真正的 Cortex-R5 上。KnotFS 简化了以下五个方面:

特性KnotFS(教学版)生产级方案(设计目标)
运行环境事件循环,单线程FreeRTOS,多任务
Flash介质RAM数组 flash[16*4096]真实NOR Flash(AUTOSAR FLS驱动)
文件大小上限32KB(8个直接块)由间接块决定(更大)
文件表(FT)合并到SB中(nodes[8])独立的FT块,有FT Log
网络终端TCP端口8004/8005

以下是每个简化点的详细解释。

1. 无FreeRTOS → 事件循环

生产级方案运行在FreeRTOS上,disk操作由独立的DiskAsync任务执行,文件系统操作由主任务执行,两者通过队列通信,优先级不同(DiskAsync优先级3,主任务优先级1)。KnotFS将所有操作序列化在一个事件循环中,knotfs_run()一次只能做一件事。

2. 无真实Flash → RAM数组 + tick延迟

生产级方案会调用AUTOSAR FLS驱动去擦写真实的NOR Flash——擦除需要约100ms,写入需要约1ms,读取需要约1μs。KnotFS用一个static uint8_t flash[16 * 4096]数组替代Flash,用计数器模拟延迟(读1 tick、写2 tick、擦3 tick)。第5.3节会详细展开。

3. 无间接块 → 32KB文件上限

生产级方案会支持间接块(indirect block),每个文件可以有远超8个数据块。KnotFS去掉了间接块机制,每个文件最多8个直接块(KNOTFS_DIRECT_BLKS = 8),即32KB(8 × 4096)。这个限制对于教学来说足够了——大部分测试文件只有几十到几百字节。

4. 无独立文件表 → FT合并到SB

这是KnotFS最显著的简化。生产级方案会有独立的File Table(FT),存放在固定的块2和块3,每个FT有自己的Log追加日志。文件条目的修改只需在FT Log中追加一条记录,不需要重写整个FT块。

KnotFS把文件条目(nodes[8])直接放在超级块里。这意味着每次文件创建、删除、大小变更,都必须通过compact重写整个超级块,而不能只追加一条Log。这导致SB槽位的擦写次数比生产级设计高约200倍——我们会在5.7节详细讨论。

5. 无网络终端 → 纯API

生产级方案会通过终端服务提供了TCP终端——端口8004用于交互式命令(mount、read、write、delete、list、stats、format),端口8005用于文件传输。KnotFS只提供C API,通过knotfs_test.c自测程序驱动。


为什么要读KnotFS源码?

因为KnotFS做了一件很罕见的事:它把嵌入式文件系统的核心思想——异步状态机、日志结构化、Copy-on-Write、双副本冗余——从生产级设计理念中提炼出来,压缩到一千多行,同时保留了全部关键设计。

你读的不只是C代码。你读的是一个工程师在说:“你看,文件系统其实没那么神秘。你看这个超级块的结构——它就是一个C结构体。你看这个状态机——它就是一个switch语句。你看这个compact——它就是一次memcpy加一次erase。”

当你理解完这约1670行代码后,再看生产级方案的设计,你会惊讶地发现:它们的内核是一样的。 生产级方案多出来的代码,大部分是集成胶水(FreeRTOS任务管理、AUTOSAR驱动调用、缓存一致性维护),而不是文件系统逻辑本身。

而这,就是KnotFS作为“教学伴侣“的终极价值。


⚠️ 教学简化提示:KnotFS的目标是让读者在没有硬件的情况下理解异步文件系统的设计。它不适合直接用于生产环境。KnotFS缺少以下生产级特性:DMA驱动的Flash I/O中断处理、间接块支持大文件、独立的FT Log减少SB磨损、FreeRTOS任务优先级调度、缓存一致性维护(arch_clean_cache_range/arch_invalidate_cache_range)、以及网络终端服务。


下集预告

你看到了knotfs.h中的常量:KNOTFS_BLOCK_SIZE = 4096KNOTFS_BLOCK_COUNT = 16KNOTFS_SB0_BLK = 0……这些数字不是随便选的。它们定义了一个16×4KB的“棋盘“——块0和块1是馆藏总目录,块2到块15是书架。下一节,我们摊开这张棋盘,看看每一格放了什么,以及为什么KnotFS选择把超级块存两份。

悬念留给:16个块中只有14个能被你用。那两个被“偷走“的块,是你的最后一道保险。