MIT-6.S081 XV6 Chapter8 File System

178 阅读58分钟

Chapter10 File System

Files and Directories

File Descriptor

文件描述符是一个非负整数,对于内核而言,所有打开的文件都通过文件描述符引用。当打开或创建一个文件时,内核就向进程返回一个文件描述符,然后后续的读和写就使用该文件描述符来标识该文件,将其作为参数传递给read和write等函数。

Open, Creat and Close

#include <fcntl.h>
#include <unistd.h>

int open(const char *path, int oflag, ... /* mode_t mode */);

int creat(const char *path, mode_t mode);
// 此creat函数等效于:
// open(path, O_WRONLY|O_CREAT|O_TRUNC, mode);

int close(int fd);

调用open可以打开或者创建一个文件,也可以调用creat来创建一个新文件。

open:返回最小的未用描述符,输入参数如下:path是要打开或创建文件的名字,通常为绝对路径,如果希望使用相对路径,可以使用openat函数。oflag用来说明open函数的多个选项,可以用以下一个或多个常量进行或运算构成oflag参数。

  • 必须且只能指定一个的常量有:O_RDONLY、O_WRONLY、O_RDWR、O_EXEC、O_SEARCH
  • 可选的部分常量有:O_APPEND、O_CREAT、O_DIRECTORY、O_EXCL、O_SYNC、O_TRUNC等。

最后是可选的mode参数,仅当创建新文件时,即oflag设置了O_CREAT时才使用该参数,它指定了新文件的访问权限位。

Lseek

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

​ 每个打开文件都有一个与其相关联的当前文件偏移量,通常是一个非负整数,用于度量从文件开始处计算的字节数。一般读、写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。打开一个文件时,除非指定了O_APPEND选项,否则偏移量设置为0。

调用lseek可以显式地对一个打开文件设置偏移量,参数offset的解释与whence的值有关。

  • 若whence是SEEK_SET,偏移量设置为距文件开始处offset个字节。
  • 若whence是SEEK_CUR,偏移量设置为当前偏移量值+offset,offset可正可负。
  • 若whence是SEEK_END,偏移量设置为文件长度+offset,offset可正可负。

​ 如果lseek成功执行,则返回新的文件偏移量。如果文件描述符fd指向的是一个管道、FIFO或网络套接字等不能设置偏移量的文件,那么lseek返回-1。

​ lseek只是将当前的文件偏移量记录在内核中,本身并不引起任何I/O操作,该偏移量被用于下一个读或写操作;文件偏移量可以大于文件的当前长度,此时对该文件的下一次写将加长该文件,并在文件中构成一个空洞,即位于文件中但没有被写过的字节都被读为0。文件空洞并不要求在磁盘上占用存储区,具体处理方式与文件系统实现有关:例如当定位超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是位于原文件尾端和新开始写的位置之间的部分,则不需要分配磁盘块。

Read and Write

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t nbytes);

ssize_t write(int fd, const void *buf, size_t nbytes);

​ 调用read从打开文件中读数据。如果read成功,返回的是读到的字节数如果已到达文件的尾端,则返回0如果出错,返回-1。读操作会从文件的当前偏移量开始在成功返回之前,该偏移量将增加实际读到的字节数。ssize_t类型带符号,而size_t类型不带符号。

有多种情况可以使得实际读到的字节数少于要求读的字节数:

  • 读普通文件时,在读到要求字节数之前就到达了文件尾端。
  • 从终端设备读时,通常一次最多读一行。
  • 从网络读时,网络中的缓冲机制可能造成返回值小于所要求读的字节数。
  • 从管道或FIFO读时,若管道包含的字节少于所需的数量,那么将只返回实际可用字节数。
  • 从某些面向记录的设备(如磁带)读时,一次最多返回一个记录。
  • 已经读了部分数据,但是被一个信号造成中断。

​ 调用write向打开文件写数据。和read类似,write成功时返回写入的字节数;否则在出错时返回-1。write出错的常见原因是磁盘已经写满,或者超过了一个给定进程的文件长度限制。

​ 对于普通文件,写操作从文件的当前偏移量处开始。如果在打开文件时指定了O_APPEND选项,则在每次写操作之前,都将文件偏移量设置在文件的当前结尾处,并在一次成功写入之后,该文件偏移量增加实际写入的字节数。

​ 在文件末尾追加时,使用O_APPEND选项和使用lseek定位到文件尾端是不同的,主要不同在于操作的原子性:O_APPEND使得内核在每次写操作之前,都先更新文件的当前偏移量,这使得写操作和更新偏移量合并成一个原子操作,即使多个进程追加同一文件,也不会出现覆盖的情况;而使用lseek方法的话,写操作和更新偏移量是两个不同的函数,不具有原子性。

​ 如果使用O_APPEND选项打开一个文件以便读、写,那么仍然可以用lseek和read读取文件中任意一个位置的内容,但是write在写之前会自动将文件偏移量设置为文件尾,因此写该文件时只能从文件尾端开始。

​ 注意,read和write都是不带缓冲的I/O函数,因此需要用户程序定义缓冲区buf。

Dup

#include <unistd.h>

int dup(int fd);
int dup2(int fd, int fd2);

​ 调用dupdup2都可以复制一个现有的文件描述符。

​ 由dup返回的新文件描述符一定是当前可用文件描述符的最小值;而dup2则可以用fd2指定新文件描述符的值,如果fd2已经打开,那么会先将其关闭,如果fd=fd2,则直接返回fd2,而不关闭fd2。两个函数返回的新文件描述符,与原来的fd共享同一个文件表项。

Sync

#include <unistd.h>

int fsync(int fd);
int fdatasync(int fd);
void sync(void);

​ 传统的UNIX系统实现在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘,这种方式称为延迟写。当内核需要重用缓冲区来存放其它磁盘块数据时,它就会把所有的延迟写数据块写入磁盘。

​ UNIX系统提供了syncfsyncfdatasync三个函数,来保证磁盘上实际文件系统与缓冲区中内容的一致性。

  • sync只是将所有修改过的块缓冲区加入写队列中,然后就返回,并不等待实际的写磁盘操作结束。通常,系统守护进程update会周期性地(例如30秒)调用sync,从而保证定期冲洗flush内核的块缓冲区。
  • fsync只对fd指定的一个文件起作用,并且会阻塞地(或者说同步地)等待写磁盘操作结束以后才返回,该函数可被用于需要确保修改过的块立即写到磁盘上的应用程序,如数据库等。
  • fdatasync类似fsync,但只影响文件的数据部分,而fsync还更新文件的元数据(或属性)。

Stat

#include <sys/stat.h>

int stat(const char *restrict pathname, struct stat *restrict buf);
int fstat(int fd, struct stat *buf);

调用statfstat可以查看特定文件的元数据。

stat函数根据给定pathname,返回与此命名文件有关的信息结构;而fstat则获得已在fd上打开的文件有关信息。

第2个参数buf是一个由用户提供的指针,函数填充buf指向的结构,该结构的基本形式如下。

struct stat{
  mode_t st_mode; // permissions
  ino_t st_ino;   // inode number
  dev_t st_dev;   // device number
  dev_t st_rdev;  // device number for special files
  nlink_t st_nlink;  // number of hard links
  uid_t st_uid;   // user ID of owner
  gid_t st_gid;   // group ID of owner
  off_t st_size;  // total sizes, in bytes
  struct timespec st_atime;  // time of last access
  struct timespec st_mtime;  // time of last modification
  struct timespec st_ctime;  // time of last status change
  blksize_t st_blksize;  // best block size for file system I/O
  blkcnt_t st_blocks;    // number of disk blocks allocated
}

Link and Unlink

#include <unistd.h>
#include <stdio.h>

int link(const char *existingpath, const char *newpath);
int linkat(int efd, const char *existingpath, int nfd, const char *newpath, int flag);

int unlink(const char *pathname);
int unlinkat(int fd, const char *pathname, int flag);

int remove(const char *pathname);

​ 调用link可以创建一个指向现有文件的硬链接,而调用unlink可以删除一个目录项并且将pathname所引用的文件的硬链接计数减1,如果该文件还有其它硬链接,仍可通过其它链接访问该文件的数据。

​ 通常来说,我们使用unlink删除文件,而使用rmdir删除空目录。或者,使用remove删除文件或目录的链接,对于文件它就像unlink,对于目录它就像rmdir。

