用惯了类 Unix 系统,应该说文件系统是日常最常接触的一个操作系统模块之一了。

$ ls
Applications Network      Users        bin          data         etc          net          sbin         usr
Library      System       Volumes      cores        dev          home         private      tmp          var

但是,究竟什么是文件系统? 为什么需要文件系统? 难道文件不是简单地存储到存储设备一块连续区域的吗?

文件系统的形式

首先我们假设文件是简单地存储在存储设备(硬盘等)某一块连续区域的。

那么如何在小则几百G,大则几十T甚至更大的存储设备上找到具体的某一文件,并读取的内存中呢?

最普通的,就只能在存储设备上一字节一字节的查找,直到找到相应的目标文件。是不是可以想象,这会很慢很慢。

有没有快一点的?很容易想到,参考类似字典的形式就可以了。

目录形式

文件内容可能很大,但相比之下,文件名就小了很多(就算不同目录下同名文件很多,但是记录全路径名也代价不大。当然了,如果都是小文件、空文件,那就不一样了)

通过为所有文件通过文件名编一个目录,指明每个文件在存储设备上的具体位置。这样需要扫描的内容就会小很多,也能更快地定位到文件内容。

逐级目录

仅仅只编制一个目录,那么层次目录又有什么实际意义吗?只是为了方便用户分类不同功能、目的的文件吗?

利用逐级目录,可以考虑对次级目录的内容编制上一级目录。通过顶级目录定位次级目录的内容,次级目录定位再次级目录,最后对应目录下的文件的内容。

文件系统的组织

在之前的理解里,文件系统是接管了整个存储设备。似乎文件系统和存储设备就是直接关联的,两个不可分割。没有建立文件系统的硬盘就不能使用,而没有硬盘,文件系统就没有了载体。

但是,在学习 Linux 的过程中,似乎发现也并不是这么一回事。没有直接硬盘,文件系统一样可以存在。一直都有在使用 .img 磁盘映像文件,只是没有意识到,这意识文件系统的一种载体。同样的,没有文件系统,在操作系统启动的过程中,一样是可以加载各种内容,乃至预置的文件信息。

在 Linux 中,最常见的文件系统就是 ext2, ext3, ext4 之流了。偶尔也有听过用过,比如说准备接入新的硬盘的时候。但是,真正里面是什么样的。还真不了解,ext2,3,4 的区别,也根本不知道。

当然了,本次也并不会对这些内容进行描述。由于一直在学习 Linux 0.11 版本的源码,接下来要描述的文件系统,也就是 0.11 版本直接使用的 minix 文件系统了。

最开始,当然是应该有一个感官的印象,才能更方便地来理解文件系统的组织形式啦!

创建文件系统

这里我们创建一个 512 KB 大小的文件系统

mkfs 应该是最简单的方式了。

$ touch disk.img            # 磁盘映像文件 (由于没有额外的磁盘,就以此作为新创建的 minix 文件系统的载体)
$ dd if=/dev/zero of=disk.img bs=1024 count=512     # 先将文件的大小扩展到 512 KB ,否则在创建文件系统的时候会失败
512+0 records in
512+0 records out
524288 bytes (524 kB, 512 KiB) copied, 0.00216049 s, 243 MB/s
$ mkfs.minix disk.img 512   # 创建文件系统
192 inodes                  # inodes - 应该很熟悉吧。每个文件都占用一个 inode
512 blocks                  # 总共 512 个磁盘块
Firstdatazone=10 (10)       # 第 10 个磁盘块开始是真正的数据块。前面是什么? 大部分是目录占用的
Zonesize=1024               # 每个磁盘块的大小为 1024 B
Maxsize=268966912           # 单一文件的最大大小

先来看看目前 disk.img 里面的内容

