1.CH-8文档学习笔记

第八章 文件系统

xv6文件系统类似于Unix的文件、目录和路径名,并将数据持久化到virtio磁盘上。文件系统解决以下难题:

  • 文件系统需要磁盘上的数据结构来表示目录和文件名称树,记录保存每个文件内容的块的标识,记录磁盘的哪些区域是空的。
  • 文件系统必须支持崩溃恢复(crash recovery)。如果计算机发生崩溃,文件系统必须在重新启动后仍能正常工作。难点在于崩溃时可能会中断一些操作,使得磁盘上的数据结构不一致(例如,一个块被某个文件使用但仍被标记为空闲)。
  • 多个进程同时在文件系统上运行,文件系统代码需要能够处理并发环境。
  • 磁盘的速度比内存慢几个数量级,因此文件系统必须将常用文件块缓存到内存中。

本章将解释xv6如何解决这些挑战。

8.1 概述

xv6文件系统分为七层,如下图所示。

  • Disk层在虚拟硬盘上读写扇区。
  • Buffer Cache层缓存磁盘块并同步对它们的访问,确保同一时刻只有一个内核进程可修改数据。
  • Logging层允许更高层将对多个更新操作包装到一次事务中,确保在崩溃时块被原子更新(要么全部更新,要么不更新)。
  • inode层提供文件服务,每个文件都表示为具有唯一i-numberinode和一些块,这些块保存文件的数据。
  • Directory层实现目录为一种特殊类型的inode,其内容是一系列目录条目,每个条目都包含文件的名称和i-number
  • Pathname层提供类似/usr/rtm/xv6/fs.c这样的分层路径名称,并使用递归查找解析它们。
  • File descriptor层使用文件系统接口,抽象了许多Unix资源(例如,管道、设备、文件等),简化应用程序程序员的工作。
image-20240329101911349

磁盘硬件将磁盘分为大小为512字节的块序列(即扇区):扇区0是第一个512字节,扇区1是下一个...。文件系统的块大小通常是扇区大小的倍数。Xv6在struct buf中保存了已读入内存的块副本(kernel/buf.h:1)。存储在其中的数据有时与磁盘不同步:可能尚未从磁盘读出(磁盘硬件正在处理,尚未返回内容),或者已被软件更新,但尚未写回磁盘。

文件系统必须提前规划它在磁盘上存储inode和内容块的位置。xv6将磁盘分成以下几个部分,如图8.2所示。

  • Block 0不使用(用于保存启动扇区)。

  • Block 1为超级块(super block),包含元数据(文件系统大小(以块为单位)、数据块的数量、inode的数量以及日志中的块数)。

  • Block 2保存日志。

  • 之后是inodes,每个块中有多个inode。

  • 之后是位图块,记录数据块使用情况。

  • 剩余是数据块;

超级块由一个名为mkfs的单独程序填充,该程序构建了初始文件系统。

本章将从缓冲区开始讨论每一层。注意观察在哪些情况下,较低层的抽象简化了更高层的设计。

image-20240401142020069

8.2 Buffer cache层

Buffer cache有两个任务:

  1. 同步对磁盘块的访问,确保磁盘块在内存中只有一个副本,且一次只有一个内核线程使用该副本。
  2. 缓存常用块,以便不需要从慢速磁盘读取它们。代码在bio.c中。

主要接口包括breadbwrite;前者获取一个包含块副本的buf,该副本可以在内存中进行读写,后者将修改后的缓冲区写回磁盘。

一个内核线程在完成操作后必须通过brelse释放缓冲区。Buffer cache使用sleep lock确保每个缓冲区每次只被一个线程使用;bread返回一个已上锁的缓冲区,brelse释放该锁。

Buffer cache中,缓冲区数量是固定的,如果文件系统请求新块,Buffer cache必须先回收旧块。回收算法是LRU。

8.3 代码:Buffer cache

Buffer cache是一个双向链表,其中包含缓冲区。数据结构定义是struct bcache

8.3.1 bcache

struct {
  struct spinlock lock;
  struct buf buf[NBUF];

  // Linked list of all buffers, through prev/next.
  // Sorted by how recently the buffer was used.
  // head.next is most recent, head.prev is least.
  struct buf head;
} bcache;

8.3.2 binit

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;
  }
}

函数binit(由main(kernel/main.c:27)调用)初始化该链表。对缓冲区缓存的所有访问都通过bcache.head引用链表简介访问buf数组,而不是直接访问buf数组。

struct buf有两个状态字段:

  • valid表示是否包含块副本
  • disk表示内容是否已写回磁盘

8.3.3 bget

static struct buf* bget(uint dev, uint blockno){
  struct buf *b;
  acquire(&bcache.lock);

  // 遍历bcache,查找是否已缓存给定数据块
  for(b = bcache.head.next; b != &bcache.head; b = b->next){
    if(b->dev == dev && b->blockno == blockno){
      // 找到给定块, 增加refcnt, 返回
      b->refcnt++;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      return b;
    }
  }

  // 未缓存,找到一个空闲块,返回供缓存用
  for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
    if(b->refcnt == 0) {
      b->dev = dev;
      b->blockno = blockno;
      b->valid = 0;
      b->refcnt = 1;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      return b;
    }
  }
  panic("bget: no buffers");
}

扫描缓冲区列表,查找给定设备和扇区号的缓冲区。

  • 如果存在,获取缓冲区的睡眠锁。然后返回锁定的缓冲区。
  • 如果未缓存,二次扫描缓冲区列表,查找未使用的缓冲区(b->refcnt = 0)。之后编辑该缓冲区,记录新设备和扇区号,并获取其睡眠锁。

注:设置b->valid=0确保bread将从磁盘读取数据,而不是错误地使用缓冲区的旧内容。

8.3.4 bread

struct buf* bread(uint dev, uint blockno){
  struct buf *b;

  b = bget(dev, blockno);
  if(!b->valid) {
    virtio_disk_rw(b, 0);
    b->valid = 1;
  }
  return b;
}

调用bget获取包含指定扇区内容的缓冲区。如果没有缓存,bread会先通过virtio_disk_rw读出内容,然后返回。

每个扇区只有一个缓冲区非常重要,因为文件系统使用缓冲区的锁进行同步,确保读操作可以看到写操作,bget在执行期间一直持有bcache.lock,以确保检查块是否存在以及(如果不存在)复用缓冲区的操作是原子性的。

bgetbcache.lock之外获取缓冲区的睡眠锁是安全的,因为非零的b->refcnt防止了缓冲区被重用。

  • 睡眠锁保护缓冲区中的内容
  • bcache.lock保护缓存区的元信息(存储扇区编号,refcnt等)。

如果所有缓冲区都已被使用,那么bget将会panic。一个更优雅的响应可能是在缓冲区空闲之前休眠,但这样可能会出现死锁。

一旦bread读取了磁盘并将缓冲区返回给调用者,调用者就可以独占使用缓冲区,读写数据。如果修改了缓冲区的数据,则必须在释放缓冲区之前调用bwrite将更改写回磁盘。bwrite调用virtio_disk_rw完成这个操作。

void bwrite(struct buf *b){
  if(!holdingsleep(&b->lock))
    panic("bwrite");
  virtio_disk_rw(b, 1);
}

使用完缓冲区后,必须调用brelse释放它。brelse释放睡眠锁,按照LRU的规则将缓冲区放到链表的最前面。

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

  releasesleep(&b->lock);

  acquire(&bcache.lock);
  b->refcnt--;
  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);
}