Symbolic Link

​ 符号链接,又称为软链接。虽然从结果上看,我们也能用新的别名来访问文件,但是本质上完全不同。一是文件类型不同,二是符号链接文件的数据是链接指向文件的路径名,三是存在悬空引用问题,即删除原始文件后,会导致符号链接指向不再存在的路径名,因此无法访问原来的文件(可以将其理解成快捷方式);而使用硬链接的话,即使删除多个别名,只要还存在硬链接,就可以通过那个链接访问原文件。

​ 如果调用unlink时pathname是符号链接,则unlink删除符号链接本身,而不是由该符号链接所引用的文件。

为什么需要符号链接?因为硬链接存在局限:不能创建目录的硬链接,因为这可能会在目录树中创建一个环,不能硬链接到其它磁盘分区中的文件。

  • 每个inode结点都有一个链接计数nlink,只有当链接计数减为0时,才可删除该文件,即释放该文件占用的磁盘块。
  • 如果inode是普通文件类型,那么nlink既是链接计数,也是硬链接的计数。
  • 但如果inode是目录类型,那么nlink并不代表硬链接,因为我们说过不能创建目录的硬链接,因此该nlink的含义只是链接计数。通常,任何一个叶目录,其链接计数总是为2,来自于命名该叶目录的目录项,以及叶目录的"."项;而非叶目录的链接计数至少为3,至少有一个额外的链接计数,来自于其子目录的".."项。

Overview

xv6文件系统的层次结构有7层,如下图所示。

image-20230523125835527.png

  • 最底层的磁盘层Disk Layer,与QEMU仿真的虚拟磁盘打交道,往磁盘上读或写一些块。
  • 缓冲区缓存层Buffer Cache Layer,负责将磁盘块缓存在内存中,并且在这一层管理所有进程对缓存块的并发访问,保证一次只能有一个进程修改某一缓存块。
  • 日志层Logging Layer,为高层提供更新磁盘的接口,高层的对几个磁盘块的更新,将被打包成事务放入日志层,日志层随后确保这些更新是原子的,并且能提供Crash Recovery。
  • inode层Inode Layer,为文件层提供接口,每个文件都是独立的,且有一个唯一的inode号标识,inode里面还有指向文件数据块所在磁盘位置的信息。
  • 目录层Directory Layer,目录被看成是一种特殊的文件,因此它的inode含义也和普通文件不同,它的数据是一系列的目录条目,包含该目录下的文件名和文件的inode号。
  • 路径名层Pathname Layer,提供符合文件系统层次结构的路径名,处理路径名递归查找。
  • 文件描述符层File Descriptor Layer,最后,文件描述符是对底层所有资源(管道、设备、普通文件等)的抽象,用户对文件系统的视图是简单而统一的,这方便了用户程序的编程。

xv6采用的磁盘布局如下图所示,这是一个默认的磁盘布局(切换到xv6的其它分支时,可能会有所不同,例如fs分支下,最大磁盘块数量为20w)。

image-20230523130043367.png

xv6文件系统不使用块0,该块用作引导块;块1是超级块Superblock,它包含了关于完整文件系统的元数据,如文件系统的大小或块数、数据块的数量、inodes的数量、日志块的数量等;块2开始,到块31为止的30块用作日志块,稍后我们将看到其作用;从块32开始是inodes,每一块inode block上面都有多个inodes;紧接着,块45的单独一块用作位图,它用于追踪哪些数据块已被分配或处于空闲;从块46开始,后续的全部用作数据块,包含着文件或目录。

Buffer Cache Layer

Buffer Cache主要做两件事:

  • 同步所有对磁盘块的并发访问,尤其是保证,一个磁盘块要么没有被缓存,要么在内存中只有一份缓存副本,而且一次只有一个内核线程可以使用该副本
  • 缓存磁盘块时,应该保留那些常被访问的磁盘块不被逐出Buffer Cache,这样后续的访问就可以直接访问内存中的副本,而不是通过慢速的磁盘I/O。

​ Buffer Cache主要向上提供两个重要接口:bread和bwrite。Buffer Cache有固定数量的槽位,每个槽位保存一个磁盘块的副本。如果文件系统希望访问的磁盘块不在Buffer Cache中,就需要回收一个已经含有缓存块的槽位,以满足此次请求。Buffer Cache使用LRU算法来回收这些槽位。

Code: Buffer Cache

struct {
  struct spinlock lock;  // Buffer Cache自己有一把自旋锁,而每个缓存块也有一把睡眠锁
                         // Buffer Cache的自旋锁保护哪些块已经被缓存的信息
                         // 而每个缓存块的睡眠锁保护对该块内容的读与写
  struct buf buf[NBUF];  // NBUF = MAXOPBLOCKS * 3 = 30
						// 代表我们有30个槽位可用。
  // 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.
  // i.e. LRU replace algorithm
  struct buf head;
} bcache;

​ Buffer Cache的一个槽位struct buf定义如下所示。可以看到,每个槽位(或者说每个缓存块)对应一把睡眠锁,保护buf中的信息。

struct buf {
  int valid;   // has data been read from disk?
               // 表示该槽位上是否缓存了磁盘块的副本;
  int disk;    // does disk "own" buf?
               // disk表示磁盘正在处理该缓存块的读或写请求,
               //如果磁盘还未处理完成,disk=1,如果磁盘处理完成,disk=0;
  uint dev;
  uint blockno;          // 表示缓存的磁盘块在磁盘上的位置;
  struct sleeplock lock; // 每个缓存块一把睡眠锁
  uint refcnt;           // 当前有多少个内核线程在排队等待读这个缓存块
  struct buf *prev; // LRU cache list
  struct buf *next;
  uchar data[BSIZE]; // BSIZE = 1024,xv6的块大小是1024B
};

首先是用于初始化Buffer Cache的binit,它在main中被调用。

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

​ bread通过调用bget来获取指定磁盘块的缓存块,如果b->valid=0,说明这个槽位是刚被回收的,还没有缓存任何磁盘块,因此调用virtio_disk_rw来先从磁盘上读取相应磁盘块的内容,读取完成后更新b->valid。bread最后返回的是上锁的且可用的缓存块。

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

  b = bget(dev, blockno); // 通过调用bget来获取指定磁盘块的缓存块
  if(!b->valid) {
    // 如果b->valid=0,说明这个槽位是刚被回收的,还没有缓存任何磁盘块,
    // 因此调用virtio_disk_rw来先从磁盘上读取相应磁盘块的内容,读取完成后更新b->valid。
    virtio_disk_rw(b, 0);
    b->valid = 1;
  }
  return b; // 返回的是上锁的且可用的缓存块。
}

进一步来看bget,bget做的事情就是,获取该缓存块的b->lock,返回上锁的缓存块。

// 根据输入的设备号和块号,扫描Buffer Cache中的所有缓存块。
// 如果缓存命中,bget更新引用计数refcnt,释放bcache.lock
static struct buf *
bget(uint dev, uint blockno) // 设备号和块号
{
  struct buf *b;
  // 多个线程访问Buffer Cache时,总是在外面等待
  acquire(&bcache.lock);

  // Is the block already cached?
  // head.next指向最近使用最多的缓存块
  for(b = bcache.head.next; b != &bcache.head; b = b->next){
    if(b->dev == dev && b->blockno == blockno){
      // Buffer Cache命中!
      // 每当有一个线程希望使用该缓存块时,引用计数就加1
      b->refcnt++;
      release(&bcache.lock);
      // 不能同时持有bcache.lock和b.lock,否则在brelse中有可能死锁
      // 返回上锁的缓存块
      // 在bget中获得缓存块的b->lock,在brelse中释放该b->lock
      acquiresleep(&b->lock);
      return b;
    }
  }

  // Not cached.
  // Recycle the least recently used (LRU) unused buffer.
  // head.prev指向最近使用最少的缓存块
  // Buffer Cache未命中,从head.prev开始寻找可以回收的LRU缓存块
  for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
    if(b->refcnt == 0) {
      // refcnt表示当前正在排队,希望读该缓存块的线程数
      // 如果没有线程要读这个缓存块,而且它是LRU的,那就回收它
      b->dev = dev;
      b->blockno = blockno;
      // 回收之后,原来槽位里的缓存块内容就无效
      // 需要重新从磁盘上读取新的磁盘块内容
      b->valid = 0;
      // 现在本内核线程要使用该缓存块,先将refcnt置1,因此这一块不会马上又被回收
      b->refcnt = 1;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      // 返回上锁的缓存块
      return b;
    }
  }
  panic("bget: no buffers");
}

