linux源码解读(二):文件系统——高速缓存区

  用户的应用程序会经常读写磁盘文件的数据到内存,但是内存的速度和磁盘的速度理论上差了好几个数量级;为了更高效地解决内存和磁盘的速度差,linux也在内存使用了缓存区(作用类似于cpu内部为了解决寄存器和内存速度差异的的L1、L2、L3 cache):如果数据要写入磁盘文件,先放在缓存区,等凑够了一定数量后再批量写入磁盘文件,借此减少磁盘寻址的次数,来提升写入效率(这里多说几句:比如U盘插上电脑后,如果要拔出,建议先卸载再拔出,而不是直接拔出,为啥了?U盘的数据也是先放入缓冲区的,缓冲区有自己的管理机制,很久没有使用的块可以给其他进程使用,如果是脏块则要进行写盘。缓冲在某些情况下才会有写盘操作,所以要拔出U盘时,应该先进行卸载,这样才会写盘,否则数据可能丢失,文件系统可能损坏。);如果从磁盘读数据,也会先放入缓存区暂存,一旦有其他进程或线程读取同样的磁盘文件,这是就可以先从内存的缓存区取数据了,没必要重新从磁盘读取,也提升了效率!linux 0.11的缓冲区是怎么工作的了?

  在main.c的main函数中,有设置缓存区的大小,代码如下:内存不同,缓存区的大小也不同,linux是怎么管理和使用这些缓存区了的?

