XV6 File System
Lab5 文件系统
1.文件系统
1.1.一些经典的文件系统
- FAT文件系统(File Allocation Table): FAT是一种简单而广泛使用的文件系统,最初用于MS-DOS和Windows操作系统。它具有相对简单的结构,容易实现和维护,但在处理大容量存储和提供高级功能方面存在一些限制。
- NTFS(New Technology File System): NTFS是由Microsoft开发的高性能文件系统,用于Windows NT及其后续版本。它支持更大的文件和分区大小,具有更先进的权限管理、日志记录和元数据特性。
- ext文件系统:
- ext2: 是Linux中早期版本使用的文件系统,具有相对简单的结构,不支持日志。
- ext3: 在ext2的基础上添加了日志功能,提供了更好的稳定性和可靠性。
- ext4:是ext3的后继者,引入了一些性能改进和新特性,支持更大的文件和分区。
- HFS和HFS+(Hierarchical File System): HFS是由苹果公司用于Macintosh计算机的文件系统。HFS+是其后续版本,引入了更大的文件和卷支持,以及更先进的特性。
- APFS(Apple File System):是由苹果公司设计和推出的现代文件系统,用于替代HFS+(Hierarchical File System Plus),并首次引入于macOS High Sierra(10.13)操作系统。
1.2.xv6fs 文件系统
今天我们的主角是xv6fs,是一个教学用途的类 Unix 操作系统,设计简单,方便学生学习和理解操作系统的基本原理。
在 xv6 中,文件系统负责管理文件和存储设备上的数据。xv6 使用的文件系统是基于简化的 Unix 文件系统的,包括基本的文件和目录操作、inode 结构等。
xv6磁盘文件系统的分区图如下:

1.2.1.boot(引导块)
Lab2 中学过,这里就不再论述了。
1.2.2.superblock(超级块)
存有文件系统的元信息
1 | //fs.h |
1 | //mkfs.c |
在我们进入xv6系统之后,会输出一段关于超级块sb中存储的信息:

我们可以看到,整个磁盘一共有1000块,其中有941块是用来存储数据的,有200个索引节点,索引项从第32个块磁盘块开始存储,30条日志记录,日志记录从第2个磁盘块开始存储,而位图区从第58个磁盘块开始存储。
1.2.3.logblock(日志区)
在 xv6 文件系统中,日志区是指用于事务日志(transaction log)的一部分存储区域。xv6 使用日志来确保文件系统的一致性,尤其是在面临系统崩溃或中断的情况下。日志区的主要目的是在进行文件系统更新时,首先记录要执行的所有操作,然后将这些操作一次性写入磁盘。这样,即使在执行过程中系统崩溃,可以通过日志来恢复到一致的状态。
1.2.4.inode(索引区)


我们现在想想,这个索引可能会存在的位置?
- 没错,肯定会在磁盘中出现,因为要在磁盘中组织这些文件,因此磁盘中一定会包含文件索引信息
- 那么还有可能就是在内存中了,因为进程对文件进行操作都是在内存中进行的,因此内存中必然包含文件的索引信息。
那么,我们现在讨论的索引区,准确来说是在磁盘中的索引。为了和内存中的索引相区分,我们将磁盘中的索引称为dinode(disk inode)。
在fs.h中包含对dinode的Definition:
1 | //fs.h |
type: 短整型(short),表示文件的类型。可能的取值包括:T_DIR:目录T_FILE:普通文件T_DEV:设备文件
major: 短整型(short),仅在文件类型为设备文件(T_DEV)时有意义,表示主设备号。minor: 短整型(short),仅在文件类型为设备文件(T_DEV)时有意义,表示次设备号。nlink: 短整型(short),表示指向该 inode 的硬链接数。size: 无符号整型(uint),表示文件的大小(以字节为单位)。addrs[NDIRECT+1]: 无符号整型数组,用于存储文件数据块的地址。NDIRECT是一个常量,表示直接数据块的个数。这个数组包含了直接数据块和一级间接数据块的地址。如果文件很小,数据块地址可以直接存储在addrs数组中;如果文件较大,会使用一级间接块来存储更多的数据块地址。