每个磁盘块最多被缓存一次,而且只能有一个内核线程正在访问该缓存块。这是为了保证所有的bread可以看到bwrite的相应修改。bget通过在返回缓存块b之前,一直持有bcache.lock来保证这一点,这样,检查缓存是否命中,以及未命中时对于被回收槽位buf的一系列元数据更新,这些操作都是原子的。

在内核线程调用bread,并且得到它需要的缓存块之后,线程就能够使用它,进行读或写;一旦线程修改了缓存块的内容,那么应该在调用brelse释放缓存块之前,调用bwrite来将更新冲刷到磁盘上。bwrite如下所示,它首先保证持有该缓存块的睡眠锁,然后调用virtio_dist_rw将更新后的缓存块冲刷到磁盘的相应位置上。

// Write b's contents to disk.  Must be locked.
void
bwrite(struct buf *b)
{
  if(!holdingsleep(&b->lock))
    panic("bwrite");
  virtio_disk_rw(b, 1);  // 1代表写入磁盘块
}

当一个内核线程使用完了一块缓存块,就需要调用brelse释放它。brelse先释放缓存块的睡眠锁,减去引用计数refcnt,然后更新链表,将这一缓存块移到链表的最前端,表示这是最近使用的。因此,head.next是最近使用的,而head.prev是最近使用最少的。这就和bget的逻辑对应了起来,bget的两个循环中,希望查找已被缓存的磁盘块时,从head.next开始正向找,而希望查找可以回收的缓存块时,从head.prev开始反向找。如果内核线程使用磁盘块具有访存局部性的话,这就可以减少搜索的开销。

// 调用者结束对一个缓存块的处理(读或写)之后,
// 调用brelse更新bcache的链表,并且释放对应的缓存块的睡眠锁
void
brelse(struct buf *b)
{
  if(!holdingsleep(&b->lock))
    panic("brelse");
  // 先释放缓存块的睡眠锁
  releasesleep(&b->lock);

  acquire(&bcache.lock);
  b->refcnt--; // 减去引用计数refcnt
  // 最近刚刚更新的块b->refcnt=1
  if (b->refcnt == 0) {
    // no one is waiting for it.
    // 这个判断条件是为了减少链表的更新次数
    // 如前面在bget所说,当两个线程希望读b而b之前没有被缓存
    // 那么第一个线程进来之后,refcnt = 2,自减后为1
    // refcnt不为0则说明还有其它的线程在等待要读它
    // 因此之后该缓存块一定会被更新,即链表一定会被更新
    // 因此这次的更新可以跳过,交给最后一个希望读它的线程来完成
    // (最后一个希望读该缓存块的refcnt = 0)
    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);
}

​ 最后,bpinbunpin分别对缓存块的引用计数refcnt进行加减,稍后我们将看到,在日志层中可以利用这两个接口,保证要写入日志的缓存块不会被回收。

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

Logging Layer

​ xv6采用数据日志来解决崩溃一致性问题,数据日志协议的四部曲如下:

  • 日志写入:将事务的内容(TxB,元数据块和数据块)写入日志,等待这些写入完成。
  • 日志提交:将事务提交块(包括TxE)写入日志,一旦完成,我们认为事务已经成功提交。
  • 加检查点:将日志中的事务(元数据和数据更新)写入磁盘的最终位置上。
  • 释放:一段时间后,更新日志超级块,将已完成加检查点的事务标记为空闲。

​ 按照上述逻辑工作。如果系统崩溃或断电,重启之后,在启动阶段,文件系统的恢复程序就会检查日志上是否有已被提交的完整事务。如果有,那么就重放它们,也就是将日志内容重新写到磁盘上的相应位置;如果没有,那么直接无视这部分日志,就好像什么也没发生过。最后,恢复程序总会清除日志。

​ 只有在日志提交(事务成功提交)这个时间点之后,系统若发生崩溃或断电,那么恢复程序可以重放上次半途而废的更新,从而修复文件系统的不一致。若崩溃或断电在日志提交完成之前发生,那么恢复程序将什么也不做;若加检查点的过程也顺利完成,日志会被释放。

Log Design

​ xv6的日志区域驻留在磁盘的一个固定位置上,并且在超级块中也有其位置记录。日志区域包括了一个日志头块logheader,以及一系列的日志块,它们是更新后的缓存块的副本,也称为logged blocks。磁盘上的logheader包含了一个数组,用于记录每个logged block要写入的目的磁盘位置(即扇区号),以及对于logged blocks的计数n。这个计数n要么为0,代表日志区中没有事务;要么不为0,代表日志区包含有一个已成功提交的事务,且该事务要处理的日志块数量是n。xv6在将所有logged blocks写入日志之后,就写入logheader,一旦写入磁盘成功,就代表该事务已被成功提交。然后在加检查点完成之后,对计数n清零。若崩溃发生在事务提交以前,logheader计数n=0;若发生在事务提交之后,logheader计数n不为0。

在使用File System的系统调用时,需要指定哪一部分的一系列写磁盘操作应该是原子的。为了提高文件系统操作的并发性,如果有多个FS系统调用并发执行,只要日志区域的空间足够,日志系统就将收集来自多个FS系统调用的多个写磁盘操作,并将它们组织成一个事务。因此,一次事务提交可能包含来自多个FS系统调用的写磁盘请求。为了避免将一次FS系统调用拆散到多个事务中,仅在当前没有FS系统调用执行时,日志系统才提交事务。这种将多个FS系统调用的事务,合为一次大的事务,然后一次性提交的思想,称为组提交Group Commit

Code: Logging

struct logheader {
  int n; // the count of log blocks
         // n=0代表当前log不包含完整的磁盘操作
         // n不为0代表有log中有n块需要被写到磁盘上
  int block[LOGSIZE]; // LOGSIZE = 30,即log可以包含30个blocks
};

struct log {
  struct spinlock lock;
  int start; // Block number of first log block
  int size;
  int outstanding; // how many FS sys calls are executing.
                   // 和缓存块的refcnt类似,表示当前执行FS syscalls的线程数
                   // 在begin_op中会加1,在end_op中会减1
                   // 等于0时说明当前没有正在执行的FS sys calls,
                   // 如果在end_op中发现该计数为0,说明这时候可以提交log
  int committing;  // in commit(), please wait.
  int dev;
  struct logheader lh;
};
struct log log;

​ logheader是真正的on-disk data structure,日志区域中的logheader就是该数据结构;而log只是在内存中维护的一份数据结构,日志区域中的logged blocks只存放更新后的缓存块副本。

​ xv6的每次FS系统调用最多只会提交数量为MAXOPBLOCKS的日志块,因此它保守地假设每个FS调用都需要这么多空间。

// called at the start of each FS system call.
// begin_op等待日志系统加检查点的完成,并且发现有足够的空间容纳新的事务,
// 那么才能开始处理这次FS系统调用。 
void
begin_op(void)
{
  acquire(&log.lock);
  while(1){
    if(log.committing){
      // 等待当前加检查点的完成
      sleep(&log, &log.lock);
    } else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
      // this op might exhaust log space; wait for commit.
      // 如果当前日志区域没有足够空间,先挂起
      sleep(&log, &log.lock);
    } else {
      // 现在受理该FS系统调用
      log.outstanding += 1;
      // 不仅表示该FS调用现在占用日志系统的部分空间,
      // 还防止日志系统在FS调用的中途就开始提交事务。
      release(&log.lock);
      break;
    }
  }
}

​ log_write先检查写日志操作是否合法,然后记录下每个日志块要写入的磁盘位置,增加logheader的计数n,还要用bpin增加相应缓存块的引用计数refcnt,这样它们就不会被逐出,因此不会被提前写入磁盘(否则会破坏原子性)。注意,以上所有这些操作都是在内存中发生,这里并没有实际的磁盘操作。此时,在事务被提交以前,位于内存中的缓存块副本是唯一的修改记录,其它FS调用读取这些块时,能够看到它们的更新。