void main(void)        /* This really IS void, no error here. */
{            /* The startup routine assumes (well, ...) this */
/*
 * Interrupts are still disabled. Do necessary setups, then
 * enable them
 */
//前面这里做的所有事情都是在对内存进行拷贝
     ROOT_DEV = ORIG_ROOT_DEV;//设置操作系统的根文件
     drive_info = DRIVE_INFO;//设置操作系统驱动参数
     //解析setup.s代码后获取系统内存参数
    memory_end = (1<<20) + (EXT_MEM_K<<10);
    //取整4k的内存大小
    memory_end &= 0xfffff000;
    if (memory_end > 16*1024*1024)//控制操作系统的最大内存为16M
        memory_end = 16*1024*1024;
    if (memory_end > 12*1024*1024) 
        buffer_memory_end = 4*1024*1024;//设置高速缓冲区的大小,跟块设备有关,跟设备交互的时候,充当缓冲区,写入到块设备中的数据先放在缓冲区里,只有执行sync时才真正写入;这也是为什么要区分块设备驱动和字符设备驱动;块设备写入需要缓冲区,字符设备不需要是直接写入的
    else if (memory_end > 6*1024*1024)
        buffer_memory_end = 2*1024*1024;
    else
        buffer_memory_end = 1*1024*1024;
    main_memory_start = buffer_memory_end;

  1、cpu有分页机制,硬件上以4KB为单位把内存分割成小块供程序使用;这个颗粒度是比较大的,有些时候可能会浪费比较多的内存,所以linux缓存区采用了1KB的大小来分割整个缓存区;假设缓存区有2MB,那么一共被分割成了2000个小块,这么多的缓存区该怎么管理了?

  每个缓存都有各自的属性,比如是否使用、数据是否更新、缓存数据在磁盘的位置、缓存的起始地址等,要想统一管理这么多的属性,最好的办法自然是构建结构体了;一个结构体管理1块(也就是1KB)的缓存区;假设这里有2000个缓存区,就需要2000个结构体,那么问题又来了:这个多的结构体,又该怎么去管理了? 

  参考前面的进程task结构体管理方式:用task数组来管理所有的进程task结构体,最大限制为64个进程,但是放在这里显然不适用:不同机器的物理内存大小是不同的,导致缓存区的block数量是不同的,但数组最大的缺点就是定长,无法适应不同的物理内存,那么这里最适合的只剩链表了,所以linux 0.11版本使用的结构体如下:

struct buffer_head {
    char * b_data;            /* pointer to data block (1024 bytes):单个数据块大小1KB */
    unsigned long b_blocknr;    /* block number */
    unsigned short b_dev;        /* device (0 = free) */
    unsigned char b_uptodate;    /*数据是否更新*/
    unsigned char b_dirt;        /* 0-clean空现,1-dirty已被占用*/
    unsigned char b_count;        /* users using this block */
    /*如果缓冲区的某个block被锁,上层应用是没法从这个block对应的磁盘空间读数据的,这里有个漏洞:
    A进程锁定了某block,B进程想办法解锁,然后就能监听A进程从磁盘读写了哪些数据
    */
    unsigned char b_lock;        /* 0 - ok, 1 -locked:锁用于多进程/多线程之间同步,避免数据出错*/
    struct task_struct * b_wait;/*A正在使用这个缓存,并已经锁定;B也想用,就用这个字段记录;等A用完后从这里找到B再给B用*/
    struct buffer_head * b_prev;
    struct buffer_head * b_next;
    struct buffer_head * b_prev_free;
    struct buffer_head * b_next_free;
};

  每个字段的含义都在注释了,这里不再赘述;既然采用了链表,解决了数组只能定长的缺点,但是链表本身也有缺点:无法直接找到目标实例,需要挨个遍历链表上的每个节点;还是假设有2000个块,好巧的不巧的是程序所需的block刚好在最后一个节点,那么需要遍历1999个节点才能到达,效率非常低,这又该怎么解决了?刚好这种快速寻址(时间复杂度O(1))是数组的优势,怎么解决数组和链表各自的优势了?-----hash表!

  linux 0.11版本采用hash表的方式快速寻址,hash映射算法如下:

// hash表的主要作用是减少查找比较元素所花费的时间。通过在元素的存储位置与关
// 键字之间建立一个对应关系(hash函数),我们就可以直接通过函数计算立刻查询到指定
// 的元素。建立hash函数的指导条件主要是尽量确保散列在任何数组项的概率基本相等。
// 建立函数的方法有多种,这里Linux-0.11主要采用了关键字除留余数法。因为我们
// 寻找的缓冲块有两个条件,即设备号dev和缓冲块号block,因此设计的hash函数肯定
// 需要包含这两个关键值。这两个关键字的异或操作只是计算关键值的一种方法。再对
// 关键值进行MOD运算就可以保证函数所计算得到的值都处于函数数组项范围内。
#define _hashfn(dev,block) (((unsigned)(dev^block))%NR_HASH)
#define hash(dev,block) hash_table[_hashfn(dev,block)]

  映射的算法也很简单:每个buffer_head结构体都有dev和block两个字段,这两个字段组合起来本身是不会重复的,所以把这两个字段异或后模上hash表的长度,就得到了hash数组的偏移;现在问题又来了:这个版本的hash_table数组长度设定为NR_HASH=307,远不如buffer_head的实例个数,肯定会发生hash冲突,这个该怎么解决了?--这里就要用上链表变长的优点了:把发生hash冲突的bufer_head实例首位相接不久得了么?最终的hash_table示意图如下:hash表本身用数组,存储buffer_head实例的地址;如果发生hash冲突,相同hash偏移的实例通过b_next和b_prev链表首尾连接!

   

   当这个一整套存储机制建立后,怎么检索了?linux的检索方式如下:先通过dev和block号定位到hash表的偏移,再遍历该偏移处的所有节点,通过比对dev和block号找到目标buffer_head实例

//// 利用hash表在高速缓冲区中寻找给定设备和指定块号的缓冲区块。
// 如果找到则返回缓冲区块的指针,否则返回NULL。
static struct buffer_head * find_buffer(int dev, int block)
{        
    struct buffer_head * tmp;

    // 搜索hash表,寻找指定设备号和块号的缓冲块。
    for (tmp = hash(dev,block) ; tmp != NULL ; tmp = tmp->b_next)
        if (tmp->b_dev==dev && tmp->b_blocknr==block)
            return tmp;
    return NULL;
}

   根据dev和block号找到缓存区的buffer_head并不代表万事大吉,因为该缓存区可能已经被其他进程/线程占用,当前线程如果一定要用这个缓存区,只能等了,所以最终查找缓存区的代码如下:这里增加了wait_on_buffer函数:

//// 利用hash表在高速缓冲区中寻找指定的缓冲块。若找到则对该缓冲块上锁
// 返回块头指针。
struct buffer_head * get_hash_table(int dev, int block)
{
    struct buffer_head * bh;

    for (;;) {
        // 在高速缓冲中寻找给定设备和指定块的缓冲区块,如果没有找到则返回NULL。
        if (!(bh=find_buffer(dev,block)))
            return NULL;
        // 对该缓冲块增加引用计数,并等待该缓冲块解锁。由于经过了睡眠状态,其他任务可能会更改这个缓存区对应的dev和block号
        // 因此有必要在验证该缓冲块的正确性,并返回缓冲块头指针。
        bh->b_count++;
        wait_on_buffer(bh);
        if (bh->b_dev == dev && bh->b_blocknr == block)
            return bh;
        // 如果在睡眠时该缓冲块所属的设备号或块设备号发生了改变,则撤消对它的
        // 引用计数,重新寻找。
        bh->b_count--;
    }
}

  wait_on_buffer函数实现:如果发现该缓存区已经上锁,那么调用sleep_on函数让出cpu,阻塞在这里等待;这个sleep_on函数传入的参数是二级指针,并且内部用了tmp变量保存临时变量;由于二级指针是全局的,所以如果有多个task等待同一个缓存区,sleep_on函数是通过先进后出的栈的形式唤醒等待任务的;参考1有详细的说明,感兴趣的小伙伴建议好好看看!

//// 等待指定缓冲块解锁
// 如果指定的缓冲块bh已经上锁就让进程不可中断地睡眠在该缓冲块的等待队列b_wait中。
// 在缓冲块解锁时,其等待队列上的所有进程将被唤醒。虽然是在关闭中断(cli)之后
// 去睡眠的,但这样做并不会影响在其他进程上下文中影响中断。因为每个进程都在自己的
// TSS段中保存了标志寄存器EFLAGS的值,所以在进程切换时CPU中当前EFLAGS的值也随之
// 改变。使用sleep_on进入睡眠状态的进程需要用wake_up明确地唤醒。
static inline void wait_on_buffer(struct buffer_head * bh)
{
    cli();                          // 关中断
    while (bh->b_lock)              // 如果已被上锁则进程进入睡眠,等待其解锁
        sleep_on(&bh->b_wait);
    sti();                          // 开中断
}

   先进后出的栈形式唤醒等待任务:

       

  接下来可能就是buffer.c中最重要的函数之一了:struct buffer_head * getblk(int dev,int block),根据设备号和块号得到buffer_head的实例,便于后续使用对应的缓存区;

//// 取高速缓冲中指定的缓冲块
// 检查指定(设备号和块号)的缓冲区是否已经在高速缓冲中。如果指定块已经在
// 高速缓冲中,则返回对应缓冲区头指针退出;如果不在,就需要在高速缓冲中设置一个
// 对应设备号和块好的新项。返回相应的缓冲区头指针。
struct buffer_head * getblk(int dev,int block)
{
    struct buffer_head * tmp, * bh;

repeat:
    // 搜索hash表,如果指定块已经在高速缓冲中,则返回对应缓冲区头指针,退出。
    if ((bh = get_hash_table(dev,block)))
        return bh;
    // 扫描空闲数据块链表,寻找空闲缓冲区。
    // 首先让tmp指向空闲链表的第一个空闲缓冲区头
    tmp = free_list;
    do {
        // 如果该缓冲区正被使用(引用计数不等于0),则继续扫描下一项。对于
        // b_count = 0的块,即高速缓冲中当前没有引用的块不一定就是干净的
        // (b_dirt=0)或没有锁定的(b_lock=0)。因此,我们还是需要继续下面的判断
        // 和选择。例如当一个任务该写过一块内容后就释放了,于是该块b_count()=0
        // 但b_lock不等于0;当一个任务执行breada()预读几个块时,只要ll_rw_block()
        // 命令发出后,它就会递减b_count; 但此时实际上硬盘访问操作可能还在进行,
        // 因此此时b_lock=1, 但b_count=0.
        if (tmp->b_count)
            continue;
        // 如果缓冲头指针bh为空,或者tmp所指缓冲头的标志(修改、锁定)权重小于bh
        // 头标志的权重,则让bh指向tmp缓冲块头。如果该tmp缓冲块头表明缓冲块既
        // 没有修改也没有锁定标志置位,则说明已为指定设备上的块取得对应的高速
        // 缓冲块,则退出循环。否则我们就继续执行本循环,看看能否找到一个BANDNESS()
        // 最小的缓冲块。BADNESS等于0意味着b_block和b_dirt都是0,这块缓存区还没被使用,目标缓存区已经找到,可以跳出循环了
        if (!bh || BADNESS(tmp)<BADNESS(bh)) {
            bh = tmp;
            if (!BADNESS(tmp))
                break;
        }
/* and repeat until we find something good */
    } while ((tmp = tmp->b_next_free) != free_list);
    // 如果循环检查发现所有缓冲块都正在被使用(所有缓冲块的头部引用计数都>0)中,
    // 则睡眠等待有空闲缓冲块可用。当有空闲缓冲块可用时本进程会呗明确的唤醒。
    // 然后我们跳转到函数开始处重新查找空闲缓冲块。
    if (!bh) {
        sleep_on(&buffer_wait);
        goto repeat;
    }
    // 执行到这里,说明我们已经找到了一个比较合适的空闲缓冲块了。于是先等待该缓冲区
    // 解锁(多任务同时运行,刚找到的缓存块可能已经被其他任务抢先一步找到并使用了,所以要再次检查)。如果在我们睡眠阶段该缓冲区又被其他任务使用的话,只好重复上述寻找过程。
    wait_on_buffer(bh);
    if (bh->b_count)
        goto repeat;
    // 如果该缓冲区已被修改,则将数据写盘,并再次等待缓冲区解锁。同样地,若该缓冲区
    // 又被其他任务使用的话,只好再重复上述寻找过程。
    while (bh->b_dirt) {
        sync_dev(bh->b_dev);
        wait_on_buffer(bh);
        if (bh->b_count)
            goto repeat;
    }
/* NOTE!! While we slept waiting for this block, somebody else might */
/* already have added "this" block to the cache. check it */
    // 在高速缓冲hash表中检查指定设备和块的缓冲块是否乘我们睡眠之际已经被加入
    // 进去(毕竟是多任务系统,有可能被其他任务抢先使用并放入has表)。如果是的话,就再次重复上述寻找过程。
    if (find_buffer(dev,block))
        goto repeat;
/* OK, FINALLY we know that this buffer is the only one of it's kind, */
/* and that it's unused (b_count=0), unlocked (b_lock=0), and clean */
    // 于是让我们占用此缓冲块。置引用计数为1,复位修改标志和有效(更新)标志。
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;
    // 从hash队列和空闲队列块链表中移出该缓冲区头,让该缓冲区用于指定设备和
    // 其上的指定块。然后根据此新的设备号和块号重新插入空闲链表和hash队列新
    // 位置处。并最终返回缓冲头指针。
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;
    insert_into_queues(bh);
    return bh;
}

   代码的整体逻辑并不复杂,但是有些细节想展开说说:

  •   BADNESS(bh):从表达式看,b_dirt左移1位后再和b_lock相加,明显b_dirt的权重乘以了2,说明作者认为缓存区是否被使用的权重应该大于是否被锁!但是实际使用的时候,会一直循环查找BADNESS小的缓存区,说明作者认为b_block比b_dirt更重要,也就是缓存区是否上锁比是否被使用了更重要,这个也符合业务逻辑
// 下面宏用于同时判断缓冲区的修改标志和锁定标志,并且定义修改标志的权重要比锁定标志大。
//  b_dirt左移1位,权重比b_block高
#define BADNESS(bh) (((bh)->b_dirt<<1)+(bh)->b_lock)
  •   循环停止的条件如下:tmp初始值就是free_list,这里的停止的条件也是tmp == free_list,说明free_list是个环形循环链表;所以整个do while循环本质上就是在free_list中找BADNESS值最小的buffer_head;如果找到BADNESS等于0(意味着b_block和b_dirt都为0,该缓存区还没被使用)的buffer_head,直接跳出循环
 while ((tmp = tmp->b_next_free) != free_list);
  •  函数结尾处: 再次检查dev+block是否已经在缓存区了,如果在,说明其他任务捷足先登,已经使用了该缓存区,本任务只能重新走查找的流程;如果该缓存块还没被使用,先设置一些标志/属性位,再把该buffer_head节点从旧hash表和free_list队列溢出,再重新加入hash_table和free_list队列,作者是咋想的?为啥要重复干这种事了
/* already have added "this" block to the cache. check it */
    // 在高速缓冲hash表中检查指定设备和块的缓冲块是否乘我们睡眠之际已经被加入
    // 进去(毕竟是多任务,期间可能会被其他任务抢先使用并放入hash表)。如果是的话,就再次重复上述寻找过程。
    if (find_buffer(dev,block))
        goto repeat;
/* OK, FINALLY we know that this buffer is the only one of it's kind, */
/* and that it's unused (b_count=0), unlocked (b_lock=0), and clean */
    // 于是让我们占用此缓冲块。置引用计数为1,复位修改标志和有效(更新)标志。
    bh->b_count=1;
    bh->b_dirt=0;
    bh->b_uptodate=0;
    // 从hash队列和空闲队列块链表中移出该缓冲区头,让该缓冲区用于指定设备和
    // 其上的指定块。然后根据此新的设备号和块号重新插入空闲链表和hash队列新
    // 位置处。并最终返回缓冲头指针。
    /*将缓冲块从旧的队列移出,添加到新的队列中,即哈希表的头,空闲表的尾,这样能够迅速找到该存在的块,而该缓冲块存在的时间最长*/
    remove_from_queues(bh);
    bh->b_dev=dev;
    bh->b_blocknr=block;
    insert_into_queues(bh);
    return bh;

  先来看看remove_from_queues和insert_into_queu函数代码:remove_from_queues没啥好说的,就是简单粗暴的从hash表和free_list删除,也是常规的链表操作,重点在insert_into_queu函数:

  •  bh节点加入了free_list链表的末尾,直接减少了后续查询遍历链表的时间,这不就直接提升了查询效率么?
  •  bh节点加入hash表某个偏移的表头,后续通过hash偏移不就能第一个找到该节点了么?又省了遍历链表的操作!
//// 从hash队列和空闲缓冲区队列中移走缓冲块。
// hash队列是双向链表结构,空闲缓冲块队列是双向循环链表结构。
static inline void remove_from_queues(struct buffer_head * bh)
{
/* remove from hash-queue */
    if (bh->b_next)
        bh->b_next->b_prev = bh->b_prev;
    if (bh->b_prev)
        bh->b_prev->b_next = bh->b_next;
    // 如果该缓冲区是该队列的头一个块(每个hash偏移的头),则让hash表的对应项指向本队列中的下一个
    // 缓冲区。
    if (hash(bh->b_dev,bh->b_blocknr) == bh)
        hash(bh->b_dev,bh->b_blocknr) = bh->b_next;
/* remove from free list */
    if (!(bh->b_prev_free) || !(bh->b_next_free))
        panic("Free block list corrupted");
    bh->b_prev_free->b_next_free = bh->b_next_free;
    bh->b_next_free->b_prev_free = bh->b_prev_free;
    // 如果空闲链表头指向本缓冲区,则让其指向下一缓冲区。
    if (free_list == bh)
        free_list = bh->b_next_free;
}

//// 将缓冲块插入空闲链表尾部,同时放入hash队列中。
static inline void insert_into_queues(struct buffer_head * bh)
{
/* put at end of free list */
    bh->b_next_free = free_list;
    bh->b_prev_free = free_list->b_prev_free;
    free_list->b_prev_free->b_next_free = bh;
    free_list->b_prev_free = bh;
/* put the buffer in new hash-queue if it has a device */
    // 请注意当hash表某项第1次插入项时,hash()计算值肯定为Null,因此此时得到
    // 的bh->b_next肯定是NULL,所以应该在bh->b_next不为NULL时才能给b_prev赋
    // bh值。
    bh->b_prev = NULL;
    bh->b_next = NULL;
    if (!bh->b_dev)
        return;
    bh->b_next = hash(bh->b_dev,bh->b_blocknr);
    hash(bh->b_dev,bh->b_blocknr) = bh;
    bh->b_next->b_prev = bh;                // 此句前应添加"if (bh->b_next)"判断
}

   当一个block使用完后就要释放了,避免“占着茅坑不拉屎”;释放的逻辑也简单,如下:引用计数count--,并且唤醒正在等待该缓存区的其他任务;

// 释放指定缓冲块。
// 等待该缓冲块解锁。然后引用计数递减1,并明确地唤醒等待空闲缓冲块的进程。
void brelse(struct buffer_head * buf)
{
    if (!buf)
        return;
    wait_on_buffer(buf);
    if (!(buf->b_count--))
        panic("Trying to free free buffer");
    wake_up(&buffer_wait);
}

  前面很多的操作,尤其是节点的增删改查都涉及到了hash表和链表,那么hash表和链表都是怎么建立的了?这里用的是buffer_init函数:hash表初始化时所有的偏移都指向null;

// 缓冲区初始化函数
// 参数buffer_end是缓冲区内存末端。对于具有16MB内存的系统,缓冲区末端被设置为4MB.
// 对于有8MB内存的系统,缓冲区末端被设置为2MB。该函数从缓冲区开始位置start_buffer
// 处和缓冲区末端buffer_end处分别同时设置(初始化)缓冲块头结构和对应的数据块。直到
// 缓冲区中所有内存被分配完毕。
void buffer_init(long buffer_end)
{
    struct buffer_head * h = start_buffer;
    void * b;
    int i;

    // 首先根据参数提供的缓冲区高端位置确定实际缓冲区高端位置b。如果缓冲区高端等于1Mb,
    // 则因为从640KB - 1MB被显示内存和BIOS占用,所以实际可用缓冲区内存高端位置应该是
    // 640KB。否则缓冲区内存高端一定大于1MB。
    if (buffer_end == 1<<20)
        b = (void *) (640*1024);
    else
        b = (void *) buffer_end;
    // 这段代码用于初始化缓冲区,建立空闲缓冲区块循环链表,并获取系统中缓冲块数目。
    // 操作的过程是从缓冲区高端开始划分1KB大小的缓冲块,与此同时在缓冲区低端建立
    // 描述该缓冲区块的结构buffer_head,并将这些buffer_head组成双向链表。
    // h是指向缓冲头结构的指针,而h+1是指向内存地址连续的下一个缓冲头地址,也可以说
    // 是指向h缓冲头的末端外。为了保证有足够长度的内存来存储一个缓冲头结构,需要b所
    // 指向的内存块地址 >= h 缓冲头的末端,即要求 >= h+1.
    while ( (b -= BLOCK_SIZE) >= ((void *) (h+1)) ) {
        h->b_dev = 0;                       // 使用该缓冲块的设备号
        h->b_dirt = 0;                      // 脏标志,即缓冲块修改标志
        h->b_count = 0;                     // 缓冲块引用计数
        h->b_lock = 0;                      // 缓冲块锁定标志
        h->b_uptodate = 0;                  // 缓冲块更新标志(或称数据有效标志)
        h->b_wait = NULL;                   // 指向等待该缓冲块解锁的进程
        h->b_next = NULL;                   // 指向具有相同hash值的下一个缓冲头
        h->b_prev = NULL;                   // 指向具有相同hash值的前一个缓冲头
        h->b_data = (char *) b;             // 指向对应缓冲块数据块(1024字节)
        h->b_prev_free = h-1;               // 指向链表中前一项
        h->b_next_free = h+1;               // 指向连表中后一项
        h++;                                // h指向下一新缓冲头位置
        NR_BUFFERS++;                       // 缓冲区块数累加
        if (b == (void *) 0x100000)         // 若b递减到等于1MB,则跳过384KB
            b = (void *) 0xA0000;           // 让b指向地址0xA0000(640KB)处
    }
    h--;                                    // 让h指向最后一个有效缓冲块头
    free_list = start_buffer;               // 让空闲链表头指向头一个缓冲快
    free_list->b_prev_free = h;             // 链表头的b_prev_free指向前一项(即最后一项)。
    h->b_next_free = free_list;             // h的下一项指针指向第一项,形成一个环链
    // 最后初始化hash表,置表中所有指针为NULL。
    for (i=0;i<NR_HASH;i++)
        hash_table[i]=NULL;
}    

  截至目前,前面围绕缓存区做了大量的铺垫,最终的目的就是和磁盘之间读写数据,那么linux又是怎么利用缓存区从磁盘读数据的了?bread函数代码如下:整个逻辑也很简单,先申请缓存区,如果已经更新就直接返回;否则调用ll_rw_block读磁盘数据;读数据是要花时间的,这段时间cpu没必要闲着,可以跳转到其他进程继续执行;等数据读完后唤醒当前进程,检查buffer是否被锁、是否被更新;如果都没有,就可以安心释放了!

//// 从设备上读取数据块。
// 该函数根据指定的设备号 dev 和数据块号 block,首先在高速缓冲区中申请一块
// 缓冲块。如果该缓冲块中已经包含有有效的数据就直接返回该缓冲块指针,否则
// 就从设备中读取指定的数据块到该缓冲块中并返回缓冲块指针。
struct buffer_head * bread(int dev,int block)
{
    struct buffer_head * bh;

    // 在高速缓冲区中申请一块缓冲块。如果返回值是NULL,则表示内核出错,停机。
    // 然后我们判断其中说是否已有可用数据。如果该缓冲块中数据是有效的(已更新)
    // 可以直接使用,则返回。
    if (!(bh=getblk(dev,block)))
        panic("bread: getblk returned NULL\n");
    if (bh->b_uptodate)
        return bh;
    // 否则我们就调用底层快设备读写ll_rw_block函数,产生读设备块请求。然后
    // 等待指定数据块被读入,并等待缓冲区解锁。在睡眠醒来之后,如果该缓冲区已
    // 更新,则返回缓冲区头指针,退出。否则表明读设备操作失败,于是释放该缓
    // 冲区,返回NULL,退出。
    ll_rw_block(READ,bh);
    wait_on_buffer(bh);
    if (bh->b_uptodate)
        return bh;
    brelse(bh);
    return NULL;
}

   ll_rw_block:ll全称应该是lowlevel的意思;rw表示读或者写请求,bh用来传递数据或保存数据。先通过主设备号判断是否为有效的设备,同时请求函数是否存在。如果是有效的设备且函数存在,即有驱动,则添加请求到相关链表中

  对于一个当前空闲的块设备,当 ll_rw_block()函数为其建立第一个请求项时,会让该设备的当前请求项指针current_request直接指向刚建立的请求项,并且立刻调用对应设备的请求项操作函数开始执行块设备读写操作。当一个块设备已经有几个请求项组成的链表存在,ll_rw_block()就会利用电梯算法,根据磁头移动距离最小原则,把新建的请求项插入到链表适当的位置处

void ll_rw_block(int rw, struct buffer_head * bh)
{
    unsigned int major;

    if ((major=MAJOR(bh->b_dev)) >= NR_BLK_DEV ||
    !(blk_dev[major].request_fn)) {
        printk("Trying to read nonexistent block-device\n\r");
        return;
    }
    make_request(major,rw,bh);
}

  该函数内部继续调用make_request生成request:函数首先判断是否为提前读或者提前写,如果是则要看bh是否上了锁。上了锁则直接返回,因为提前操作是不必要的,否则转化为可以识别的读或者写,然后锁住缓冲区;数据处理结束后在中断处理函数中解锁;如果是写操作但是缓冲区不脏,或者读操作但是缓冲区已经更新,则直接返回;最后构造request实例,调用add_request函数把实例添加到链表!

  add_request函数用了电梯调度算法,主要是考虑到早期机械磁盘的移臂的时间消耗较大,要么从里到外,要么从外到里,顺着某个方向多处理请求。如果req刚好在磁头移动的方向上,那么可以先处理,这样能节省IO(本质是寻址)的时间

/*
 * add-request adds a request to the linked list.
 * It disables interrupts so that it can muck with the
 * request-lists in peace.
 */
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;
    if (!(tmp = dev->current_request)) {
        dev->current_request = req;
        sti();
        (dev->request_fn)();
        return;
    }
    for ( ; tmp->next ; tmp=tmp->next)
        if ((IN_ORDER(tmp,req) || 
            !IN_ORDER(tmp,tmp->next)) &&
            IN_ORDER(req,tmp->next))
            break;
    req->next=tmp->next;
    tmp->next=req;
    sti();
}

