Linux 0.12源码阅读之文件系统

| 分类 linux  | 标签 linux 

1.minix 1.0 文件系统

在minix1.0中,整个磁盘线性的按照1K大小分成一个一个块,每个块按照不同的作用可以分为统计数据,索引数据和实际数据等。

1.1.数据结构

  • 引导块:只有作为引导盘的硬盘分区才需要引导块。一般的数据分区引导块内容会空着。
  • 超级快: 描述了当前文件分区中的统计数据,例如当前的inode map个数等等。
  • i节点位图(imap): inode的索引数据,每个bit代表一个inode,imap最多8块,所以支持的inode最多为1024*8*8=64K,由于一个inode节点数据占用的字节为32byte,所以最多可以支持的inode块数为为1024*8*8*32/1024=2K.这里为每一个i节点编号, 号码从1开始,所以最低为的i节点位图不用(初始化为1),而对应的i节点32字节数据也被初始化为0.
  • 逻辑块位图(zmap): 数据块的索引数据,每个bit代表一个数据块,zmap最多8块,所以支持的数据块最多为1024*8*8=64K,支持最大空间为64K*1K=64M。zmap索引数据记录了对于block数据是否可用(被占用)。这里为每一个逻辑块编号, 号码从1开始,所以最低位位图不用(初始化为1)。逻辑号1表示第一个数据区数据,而不是盘上的第一个磁盘块-引导块。数据区的第一个数据块是从编号1开始,而不是0.
  • i节点(inode): 记录了每个inode的元数据,例如文件属性,用户属主等,每个inode数据表示一个目录(如/ /usr /usr/local /usr/local/bin等)或者一个文件(如/usr/local/bin/java.exe)。
    //inode数据结构,大小为32字节。和imap成线性映射,如第一个imap块支持8192个inode,
    //对应的inode1~inode8191按照线性关系存储在inode区。
    struct m_inode {
        unsigned short i_mode;
        unsigned short i_uid;
        unsigned long i_size;  //记录该节点包含的儿子节点个数(该节点是一个dir节点)
        unsigned long i_mtime;
        unsigned char i_gid;
        unsigned char i_nlinks;
        unsigned short i_zone[9];  //实际数据所在的数据块磁盘块号
        //取值范围为s_firstdatazone~s_firstdatazone+8192*8-1。
        //如果是<7则用直接块号,否则使用间接,二次间接块号
    }
  • 数据区(data): 如果一个inode是dir,那么从i_zone指过来的数据块保存的是该dir下面包含的dir_entry,例如/usr/local/bin保留了., ..和java.exe三个文件。
    #define NAME_LEN 14    //名字长度为14字节,这里的名字指的是usr,local等
    #define ROOT_INO 1     //根节点号

    struct dir_entry {
        unsigned short inode;   //dir对应的i节点号,1~8192*8-1
        char name[NAME_LEN];    //该dir_entry对应的名字如usr, local等
    };

如果inode是一个文件,那么从i_zone指过来的数据块保存的是文件内容。这里i_zone[9]里记录的是对应inode的磁盘块号,而不是逻辑块号nr。这两个之间有一个s_firstdatazone的差,即减去磁盘分区上的前几个块(引导块/超级快/i节点位图/逻辑块位图/i节点)。在超级块中s_firstdatazone记录了第一个数据块的磁盘号。所以,逻辑号和磁盘号之间有关系

block = nr + s_firstdatazone -1

1.2.从一个文件名寻找inode

这里的文件名形如/usr或者/bin/java.exe,目标是在磁盘文件系统上找到对应的inode信息。

首先从根root /代表的inode开始(inode 1)找到/对应的i_zone,因为是/是一个dir,所以通过i_zone可以找到目录块内容,查到下面有/bin, /var, /usr等几个dir,找到/usr对应的inode,再从/usr代表的inode找到/usr下面对应的目录如/usr/local, /usr/bin等几个dir,找到/usr/bin对应的inode。在根据/usr/bin的inode找到其下对应的儿子节点,如果有java.exe则找到/usr/bin/java.exe对应的inode。

1.3.创建一个文件

目标是创建一个新文件wc.exe到/usr/local/bin下面

  • 1)遍历zmap块找到可用使用的data block并置为1;
  • 2)通过buffer将wc.exe写到data block中;
  • 3)遍历imap块找到可用使用的inode并置1;
  • 4)将wc.exe文件的数据如i_mode, mtime, i_uid等写入该inode;
  • 5)找到其父亲节点/usr/local/bin的inode,随后找到对应的目录块数据,在目录块数据中加入新的一个dir entry数据,其中name为wc.exe,inode为wc.exe对应的inode号。这样该文件就关联到父亲节点上。