void
log_write(struct buf *b)
{
  int i;

  acquire(&log.lock);
  // 检查当前已经写入log header的log blocks数量,上限为LOGSIZE
  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
      // 如果之前已经在log中有提交对同一个块b的更新
      // 就不用分配新的logged block,直接修改上次的即可
      break;
  }
  // 如果之前没有提交过块b的更新,那么i=log.lh.n
  // 然后紧接着上次写日志的地方,往logheader的block[n]写入块b的目标磁盘位置
  log.lh.block[i] = b->blockno;
  if (i == log.lh.n) {  // Add new block to log?
    // 为了不违反原子性,日志块不应该被逐出,因为逐出会马上被写入到磁盘上
    // 所以调用bpin增加其引用计数refcnt,这样就不会被逐出
    // 在install_trans的时候会bunpin相应的块
    bpin(b);
    log.lh.n++;
    // 直到调用commit开始提交以前,这些事务块还是位于内存的Buffer Cache中
  }
  release(&log.lock);
}

​ log_write还注意到了,一个块被提交多次的情况,这时不是额外分配日志空间给它,而是继续使用日志区域中相同的槽位。这样,在没有优化之前,对相同的几个块进行十几次更新,需要十几块日志块,但现在只需要几个块的槽位,从而节省了日志空间。最终,每个缓存块只有一份更新后的副本会被写入到磁盘上,这种优化称为吸收Absorption。

​ 调用end_op标志着这次FS系统调用的结束,因此要准备提交事务。首先,它减少计数outstanding,表示当前FS系统调用处理将结束;然后检查自己是否是当前的最后一个FS系统调用,如果是,那么就调用commit提交当前日志区域中的所有事务。

void
end_op(void)
{
  int do_commit = 0;

  acquire(&log.lock);
  log.outstanding -= 1;
  if(log.committing)
    panic("log.committing"); // 没有事务能够在还有FS调用的情况下就被提交
                             // 如果发生了,就是panic
  if(log.outstanding == 0){
    // 当前没有FS sys calls,可以开始事务提交
    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.
    // 如果当前还有其它FS调用正在执行,那么我们先不急着提交
    // 而是留给最后一个FS调用负责提交所有事务,这是group commit
    // 此外,可能有其它FS调用在begin_op时因空间不足而被挂起
    // 现在可能有空间可用,唤醒一个被挂起的FS调用
    wakeup(&log);
  }
  release(&log.lock);

  if(do_commit){
    // call commit w/o holding locks, since not allowed
    // to sleep with locks.
    commit();
    // 修改commit状态,需要锁保护
    acquire(&log.lock);
    // 全部提交完毕后committing重置0
    // 唤醒一个被挂起的FS调用,它的begin_op现在可以继续
    log.committing = 0;
    wakeup(&log);
    release(&log.lock);
  }
}

commit如下

static void
commit()
{
  if (log.lh.n > 0) {
    write_log();     // 将位于内存中的,更新后的缓存块写入磁盘上的日志里;
    write_head();    // 将logheader写入磁盘,如果这一步成功完成,代表事务被正式提交
    install_trans(0); // 加检查点的过程
    log.lh.n = 0;
    write_head(); // 清除日志,将logheader的计数n重置为0,并且写入磁盘上完成更新。
  }
}

​ 先看commit的第一步,write_log。write_log的工作就是将所有位于内存中的,更新后的缓存块,按顺序写入到磁盘的日志区域上。

// Copy modified blocks from cache to log.
static void
write_log(void)
{
  int tail;

  for (tail = 0; tail < log.lh.n; tail++) {
    // 从磁盘上按顺序读出日志区域的磁盘块,额外加1是跳过logheader
    struct buf *to = bread(log.dev, log.start+tail+1); // log block
    // 从Buffer Cache中读出更新后的缓存块
    struct buf *from = bread(log.dev, log.lh.block[tail]); // cache block
    // 从from复制到to,使用memmove后,现在两个缓存块副本都是更新后的状态
    memmove(to->data, from->data, BSIZE);
    // 将更新后的logged block写回磁盘的日志区域上
    bwrite(to);  // write the log
    brelse(from);
    // 两个缓存块都使用完毕,调用brelse更新链表
    brelse(to);
  }
}

​ 接着是commit的第二步,write_head。write_head做的事情很简单,就是将更新后的logheader写回磁盘上。一旦这一步完成,就代表事务提交真正完成,因此从这里开始,到日志被清除以前,期间发生的crash都可以恢复。

// Write in-memory log header to disk.
// This is the true point at which the
// current transaction commits.
static void
write_head(void)
{
  // 通过bread,获得logheader的缓存块
  struct buf *buf = bread(log.dev, log.start);
  struct logheader *hb = (struct logheader *) (buf->data);
  int i;
  // 更新磁盘上logheader的n
  hb->n = log.lh.n;
  // 更新每个log block对应的data block的块号
  for (i = 0; i < log.lh.n; i++) {
    hb->block[i] = log.lh.block[i];
  }
  // 将更新后的logheader写回磁盘上
  bwrite(buf);
  // 从这里开始,事务提交真正完成,因此这里开始发生的crash可以恢复
  brelse(buf);
}

​ 再接着是commit的第三步,install_trans。install_trans根据logheader的指示(logheader中的数组指示了每个日志块最终应该被写到磁盘的哪个位置),将每个logged blocks写到目的磁盘位置上,然后bunpin其在内存中的相应缓存块,这样Buffer Cache可以回收它。一旦这一步完成,那么本次事务就成功更新到磁盘上,因此可以准备释放日志块。

// Copy committed blocks from log to their home location
static void
install_trans(int recovering)
{
  int tail;

  for (tail = 0; tail < log.lh.n; tail++) {
    struct buf *lbuf = bread(log.dev, log.start+tail+1); // read log block
    struct buf *dbuf = bread(log.dev, log.lh.block[tail]); // read dst
    // 和write_log不同之处在于,写入磁盘时的目的地不同
    // write_log写入的是磁盘上的日志区域logged blocks,
    // install_trans写入的是logged block最终应该被写入的data blocks的位置
    memmove(dbuf->data, lbuf->data, BSIZE);  // copy block to dst
    bwrite(dbuf);  // write dst to disk
    // 在log_write中bpin了相应的缓存块,这里bunpin它
    // 因此在这之后,这一缓存块可以被Buffer Cache回收
    if(recovering == 0)
      bunpin(dbuf);
    brelse(lbuf);
    brelse(dbuf);
  }
}

​ commit的最后一步,将logheader的计数n置0,并且将logheader写回到磁盘上。这里对应数据日志四部曲中的释放,因为n已经被标记为0,所以日志区域中的块现在可以被重用。注意,我们应该等待这一步完成之后,才能回到end_op中,将commiting置0,表示日志系统提交完成并处于空闲。这个顺序很重要,先释放旧日志块再开始处理新事务,否则后续发生crash时,可能会使用前一个事务的日志块来恢复。

​ 现在我们关注crash发生时,文件系统如何恢复?答案是,在系统的启动阶段,第一个用户进程init会调用fsinit初始化文件系统。而在fsinit函数中,调用了initlog来初始化日志系统。

// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void
forkret(void)
{
  static int first = 1;

  // Still holding p->lock from scheduler.
  release(&myproc()->lock);

  if (first) {
    // File system initialization must be run in the context of a
    // regular process (e.g., because it calls sleep), and thus cannot
    // be run from main().
    first = 0;
    fsinit(ROOTDEV);
  }

  usertrapret();
}

// Init fs
void
fsinit(int dev) {
  readsb(dev, &sb);
  if(sb.magic != FSMAGIC)
    panic("invalid file system");
  initlog(dev, &sb);
}

initlog如下所示,它做的事情就是从超级块上,读取关于日志系统的信息,包括日志区域的起始位置,日志区域的大小等。然后,它就调用recover_from_log开始恢复(不管有没有需要恢复的日志都可以调用它,这并不带来负面影响)。

void
initlog(int dev, struct superblock *sb)
{
  if (sizeof(struct logheader) >= BSIZE)
    panic("initlog: too big logheader");

  initlock(&log.lock, "log");
  log.start = sb->logstart;
  log.size = sb->nlog;
  log.dev = dev;
  recover_from_log();
}