static void make_request(int major,int rw, struct buffer_head * bh)
{
    struct request * req;
    int rw_ahead;

/* WRITEA/READA is special case - it is not really needed, so if the */
/* buffer is locked, we just forget about it, else it's a normal read */
    if ((rw_ahead = (rw == READA || rw == WRITEA))) {
        if (bh->b_lock)
            return;
        if (rw == READA)
            rw = READ;
        else
            rw = WRITE;
    }
    if (rw!=READ && rw!=WRITE)
        panic("Bad block dev command, must be R/W/RA/WA");
    lock_buffer(bh);
    if ((rw == WRITE && !bh->b_dirt) || (rw == READ && bh->b_uptodate)) {
        unlock_buffer(bh);
        return;
    }
repeat:
/* we don't allow the write-requests to fill up the queue completely:
 * we want some room for reads: they take precedence. The last third
 * of the requests are only for reads.
 */
    if (rw == READ)
        req = request+NR_REQUEST;
    else
        req = request+((NR_REQUEST*2)/3);
/* find an empty request */
    while (--req >= request)
        if (req->dev<0)
            break;
/* if none found, sleep on new requests: check for rw_ahead */
    if (req < request) {
        if (rw_ahead) {
            unlock_buffer(bh);
            return;
        }
        sleep_on(&wait_for_request);
        goto repeat;
    }
/* fill up the request-info, and add it to the queue */
    req->dev = bh->b_dev;
    req->cmd = rw;
    req->errors=0;
    req->sector = bh->b_blocknr<<1;
    req->nr_sectors = 2;
    req->buffer = bh->b_data;
    req->waiting = NULL;
    req->bh = bh;
    req->next = NULL;
    add_request(major+blk_dev,req);
}

   add_request中定义了宏IN_ORDER,真正的电梯调度算法体现在这里了:read请求排在写请求前面,先处理读请求,再处理写请求;同一读或写请求先处理设备号小的设备请求,再处理设备号大的设备请求;同一读或写请求,同一设备,按先里面的扇区再到外面的扇区的顺序处理。

/*
 * This is used in the elevator algorithm: Note that
 * reads always go before writes. This is natural: reads
 * are much more time-critical than writes.
 */
#define IN_ORDER(s1,s2) \
((s1)->cmd<(s2)->cmd || ((s1)->cmd==(s2)->cmd && \
((s1)->dev < (s2)->dev || ((s1)->dev == (s2)->dev && \
(s1)->sector < (s2)->sector))))

  

  

参考:

1、https://blog.csdn.net/jmh1996/article/details/90139485     linux-0.12源码分析——缓冲区等待队列(栈)sleep_on+wake_up分析2

2、https://blog.csdn.net/ac_dao_di/article/details/54615951   linux 0.11 块设备文件的使用

3、https://cloud.tencent.com/developer/article/1749826 Linux文件系统之 — 通用块处理层

posted @ 2021-11-29 21:31  第七子007  阅读(2048)  评论(0编辑  收藏  举报