xv6 risc-v file system

408 阅读9分钟

概述

文件系统层次

image.png xv6 文件系统的实现分为七层:
  1.磁盘层在 virto 磁盘上读写块。
  2.Buffer 缓存层缓存磁盘块,并同步访问他们,确保一个块只能同时被内核中的一个进程访问。
  3.日志层允许上层通过事物更新多个磁盘块,并确保在崩溃时,磁盘块的更新操作是原子的。
  4.inode 层将一个文件都表示为一个 inode,每个文件包含一个唯一的 i-number(in memory)和一些存放文件数据的块。
  5.目录层将实现一种特殊的 inode,其中包含一个目录项序列,每个目录项由文件名和 i-number 组成。
  6.路径名层提供了层次化的路径名,可以用递归查找进行解析。
  7.文件描述符层用文件系统接口抽象了许多 Unix 资源(如管道、设备、文件等)。

文件系统结构

image.png

  第 0 个 block 是 boot 引导块,文件系统不使用这个块。第 1 个 block 是 superblock(超级块),记录了文件系统的元数据(包括以 block 为基本单位的文件系统的大小、日志块的块数量、inode 的数量、数据块的数量)。从第 2 个 block 开始存放日志。接下来是 inodes,记录了各个文件的属性(类型、大小、组成该文件的 block块号,可在 data 部分根据块号查找)。再下来是 bitmap 位图,记录数据块中哪些部分是在使用的。最后一部分就是存放数据的数据块部分。

startBlockinodes+sizeof(inode)inumbersizeof(block)startBlock_{inodes} + \frac{sizeof(inode) * i_{number}}{sizeof(block)}

superblock

// Disk layout:
// [ boot block | super block | log | inode blocks |
//                                          free bit map 
struct superblock {
  uint magic;        // Must be FSMAGIC
  uint size;         // Size of file system image (blocks)
  uint nblocks;      // Number of data blocks
  uint ninodes;      // Number of inodes.
  uint nlog;         // Number of log blocks
  uint logstart;     // Block number of first log block
  uint inodestart;   // Block number of first inode block
  uint bmapstart;    // Block number of first free map block
};

  记录了文件系统的元数据,包括 inodes、log、bitmap、data部分的大小以及起始位置。

image.png   可以看出 xv6 文件系统块总数为20000。

Buffer cache layer

   主要作用是缓存块,包括块的元数据、块内数据。

// double list
struct buf {
  int valid;   // 是否从磁盘读取了数据
  int disk;    // 缓冲区的内容已经被修改,需要被重新写入磁盘
  uint dev;
  uint blockno;
  struct sleeplock lock; //buffer 缓存为每个 buffer 的都设有 sleep lock,以确保每次只有一个线程使用 buffer
  uint refcnt;  // 使用 refcnt 引用计数表示当前 buffer 是否空闲
  struct buf *prev; // LRU cache list
  struct buf *next;
  uchar data[BSIZE];
};

struct {
  struct spinlock lock;
  struct buf buf[NBUF];
  struct buf head;
} bcache;

  通过代码可以看出,buffer cache 中的使用双向链表实现 LRU,将最近使用的放在头部。对于已分配的 buffer 使用设备号、块号进行标识。所以查找是否已经在某个设备中某个块号时,会是从头开始向尾部扫描;分配内存块时,是从尾部向头部扫描。

初始化 buffer cache 数组 bcache

void
binit(void)
{
  struct buf *b;

  initlock(&bcache.lock, "bcache");

  // Create linked list of buffers
  // 系统启动时创建固定大小的链表,采用头插法
  bcache.head.prev = &bcache.head;
  bcache.head.next = &bcache.head;
  for(b = bcache.buf; b < bcache.buf+NBUF; b++){
    b->next = bcache.head.next;
    b->prev = &bcache.head;
    initsleeplock(&b->lock, "buffer");
    bcache.head.next->prev = b;
    bcache.head.next = b;
  }
}

  观察代码发现,在 xv6 中,对 buffer cache 数组、进程结构体数组、全局打开文件表、进程打开文件表、log 等都是直接指定了数组大小,若进行分配动作,就遍历对应数组,找到引用计数为 0,或者是当前状态为 NEW 之类的,进行分配。

从磁盘读一个磁盘块到内存