recover_from_log如下所示,做的事情很简单,读出磁盘上的logheader,如果计数n=0,代表日志系统不需要进行恢复,因此跳过recovery继续启动;如果计数n不等于0,代表有需要重放的日志,因此我们回到数据日志四部曲中的加检查点,再次调用install_trans重新执行加检查点过程。最后在函数退出点,清除旧日志。

static void
recover_from_log(void)
{
  read_head();  // 读出logheader
  // log.lh.n表示需要写入磁盘的log blocks的数量
  // 如果不需要恢复,为0,自动跳出install_trans的循环
  // 如果>0,install_trans会重新将logged blocks写入到磁盘的相应位置
  install_trans(); // if committed, copy from log to disk
  // 清除旧日志
  log.lh.n = 0;
  write_head(); // clear the log
}

​ begin_op和end_op的成对使用,相当于告诉文件系统,现在将要进行一系列块的更新,而且我们希望这些块的更新是原子的,所以在我发出end_op之前,不要擅自将任何一块或几块的更新写入到磁盘上。

Code: Block Allocator

​ 磁盘块分配器主要有两个接口,balloc和bfree,和内存分配器相似。balloc负责分配一块新的磁盘块,它会扫描所有的块,从块0一直到文件系统的最大块号为止。它将会找到一个空闲的磁盘块,该块在位图上标记为0,然后更新位图并返回该磁盘块。balloc有两层循环,外层循环遍历每个位图块,而内层循环则对于给定位图块,检查里面的8192位(1024*8B),直到发现一个标记为0的位,于是就决定分配那一块磁盘块,更新位图之后,返回该新分配磁盘块的块号。Buffer Cache有一把锁,所以调用bread时内含同步机制。bfree的操作和balloc相反,找到位图块中的相应位,将其重新标记为0,代表该磁盘块现在空闲,可以被重新分配。balloc和bfree应该在一个事务中被调用。

Inode Layer

​ xv6的inode有两个含义,一个是指磁盘上的数据结构,另一个是指内存中的数据结构。磁盘上的inode包含了文件大小、数据块的位置等基本信息;而内存中的inode是前者的一份副本,而且还包含有内核所需的额外信息。

​ on-disk inode是磁盘上一块连续的区域,这些块称为inode block,每个inode的大小是固定的,所以很容易就能找到第n个inode,n也称为inode number。

struct dinode是磁盘上inode的数据结构定义。

// 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;          // 表示有多少个目录条目包含这一inode。
                        //对于文件来说,nlink既是链接计数也是硬链接数,
                        //而对于目录来说,nlink只是链接计数,nlink指示这个inode及其相应的数据块什么时候被释放;
  uint size;           // Size of file (bytes)
  uint addrs[NDIRECT + 1]; // 指向inode的数据块所在位置
};

struct inode则是内核在内存中维护的,正被使用的on-disk inode的一份副本。仅当存在指针指向内存中的某一inode时,内核才会保持该inode的内存副本。

// in-memory copy of an inode
struct inode {
  uint dev;           // Device number
  uint inum;          // Inode number
  int ref;            // Reference count
                      // 指示了有多少C指针指向该inode的内存副本,
                      // ref大于0,就会继续在icache中保存该inode,而且该缓存条目不会被置换成别的inode
                      // ref为0时,内核就清除该inode在icache中的副本
                      // nlink是硬链接,断电之后还会保存在磁盘里面
                      // 而ref是内存里面的数据结构,断电之后就会消失
  struct sleeplock lock; // protects everything below here
  int valid;          // inode has been read from disk?
  // 以下为struct dinode的成员
  short type;         // copy of disk inode
  short major;
  short minor;
  short nlink;
  uint size;
  uint addrs[NDIRECT+1];
};

​ 和Buffer Cache类似,inode层也有一个Inode Cache称为icache,定义如下。icache.lock保护两个条件:**一是每个inode在缓存中至多有一个副本;二是每个inode的缓存副本中,ref代表了当前指向该inode副本的指针数。**内存中的每个inode副本都有一把睡眠锁,可以同步对这些inode和它们的数据块的并发访问,这些设计思想和Buffer Cache如出一辙。

struct {
  struct spinlock lock;
  // 这把自旋锁用于保护icache缓存区
  // 每个inode的睡眠锁用于保护每个inode的信息
  // 和bcache情况类似
  struct inode inode[NINODE];  // 最多50个inode同时被缓存
} icache;

​ 通过调用iget得到的,指向某一struct inode的指针,会一直有效,直到对该inode调用iput只要存在指向该struct inode的指针,该inode的内存副本就不会被删除,而且icache也不会释放并回收这个副本。值得一提的是,iget虽然需要icache.lock来同步并发的访问,但对于每个inode,并没有去获取该inode的睡眠锁。这意味着如果有多个iget调用,并因此返回了多个指向同一inode的指针,那么这些指针是可以不受限制地并发访问该inode的。xv6文件系统的相当一部分功能依赖于iget这个特别的,不马上对inode进行加锁的机制,例如维护一些长期有效的指针(如打开的文件、当前工作目录等),或是在一些需要修改多个inode的代码中避免死锁发生(如路径名查找等)。

​ 此外,调用iget得到的inode指针可能并不能访问到什么有效信息,为了保证icache中的inode确实是磁盘上inode的一个副本,还要调用ilock。ilock给inode上锁,并且读取磁盘上的inode;与之相对的,iunlock释放inode的锁。多个进程可以同时拥有指向同一个inode的指针,但是通过ilock,只有一个进程可以上锁并访问该inode。

​ 最后,设计icache的主要目的是为了同步多个进程对inode的访问,而利用缓存的高速性只是次要目的。如果一个inode经常被使用,就算它不在icache中,Buffer Cache也可能会缓存它。icache是直写式(write-through)的,这意味着一旦icache中的某个inode副本被修改,就要马上调用iupdate来更新到磁盘上。

Code: Inodes

​ 首先,创建一个新的inode,需要调用ialloc。ialloc在并发情况下仍能正确工作,得益于下层Buffer Cache提供的同步机制。

struct inode*
ialloc(uint dev, short type)
{
  // 注意和iget的区别
  // ialloc每次调用时,都会找到一个磁盘上空闲的inode块(数据结构是dinode)
  // 然后将其标记为已分配,分配完之后就会马上调用iget并将其缓存在icache中
  // iget则是给定inode number,先看对应的inode是否已经被缓存了
  // 如果没有,就分配缓存区中第一个空位来缓存该inode
  int inum;
  struct buf *bp; // 磁盘上的inode,所以数据结构类型为dinode
  struct dinode *dip;

  for(inum = 1; inum < sb.ninodes; inum++){
    bp = bread(dev, IBLOCK(inum, sb)); // 在bread的bget中隐含同步机制
    // IBLOCK计算给定的inode i在第几个block上
    // 读取对应的block上的data,这个block包含了多个inode,数量为IPB
    // inum%IPB是block内的偏移量,加上偏移量之后则即读取block上第inum个inode
    dip = (struct dinode *)bp->data + inum % IPB;
    // 最终dip指向的是bp->data的某一部分,即相应的dinode
    // 因此修改dip就是修改对应的dionde
    if(dip->type == 0){  // a free inode
      memset(dip, 0, sizeof(*dip));
      dip->type = type;
      log_write(bp); // 缓存块bp被修改,写进log
      brelse(bp);
      // 调用iget将inode缓存在icache中
      return iget(dev, inum);
    }
    brelse(bp);
  }
  panic("ialloc: no inodes");
}

​ 接着来看iget。给定设备号和inode号,iget查找icache中是否有相应inode的缓存副本。如果有,那么直接返回一个新的inode指针;如果没有,那么就使用icache中第一个为空的槽位,或者回收一个ref为0的inode,从而将空间分配给新的inode缓存副本。注意,我们这里只是简单地将一个槽位标记给某个inode使用,但这个inode的缓存副本现在还没有任何有效内容,需要在之后调用ilock,才能从磁盘上读入该inode的元数据和数据。

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++){
    // 先在icache缓存中查找,看是否已经被缓存
    if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
      ip->ref++;
      release(&itable.lock);
      // 返回一个新的inode指针,指向icache中的inode
      return ip;
    }
    if (empty == 0 && ip->ref == 0) // 找到icache中第一个为空的槽位
      empty = ip;
  }

  // Recycle an inode entry.
  if(empty == 0)
    panic("iget: no inodes");
  // 遍历了当前所有icache中的inode,但是缓存没有命中
  // empty是icache中第一个为空的槽位
  // 初始化之后,返回指向该inode的指针
  ip = empty;
  ip->dev = dev;
  ip->inum = inum;
  ip->ref = 1;
  ip->valid = 0;
  release(&itable.lock);

  return ip;
}