Buffer Cache层总结

  1. 和Disk层直接打交道,接收设备号和扇区号,返回内容,如有必要,会从硬盘将数据读至Cache,之后返回缓存地址供上层使用
  2. 返回的缓冲区会加锁,因此每个缓冲区都是独占使用,其他进程想访问,会进入睡眠,即每个扇区同一时刻只有一个进程能访问。

8.4 Log层

Log主要为解决崩溃恢复。如果文件操作在执行一半时崩溃,就可能导致磁盘上的文件处于不一致的状态。例如,在文件截断时崩溃。可能会留下一个引用了已标记为空闲的内容块的inode,或留下一个已分配但未引用的内容块。

后者相对无害,但前者会引起严重问题。该块可能会被分配给另一个文件,此时两个文件同时使用该块,将出错。

Xv6通过日志解决该问题,比如写入文件时,Xv6不会直接写入磁盘。而是先将需要进行的所有操作写入磁盘上的日志中。之后写入一个commit标记,表示日志完整。之后再将这些操作从日志复制到文件中。最后擦除日志。

如果系统崩溃,重启时文件系统会进行恢复:如果日志完整(含有commit),那就根据日志将操作执行完成。如果日志不完整,就忽略并擦除日志。

为什么日志解决了崩溃的问题?假如文件操作执行一半时崩溃:

  1. 若崩溃发生在commit之前:磁盘上的日志将不完整,恢复代码将忽略它,磁盘内容没变。

  2. 若崩溃发生在commit之后,那么恢复将重新复制所有块,可能会重复复制某些块,但是内容不会出错。

可以看出,Log使文件操作具有原子性,所有操作要么都执行,要么都不执行,文件将不会处于中间状态,即不出错。

8.5 Log设计

image-20240401142020069

日志位于super块指定的固定位置。由一个头块(header block)和一系列更新块的副本组成。头块包含一个扇区号数组,每个logged block都有一个,以及日志块的计数。 header block中的计数要么是零,表示日志中没有事务,要么是非零,表示日志包含一个完整的提交事务,并指出logged block的块数。Xv6在事务提交commit时写入头块,在此之前不会写入。在将logged blocks复制到文件系统后将计数清零。

每个系统调用的代码都指示写入序列的起止,考虑到崩溃,写入序列必须具有原子性。为允许不同进程并发执行文件系统操作,日志系统可以将多个系统调用的写入累积到一个事务中。因此,单次提交可能涉及多次写入。为避免在事务之间拆分系统调用,日志系统仅在没有文件系统调用进行时提交。

将多个事务一起提交的想法称为组提交(group commit)。组提交减少了磁盘操作的数量,因为一次提交完成了多次操作。组提交向磁盘系统提供更多的并发写入,允许磁盘在一次磁盘旋转期间内完成所有写入。Xv6的virtio驱动程序不支持这种批处理,但Xv6的文件系统设计允许这样做。

Xv6在磁盘上专门留出固定空间保存日志。事务中系统调用写入的块数必须小于该空间。这有两个后果。

  1. 单个系统调用不允许写入超出日志空间的块数量。这对于大多数系统调用来说不是问题,但有两个调用可能会写入许多块:writeunlink。一个大文件的write需要写入多个数据块和多个位图块以及一个inode块;unlink大文件可能会写入许多位图块和inode。Xv6的write系统调用将大的写入分解为适合日志的多个较小的写入,unlink不会导致此问题,因为实际上Xv6文件系统只使用一个位图块。
  2. 除非确定系统调用的写入小于日志中剩余的空间,否则日志系统无法允许启动系统调用。

8.6 代码:Log

Log的相关代码都在kernel/log.c中,使用日志的代码模板如下:

 begin_op();
 ...
 bp = bread(...);
 bp->data[...] = ...;
 log_write(bp);
 ...
 end_op(); 

下面介绍日志相关的部分函数

8.6.0 相关结构体

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;
};
struct log log;

8.6.1 begin_op

功能:

  1. log.lock加锁,确保只有一个内核进程在日志系统上执行
  2. 当log空间不足时,sleep等待
void begin_op(void){
  acquire(&log.lock);
  while(1){
    if(log.committing){
      // 如果日志系统此时处于commit状态,将sleep到commit完成
      sleep(&log, &log.lock);
    } else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
      // 代码为每次日志系统调用预留MAXOPBLOCKS个块用于保存日志
      // 这里是判断是否有足够空间,如果日志空间不足,sleep
      sleep(&log, &log.lock);
    } else {
      // log.outstanding 表示当前有多少个系统调用在调用日志系统
      log.outstanding += 1;
      release(&log.lock);
      break;
    }
  }
}

8.6.2 log_write

#define MAXOPBLOCKS  10  // max # of blocks any FS op writes
#define LOGSIZE      (MAXOPBLOCKS*3)  // max data blocks in on-disk log
void log_write(struct buf *b) {
  int i;
  acquire(&log.lock);
  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");

  for (i = 0; i < log.lh.n; i++)
    if (log.lh.block[i] == b->blockno)   // log absorption
      break;
  log.lh.block[i] = b->blockno;
  if (i == log.lh.n) {  // Add new block to log?
    bpin(b);
    log.lh.n++;
  }
  release(&log.lock);
}
  1. 记录blockno,为以后多次写入合并时使用。

  2. 如果是旧块,之前已经记录,现在不需要进行任何操作,

  3. 如果是新块,调用bpin将缓存固定在block cache中,防止cache将其逐出。

这种将多次操作合并到一起的优化叫合并(absorption)

8.6.3 end_op

void end_op(void){
  int do_commit = 0;

  acquire(&log.lock);
  log.outstanding -= 1;
  if(log.committing)
    panic("log.committing");
  if(log.outstanding == 0){
    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(&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);
  }
}
  1. 减少outstanding计数。
  2. 如果outstanding为零,则通过调用commit()提交当前事务

8.6.4 commit

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
  }
}

分为四个阶段:

  1. write_log()将每个块从Buffer Cache写入到磁盘上的Log块中。
  2. write_head()将当前logheader写入磁盘,这里就是commit标记
  3. install_trans从日志中读取每个块,将其写入文件中。
  4. logheader中的计数清0后写入磁盘;这必须在下一次事务开始前完成,以防止发生崩溃重启后,系统使用前一个logheader和后一个事务的日志块进行错误恢复。

8.6.5 write_log

static void write_log(void){
  int tail;
  for (tail = 0; tail < log.lh.n; tail++) {
    // 从cache层获取log块
    struct buf *to = bread(log.dev, log.start+tail+1); // log block
    // 从cache层获取修改后的块
    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);
  }
}

8.6.6 recover_from_log

static void recover_from_log(void){
  // 从磁盘读取log header
  read_head();
  // 将日志写回磁盘, 传入的参数表示是否需要从cache中release该块,这里不需要
  install_trans(1);
  // 最后两步清理log header
  log.lh.n = 0;
  write_head(); // clear the log
}

开机时,通过forkret(kernel/proc.c) --> fsinit(kernel/fs.c) --> initlog调用进行恢复

总结

建立在Buffer Cache层之上,主要用于写入数据块时,保证写入的原子性,防止系统崩溃时,文件系统处于不一致状态

8.7 代码:Block allocator

所有磁盘块都是通过位图分配的,Xv6的块分配器在磁盘上维护一个空闲位图,每个扇区对应一个位。0表示空闲,1表示使用。程序mkfs.c设置对应于引导扇区、超级块、日志块、inode块和位图块的位。

提供两个函数:

8.7.1 balloc