了解了dinode,我们趁热打铁,继续了解内存中的inode。
内存中的inode被一个叫icache的结构所组织,在fs.c中进行定义:
1 | //fs.c |
struct spinlock lock: 自旋锁,用于对整个icache结构进行加锁。由于 inode 缓存是一个共享的数据结构,多个线程同时访问时需要使用锁来保护共享资源的一致性。struct inode inode[NINODE]:inode数组 ,包含了 NINODE 个struct inode结构体。NINODE是一个常量,表示 inode 缓存中可以缓存的 inode 的数量。每个struct inode表示一个文件或目录的元数据信息,包括文件类型、大小、指向数据块的地址等。
在file.h中对inode进行定义:
1 | //file.h |
我们可以看到,除了有跟dinode一致的数据项之外,还有一些额外数据项:
uint dev: 无符号整型(uint),表示该 inode 所在的设备的设备号。uint inum: 无符号整型(uint),表示该 inode 的编号,即该 inode 在设备上的唯一标识符。int ref: 整型(int),表示对该 inode 的引用计数。引用计数用于跟踪有多少个指针(例如,打开文件描述符)引用了这个 inode。当引用计数为零时,inode 可以被释放。struct sleeplock lock: 一个睡眠锁(sleeping lock),用于保护struct inode中除了lock自身之外的其他字段。睡眠锁是一种同步机制,它允许线程在访问被锁定资源时进入睡眠状态。int valid: 整型(int),表示该 inode 是否已经从磁盘读取并被标记为有效。当valid为 1 时,表示该 inode 包含的信息已经被读取到内存中。
Ex5-1 请解释为 icache 添加的锁 与 为 inode 添加的锁不同的原因?
1.2.5.bitmap(位图区)
在理论课中,我们学过,位图是一种磁盘空间管理的方案。

这里补充一个大家容易误解的知识点:数据块的分配和释放由位图来管理,但位图管理的区域不止数据区,而是整个文件系统。
位图块中每一位都代表着一块,该位置 1 表示相应的块正在使用,该位置 0 表示相应的块空闲。
1.2.6.data(数据区)
数据区没啥好说的,只要记住一个点:以块为单位进行存储,可能会产生块内碎片;数据区的存储由位图区进行管理。
到这里,我们就能对单个文件的检索过程有一个清晰的认知:

除了上述一些在磁盘分区中直接体现的结构之外,文件系统还有一些重要的结构需要我们去了解。
我们先试想一下,索引极大的方便了数据块的查找,但其依然是对于文件系统内部而言的。我们平常去检索一个文件从来没有说,通过索引项去找到该文件的吧。相反,我们总是通过文件的文件名去检索文件。因此,还必须有一个结构去实现这种 ”按名存取“ 的功能。这就是目录!
1.2.7.directory(目录)
在xv6中,跟目录有关的结构体被定义在fs.h中:
1 | //fs.h |
ushort inum:无符号短整型,表示该目录项对应的 inode 号。char name[DIRSIZ]:字符数组,表示目录项的名字。DIRSIZ是一个常量,表示目录项名字的最大长度。
其中,每一个dirent结构体被称为目录项。在 xv6 操作系统中,目录项存储在磁盘的数据块中。每个目录都有一个对应的 inode,而目录项信息实际上被存储在该目录的数据块中。这个数据块包含了一系列的目录项,每个目录项都表示一个文件或子目录。
我们在xv6的终端中输入ls命令,既可以看到根目录的目录结构:

我们使用xv6提供的终端命令mkdir自己创建一个目录mydir,再在改目录下通过echo命令创建两个文件

之后我们在使用ls命令查看根目录的结构

我们来解释一下ls输出的内容:
- 第一列表示文件名,其中“.”表示本目录,”..“表示父目录(根目录的父目录就是其自身)

- 第二列表示文件类别,我们知道,在Unix操作系统中,”一切皆文件“的思想,在
stat.h中,给出了三种文件类型:
1 |
其中1表示目录文件,2表示数据文件,3表示设备文件。
我们可以看到“.”和”..“以及我们创建的“mydir”都是目录文件,而console是设备文件
- 第三列表示inode编号,这是直接在目录项中存在的,其数值必然是唯一的。
- 第四列表示文件的大小,这是在目录项中没有的,因此我们可以推断,
ls命令应该既访问了目录项,又访问了inode结点。
很显然,这种目录的组织结构就是我们熟知的树型结构

