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文件系统之 — 通用块处理层