$ hexdump disk.img
0000000 0000 0000 0000 0000 0000 0000 0000 0000
*                                                   # * 表示这段数据全为 0
0000400 00c0 0200 0001 0001 000a 0000 1c00 1008
0000410 138f 0001 0000 0000 0000 0000 0000 0000
0000420 0000 0000 0000 0000 0000 0000 0000 0000
*
0000800 0003 0000 0000 0000 0000 0000 0000 0000
0000810 0000 0000 0000 0000 fffe ffff ffff ffff
0000820 ffff ffff ffff ffff ffff ffff ffff ffff
*                                                   # * 表示这段数据全为 ff ( * 总是表示与前一段数据一致的省略)
0000c00 0003 0000 0000 0000 0000 0000 0000 0000
0000c10 0000 0000 0000 0000 0000 0000 0000 0000
*
0000c30 0000 0000 0000 0000 0000 0000 0000 ff80
0000c40 ffff ffff ffff ffff ffff ffff ffff ffff
*
0001000 41ed 0000 0040 0000 cf12 5bd3 0200 000a
0001010 0000 0000 0000 0000 0000 0000 0000 0000
*
0002800 0001 002e 0000 0000 0000 0000 0000 0000
0002810 0000 0000 0000 0000 0000 0000 0000 0000
0002820 0001 2e2e 0000 0000 0000 0000 0000 0000
0002830 0000 0000 0000 0000 0000 0000 0000 0000
0002840 0000 622e 6461 6c62 636f 736b 0000 0000
0002850 0000 0000 0000 0000 0000 0000 0000 0000
*
0080000                                             # 截止字节,0x80000 = 512 KB 并不存在

似乎并没有太多的感觉,现在我们往 disk.img 这个磁盘映像文件中添些内容再来看看。

