Appearance
文件系统
这一章我要完整讲完我这个文件系统的内部实现。前面第七章我已经把 IDE 驱动和分区扫描写清楚了,这里就不再重复硬件层的东西,直接从文件系统内部结构开始。
我这个文件系统的实现参考了经典 Unix 文件系统的设计思路:超级块、位图、inode、目录项这套东西。虽然功能比较简陋,但核心链路是完整的——从格式化分区到用户在 shell 里敲 ls、cat,整条路都能跑通。
文件系统概述
在写这个文件系统之前,我其实花了不少时间去理解"文件系统到底在做什么"。表面上看,文件系统就是让你能用open、read、write这些接口来操作数据。但往深了想,它其实是在解决一个核心问题:如何把一堆线性排列的磁盘扇区,组织成一个有层次结构的命名空间。
从磁盘到文件:需要解决什么问题
磁盘对我来说就是一个大数组——几十万个512字节的扇区,按LBA编号线性排列。IDE驱动提供了ide_read和ide_write这两个接口,能让我按扇区地址读写任意数据。
但这远远不够。用户想要的是"文件"和"目录"的概念,他想用路径名 /home/test.txt 来定位数据,而不是记住"在第12345号扇区"。所以文件系统要做的第一件事,就是在磁盘上建立一套元数据结构,把"扇区地址"和"文件名"关联起来。
我选择的方案是经典的Unix风格:用inode来描述每个文件的属性和数据块位置,用目录项来维护"名字→inode编号"的映射。这套方案虽然古老,但足够简单,而且经受住了几十年的考验。
我这个文件系统的设计目标
我给自己定的目标很明确:
第一,能格式化一个分区,写入超级块、位图、根目录这些基础结构。第二,能创建文件和目录,能读写文件内容。第三,能在shell里用ls、cd、cat这些命令操作文件。第四,支持基本的POSIX风格系统调用接口。
我没有追求高性能或者高可靠性(没做日志、没做缓存、没做并发控制),因为那些东西会让实现复杂度翻倍。对于一个个人开发的小OS来说,把核心链路跑通比什么都重要。
整体架构
我的文件系统分成几个层次,从下往上看:
最底层是IDE驱动,它提供扇区级别的读写能力,这部分在第七章已经讲过了。往上是分区管理和超级块,用来描述整个分区的布局:inode位图在哪、数据区从哪开始、根目录的inode是几号。再往上是inode层,负责管理文件的元数据和数据块索引。然后是目录层,实现目录项的增删改查和路径解析。最后是系统调用层,把底层功能封装成open、read、write这些用户能调用的接口。
这几层的代码分别对应kernel/module/filesystem/下面的不同文件:
super_block.cpp— 超级块结构定义fs.cpp— 格式化和挂载inode.cpp— inode的打开、关闭、同步dir.cpp— 目录操作file.cpp— 文件创建和读写path.cpp— 路径解析sysfunc.cpp— 系统调用接口

磁盘布局概览
在我格式化一个分区之后,磁盘上的布局是这样的:第0号扇区预留给引导块(虽然我没用它),第1号扇区存超级块,接下来是块位图、inode位图、inode表,最后是大片的数据区。
各区域的起始位置可以这样计算:
超级块:
start_lba + 1块位图:
start_lba + 2inode位图:块位图起始 + 块位图扇区数
inode表:inode位图起始 + inode位图扇区数
数据区:inode表起始 + inode表扇区数
所有这些信息都记录在超级块里。挂载分区时,我把超级块读进内存,后续所有操作都通过超级块里的字段来定位各个区域的位置。