#define BSIZE 1024  // 一个block大小为1kb
#define BPB   (BSIZE*8)
#define BBLOCK(b, sb) ((b)/BPB + sb.bmapstart)
static uint balloc(uint dev){
  int b, bi, m;
  struct buf *bp;
  bp = 0;
  // 每次处理一个块
  for(b = 0; b < sb.size; b += BPB){
    // 获取这个块对应的位图块
    bp = bread(dev, BBLOCK(b, sb));
    for(bi = 0; bi < BPB && b + bi < sb.size; bi++){
      m = 1 << (bi % 8);
      if((bp->data[bi/8] & m) == 0){  // Is block free?
        bp->data[bi/8] |= m;  // Mark block in use.
        log_write(bp);
        brelse(bp);
        bzero(dev, b + bi);
        return b + bi;
      }
    }
    brelse(bp);
  }
  printf("balloc: out of blocks\n");
  return 0;
}

从block 0搜索到sb.size(文件系统总块数)。寻找位图中状态为0的块(空闲块)。

循环被分成两部分。外循环读取位图块。内循环检查块中的每一位。

8.7.2 bfree

static void bfree(int dev, uint b){
  struct buf *bp;
  int bi, m;

  bp = bread(dev, BBLOCK(b, sb));
  bi = b % BPB;
  m = 1 << (bi % 8);
  if((bp->data[bi/8] & m) == 0)
    panic("freeing free block");
  bp->data[bi/8] &= ~m;
  log_write(bp);
  brelse(bp);
}

释放块,找到正确的位图块并清除正确的位。

同样,breadbrelse隐含的独占使用避免了显式锁定的需要。

正如本章余下部分的代码一样,balloc和bfree必须在事务内部调用。

8.8 Inode

inode有两种含义:

  1. 指包含文件元数组(如文件大小,块号列表)的磁盘数据结构。
  2. 指内存中的inode,包含磁盘上inode的副本以及内核需要的额外信息。
image-20240401142020069

磁盘上的inode被集中放置在inodes中,这是一块连续的磁盘区域。每个inode的大小都相同,因此很容易根据编号在磁盘上找到对应的inode。这个编号称为inode number或i-number,是inode的标识。

inode的磁盘数据结构:struct dinode(kernel/fs.h)

// On-disk inode structure
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
};
  • type区分文件、目录和特殊文件(设备)。0表示空闲
  • nlink记录引用inode的Directory数目,以判断是否需要释放inode及其数据块。
  • size记录文件内容的字节数。
  • addrs记录保存文件内容的磁盘块号。
// in-memory copy of an inode
struct inode {
  uint dev;           // Device number
  uint inum;          // Inode number
  int ref;            // Reference count
  struct sleeplock lock; // protects everything below here
  int valid;          // inode has been read from disk?

  short type;         // copy of disk inode
  short major;
  short minor;
  short nlink;
  uint size;
  uint addrs[NDIRECT+1];
};

内核将活跃的inode保存到内存中;struct inodestruct dinode的内存副本。

  • ref记录引用该inode的指针数量,为0时,将从内存中丢弃该inode。
  • iget()iput()的作用是获取释放指向inode的指针,同时修改引用计数。
struct {
  struct spinlock lock;
  struct inode inode[NINODE];
} itable;

inode代码中有四种锁或类似锁的机制。

  • itable.lock确保每个inode在inode table中只出现一次,以及ref字段的正确性。

  • 每个inode都有一个sleeplock lock字段,确保对inode的字段(如文件长度)以及inode文件或目录块的独占访问。

iget()返回的inode指针在iput()之前保证有效:期间inode不会被删除,指针引用的内存也不会被重用。iget()返回的是不加锁的inode指针,因此可以有多个指针指向同一inode。文件系统代码的许多部分都依赖于iget()的这种行为,既可实现对inode的长期引用(如打开的文件和当前目录),也可防止争用,避免操纵多个inode(如路径名查找)的代码产生死锁。

iget返回的struct inode可能没有有效内容,因为还未从磁盘加载。为确保它保存了磁盘inode的副本,代码必须调用ilock。这将锁定inode,并从磁盘读取inode的内容。

iunlock释放inode锁。将获取inode指针锁定inode分开有助于在某些情况下避免死锁,例如在目录查找期间。可以有多个进程同时指向iget返回的inode指针,但一次只能有一个进程锁定inode。

inode table只存储内核代码或数据结构持有C指针的inodes。它的主要职责是同步多个进程的访问。inode表也恰好缓存频繁使用的inodes,但缓存是次要的;如果一个inode使用频繁,buffer cache会将其保留在内存中。修改内存中inode的代码会使用iupdate将其写入磁盘。

8.9 代码:Inodes

image-20240401142020069
// 超级块结构体
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
};

8.9.1 ialloc()

// ialloc -- kernel/fs.c
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);
  }
  printf("ialloc: no inodes\n");
  return 0;
}

功能:分配新的inode(比如创建文件)

逻辑:遍历磁盘上的inode,查找空闲inode。找到后,写入新type,然后末尾通过调用igetinode缓存返回条目。ialloc的正确性依赖于一次只有一个进程可持有bp的引用(由Buffer Cache层的bget()保证),从而确保没有其他进程可以看到inode并尝试修改其类型。

8.9.2 iget()

static struct inode* iget(uint dev, uint inum){
  struct inode *ip, *empty;
  acquire(&itable.lock);

  // Is the inode already in the table?
  empty = 0;
  for(ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++){
    if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
      ip->ref++;
      release(&itable.lock);
      return ip;
    }
    if(empty == 0 && ip->ref == 0)    // Remember empty slot.
      empty = ip;
  }
  if(empty == 0)
    panic("iget: no inodes");
  ip = empty;
  ip->dev = dev;
  ip->inum = inum;
  ip->ref = 1;
  ip->valid = 0;
  release(&itable.lock);
  return ip;
}

功能:根据设备和inode编号,返回指定的inode

逻辑:如果在table中找到目标inode,则增加ref并返回,否则,记录itable中第一个空槽位位置,将目标inode分配到这个槽位中。

8.9.3 ilock()

// Lock the given inode.
// Reads the inode from disk if necessary.
void ilock(struct inode *ip){
  struct buf *bp;
  struct dinode *dip;
  if(ip == 0 || ip->ref < 1)
    panic("ilock");
  acquiresleep(&ip->lock);

  if(ip->valid == 0){
    bp = bread(ip->dev, IBLOCK(ip->inum, sb));
    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");
  }
}

// Unlock the given inode.
void iunlock(struct inode *ip){
  if(ip == 0 || !holdingsleep(&ip->lock) || ip->ref < 1)
    panic("iunlock");
  releasesleep(&ip->lock);
}

在读写inode的元数据或内容之前,必须使用ilock锁定inode。ilock为此使用睡眠锁。ilock获取到锁后,将根据需要从磁盘(也可能是buffer cache)读取inode。

iunlock释放睡眠锁,这会唤醒睡眠进程。

8.9.4 iput()

void iput(struct inode *ip){
  acquire(&itable.lock);

  if(ip->ref == 1 && ip->valid && ip->nlink == 0){
    acquiresleep(&ip->lock);
    release(&itable.lock);
    itrunc(ip);
    ip->type = 0;
    iupdate(ip);
    ip->valid = 0;
    releasesleep(&ip->lock);
    acquire(&itable.lock);
  }
  ip->ref--;
  release(&itable.lock);
}

iput每次调用减少ref,并通过ref判断是否需要释放该块

如果iput发现没有指向inode的指针,而且没有指向inode的链接(发生于无目录),则将释放inode及其数据块。

  • 调用itrunc将文件截断为零字节,释放数据块
  • 将索引节点类型设置为0(未分配)
  • 并将inode写回磁盘

