如何实现一个文件系统(之二)
本文作者 康华:计算机硕士,主要从事Linux操作系统内核、Linux技术标准、计算机安全、软件测试等领域的研究与开发工作,现就职于信息产业部软件与集成电路促进中心所属的MII-HP Linux软件实验室。如果需要可以联系通过kanghua151@msn.com联系他。< 续.. >
( 原文 http://www.kerneltravel.net/jiaoliu/002.htm#_ftn4):
文件系统的读写
要自己创建文件系统必须知道文件系统需要那些操作,各种操作的功能范围,所以我们下面内容就是分析Linux文件系统文件读写过程,从中获得文件系统的基本功能函数信息和作用范围。
打开文件
在对文件进行写前,必须先打开文件。打开文件的目的是为了能使得目标文件能和当前进程关联,同时需要将目标文件的索引节点从磁盘载入内存,并初始化。
open操作主要包含以下几个工作要做(实际多数工作由sys_open()完成):
l 1分配文件描述符号。
l 2 获得新文件对象。
l 3 获得目标文件的目录项对象和其索引节点对象(主要通过open_namei()函数)——具体的讲是通过调用索引节点对象(该索引节点或是安装点或是当前目录)的lookup方法找到目录项对应的索引节点号ino,然后调用iget(sb,ino)从磁盘读入相应索引节点并在内核中建立起相应的索引节点(inode)对象(其实还是通过调用sb->s_op->read_inode()超级块提供的方法),最后还要使用d_add(dentry,inode)函数将目录项对象与inode对象连接起来。
l 4 初始化目标文件对象的域,特别是把f_op域设置成索引节点中i_fop指向文件对象操作表——以后对文件的所有操作将调用该表中的实际方法。
l 5 如果定义了文件操作的open方法(缺省),就调用它。
到此可以看到打开文件后,文件相关的“上下文”、索引节点、目录对象等都已经生成就绪,下一步就是实际的文件读写操作了。
文件读写:
用户空间通过read/write系统调用进入内核执行文件操作,read操作通过sys_read内核函数完成相关读操作,write通过sys_write内核函数完成相关写操作。简而言之,sys_read( ) 和sys_write( )几乎执行相同的步骤,请看下面:
l1 调用fget( )从fd获取相应文件对象file,并把引用计数器file->f_count减1。
l2 检查file->f_mode中的标志是否允许请求访问(读或写操作)。
l3 调用locks_verify_area( )检查对要访问的文件部分是否有强制锁。
l4 调用file->f_op->read 或file->f_op->write来传送数据。两个函数都返回实际传送的字节数。
l5 调用fput( )以减少引用计数器file->f_count的值
l6 返回实际传送的字节数。
搞清楚大体流程了吧?但别得意,现在仅仅看到的是文件读写的皮毛。因为这里的读写方法仅仅是VFS提供的抽象方法,具体文件系统的读写操作可不是表面这么简单,接下来我们试试看能否用比较简洁的方法把从这里开始到数据被写入磁盘的复杂过程描述清楚。
现在我们要进入文件系统最复杂的部分——实际读写操作了。f_op->read/f_op->write两个方法属于实际文件系统的读写方法,但是对于基于磁盘的文件系统(必须有I/O操作),比如EXT2等,所使用的实际的读写方法都是利用Linux系统业以提供的通用函数——generic_file_read/generic_file_write完成的,这些通用函数的作用是确定正被访问的数据所在物理块的位置,并激活块设备驱动程序开始数据传送,它们针对Unix风格的文件系统都能很好的完成功能 ,所以没必要自己再实现专用函数了。下面来分析这些通用函数。
先说读方法:
第一部分利用给定的文件偏移量(ppos)和读写字节数(count)计算出数据所在页[1]的逻辑号(index)。
第二 然后开始传送数据页。
第三 更新文件指针,记录时间戳等收尾工作。
其中最复杂的是第二部,首先内核会检查数据是否已经驻存在页高速缓存(page= find_get_page(mmaping ,index),其中mammping为页高速缓存对象,index为逻辑页号),如果在高速缓存中发现所需数据而且数据是有效的(通过检查一些标志位,如,PG_uptodate),那么内核就可以从缓存中快速返回需要的页;否则如果页中的数据是无效的,那么内核将分配一个新页面,然后将其加入到页高速缓存中,随即使用address_space对象的readpage方法(mapping->a_ops->readpage(file,page))激活相应的函数使磁盘到页的I/O数据传送。完成之后[2]还要调用file_read_actor( )方法把页中的数据拷贝到用户态缓冲区,最好进行一些收尾等工作,如更新标志等。
到此为止,我们才要开始涉及和系统系统I/O层打交道了,下面我们就来分析readpage函数具体如何激活磁盘到页的I/O传输。
address_space对象的readpage方法存放的是函数地址,该函数激活从物理磁盘到页高速缓存的I/O数据传送。对于普通文件,该函数指针指向block_read_full_page( )函数的封装函数。例如,REISEFS文件系统的readpage方法指向下列函数实现:
int reiserfs _readpage(struct file *file, struct page *page)
{
return block_read_full_page(page, reiserfs _get_block);
}
需要封装函数是因为block_read_full_page( )函数接受的参数为待填充页的页描述符及有助于block_read_full_page()找到正确块的函数get_block的地址。该函数依赖于具体文件系统,作用是把相对于文件开始位置的块号转换为相对于磁盘分区中块位置的逻辑块号。在这里它指向reiserfs _get_block( )函数的地址。
block_read_full_page( )函数目的是对页所在的缓冲区启动页I/O操作,具体将要完成这几方面工作:
n 调用create_empty_buffers( )为页中包含的所有缓冲区[3]分配异步缓冲区首部
n 从页所对应的文件偏移量(page->index域)导出页中第一个块的文件块号
n 初始化缓冲区首部,最主要的工作是通过get_block函数进行磁盘寻址,找到缓冲区的逻辑块号(相对于磁盘分区的开始而不是普通文件的开始)
n 对于页中的每个缓冲区首部,对其调用submit_bh( )函数,指定操作类型为READ。
l 接下来的工作就该交给I/O传输层处理了,I/O层负责磁盘访问请求调度和管理传输动作。我们简要分析submit_bt()函数,该函数总体来说目的是向tq_disk任务队列[4]提交请求,但它所做工作颇多,下面就简要分析该函数的行为:
u 从b_blocknr(逻辑块号)和b_size(块大小)两个域确定磁盘上第一个块的扇区号,即b_rsector域的值
u 调用generic_make_request()函数向低级别驱动程序[5]发送请求,它接受的参数为缓冲区首部bh和操作类型rw。而该函数从低级驱动程描述符blk_dev[maj]中获得设备驱动程序请求队列的描述符,接着调用请求队列描述符的make_request_fn方法
make_request_fn方法是请求队列定义的合并相临请求,排序请求的主要执行函数。它将首先创建请求(实际上就是缓冲头和磁盘扇区的映射关系);然后检查请求队列是否为空:
l 如果请求队列为空,则把新的请求描述符插入其中,而且还要将请求队列描述符插入tq_disk任务队列,随后再调度低级驱动程序的策略例程的活动。
l 如果请求队列不为空,则把新的请求描述符插入其中,试图把它与其他已经排队的请求进行组合(使用电梯调度算法)。
低级驱动程序的活动策略函数是request_fn方法。
策略例程通常在新请求插入到空列队后被启动。随后队列中的所有请求要依次进行处理,直到队列为空才结束。
策略例程request_fn(定义在请求结构中)的执行过程如下:
u 策略例程处理队列中的第一个请求并设置块设备控制器,使数据传送完成后产生一个中断。然后策略例程就终止。
u 数据传送完毕后块设备控制器产生中断,中断处理程序就激活下半部分。这个下半部分的处理程序把这个请求从队列中删除(end_request( ))并重新执行策略例程来处理队列中的下一个请求。
好了,写操作说完了,是不是觉得不知所云呀,其实上面仅仅是抽取写操作的骨架简要讲解,具体操作还要复杂得多,下面我们将上面的流程总结一便。
粗略地分,读操作依次需要经过:
用户界面层——负责从用户函数经过系统调用进入内核;
基本文件系统层——负责调用文件写方法,从高速缓存中搜索数据页,返回给用户。
I/O调度层——负责对请求排队,从而提高吞吐量。
I/O传输层——利用任务列队异步操作设备控制器完成数据传输。
请看下图4给出的逻辑流程。
周期运行 |
read |
sys_read |
file->f_op->read |
do_generic_file_read |
i_mapping->readpage |
block_read_full_page |
submit_bh submit_bh( )
|
generic_make_request |
make_request_fn |
add_requst |
用户空间函数 |
内核系统调用 |
文件系统读操作 |
通用文件系统读操作 |
页面读操作 |
通用页读取操作 |
请求提交操作 |
向低级设备驱动发送请求 |
设备具体的请求发送函数 |
将请求加入请求队列 |
I/O 调度层 |
tq_disk |
request_fn |
调度策略函数 |
任务队列 |
end_request |
请求处理完毕收尾函数 |
中断处理函数 |
块设备DMA数据传送
|
图 4 读操作流
写操作和读操作大体相同,不同之处主要在于写页面高速缓存时,稍微麻烦一些,因为写操作不象读操作那样必须和用户空间同步[6]执行,所以用户写操作更新了数据内容后往往先存先存储在页高速缓存中,然后等页回写后台例程bdflush和kupdate[7]等来完成写如磁盘的工作。当然写入请求处理还是要通过上面提到的submit_bh函数[8]进行I/O处理的。下面简要介绍写过程:
page = __grab_cache_page(mapping,index,&cached_page,&lru_pvec);
status = a_ops->prepare_write(file,page,offset,offset+bytes);
page_fault = filemap_copy_from_user(page,offset,buf,bytes);
status = a_ops->commit_write(file,page,offset,offset+bytes);
首先,在页高速缓存中搜索需要的页,如果需要的页不在高速缓存中,那么内核在高速缓存中新分配一空闲项;下一步,prepare_write()方法被调用,为页分配异步缓冲区首部;接着数据被从用户空间拷贝到了内核缓冲;最后通过commit_write()函数将对应的函数把基础缓冲区标记为脏,以便随后它们被页回写例程写回到磁盘。
好累呀,到此总算把文件读写过程顺了一便,大家明白了上述概念后,我门进入最后一部分: Romfs事例分析。
Romfs文件系统是如何实现的
Romfs是基于块的只读文件系统,它使用块(或扇区)访问存储设备驱动(比如磁盘,CD,ROM盘驱动)。由于它小型、轻量,所以常常用在嵌入系统和系统引导时。这种文件系统结构简单,实现容易,我们下面就分析一下它的实现方法,为读者勾勒出编写新文件系统的思路和步骤。希望能为大家自己设计文件系统起到抛砖引玉的效果。
Romfs文件系统布局与文件结构
文件系统简单理解就是数据的分层存储结构,数据由文件组织,而文件又由文件系统安排存储形式,所以首先必须设计信息和文件组织形式——也就是这里所说的文件布局。
Romfs是种很简单的文件系统,它的文件布局和Ext2等文件系统相比要简单的得多。在Linux内核源代码种得Document/fs/romfs中介绍了romfs文件系统得布局和文件结构。现面我们简要说明一下它们。
0 |
文件系统名称 |
文件系统大小 |
检验合(前512字节) |
8 |
12 |
16 |
卷名 |
第一个文件头 |
图 5 romfs文件系统布局
上图是romfs布局图,可以看到文件系统中每部分信息都是16位对其的,也就是说存储的偏移量必须最后4位为0,这样作是为了提高访问速度。随意如果信息不足时,需要填充0以保证所有信息的开始位置都为16为对其[9]。
文件系统的开始8个字节存储文件系统的ASCII形式的名称,比如“romfs”;接着4个字节记录文件大小;然后的4个字节存储的是文件系统开始处512字节的检验和;接下来是卷名;最后是第一个文件的文件头,从这里开始依次存储的信息就是文件本身了。
Romfs的文件结构也非常简单,我们看下图
文件 |
0 |
下一个文件头的偏移 |
文件类型信息 |
文件大小 |
检验和 |
文件 |
4 |
8 |
12 |
16 |
图 6 romfs文件布局
具体需要实现的对象
编写新文件系统自己需要一些基本对象[10]:文件系统类型对象;文件对象;索引节点对象;高速缓存对象;超级块对象。这些文件对象前面已经介绍过了,要强调的是,多数对象中的数据和操作都已经定义或有通用实现,所以最需要自己定义的往往是针对具体文件系统中对象的操作函数。
Romfs文件系统定义针对文件系统布局和文件结构定义了一个磁盘超级块结构和磁盘inode(对应于文件)结构:
struct romfs_super_block {
__u32 word0;
__u32 word1;
__u32 size;
__u32 checksum;
char name[0];
};
struct romfs_inode {
__u32 next;
__u32 spec;
__u32 size;
__u32 checksum;
char name[0];
};
上述两种结构分别描述了文件系统结构与文件结构,它们将在内核装配超级块对象和索引节点对象时被使用。
Romfs文件系统首先要定义的对象是文件系统类型romfs_fs_type。定义该对象同时还要定义读取超级块的函数romfs_read_super。
ramfs_read_super()作用是从磁盘读取磁盘超级块给超级块对象,具体行为如下
1 装配超级块。
1.1 初始化超级块对象某些域。
1.2 从设备中读取磁盘第0块到内存到内存 bread(dev,0,ROMBSIZE),其中dev是文件系统安装时指定的设备,0指设备的首块,也就是磁盘超级块,ROMBSIZE是读取的大小。
1.3 检验磁盘超级块中的校验和
1.4 继续初始化超级块对象某些域
2 给超级块对象的操作表赋值(s->s_op = &romfs_ops)
3 为根目录分配目录项 s->s_root = d_alloc_root(iget(s,sz), sz为文件系统开始偏移。
超级块操作表中romfs文件系统实现了两个函数
static struct super_operations romfs_ops = {
read_inode: romfs_read_inode,
statfs: romfs_statfs,
};
第一个函数read_inode(inode)是用磁盘上的数据填充参数指定的索引节点对象的域;索引节点对象的i_ino域标识从磁盘上要读取的具体文件系统的索引节点。
1 根据inode参数寻找对应的索引节点。
2 初始化索引节点某些域
3 根据文件的访问权限(类别)设置索引节点的相应操作表
3.1 如果是目录文件则将索引节点表设为i->i_op = &romfs_dir_inode_operations;文件操作表设置为->i_fop = &romfs_dir_operations; 如果索引节点对应目录的话,那么需要的操作仅仅会是lookup操作(因为romfs是个功能很有限的文件系统);对于文件操作表中的两个方法一个为read,另一个为readdir。前者利用通用函数generic_read_dir,返回用户错误消息。后者是针对readdir/getdents等系统调用实现的返回目录中文件的函数
3.2 如果是常规文件,则将文件操作表设置为i->i_fop = &generic_ro_fops;
将页高诉缓存表设置为i->i_data.a_ops = &romfs_aops;由于romfs是只读文件系统,它在对正规文件操作时不需要索引节点操作,如mknod,link等,因此不用给管索引节点操作表。
对常规文件的操作也只需要使用内核提供的通用函数表struct generic_ro_fops ,它包含基本的三种常规文件操作:
llseek: generic_file_llseek,
read: generic_file_read,
mmap: generic_file_mmap,
利用这几种通用函数,完全能够满足romfs文件系统的的文件操作要求,具体函数请自己阅读源代码。
回忆前面我们提到过的页高速缓存,显然常规文件访问需要经过它,因此有必要实现页高诉缓存操作。因为只需要读文件,所以只用实现romfs_readpage函数,这里readpage函数使用辅助函数romfs_copyfrom完成将数据从设备读入页高速缓存,该函数根据文件格式从设备读取需要的数据。设备读取操作需要使用bread块I/O例程,它的作用是从设备读取指定的块[11]。
3.3 如果是连接文件,则将索引节点操作表设置为:i->i_op=&page_symlink_inode_operations;
将页高速缓存操作表设置为:
i->i_data.a_ops = &romfs_aops;
符号连接文件需要使用通用符号连接操作page_symlink_inode_operations实现,同时也需要使用页高速缓存方法。
3.4 如果是套接字或管道则,进行特殊文件初始化操作init_special_inode(i, ino, nextfh);
到此,我们已经遍例了romfs文件系统使用的几种对象结构:romfs_super_block、romfs_inode、romfs_fs_type、super_operations romfs_ops、address_space_operations romfs_aops 、file_operations romfs_dir_operations、inode_operations romfs_dir_inode_operations 。实现上述对象是设计一个新文件系统的最低要求。
最后要说明的是为了使得romfs文件系统作为模块挂载,需要实现static int __init init_romfs_fs(void)
{
return register_filesystem(&romfs_fs_type);
}
和
static void __exit exit_romfs_fs(void)
{
unregister_filesystem(&romfs_fs_type);
}
两个在安装romfs文件系统模块时使用的例程。
安装和卸载
module_init(init_romfs_fs)
module_exit(exit_romfs_fs)
到此,romfs文件系统的关键结构都已介绍,至于细节还是需要读者仔细推敲。Romfs是最简单的基于块的只读文件系统,而且没有访问控制等功能。所以很多访问权限以及写操作相关的方法都不必去实现。
总结:实现文件系统必须想上要清楚文件系统和系统调用的关系,想下要了解文件系统和I/O调度、设备驱动等的联系。另外还必须了解关于缓存、进程、磁盘格式等概念。在这里并没有对这些问题进行是深入分析,仅仅提供给大家一个文件系统全景图,希望能对自己设计文件系统有所帮助。文件系统内容庞大、复杂,许多问题我也比较含混,有文件系统经验的朋友希望能够广泛交流。
[1] 无论读文件或写文件,文件中的数据都是必须经过内存中的页高速缓存做中间存储才能够被使用。高速缓存由一个叫做address_space的特殊数据结构表示,其中含有对页高速缓存宿主(address_space->host)的操作表。
[2] 这期间要要处理一些预读,以此提高未来访问的速度。
[3] 缓冲与相应的块一一对应,它的作用相当于是磁盘块在内存中的表示。
[4] tq_disk是专门负责磁盘请求的任务队列,任务队列是用来推后异步执行的一种机制。2.6内核中已经用工作队列代替了工作队列。
[5] 块设备驱动程序可以划分为两部分:底级驱动程序(blk_dev_struct)和高级设备驱动(block_device)。底级设备驱动程序作用是记录每个高级驱动程序送来的请求组成的队列。
[6] 文件读区操作必须同步进行,在读取的数据返回前,工作无法继续进行。而且如果结果在30秒内回不来,则用户必需将无法忍受,所以读操作执行紧迫。而对于写操作,则可以异步执行,因为写入操作一般不会影响下一步的执行,所以紧迫性也低。
[7] bdflush和kupdate 分别是当空闲内存过低时释放脏页和当脏缓冲区在内存中存在时间过长时刷新磁盘的。而在2.6内核中,这两个函数的功能已经被pdflush统一完成。
[8] 实际是从block_read_full_page( )函数中调用submit_bh()函数的。
[9] 创建romfs文件系统可使用genromfs格式化工具。
[10] 对象指的是内存中的结构体实例,而不是物理上的存储结构。
[11] 索引节点结构描述了从物理块(块设备上的存取单位,是每次I/O操作最小传输的数据大小)到逻辑块(文件实际操作基本单元)的映射关系。