struct buf*
bread(uint dev, uint blockno)
{
  struct buf *b;
  // bget就根据dev、blockno在内存 buffer cache中线性查找是否有这个dev、blockno的 buf
  // 如果有则返回,没有就尝试进行分配,并且将dev、blockno写入buf元数据。
  // 注意,buffer 中是使用 refcnt 引用计数表示当前内存块是否空闲
  b = bget(dev, blockno);
  if(!b->valid) { // valid 表示 dev、blockno 对应的内存 buf 是否已经存在在 buffer cache 中。
  // 如果 buf 是新分配的,则会从磁盘读数据到 buf.data,并置 valid 为 1,下次如果再次 bread,则可以直接返回。
    virtio_disk_rw(b, 0);//
    b->valid = 1; // 修改 valid 为 1
  }
  return b;
}

  内存 buffer cache 有大小要求,当全部 buf 已经被占用的时候,就会 panic。

从内存 buffer cache 释放一个 buf

void
brelse(struct buf *b)
{
  if(!holdingsleep(&b->lock))
    panic("brelse");

  releasesleep(&b->lock);

  acquire(&bcache.lock);
  b->refcnt--;
  // 使用 refcnt 为 0,则可以释放
  if (b->refcnt == 0) {
    // no one is waiting for it.
    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);
}

  是由 file、log 层调用 brelsebread

Log

  log 记录了修改的数据块。

struct logheader {
  int n;
  int block[LOGSIZE];
};

struct log {
  struct spinlock lock;
  int start;
  int size;
  int outstanding; // 正在执行的文件系统调用的次数
  int committing;  // 是否有正在执行 commit 操作
  int dev;
  struct logheader lh;
};

rule

  • write head log rule:在对磁盘块的修改最终写入到原本位置之前,需要先到 log 中;
  • freeing rule:在 log 完成 commit 前,所有日志块不能被复用或覆盖 。

sequence

//       begin_op()
//       modify()
//       end_op()
void
begin_op(void)
{
  acquire(&log.lock);
  while(1){
    if(log.committing){ // end_op 在标记 log.committing = 1 后,会释放 log.lock,但是还未执行完 commit(),所以 begin_op 会获取 log.lock,因而会在此 sleep,等待被唤醒,才能进行后续操作。 
      sleep(&log, &log.lock);
    } else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){ // LOGSIZE = MAXOPBLOCKS * 3, 推出最多同时能有三个写 log 的操作。
      // this op might exhaust log space; wait for commit.
      sleep(&log, &log.lock);
    } else { // 上述条件均满足,则可以进行本次 fs_sys_call 操作
      log.outstanding += 1;
      release(&log.lock);
      break;
    }
  }
}

void
end_op(void)
{
  int do_commit = 0;
  // 假设有多个 transaction 当前正在进行,则 log.outstanding -= 1 后,仍然大于 1 ,表明当前仍然有 transaction 在写 buffer cache,此时还未到写 buffer cache 到 log 阶段。
  acquire(&log.lock); 
  log.outstanding -= 1;
  if(log.committing) // 如果当前已经有 transaction 处于写 buffer cache 到 log 阶段,那么会 panic。保证了同一时刻,只能有一个 transaction 执行 commit。
    panic("log.committing");
  if(log.outstanding == 0){ // 如果当前不存在额外 transaction,则本次 transaction 可以提交 
    do_commit = 1;
    log.committing = 1;
  } else { // 为什么这里会 wakeup?对于 begin_op 中,新 transaction 由于 log 空间原因被sleep,需要本次 end_op() 释放空间得以 wakeup()。
    // 为什么不在 commit() 操作结束后才 wakeup()?
    // begin_op() may be waiting for log space,
    // and decrementing log.outstanding has decreased
    // the amount of reserved space.
    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) {
    write_log();     // Write modified blocks from cache to log
    write_head();    // Write header to disk -- the real commit
    install_trans(0); // Now install writes to home locations
    log.lh.n = 0;
    write_head();    // Erase the transaction from the log
  }
}

   log_write 在 log header 中记录被修改过数据的 buffer cache 块。

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");

  acquire(&log.lock);
  for (i = 0; i < log.lh.n; i++) {
    if (log.lh.block[i] == b->blockno)   // log absorbtion
      break;
  }
  log.lh.block[i] = b->blockno;
  if (i == log.lh.n) {  // Add new block to log?
    // 为什么调用bpin函数增加此 buffer 的引用计数?
    bpin(b);
    log.lh.n++;
  }
  release(&log.lock);
}