​ 通过iget我们得到了指向inode缓存副本的指针,但是如果要使用该指针读取或修改inode副本的内容,必须要先调用ilock。ilock获取相应inode的睡眠锁,从而同步所有线程对inode的并发访问。如果该inode是刚刚由ialloc分配的,那么valid字段为0,表示需要从磁盘上读出inode的内容到其缓存副本中。

iunlock与ilock相对应,释放inode的锁。

​ 接下来是与iget相对应的iput,iput接收一个inode指针输入,并且将其ref计数减1。**如果该指针是最后一个对该inode的引用,而且该inode硬链接数nlink为0,那么就释放磁盘上的对应inode和相关数据块。**iput通过调用itrunc来释放inode,将长度截断为0并且释放相关数据块,然后标记type为空闲,最后调用iupdate将新的inode元数据写回磁盘上。

来看iupdate,iupdate将更新后的inode缓存副本写回磁盘上,这里使用了日志系统。

void
iupdate(struct inode *ip)
{
  // 每次更改icache中inode副本,都需要调用iupdate写入到磁盘

  struct buf *bp;
  struct dinode *dip;

  bp = bread(ip->dev, IBLOCK(ip->inum, sb));
  dip = (struct dinode*)bp->data + ip->inum%IPB;

  // ip是新的inode缓存副本,dip是旧的磁盘inode
  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
  log_write(bp);
  brelse(bp);
}

​ 因为调用了iupdate,所以iput含有写入磁盘的操作。这意味着凡是访问和使用了文件系统的系统调用,最终都会引发写入磁盘的操作(因为几乎所有的文件系统操作与inode相关)。

​ 还有一个比较微妙的关系是,当一个文件的硬链接数nlink为0,iput并不会马上就删除并释放该文件,因为在内存中可能还存在其它指向该文件的inode指针,甚至有进程仍然在读写这个文件。如果在最后一个进程关闭该文件的文件描述符,释放最后一个inode指针之前,崩溃或者断电发生了,下次启动时该文件仍会被标记为分配(因为没有被删除并释放),但是没有任何目录条目会包含它,也不会有任何内存中的inode指针指向它,我们失去了一些磁盘空间。

文件系统有两种方式处理这种情况:

  • 在系统启动的恢复阶段,文件系统扫描磁盘上的整个文件系统,检查是否有文件标记被分配,但是没有任何目录条目包含它,然后如果有,就删除释放这些文件。
  • 在磁盘的某个位置上(例如超级块中),记录硬链接数为0,而指向其的指针数不为0的文件的inode号。如果该文件最后确实被删除释放了,那么就从该记录列表中移出相应的inode号。然后在系统启动的恢复阶段,不扫描整个文件系统,而是简单地扫描该列表,删除释放该列表中的文件即可。

但是,xv6文件系统并不提供以上两种的任一一种实现,这意味着随着xv6的运行,会有越来越多的磁盘空间流失,最终,我们可能会耗尽磁盘上的空间。

Code: Inode Content

​ dinode的数组addrs可以形象地用下图表示。前NDIRECT个即前12个是直接块,因此addr[0]到addr[11]保存的都是数据块的块号,文件的前12KB就在这些数据块中。addr[12]是一个间接块块号,间接块里面又是一系列的数据块块号。因为块大小为1024B,而一个块号用数据类型uint表示,即4B,所以一个间接块里面又可以存放256个块号,文件的后256KB就在其中。

image-20230524101417966转存失败,建议直接上传图片文件

​ 这种映射方式对于磁盘来说是节省空间的好方法,但是对于用户来说可能会有一点复杂。

​ 因此,inode层提供了一个接口bmap。给定inode指针,bmap就为我们找到文件的第bn块对应的数据块块号,这为很多更高层的接口如readi和writei都提供了便利。如果该数据块还未被分配,bmap还会调用balloc为我们分配它。

bmap的实现如下,逻辑比较清晰。首先检查直接块,如果bn不是对应直接块,那么检查间接块。如果读出的内容是0而不是块号,代表那一块还没有被分配。如果直接块不存在,那么就分配新的数据块;如果间接块不存在,那么先分配一块给间接块,后续再分配一块给间接块的数据块。这种按需分配的方式,可以很大程度上节省磁盘空间。注意,这里只将新的一级间接块写入日志更新,如果分配了新的数据块导致inode的addrs[ ]发生了改变,那么应该由上层的调用来调用iupdate。

static uint
bmap(struct inode *ip, uint bn)
{
  // 0 <= bn < NDIRECT + NINDIRECT
  uint addr, *a;
  struct buf *bp;
  // 先查找直接块
  if(bn < NDIRECT){
    if((addr = ip->addrs[bn]) == 0)
      // 该直接块还没有被分配,因此分配一个新数据块
      ip->addrs[bn] = addr = balloc(ip->dev);
    return addr;
  }
  bn -= NDIRECT;
  // 如果bn不是直接块,就查找间接块
  if(bn < NINDIRECT){
    // Load indirect block, allocating if necessary.
    if((addr = ip->addrs[NDIRECT]) == 0)
      // 一级间接映射块是空的,先分配一个一级间接块
      ip->addrs[NDIRECT] = addr = balloc(ip->dev);
    bp = bread(ip->dev, addr);
    // 在这里将a变为uint*形的指针,这样a的每次偏移都会增加一个sizeof(uint)
    a = (uint*)bp->data;
    if((addr = a[bn]) == 0){
      // 分配一个新数据块
      a[bn] = addr = balloc(ip->dev);
      // balloc只会将新的位图写入日志中,而一级间接块现在被更新
      // 所以要另外调用log_write将更新写入日志中
      log_write(bp);
    }
    brelse(bp);
    return addr;
  }

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

​ itrunc释放一个给定inode的所有数据块,并且将inode长度截断为0。同样地,先检查直接块,然后再检查间接块和间接数据块。

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;
  // 更改了inode,因此要更新到磁盘上
  iupdate(ip);
}

两个位于bmap之上的接口,readi和writei

readi如下,开始时先检查读取是否合法,不能读超过文件大小的字节。然后主循环就会不断地读出文件相应的数据块,并把数据块的内容从Buffer Cache拷贝到dst中。

int
readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n)
{
  uint tot, m;
  struct buf *bp;
  // 检查指定的偏移量 ( off) 是否大于索引节点的大小,
  // 或者偏移量与要读取的字节数的总和n是否小于偏移量本身。
  // 有一个为真表示读取非法,没有数据可读。
  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){
    // bmap(ip, off/BSIZE)将文件偏移量映射到磁盘块地址。
    // bread(ip->dev, block_address)将相应的块从磁盘读取到缓冲区缓存 ( struct buf) 中。
    bp = bread(ip->dev, bmap(ip, off / BSIZE));
    m = min(n - tot, BSIZE - off % BSIZE); // 要读取的剩余字节数和当前块中剩余的字节数的最小值
    // either_copyout函数将数据从缓冲区缓存复制到用户虚拟地址或内核地址,具体取决于的值user_dst。
    // 该dst参数递增以m指向下一个目标地址。
    if(either_copyout(user_dst, dst, bp->data + (off % BSIZE), m) == -1) {
      brelse(bp);
      tot = -1;
      break;
    }
    brelse(bp);
  }
  return tot; // 返回读出的大小
}

writei如下,和readi的架构相似,但有几个不同之处:一是writei可以超出文件大小,从而增长文件;二是与readi相反,writei拷贝数据到Buffer Cache中;三是一旦文件增长,就要更新其inode的大小信息。值得一提的是,即便文件大小没有增长,也照样调用iupdate将inode写入磁盘,因为在调用bmap时可能分配了新的数据块,从而inode的addrs[ ]会被改变。

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)
    // off可以等于ip->size,这样就相当于增长文件,但off+n即期望文件总大小不能超过规定的
    return -1;

  // copies data into the buffers 
  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);
    brelse(bp);
  }

  if(n > 0){
    if(off > ip->size)
      ip->size = off;
    iupdate(ip);
  }

  return n;
}