那么,假如我们要检索创建的“myfile1”文件,就需要按照树型结构的路径一层层往下找。当然,不同的起始出发点就会出现两种寻找策略:直接从根节点出发寻找和从当前目录项出发寻找。但是不管是哪一种策略,其都是一个递归的过程。
到这里,我们都还是介绍一些”共性“的结构,或者说概念。但是每一个文件肯定是不同,我们还需要一种能体现”特性“的结构体来表示每一个文件。
1.2.8.file(文件结构体)
1 | //file.h |
num { FD_NONE, FD_PIPE, FD_INODE } type: 表示文件类型的枚举。文件可以是普通文件(FD_INODE)、管道文件(FD_PIPE)或者无效的文件(FD_NONE)。int ref: 引用计数,用于追踪有多少个文件描述符引用了这个文件。引用计数用于文件的释放,当引用计数为零时,文件可以被释放。char readable和char writable: 标志文件是否可读和可写。这两个字段表示了文件的访问权限。struct pipe \*pipe: 如果文件是管道文件,这个字段指向管道结构体。struct inode \*ip: 指向文件关联的 inode 结构体,用于获取文件的元数据信息。uint off: 当前文件的读写位置,表示下一次读写操作将在文件中的哪个位置发生。
接下来我们还得再了解一个概念:文件描述符,这是在进程中直接使用的一个结构。
1.2.9.文件描述符
进程使用文件并不使用file结构体或inode结构体,而是提供文件的路径名来打开文件并获得文件描述符,后续将使用文件描述符来指代这个打开的文件。xv6中,每个进程都有一个文件描述符表proc->ofile[],最多可以使用NOFILE=16个文件描述符(也就是说一个进程最多同时打开16个文件),每个文件描述符都直接指向一个file结构体(系统管理的已打开的文件)。
1 | //proc.c |
现在,我们可以站在整个文件系统的层次对一个进程访问文件的整个过程有一个宏观的认知:

2.文件系统操作
2.1.盘块操作
文件系统的所有操作中,对盘块的操作是最底层的,是直接和硬件(设备驱动程序)打交道的。
在介绍盘块操作之前,我必须先指出大家可能模棱两可的问题:
对盘块的操作并不是直接在磁盘中对盘块进行操作,而是对映射到内存中的块缓存进行操作,至于原因,我觉得大家都明白。对一个盘块的多次读写操作都是在内存中完成的,直到换出到磁盘上才真正地执行写盘操作。
xv6fs只能通过块缓存来访问磁盘,而不允许直接访问。
2.1.1.盘块缓冲区
在xv6的buf.c中有对缓冲区的定义:
1 | //buf.c |
struct spinlock lock: 互斥锁,用于对整个缓冲区缓存进行加锁。由于缓冲区缓存是一个共享的数据结构,多个线程同时访问时需要使用锁来保护共享资源的一致性。struct buf buf[NBUF]: 缓冲区数组,包含 NBUF 个struct buf结构体。每个元素表示一个缓冲区,用于缓存磁盘上的数据块。struct buf head:struct buf结构体,用作链表的头部。通过head.next和head.prev可以构建一个双向链表,用于管理所有缓冲区。head.next指向最近使用的缓冲区,head.prev指向最久未使用的缓冲区。
对每一个缓冲单元在buf.h中进行定义:
1 | //buf.h |
int flags: 整型,用于表示缓冲区的状态标志。这些标志可能包括:B_VALID:缓冲区包含有效的数据。B_DIRTY:缓冲区中的数据已经被修改,需要写回磁盘。- 其他标志用于表示缓冲区的状态。
uint dev: 无符号整型,表示数据块所在的设备的设备号。uint blockno: 无符号整型,表示数据块的块号。这是指在设备上的位置。struct sleeplock lock: 一个睡眠锁(sleeping lock),用于对缓冲区进行加锁。睡眠锁是一种同步机制,它允许线程在访问被锁定资源时进入睡眠状态。uint refcnt: 无符号整型,表示缓冲区的引用计数。引用计数用于跟踪有多少个指针引用了这个缓冲区。当引用计数为零时,缓冲区可以被释放。struct buf *prev和struct buf *next: 指向双向链表中前一个和后一个缓冲区的指针。这些指针用于在缓冲区之间构建 LRU(Least Recently Used)缓存列表,以实现缓冲区的管理和替换。struct buf *qnext: 指向缓冲区的下一个缓冲区,用于构建磁盘 I/O 队列。这个指针用于将缓冲区连接到待写回磁盘的队列中。uchar data[BSIZE]: 字节数组,用于存储实际的数据块。BSIZE是一个常量,表示数据块的大小。
2.1.2.初始化
1 | //main.c |
1 | //bio.c |
初始化就干两件事:
- 创建bcache自旋锁
- 将缓存块buf构成一个LRU双向链表
希望同学们不要把大部分精力陷入到代码的具体理解中,这没意义!
2.1.3.查找
1 | //bio.c |
根据设备号和盘块号查找块缓存。需要注意的是,盘块缓冲区是对磁盘空间进行了抽象,抽象成了一个连续的空间。而对磁盘缓冲块的查找也相当的粗暴,直接for循环遍历!
2.1.4.释放
1 | //bio.c |
释放的代码很简单,需要注意的就是进行b->refcnt--;操作。
后续还有很多函数,考虑到文章的篇幅,这里我们不再贴代码,会指明代码所在的文件,同学们自行查找即可!
2.1.5.盘块的读写
请注意,盘块的读写指的都是从缓存块和磁盘块直接交互的过程!