关键数据结构预览
在深入各个模块之前,我先把几个核心数据结构的关系理一下。
super_block:描述整个分区的元信息,包括魔数、扇区总数、各区域的起始LBA地址等。整个结构刚好512字节,占满一个扇区。
inode:描述单个文件或目录。包含文件大小、13个块指针(12个直接块 + 1个一级间接块指针)、打开计数等。每个inode有一个编号,通过编号可以算出它在inode表里的位置。
dir_entry:目录项,存储"文件名→inode编号"的映射。目录的数据块里存的就是一堆目录项。
file_manager:内核里表示一个打开的文件。包含当前读写位置、标志位、指向对应inode的指针。
这几个结构之间的关系是:用户通过路径名找到目录项,目录项里有inode编号,通过编号找到inode,inode里有数据块地址,最终就能读写文件内容。
接下来几节,我会按照这个顺序一个一个讲清楚。
超级块结构
超级块是文件系统的"元数据之父",它存在每个分区的第1号扇区(第0号预留给引导块),整个结构正好512字节,刚好占满一个扇区。
超级块都存了什么
我在kernel/module/filesystem/super_block.cpp里定义了超级块的结构:
c++
export struct super_block
{
u32 magic; // 魔数标识文件系统类型
u32 sec_cnt; // 本分区总共的扇区数
u32 inode_cnt; // 本分区inode数量
u32 lba_base; // 本分区的起始lba地址
u32 block_bitmap_lba; // 块位图的起始扇区地址
u32 block_bitmap_sects; // 块位图占用的扇区数
u32 inode_bitmap_lba; // inode位图的起始扇区lba地址
u32 inode_bitmap_sects; // inode位图占的扇区数
u32 inode_table_lba; // inode表的起始lba地址
u32 inode_table_sects; // inode表占用的扇区数
u32 data_start_lba; // 数据区开始的第一个扇区号
u32 root_inode_no; // 根目录所在的inode号
u32 dir_entry_size; // 目录项大小
std::array<u8,460> pad; // 凑够512字节一个扇区
};这里面最关键的是那些_lba后缀的字段。它们记录了文件系统各个区域在磁盘上的绝对位置。有了这些,我就能直接算出"第N个inode在磁盘的哪个位置"、"块位图的第M位对应哪个数据块"这类问题。
magic字段是个魔数,我定的是0x19590318。磁盘上已有文件系统时,格式化前先检查这个魔数,如果匹配就跳过格式化。这是个简单粗暴但有效的做法。