2.高速缓存

2.1.作用

高速缓存的作用是为了加速数据的读写,如果数据已经在高速缓存区,就不需要去读磁盘;当对数据进行写操作时,也可以先写到高速缓存区,然后由高速缓存模块统一写到硬盘上。高速缓存区分布从1M-4M。以1024为1个block,具体数目见buffer_init()函数。高速缓存区是以inode做为抽象层。

从用户进程来说,有两处数据需要用到高速缓存区。

  • 用户进程本身的可执行代码文件(如/usr/bin/java)
  • 用户进程通过系统调用write, read等从磁盘上读写指定文件。

对于第一种情况,首先是用户进程在fork之后通过exec调用,加载对应的可执行程序文件的inode,这时候根据文件名查找到inode信息,并对进程的线性地址空间进行分配,读取该inode对应的数据(参见进程调度一文关于exec的解释)。这个时候具体文件的内容还不会加载,只有到执行对应的代码,而代码对应的线性地址空间并不在内存中(查找页目录表和页表可知是否在内存中)时,缺页中断程序执行,从而会加载对应的可执行文件内容到高速缓存区中。

//程序执行exec
int do_execve(unsigned long * eip,long tmp,char * filename,
    char ** argv, char ** envp){
    ...
    if (!(inode=namei(filename)))       /* get executables inode */
        return -ENOENT;
    ...
    //读出inode的开头4K数据
    if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {
        retval = -EACCES;
        goto exec_error2;
    }
    ...
}


//缺页处理, address为输入的线性地址
void do_no_page(unsigned long error_code,unsigned long address){
    ...
    //第一个block空出来不用,所以block要从1开始算起
    for (i=0 ; i<4 ; block++,i++)
        nr[i] = bmap(inode,block);
    //读出一页数据
    bread_page(page,inode->i_dev,nr);
    ...
}

对于第二种情况,系统调用read, write会调用bread等操作完成inode的读写。

2.2.与其他模块的交互

fs其他函数(do_exec,file_read, file_write)—> buffer bread( ) —> block dev ll_rw_block( ) —>硬盘控制器 —>硬盘

fs其他函数如do_exec, file_read等通过调用bread( int dev,int block)来读取block逻辑块的硬盘数据。 bread( )函数通过向block设备申请读入数据接口ll_rw_block( )来完成数据读写。 block设备具体通过和硬盘控制器发送指令读写数据。

2.3.实现

实现方面,使用了一个hash queue保存已经读到内存中的一个单位的blockd数据,hash queue一共307个主键,每个主键为dev^block_nr % 307。当hash_queue不止一个元素时组织成一个链表,另外使用了一个双向链表free_list保存所有可以使用的block。

初始化时,双向链表以free_list为头,hash table 307个键值对初始化为空。

申请数据时调用bread( )函数。

该函数首先根据请求的设备号和block_nr从hash queue里按主键值查找,如果发现已经数据已经在则直接返回;否则从free_list双向链表里从头开始寻找可以使用的块数据,寻找的规则是count=dirt=lock=0,如果没找到则休眠当前进程等待有空余的块数据可以使用。这里的休眠是因为高速缓存区域的内存是有限制的,没办法把所有硬盘数据刷入到内存中;当某个时刻所有的可以使用的高速缓存区都被任务占用(很多任务在读写不同的数据块),新的任务必须等待。

找到一个可以使用的空闲块以后,向块设备接口申请读入该block数据,将该buffer_head 根据其dev/block_nr的值加入到对应hash queue中,并且将该块数据从free_list其他位置 移到free_list双向链表的最后一项。最后一项表示这块内存是最后可以被使用的,因为搜索可以使用的空闲块时是从free_list的头往后寻找,所以最近使用的缓存数据在高速缓存中保存的时间最长,而最后使用的缓存数据在高速缓存中保存的实际时间最短(优先会被置换掉),这样就形成了最少访问优先使用的LRU (Least Recently Used)算法。

释放数据时调用brelse( )函数。该函数首先将对应的块数据应用计数-1,并且等待lock释放。这样在bread()检索时可以任务该块数据可以被使用。

比如说,在4分访问了2个块数据,加入到高速缓存中,之后释放掉;在5分又访问了另一块数据然后释放。4分得2个块数据会首先被置换出去,而5分的数据仍然会存在高速缓存中,直到4分的两个块数据读完,第3个数据读取请求到达。

用hash queue的目的是加速数据命中时检索和读取的速度。当然,我们也可以仅仅使用free_list,通过遍历free_list双向链表,找到可以使用的(count>=1)的块数据。这时候性能会大量损失。