最后,stat将inode的元数据拷贝到位于内存中的struct stat,为上层的用户进程提供访问该inode元数据的接口。

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

Code: Directory Layer

目录实质上也是一种文件,它的inode类型字段是T_DIR,其数据内容是一系列目录条目。每个目录条目的类型是struct dirent,如下所示,每个条目包含了一个用户可读名称和inode号。用户可读名称最长为14个字符,而inode号为0的目录条目是空闲的。

#define DIRSIZ 14

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

dirlookup给定一个目录的inode指针和条目名称,查找相应的目录条目。如果找到,就返回该目录条目的inode指针,且将*poff设为该条目在目录中的偏移量。如果没有则返回0。

​ iget不给相应的inode上锁的原因是,避免在路径名查找时发生死锁。因为调用dirlookup的线程已经对当前目录dp上锁了,而如果要查找的路径名是".",即当前目录,那么就会发生死锁,多线程并发和查找上一级目录".."也可能会导致死锁。因此我们规定,调用线程一次只能持有一把锁,它应该在dirlookup返回之后,解锁dp,而对新的inode上锁。

dirlink则给定一个条目名称和相应的inode号,在给定目录dp中创建一个新的目录条目,即创建一个新的硬链接。如果该条目已经存在,dirlink返回-1表示错误;在主循环中,查找一个空的位置写入新的条目,如果当前位置已满,那么dp的size会增加(通过writei)。

Code: Path Names

​ 前面简单说明了,给定一个目录,如何在其中查找或添加相应条目。现在我们关注完整的路径名查找过程,这个过程需要调用一连串的dirlookup,每个目录名都需要一次

nameinameiparent就负责这样的路径名查找,它们都接收完整的路径名作为输入,返回相关的inode。不同之处在于,namei返回路径名中最后一个元素的inode;而nameiparent返回最后一个元素的父目录的inode,并且将最后一个元素的名称复制到调用者指定的位置*name中。这两个函数都调用namex完成路径名查找的工作。

struct inode*
namei(char *path)
{
  char name[DIRSIZ];
  //evaluates path and returns the corresponding inode
  return namex(path, 0, name);
}

struct inode*
nameiparent(char *path, char *name)
{
  // 这里name不是临时buffer,调用者提供name[]来装载它,因为调用者还需要它
  return namex(path, 1, name);
}

​ 两个函数都调用了namex,在namex中调用了skipelem用来解析路径名,skipelem做的事情是解析给定的路径名path。它提取出path中的下一个元素,并拷贝到name中,然后返回在下个元素之后的后续路径,如注释中的几个示例所示。如果提取出的下一个元素已经是最后一个元素,那么返回"\0"。如果输入path是"\0",那么返回0。

namex的主循环工作流程如下:

  • 使用skipelem解析路径名,将下一个元素放入name中,并更新path为跟在下一个元素之后的后续路径,可以看在代码注释中举的例子。
  • 如果path返回0,说明路径遍历已经结束,则跳出循环,在末尾返回最终的inode。值得注意的是,namex的上层调用是namei时,跳出循环并在末尾返回ip才是正确的;nameiparent应该在主循环中就结束其工作并返回,如果它也跳出循环,只能说明nameiparent失败。
  • 主循环中的第一步,给当前目录结点ip上锁,然后检查ip是否为目录类型,如果不是,整个namex就会失败。我们要给ip上锁的原因是,直到我们调用ilock为止,不保证ip的ip->type已经从磁盘上读出。
  • 接着,主循环中检查上层调用是否来自nameiparent。如果是,而且path='\0'(回忆skipelem,这表示name里面是最后一个元素),那么nameiparent应该就此结束,此时ip刚好就是最后一个元素的父目录,因此返回ip。
  • 然后,主循环调用dirlookup查找当前目录下的目录条目,在ip中查找name,即查找ip的下一个元素并保存到next中。如果当前目录下没有这个条目,namex也会失败。
  • 最后,主循环释放ip的锁,然后更新ip为next。next是目录或文件,如果是目录,下一个循环还能继续;如果是文件,那么将跳出下一次循环。值得注意的是,在这里我们先释放了ip的锁,然后在下一次循环开始时,再获取next的锁。这是为了避免死锁,例如当查找"."时,next和ip相同,若在释放ip的锁之前给next上锁,就会发生死锁。

File Descriptor Layer

​ UNIX操作系统实现了很好的抽象——一切皆文件,无论底层是普通文件、目录、还是控制台、管道等设备,用户都可以将它们看成是文件。

​ 一般每个进程都有一个打开文件表,有一系列相应的文件描述符。在xv6中,每个打开文件可以用一个struct file类型表示,如下所示,它还封装了inode、pipe等信息。

// Each call to open creates a new open file(a new struct 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
  short major;       // FD_DEVICE
};

​ 每次调用open时,都会创建一个新的打开文件,即创建一个新的struct file。因此,如果有多个进程同时打开同一个文件,它们的struct file实例是不同的,即它们会有不同的偏移量。另一方面,相同的struct file实例,可以多次出现在一个或多个进程的打开文件表中,例如使用open打开文件之后,又调用了dup或fork等系统调用时,就会出现这种情况。ref字段跟踪该struct file被引用了多少次,还有两个字段指示该打开文件是否允许读或写。

​ xv6使用了一个全局的打开文件表,称为ftable,如下所示,系统中所有的打开文件都会存放在这个ftable中,因此xv6最多可以同时打开100个文件。

struct {
  struct spinlock lock;
  struct file file[NFILE];  // NFILE = 100
} ftable;

​ 此外,每个xv6进程都有一个打开文件表ofile,每个用户进程最多同时打开16个文件,即用户进程的文件描述符号范围是从0到15。

struct proc{
  // ...
  struct file *ofile[NOFILE];  // Open files, NOFILE = 16
  // ...
}
  • filealloc**分配一个新的打开文件struct file,它扫描ftable,找到第一个空闲的槽位,然后返回新的引用。打开文件数达到上限时,filealloc只是返回0,并不会使xv6陷入panic。
  • filedup创建一个打开文件struct file的副本,只是简单地增加引用计数ref,然后返回指向相同struct file的指针。
  • fileclose减少ref引用计数。当ref大于1时,直接将ref减1即可返回;当ref=1时,代表对该打开文件的最后一个引用也将删除,fileclose就更改该打开文件的类型为FD_NONE,然后再检查该打开文件下层资源的类型,如果是pipe或inode,还要相应地调用pipeclose或iput,以关闭和释放这些底层资源。
  • filestat利用stati接口,为系统调用fstat提供服务。filestat只允许对inode类型读取其stat信息。
  • fileread根据不同的底层文件类型,检查文件可读模式是否打开,然后调用不同的方法来读取这些资源,为系统调用read提供服务。fileread和接下来的filewrite都使用了struct file中的偏移量off,每次读完之后就更新它,有一个例外是管道,管道没有偏移量。
  • filewrite和fileread类似,这次检查写模式是否打开,它为系统调用write提供服务。**fileread和filewrite中利用了ilock的锁机制,读取和写入的偏移量都将会原子地更新,因此对同一文件的多次写入不会覆盖彼此的数据,对同一文件的多次读也不会读出重复的数据。**利用ilock的锁机制,我们不需要在struct file中再额外添加一把锁保护off。
  • fdalloc根据给定的ftable中的打开文件f,在用户进程的打开文件表ofile中记录f,并且分配一个文件描述符。

Code: System Calls

sys_link为给定的inode创建新的目录条目,即创建新的硬链接。

// Create the path new as a link to the same inode as old.
uint64
sys_link(void)
{
  //文件名,新硬链接,现有文件硬链接
  char name[DIRSIZ], new[MAXPATH], old[MAXPATH];
  struct inode *dp, *ip;
  //用argstr检查文件路径长度,超过MAXPTATH,返回-1。
  if(argstr(0, old, MAXPATH) < 0 || argstr(1, new, MAXPATH) < 0)
    return -1;
  //等待添加检查点完成
  begin_op();
  // 用旧的路径调用namei函数以获得现有文件的inode。
  //如果该文件不存在,它将释放文件系统上的锁并返回-1。
  if((ip = namei(old)) == 0){
    end_op();
    return -1;
  }

  ilock(ip);
  if(ip->type == T_DIR){
    iunlockput(ip);
    end_op();
    return -1;
  }
  // 如果旧路径名存在而且不是目录(目录不能创建硬链接),那么就使该inode的硬链接数加1。
  ip->nlink++;
  iupdate(ip);
  iunlock(ip);
  // 查找新路径名的最后一个元素的父目录
  // 限制条件是,新路径名必须存在,而且要与旧路径名位于同一个设备上,因为inode号只在同一个设备上有效。
  if((dp = nameiparent(new, name)) == 0)
    goto bad;
  ilock(dp);
  // 创建一个新的目录条目,指向old的inode
  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;
}