当iput释放inode时,存在几个问题,第一个问题是,在iput释放inode时,会不会存在一个并发线程在ilock中等待使用该inode?这种情况不会出现,因为iput释放inode会检查ref和nlink数量,如果有另一个线程在使用该inode,ref和nlink就不会满足释放标准。

另一个问题是,对ialloc的并发调用可能会选择iput正在释放的同一个inode。这只能在iupdate写入磁盘以使inode的type为零后发生。这个争用是良性的:ialloc将等待inode的睡眠锁,然后再读取或写入inode,此时iput已完成。

iput()可以写入磁盘。这意味着任何使用文件系统的系统调用都可能写入磁盘,即使像read()这样看起来是只读的调用,也可能最终调用iput()。这也意味着,即使是只读系统调用,如果它们使用文件系统,也必须在事务中进行包装。

iput()崩溃之间有一个具有挑战性的交互。当文件的nlink降到零时,iput()不会立即截断文件,因为可能仍有进程在内存中持有该inode的引用:进程可能仍在读取和写入文件,因为它已成功地打开了文件。但是,如果最后一个进程在关闭文件描述符之前发生崩溃,那么文件将在磁盘上被标记为已分配,但没有目录项指向它。

可以有两种方式处理这种情况。简单的方案是在重启后的恢复阶段,文件系统扫描整个文件系统,寻找被标记为已分配但没有目录项指向的文件。如果存在这样的文件,那么它就可以释放这些文件。

第二种解决方案不需要扫描文件系统。在这个解决方案中,文件系统在磁盘上(例如,超级块中)记录了nlink为零但ref不为零的inode编号。当ref为0时,它会通过从列表中删除该inode来更新磁盘上的列表。在恢复阶段,文件系统释放列表中的所有文件。

Xv6没有实现这两种解决方案中的任何一种,这意味着即使inode不再使用,它们也可能在磁盘上被标记为已分配。随着时间的推移,xv6可能会面临磁盘空间耗尽的风险。

8.10 代码: Inode content

// On-disk inode structure
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
};

struct dinode包含size和块号数组(见图8.3)。inode数据块存储在dinodeaddrs数组中。分为直接块和间接块,addrs数组的前NDIRECT个为直接块。接下来还有NINDIRECT个间接块,addrs数组的最后一个元素存储了间接块的地址。直接块的大小为12 kB(NDIRECT x BSIZE),间接块存储下一个256 kB(NINDIRECT x BSIZE)。函数bmap管理这种表示,以便实现readiwritei这样更高级的方法。bmap(struct inode *ip, uint bn):返回索引结点ip的第bn个数据块的磁盘块号。如果ip还没有这样的块,bmap会分配一个。

image-20240405142815080

8.10.1 bmap

static uint bmap(struct inode *ip, uint bn){
  uint addr, *a;
  struct buf *bp;
  
  if(bn < NDIRECT){
    // 直接块
    if((addr = ip->addrs[bn]) == 0){
      addr = balloc(ip->dev);
      if(addr == 0)
        return 0;
      ip->addrs[bn] = addr;
    }
    return addr;
  }
  bn -= NDIRECT;

  if(bn < NINDIRECT){
    // 间接块, 先load间接块
    // Load indirect block, allocating if necessary.
    if((addr = ip->addrs[NDIRECT]) == 0){
      addr = balloc(ip->dev);
      if(addr == 0)
        return 0;
      ip->addrs[NDIRECT] = addr;
    }
    bp = bread(ip->dev, addr);
    a = (uint*)bp->data;
    if((addr = a[bn]) == 0){
      addr = balloc(ip->dev);
      if(addr){
        a[bn] = addr;
        log_write(bp);
      }
    }
    brelse(bp);
    return addr;
  }

  panic("bmap: out of range");
}

8.10.2 itrunc

void itrunc(struct inode *ip){
  int i, j;
  struct buf *bp;
  uint *a;

  for(i = 0; i < NDIRECT; i++){
    if(ip->addrs[i]){
      bfree(ip->dev, ip->addrs[i]);
      ip->addrs[i] = 0;
    }
  }

  if(ip->addrs[NDIRECT]){
    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);
}

itrunc释放文件的块,将size置0。先释放直接块,再释放间接块,最后释放间接块本身。

8.10.3 readi

int readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n){
  uint tot, m;
  struct buf *bp;

  // 若off超出文件范围,或者n < 0,反向读取,则直接返回
  if(off > ip->size || off + n < off)
    return 0;
  // 如果要读取的范围超出文件范围,则只返回文件范围内的数据
  if(off + n > ip->size)
    n = ip->size - off;

  for(tot=0; tot<n; tot+=m, off+=m, dst+=m){
    uint addr = bmap(ip, off/BSIZE);
    if(addr == 0)
      break;
    bp = bread(ip->dev, addr);
    m = min(n - tot, BSIZE - off%BSIZE);
    if(either_copyout(user_dst, dst, bp->data + (off % BSIZE), m) == -1) {
      brelse(bp);
      tot = -1;
      break;
    }
    brelse(bp);
  }
  return tot;
}

readi首先确保要读取的范围不超出文件。而从文件末尾开始或穿过文件末尾的读取返回的字节数少于请求的字节数。主循环处理文件的每个块,将数据从缓冲区复制到dst

8.10.4 writei

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){
    uint addr = bmap(ip, off/BSIZE);
    if(addr == 0)
      break;
    bp = bread(ip->dev, addr);
    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);
    brelse(bp);
  }

  if(off > ip->size)
    ip->size = off;

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

  return tot;
}

writei与readi相同,有三个例外:从文件末尾处或跨越文件末尾开始的写入操作会增加文件大小,最多到最大文件大小(kernel/fs.c:513-514);循环将数据复制到缓冲区而不是输出(kernel/fs.c:36);如果写入操作扩展了文件,writei必须更新其大小。

void stati(struct inode *ip, struct stat *st){
  st->dev = ip->dev;
  st->ino = ip->inum;
  st->type = ip->type;
  st->nlink = ip->nlink;
  st->size = ip->size;
}

函数stati将inode元数据复制到stat结构体中,通过stat系统调用向用户程序返回元数据。

8.11 代码:Directory

struct dirent {
  ushort inum;
  char name[DIRSIZ];
};

Directory的实现和文件很像。

  • Directory inode的typeT_DIR
  • 数据是一系列目录条目(directory entries)
  • 每个条目是一个struct dirent,包含name和inode编号inum。名称最多DIRSIZ(14)个字符;如果短于这个长度,则以NUL(0)结束
  • inum为0的目录条目是空闲的