3.block driver

block driver(块设备)提供读写接口,块设备的角色相当于是磁盘的抽象层,block driver可见的是设备号和数据块号。 上层模块通过block driver获取数据,对应模块的不同设备号是不相同的,例如swap的设备号是0,而高速缓存的设备号是3. 高速缓存读写操作和MM的交换分区操作都是通过block driver提供的函数完成操作的。

3.1.块设备读写模块承前继后的关系

MM模块的swap从硬盘读写数据

read_swap_page(swap_nr, (char * )page) 
define read_swap_page(nr,buffer) ll_rw_page(READ,SWAP_DEV,(nr),(buffer)); 

对应关系为一个swap 页对应到8个sector(4096)

req->sector = page<<3;  //swap_page<<3 
req->nr_sectors = 8; 

swap nr 0是一个特殊的swap页,其数据的最后10bit应该为”SWAP_SPACE"

buffer模块从硬盘读写数据:

ll_rw_block(int rw, struct buffer_head * bh) 
    req->sector = bh->b_blocknr<<1; 
req->nr_sectors = 2; 

对应关系为一个block对应到2个sector (1024)

  • block 1: sector 2 3
  • block 2: sector 4 5

3.2.请求队列的竞争控制

在block device设备驱动层,每个设备对应一个请求队列,请求队列的每一项是一个数据块读写请求,根据电梯算法,将请求按照访问最优策略加入到请求队列中。这就涉及到一个问题,同时对相同设备的读写请求是如何处理的。实际上,在设备请求入队列的过程中,中断是关闭的,保持对队列的增加操作是原子性的。这里关闭中断 不仅对timer中断进行了屏蔽,更重要的是对磁盘控制器的中断请求进行了屏蔽。而后者,是需要访问当前请求队列的。实际上,timer中断如果不屏蔽,也只是对当前add_request的进程做一次时间计时而已,add_request还是会继续得以执行的。

static void add_request(struct blk_dev_struct * dev, struct request * req)
{
    struct request * tmp;

    req->next = NULL;
    //关闭中断
    cli();
    if (req->bh)
        req->bh->b_dirt = 0;
    //当前设备没有请求,则将该请求加入,并直接执行request_fn
    if (!(tmp = dev->current_request)) {
        dev->current_request = req;
        //这里打开中断
        sti();
        (dev->request_fn)();
        return;
    }
    for ( ; tmp->next ; tmp=tmp->next) {
        if (!req->bh)
            if (tmp->next->bh)
                break;
            else
                continue;
        //如果tmp到req 或者 req到tmp->next
        if ((IN_ORDER(tmp,req) ||
            !IN_ORDER(tmp,tmp->next)) &&
            IN_ORDER(req,tmp->next))
            break;
    }
    //当前req放入requst链表
    req->next=tmp->next;
    tmp->next=req;
    //这里打开中断
    sti();
}

而当一个设备读写请求完成后,磁盘控制器的中断请求会引起取链表中的下一个请求。这里没有关闭中断,原因是这里取链表的下一个元素的操作,是在磁盘读写请求完成后的中断程序中执行的。试想如果在执行取链表元素下一个的时候,该中断程序被timer中断抢占,而timer中断会发现当前的进程代码已经运行在内核态,从而退出,使得取链表元素的操作得以继续执行。

#define CURRENT (blk_dev[MAJOR_NR].current_request)

/*结束块读写请求 唤醒等待在当前block rw上的任务*/
extern inline void end_request(int uptodate)
{
    DEVICE_OFF(CURRENT->dev);
    //解除block并唤醒等待该block的任务队列
    if (CURRENT->bh) {
        CURRENT->bh->b_uptodate = uptodate;
        unlock_buffer(CURRENT->bh);
    }
    if (!uptodate) {
        printk(DEVICE_NAME " I/O error\n\r");
        printk("dev %04x, block %d\n\r",CURRENT->dev,
            CURRENT->bh->b_blocknr);
    }
    //唤醒可能的swap等待任务,注意swap读写时会一直阻塞并且req.waiting= current。 
    wake_up(&CURRENT->waiting);
    //唤醒等待加入block dev请求队列的任务
    wake_up(&wait_for_request);
    CURRENT->dev = -1;
    CURRENT = CURRENT->next;
}

4.I/O调度

由于硬盘读写的速度是非常慢的,所以在讨论系统性能时I/O吞吐率以及相应的调度无疑是需要特别关注的。这里试图从linux0.12的实现角度回答一下几个问题:

  • block I/O在CPU时间分配和执行上,是如何进行的。到底是哪个进程在负责读写I/O?
  • 多个任务同时读写同一个文件的效率问题?多个任务同时读写不同文件的效率问题?是单任务读写快还是多个任务快?
  • linux0.12有没有提供异步I/O?