​ sys_link为一个已存在的inode创建新的硬链接,而create则根据给定的路径名,创建一个全新的inode,并且创建硬链接,如下所示。

// 有三种情况会用到create:
//open使用O_CREATE模式创建一个新文件;
//mkdir创建一个新目录;
//mkdev创建一个新设备文件。
static struct inode*
create(char *path, short type, short major, short minor)
{
  struct inode *ip, *dp;
  char name[DIRSIZ];
  // 获得新路径名最后一个元素的父目录的inode指针
  if((dp = nameiparent(path, name)) == 0)
    return 0;
  // 然后获取父目录dp的锁
  ilock(dp);
  // 查找要创建的inode是否已经存在于dp之中
  // 如果已经存在,根据语义的不同,create会做不同的处理
  // 如果create的上层调用是open
  if((ip = dirlookup(dp, name, 0)) != 0){
    iunlockput(dp);
    ilock(ip);
    // 而且已存在的是普通文件或设备文件
    if(type == T_FILE && (ip->type == T_FILE || ip->type == T_DEVICE))
      return ip; // 直接返回已经存在的inode。
    // 如果create的上层调用是mkdir或mkdev,那么create将直接返回错误。
    iunlockput(ip); 
    return 0;
  }
  // 如果要创建的inode不存在,就调用ialloc分配一个新的inode ip,然后对ip上锁。
  if((ip = ialloc(dp->dev, type)) == 0)
    panic("create: ialloc");

  ilock(ip);
  // 从这里开始,create同时持有ip和dp的锁
  ip->major = major;
  ip->minor = minor;
  ip->nlink = 1;
  iupdate(ip);
  // 如果新的inode是目录类型
  if(type == T_DIR){  // Create . and .. entries.
    dp->nlink++;      // 文件链接计数/硬链接数,和目录的链接计数,在xv6中都用nlink来表示
    iupdate(dp);
    // No ip->nlink++ for ".": avoid cyclic ref count.
    // 在该目录内容中创建两个条目"."和"..",
    if(dirlink(ip, ".", ip->inum) < 0 || dirlink(ip, "..", dp->inum) < 0)
      panic("create dots");
  }
  // 将新的条目ip添加到dp中
  if(dirlink(dp, name, ip->inum) < 0)
    panic("create: dirlink");
  // 然后解锁dp
  iunlockput(dp);
  // 返回一个上锁的ip。
  return ip;
}

​ 利用create,很容易就能实现上层的sys_open、sys_mkdir和sys_mknod等系统调用接口。sys_open,无论ip是由create新分配的,还是用namei打开的原有的,sys_open接着调用filealloc分配一个打开文件,并且调用fdalloc分配一个文件描述符,接着填充struct file结构。

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

  if((n = argstr(0, path, MAXPATH)) < 0 || argint(1, &omode) < 0)
    return -1;

  begin_op();
  // 如果调用open时设置了O_CREATE模式,那么将调用create
  if(omode & O_CREATE){
    ip = create(path, T_FILE, 0, 0); // 上锁的inode
    if(ip == 0){
      end_op();
      return -1;
    }
  } else
  { // 否则,open默认打开一个现有的文件,因此调用namei。
    if((ip = namei(path)) == 0)
    { // inode并不上锁
      end_op();
      return -1;
    }
    ilock(ip);
    if(ip->type == T_DIR && omode != 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){
    if(f)
      fileclose(f);
    iunlockput(ip);
    end_op();
    return -1;
  }

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

​ 最后再看sys_pipe,它是创建(匿名)管道的系统调用,调用时,我们要传入一个容纳两个整型的数组作为参数,接着调用pipealloc创建一个新的管道,管道两端的打开文件结构rf和wf也会被创建并设置好。然后sys_pipe通过fdalloc,用rf和wf分配两个新的文件描述符,最后通过copyout在传入的数组中放置这两个新的文件描述符fd0和fd1。

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

  // int p[2]
  // pipe(p),a0寄存器是数组p[2]
  if(argaddr(0, &fdarray) < 0)
    return -1;
  if(pipealloc(&rf, &wf) < 0)
    return -1;
  fd0 = -1;
  // 分配两个新的文件描述符fd0和fd1
  if((fd0 = fdalloc(rf)) < 0 || (fd1 = fdalloc(wf)) < 0){
    // 异常处理
    if(fd0 >= 0)
      p->ofile[fd0] = 0;
    fileclose(rf);
    fileclose(wf);
    return -1;
  }
  // 将两个文件描述符fd0和fd1拷贝回数组p[2]中
  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,为了创建管道,会分配两个分配一个新的打开文件f0和f1。

Real World

​ 真实操作系统的Buffer Cache比xv6的要更复杂,但有两个理念是相同的:缓存和同步。xv6的缓存维护和换出策略是LRU,然而还有更多的复杂策略,每种策略都在某种工作负载上表现不错。一个更有效率的LRU缓存不使用链表,而使用哈希表和堆来完成查找和换出。**更常见的缓存类型是将虚拟内存页面和文件系统页面集成到统一页面缓存中,**以便支持内存映射文件。

​ xv6的日志系统使用数据日志协议,数据日志往往不够高效,因为数据和元数据都要被写入磁盘两次,所以元数据日志从性能上看可能是一个更好的方案。而且,当xv6有系统调用正在执行时,就不能提交日志,这也限制了并发性。再者,即使只有几个字节要更新,也需要将整个数据块都写入日志。最后,写入日志和加检查点时,一次只写入一个块到磁盘上,这很可能导致每次写入都需要一个完整的磁盘旋转,从而导致高定位成本。显然,现代操作系统的日志系统更为高效和完善,解决了以上这些性能问题。

​ xv6的文件系统是类UNIX文件系统,磁盘布局非常相似。BSD的UFS和FFS、Linux的ext2和ext3都以这种布局和相关数据结构为基础。但是,UNIX文件系统效率的低下可能体现在目录结构上,例如每次查找目录时,需要线性地扫描一遍所有目录条目。如果目录只有很少条目,这是高效的,但是对于一个拥有很多文件的目录来说,这显然不是一个好的方案。因此,Windows的NTFS、Mac OS X的HFS、Solaris的ZFS等文件系统,使用B树这种数据结构来构建目录层次结构,这稍显复杂,但是查找目录时只需要对数级别的时间复杂度。

​ xv6对于磁盘错误的处理过于简单,它只会陷入panic。这种方式是否合理,取决于底层硬件。如果操作系统是在特殊硬件,例如RAID上构建的,那么偶尔出现的磁盘错误,或许操作系统能够利用冗余备份进行一些恢复操作;而在普通磁盘上构建的操作系统,或许对这种情况无能为力,但是应该合适地处理磁盘错误,不让它影响文件系统正常部分的使用。

​ xv6的文件系统只挂载在一个磁盘设备上,且大小是固定的。这种限制不能满足例如数据库、多媒体文件驱动器等存储要求高的实例。一种基本的思想是,将多个磁盘组合成一个同一的逻辑磁盘。RAID仍然是目前最热门的硬件解决方案,但是趋势是使用软件来解决,例如通过动态添加或删除磁盘来增大或缩小逻辑设备。与此相对的,文件系统也需要能动态地调整各组件的大小,如inode块和数据块的数量等。

​ xv6的文件系统缺少很多现代文件系统具备的特性,例如磁盘快照、定期备份等。

​ 现代的UNIX文件系统允许系统调用像访问本地磁盘一样,访问更多类型的资源如命名管道、网络套接字、网络文件系统、监视和控制接口(如/proc)等,它们为打开文件提供一系列的函数指针,利用这些函数指针可以调用各个子系统的功能。