8.11.1 dirlookup

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)){
    // 每次读出一个dirent
    if(readi(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
      panic("dirlookup read");
    // 如果inum为0,表示该目录条目为空
    if(de.inum == 0)
      continue;
    if(namecmp(name, de.name) == 0){
      // entry matches path element
      if(poff)
        *poff = off;
      inum = de.inum;
      return iget(dp->dev, inum);
    }
  }

  return 0;
}

dirlookup根据name返回对应inode。

若找到,通过iget返回指向相应的inode的未锁定指针,并设置poff为条目在目录内的字节偏移量,以防调用方需要编辑它。

返回未锁定inode是因为调用者已经锁定了dp,如果要查找的是".",即当前目录的别名,将会重新尝试锁定dp,从而导致死锁。调用方可以解锁dp然后锁定ip,确保一次只持有一个锁。

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)){
    // 每次读出一个dirent
    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;
  if(writei(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
    return -1;
  return 0;
}

dirlink会创建一个新的目录条目,写入指定名称和inode编号,之后将条目写入目录dp。

如果名称已经存在,dirlink返回错误。

主循环读取目录条目,寻找空闲条目。当找到时跳出循环,off设置为可用条目的偏移量。若无空闲条目,循环结束时off为dp->size。无论如何,dirlink通过在偏移量off处写入来向目录添加新条目。

8.12 代码:Path names

8.12.1 namei

struct inode* namei(char *path){
  char name[DIRSIZ];
  return namex(path, 0, name);
}

struct inode* nameiparent(char *path, char *name){
  return namex(path, 1, name);
}

namei:返回路径对应的inode

nameiparent:返回目标文件的父节点,并将文件名保存在name中。比如传入/a/b/c,会返回/a/b对应的inode,并将c保存在name

8.12.2 namex

static struct inode* namex(char *path, int nameiparent, char *name){
  struct inode *ip, *next;

  if(*path == '/') ip = iget(ROOTDEV, ROOTINO);
  else ip = idup(myproc()->cwd);

  while((path = skipelem(path, name)) != 0){
    ilock(ip);
    if(ip->type != T_DIR){
      iunlockput(ip);
      return 0;
    }
    if(nameiparent && *path == '\0'){
      // Stop one level early.
      iunlock(ip);
      return ip;
    }
    if((next = dirlookup(ip, name, 0)) == 0){
      iunlockput(ip);
      return 0;
    }
    iunlockput(ip);
    ip = next;
  }
  if(nameiparent){
    iput(ip);
    return 0;
  }
  return ip;
}
  1. 先确定起始位置。如果路径以/开始,则起始目录为根目录,否则为当前目录。
  2. skipelem函数的作用:比如path:\a\b\c ,则返回\b\c,同时name = a
  3. 使用skipelem依次查看路径上的每个元素。每次循环都在ip中查找name
    1. 首先给ip上锁(上锁是必要的,因为直到上锁之前,ip->type不能保证已从磁盘加载),检查类型是否是目录。如果不是,则查找失败。
    2. 如果nameiparent=1,并且当前是最后一个路径元素,则循环会提前停止;最后一个路径元素已经复制到name中,因此namex只需返回未锁定的ip
    3. 使用dirlookup查找name对应的inode,并通过设置ip=next为下一次迭代做准备。当循环用完路径元素后,返回ip

namex执行可能需要很长时间:可能需要多次磁盘操作,读出路径目录的inode(如果buffer cache未缓存)。Xv6经过精心设计,多个内核线程如果访问不同的路径,namex可以并行执行,namex锁定的粒度是路径上的每个目录。

这带来了挑战。例如,当一个内核线程正在查找路径时,另一个内核线程可能正在通过解除链接来改变目录树。查找可能在已被另一个内核线程删除的目录中搜索,并且它的块已被重用于另一个目录或文件。

Xv6避免了这种竞争。例如,在namex中执行dirlookup时,lookup持有目录锁,dirlookup返回使用iget获得的inode。iget增加索引节点的引用计数。只有在从dirlookup接收inode之后,namex才会释放目录上的锁。现在,另一个线程可以从目录中取消inode的链接,但是xv6还不会删除inode,因为inode的引用计数仍然大于零。

另一个风险是死锁。例如,查找.时,next指向与ip相同的inode。在释放ip的锁之前锁定next将导致死锁。为避免死锁,namex在获得下一个目录的锁之前解锁该目录。在这里我们再次看到igetilock分离的重要性。

8.13 File descriptor

Unix接口中,大多数资源都表示为文件,包括真实文件,以及控制台、管道等设备。文件描述符层实现了这种特性。

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
  short major;       // FD_DEVICE
};

正如在第1章中看到的,Xv6为每个进程提供了打开文件表,或称为文件描述符列表。

  • 每个打开的文件由一个struct file表示,它是inode或管道的封装,加上I/O偏移量
  • 每次调用open都会创建一个新的struct file:如果多个进程独立地打开同一文件,每个struct file具有不同的I/O偏移量
  • struct file可以重复出现在一个进程的文件表中,也可出现在多个进程的文件表中。如果一个进程使用open打开文件,然后使用dup创建别名,或使用fork与子进程共享,就会发生这种情况
  • ref跟踪文件的引用数
  • readablewritable控制文件打开模式
#define NFILE 100  // open files per system
struct {
  struct spinlock lock;
  struct file file[NFILE];
} ftable;

系统中所有打开的文件都保存在全局文件表ftable中。文件表具有分配文件(filealloc)、创建重复引用(filedup)、释放引用(fileclose)以及读取和写入数据(filereadfilewrite)的函数。

8.13.1 filealloc

struct file* filealloc(void){
  struct file *f;
  acquire(&ftable.lock);
  for(f = ftable.file; f < ftable.file + NFILE; f++){
    if(f->ref == 0){
      f->ref = 1;
      release(&ftable.lock);
      return f;
    }
  }
  release(&ftable.lock);
  return 0;
}

filealloc扫描ftable,查找未引用的文件(f->ref == 0),并返回一个新的引用;

8.13.2 filedup

struct file* filedup(struct file *f){
  acquire(&ftable.lock);
  if(f->ref < 1)
    panic("filedup");
  f->ref++;
  release(&ftable.lock);
  return f;
}

filedup增加ref;

8.13.3 fileclose

void fileclose(struct file *f){
  struct file ff;

  acquire(&ftable.lock);
  if(f->ref < 1)
    panic("fileclose");
  if(--f->ref > 0){
    release(&ftable.lock);
    return;
  }
  ff = *f;
  f->ref = 0;
  f->type = FD_NONE;
  release(&ftable.lock);

  if(ff.type == FD_PIPE){
    pipeclose(ff.pipe, ff.writable);
  } else if(ff.type == FD_INODE || ff.type == FD_DEVICE){
    begin_op();
    iput(ff.ip);
    end_op();
  }
}

fileclose递减ref。当ref减为0时,fileclose会根据type释放底层管道或inode。

8.13.4 filestat

int filestat(struct file *f, uint64 addr){
  struct proc *p = myproc();
  struct stat st;
  
  if(f->type == FD_INODE || f->type == FD_DEVICE){
    ilock(f->ip);
    stati(f->ip, &st);
    iunlock(f->ip);
    if(copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
      return -1;
    return 0;
  }
  return -1;
}

filestat只允许在inode上操作,并且调用了stati

8.13.5 fileread

int fileread(struct file *f, uint64 addr, int n){
  int r = 0;

  if(f->readable == 0)
    return -1;

  if(f->type == FD_PIPE){
    r = piperead(f->pipe, addr, n);
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
      return -1;
    r = devsw[f->major].read(1, addr, n);
  } else if(f->type == FD_INODE){
    ilock(f->ip);
    if((r = readi(f->ip, 1, addr, f->off, n)) > 0)
      f->off += r;
    iunlock(f->ip);
  } else {
    panic("fileread");
  }

  return r;
}
  1. 首先检查是否readable,然后将read传递给管道或inode的具体实现。
  2. 如果是inode,filereadfilewrite使用I/O偏移量作为操作的偏移量,然后将文件指针前进该偏移量。管道没有偏移的概念。

回想一下,inode的函数要求调用方持有锁。inode锁定有一个方便的副作用,即读取和写入偏移量以原子方式更新,因此,对同一文件的同时多次写入不会覆盖彼此的数据,尽管他们的写入最终可能是交错的。

8.13.6 filewrite

int filewrite(struct file *f, uint64 addr, int n){
  int r, ret = 0;

  if(f->writable == 0)
    return -1;

  if(f->type == FD_PIPE){
    ret = pipewrite(f->pipe, addr, n);
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
      return -1;
    ret = devsw[f->major].write(1, addr, n);
  } else if(f->type == FD_INODE){
    // write a few blocks at a time to avoid exceeding
    // the maximum log transaction size, including
    // i-node, indirect block, allocation blocks,
    // and 2 blocks of slop for non-aligned writes.
    // this really belongs lower down, since writei()
    // might be writing a device like the console.
    int max = ((MAXOPBLOCKS-1-1-2) / 2) * BSIZE;
    int i = 0;
    while(i < n){
      int n1 = n - i;
      if(n1 > max)
        n1 = max;

      begin_op();
      ilock(f->ip);
      if ((r = writei(f->ip, 1, addr + i, f->off, n1)) > 0)
        f->off += r;
      iunlock(f->ip);
      end_op();

      if(r != n1){
        // error from writei
        break;
      }
      i += r;
    }
    ret = (i == n ? n : -1);
  } else {
    panic("filewrite");
  }

  return ret;
}

fileread大致相同

8.14 代码:System calls

通过底层提供的函数,大多数系统调用的实现都很简单(参阅kernel/sysfile.c)。有一些调用值得仔细研究。

函数sys_linksys_unlink编辑目录,创建或删除索引节点的引用。它们是使用事务能力的另一个很好的例子。

uint64 sys_link(void){
  char name[DIRSIZ], new[MAXPATH], old[MAXPATH];
  struct inode *dp, *ip;

  // 获取参数,old和new
  if(argstr(0, old, MAXPATH) < 0 || argstr(1, new, MAXPATH) < 0)
    return -1;

  begin_op();
  // namei: 根据path找到inode
  if((ip = namei(old)) == 0){
    end_op();
    return -1;
  }

  ilock(ip);
  // 目标inode不能是目录类型
  if(ip->type == T_DIR){
    iunlockput(ip);
    end_op();
    return -1;
  }

  ip->nlink++;
  iupdate(ip);
  iunlock(ip);

  if((dp = nameiparent(new, name)) == 0)
    goto bad;
  ilock(dp);
  if(dp->dev != ip->dev || dirlink(dp, name, ip->inum) < 0){
    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;
}

函数功能:为已有inode创建一个新名称,接收两个参数,old和new,执行成功时,会创建一个新目录项new,该目录项指向和old同一个inode。

  1. 获取参数,两个字符串分别是oldnew
  2. 假设old存在,且不是一个目录,sys_link会增加其ip->nlink计数。然后调用nameiparent来查找new的父目录和最终路径元素,并创建一个指向old的inode的新目录条目。new的父目录必须存在并且与现有inode位于同一设备上:inode编号在一个磁盘上只有唯一的含义。如果出现这样的错误,sys_link必须返回并减少ip->nlink

事务简化了实现,因为它需要更新多个磁盘块,但我们不必担心更新的顺序。他们要么全部成功,要么什么都不做。例如在没有事务的情况下,在创建一个链接之前更新ip->nlink会使文件系统暂时处于不安全状态,而在这两者之间发生的崩溃可能会造成严重破坏。对于事务,我们不必担心这一点

8.14.2 create

static struct inode* create(char *path, short type, short major, short minor){
  struct inode *ip, *dp;
  char name[DIRSIZ];

  if((dp = nameiparent(path, name)) == 0)
    return 0;

  ilock(dp);

  if((ip = dirlookup(dp, name, 0)) != 0){
    // 存在和name同名的文件
    iunlockput(dp);
    ilock(ip);
    if(type == T_FILE && (ip->type == T_FILE || ip->type == T_DEVICE))
      return ip;
    iunlockput(ip);
    return 0;
  }

  if((ip = ialloc(dp->dev, type)) == 0){
    iunlockput(dp);
    return 0;
  }

  ilock(ip);
  ip->major = major;
  ip->minor = minor;
  ip->nlink = 1;
  iupdate(ip);

  if(type == T_DIR){  // Create . and .. entries.
    // No ip->nlink++ for ".": avoid cyclic ref count.
    if(dirlink(ip, ".", ip->inum) < 0 || dirlink(ip, "..", dp->inum) < 0)
      goto fail;
  }

  if(dirlink(dp, name, ip->inum) < 0)
    goto fail;

  if(type == T_DIR){
    // now that success is guaranteed:
    dp->nlink++;  // for ".."
    iupdate(dp);
  }

  iunlockput(dp);

  return ip;

 fail:
  // something went wrong. de-allocate ip.
  ip->nlink = 0;
  iupdate(ip);
  iunlockput(ip);
  iunlockput(dp);
  return 0;
}

函数功能:为新inode创建名称。它是三个文件创建系统调用的泛化:

逻辑

  1. 调用nameiparent,获取父目录的inode

  2. 调用dirlookup,检查name是否存在。如果已存在,create的行为将取决于create是被哪个函数所调用的。

    如果是被open(type==T_FILE)所调用的,并且名称所对应的本身也是一个文件,那就视为成功,并返回。否则返回错误。

  3. 如果name不存在,使用ialloc分配一个新的inode。

  4. 如果新的inode是目录T_DIRcreate将使用...对它进行初始化。

  5. 将inode链接到父目录。

createsys_link一样,同时持有两个inode锁:ipdp。不存在死锁的可能性,因为索引结点ip是新分配的:系统中没有其他进程会持有ip的锁,然后尝试锁定dp

利用create,很容易实现sys_opensys_mkdirsys_mknod

sys_open是最复杂的,因为创建文件只是它的部分功能。如果open被传递了O_CREATE标志,它将调用create,否则,它将调用nameiCreate返回一个锁定的inode,但namei不锁定,因此sys_open必须锁定inode本身。这提供了一个方便的地方来检查目录是否仅为读取打开,而不是写入。假设inode以某种方式获得,sys_open分配一个文件和一个文件描述符,然后填充该文件。注意,没有其他进程可以访问部分初始化的文件,因为它仅位于当前进程的表中。

在我们还没有文件系统之前,第7章就研究了管道的实现。函数sys_pipe通过提供创建管道对的方法,将该实现连接到文件系统。它的参数是指向两个整数空间的指针,它将在其中记录两个新的文件描述符。然后分配管道并安装文件描述符。

8.15 真实世界

实际操作系统中的buffer cache比xv6复杂得多,但它有两个相同的用途:缓存和同步对磁盘的访问。与UNIX V6一样,Xv6的buffer cache使用简单的最近最少使用(LRU)替换策略;有许多更复杂的策略可以实现,每种策略都适用于某些工作场景,而不适用于其他工作场景。更高效的LRU缓存将消除链表,而改为使用哈希表进行查找,并使用堆进行LRU替换。现代buffer cache通常与虚拟内存系统集成,以支持内存映射文件。

Xv6的日志系统效率低下。提交不能与文件系统调用同时发生。系统记录整个块,即使一个块中只有几个字节被更改。它执行同步日志写入,每次写入一个块,每个块可能需要整个磁盘旋转时间。真正的日志系统解决了所有这些问题。

日志记录不是提供崩溃恢复的唯一方法。早期的文件系统在重新启动期间使用了一个清道夫程序(例如,UNIX的fsck程序)来检查每个文件和目录以及块和索引节点空闲列表,查找并解决不一致的问题。清理大型文件系统可能需要数小时的时间,而且在某些情况下,无法以导致原始系统调用原子化的方式解决不一致问题。从日志中恢复要快得多,并且在崩溃时会导致系统调用原子化。

Xv6使用的索引节点和目录的基础磁盘布局与早期UNIX相同;这一方案多年来经久不衰。BSD的UFS/FFS和Linux的ext2/ext3使用基本相同的数据结构。文件系统布局中最低效的部分是目录,它要求在每次查找期间对所有磁盘块进行线性扫描。当目录只有几个磁盘块时,这是合理的,但对于包含许多文件的目录来说,开销巨大。Microsoft Windows的NTFS、Mac OS X的HFS和Solaris的ZFS(仅举几例)将目录实现为磁盘上块的平衡树。这很复杂,但可以保证目录查找在对数时间内完成(即时间复杂度为O(logn))。

Xv6对于磁盘故障的解决很初级:如果磁盘操作失败,Xv6就会调用panic。这是否合理取决于硬件:如果操作系统位于使用冗余屏蔽磁盘故障的特殊硬件之上,那么操作系统可能很少看到故障,因此panic是可以的。另一方面,使用普通磁盘的操作系统应该预料到会出现故障,并能更优雅地处理它们,这样一个文件中的块丢失不会影响文件系统其余部分的使用。

Xv6要求文件系统安装在单个磁盘设备上,且大小不变。随着大型数据库和多媒体文件对存储的要求越来越高,操作系统正在开发各种方法来消除“每个文件系统一个磁盘”的瓶颈。基本方法是将多个物理磁盘组合成一个逻辑磁盘。RAID等硬件解决方案仍然是最流行的,但当前的趋势是在软件中尽可能多地实现这种逻辑。这些软件实现通常允许通过动态添加或删除磁盘来扩展或缩小逻辑设备等丰富功能。当然,一个能够动态增长或收缩的存储层需要一个能够做到这一点的文件系统:xv6使用的固定大小的inode块阵列在这样的环境中无法正常工作。将磁盘管理与文件系统分离可能是最干净的设计,但两者之间复杂的接口导致了一些系统(如Sun的ZFS)将它们结合起来。

Xv6的文件系统缺少现代文件系统的许多其他功能;例如,它缺乏对快照和增量备份的支持。

现代Unix系统允许使用与磁盘存储相同的系统调用访问多种资源:命名管道、网络连接、远程访问的网络文件系统以及监视和控制接口,如/proc(注:Linux 内核提供了一种通过/proc文件系统,在运行时访问内核内部数据结构、改变内核设置的机制。proc文件系统是一个伪文件系统,它只存在内存当中,而不占用外存空间。它以文件系统的方式为访问系统内核数据的操作提供接口。)。不同于xv6中filereadfilewriteif语句,这些系统通常为每个打开的文件提供一个函数指针表,每个操作一个,并通过函数指针来援引inode的调用实现。网络文件系统和用户级文件系统提供了将这些调用转换为网络RPC并在返回之前等待响应的函数。

8.16 练习

  1. 为什么要在ballocpanic?xv6可以恢复吗?
  2. 为什么要在iallocpanic?xv6可以恢复吗?
  3. 当文件用完时,filealloc为什么不panic?为什么这更常见,因此值得处理?
  4. 假设在sys_link调用iunlock(ip)dirlink之间,与ip对应的文件被另一个进程解除链接。链接是否正确创建?为什么?
  5. create需要四个函数调用都成功(一次调用ialloc,三次调用dirlink)。如果未成功,create调用panic。为什么这是可以接受的?为什么这四个调用都不能失败?
  6. sys_chdiriput(cp->cwd)之前调用iunlock(ip),这可能会尝试锁定cp->cwd,但将iunlock(ip)延迟到iput之后不会导致死锁。为什么不这样做?
  7. 实现lseek系统调用。支持lseek还需要修改filewrite,以便在lseek设置off超过f->ip->size时,用零填充文件中的空缺。
  8. O_TRUNCO_APPEND添加到open,以便>>>操作符在shell中工作。
  9. 修改文件系统以支持符号链接。
  10. 修改文件系统以支持命名管道。
  11. 修改文件和VM系统以支持内存映射文件。

8.17 附录:文件函数串讲

本章虽然介绍了每一层的所有相关函数,但是总感觉不够连贯,所以本节将对个人感兴趣的部分流程进行总结, 包括创建文件,打开并修改文件,删除文件,打开管道等

一、创建文件

创建文件的相关函数是open,对应的内核函数是sys_open

sys_open

uint64 sys_open(void){
  char path[MAXPATH];
  int fd, omode;
  struct file *f;
  struct inode *ip;
  int n;

  // 获取open mode
  argint(1, &omode);
  // 获取文件名
  if((n = argstr(0, path, MAXPATH)) < 0)
    return -1;

  begin_op();

  if(omode & O_CREATE){
    // 如果mode包含O_CREATE,则使用create处理该请求
    ip = create(path, T_FILE, 0, 0);
    if(ip == 0){
      end_op();
      return -1;
    }
  } else {
    // 如果没有,使用namei找到path对应inode
    if((ip = namei(path)) == 0){
      end_op();
      return -1;
    }
    ilock(ip);
    if(ip->type == T_DIR && omode != O_RDONLY){
      // Directory只能以O_RDONLY模式打开
      iunlockput(ip);
      end_op();
      return -1;
    }
  }

  if(ip->type == T_DEVICE && (ip->major < 0 || ip->major >= NDEV)){
    // 如果参数不合法,返回错误
    iunlockput(ip);
    end_op();
    return -1;
  }

  if((f = filealloc()) == 0 || (fd = fdalloc(f)) < 0){
    // filealloc在打开文件表中找到一个空位置
    // fdalloc接收struct file*,为其分配一个文件描述符编号
    if(f)
      fileclose(f);
    iunlockput(ip);
    end_op();
    return -1;
  }

  // 将之前请求的inode信息写入file中
  if(ip->type == T_DEVICE){
    f->type = FD_DEVICE;
    f->major = ip->major;
  } else {
    f->type = FD_INODE;
    f->off = 0;
  }
  f->ip = ip;
  f->readable = !(omode & O_WRONLY);
  f->writable = (omode & O_WRONLY) || (omode & O_RDWR);

  // 截断文件
  if((omode & O_TRUNC) && ip->type == T_FILE){
    itrunc(ip);
  }

  iunlock(ip);
  end_op();

  return fd;
}

open mode包含以下值

#define O_RDONLY  0x000 // 0000 0000 0000
#define O_WRONLY  0x001 // 0000 0000 0001
#define O_RDWR    0x002 // 0000 0000 0010
#define O_CREATE  0x200 // 0010 0000 0000
#define O_TRUNC   0x400 // 0100 0000 0000

总结

  1. 首先根据open mode参数,判断是打开或新建inode
  2. 在打开文件表找一个空file,并根据file获取文件描述符
  3. 将inode信息写入file
  4. 最后返回文件描述符

二、打开并修改文件

打开文件已经讲过,接下来将修改文件

write的函数定义是write(fd, buf, sz)

sys_write

uint64 sys_write(void){
  struct file *f;
  int n;
  uint64 p;
  
  argaddr(1, &p);
  argint(2, &n);
  if(argfd(0, 0, &f) < 0)
    return -1;

  return filewrite(f, p, n);
}

filewrite

int filewrite(struct file *f, uint64 addr, int n){
  int r, ret = 0;

  if(f->writable == 0)
    return -1;

  if(f->type == FD_PIPE){
    ret = pipewrite(f->pipe, addr, n);
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
      return -1;
    ret = devsw[f->major].write(1, addr, n);
  } else if(f->type == FD_INODE){
    // write a few blocks at a time to avoid exceeding
    // the maximum log transaction size, including
    // i-node, indirect block, allocation blocks,
    // and 2 blocks of slop for non-aligned writes.
    // this really belongs lower down, since writei()
    // might be writing a device like the console.
    int max = ((MAXOPBLOCKS-1-1-2) / 2) * BSIZE;
    int i = 0;
    while(i < n){
      int n1 = n - i;
      if(n1 > max)
        n1 = max;

      begin_op();
      ilock(f->ip);
      if ((r = writei(f->ip, 1, addr + i, f->off, n1)) > 0)
        f->off += r;
      iunlock(f->ip);
      end_op();

      if(r != n1){
        // error from writei
        break;
      }
      i += r;
    }
    ret = (i == n ? n : -1);
  } else {
    panic("filewrite");
  }

  return ret;
}

三、删除文件

四、创建管道

sys_pipe

uint64 sys_pipe(void){
  uint64 fdarray; // user pointer to array of two integers
  struct file *rf, *wf;
  int fd0, fd1;
  struct proc *p = myproc();

  argaddr(0, &fdarray);
  if(pipealloc(&rf, &wf) < 0)
    return -1;
  fd0 = -1;
  if((fd0 = fdalloc(rf)) < 0 || (fd1 = fdalloc(wf)) < 0){
    if(fd0 >= 0)
      p->ofile[fd0] = 0;
    fileclose(rf);
    fileclose(wf);
    return -1;
  }
  if(copyout(p->pagetable, fdarray, (char*)&fd0, sizeof(fd0)) < 0 ||
     copyout(p->pagetable, fdarray+sizeof(fd0), (char *)&fd1, sizeof(fd1)) < 0){
    p->ofile[fd0] = 0;
    p->ofile[fd1] = 0;
    fileclose(rf);
    fileclose(wf);
    return -1;
  }
  return 0;
}

pipealloc

int pipealloc(struct file **f0, struct file **f1){
  struct pipe *pi;

  pi = 0;
  *f0 = *f1 = 0;
  // 从打开文件表获取两个空闲file
  if((*f0 = filealloc()) == 0 || (*f1 = filealloc()) == 0)
    goto bad;
  // 获取一片内存区域
  if((pi = (struct pipe*)kalloc()) == 0)
    goto bad;
  pi->readopen = 1;
  pi->writeopen = 1;
  pi->nwrite = 0;
  pi->nread = 0;
  initlock(&pi->lock, "pipe");
  (*f0)->type = FD_PIPE;
  (*f0)->readable = 1;
  (*f0)->writable = 0;
  (*f0)->pipe = pi;
  (*f1)->type = FD_PIPE;
  (*f1)->readable = 0;
  (*f1)->writable = 1;
  (*f1)->pipe = pi;
  return 0;

 bad:
  if(pi)
    kfree((char*)pi);
  if(*f0)
    fileclose(*f0);
  if(*f1)
    fileclose(*f1);
  return -1;
}

pipeclose

void pipeclose(struct pipe *pi, int writable){
  acquire(&pi->lock);
  if(writable){
    pi->writeopen = 0;
    wakeup(&pi->nread);
  } else {
    pi->readopen = 0;
    wakeup(&pi->nwrite);
  }
  if(pi->readopen == 0 && pi->writeopen == 0){
    release(&pi->lock);
    kfree((char*)pi);
  } else
    release(&pi->lock);
}

pipewrite

int pipewrite(struct pipe *pi, uint64 addr, int n){
  int i = 0;
  struct proc *pr = myproc();

  acquire(&pi->lock);
  while(i < n){
    if(pi->readopen == 0 || killed(pr)){
      release(&pi->lock);
      return -1;
    }
    if(pi->nwrite == pi->nread + PIPESIZE){ //DOC: pipewrite-full
      wakeup(&pi->nread);
      sleep(&pi->nwrite, &pi->lock);
    } else {
      char ch;
      if(copyin(pr->pagetable, &ch, addr + i, 1) == -1)
        break;
      pi->data[pi->nwrite++ % PIPESIZE] = ch;
      i++;
    }
  }
  wakeup(&pi->nread);
  release(&pi->lock);

  return i;
}

piperead

int piperead(struct pipe *pi, uint64 addr, int n){
  int i;
  struct proc *pr = myproc();
  char ch;

  acquire(&pi->lock);
  while(pi->nread == pi->nwrite && pi->writeopen){  //DOC: pipe-empty
    if(killed(pr)){
      release(&pi->lock);
      return -1;
    }
    sleep(&pi->nread, &pi->lock); //DOC: piperead-sleep
  }
  for(i = 0; i < n; i++){  //DOC: piperead-copy
    if(pi->nread == pi->nwrite)
      break;
    ch = pi->data[pi->nread++ % PIPESIZE];
    if(copyout(pr->pagetable, addr + i, &ch, 1) == -1)
      break;
  }
  wakeup(&pi->nwrite);  //DOC: piperead-wakeup
  release(&pi->lock);
  return i;
}

X.本章总结

Buffer cache

  1. 磁盘的以扇区进行划分,每个扇区大小为512KB

  2. 可用设备号扇区号读出数据

  3. 对最近访问的扇区进行缓存,避免每次都访问磁盘。

  4. 缓存的数据结构

    struct buf {
      int valid;   // has data been read from disk?
      int disk;    // does disk "own" buf?
      uint dev;
      uint blockno;
      struct sleeplock lock;
      uint refcnt;
      struct buf *prev; // LRU cache list
      struct buf *next;
      uchar data[BSIZE];
    };
    

log

  1. log块的存储位置由Super Block确定

  2. Log块存储在Log区域

    image-20240401142020069
  3. 为文件操作提供提供事务,保证文件操作的原子性

Block Allocator

  1. Block是通过bit map进行分配的
  2. Block Allocator的作用就是检查bit map,分配或回收Block

inode

  1. 文件元数据的集合,包括文件大小,使用的Block块号

  2. 存储在inodes区域中

    // On-disk inode structure
    struct dinode {
      short type;           // 文件类型
      short major;          // Major device number (T_DEVICE only)
      short minor;          // Minor device number (T_DEVICE only)
      short nlink;          // 有多少文件使用了这个inode
      uint size;            // 文件大小(以字节为单位)
      uint addrs[NDIRECT+1];   // 使用的Block块号
    };
    

    nlink:有多少文件或目录引用了这个inode,当为0时,block会被释放,供其他inode使用

  3. inode被加载到内存后,会在内存创建一个副本,同时增加一些字段方便使用

    struct inode {
      uint dev;           // Device number
      uint inum;          // Inode number
      int ref;            // Reference count
      struct sleeplock lock; // protects everything below here
      int valid;          // inode has been read from disk?
    
      short type;         // copy of disk inode
      short major;
      short minor;
      short nlink;
      uint size;
      uint addrs[NDIRECT+1];
    };
    
    • ref:引用该inode的指针数量,当为0时,将会从内存释放回disk

Directory

  1. 存储目录

  2. 底层也是inode,存储在data区域,类型是T_DIR

  3. Directory inode的数据区域中,存储的是子文件数据

    struct dirent {
      ushort inum; // inode编号
      char name[DIRSIZ]; // 文件名
    };
    
  4. inum对应的inode可能是文件,也可能是目录

Path names

根据起始inode和目录名,遍历Directory找到对应的inode

File descriptor

  1. 将文件,管道,socket统一,向外提供同一接口

    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
      short major;       // FD_DEVICE
    };
    
  2. 每个进程有一个打开文件表

    struct proc {
    	//。。。
      struct file *ofile[NOFILE];  // Open files
      //。。。
    };
    

    文件在ofile数组中的下标就是fd,当fd为1时,访问fd,就能根据这个数组获取对应文件,然后进行修改。

锁功能总结

其他

posted @ 2024-04-21 00:16  INnoVation-V2  阅读(3)  评论(0编辑  收藏  举报