为了加深对Linux操作系统中文件系统的理解,本篇文章将详细介绍编写一个简单文件系统的第一步,如何实现磁盘的格式化。作者能力有限,仅作零基础入门学习的实验,所以相关概念也会尽量解释到详细。好了,那就从准备工作开始吧
准备工作
准备工作主要对文件系统的概念,格式化的概念,磁盘的准备,文件系统逻辑结构设计,重要数据结构进行介绍。
文件系统的概念
文件系统是操作系统向用户提供一套存取数据的抽象数据结构,方便用户管理一组数据。文件系统在Linux操作系统中的位置在下图红框中标出,如Ext2、Ext4等。而在windows中现在常用的文件系统为NTFS、exFAT等,想必大家在格式化U盘、硬盘的时候就经常见到了。
为什么要用文件系统来存取数据呢?是为了图个方便。试想如果没有文件系统,放置在存储介质(硬盘)中的数据将是一个庞大的数据主体,无法分辨一个数据从哪里停止,下一个数据又从哪里开始。我们在田地里面种菜,还需要划分出几畦几垄。放到磁盘上也是一样的,通过将数据分为一块一块的,并为每一块都赋予一个名字,数据将会很容易隔离和确定。当然这都是在逻辑上去划分,并不是像对土地那样,去把磁盘切开,聚拢成堆。既然是在逻辑上划分,那总得有个依据,将划分的结果落实下来,不能凭空的,今天我这样划分,明儿个他那样划分,就乱套了。这时候我们就需要创建一系列的数据结构(包含数据和对此数据的一系列操作),来表示我们划分的逻辑。
格式化的概念
如果还是很模糊,我们就离了菜地,再来换个例子。如果将硬盘比作一张空白A4纸,那么将其格式化为某种文件系统(如下图所示,格式化的时候会让我们选择一个文件系统),就类似在纸上面印上田字格,页码,为其加上目录,用来练习书法;有的人想练习英文,那就印上四线三格。这样一张纸根据不同的用途,就有了不同的结构划分。言而总之,通过定义一系列数据结构,用于划分硬盘存储的结构和管理数据,就是文件系统。
。
我们顺便把格式化也给说完了,如果还不清楚,后面还会有介绍。
磁盘的准备
因为我们是实验是编写简单文件系统,为了方便和简化,我们将创建一个名字为image的文件,来代替真正的物理磁盘。我们对该文件进行格式化,查看文件的内容,来检验我们的成果。
下面我们将使用linux中dd命令来创建一个指定块大小的文件。我们的image包含100个块,并且每个块的大小为4096字节,全部用0填充。
1 | dd bs=4096 count=100 if=/dev/zero of=image |
dd命令:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换。
if=文件名:输入文件名,缺省为标准输入。即指定源文件。
of=文件名:输出文件名,缺省为标准输出。
count=blocks:只拷贝blocks个块,即拷贝块的个数。
bs=bytes:同时设置读入/输出的块大小为bytes个字节。
/dev/zero 是类 Unix 系统中一个特殊的文件,当读取该文件时,它会提供无限的空字符 null或者0。
可以使用hexdump命令,查看image里面的数据全都为0。这就是一个未格式化磁盘的样子。它的地址从0x00000000-0x00064000(409600),也就是文件大小为409600 byte。
szp@szp-pc:~/code/myfs$ dd bs=4096 count=100 if=/dev/zero of=image 记录了100+0 的读入 记录了100+0 的写出 409600 bytes (410 kB, 400 KiB) copied, 0.00192153 s, 213 MB/s szp@szp-pc:~/code/myfs$ hexdump -C image 00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00064000
文件系统逻辑结构设计
磁盘准备好了,下面就应该考虑如何进行逻辑上的划分了。磁盘都是以块为单位,我们上面创建的磁盘一共包含100块,每一块的大小为4k。参考现有的文件系统,一个文件系统必须包含以下逻辑结构划分。
将100块,分为6大部分,现做简要介绍。
0-1块:引导块,用于引导操作系统启动,必须保留。
1-2块:Super Block(超级块),包含了文件系统布局的重要信息,一旦破坏,将导致磁盘不可读。包括inode节点的个数、磁盘块数以及空闲块链表的起始位置。
2-3块:block map(块位图),记录磁盘中所有块的使用情况,即这100个块哪个是被使用了,哪个没有被使用。
3-4块:inode map(inode位图),与块位图同理,存储的是inode节点的占用情况。
4-10块:inode table(inode节点),每个inode节点表示一个确切的文件。
10-99块:data block(数据块),存放文件具体内容的块。
inode用于描述一个具体的文件的元信息,例如该文件的创建时间,是目录还是具体文件等等。需要注意的是它并不存储文件内部具体的数据,文件中具体数据是存放在数据块(data block)中的,那我们怎么知道当前inode对应那几个数据块呢,毕竟我们这里数据块就有89个。inode中设置一个block数组,block数组存储着每个块的索引,用于定位文件。
位图或位向量(块位图和inode位图)是一系列位或位的集合,其中每个位对应一个磁盘块,该位可以采用两个值: 0和1, 1表
示已分配该块,而0表示一个空闲块。如1111 0000表示前四块已分配,后四块空闲。
uint64_t block[MISER_N_BLOCKS];
MISER_N_BLOCKS设置为10,也就是一个文件最大也就能用10个块来存储,因为一个块的大小为4k,那么一个文件最大也就40k。这也太小了,真正的文件系统中inode会有多级索引,可以支持更多的块索引,我们这里只用10个一级索引,所以小了一些。
重要数据结构
磁盘逻辑结构既然划分好了,就要定义一系列数据结构来进行表示、存储和操作。
super block
超级块代表了整个文件系统,超级块是文件系统的控制块,有整个文件系统信息。
1 | struct MISER_fs_super_block { |
- 超级块中的char padding[4016],是为了使超级块的大小为4096bytes,以简化计算;
- magic为1314522,用以区别其他文件系统;
- inodes_count记录文件系统所支持的inode个数,在格式化的时候写入。
- bmap_block记录着bmap开始的块索引,我们这里根据上面的逻辑划分,取值为2。
- imap_block记录imap开始的块索引,取值为3。
- inode_table_block记录inode开始的块索引,取值为4。
- data_block_number记录数据部分开始的块索引,这里为10,记录索引是为了简化文件块的定位操作。
MISER_inode
MISER_inode对应磁盘中的inode。
1 | struct MISER_inode { |
- mode表示当前文件是目录(目录也是文件)还是一个常规文件
- inode_no表示节点号码
- blocks表示该inode自身所占块的数目
- block[MISER_N_BLOCKS]表示文件中数据存储位置的数据块索引
- file_size如果该inode是常规文件,表示文件大小
- dir_children_count如果该inode是目录,表示子目录数
- i_uid、i_gid表示用户id,组id
目录项
文件只有打开后才能够被读取。在文件打开后,操作系统会使用用户提供的路径名来定位磁盘中的目录。目录项提供了查找文件磁盘块所需要的信息。
如果使用相对路径,则从当前进程的当前目录开始查找,否则就从根目录开始。
在以上两种情况中,第一个目录的i节点很容易定位:在进程描述符中有指向它的指针,或者在使用根目录的情况下,它存储在磁盘上预定的块上。
1 |
|
- filename字符数组,存储文件的名称,MISER_FILENAME_MAX_LEN是256,文件名称最长为256
- inode_no存储对应的inode节点号码
功能实现
为了实现我们设计的文件系统,我们需要创建一个mkfs格式化程序,使用该程序,将image磁盘改写成我们划分好的结构。
mkfs程序依次向image写入超级块(引导块保留)、block map、inode map、inode,在data block创建一个根目录,根目录中创建一个测试文件。
我们将按照如下顺序依次写入文件:
1 | //初始化superblock |
初始化超级块和块位图
超级块包含了文件系统的基本信息,其信息在上文中有详细描述。写入超级块信息,需要计算整个磁盘的大小,然后计算imap,bmap以及inode table的大小,这样才能确定各个区域在磁盘中的位置。这些工作都是在init_disk这个函数中完成的。
1 |
|
主要流程为,获取磁盘大小(image文件),计算bmap,imap的大小,需要用几个块存储;计算inode总共占用几个块,分别计算出6个部分逻辑划分的起始块索引。计算出了磁盘元数据占用的块数,就可以使用set_bmap修改bmap了。根据逻辑划分可以知道,引导块,超级块,bmap,imap,inode总共使用了10个块,然后我们还需要创建一个根目录和一个文件,会占用一个data block。因此image中前11个块都会被占用。set_bmap函数执行完成后,使用gdb单步调试可以看到bmap数组前11位被置为1。该数组会在后面直接写入到文件。
设置bmap函数如下:
1 |
|
写入引导块
初始化超级块(struct MISER_fs_super_block super_block)和块位图(uint8_t* bmap)以后,首先写入第一个块,也就是引导块的数据。引导块全部置0。
1 |
|
使用hexdump命令查看写入的情况,是否正确。
第0块,0x00000000-0x00001000,其中的数据全部为0,*省略了重复的内容。
szp@szp-pc:~/code/myfs$ hexdump -C image 00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00001000 01 00 00 00 00 00 00 00 da 0e 14 00 00 00 00 00 |................|
写入超级块
紧接着在引导块之后,写入刚才初始化好的超级块。
1 |
|
第1块,0x00001000-0x00002000,可以看到版本号占8个字节为值为01,0x140eda代表magic,转换为十进制值为1314522,往下依次类推,都符合我们的设置。
00001000 01 00 00 00 00 00 00 00 da 0e 14 00 00 00 00 00 |................| 00001010 00 10 00 00 00 00 00 00 64 00 00 00 00 00 00 00 |........d.......| 00001020 59 00 00 00 00 00 00 00 64 00 00 00 00 00 00 00 |Y.......d.......| 00001030 02 00 00 00 00 00 00 00 03 00 00 00 00 00 00 00 |................| 00001040 04 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 |................| 00001050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00002000 ff 07 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
写入块位图
块位图即上面我们构建好的bmap数据,依次写入。
1 |
|
写入后的数据为0xff07,二进制为11111111 111,与bmap数组中的数据相同。
00002000 ff 07 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00002010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| *
写入inode位图
根目录需要三个目录项,一个是代表当前目录的‘.’,一个是代表父目录的‘..’,还有一个是我们创建的测试文件。所以需要使用三个inode,在inode位图上对应位,设置前三个位为占用。
1 |
|
如下图0x03,二进制为11,表示inode table中前两个inode已被使用。
00003000 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00003010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| *
创建根目录
创建两个inode,一个表示根目录文件,一个表示测试文件。依次写入到inode table中。然后在data block中写入三个目录项。并与前面创建的两个inode,通过其中的block数组进行关联。
1 |
|
对于根目录来讲,写入的数据为三个目录项,目录项的内容为文件(目录)名以及对应的inode编号。第一个目录项为当前目录和对应的inode编号0,第二个目录项为上一级目录和对应的inode编号0,第三个目录项为欢迎文件,内容为文件名“file”和对应的inode编号1。
编译执行
编写完成后,就可以进行编译执行。
1 | gcc mkfs.c -o mkfs |
至此,磁盘image中就格式化了我们设计的文件系统,接下来就该实现文件系统的挂载与卸载。