读写函数都定义在bio.c中,bread()先调用bget()查找对应的缓存块,然后调用iderw()将数据块从磁盘中写入缓存区中;而bwrite()就是调用iderw()将数据块写回磁盘。
1 | struct buf* |
这个读函数还是给大家贴出来,因为后面做实验要用到。
2.1.6.读入超级块
超级块的读入有点特殊(很正常,人家名字就已经很特殊了),有专门的在fs.c中的readsb()。
嘿嘿,要不同学猜猜,为什么要抽离出来,定义一个专门的函数来读这个盘块?
Ex5-2 请解释为什么要单独定义一个超级块的读入函数?
骚微提示一下,同学们想想这个盘块是在什么时候开始读的,哈哈哈,只能提示这么多了。
2.1.7.其他函数
还有几个比较重要的函数是bzero、balloc、bfree,具体干嘛的,同学们自行查阅相关资料。
2.2.索引节点操作
对索引节点的操作是抽象在对磁盘操作的基础之上的,具体有对索引节点管理的文件的读写,以及对节点自身的分配,删除和修改。
2.1.1.对索引节点自身的操作
主要的函数有iget、iupdate、idup、itrunc、stati、ilock、iunlock、iput,都在fs.c中。
iget()根据设备号dev和索引节点inum在索引节点缓存中查找,返回所匹配的索引节点缓存,或者分配一个空闲的索引节点缓存。
iupdate()将inode缓存的内容更新到磁盘的dinode上,最后写回磁盘中。
idup()增加索引节点缓存的引用计数,将其成员变量ref++即可。
itrunc()将索引节点管理的文件数据(直接块和间接块)都释放掉,每个盘块通过bfree()释放
stati()将索引节点缓存中的基本信息复制到stat结构体中并返回。
iput()减少索引节点缓存的引用计数,将其成员变量ref–,若小于0则itrunc。
2.1.2.对文件的操作
主要的函数有readi、bmap、writei,都在fs.c中。
readi()
用于从 inode 对应的磁盘文件的偏移 off 处,读入n个字节到 dst 指向的数据缓冲区。如果是设备文件(T_DEV),则使用设备的读操作函数 devsw[ip->major]。read()完成读入操作,否则将执行磁盘文件的读入操作,该操作略微有些复杂。
磁盘文件需要逐个盘块读入数据,但首先要知道文件偏移量对应的物理盘块号是哪个,这是通过 bmap()完成的。
确定盘块号之后,将会调用前面讨论过的 bread()完成磁盘盘块的读人。由于 bread()将数据读入到块缓存中,因此还需要用 memmove()将数据复制到用户空间缓冲区。
1 | int |
这个读函数还是给大家贴出来,因为后面做实验要用到。
bmap()
由于进程发出的文件读写操作使用的是字节偏移(转换成文件内部的逻辑盘块号bn),而磁盘读写 bread()和 bwrite()使用的是物理盘块号,因此需要 bmap()将文件字节偏移对应的逻辑盘块号 bn 转换成物理盘块号。其转换过程需要借助索引节点的 dinode.addrs[]或 inode.addrs[],并且需要考虑直接盘块和间接盘块。
如果对应的数据盘块不存在,则 bmap() 会调用 balloc() 分配一个空闲盘块,然后再修改索引,使得 ip->addrs[bn] 指向新分配的盘块;如果该偏移落人间接索引区,则可能还需要分配间接索引盘块,然后才能分配盘块号bn所对应的数据盘块并建立索引关系。
writei()
writei()需要逐个盘块写出数据,因为有块缓存的存在,其会先调用 bread()将磁盘盘块读入到块缓存,然后才是将数据复制到块缓存中,最后由 log_write()向日志系统写出。writei()也是借用 bmap(),通过查找 dinode.addrs[]完成文件偏移量到磁盘盘块号的转换。
如果是设备(T_DEV),则需要通过它自身的读函数 devsw[ip->major].read 完成。
2.3.目录操作
这一部分我们直接略过了,其主要函数都在fs.c中,如用于目录查找的函数:dirlookup、skipelem;用于创建和删除的函数:dirlink;用于文件定位的函数:namex、namei、nameiparent,其均在fs.c中实现。
2.4.文件操作
2.4.1.文件打开表初始化
在file.c中有fileinit的定义:
1 | //file.c |
1 | int |
用于对系统中已打开文件列表ftable[]进行初始化,即初始化ftable.lock自旋锁
2.4.2.分配、关闭和复制
filealloc分配一个空闲的file对象
filedup当用户进程对某个文件描述符进行复制时,将引起对应的file对象引用次数加一
fileclose类似iput
filestat读取文件的元数据
2.4.3.文件读写操作
fileread()是通用的文件读操作函数,当文件的type为FD_PIPE将调用piperead()进行读,而为FD_INODE时,调用readi()进行读。
1 | //file.c |
这个读函数还是给大家贴出来,因为后面做实验要用到。
filewrite()是通用的文件写操作函数,与读操作类似,也通过type分别调用具体的写函数。
2.5.系统调用
系统调用在Lab4中已经讲过,为此我们不再做过多赘述。本节课的系统调用的重点就是关注那些与文件操作相关的系统调用,如sys_open、sys_close、sys_read、sys_write、sys_mkdir、sys_mknod等
1 | int |
由于我们后面的实验会用到sys_read(),因此各位重点了解一下这个函数。
3.实验
文件系统的内容相当庞大,涉及的概念多吗,因此容易弄混是很常见的事。笔者在上理论课的时候也就在这块理解的不是特别丝滑,因为诸位应该秉着从全局到末端的策略,反复理解。
那么,我们现在结合一个简单的实验,帮助同学们从进程、文件系统(内存到外存)到设备驱动,这整个文件读的操作理解清楚。