格式化时如何计算各区域大小
格式化逻辑在fs.cpp的format_partition函数里。这个函数做的第一件事就是算出各个区域该占多少扇区。
我在代码里预设了几个常量:
c++
auto constexpr MAX_FILES_PER_PART = 4096; // 每个分区最大文件数
auto constexpr BITS_PER_SECTOR = 4096; // 每扇区能管理的位数
auto constexpr BLOCK_SIZE = 512; // 块大小等于扇区大小然后根据这些常量来算位图和inode表的大小:
c++
auto constexpr inode_bitmap_sects = std::div_ceil(MAX_FILES_PER_PART, BITS_PER_SECTOR);
auto constexpr inode_table_sects = std::div_ceil(sizeof(inode) * MAX_FILES_PER_PART, BLOCK_SIZE);块位图的计算稍微复杂一点,因为它要管理的块数量依赖于剩余空间,而块位图自己也要占用空间。我用了两轮计算来处理这个循环依赖:先按全部剩余空间算一遍,然后扣掉位图自己占的扇区再算一遍。
超级块的初始化
算好各区域大小后,format_partition就构造一个超级块对象:
c++
auto sb = (super_block) {
.magic = 0x19590318,
.sec_cnt = part->sec_cnt,
.inode_cnt = MAX_FILES_PER_PART,
.lba_base = part->start_lba,
.block_bitmap_lba = part->start_lba + 2,
.block_bitmap_sects = block_bitmap_sects,
.inode_bitmap_lba = part->start_lba + 2 + block_bitmap_sects,
.inode_bitmap_sects = inode_bitmap_sects,
.inode_table_lba = part->start_lba + 2 + block_bitmap_sects + inode_bitmap_sects,
.inode_table_sects = inode_table_sects,
.data_start_lba = part->start_lba + 2 + block_bitmap_sects + inode_bitmap_sects + inode_table_sects,
.root_inode_no = 0,
.dir_entry_size = sizeof(dir_entry),
};注意这里的计算链:块位图在start_lba + 2(跳过引导块和超级块),inode位图紧跟在块位图后面,inode表紧跟在inode位图后面,数据区紧跟在inode表后面。每个区域的起始地址都是前一个区域的起始地址加上它占用的扇区数。
挂载时读取超级块
分区格式化后,下次启动系统需要挂载它。挂载的核心就是把超级块从磁盘读进内存:
c++
auto sb = new super_block;
ide_read(hd, part->start_lba + 1, sb, 1);
cur_part->sb = sb;读进来之后,我还要把块位图和inode位图也读进内存,这样后续分配块或inode时直接操作内存位图就行了:
c++
cur_part->block.bits = new u8[sb->block_bitmap_sects * BLOCK_SIZE];
ide_read(hd, sb->block_bitmap_lba, cur_part->block.bits, sb->block_bitmap_sects);
cur_part->inode.bits = new u8[sb->inode_bitmap_sects * BLOCK_SIZE];
ide_read(hd, sb->inode_bitmap_lba, cur_part->inode.bits, sb->inode_bitmap_sects);这两个位图的作用是跟踪哪些块和哪些inode已经被使用了。分配时找一个空闲位设成1,释放时把对应位清成0。每次修改完内存位图后,还要用bitmap_sync把改动同步回磁盘。
我这里把整个位图都读进内存,对于几十MB的小分区来说没问题。但如果分区很大(比如几百GB),位图本身就会很大,这时候就需要按需加载部分位图。我目前没做这个优化。
inode与位图管理
文件系统的核心是"如何找到文件的数据"。在Unix设计哲学里,这个任务由inode(索引节点)承担。inode是文件系统里最忙碌的数据结构,它不仅要记录文件属性,还要维护数据块的索引表。
inode结构设计
我在kernel/module/filesystem/inode_structure.cpp里定义了inode。因为我希望简化实现,所以只用了两级索引(直接块+一级间接块),最大支持的文件大小约为70KB($12 \times 512 + 128 \times 512$),这对一个玩具OS来说足够了。
c++
export struct inode
{
u32 no; // inode编号
u32 size; // 文件大小
u32 open_cnts; // 打开次数(仅内存有效)
bool wdeny; // 写互斥标志(仅内存有效)
// [0-11]直接块指针,[12]一级间接块指针
std::array<u32, 13> sectors;
list_node tag; // 用于加入已打开inode链表
};注意有两个成员open_cnts和wdeny主要是在内存里用的。但在写入磁盘时,我会把它们清零,确保磁盘上的inode数据是干净的。
数据块索引机制
数据块的寻址方式是我觉得这章最有意思的地方。
sectors[0-11]:直接存放数据块的LBA地址。如果文件很小($
\le 6$KB),只用这部分就够了。sectors[12]:存放"一级间接块"的LBA地址。这个间接块里存的不是文件数据,而是128个($
512/4$)指向实际数据块的LBA地址。
这种设计让我能在很小的inode结构里(几十字节)支持从几字节到几万字节的文件,且无需复杂的树平衡算法。