Inode layer

  inode layer 将一个文件表示成为一个 inode。两种可能的含义:

  • on-disk inode 表示磁盘上的数据结构,包含文件大小、数据块号的列表;
  • in-memory inode,表示内存中的数据结构,包含了磁盘上 inode 的副本、内核中需要的其他信息。

On-disk inode

  On-disk inode 放置在磁盘的一个连续区域中,且每一个 inode 的大小都是一样的。

// On-disk inode structure
struct dinode {
  short type;           // 文件类型:文件、目录和特殊文件(设备),type 为 0 表示该 inode 是空闲的
  short major;          // 主设备号 (T_DEVICE only)
  short minor;          // 次设备号 (T_DEVICE only)
  short nlink;          // 文件系统中引用这个 inode 的目录项的数量
  uint size;            // 文件中内容的字节数
  uint addrs[NDIRECT+1];   // 持有文件内容的磁盘块的块号
};

In-memory inode

内核将正在使用的 inode 保存在内存中;结构体 inode 是磁盘 dinode 的拷贝。内核只在有指针指向 inode 时才会存储

// in-memory copy of an inode
struct inode {
  uint dev;           // 设备号
  uint inum;          // inode 编号,从磁盘读进来时,动态分配
  int ref;            // 引用计数
  struct sleeplock lock; // protects everything below here
  int valid;          // 当前inode是否从磁盘读出

  // 下面的和磁盘node是相同的
  short type;         
  short major;
  short minor;
  short nlink;         
  uint size;
  uint addrs[NDIRECT+1];
};

image.png

icache

   类似于bcache的作用,用来维护在内存中inode数组。

struct {
  struct spinlock lock;
  struct inode inode[NINODE];
} icache;

  icache.lock保证了一个inode在缓存中只有一个副本,以及缓存inoderef字段计数的正确性。
  每个inode都有一个sleep-lock字段,它保证了可以独占访问inode的其他字段以及inode的文件或者目录内容块。

relate function
  ialloc 分配内存 inode,并标记已使用。而后通常由调用函数对 inode 的其他属性进行赋值,以此来表示一个文件。

struct inode*
ialloc(uint dev, short type)
{
  int inum;
  struct buf *bp;
  struct dinode *dip;
  for(inum = 1; inum < sb.ninodes; inum++){
    bp = bread(dev, IBLOCK(inum, sb));
    dip = (struct dinode*)bp->data + inum%IPB;
    if(dip->type == 0){  // a free inode
      memset(dip, 0, sizeof(*dip));
      dip->type = type;
      log_write(bp);   // mark it allocated on the disk
      brelse(bp);
      return iget(dev, inum);
    }
    brelse(bp);
  }
  panic("ialloc: no inodes");
}

  前置知识已知文件系统的存储结构中有一段连续的区域存储文件的元数据信息,称之为 inodes 区域。IBLOCK 确定了编号为 inum 的 inode 所在 inodes 区域的磁盘块号,由 bread 将该磁盘块从磁盘读到内存,找到 inum 对应 dinode,并通过对 type 字段的判断,找到还未分配的 inode 进行分配,随后调用 iget 函数,将这个刚刚分配的 dinode 读入内存。

// 将修改过的内存 inode 写回到磁盘 dinode 中。并且由于 i-node cache 是 write-through,
// 必须要直接写回磁盘,因此调用者必须持有 ip->lock 以免在写回磁盘的过程中,数据被修改。
void
iupdate(struct inode *ip)
{
  struct buf *bp;
  struct dinode *dip;
  // bread 读取了当前 ip->num元数据所在的block
  bp = bread(ip->dev, IBLOCK(ip->inum, sb));
  // ip->inum%IPB == inum * sizeof(dinode) % BSIZE
  dip = (struct dinode*)bp->data + ip->inum%IPB;
  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);
}

  itrunc 函数的作用是清除 inode 数据内容。由于要改 inode,因此必须要持有 inode->lock
  

Directory layer

struct dirent {
  ushort inum;        // inode 号
  char name[DIRSIZ];  // 名称
};