假设有三个任务同时对某个inode进行写操作,当然我们假设对该inode的不同数据块,比如任务A写第一个数据块(1-1024),任务B写第二个数据块(1025-2048),以此类推。当然任务A开始写的时候假设他是对该硬盘设备的第一个请求,于是任务A发现请求队列为空,将自己的请求加入到队列头,并通过向控制器发送读写控制字然后休眠等待,于此同时任务B和任务C也开始写操作,在调度程序看来,此时任务A处于内核态休眠中(执行系统调用write中),任务B也陷入内核调用,将自己的第二个数据块的请求追加到请求对列;任务C类似也陷入内核调用,将自己的第三个数据块的请求追加到请求对列。三个磁盘操作的进程都陷入内核态休眠等待。

如果磁盘控制器将第一个块成功写入以后,控制器发出中断通知内核,内核通过hd_interrupt响应该中断,由于磁盘读写时将write_intr read_intr注册为回调函数(具体的做法是通过修改了do_hd函数的地址),这两个回调函数会继续查看队列是否还有读写请求,于是在中断处理函数中继续向控制器发出读写操作命令,该中断处理程序然后退出。注意,第一次磁盘读写中断处理是在另外一个正在运行的任务中(如系统init进程)抢占的。第一次磁盘读写任务完成后,任务A就被从内核态休眠中唤醒继续执行(write调用后面的代码得以继续执行)。

紧接着,磁盘控制器将第二个块成功写入,磁盘中断处理函数继续向控制器发出读写操作命令,该中断处理程序然后退出。这一次的磁盘读写中断处理是在另外一个正在运行的任务中(如被唤醒的进程A)抢占执行的,第二次磁盘读写任务完成后,任务B 就被从内核态休眠中换醒继续执行(write调用后面的代码得以继续执行)。

最后,磁盘控制器将第三个块成功写入,磁盘中断处理函数发现请求队列为空,不在向控制器发读写请求,该中断处理程序然后退出。这一次的磁盘读写中断处理还是在另外一个正在运行的任务中(如被唤醒的进程B)抢占执行的,第三次磁盘读写任务完成后,任务C 就被从内核态休眠中换醒继续执行(write调用后面的代码得以继续执行)。

可见,每次读写磁盘的等待时间是由控制器发起中断决定的,也就是磁盘马达读写磁盘的速度决定了磁盘读写的等待时间。所以针对I/O的优化最终要保证读写磁盘文件以一致有序的方式进行,比如读写大小为磁盘数据块大小512字节,读写的方向是按照磁盘号有序进行等待。这样保证读写文件的速度达到最高。

回到一开始的问题,

  • block I/O在CPU时间分配和执行上,是如何进行的。到底是哪个进程在负责读写I/O?
    A: 没有CPU时间消耗在I/O读写上,这句话的意思是I/O读写对内核而言,是一个离线的异步过程。内核将读写命令发送给控制器并注册中断回调函数以后,就算结束了。之后I/O的具体读写是由控制器本身控制磁盘驱动马达完成的。那么这里的block I/O又体现在哪里?具体而言,体现在内核休眠等待上,就是下面bread函数的wait_on_buffer上。
    /*
     * bread() reads a specified block and returns the buffer that contains
     * it. It returns NULL if the block was unreadable.
     */
    struct buffer_head * bread(int dev,int block)
    {
        struct buffer_head * bh;

        if (!(bh=getblk(dev,block)))
            panic("bread: getblk returned NULL\n");
        //该block已经是最新的,直接返回
        if (bh->b_uptodate)
            return bh;
        //该block不是最新,需要从硬盘中读取
        ll_rw_block(READ,bh);
        //在这里等待读操作结束
        wait_on_buffer(bh);
        if (bh->b_uptodate)
            return bh;
        brelse(bh);
        return NULL;
    }
  • 多个任务同时读写同一个文件的效率问题?多个任务同时读写不同文件的效率问题?是单任务读写快还是多个任务快?
    A: 实际上读写文件的速度和进程数目是没有多大关系的,所有的进程都会被阻塞在write系统调用上,而该调用主要完成的是将磁盘读写请求送入队列,然后等待磁盘控制器在读写任务完成后中断通知内核。所以,多个任务读写硬盘并不会比单个任务更快。增大进程数会对计算敏感的任务有效。
  • linux0.12有没有提供异步I/O?
    A: 有的。select调用就是。

上一篇     下一篇