前面的博文中详细讲述了xv6的文件系统,其中使用位图块来进行磁盘block的 管理,但是对于block内容进行读写则需要更底层的磁盘驱动程序,同时考虑到磁盘读写的速度非常慢(相对于内存读写),因此我们有必要对磁盘的数据块进行缓存。
整个磁盘的缓存是采用类似对象池(object Pool)的框架来实现的。
对于每个磁盘block,对应一个对应的数据结构buffer,具体如下:
truct buf { int flags; //B_BUSY=1, B_VALID=2, B_DIRTY=4 uint dev; uint sector; struct buf *prev; // block cache list struct buf *next; struct buf *qnext; // ide queue uchar data[512]; };
通过dev和sector就可以唯一确定该block的地址,从而进行读写。读写的粒度为block(512字节),相应的数据保存在data中。每个buffer含有一个标志flags,如果为B_BUSY说明这个buffer正在被一个进程使用,如果B_VALID则说明buffer中的数据已经准备好了,B_DIRTY说明是读取磁盘数据还是将数据写入磁盘。
一、磁盘驱动的实现
类似下图,xv6维持了一个磁盘请求队列idequeue。它每次将头部的buf发送到磁盘设备,而每次新加入的buffer放在队列的尾部。
根据buffer中的元数据及标志来读写扇区,对应的函数为iderw,它将新添加的buffer添加到请求队列的末尾,并且这个进程因为这个buffer进入睡眠状态。
iderw的关键代码如下所示:
void iderw(struct buf *b) { struct buf **pp; acquire(&idelock); // 将这个buffer加入请求队列idequeue的末尾 b->qnext = 0; for(pp=&idequeue; *pp; pp=&(*pp)->qnext) ; *pp = b; // 如果原来队列为空,则立即处理这个buffer if(idequeue == b) idestart(b); // 等待操作完成 while((b->flags & (B_VALID|B_DIRTY)) != B_VALID) { sleep(b, &idelock); } release(&idelock); }
其中将buffer的数据及标志发送到磁盘设备的代码idestart如下:
static void idestart(struct buf *b) { idewait(0); outb(0x3f6, 0); // generate interrupt outb(0x1f2, 1); // number of sectors outb(0x1f3, b->sector & 0xff); outb(0x1f4, (b->sector >> 8) & 0xff); outb(0x1f5, (b->sector >> 16) & 0xff); outb(0x1f6, 0xe0 | ((b->dev&1)<<4) | ((b->sector>>24)&0x0f)); if(b->flags & B_DIRTY) { //需要写数据 outb(0x1f7, IDE_CMD_WRITE); outsl(0x1f0, b->data, 512/4); } else { //需要读数据 outb(0x1f7, IDE_CMD_READ); } }
如果操作时读取,则在磁盘控制器数据准备好时会触发一个中断通知中断处理程序获取数据;如果操作时写入,则在数据成功写入后触发一个中断
二、磁盘中断处理程序
xv6磁盘中断处理程序调用ideintr来进程处理。它通过查询请求队列idequeue的第一个buffer得知已发生的操作。如果是读取操作,则将磁盘控制器中已经准备好的数据读到data中。现在buffer中的数据已经准备好了,则设置为B_VALID并清除B_DIRTY,并且唤醒因为这个buffer而进入睡眠状态的进程。接着ideintr传递请求队列中的下一个buffer到磁盘设备进行处理。
void ideintr(void) { struct buf *b; // 获取idequeue中的第1个buf acquire(&idelock); if((b = idequeue) == 0) { release(&idelock); cprintf("Spurious IDE interrupt./n"); return; } idequeue = b->qnext; // 看看是否需要读取数据 if(!(b->flags & B_DIRTY) && idewait(1) >= 0) insl(0x1f0, b->data, 512/4); // 唤醒正在等待这个buf的进程 b->flags |= B_VALID; b->flags &= ~B_DIRTY; wakeup(b); // 如果idequeue非空,则继续处理下一buf if(idequeue != 0) idestart(idequeue); release(&idelock); }
三、磁盘缓存
xv6使用缓存来同步各个进程之间访问磁盘block,它通过bread中独占访问来实现:即如果有两个进程通过bread同时读取具有相同dev和sector的未使用的磁盘block,那么其中一个进程会立即返回一个已锁住的buffer,而另一个进程则需要等待前一个进程访问完后调用brelse时
发送的解锁信号。
bread非常简单,代码如下:
struct buf* bread(uint dev, uint sector) { struct buf *b; b = bget(dev, sector); if(!(b->flags & B_VALID)) iderw(b); return b; }
bread调用bget来获取并返回给定dev和sector的一个已锁住的buffer。任何时候只有一个进程访问bcache,并且只有成功得到一个已锁住的buffer才释放这个自旋锁,从而保证进程访问磁盘的同步性。
它采用的算法非常精妙:
1. 在bcache中使用next指针(见最上面的图)从前往后扫描缓存bcache,尝试找到一个和给定数据相同的buffer。若有并已经被别的进程使用(B_BUSY置位),则因为这个buffer进入睡眠状态,否则,置位B_BUSY,从而返回一个已锁住的buffer。若没有找到,则进入2
2. 在bcache中使用pre指针(见最上面的图)从后往前扫描缓存bcache,尝试找到一个未被使用的buffer(B_BUSY=0),如果找到则修改相应的dev和sector,并且置位B_BUSY,并且置位B_VALID和B_DIRTY位,表明bread需要刷新buffer中的数据而不是使用先前buffer中的数据。
--->需要特别注意的是如果找到了但是该buffer已使用而进入睡眠的进程,后来被唤醒了,则需要从新来过,因为有可能这个buffer被复用到别的dev或sector了,只好重新来过咯
整个过程如下:
static struct buf* bget(uint dev, uint sector) { struct buf *b; acquire(&bcache.lock); loop: // 从前往后,尝试寻找一个和给定扇区相同的buffer for(b = bcache.head.next; b != &bcache.head; b = b->next) { if(b->dev == dev && b->sector == sector) /找到这样的buffer { if(!(b->flags & B_BUSY)) //如果未被使用则设置为B_BUSY,并返回 { b->flags |= B_BUSY; release(&bcache.lock); return b; } sleep(b, &bcache.lock); //如果已被使用则睡眠,等待别的进程释放buffer goto loop; } } // 如果找不到这样的buffer,则从后往前找一个不在使用的buffer,并修改相应的信息 for(b = bcache.head.prev; b != &bcache.head; b = b->prev) { if((b->flags & B_BUSY) == 0) { b->dev = dev; b->sector = sector; b->flags = B_BUSY; release(&bcache.lock); return b; } } panic("bget: no buffers"); }
一旦bread返回了一个已锁住的buffer给调用者,那么调用者就可以独占的使用buffer来进行读写操作。当调用者处理完这个buffer,必须使用brelse来释放它,brelse将这个buffer移动来列表的最前面,清除B_BUSY位并唤醒其他因为这个buffer而进入睡眠的进程。这个算法就是经典的LRU算法,它的精妙之处在于考虑到程序的局部性原理:因为这样的移动,使得icache中最前面的buffer是最近使用的,而最后面的是最久未使用,从而从前往后查找最近使用的buffer,从后往前查找最久未使用的buffer,效率非常高。
四、思考及小结
如果一个block缓存,从磁盘中读取时同步还是异步?并简述过程
这是一个异步的过程,其过程如下:
1)首先调用bread,在icache中找到一个未被使用(B_BUSY)的buffer。
2) 修改该buffer的dev和sector,并置位B_BUSY,从而返回一个已锁住的buffer
3)将该buffer加入到请求列表idequeue的末尾,并因为这个buffer而进入睡眠状态
4)当轮到改buffer时,idestart将该buffer的数据及标志发送到磁盘设备
5)磁盘设备处理完成后,产生一个中断
6)磁盘中断处理程序ideintr从磁盘控制器读取已准备好的数据到data中,并设置buffer的标志位B_VALID,并唤醒因为这个buffer而进入睡眠的进程
7)调用者处理完buffer后,最后调用brelse释放这个buffer。并唤醒因为这个buffer而进入睡眠的进程。