inode的定位与读写
系统怎么知道第N号inode在磁盘的哪个位置?
这就是超级块里inode_table_lba的作用了。inode表在磁盘上是连续存储的数组。计算公式在inode_locate函数里:
c++
auto off_size = inode_no * sizeof(inode);
auto off_sec = off_size / 512;
auto off_size_in_sec = off_size % 512;
auto sec_lba = part->sb->inode_table_lba + off_sec;读写inode时有个坑点:一个inode可能会跨扇区。虽然我的inode很小不会跨扇区,但通用逻辑必须处理这种情况。我的代码里用two_sec标志来判断读取时是否需要跨越扇区边界。
位图管理:资源的分配与回收
文件系统有两种资源需要分配:inode号和数据块。我用两个位图(bitmap)来管理它们:inode_bitmap和block_bitmap。
位图的操作非常直观:
分配:在位图里找到第一个为0的位(
scan(1)),置为1,返回索引。如果是分配数据块,索引还要加上data_start_lba转换成绝对LBA。同步:改完内存位图后,必须马上把对应的位图扇区写回磁盘(
bitmap_sync),否则断电后文件系统就坏了。释放:把对应位清0,同步回磁盘。
这就解释了为什么创建文件时磁盘会咯噔响好几下:写inode、写inode位图、写目录项、写目录块...文件系统的一致性全靠这些同步操作来维持。
目录操作
在文件系统中,"目录"其实就是一种特殊的文件。普通文件的内容是用户数据,而目录文件的内容是一张映射表:文件名 $\rightarrow$ inode编号。
目录项结构
这张映射表里的每一项就叫目录项(Directory Entry)。我在dir_structure.cpp里定义了它:
c++
export struct dir_entry
{
std::array<char, MAX_FILES_NAME_LEN> filename; // 文件名
u32 inode_no; // inode编号
file_type type; // 文件类型(普通/目录)
};这个结构非常紧凑。我把文件名限制在16字节以内(MAX_FILES_NAME_LEN=16),加上inode编号和类型,一个目录项只占很小的空间。一个512字节的扇区能存下几十个这样的目录项。
文件名长度限制是我的一个简化设计。如果要支持变长文件名,目录项管理会变得非常复杂(这就变成了堆内存管理问题)。定长数组虽然浪费空间,但能让随机访问和删除变得异常简单。
根目录的特殊性
文件系统必须要有一个入口,这就是根目录。在格式化时,我会把根目录固定分配在 inode 0 号位置。
c++
auto root_inode_no = 0;
// 格式化时直接写入 . 和 .. 两个目录项
p_de->filename = "."; p_de->inode_no = 0;
p_de->filename = ".."; p_de->inode_no = 0;这就是为什么空目录其实也不为空——它至少包含.(指向自己)和..(指向父目录)。对于根目录来说,它的父目录就是它自己。
目录遍历与查找
当我要找target.txt这个文件时,我其实是在遍历目录文件的数据块。逻辑在search_dir_entry里:
读出目录inode指向的第1个数据块。
把这512字节当成一个
dir_entry数组。挨个比对
filename。如果没找到,继续读第2个数据块...
目录项的增删
创建文件时,本质上就是往目录里插入一条记录:
- sync_dir_entry:先遍历查找有没有空的“坑位”(之前被删除留下的空洞,或者新分配的空间)。找到坑位后,把名字和inode号写进去。
删除文件时,不是把数据抹掉,而是把对应的目录项清空(或者标记为无效):
- delete_dir_entry:找到对应名字的条目,把它的内存memset为0,然后写回磁盘。这样下次遍历时它就被当成空闲坑位了。
这种“只标记不擦除”的策略也是文件系统恢复数据的原理所在——只要目录项没被新数据覆盖,原来的inode号还在,数据就还在。

文件操作
有了目录和inode,我们只能找到文件,还不能读写它。要真正操作文件内容,还需要内核层面的支持:文件结构体(file)和文件描述符(file descriptor)。
文件表与文件结构
内核里有一张全局的文件打开表(system-wide open file table),数组名叫file_table。数组里的每个元素是一个file_manager结构:
c++
export struct file_manager
{
u32 pos; // 当前读写偏移量
u32 flag; // 打开标志 (RW/Create等)
inode* node; // 指向对应的inode
};这个pos就是文件读写的核心状态——你读了100字节,pos就加100,下次read从pos继续。多个进程如果dup了同一个fd,或者fork了子进程,它们会共享同一个file_manager,也就共享了同一个读写偏移量。
文件描述符(fd)的本质
用户程序拿到的fd(比如3),其实只是他在自己的"PCB文件描述符数组"里的下标。
PCB.fd_table[3] $
\rightarrow$ 全局文件表的某个下标(比如5)file_table[5] $
\rightarrow$ 具体的file_manager对象file_manager.node$\rightarrow$ 具体的inode
这种两级映射设计非常巧妙,它隔离了进程空间和内核空间,同时也方便了管道和重定向的实现。

创建文件流程
file_create函数的逻辑大概是这样的:
申请inode号(查位图)。
申请空闲的inode内存对象。
在父目录里添加目录项(名字指向这个inode号)。
申请文件描述符fd和文件表项。
将它们关联起来,返回fd给用户。

这里有个细节:创建文件时我并不会马上分配数据块。也就是文件的size是0,没有占用磁盘空间。只有当你真正写入数据时,才会触发块分配。
读写文件的实现
file_write是文件系统最复杂的函数之一。它需要解决的核心问题是:如何把逻辑上的连续写入映射到物理上不连续的数据块。
比如你要从文件偏移量1000处写500字节:
1000处对应第2个扇区(512-1023是第1个,1024-1535是第2个)。
写入长度跨越了第2个和第3个扇区。
甚至可能跨越直接块和间接块的边界。
我的做法是: 1. 先算出这500字节涉及哪些具体的数据块(all_blocks)。如果涉及的块不存在,就申请分配。 2. 对于首尾两个扇区,可能只写一部分,所以需要先读出来,修改中间部分,再写回去(Read-Modify-Write)。 3. 对于中间完整的扇区,直接整块覆盖写入。 4. 最后更新file->pos和inode->size。
这个过程非常容易出错,稍微算错一个边界偏移,数据就全乱了。