$ mount disk.img /mnt   # 把 disk.img 挂载到 /mnt 目录下
$ cd /mnt
$ echo "#include <stdio.h>" > hello.c       # 创建 hello.c 文件,并写入 #include <stdio.h> 
$ umount /mnt           # 解挂 disk.img
$ hexdump -C disk.img
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000400  c0 00 00 02 01 00 01 00  0a 00 00 00 00 1c 08 10  |................|
00000410  8f 13 01 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000420  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000800  07 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000810  00 00 00 00 00 00 00 00  fe ff ff ff ff ff ff ff  |................|
00000820  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  |................|
*
00000c00  07 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000c10  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000c30  00 00 00 00 00 00 00 00  00 00 00 00 00 00 80 ff  |................|
00000c40  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  |................|
*
00001000  ed 41 00 00 60 00 00 00  e5 0d d4 5b 00 02 0a 00  |.A..`......[....|
00001010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00001020  a4 81 00 00 13 00 00 00  e5 0d d4 5b 00 01 0b 00  |...........[....|
00001030  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00002800  01 00 2e 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002810  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002820  01 00 2e 2e 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002830  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002840  02 00 68 65 6c 6c 6f 2e  63 00 00 00 00 00 00 00  |..hello.c.......|
00002850  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00002c00  23 69 6e 63 6c 75 64 65  20 3c 73 74 64 69 6f 2e  |#include <stdio.|
00002c10  68 3e 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |h>..............|
00002c20  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00080000

很明显,我们看到了 #include <stdio.h> 字样的内容,同时也出现了 hello.c 的文件名。

需要注意,文件名和文件内容是相互割裂的,并没有直接存储在一起。

文件系统存储结构

那么,配合着上面的内容来看看文件系统的组织结构。

首先可以注意到的是,每一段非零的数据的开始地址,基本都是在 0x0000, 0x0400, 0x0800, 0x0c00, 0x1000 。间隔了 0x0400 = 1KB,这就是最基本的块的大小。

Minix 文件系统将 1024 B 作为基本块的大小。disk.img 这个 512 KB 的磁盘映像文件,也就恰好被分割成 512 块。

$ mkfs.minix disk.img 512   # 创建文件系统
192 inodes                  # inodes - 应该很熟悉吧。每个文件都占用一个 inode
512 blocks                  # 总共 512 个磁盘块
Firstdatazone=10 (10)       # 第 10 个磁盘块开始是真正的数据块。前面是什么? 大部分是目录占用的
Zonesize=1024               # 每个磁盘块的大小为 1024 B
Maxsize=268966912           # 单一文件的最大大小

在回过头来看看前面创建文件系统的时候的信息,相信也就能够看懂一部分了。

块的作用划分

那么,每个块如何进行使用呢?

首先,最开始的一个块并不会直接使用。我们知道引导程序应该出现在引导盘最开始 512 B。但是,有些盘有引导程序,有些盘又没有,如何处理呢?

文件系统结构中,默认地不对第一个 1024 B 的块进行操作,即原本是什么数据就是什么数据,与文件系统并不相干。

除了引导块,之后还有超级块,inode 位图,逻辑块位图,inode 区块,数据区块

超级块

真正对 MINIX 文件系统起着统领性作用的,是超级块(第二个块)。

struct super_block {
 unsigned short s_ninodes;          /* i 节点的数量 */
 unsigned short s_nzones;           /* 总区块数量 */
 unsigned short s_imap_blocks;      /* i 节点位图的数量 */
 unsigned short s_zmap_blocks;      /* 区块位图的数量 */
 unsigned short s_firstdatazone;    /* 第一个数据块的编号 */
 unsigned short s_log_zone_size;    /* log2(磁盘块大小 / 逻辑块大小) */
 unsigned long s_max_size;          /* 单文件的最大长度 */
 unsigned short s_magic;            /* 文件系统的魔数 */

 /* 下面的内容是在内存中使用的,并不会在磁盘上进行读写 */
 struct buffer_head * s_imap[8];
 struct buffer_head * s_zmap[8];
 unsigned short s_dev;
 struct m_inode * s_isup;
 struct m_inode * s_imount;
 unsigned long s_time;
 struct task_struct * s_wait;
 unsigned char s_lock;
 unsigned char s_rd_only;
 unsigned char s_dirt;
};

结合着 disk.img 的数据来看看。

00000400  c0 00 00 02 01 00 01 00  0a 00 00 00 00 1c 08 10  |................|
00000410  8f 13 01 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

0x00c0  -> 192 个 i 节点
0x0200  -> 512 个逻辑块
0x0001  -> 一个i节点位图
0x0001  -> 一个逻辑块位图
0x000a  -> 第一个数据块编号为 10 
0x0000  -> log2(磁盘块大小 / 逻辑块大小) = 0 ,即磁盘块大小 = 逻辑块大小 = 1024 B
0x10081c00 -> 单文件最大 268966912 B 

inode 位图

inode 位图的数据比较简单,是通过设置比特位 0、1 的方式,来直接表明相应编号的 i 节点是否存在。 默认 i 节点从 1 号开始编号,0 号节点在 inode 位图上一定设置为 1

00000800  07 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000810  00 00 00 00 00 00 00 00  fe ff ff ff ff ff ff ff  |................|
00000820  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  |................|

总共 192 个 i 节点,加上 0 号节点总计需占用 193 位 ( = 193 / 8 = 24 字节 + 1 位)

其余多余的位,全部置位为 1 。

位图存储的最初数据 0x07 (注意,小端存储法) = 0b111

由此,总共有1号和2号i节点。

逻辑块位图

类似的,逻辑块位图是为了表明第n个逻辑块存储有数据,或处于空闲状态。

inode 区块

参照前面讲过的字典目录的形式,inode 就是一种分级编排后的目录。

当然,说目录可能还会引发误解。这里无论是对于操作系统下的目录或是文件,每一个都对应着一个独特的 inode

struct d_inode {
 unsigned short i_mode;     /* 文件类型和属性 (rwx 位) */
 unsigned short i_uid;      /* 文件所有者 id */
 unsigned long i_size;      /* 文件大小 */
 unsigned long i_time;      /* 修改时间 */
 unsigned char i_gid;       /* 文件所在组 id */
 unsigned char i_nlinks;    /* 有多少个链接数 (有多少个文件目录项指向该 i 节点) */
 unsigned short i_zone[9];  /* 文件数据所占用数据盘的指针 */
};

每个i节点的数据分别 32 字节

同样的,结合 disk.img 的数据来看

00001000  ed 41 00 00 60 00 00 00  e5 0d d4 5b 00 02 0a 00  |.A..`......[....|
00001010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00001020  a4 81 00 00 13 00 00 00  e5 0d d4 5b 00 01 0b 00  |...........[....|
00001030  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

0x41ed  -> 
0x0000  ->  文件所有者为 0 即 root
0x00000060  ->  文件大小为 96 B
0x5bd40de5  ->  文件最后修改时间为 Unix TimeStamp 1540623845 => 2018/10/27 15:4:5
0x00    ->  文件所在组 id
0x02    ->  有两个文件目录项指向1号i节点
0x000a  ->  1 号 i 节点指向的第0块数据为第10个逻辑块(即第1个数据块)
0x0000  =>  由于数据比较小,后面的 i_zone[1] ~ i_zone[8] 均为 0

i_zone 指向的是数据实际存储的数据块的位置。

但是,仅仅只有 9 个指针,每个数据块能存储 1024 B ,是远远达不到之前所描述的单文件 0x10081c00 = 268966912 B 的上限的。

事实上,minix 在实际处理的过程中,把前7个指针作为直接数据块指针,最多存储 7168 B 数据。

i_zone[7] 表示一次间接指针,指向某一个数据块,但是该数据块每2字节作为一个指针,指向真正的数据块。

i_zone[8] 表示二次间接指针。

Copied from Linux 内核完全注释V3.0

数据块

最后,我们看看数据块的内容,也希望能够更好地来理解 Linux 目录和文件的异同。

00002800  01 00 2e 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002810  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002820  01 00 2e 2e 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002830  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00002840  02 00 68 65 6c 6c 6f 2e  63 00 00 00 00 00 00 00  |..hello.c.......|
00002850  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00002c00  23 69 6e 63 6c 75 64 65  20 3c 73 74 64 69 6f 2e  |#include <stdio.|
00002c10  68 3e 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |h>..............|
00002c20  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

在i节点区块上,我们看到有1号i节点和2号i节点已经被使用了。

0x2800 ~ 0x2860 的位置表示的就是 1 号i节点的数据。事实上这是一个目录(当然,怎么区分目录还是文件,在i节点区块的 i_mode 字段已经描述过了)

对于目录文件,当然是用来记载目录项的,每个目录项占用 32 字节。

这里需要进行说明,MINIX 1.0 版本的文件系统似乎是对每个目录项占用 16 字节的。

这里提供一个 Linux 0.11 版本的仿真运行结果以供证明

上面描述的 disk.img 是在 Ubuntu 16.04 LTS 下跑出来的结果,所有确实出现了一些差别。 至于具体的一个规范,目前还没有找到,所有这部分只能做模糊处理了,勿怪。

前2个字节用来表示该目录项所对应的 i 节点编号。之后的30个字节用来描述该目录项的名字。

因此,对 0x2800 ~ 0x2860 的数据进行重建的结果就是 . .. hello.c 三个目录项了。其中,由于是根目录,... 所指向的i节点的相同的,都是1号i节点。

而 hello.c 文件指向的是 2 号i节点。

哈哈,2号i节点的数据内容就在 0x2c00 开始的位置。通过右侧的 ASCII 结果我们也可以看到。

当然,之前说过每个数据块是 1024 B ,那么数据块多余的部分,就是留空咯,而不会被其它i节点占用(除非该文件or目录被完全移除了)

小结

到此为止,文件系统宏观的描述就已经完结了。

下一节将对操作系统如何使用文件系统进行描述。

  __                    __                  
 / _| __ _ _ __   __ _ / _| ___ _ __   __ _ 
| |_ / _` | '_ \ / _` | |_ / _ \ '_ \ / _` |
|  _| (_| | | | | (_| |  _|  __/ | | | (_| |
|_|  \__,_|_| |_|\__, |_|  \___|_| |_|\__, |
                 |___/                |___/