前言
《Linux内核完全注释》中对文件系统进行了详细地解析,从文件系统组织结构到代码实现都很好理解。本篇对该块内容进行简单梳理,并通过跟踪open系统调用理解文件系统与读写文件的关系
文件系统
基本概念
通俗地说,文件系统用来辅佐内核与设备进行交互,如读写文件,显示字符等操作。就好比作为一个图书馆,需要设计目录,分区来方便用户查询或放置图书。以往块设备打开并写入一个文件这一过程为例,我们看看内核应该怎么完成
块设备分块
。首先,块设备对于CPU而言也是一块地址空间,如划分内存物理页一样,应该对块设备存储空间进行分块。如,1k为一块设置块位图
。内存管理中,通过mem_map表示当前物理内存物理页使用情况。同样地,对于块设备的每一块的使用情况也应设计位图进行表示设置i节点
。在物理内存中,通过连续地址空间存储一个结构体,由于地址是连续的,内核通过起始地址便能索引到该结构体。而块设备如果要存储文件,一个文件有大有小,仍使用连续块进行分配会造成空间浪费。因此,可以通过设计i节点,每个文件对应一个i节点,i节点中存储了该文件所使用的块索引。对于文件,内核只需知道i节点地址即可。同时,i节点应该存在内存和块设备中。设置超级块
。当块设备更换挂载的内核时,如何让内核快速知道块设备目前情况呢?最好的办法就是设计一个总结构体,其中存放如i节点数量,块数量等信息。该结构体我们成为超级块。设置高速缓冲区
。上述四步将块设备结构化后,内核已经可以对其进行寻址和访问。但如CPU与磁盘的IO与CPU执行速度相差巨大,会影响用户体验,并且对于一些文件可能会重复使用,理应单次读取多次使用。为此,需要在物理内存中设计一块高速缓冲区。当内核想写文件时,将内容写入高速缓冲区即刻返回。后续交给块设备驱动将缓冲区内容同步至块设备中。该过程其实就是异步,缓冲区相当于一个消息队列。实现同步
。上述提到,文件内容写入高速缓冲区即刻返回。那怎么将缓冲区内容写回块设备呢?这就是同步刷盘。需要设置程序将缓冲区内容写回块设备,或者读入缓冲区。设计消息队列
。由于块设备也属于共享资源,不能允许多个程序同时刷盘。因此需要对刷盘请求进行排队。并且,由于写入块设备时,如磁盘寻址是有顺序的,因此必须对刷盘请求进行排序,尽可能地让磁盘一次寻址过程能够同步尽可能多的数据
上述我们简述了完成一个文件系统大致要完成的事情。进一步地我们看看代码实际是如何实现的
MINIX文件系统
MINIX 文件系统与标准 UNIX 的文件系统基本相同,它由 6 个部分组成:①引导块;②超级块;③i 节点位图;④逻辑块位图;⑤i 节点;⑥数据块。对于一个普通的磁盘块设备来说,其各部分的分布见图 所示
超级块用于存放盘设备上文件系统的结构信息,并说明各部分的大小。其结构见图 12-3 所示。其中,s_ninodes 表示设备上的 i 节点总数。s_nzones 表示设备上以逻辑块为单位的总逻辑块数。s_imap_blocks和 s_zmap_blocks 分别表示 i 节点位图和逻辑块位图所占用的磁盘块数s_firstdatazone 表示设备上数据区开始处占用的第一个逻辑块块号。s_log_zone_size 是使用 2 为底的对数表示的每个逻辑块包含的磁盘块数。对于 MINIX 1.0 文件系统该值为 0,因此其逻辑块的大小就等于磁盘块大小,都是 1KB。s_max_size是以字节表示的最大文件长度,这里不超过 4GB。当然这个长度值将受到磁盘容量的限制。s_magic 是文件系统魔幻数,用以指明文件系统的类型。对于 MINIX 1.0 文件系统,它的魔幻数是 0x137f。
----《Linux内核完全注释》12.1总体功能
上述介绍为书中内容,对应基本概念中的前4点。具体地,超级块与inode节点分别定义如下
linux/fs.h
struct m_inode {
unsigned short i_mode;
unsigned short i_uid;
unsigned long i_size;
unsigned long i_mtime;
unsigned char i_gid;
unsigned char i_nlinks;
unsigned short i_zone[9];
/* these are in memory also */
struct task_struct * i_wait;
struct task_struct * i_wait2; /* for pipes */
unsigned long i_atime;
unsigned long i_ctime;
unsigned short i_dev;
unsigned short i_num;
unsigned short i_count;
unsigned char i_lock;
unsigned char i_dirt;
unsigned char i_pipe;
unsigned char i_mount;
unsigned char i_seek;
unsigned char i_update;
};
struct super_block {
unsigned short s_ninodes;
unsigned short s_nzones;
unsigned short s_imap_blocks;
unsigned short s_zmap_blocks;
unsigned short s_firstdatazone;
unsigned short s_log_zone_size;
unsigned long s_max_size;
unsigned short s_magic;
/* These are only in memory */
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;
};
因此,当挂载一个块设备后,可以通过读取其超级块中存储的内容,快速获取当前块设备统计信息。这里需要注意的是,超级块中关于i节点个数,以及逻辑数据块的块下标等信息都是在创建文件系统时给定的。内核在执行写入等操作时,要保证不越界。
另一方面,可以看到示意图中,i节点直接存储于一整块逻辑块中。实际上不是的,存储于块设备中的i节点内容,只有32个字节,因此一个逻辑块可以存储32个i节点。因此块设备中第n个i节点地址应该为m+n*32,m为当前块地址
根据i节点中zone数组的内容,可以找到i节点表示文件的具体内容。书上很详细,就不展开了,如下图所示。以及关于目录项、目录、寻址等内容也不是本篇重点,可自行阅读书中章节。
高速缓冲区
这是基本概念第5点。这部分书中讲的也很好,需理解缓冲块与块设备逻辑块的一一对应关系。并且write,read等系统调用,都是与缓冲块有关,不用考虑同步的事情。
具体地,应该怎么实现read?
- read作为系统调用,根据待访问设备类型,调用具体函数
(ps:这部分表述较困难,注意点不多,看书即可,不往下记了)
实现同步
负责同步的函数如下,主要通过ll_rw_block
将bh对应缓冲块内容同步进目标设备中
int sys_sync(void)
{
int i;
struct buffer_head * bh;
sync_inodes(); /* write out inodes into buffers */
bh = start_buffer;
for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
wait_on_buffer(bh);
if (bh->b_dirt)
ll_rw_block(WRITE,bh);
}
return 0;
}
int sync_dev(int dev)
{
int i;
struct buffer_head * bh;
bh = start_buffer;
for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
if (bh->b_dev != dev)
continue;
wait_on_buffer(bh);
if (bh->b_dev == dev && bh->b_dirt)
ll_rw_block(WRITE,bh);
}
sync_inodes();
bh = start_buffer;
for (i=0 ; i<NR_BUFFERS ; i++,bh++) {
if (bh->b_dev != dev)
continue;
wait_on_buffer(bh);
if (bh->b_dev == dev && bh->b_dirt)
ll_rw_block(WRITE,bh);
}
return 0;
}
需要注意的是,两个函数被调用的次数并不多。只出现在panic报错时,或者iput
中。因此,有input的地方就意味着要刷盘
设计消息队列
对于每个刷盘或者读盘需求,都会封装成一个请求,加入队列中,等待对应设备控制器执行。这部分在9.1章节
write过程
我们根据write执行流程看看内核与文件系统如何交互的。
1 . 首先从系统调用sys_write开始 fs/read_write.c
int sys_write(unsigned int fd,char * buf,int count)
{
struct file * file;
struct m_inode * inode;
if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
return -EINVAL;
if (!count)
return 0;
inode=file->f_inode;
if (inode->i_pipe)
return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
if (S_ISCHR(inode->i_mode))
return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
if (S_ISBLK(inode->i_mode))
return block_write(inode->i_zone[0],&file->f_pos,buf,count);
if (S_ISREG(inode->i_mode))
return file_write(inode,file,buf,count);
printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
sys_write
根据i_mode
,判断执行哪种设备访问。我们以file_write
为例进一步跟踪
fs/file_dev.c
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
off_t pos;
int block,c;
struct buffer_head * bh;
char * p;
int i=0;
/*
* ok, append may not work when many processes are writing at the same time
* but so what. That way leads to madness anyway.
*/
if (filp->f_flags & O_APPEND)
pos = inode->i_size;
else
pos = filp->f_pos;
while (i<count) {
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
if (!(bh=bread(inode->i_dev,block)))
break;
c = pos % BLOCK_SIZE;
p = c + bh->b_data;
bh->b_dirt = 1;
c = BLOCK_SIZE-c;
if (c > count-i) c = count-i;
pos += c;
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
i += c;
while (c-->0)
*(p++) = get_fs_byte(buf++);
brelse(bh);
}
inode->i_mtime = CURRENT_TIME;
if (!(filp->f_flags & O_APPEND)) {
filp->f_pos = pos;
inode->i_ctime = CURRENT_TIME;
}
return (i?i:-1);
}
简单地说,就是找到缓冲块,往里写入用户缓冲buf中的内容。此处操作都是针对缓冲块的。没有刷盘
写完后调用sys_close
关闭文件,其中调用了iput
int sys_close(unsigned int fd)
{
struct file * filp;
if (fd >= NR_OPEN)
return -EINVAL;
current->close_on_exec &= ~(1<<fd);
if (!(filp = current->filp[fd]))
return -EINVAL;
current->filp[fd] = NULL;
if (filp->f_count == 0)
panic("Close: file count is 0");
if (--filp->f_count)
return (0);
iput(filp->f_inode);
return (0);
}
关系梳理
进程 task_struct 中有 flip 结构体数组,用于存放该进程已打开的 file 结构体。每个 file 与 一个 inode 进行关联。
文件系统中有超级快 super_block,用于标识文件系统信息。可由 super_block 定位到根目录的 dentry 目录项,根据目录项可以通过文件名找到对应目标的 i 节点号,从而获得其 inode。
打开一个文件时。进程需要判断 flip 是否已满,未满才可新建一个 file 结构体。根据 pathname 获得目标的 inode,将其与 file 进行关联。
open
通过分析 open 系统调用执行流程,梳理上述每个结构体之间的相互关系。首先,open 标准库函数会进一步进行 sys_open 的系统调用。
- 遍历 flip 找到空的 file 对应的 fd 文件描述符
- 遍历 file_table 文件结构体缓存,获取空的 file 文件结构体,f
- 通过 open_namei ,根据 filename 获取目标的 inode 结构体。在该函数中,会根据 mode 对 filename 对应目标执行相应的操作。如果 mode 为创建 CREATE。则会为 filename 目标在指定的位置创建目标项以及申请 inode。
- 当 inode 申请成功,将其放入 f 的 f_inode 属性中。返回 fd
- 因此,需要写入内容时,只需要通过 fd 而无需 filename ,可以通过 flip[fd] 获取到对应目标的 file->inode 进行相关操作。
int sys_open(const char * filename,int flag,int mode)
{
struct m_inode * inode;
struct file * f;
int i,fd;
mode &= 0777 & ~current->umask;
for(fd=0 ; fd<NR_OPEN ; fd++)
if (!current->filp[fd])
break;
if (fd>=NR_OPEN)
return -EINVAL;
current->close_on_exec &= ~(1<<fd);
f=0+file_table;
for (i=0 ; i<NR_FILE ; i++,f++)
if (!f->f_count) break;
if (i>=NR_FILE)
return -EINVAL;
(current->filp[fd]=f)->f_count++;
if ((i=open_namei(filename,flag,mode,&inode))<0) {
current->filp[fd]=NULL;
f->f_count=0;
return i;
}
/* ttys are somewhat special (ttyxx major==4, tty major==5) */
if (S_ISCHR(inode->i_mode))
if (check_char_dev(inode,inode->i_zone[0],flag)) {
iput(inode);
current->filp[fd]=NULL;
f->f_count=0;
return -EAGAIN;
}
/* Likewise with block-devices: check for floppy_change */
if (S_ISBLK(inode->i_mode))
check_disk_change(inode->i_zone[0]);
f->f_mode = inode->i_mode;
f->f_flags = flag;
f->f_count = 1;
f->f_inode = inode;
f->f_pos = 0;
return (fd);
}
如上所述,sys_open 中主要执行流程为 open_namei ,为 filename 对应目标获得相应 inode。进一步地,看看 open_namei 具体做了什么
- 首先,通过 dir_namei 获取 pathname 最接近目标的目录文件 inode。例如 pathname 为 /user/etc/ssh ,会获取到 etc 这一目录文件 inode。以及 basename 为 ssh,namelen 为 3。
- 经由 find_entry ,通过目录文件 inode 寻找 etc 这一目录文件下的目录项,并判断其与 basename 的关系,返回 bh 高速缓存。例如,etc/ 下可能有两个文件 ssh 与 aab,那么就会包括 {name: ssh,inode_n:1} 和 {name:aab,inode_n:2}这两个 dentry。如果 mode 为CREATE ,并且不存在 {name: ssh,inode_n:1} ,则需要为其创建对应 inode 和为其添加目录项。
- 因此,如果 bh 为空,说明 find_entry 匹配不到 basename 。因此要通过 new_inode 创建一个 inode。并由 add_entry 为其添加目录项。
- 此时新建的 inode 与 bh 新建目录项缓存没有建立关系,需要为其建立关系,即通过 bh 修改目录项为 {name:ssh,inode_n:x}。对应
de->inode = inode->i_num;bh->b_dirt = 1;
- 由于 bh 是缓存,还需要调用 brelse 进行同步,以便于将 bh 内容刷入块设备磁盘中。
int open_namei(const char * pathname, int flag, int mode,
struct m_inode ** res_inode)
{
const char * basename;
int inr,dev,namelen;
struct m_inode * dir, *inode;
struct buffer_head * bh;
struct dir_entry * de;
if ((flag & O_TRUNC) && !(flag & O_ACCMODE))
flag |= O_WRONLY;
mode &= 0777 & ~current->umask;
mode |= I_REGULAR;
if (!(dir = dir_namei(pathname,&namelen,&basename,NULL)))
return -ENOENT;
if (!namelen) { /* special case: '/usr/' etc */
if (!(flag & (O_ACCMODE|O_CREAT|O_TRUNC))) {
*res_inode=dir;
return 0;
}
iput(dir);
return -EISDIR;
}
bh = find_entry(&dir,basename,namelen,&de);
if (!bh) {
if (!(flag & O_CREAT)) {
iput(dir);
return -ENOENT;
}
if (!permission(dir,MAY_WRITE)) {
iput(dir);
return -EACCES;
}
inode = new_inode(dir->i_dev);
if (!inode) {
iput(dir);
return -ENOSPC;
}
inode->i_uid = current->euid;
inode->i_mode = mode;
inode->i_dirt = 1;
bh = add_entry(dir,basename,namelen,&de);
if (!bh) {
inode->i_nlinks--;
iput(inode);
iput(dir);
return -ENOSPC;
}
de->inode = inode->i_num;
bh->b_dirt = 1;
brelse(bh);
iput(dir);
*res_inode = inode;
return 0;
}
inr = de->inode;
dev = dir->i_dev;
brelse(bh);
if (flag & O_EXCL) {
iput(dir);
return -EEXIST;
}
if (!(inode = follow_link(dir,iget(dev,inr))))
return -EACCES;
if ((S_ISDIR(inode->i_mode) && (flag & O_ACCMODE)) ||
!permission(inode,ACC_MODE(flag))) {
iput(inode);
return -EPERM;
}
inode->i_atime = CURRENT_TIME;
if (flag & O_TRUNC)
truncate(inode);
*res_inode = inode;
return 0;
}
理解上述过程的关键在于,bh 高速缓存应该怎么用。对此再梳理一下。inode 中只通过 i_zone 记录具体文件内容所在盘块号。如果要查询具体盘块号内容,必须先为其申请一个 bh 高速缓存,并将盘块内容读入 bh 中。这样才能通过 bh 间接访问具体盘块内容。当需要修改文件内容时,应先修改文件内容对应 bh ,将其刷盘,同步到磁盘中才能完成修改。
因此在 open_namei 中。通过 dir_namei 获取到了 etc 目录文件的 inode,但不知道里面具体的内容,因此需要通过 find_entry 获取其具体内容,在 bh 中显示。此时目录项内容就在 bh 中,因此可以在 bh 中进行 basename 的匹配,直到找出 basename 对应的 inode。如果找不到 inode 则需要创建,并且还要修改目录项。
同理。新建 inode 时也需要考虑同步的问题。首先, inode 在内存中有记录,同时块设备内部也有记录。因此,新建的过程应该是,先从内存的 inode_table 缓存中申请一个空白的 inode ,通过 bh 修改超级块中 inode 的位图信息。刷新 bh 后,块设备的超级块信息被同步,由于 inode 位图信息已经被更新,因此文件系统能够感知到 inode 被分配出去了。
write
经过 open 的分析,我们可以大致理解 inode 如何被创建,并绑定于进程上。那么当需要修改文件内容时,可以通过进程获取到 inode ,并根据 inode 中 i_zone 指向的块设备号,根据其建立一个 bh,修改 bh 并同步它,则能够实现对具体块设备磁盘内容的修改
可以看到 sys_write 会根据 i_mode 类型调用不同的 write 实现。具体地,跟踪一下 file_write
int sys_write(unsigned int fd,char * buf,int count)
{
struct file * file;
struct m_inode * inode;
if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
return -EINVAL;
if (!count)
return 0;
inode=file->f_inode;
if (inode->i_pipe)
return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
if (S_ISCHR(inode->i_mode))
return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
if (S_ISBLK(inode->i_mode))
return block_write(inode->i_zone[0],&file->f_pos,buf,count);
if (S_ISREG(inode->i_mode))
return file_write(inode,file,buf,count);
printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
pos 为 file 中属性。我们可以知道,之所以每个进程访问同一个文件的游标不同,是因为每个进程使用了不同的 file ,并且 pos 不同。但 file 对应的是同一个 inode。
- 如前所述,通过 bread 可以根据 inode 和 block 号创建一个 bh ,用来映射具体地磁盘文件内容。
- 将 p 指向 bh 的数据域。通过 get_fs_byte 将 buf 内容写入 bh 数据域中。
- 调用 brelse 将 bh 内容同步至磁盘中。
int file_write(struct m_inode * inode, struct file * filp, char * buf, int count)
{
off_t pos;
int block,c;
struct buffer_head * bh;
char * p;
int i=0;
/*
* ok, append may not work when many processes are writing at the same time
* but so what. That way leads to madness anyway.
*/
if (filp->f_flags & O_APPEND)
pos = inode->i_size;
else
pos = filp->f_pos;
while (i<count) {
if (!(block = create_block(inode,pos/BLOCK_SIZE)))
break;
if (!(bh=bread(inode->i_dev,block)))
break;
c = pos % BLOCK_SIZE;
p = c + bh->b_data;
bh->b_dirt = 1;
c = BLOCK_SIZE-c;
if (c > count-i) c = count-i;
pos += c;
if (pos > inode->i_size) {
inode->i_size = pos;
inode->i_dirt = 1;
}
i += c;
while (c-->0)
*(p++) = get_fs_byte(buf++);
brelse(bh);
}
inode->i_mtime = CURRENT_TIME;
if (!(filp->f_flags & O_APPEND)) {
filp->f_pos = pos;
inode->i_ctime = CURRENT_TIME;
}
return (i?i:-1);
}
小结
初始版本的文件系统实现比较简单,由于内存过小,因此基本没有考虑使用缓存。在后续版本的改进中,随着内存的增加,缓存的使用,数据结构变得更复杂,例如 dentry 等。而 inode , super_block 等结构也作为了