路径解析
文件系统最常用的接口是open("/home/test/a.txt")。用户给路径,内核给fd。把这个路径字符串转换成最终inode号的过程,就是路径解析。
我的路径解析逻辑全部封装在path.cpp里,核心函数是path::search。
解析流程
解析过程其实就是一个不断"剥洋葱"的过程:
起点判断:如果路径以
/开头,就从根目录(inode 0)开始找;否则从当前工作目录(CWD)开始找。分级查找:
提取第一级目录名(如
home)。在当前父目录里查找
home对应的目录项。找到后,获取inode号,打开该目录,把它作为新的父目录。
提取下一级目录名(如
test),重复上述过程。
终止条件:直到解析到最后一级文件名,或者中间某一级目录找不到为止。
特殊路径处理
为了让路径解析好用,必须处理几个特殊情况:
多余的斜杠:
//home///test//应该等价于/home/test。我的path::parse函数会自动跳过连续的/。. 和 ..:因为每个目录下都已经有了这两个目录项,所以我的解析逻辑不需要特殊处理它们,天然支持
cd ..这种操作。路径深度限制:为了防止死循环和栈溢出,我做了一个深度检测(
path::depth),虽然目前的实现里直接用简单的循环解析,并没有递归调用,所以没有栈溢出风险,但限制深度是个好习惯。

路径搜索记录
在解析过程中,我用一个search_record结构来记录中间状态。如果在解析到中途失败了(比如想创建文件),我需要知道“是在哪一级停下的”以及“最后停留的父目录是谁”。这对于mkdir和create非常重要——它们需要在父目录里插新条目。
系统调用接口
这一节我简单介绍一下文件系统暴露给用户态的系统调用接口。具体的系统调用机制(如 int 0x80 中断处理、参数传递)会在第九章详细讲,这里只关注文件系统相关的部分。
我的所有系统调用实现都放在kernel/module/filesystem/sysfunc.cpp里。
主要接口列表
目前支持的POSIX风格接口包括:
open/close:打开和关闭文件。
read/write:读写文件内容。
lseek:调整读写位置。
unlink:删除文件。
mkdir/rmdir:创建/删除目录。
opendir/closedir/readdir:目录遍历操作。
stat:获取文件属性。
getcwd/chdir:获取/切换当前工作目录。
接口实现模式
几乎所有的文件系统调用都遵循同一个模式:
转换路径:如果参数是相对路径,先把它转换成绝对路径(利用PCB里存的CWD)。
操作核心层:调用
file_create、file_open、dir_open等内核函数,获取内核对象(inode或file)。资源映射:把内核对象映射成用户能理解的句柄(fd或DIR*)。
错误处理:如果中间任何一步出错(如文件不存在),返回-1。