3.1.gdbinit配置文件断点设置
1 | layout src |
3.2.编译xv6&启动gdb
1 | make qume-nox-gdb |
3.3.具体过程
Step1:在gdb中:通过info breakpoints检查断点

Step2:取消断点2,3,4,5
1 | disable 2 3 4 5 |
Step3:continue
在gdb中输入一个c之后,程序执行到第一个断点位置,此时xv6终端呈现如下,但依旧不能输入。

在输入第二个c后,程序进入死循环,此时终端可以使用,即开中断已打开。
Step4:在xv6中输入cat README,即读README文件,并将内容输出到终端中。
此时,产生中断,截停在sys_read函数中
Step5:此时我们在gdb中恢复断点2,3,4,5
1 | enable 2 3 4 5 |

然后,我们逐行调试,在第78行进入函数fileread中。

再接着逐行调试,会在107行进入readi函数中。

再继续,此时可能会读写多个缓存块。在470行进入bread函数中。

最后,通过设备驱动程序读取文件,在103行进入iderw函数中。
Step7:当你继续c后,直到xv6终端中出现这么一大段字符,就说明已经从磁盘中读取了一个块的内容。
1 | NOTE: we have stopped maintaining the x86 version of xv6, and switched |
读者可以数一下,这一共多少个字符。如果是512个,那么恭喜,你数对了。字符数就是一个块的大小(512B)。
当然,读者再继续c几下还会出现更多的内容。我们直到README文件的大小为2286B,那么算下来,读者应该会执行5次c命令才算把这个文件读完。
Step8:做到这里,基本上这个小实验就算完成了。做完这些,读者应该大致对这个文件读的过程有了一个宏观的了解。至于这个后面,文件名是如何传递的,索引号是如何检索的,读者如果感兴趣,可以自行探索。
最后,我们再留一个小作业。我们刚带诸位了解了文件读的过程,那么文件写的执行过程是怎样的,请同学们自行实验探索。
Ex5-3 请分析echo “0” > /mydir/myfile3这条命令的执行过程,要求对执行过程经过的几个关键的写函数进行分析和截图,并绘制如下,关于写过程的流程图。

- Title: XV6 File System
- Author: Hong Nie
- Created at : 2024-01-13 22:00:51
- Updated at : 2025-02-28 22:25:18
- Link: https://gme-hong.github.io/2024/01/13/XV6-FS/
- License: This work is licensed under CC BY-NC-SA 4.0.