File system
文件系统的主要目的是储存数据,并且这种存储是persistence的。正如xv6书上列的,要实现储存数据的功能需要注意一下的几个实现:
-
对于不同的文件需要有一种组织他们的方式,例如组织好目录和文件
-
要处理好突然的crash,保证crash后,重启数据仍然正确
-
disk是公共资源,所以并发是要解决的问题之一
-
缓存问题
Overview
xv6中的文件系统的实现可以分为以下的七层: File descripter Pathname Directory Inode Log Buffer cache Disk
最开始我很难理解为啥要分层这七层?为什么不把每个都独立出来一个单独的模块?这样分层与层之间是否有联系?
后面通读一遍全文后,对这七层有了一点点的认知。
其实可以把这七层模型跟网络的五层模型相对应,一句话总结就是:上层可以调用下层提供的服务来实现自己的功能。 (当然还是有区别的,这里每一次不单单只能调用相邻层的函数,而是下层函数都可以调用)
简单举一个后面的例子,看不懂也没关系。Disk层会提供直接读写磁盘的接口
virtio_disk_rw()
,而Buffer cache 层使用这个接口来实现缓存到disk的写入bwrite()
。同理 Log层中commit()
调用bwrite将日志内容写入磁盘等等。
总的disk分区如下图所示:
disk中分成的非常多个block ,一个block 大小是1024字节。
上图中boot区域应该是跟部署有关,不用管。 super区域里面储存的是后面的log,inode,bitmao,data这些区域的大小以及位置。 log 区用来保证写入磁盘操作的原子性。 inode中数据 和 data 区域 数据 共同组成一个文件。 其中inode 储存一些文件的属性,例如大小,类型等。还储存了指向data对应区域的指针。而data区域就是这个文件中的所有数据。bitmap是位图,来标记data区域中有哪些block是空的。后面将会一层一层·来介绍。
Buffer cache
最底层的disk 就提供了virtio_disk_rw
() 我们暂且忽略这一层。
课本上说buffer cache的作用有两点:
-
作为disk的缓存,加快访问速度。
-
允许内存中只有一个disk block的拷贝,并且实现访问该block的同步。
直接来看代码实现。代码主要在(bio.c 文件中)
struct buf { int valid; //是否是有效的?如果是0 代表刚创建,需要从disk中获取数据 int disk; uint dev; uint blockno; //这个字段与上一个共同确定了唯一的一个block,对应于disk中的block strut sleeplock lock; //每一个buf都有一个锁 uint refcnt; //这个buf有多少个进程在使用 struct buf *prev; struct buf *next; //buf是双向链表的一个节点 uchar data[BSIZE]; //储存的数据 } struct { struct spinlock lock; //每一个cache buf 都有一个lock struct buf buf[NBUF]; // buf的双向环状链表 struct buf head; // 链表头, head->next 表示recent buf //head->prev 表示least buf,实现LRU } bcache;
上面两个数据结构是关键的缓冲块,其中buf是一个双向链表的节点,而bache拥有这个双向链表的头结点,以及所有buf的数组。
void binit(void){ struct buf *b; initlock(&bcache.lock, "bcache"); bcache.head.prev = &bcache.head; bcache.head.next = &bcache.head; //初始化头结点,让其next和prev都指向自己 for(b = bcache.buf; b < bcache.buf + NBUF; b++){ b->next = bcache.head->next;// 头插法插入每一个buf,形成一个环形双向链表 b->prev = bcache.head; initsleeplock(&b->lock, "buffer"); bcache.head.next->prev = b; bcache.head.next = b; } }
//通过 dev 和 blockno 来寻找内存中对这个disk的缓存buf。 static struct buf* bget (uint dev, uint blockno){ struct buf *b; acquire(*bcache.lock); //需要操作共享的变量,所有要提前获取锁 //遍历从头开始遍历双向链表,找到出满足条件的buf for(b = bcache.head.next; b!= &bcache.head; b= b->next){ if(b->dev ==dev && b->blockno == blockno){ b->refcnt++;//将该buf的ref+1 release(&bcache.lock); acquiresleep(&b->lock);//获取该buf的锁后返回该buf指针。 return b; } } //如果没找到怎么办? 没找到那只能回收buf,然后再返回了 for(b = bcache.head.prev; b != &bcache.head; b = b->prev){ //反向遍历,寻找ref为0的第一个buf,也就是最久没有进程使用的buf。 if(b->refcnt == 0) { b->dev = dev; b->blockno = blockno; //吧这个buf修改成目标buf b->valid = 0; //显然这个buf还没有disk中的值,设置为无效 b->refcnt = 1; release(&bcache.lock); acquiresleep(&b->lock); return b; } } } struct buf* bread(uint dev, uint blockno) //对bget的封装,获取有数据的buf。 { struct buf *b; b = bget(dev, blockno); //获取这个buf if(!b->valid) { //如果这个valid为0 表示这个buf是回收来的,我们要去disk读取数据 virtio_disk_rw(b, 0); //读取数据 b->valid = 1; } return b; } void bwrite(struct buf *b) //将这个buf写入到disk。 { if(!holdingsleep(&b->lock)) panic("bwrite"); virtio_disk_rw(b, 1); }
上面函数中,bread()
就是对bget()
的一层封装,返回获取到buf的指针。
void brelse(struct buf *b){ if(!holdingsleep(&b->buf)) panic("brelse"); releasesleep(&b->lock);//只有调用了brelse 才在这个地方释放了buf的锁!这个在封装的函数中获取锁,然后在另一个封装的函数中释放锁的设计见得比较少。 acuire(&bcache.lock); b->refcnt--; //注意这里没有获取b的锁仍然操作了b,这里会涉及到race condition吗? //答案应该是不会,我们获取一个buf的唯一入口是bread,那么就一定需要先获得bcache.lock才能获得buf,在上面的实现中,对任何buf的改变都是在释放bcache锁之前就已经完成了,所以这里只需要获得bcache锁就可以了。 if(b->refcnt == 0){ //将这个buf插入到双线链表的头部,代表这个buf是最近使用过的。 b->next->prev = b->prev; b->prev->next = b->next; b->next = bcache.head.next; b->prev = &bcache.head; bcache.head.next->prev = b; bcache.head.next = b; } release(&bcache.lock); }
//这两个小函数就是单独的操作某一个 buf 将其的ref++ 或者-- 。 //这里的意思就是 除了上面的bread 需要将ref++. breleas需要将ref--外。还可能有其他的地方需要操作ref。后面遇到了再说 void bpin(struct buf *b) { acquire(&bcache.lock); b->refcnt++; release(&bcache.lock); } void bunpin(struct buf *b) { acquire(&bcache.lock); b->refcnt--; release(&bcache.lock); }
一般来说使用上述接口过程是: 先获取一个缓存区的bufbread()
然后操作这个buf,不管是读是写,然后如果是写的话要调用bwrite()
来将这个写入刷新到disk中(当然不是直接调用而是通过事务来写,后面会提到),最后调用relese()
来释放这个buf。
Logging layer
日志层存在的意义就是要解决crash recovery。 在对disk执行操作的时候,可能会有多个bwrite()
来实现一个功能,但是如果每一个实现都是直接对应disk的改变,那么当有在crash出现在多步操作中间时,只会执行crash前面的操作,后面的不会执行,这样可能会导致数据异常。(非常非常类似数据库中的事务的原子性)xv6中的日志跟数据库的事务一样,也需要开启和关闭。只有在关闭函数执行后才会“提交”所有的操作,否则什么都不做。
日志区域在xv6中占有30个block。其中第一个block是header block, 剩下的都是logged block。在header block中记录的是此时log block中待 install的数据块有多少个。这个数值要么是0,代表没有事务要提交;要么是非零,代表这个有一个完整的事务待提交,这个事务需要提交的block数目就是里面存的数值。 因此只有当header block中的count不为0的那一刻起,这个事务就提交了。同时日志还支持多个进程同时对其进行写入,当然每个线程的提交就不是随心所欲了,因为这样一次提交可能会代表多个syscall的事务,而其中可能有一个事务还没来得及写完所有的操作到log中。因此只有在没有syscall执行的时候才能够commit,并且这个commit会更新多个系统调用的结果。 ok 简单介绍完了后来看看代码。
struct logheader{ int n; //这个就是判断有没有traction的标准 int block[LOGSIZE];//这个数组用来记录log中的每一个块对应的blockno } struct log{//日志总体 struct spinlock lock; int start; int size; int outstanding; //用来协调多进程写入日志 int committing; //用来判断此时是否是提交状态 int dev; struct logheader lh;//该日志区域的header }
上面的两个结构体就是log代码的核心结构,其中有些成员我们在后面详细介绍。
void initlog(int dev, struct superblock *sb){ if (sizeof(struct logheader) >= BSIZE)//显然一个block不能超过1024 panic("initlog: too big logheader"); initlock(&log.lock, "log");//都有的操作初始化锁 log.start = sb->logstart;//根据superblock中的记录来初始化log区域。 log.size = sb->nlog; log.dev = dev; recover_from_log(); //关键函数,在初始化文件系统的时候会调用 initlog(),然后执行到这里,这里的作用就是重新从log中恢复数据。 } static void recover_from_log(void) { //这里的函数都要一个一个的看,所以暂时跳过这个函数。 read_head(); install_trans(1); log.lh.n = 0; write_head(); } static void read_head(void) { struct buf *buf = bread(log.dev, log.start); //log.star是根据super初始化的,通过blockno 来找到 对应的内存中的 header 对应的buf(当然此时内存是没有的,必须要通过bget()的第二个循环来将磁盘的中的块copy到某个buf下,然后返回这个buf) struct logheader *lh = (struct logheader *) (buf->data);//此时这个buf的data就就是header block。 int i; log.lh.n = lh->n; //将从磁盘中获得的header数据copy到 内存的log.lh中 for (i = 0; i < log.lh.n; i++) { log.lh.block[i] = lh->block[i]; //将header block中的储存的blockno数据copy到内存中的log来 } brelse(buf); //bread一个块后记得brelse。 } static void install_trans(int recovering) //上一个函数copy了header { int tail; for (tail = 0; tail < log.lh.n; tail++) { //这个循环中做的东西比较有趣,得仔细看看避免搞混淆。 //首先要弄清除的是log.start + tail + 1,指的是什么。log.start 是log结构体中的一个变量,存的是log区域的第一个开始的block的blockno。这个函数就是读取disk中的log的header块的后面一个块,也就是第一个数据块,并且把它缓存进来,dbuf。 struct buf *lbuf = bread(log.dev, log.start+tail+1); //这里的log.lh.block[tail]存的是什么呢? 在log header结构体中的 block[] 储存的是log其他block中对应在data区域的block的blockno。所以这里获取的是data区域的块,并且缓存到内存中来。 struct buf *dbuf = bread(log.dev, log.lh.block[tail]); // read dst //现在 不管是log block,还是对应的data block都缓存到内存来了。我们要做的就是将log中的数据copy到data中,以此来恢复数据(还记得吗?) memmove(dbuf->data, lbuf->data, BSIZE); // copy block to dst //将copy的data block写入到磁盘中。 注意这里如果循环写到一半又发生了crash怎么办? //同理系统reboot的时候还是会调用这个函数,继续从头开始写,重复写的部分在写一次不会有什么影响(性能可能不好)。 bwrite(dbuf); // write dst to disk if(recovering == 0)//如果不是reboot调用的执行这个buf的ref操作(还有哪里会调用呢? 后面会提到。。) bunpin(dbuf); brelse(lbuf); brelse(dbuf); } } //这个函数就是将内存中的header block 写到 磁盘中。 static void write_head(void) { struct buf *buf = bread(log.dev, log.start); struct logheader *hb = (struct logheader *) (buf->data); int i; hb->n = log.lh.n; //将header中的n置为0 for (i = 0; i < log.lh.n; i++) { hb->block[i] = log.lh.block[i]; } bwrite(buf); brelse(buf); } //再次回到这个函数 static void recover_from_log(void) { read_head(); //从磁盘中读取header block,这里面保留有要重新拷贝到磁盘data block中的数据 install_trans(1); //执行这个拷贝 log.lh.n = 0; //将header的n变成0,表示不需要install write_head(); //将新的header block写入磁盘。 至此reboot的log操作全部结束。 }
至此crash恢复相关的 函数介绍完了。简单来说就是: 重启时将磁盘log header block读入内存,将这个header中对应的每一个data block也读入内存,执行copy操作,然后将data block写入磁盘更新数据,最后通过将header的 n 变成0,写入磁盘来表示log中没有事务需要提交了,数据恢复完毕。
接下来介绍log_write 相关的代码。 其实在对磁盘的数据进行修改,也就是通过bread()
获得buf 后进行修改,我们需要把内存中的修改写入到磁盘中,但是我们不能用b_write,因为这样就没有用到log来写入。 而用log写入的操作具体如下
... begin_op(void); //类似开启事务 log_write(buf);//执行事务 end_op(void); //提交事务 ...
ok现在来具体介绍一下这几个函数,也会吧前面埋了两个坑给填起来。
void begin_op(void) { acquire(&log.lock); while(1){ if(log.committing){ //如果log正在提交,阻塞 sleep(&log, &log.lock); } else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){ //如果准备写入log的syscall过多,要写的block可能超过log的长度,阻塞,注意这里的outstanding ,表示的意思是此时有多少个进程正在用这个log // this op might exhaust log space; wait for commit. sleep(&log, &log.lock); } else { log.outstanding += 1;//表示新的进程开一个log的事务。 release(&log.lock); break; } } } void log_write(struct buf *b) { int i; if (log.lh.n >= LOGSIZE || log.lh.n >= log.size - 1) //这两个判断基本不会发生。 panic("too big a transaction"); if (log.outstanding < 1) panic("log_write outside of trans"); //但是我第一个lab报这个错 我debug了好长时间,最后发现是一个左括号打错了位置,记录一下(哭死)。 acquire(&log.lock); for (i = 0; i < log.lh.n; i++) {//遍历log中的block,寻找有没有已经在里面的块了 if (log.lh.block[i] == b->blockno) // log absorbtion 找到的话直接用这个块 break; } log.lh.block[i] = b->blockno; //这个很关键,此时log中虽然还没有修改后的数据,但是我们可以让header记录这个log block与需要修改的数据的blockno if (i == log.lh.n) { // Add new block to log?//没有找到那么就找新的一个块(前一个调用后面紧接着的block) bpin(b); // 如果是一个新new出来的block 就要将这个 b 的 ref++, 这里就对应到前面的第一个坑了,除了bread 外 这里还要将其的ref+1,显然这个调用完后 后面肯定要用relese,那么此时这个buf就没有引用了,可能会被回收掉,但是这个buf是要写入磁盘的,所以这里ref+1 是为了防止被回收。 log.lh.n++; //将这个header 中的n+1 } release(&log.lock); } void end_op(void) { int do_commit = 0; acquire(&log.lock); log.outstanding -= 1; //每个进程到这里都会将outstanding -1,代表我用完了log(这里我最开始会觉得 感觉有点太早了是不是?) 比如说此时wake up另外一个线程的 begin_op,那么那个线程会顺利通过log block容量的判断, 然后实际上如果这个进程的函数没有执行完成,那么显然实际上空间是不够大。 //但是这种考虑是多余的,注意到我们此时是拥有 Log 锁的,所以我们wake up 后,begin_op 仍然不能从sleep中醒来,因为要获取锁才能醒来啊。所以下面的第一个wake up 感觉有点多余? if(log.committing) panic("log.committing"); if(log.outstanding == 0){ //如果此时的outstanding为0 表示的是所有的进程都写完了自己的log。可以提交了 do_commit = 1; log.committing = 1; } else { // begin_op() may be waiting for log space, // and decrementing log.outstanding has decreased // the amount of reserved space. //为什么这里就能wakeup啊 wakeup(&log); } release(&log.lock); if(do_commit){ //提交事务 // call commit w/o holding locks, since not allowed // to sleep with locks. commit(); //真正的提交是在这个函数中。 acquire(&log.lock); log.committing = 0; wakeup(&log); release(&log.lock); } } static void commit() { if (log.lh.n > 0) { //如果n大于0 表示需要提交 write_log(); // Write modified blocks from cache to log write_head(); // Write header to disk -- the real commit //修改了header的n后就算是正式提交。 install_trans(0); // Now install writes to home locations//这里的传入参数是0,也就是前面埋了第二个坑。这里再修改完数据后啊,可以不需要这个buf了,所以把它的ref--就行。但是如果是reboot状态进来的,那么没有调用Log_write,所以还没有++呢,所以就不需要--了。如果在这个地方carsh了也没事,事务已经算是提交了,在下次reboot的时候会重新恢复。 log.lh.n = 0; write_head(); // Erase the transaction from the log } } //这个write_log跟install_trans 是相反的。后者是要通过log来恢复磁盘中的数据,前者呢,注意到我们操作buf修改数据始终对应的是data中的数据,而不能直接操作log中的数据吧,所以我们需要把内存中修改的data数据copy到log的内存中,然后将内存中的log写入到磁盘的log中。 //然后再调用后者,通过磁盘的log 来修改磁盘中的data。 static void write_log(void) { int tail; for (tail = 0; tail < log.lh.n; tail++) { struct buf *to = bread(log.dev, log.start+tail+1); // log block struct buf *from = bread(log.dev, log.lh.block[tail]); // cache block memmove(to->data, from->data, BSIZE); bwrite(to); // write the log brelse(from); brelse(to); } }
至此log层的所有函数基本解析完毕。这里的难点就是要注意 磁盘中 可以分为Log block 和 data block 两个区域。
我们内存中存储的也是分为这两个区域log block 和 data block。 而我们修改数据是修改的内存中的data block,因此需要通过data block 来构建内存中的log block,再写入到磁盘中log block。然后再将这个log block写入到内存中,同时也将原始的data block也写入内存,然后执行更新,最后写入磁盘中。有点绕,但是只要记住一个核心,就是数据的操作只能在内存中进行就可以了。
同时还需要注意的是 buf 与 log block 和 data block 之间的关系。实际上后面两者都是buf, buf结构体能够储存这个buf对应的是哪一个block,也就是blockno。 而log header block 中的数组也会存储不同的log block对应的是哪一个data block的blockno。
总结来说还是那句话,log层利用的bcache层提供的接口,实现了crash 的修复。
Block allocator
在继续将inode层之前,我们教材中插入的block的获取与释放的函数。也稍微记录一下,有利于加深disk空间的理解, 如果有点模糊,详细对照前面的disk 的区域分布来看,应该是能看懂的。
//讲balloc前有几个宏定义 #define BPB (BSIZE*8) //一个bitmap有1024个字节,有1024*8位, // 所以可以表示这么多个block有没有被使用 #define BBLOCK(b, sb) ((b)/BPB + sb.bmapstart) // 是这个就是计算当前的b block应该由哪一个bitmap来对应。 //根据bitmap来遍历data区的空间,看是否有空的空间,根据的是bitmap的位图标志。(但是这里的实现是从block0开始的,感觉可以直接从data区域的block开始遍历,因为其他区域的block咱也不能使用对吧) static uint balloc(uint dev) { int b, bi, m; struct buf *bp; bp = 0; for(b = 0; b < sb.size; b += BPB){ //这里的b是blockno, bp = bread(dev, BBLOCK(b, sb));//bp是内存读取对应的bitmap,我们将要在内存中遍历整个bitmap的每一个位。 //这里的防止race condition的操作也是由于bread返回的是带锁的buf for(bi = 0; bi < BPB && b + bi < sb.size; bi++){ m = 1 << (bi % 8); //偏移量 if((bp->data[bi/8] & m) == 0){ // 此时的bp是bitmap 所以他的data里面存的就是标志位,如果这个标志位是0,说明找到了 空的disk。 bp->data[bi/8] |= m; // Mark block in use. 修改这个block log_write(bp); //将修改后的bitmap写入日志(当然不会直接调用bwrite),注意这个函数里面并没有类似事务的开启和关闭,也就是begin_op 和 end_op 函数,所以在调用这个函数之前必须要开启事务,调用后关闭事务。 brelse(bp); bzero(dev, b + bi); return b + bi;//返回这个这个空block的 blockno } } brelse(bp); } panic("balloc: out of blocks"); }
bfree()
的操作基本类似与ballock()
所以这里我就不讨论了。
Inode layer
书本上一来就说了,在xv6中,有两种inode 一种就是磁盘中的inode,里面数据包括了文件类型, 大小,链接数量,以及data区的blockno, 在code中定义为 dinode。此外还有一种node,就是内存中储存的inode,就叫做inode,其有dinode的一些数据外还有其他的一些数据。代码如下:
// On-disk inode structure ,也就是说在磁盘中 inode就是如下进行数据排布的 struct dinode { short type; // File type short major; // Major device number (T_DEVICE only) short minor; // Minor device number (T_DEVICE only) short nlink; // Number of links to inode in file system 连接 uint size; // Size of file (bytes) uint addrs[NDIRECT+1]; // Data block addresses //这个很重要我们后面再说。 }; // in-memory copy of an inode struct inode { uint dev; // Device number uint inum; // Inode number //用来定位是哪一个inode int ref; // Reference count struct sleeplock lock; // protects everything below here int valid; // inode has been read from disk? short type; // dinode的数据的拷贝 short major; short minor; short nlink; uint size; uint addrs[NDIRECT+1]; }; struct buf { int valid; //是否是有效的?如果是0 代表刚创建,需要从disk中获取数据 int disk; uint dev; uint blockno; //这个字段与上一个共同确定了唯一的一个block,对应于disk中的block struct sleeplock lock; //每一个buf都有一个锁 uint refcnt; //这个buf有多少个进程在使用 struct buf *prev; struct buf *next; //buf是双向链表的一个节点 uchar data[BSIZE]; //储存的数据 }
为了方便理解struct inode
我把前面的buf 也列出来了。可以看到 buf 可以是对于disk中data的缓冲,而srtuct inode
的成员变量跟buf 十分像, 所以我这里就直接把inode 理解为 特殊的buf, 用来专门缓存 disk 中的buf的。而类比一下,buf有bread()``brelse()
函数,那么类比一下,inode中肯定也有类似的函数,其实也就是iget()
和iput()
。 此外 有专门管理所有的buf的 对象 bcache
同样 也有专门管理inode的对象 icache
。看下面的代码解析能够更加清楚buf与inode之间的关系。
让我们来详细看看相关的代码
struct { struct spinlock lock; struct inode inode[NINODE]; } icache; //inode集合,完全类似于bcache void iinit()//初始化集合锁 和每一个inode的锁 { int i = 0; initlock(&icache.lock, "icache"); for(i = 0; i < NINODE; i++) { initsleeplock(&icache.inode[i].lock, "inode"); } } //很久inode的序号获得该inode 的in_memory inode。 类似于 bget。但是这里只是返回,而bget是获取锁后返回。 static struct inode* iget(uint dev, uint inum) { struct inode *ip, *empty; acquire(&icache.lock); // Is the inode already cached? empty = 0; //遍历目前的所有inode缓存 for(ip = &icache.inode[0]; ip < &icache.inode[NINODE]; ip++){ if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){ //如果找到了这个inode当前正在被使用,那么直接将引用加一返回就行。 ip->ref++; release(&icache.lock); return ip; } //找到第一个没被使用的inode块,作为备用返回。 if(empty == 0 && ip->ref == 0) // Remember empty slot. empty = ip; } //遍历结束没有找到已缓存的,那么就用上面的备用 // Recycle an inode cache entry. if(empty == 0) panic("iget: no inodes"); ip = empty;//拷贝一系列 ip->dev = dev; ip->inum = inum; ip->ref = 1; ip->valid = 0;//这里valid表示为0,代表需要从磁盘中拷贝,那么如何拷贝呢? 是否能够跟buf一样使用disk层提供的硬件接口? 后面我们会用到!。 release(&icache.lock); return ip; } //这里就是要在磁盘中创建一个新的inode,简单了来说就是找到一个type为0的inode然后将其赋予我们要创建的类型,file/dir/div struct inode* ialloc(uint dev, short type) { int inum; struct buf *bp; struct dinode *dip; //根据序号遍历所有的inode,查看是否是满足type = 0 //很显然这里涉及到将磁盘中数据读取到内存中的步骤,到目前为止(xv6所有的操作)所有的从磁盘中读取数据到内存中 //我们只能通过buf cache层给我们提供的接口 也就是bread来实现,这里也是一样。 for(inum = 1; inum < sb.ninodes; inum++){ //全程是有锁的。 bp = bread(dev, IBLOCK(inum, sb)); //#define IBLOCK(i, sb) ((i) / IPB + sb.inodestart) // #define IPB (BSIZE / sizeof(struct dinode)) //简单来说就是计算出这个序号的inode在哪一个block中,将这个block读 入磁盘 dip = (struct dinode*)bp->data + inum%IPB; //对IPB取模来得到目前的dinode* if(dip->type == 0){ // a free inode 在disk中找到一个free的inode memset(dip, 0, sizeof(*dip)); dip->type = type; log_write(bp); // mark it allocated on the disk,将找到的buf写入disk,注意这里函数中没有begin_op // end_op 那么在开启这个ialloc函数前肯定有这两个函数。 brelse(bp); return iget(dev, inum);//此时我们以及找到了对应的inum号,将内存中icache与这个找到的inode关联。 } brelse(bp); } panic("ialloc: no inodes"); } //将对应的修改后的inode,找到dinode,然后写入磁盘。 void iupdate(struct inode *ip) { struct buf *bp; struct dinode *dip; bp = bread(ip->dev, IBLOCK(ip->inum, sb)); dip = (struct dinode*)bp->data + ip->inum%IPB;//跟上面的操作类似,找到磁盘的dinode dip->type = ip->type; dip->major = ip->major; dip->minor = ip->minor; dip->nlink = ip->nlink; dip->size = ip->size; memmove(dip->addrs, ip->addrs, sizeof(ip->addrs)); log_write(bp);//日志写入 brelse(bp); } //将传入的inode上锁,并且有必要的话从磁盘中读取该inode的对应的dinode中,上面也提到过valid为0时该如何操作 void ilock(struct inode *ip) { struct buf *bp; struct dinode *dip; if(ip == 0 || ip->ref < 1) panic("ilock"); acquiresleep(&ip->lock); //获取该inode的锁 if(ip->valid == 0){ //如果是valid==0 表示还没有读取磁盘中的dinode bp = bread(ip->dev, IBLOCK(ip->inum, sb)); //还是要通过bread来读取磁盘中的数据 dip = (struct dinode*)bp->data + ip->inum%IPB; ip->type = dip->type; ip->major = dip->major; ip->minor = dip->minor; ip->nlink = dip->nlink; ip->size = dip->size; memmove(ip->addrs, dip->addrs, sizeof(ip->addrs)); brelse(bp); ip->valid = 1; if(ip->type == 0) panic("ilock: no type"); } }
ok 到这里基本上只跟inode本身有关的内容已经差不结束了。还剩下关键的内容是inode 里面指向的 conten的内存。至于inode与content之间的关系如下图。
简单来说就是 inode中的addrs 数组储存的是inode中指向真正储存数据的block num。 ok 我们再介绍跟和这个有关的函数吧,这里就涉及到了 file system lab中的第一个,算是比较好做的吧。
//接收一个cache Inode,和一个bn这个inode中储存的第几个block //返回的是这个inode中第bn个block对应的disk中的blockno static uint bmap(struct inode *ip, uint bn) { uint addr, *a; struct buf *bp; if(bn < NDIRECT){ //如果小于12 他们是直接映射,直接返回储存的值 if((addr = ip->addrs[bn]) == 0) //如果储存的值是0,代表这个块没有映射,那么就 //创建一个块,返回这个块的num数 ip->addrs[bn] = addr = balloc(ip->dev); return addr; } bn -= NDIRECT; //如果大于等于12,那么先减去12 代表的就是在第十二块中对应的第几个 if(bn < NINDIRECT){ //当然这个bn必须要小于256 // Load indirect block, allocating if necessary. if((addr = ip->addrs[NDIRECT]) == 0) //如果这个代表第12个块的块是0,说明没有被分配,那么就 //给他分配一个块返回的也是blockno ip->addrs[NDIRECT] = addr = balloc(ip->dev); bp = bread(ip->dev, addr); //根据这个blockno地址,获得这块block的buf a = (uint*)bp->data; //读取这个块的内容, if((addr = a[bn]) == 0){ //读取的内容是0仍然重复的创建块 a[bn] = addr = balloc(ip->dev); log_write(bp); //此时这个块已经创建好了,同时buf也是修改过的将这个buf写入日志中 } brelse(bp); return addr; } panic("bmap: out of range"); }
默认实现中inode的addr数组的size为13,其中12个为直接储存,第十三个是间接储存,他指向了另一个储存的block。一个block是1k,一个条目是uint 4byte,所以可以有256个条目。一共就是256 + 12 = 268。
lab1 中就是要我们提高这个能够储存的范围,使用一个类似二级页表的形式,也就是说间接储存指向的一个新的block中的条目仍然是一个间接储存,指向新的block,在新的block中储存的是最终的blockno,这样就会有 11 + 256 + 256*256 字节大小的存储空间了。 代码如下:
static uint bmap(struct inode *ip, uint bn) { uint addr, *a; uint *b; struct buf *bp; struct buf *bp1; if(bn < NDIRECT){ //如果小于11 他们是直接映射,直接返回储存的值 if((addr = ip->addrs[bn]) == 0){ ip->addrs[bn] = addr = balloc(ip->dev); } //如果储存的值是0,代表这个块没有映射,那么就 //创建一个块,返回这个块的num数 return addr; } bn -= NDIRECT; if(bn < NINDIRECT){ //如果此时小于256,那么就在第11个block内 // Load indirect block, allocating if necessary. if((addr = ip->addrs[NDIRECT]) == 0){ ip->addrs[NDIRECT] = addr = balloc(ip->dev); } //如果这个代表第12个块的块是0,说明没有被分配,那么就 //给他分配一个块返回的也是blockno bp = bread(ip->dev, addr); //根据这个地址,找到这个块 a = (uint*)bp->data; //读取这个块的内容,内容就是指向的块 if((addr = a[bn]) == 0){ //读取的内容是0仍然重复的创建块 a[bn] = addr = balloc(ip->dev); log_write(bp); //此时这个块已经创建好了,同时buf也是修改过的将这个buf写入日志中 } brelse(bp); return addr; } bn -= NINDIRECT; if(bn < DOUBLE_NINDIRECT){ //如果是属于第12个块 uint bn1 = bn / NINDIRECT; uint bn2 = bn % NINDIRECT; if((addr = ip->addrs[NDIRECT+1]) == 0) ip->addrs[NDIRECT+1] = addr = balloc(ip->dev); bp = bread(ip->dev, addr); a = (uint*)bp->data; if((addr = a[bn1]) == 0){ a[bn1] = addr = balloc(ip->dev); log_write(bp); } brelse(bp); bp1 = bread(ip->dev, addr); b = (uint*)bp1->data; if((addr = b[bn2]) == 0){ b[bn2] = addr = balloc(ip->dev); log_write(bp1); } brelse(bp1); return addr; } panic("bmap: out of range"); }
然后就是itrunc()
函数了
void itrunc(struct inode *ip) { int i, j; struct buf *bp; uint *a; for(i = 0; i < NDIRECT; i++){ //对于直接储存的block我们直接bfree对应的block就行 if(ip->addrs[i]){ bfree(ip->dev, ip->addrs[i]); ip->addrs[i] = 0; } } if(ip->addrs[NDIRECT]){ //对于间接储存的block 我们要先将最远端的储存block free掉,然后才能free间接储存的block, bp = bread(ip->dev, ip->addrs[NDIRECT]); a = (uint*)bp->data; for(j = 0; j < NINDIRECT; j++){ if(a[j]) bfree(ip->dev, a[j]); } brelse(bp); bfree(ip->dev, ip->addrs[NDIRECT]); ip->addrs[NDIRECT] = 0; } ip->size = 0; iupdate(ip); }
至于lab中的itrunc实现,我相信你能看懂上面的写起来应该不是难事吧。
ok 核心函数都介绍完毕了,还剩下几个根据上面核心函数的一些上层函数,也是file system 中其他上层模块直接调用的函数:
//根据inode 来读取他只想的data中的数据. // ip是inode指针,user_dst表示要目标地址是否是用户地址(暂时不管) //dst 是目标地址,off是要读取数据的偏移,n是要读取数据的大小 int readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n) { uint tot, m; struct buf *bp; if(off > ip->size || off + n < off) return 0; if(off + n > ip->size)//如果n大于数据剩下的长度,那么就以实际的数据长度为标准 n = ip->size - off; for(tot=0; tot<n; tot+=m, off+=m, dst+=m){//循环读取 bp = bread(ip->dev, bmap(ip, off/BSIZE));//根据偏移找到对应的具体那一个block,读取这个block的 buf m = min(n - tot, BSIZE - off%BSIZE); //每一次最后要读取完一页,最后一次读不能超过页的大小,所以每一 次增长就是按这个算法来 if(either_copyout(user_dst, dst, bp->data + (off % BSIZE), m) == -1) {//实际的内存copy brelse(bp); tot = -1; break; } brelse(bp); } return tot; } //跟上面的读差不多,但是有多一点的操作。 int writei(struct inode *ip, int user_src, uint64 src, uint off, uint n) { uint tot, m; struct buf *bp; if(off > ip->size || off + n < off) return -1; if(off + n > MAXFILE*BSIZE) return -1; for(tot=0; tot<n; tot+=m, off+=m, src+=m){ bp = bread(ip->dev, bmap(ip, off/BSIZE)); m = min(n - tot, BSIZE - off%BSIZE); if(either_copyin(bp->data + (off % BSIZE), user_src, src, m) == -1) { brelse(bp); break; } log_write(bp);//要将新的buf写入磁盘中 brelse(bp); } if(off > ip->size) ip->size = off;//更新inode的size // write the i-node back to disk even if the size didn't change // because the loop above might have called bmap() and added a new // block to ip->addrs[]. iupdate(ip);//更新dinode return tot; }
ok innode层的函数基本上说完了。在这一层我们要对xv6系统中如何储存 文件的大致的图片一定要在脑子里面有一个概念。此外还要弄清楚inode 和 buf到底是什么关系。这样这一层基本上就清晰了。
directory layer
对于目录文件inode 中会的type标识为目录格式。此外文件目录的data区中保存的是一项一项的entry,每一个entry的结构如下
struct dirent { ushort inum;//该data对应的inode char name[DIRSIZ];//表示某个file的名字 }; //data区域中的数据由很多个entry填
可以通过dirlookup()
函数来根据directory inode查找是否有该文件:
//dp是目录的inode,name是要查找的名字,poff是这个entry在inode指定data中的offset struct inode* dirlookup(struct inode *dp, char *name, uint *poff) { uint off, inum; struct dirent de; if(dp->type != T_DIR) panic("dirlookup not DIR"); for(off = 0; off < dp->size; off += sizeof(de)){ // 读取inode指向的data区域,一次读取一个entry if(readi(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))//读取函数 panic("dirlookup read"); if(de.inum == 0) continue; if(namecmp(name, de.name) == 0){//判断名字是否相等 // entry matches path element if(poff) //设置偏移 *poff = off; inum = de.inum; //得到找到文件的inode号 return iget(dp->dev, inum);//根据inode号来获取inode } } return 0; }
上面那个是查找,下面这个就是写入entry了,可以猜一猜可能要用到上面的哪些函数来实现(没错就是writei()
//dp就是要插入的目录的inode, name 就是要插入的文件的name,inum就是要插入文件的inum int dirlink(struct inode *dp, char *name, uint inum) { int off; struct dirent de; struct inode *ip; // Check that name is not present. if((ip = dirlookup(dp, name, 0)) != 0){//查找有没有同名文件 iput(ip); return -1; } // Look for an empty dirent. for(off = 0; off < dp->size; off += sizeof(de)){ //寻找的空位的entry if(readi(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de)) panic("dirlink read"); if(de.inum == 0) break; } strncpy(de.name, name, DIRSIZ);//复制名字 de.inum = inum; //复制inode号 if(writei(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de)) //将新的de写入dp中 panic("dirlink"); return 0; }
Path names
这个里面的函数没啥好讲的都是根据路径进行的一些操作。我也不太感兴趣,跳过。
File descriptor layer
这一层就比较重要了。unix系统的特点就是一切皆文件,对于不了解操作系统,我们只能知道打开文件后会返回一个int类型的文件描述符,根据这个fd就能操作文件,那么底层是怎样的呢。知道了底层后我们能够更加自信的使用文件描述符。
进程结构体中我们 有这样的成员
struct file *ofile[NOFILE]; // Open files,最大16个
表示该进程打开的文件描述符集合(数组); file 结构体如下:
struct file { enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;//文件的类型 int ref; // reference count 引用计数 char readable; char writable; struct pipe *pipe; // FD_PIPE struct inode *ip; // FD_INODE and FD_DEVICE uint off; // FD_INODE 打开同一个文件有不相同的偏移量,每一个进程都有自己的file table short major; // FD_DEVICE }; //这个file简单来说就是对inode 的进一步封装,也符合最开始说的层数的关系
每一个进程都有自己的文件描述符集合,所以不同的进程打开相同的文件有自己的偏移量。
这里需要注意的是,拥有自己的偏移量的前提是文件是由该进程自己open的,而不是通过fork继承来的。
如果是fork产生的进程,那么两个进程打开的文件描述符一模一样,也会有相同的偏移量(应该是直接拷贝的
除了每一个进程都有自己的文件描述符集合外,内核中还有所哟打开的文件描述符的集合
struct { struct spinlock lock; struct file file[NFILE];//最大100个 } ftable;
那么我们一般调用read()的时候,用的是一个int类型来表示文件,内核如何将这个int指向对应的file呢,是根据这个全局变量,还是根据进程中的变量呢。我们看一下argfd这个函数:
static int argfd(int n, int *pfd, struct file **pf) { int fd; struct file *f; if(argint(n, &fd) < 0) return -1; if(fd < 0 || fd >= NOFILE || (f=myproc()->ofile[fd]) == 0)//显然是通过进程的fd集合来获取file return -1; if(pfd) *pfd = fd; if(pf) *pf = f; return 0; }
至于后面的一些列函数基本上都是调用我们上面讲过的基础函数实现的功能,这里就不具体讲了。
System call
具体的各个层级我们都介绍完了,这里再稍微说说几个系统调用。用课本上的原话来说就是,有了很多的基础的底层的实现后, 很多系统调用的实现都是很简单的。
首先就是一个文件的连接数,也就是sys_link。这个对于第二个lab很有作用。
这里先简单介绍一些软硬链接:
硬链接指通过索引节点(inode)来链接。 对于两个硬链接的文件A和B(),他们两个文件名对应的inode节点是同样的,所以指针指向的block是一样的。硬链接有点类似于shared_ptr,只有一个inode的硬链接数目为0的时候才会删除该文件(对应于上面的删除文件)。有多个硬链接的文件,删除任一一个都不会删除inode表中的表项。
硬链接的创建方法
ln f1 f2
该命令是创建f1的一个硬链接文件f2
软连接也成为了符号连接,类似于window中的快捷方式。实际上他是一个特殊的文件,它对应的inode号跟连接的文件不一样,它的block中存放的是另一个连接文件的位置信息。例如A是B的软连接,那么A与B的inode不同,而A的数据块中存放的是B的路径名,所有可以找到B。
//这个函数就是根据用户区提供的新旧地址来创建硬链接。 uint64 sys_link(void) { char name[DIRSIZ], new[MAXPATH], old[MAXPATH]; struct inode *dp, *ip; if(argstr(0, old, MAXPATH) < 0 || argstr(1, new, MAXPATH) < 0) //获取两个path return -1; begin_op(); if((ip = namei(old)) == 0){//根据旧path获得inode end_op(); return -1; } ilock(ip); if(ip->type == T_DIR){ iunlockput(ip); end_op(); return -1; } ip->nlink++;//将inode的计数+1 iupdate(ip); iunlock(ip); if((dp = nameiparent(new, name)) == 0)//找到新path的目录inode goto bad; ilock(dp); if(dp->dev != ip->dev || dirlink(dp, name, ip->inum) < 0){//在新的目录inode中添加条目,inum与旧的一样 iunlockput(dp); goto bad; } iunlockput(dp); iput(ip); end_op(); return 0; bad: ilock(ip); ip->nlink--; iupdate(ip); iunlockput(ip); end_op(); return -1; }