以sys_open为例:它并不直接返回file_manager指针,那是内核地址,用户态不能碰。它返回的是PCB文件表的一个整数下标(fd),用户态拿着这个fd再来找内核办事。
用户态看到的DIR*指针其实指向的是用户堆里分配的一块内存,用来缓存目录读取状态。这和标准C库里的设计是一致的。
Shell集成
有了系统调用,最后一步就是让Shell能用上这些功能。Shell对于文件系统来说,既是一个普通的"用户程序",也是一个测试和交互的控制台。
内建命令实现
Shell里的大部分文件操作命令(ls, cd, mkdir)都是以内建命令(Builtin)形式实现的。代码在kernel/module/shell/builtin.cpp。
ls命令
ls命令本质上就是opendir + readdir循环:
c++
auto dir = opendir(path);
while (auto de = readdir(dir)) {
// 根据de->type判断是文件还是目录,打印不同颜色
// stat(name) 获取文件大小
printf("%s %d %s\n", type_str, size, name);
}
closedir(dir);cd命令
cd命令稍微特殊一点,因为它改变的是Shell进程自己的状态。它调用chdir系统调用,但这还不够,Shell自己维护了一个cwd_cache字符串,用来在提示符里显示当前路径。所以cd成功后必须更新这个缓存。
路径自动补全与清洗
用户输入的路径往往是不规范的,比如cd .././home。Shell有一套路径清洗逻辑(wash_path),负责把这些相对路径转换成干净的绝对路径,然后再传给系统调用。
虽然目前我的Shell还不支持Tab键补全,但底层的路径解析逻辑已经为未来支持补全做好了准备——只要遍历目录项匹配前缀即可。
上机演示
为了验证文件系统的功能是否正常,我在kernel/main.cpp里编写了一组综合测试用例。这组测试模拟了用户常见的操作场景,涵盖了文件创建、读写、目录操作和删除功能。
文件读写测试
测试逻辑如下:
用
O_CREATE标志创建并打开文件/file1。写入一段字符串"hello, world!"。
关闭文件,重新打开。
读取内容并打印到屏幕,确认与写入一致。
删除文件
unlink("/file1")。
这一连串操作如果能顺利跑通且不报错,说明inode分配、数据块分配、目录项增删、文件描述符管理这一整套链路都是通的。
目录操作测试
目录测试稍微复杂一点:
创建目录
/dir1。在
/dir1下创建文件.test。切换目录
chdir("/dir1"),创建文件file2。使用
ls命令(或readdir循环)列出/dir1下的内容,应该能看到.、..、.test和file2。
测试代码实现
以下是kernel/main.cpp中的关键测试代码:
c++
// 综合文件系统测试
auto test_filesystem() -> void
{
console::println("=== Testing Filesystem ===");
// 1. 文件基本读写测试
console::println("[1] File Write/Read Test");
unlink("/file1"); // 清理旧文件
auto fd = open("/file1", +open_flags::create | +open_flags::write);
if(fd != -1) {
auto msg = "hello, world!";
write(fd, msg, 13);
close(fd);
console::println(" Created /file1 and wrote 'hello, world!'");
} else {
console::println(" Failed to create /file1");
}
fd = open("/file1", +open_flags::read);
if(fd != -1) {
char buf[32] = {};
auto len = read(fd, buf, 32);
close(fd);
console::println(" Read from /file1: {} (len={})", buf, len);
} else {
console::println(" Failed to open /file1");
}
// 2. 目录操作测试
console::println("[2] Directory Operation Test");
// 清理旧目录(先删除其中的文件,再删除目录)
unlink("/dir1/.test");
unlink("/dir1/file2");
rmdir("/dir1");
if(mkdir("/dir1")) {
console::println(" Created directory /dir1");
}
fd = open("/dir1/.test", +open_flags::create | +open_flags::write);
if(fd != -1) {
close(fd);
console::println(" Created file /dir1/.test");
}
// 在目录中创建另一个文件
fd = open("/dir1/file2", +open_flags::create | +open_flags::write);
if(fd != -1) {
close(fd);
console::println(" Created file /dir1/file2");
}
// 列出当前目录内容
console::println(" Listing /dir1 content:");
auto dir = opendir("/dir1");
if(dir) {
while(auto de = readdir(dir)) {
console::println(" - {}", de->filename.data());
}
closedir(dir);
}
console::println("=== Filesystem Test Done ===");
}运行结果
当我们在Shell里运行这些命令时,可以看到如下输出:

从截图中我们可以清晰地看到文件系统的运作逻辑:
文件读写:
/file1被成功创建并写入了 "hello, world!",随后的读取操作准确返回了这13个字节,证明了write和read链路的数据一致性。目录创建:通过
mkdir("/dir1")成功创建了目录,返回值为true表示操作成功。多文件管理:在
/dir1目录下成功创建了.test和file2两个文件,证明了目录项增删逻辑正常工作。目录遍历:
opendir+readdir成功列出了/dir1下的所有条目,包括.(当前目录)、..(父目录)、.test和file2,验证了目录项初始化和遍历逻辑的正确性。
屏幕上打印出了写入文件的内容,ls命令也正确列出了目录结构,证明我们的简易文件系统已经能够正常工作了。虽然它很简陋,但它确实是一个真正的、能持久化存储数据的文件系统。