本文作者:
康华:计算机硕士,主要从事Linux操作系统内核、Linux技术标准、计算机安全、软件测试等领域的研究与开发工作,现就职于信息产业部软件与集成电路促进中心所属的MII-HP Linux软件实验室。如果需要可以联系通过kanghua151@msn.com联系他。
< 续.. >
要自己创建文件系统必须知道文件系统需要那些操作,各种操作的功能范围,所以我们下面内容就是分析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给出的逻辑流程。
![]()
![]()


图 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是基于块的只读文件系统,它使用块(或扇区)访问存储设备驱动(比如磁盘,CD,ROM盘驱动)。由于它小型、轻量,所以常常用在嵌入系统和系统引导时。这种文件系统结构简单,实现容易,我们下面就分析一下它的实现方法,为读者勾勒出编写新文件系统的思路和步骤。希望能为大家自己设计文件系统起到抛砖引玉的效果。
文件系统简单理解就是数据的分层存储结构,数据由文件组织,而文件又由文件系统安排存储形式,所以首先必须设计信息和文件组织形式——也就是这里所说的文件布局。
Romfs是种很简单的文件系统,它的文件布局和Ext2等文件系统相比要简单的得多。在Linux内核源代码种得Document/fs/romfs中介绍了romfs文件系统得布局和文件结构。现面我们简要说明一下它们。

图 5 romfs文件系统布局
上图是romfs布局图,可以看到文件系统中每部分信息都是16位对其的,也就是说存储的偏移量必须最后4位为0,这样作是为了提高访问速度。随意如果信息不足时,需要填充0以保证所有信息的开始位置都为16为对其[9]。
文件系统的开始8个字节存储文件系统的ASCII形式的名称,比如“romfs”;接着4个字节记录文件大小;然后的4个字节存储的是文件系统开始处512字节的检验和;接下来是卷名;最后是第一个文件的文件头,从这里开始依次存储的信息就是文件本身了。
Romfs的文件结构也非常简单,我们看下图

![]()
图 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: