skip to content

Search

文件

43 min read Updated:
大树

XV6文件系统的设计

总体结构

磁盘

在硬件上磁盘分为若干个扇区,操作系统将多个扇区组合成一个块读写

具体对于virtio磁盘,1个扇区是512字节;对XV6,1个块是1024字节,即2个扇区,即BSIZE = 1024

下面是磁盘的布局

XV6磁盘布局
块号区域块数用途
0boot block1引导块加载内核
1super block1超级块描述磁盘布局
2 ~ 32log31日志区负责崩溃恢复
33 ~ 45inode blocks13存 dinode
46bitmap1位图,1 位/块
47 ~ 1999data blocks1953文件与目录内容

引导块通常存有引导程序,负责启动时将系统内核加载到内核中运行。在XV6中QEMU直接将内核开始处拷贝到内存中的固定位置,所以引导块并没有实际用上

超级块记录了磁盘的布局信息,其具体成员定义如下

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

inode区存放dinode,是描述文件的核心结构体。bitmap区是位图,记录了磁盘上哪些块已经被使用,哪些块是空闲的,每一块用一位表示。data blocks区存放文件的具体数据

分层

XV6设计的文件系统分为若干层次,从上到下依次为

  • File Descriptor: 文件描述符层,抽象Unix资源供程序使用
  • Pathname: 路径名层,提供层次的路径名
  • Directory: 目录层,目录被视作一个文件,记录了名字-inode的映射关系
  • Inode: inode层,文件的内容管理,通过此找到磁盘具体数据
  • Logging: 日志层,将操作打包为事务,保存崩溃也能一致
  • Buffer Cache: 缓冲层,缓存磁盘数据,减少磁盘I/O,提供锁做同步
  • Disk: 磁盘层,提供对磁盘的读写操作

调用链

学习这一章容易陷入函数的海洋中,这一章函数极其多,很容易看懂代码但是搞不清函数在哪里调用,什么时候调用

下面将读文件,写文件,创造文件,删除文件的全过程调用链总结出来,此节可以暂时先跳过,读完下面函数之后再回来看

总则

  1. 所有对磁盘的读,最终收敛到缓冲层的bread,用完brelse释放缓冲区
  2. 所有对磁盘的写都不直接写。先是bread拿到原始数据,在内核缓冲区进行修改(修改的是内存,磁盘没有改),然后调用log_write登记事务,brelse释放缓冲区,最后由日志层的end_op决定commit之后调用缓冲层的bwrite写回磁盘

读文件

read(fd, buf, n)                                [用户]
└─ sys_read                                     [系统调用层] 取参、argfd 找 struct file
   └─ fileread(f, addr, n)                      [文件描述符层] 按 f->type 分发,走 FD_INODE 分支
      ├─ ilock(f->ip)                           [inode 层] 上睡眠锁,valid==0 时读 dinode
      ├─ readi(ip, 1, addr, f->off, n)          [inode 层] 按字节偏移逐块读
      │  └─ 循环每块
      │     ├─ bmap(ip, off/BSIZE)              [inode 层] 文件块号 -> 磁盘块号(读路径不分配)
      │     ├─ bread(ip->dev, addr)             [缓冲层] 命中直接用,miss 才 virtio_disk_rw 读盘
      │     ├─ either_copyout(...)              块内容 -> 用户地址
      │     └─ brelse(bp)                       [缓冲层] 放回 LRU
      ├─ iunlock(f->ip)                         [inode 层]
      └─ f->off += r                            [文件描述符层] 偏移在这里更新,不在 inode 层

用户态程序通过read系统调用,到内核中就是sys_read,sysread调用fileread,对于普通文件而言,经过inode层的ilock获取inode.lock睡眠锁之后,调用readi读取文件内容。readi通过bmap找到文件对应数据块之后,调用缓冲层函数bread,之后用either_copyout将数据拷贝到用户空间,最后调用brelse释放缓冲区。最后iunlock释放inode层睡眠锁

注意读文件不需要日志,需要inode的睡眠锁

写文件

write(fd, buf, n)
└─ sys_write                                    [系统调用层]
   └─ filewrite(f, addr, n)                     [文件描述符层]
      └─ 循环分批(每批 <= max)
         ├─ begin_op()                          [日志层] 进入事务
         ├─ ilock(f->ip)                        [inode 层]
         ├─ writei(ip, 1, src, off, n1)         [inode 层] 逐块写
         │  ├─ 循环每块
         │  │  ├─ bmap(ip, off/BSIZE)           [inode 层] 块不存在 -> balloc
         │  │  │  └─ balloc                     [inode 层] bread 位图 -> 置位 -> bzero
         │  │  │     └─ log_write(位图块)       [日志层]
         │  │  ├─ bread(ip->dev, addr)          [缓冲层]
         │  │  ├─ either_copyin(...)            用户数据 -> 块内容
         │  │  ├─ log_write(bp)                 [日志层] 登记数据块
         │  │  └─ brelse(bp)                    [缓冲层]
         │  └─ ip->size=off; iupdate(ip)        [inode 层] 登记 inode 块
         ├─ iunlock(f->ip)                      [inode 层]
         └─ end_op()                            [日志层] 最后一个 outstanding 触发 commit
            └─ commit()                         [日志层]
               ├─ write_log()                   缓存 -> 日志区
               ├─ write_head()                  真正提交点
               ├─ install_trans()               日志区 -> 实际块号
               └─ write_head()                  清空日志

写文件更为复杂一些,普通文件的写系统调用最终到达filewrite函数。先调用begin_op进入事务,之后调用ilock获取inode.lock睡眠锁,随后调用writei写入文件内容。writei通过bmap找到文件对应数据块。用bread读到磁盘数据之后,用either_copyin将用户数据拷贝到缓冲区,之后调用log_write登记事务,最后调用brelse释放缓冲区。全部完成之后iunlock释放inode.lock睡眠锁,最后调用end_op结束事务,触发日志提交

创造文件

open(path, O_CREATE) / mkdir(path) / mknod(path, ...)
└─ sys_open / sys_mkdir / sys_mknod             [系统调用层] 先 begin_op()
   ├─ create(path, type, major, minor)          [系统调用层, static]
   │  ├─ nameiparent(path, name)                [路径名层] 父目录 inode + 最后一段名字
   │  ├─ ilock(dp)                              [inode 层] 锁父目录,让"查重+分配+链接"原子
   │  ├─ dirlookup(dp, name, 0)                 [目录层] 先查有没有重名
   │  ├─ ialloc(dp->dev, type)                  [inode 层] 找空闲 dinode -> 标 type -> iget
   │  │  └─ log_write(dinode 块)                [日志层]
   │  ├─ ilock(ip)
   │  ├─ ip->nlink=1; iupdate(ip)               [inode 层]
   │  ├─ 若 type == T_DIR
   │  │  ├─ dirlink(ip, ".",  ip->inum)         [目录层]
   │  │  └─ dirlink(ip, "..", dp->inum)         [目录层]
   │  ├─ dirlink(dp, name, ip->inum)            [目录层] 写一条 dirent
   │  │  └─ writei(dp, ...)                     [inode 层] 目录也是文件
   │  │     └─ bmap / bread / log_write / brelse [inode 层 + 日志层 + 缓冲层]
   │  └─ iunlockput(dp)                         [inode 层]
   ├─ filealloc()                               [文件描述符层] 分配 struct file
   ├─ fdalloc(f)                                [系统调用层] 进程 ofile[] 找空 fd
   ├─ f->type=FD_INODE; f->off=0; f->ip=ip
   ├─ 若 O_TRUNC: itrunc(ip)                    [inode 层]
   ├─ iunlock(ip)                               [inode 层]
   └─ end_op()                                  [日志层]

删除文件

unlink(path)
└─ sys_unlink                                   [系统调用层] begin_op()
   ├─ nameiparent(path, name)                   [路径名层] 父目录 dp + 名字
   ├─ ilock(dp)                                 [inode 层]
   ├─ dirlookup(dp, name, &off)                 [目录层] 找到目标 ip + 目录项偏移 off
   ├─ ilock(ip)                                 [inode 层]
   ├─ 检查 nlink>=1;若目录还需 isdirempty
   ├─ writei(dp, 0, &空dirent, off, ...)        [inode 层] 把目录项清零(删"名字")
   ├─ 若是目录: dp->nlink--; iupdate(dp)        [inode 层]
   ├─ iunlockput(dp)                            [inode 层]
   ├─ ip->nlink--; iupdate(ip)                  [inode 层] 只把磁盘链接数 -1
   ├─ iunlockput(ip)                            [inode 层] -> iput,真正的释放在这里
   │  └─ iput: 若 ref==1 && valid && nlink==0
   │     ├─ itrunc(ip)                          [inode 层] bfree 直接块 + 间接块,size=0
   │     └─ ifree(dev, inum)                    [inode 层] 磁盘 dinode type=0
   └─ end_op()                                  [日志层]

缓冲区

分类函数作用
初始化binit将缓冲区初始化为双向链表
取块bget查缓存,未命中则分配新的,不读磁盘,会拿锁
读块bread调用 bget;数据无效真正读磁盘
写块bwrite把内容写回磁盘,只有日志层调用
释放brelse释放睡眠锁,释放缓冲块

所有对磁盘的读写最终都落到缓冲区层。bread不关心块的属性,它只从对应设备的对应块号取出内容,通过调用bget在缓冲区内分配。bwrite与bread类似,只不过读变成了写,只有日志层调用它,在事务结束生效之后正式写入。brelse释放缓冲块

缓冲区结构

缓冲层的核心是bcache,其内部有长度为NBUF的缓冲区数组buf[NBUF]

struct {
  struct spinlock lock;
  struct buf buf[NBUF];   // 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.
  struct buf head;        // 哨兵节点,不存数据
} bcache;
struct buf {
  int valid;        // 内存副本是否有效
  int disk;         // 磁盘是否正占用它
  uint dev;         // 设备号
  uint blockno;     // 块号
  struct sleeplock lock;  // 睡眠锁,保护 data
  uint refcnt;      // 引用计数:多少人在用
  struct buf *prev;
  struct buf *next;
  uchar data[BSIZE];// 块内容,BSIZE=1024
};

每一个buf都有一个sleeplock,用于保护data。bcache也有一把大自旋锁保护整个缓冲区,拿到具体的buf的睡眠锁之前要放掉

启动时main调用binit()初始化缓冲区,其将缓冲区各个buf链接为带哨兵节点的双向循环链表

void
binit(void)
{
  struct buf *b;
 
  initlock(&bcache.lock, "bcache");
 
  // 哨兵节点先自己成环
  bcache.head.prev = &bcache.head;
  bcache.head.next = &bcache.head;
  // 遍历数组中的所有buf
  for (b = bcache.buf; b < bcache.buf + NBUF; b++) {
    // 将buf插入链表头部,也就是哨兵节点的下一位
    b->next = bcache.head.next;
    b->prev = &bcache.head;
    // 初始化睡眠锁
    initsleeplock(&b->lock, "buffer");
    bcache.head.next->prev = b;
    bcache.head.next = b;
  }
}

链表被特意设计为LRU形式,即虽然已占用的buf和空闲的buf混在一起,但是对于空闲的buf而言,从链表哨兵头开始向后遍历,越靠前的空闲buf越是最近释放的。这个规则是由brelse()函数实现的。当释放一个buf就将其移至链表头部

bget

bget()尝试通过设备号和块号找到对应已有的buf,如果找不到就分配一个空闲的buf,然后填上设备号和块号。返回的buf已经拿到睡眠锁

static struct buf *
bget(uint dev, uint blockno)
{
  struct buf *b;
 
  acquire(&bcache.lock);            // 全程持锁
 
  // 判断是否已经被缓存过
  // 从链表头往后走
  for (b = bcache.head.next; b != &bcache.head; b = b->next) {
    if (b->dev == dev && b->blockno == blockno) {
      b->refcnt++;                  // 命中:引用 +1
      release(&bcache.lock);
      acquiresleep(&b->lock);       // 先释放大锁,再拿该buf的睡眠锁
      return b;
    }
  }
 
  // 找不到,没有被缓存
  // 从链表尾往前走,head.prev即为链表尾部
  for (b = bcache.head.prev; b != &bcache.head; b = b->prev) {
    if (b->refcnt == 0) {           // 找空闲块
      b->dev = dev;
      b->blockno = blockno;
      b->valid = 0;                 // 关键:迫使 bread 重新读盘
      b->refcnt = 1;
      release(&bcache.lock);
      acquiresleep(&b->lock);
      return b;
    }
  }
  panic("bget: no buffers");
}

bget没有实际读磁盘数据,而是仅仅在缓存区中占位

找到过程中,必须先放掉大锁再拿睡眠锁,因为陷入睡眠时不可以持有别的锁。这样的实现是安全的,因为refcnt++保证了buf不会被释放掉

对于新占用的块清空valid,迫使后面的bread()函数重新从磁盘读取数据

锁的使用保证了一个磁盘块只会有最多一个buf与之对应

注意占用新缓存时从链表尾部开始向前读,体现了LRU原则。LRU的维护由brelse()保证

bread

bread尝试从缓冲区中取对应块,如果块内数据无效,就从磁盘读取数据到缓冲区中

struct buf *
bread(uint dev, uint blockno)
{
  struct buf *b;
 
  b = bget(dev, blockno);
  if (!b->valid) {
    virtio_disk_rw(b, 0);   // 0 = 读,真正和磁盘交互
    b->valid = 1;
  }
  return b;                  // 返回已加锁的 buf
}

virtio_disk_rw是真正与磁盘交互的函数,他会睡眠以等待一次磁盘I/O完成

bwrite

// Write b's contents to disk.  Must be locked.
// Only the log calls bwrite.
void
bwrite(struct buf *b)
{
  if (!holdingsleep(&b->lock))
    panic("bwrite");
  virtio_disk_rw(b, 1);      // 1 = 写
}

只有日志层会调用这个函数,决定数据是否脏了需要写回

brelse

brelse释放一个buf,将其从链表中移到头部,保证LRU原则

void
brelse(struct buf *b)
{
  if (!holdingsleep(&b->lock))
    panic("brelse");
 
  releasesleep(&b->lock);       // 释放睡眠锁
 
  acquire(&bcache.lock);
  b->refcnt--;                  // 引用 -1
  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; // 插到 head 后面(最近使用端)
    b->prev = &bcache.head;
    bcache.head.next->prev = b;
    bcache.head.next = b;
  }
  release(&bcache.lock);
}

先放睡眠锁再拿大锁与前面保持对称,避免死锁。当refcnt为0时,说明没有人使用这个buf了,就将其从链表中摘下并插到链表头部

mkfs

mkfs不属于XV6系统,是跑在QEMU之外的主机程序,用于建立硬盘,在内核启动之前执行。下面是他的步骤

  1. 打开:创建 fs.img,把整块镜像清零
  2. 计算布局:根据 FSSIZE、NINODES、BSIZE 算出日志块数、inode 块数、位图块数,进而得到 logstart、inodestart、bmapstart 和各区大小
  3. 写超级块到 1 号块
  4. 创建根目录:分配 1 号 inode(T_DIR),写入 . 和 .. 两条目录项
  5. 写入各用户程序:为每个文件分配 inode 和数据块,内容拷进数据块,并在根目录追加一条目录项
  6. 写位图:把“已被占用的块”(元数据块 + 刚才分配的数据块)对应的位全部置 1,其余保持 0

结果是镜像里有了合法的超级块、根目录、用户程序,以及一张正确的位图。之后内核运行时,通过 balloc/bfree 在这张位图上继续分配和回收

块分配器

块分配器位于日志层之上,负责分配和回收磁盘块并管理位图

分类函数/宏作用
定位BBLOCK给定块号算它在哪个位图块
分配balloc从前往后扫位图找到0位置1占用,返回块号
释放bfree把对应位图位清 0
辅助bzero把指定块清零

块分配器所谓的分配与回收块本质上是修改位图中对应块的位,1表示占用,0表示空闲。balloc分配占用一个空闲的磁盘块,bfree释放一个已占用的磁盘块。bzero是辅助函数,清零一个块的内容,为了保证安全,balloc分配一个块之后会调用bzero清零该块内容,避免泄露之前的内容

位图

位图区中的每一位表示一个块的使用情况,1表示已被占用,0表示空闲,通常位图区第nn个位表示第nn个块的使用情况,线性映射。一个位图块能表示 BPB = BSIZE*8 = 8192 个块。具体映射方法定义了宏

#define BPB (BSIZE * 8)                          // 8192
#define BBLOCK(b, sb) ((b) / BPB + sb.bmapstart) // 块 b 的位在哪个位图块

b是块的编号,sb是超级块,sb.bmapstart是位图块的起始块号。XV6中 bmapstart=46 且 FSSIZE=2000 < BPB,即一个位图块就能存下所有块的占用信息,所以所有块都在位图块 46

balloc

// Allocate a zeroed disk block.
// returns 0 if out of disk space.
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) {            // 该位为 0 => 空闲
        bp->data[bi / 8] |= m;                      // 置 1:标记占用
        log_write(bp);                              // 登记进事务
        brelse(bp);
        bzero(dev, b + bi);                         // 清零新块
        return b + bi;                              // 返回块号
      }
    }
    brelse(bp);
  }
  printk("balloc: out of blocks\n");
  return 0;                                          // 0 = 失败哨兵
}

balloc从块0开始从前往后遍历整个位图,找到第1个空闲块就占用它,将其清空,并返回块号。代码比较难懂我详细解释一下

  1. b表示块号,b每次增加BPB,即每次处理一整个位图块。BBLOCK(b, sb)计算出块号b对应的位图块号,读入该位图块
  2. bi表示位图块内的位号,bi每次增加1,直到BPB,即处理该位图块内的每一位,同时保证b + bi < sb.size,即不超过磁盘总块数
  3. 读该块对应的位图位,决定是否占用

由于一开始硬盘boot block - bitmap都被占用,所以balloc会跳过这些块,找到第一个空闲块(通常是数据块)并占用它

bfree

// Free a disk block.
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;            // 清 0:标记空闲
  log_write(bp);
  brelse(bp);
}

bfree是balloc的逆操作,释放一个块。如果该块对应位图中的位是0,说明重复释放bug,panic报错

balloc和bfree调用的分配和释放的bread和brelse,管理了缓冲区的锁,天然互斥,因此不再需要额外的锁

inode

分类函数作用
分配ialloc磁盘上找空闲dinode,调用iget
引用iget内存inode表中查找/新建条目;ref++;不读盘
锁ilock加睡眠锁;inode无效时从磁盘读
同步iupdate内存inode写入磁盘
释放iputref--;最后一个引用且nlink == 0时释放
寻址bmap找到文件数据块块号,可能分配新的
截断itrunc释放全部直接块与间接块
元信息stati供stat系统调用获取元数据
读内容readi从 off 读 n 字节到 dst
写内容writei从 src 写 n 字节到 off,必要时分配新块并iupdate

inode层函数可以分为下面几类

  • 磁盘dinode的分配与释放:ialloc,ifree(ifree释放磁盘dinode,这里没有详细讲)
  • 内存inode的引用计数管理:iget,iput,idup
  • 内存inode的锁与同步:ilock,iunlock
  • 内存与磁盘的inode同步:iupdate
  • 文件内容的读写:readi,writei,他们调用bmap;itrunc释放文件内容块

磁盘inode

磁盘inode即为存在磁盘上面的dinode结构体

struct dinode {
  short type;              // 文件类型;0 = 空闲
  short major;             // 主设备号(仅设备文件)
  short minor;             // 次设备号(仅设备文件)
  short nlink;             // 链接数:多少目录项指向它
  uint size;               // 内容字节数
  uint addrs[NDIRECT + 1]; // 数据块地址(12 直接 + 1 间接)
};

它的大小是2 + 2 + 2 + 2 + 4 + (13 * 4) = 64字节,正好BSIZE / NINODE = 1024 / 16 = 64,即每块存放16个dinode,即IPB。相关的宏定义如下

#define IPB (BSIZE / sizeof(struct dinode))       // 16
#define IBLOCK(i, sb) ((i) / IPB + sb.inodestart) // 第 i 个 inode 所在块

内存inode

struct inode {
  uint dev;              // 设备号
  uint inum;             // inode 号
  int ref;               // 引用计数(内存指针数)
  struct sleeplock lock; // 保护下面所有字段
  int valid;             // 是否已从磁盘读入
 
  short type; // 以下都是 dinode 的副本
  short major;
  short minor;
  short nlink;
  uint size;
  uint addrs[NDIRECT + 1];
};

从磁盘中读取的dinode会被缓存到内存中,添加额外数据变为inode。内存维护了一张inode表

struct {
  struct spinlock lock;
  struct inode inode[NINODE];   // NINODE = 50
} itable;

inode有4种状态

  1. 是否已分配:磁盘 type 是否非 0;ialloc 分配,iput 释放
  2. 是否在表中被引用:ref > 0;iget 加,iput 减
  3. 内容是否有效:valid;ilock 读盘后置 1,iput 归零时清 0
  4. 是否被锁:先 ilock 才能读写字段和内容

inode锁

inode的两把锁管理的区域不同

  • itable.lock: 全局大锁,管理整个inode槽位的归属,即每个槽位的ref,dev,inum,valid字段
  • inode.lock: 每个inode的睡眠锁,保护该inode的内容字段,即type,major,minor,nlink,size,addrs

inode只出现在三个函数iget, idup, iput中,他们操作的都是inode那些内存特有的字段。只要读写那些字段,必须持有itable.lock,读写完成立刻释放

inode.lock在读写inode的内容字段时使用

ialloc

ialloc与balloc类似,balloc尝试找到一个空闲块,ialloc尝试在硬盘中找到一个空闲的dinode

硬盘中dinode.type为0表示空闲,非0表示已分配

他最后会调用iget在内存中分配inode条目并返回

// Allocate an inode on device dev.
struct inode *
ialloc(uint dev, short type)
{
  int inum;
  struct buf *bp;
  struct dinode *dip;
 
  for (inum = 1; inum < sb.ninodes; inum++) {   // 从 1 号扫到最后一个
    bp = bread(dev, IBLOCK(inum, sb));          // IBLOCK 计算出该 inode 所在块号
    // bp->data读出整块的数据,有16个dinode,将其转为dinode *类型加上偏移得到该dinode的指针
    // data是uchar*类型,转为dinode *就指向第0个dinode
    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);                             // 登记写盘
      brelse(bp);
      return iget(dev, inum);                    // 返回内存条目
    }
    brelse(bp);
  }
  printk("ialloc: no inodes\n");
  return 0;
}

iget

iget只操作内存inode表,不读盘,不加睡眠锁

dev是设备号,inum是磁盘上的dinode索引,返回内存inode表中对应的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++) {
    if (ip->ref > 0 && ip->dev == dev && ip->inum == inum) {
      ip->ref++;                     // 命中:返回一个新引用
      release(&itable.lock);
      return ip;
    }
    if (empty == 0 && ip->ref == 0)  // 记住第一个空槽
      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;
}

iget仅仅在内存inode表中查找或分配一个条目,没有建立与磁盘dinode的联系

&itable.lock保护整个内存inode表,使得一个inode最多只有一个条目

ilock

ilock加睡眠锁,如果数据无效从磁盘读入数据到内存inode中

相对的iunlock释放睡眠锁

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;  // 和ialloc的指针操作一致
    ip->type = dip->type;       // dinode -> 内存 inode
    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");
  }
}

iupdate

iupdate将内存inode写回磁盘dinode

void
iupdate(struct inode *ip)
{
  struct buf *bp;
  struct dinode *dip;
 
  bp = bread(ip->dev, IBLOCK(ip->inum, sb));
  dip = (struct dinode *)bp->data + ip->inum % IPB;
  dip->type = ip->type;                  // 内存 inode -> dinode
  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);
}

每次改动会落盘的字段后都要调用iupdate,调用者必须持 ip->lock;

iput

iput释放一个内存inode引用,如果是最后一个引用且没有链接,就释放磁盘dinode和数据块

void
iput(struct inode *ip)
{
  acquire(&itable.lock);
 
  int last = (ip->ref == 1 && ip->valid && ip->nlink == 0); // 最后引用且无链接
  uint dev = ip->dev, inum = ip->inum;   // 先捕获,ref 归零后槽位可能被复用
 
  if (last) {
    acquiresleep(&ip->lock);
    release(&itable.lock);
 
    itrunc(ip);        // 释放数据块(磁盘上 type 暂时保留)
    ip->valid = 0;
 
    releasesleep(&ip->lock);
 
    acquire(&itable.lock);
  }
 
  ip->ref--;           // 放引用
  release(&itable.lock);
 
  if (last)
    ifree(dev, inum);  // 现在再把磁盘 dinode 的 type 清 0
}

这里持有自旋锁时再次尝试获取睡眠锁是安全的,因为ref == 1表面没有其他线程在使用该inode,所以不可能有线程持有ip->lock,因而acquiresleep(&ip->lock)不会陷入睡眠而是直接获取锁之后返回

bmap

dinode.addrs存放了文件数据的磁盘块号,前0-11是直接块地址,直接指向数据块,12是间接块地址,指向的间接块另外存储了额外的数据块地址,每个地址4字节,一个块至多存256个地址

磁盘上的文件布局

bmap函数包装了这种表示逻辑,其返回inode下第bn个数据块的磁盘块号,如果没有就分配一个新的数据块并返回

由于修改了inode.addrs,必须持有inode.lock

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;                      // 换成间接区下标,减12
 
  if (bn < NINDIRECT) {               // 间接块区,不能超过256
    // Load indirect block, allocating if necessary.
    if ((addr = ip->addrs[NDIRECT]) == 0) { // 还没有间接块?
      addr = balloc(ip->dev);         // 分配的新块都会清0
      if (addr == 0)
        return 0;
      ip->addrs[NDIRECT] = addr;
    }
    bp = bread(ip->dev, addr);            // 读入间接块
    a = (uint *)bp->data;                 // 当作 uint 数组
    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");
}

itrunc

itrunc与bmap相反,作用是递归地释放inode下的所有数据块,其按照先直接块,再间接块对应的数据块,再间接块本身清理

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

stati

stati是stat系统调用的实现,返回文件的元信息

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

readi

readi意图从inode为ip的文件,文件内字节偏移off开始读,一共读n个字节,将内容拷贝到dst地址上面的缓冲区上。如果user_dst == 1,那么dst就是用户地址,否则为内核地址

int
readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n)
{
  uint tot, m;
  struct buf *bp;
 
  if (off > ip->size || off + n < off)  // 起点越界,或 n 溢出
    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;                           // 实际读到的字节数
}

开头检查off是否在文件内,以及n是否溢出。之后对于要读取的字节数超过文件末尾剩余长度的情况,缩减n

之后在主循环中,通过bmap找到对应的数据块号,用bread读磁盘块,算出本次能拷贝多少个字节(剩余需要拷贝的数量n - tot,与当前块中偏移off剩下的字节数的较小值),执行拷贝

由于off检查后一定小于ip->size,所以bmap不会分配新块

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) {
      log_write(bp);                    // 可能已改了半个块,要记日志
      brelse(bp);
      break;
    }
    log_write(bp);                      // 把修改记入日志
    brelse(bp);
  }
 
  if (off > ip->size)                   // 写使文件长大了?
    ip->size = off;                     // 更新大小
  iupdate(ip);                          // 写回 inode
  return tot;
}

writei执行写文件,在超过文件末尾但是没有超过文件上限(所有的直接块与间接块对应的数据块)的情况下分配新的数据块

目录层

目录结构

XV6的设计与Unix理念类似,目录被视为一种特殊的文件,数据排布方式与普通文件相同(直接块+间接块),但是其数据为dirent结构体数组,每个dirent表示一个目录项,包含文件名和对应的inode号。一个结构体大小16字节,一块能存64个目录项

#define DIRSIZ 14
 
struct dirent {
  ushort inum;                            // 2 字节
  char name[DIRSIZ] __attribute__((nonstring)); // 14 字节
};

inum是目录号,回顾之前便是磁盘上inode区的索引号,为0表示该槽位空闲;name是文件名,长度至多14字节

  • 当文件名小于14字节时,name后面补\0
  • 当文件名大于等于14字节时,name不补\0,只取前14字节

因此比较两个文件名是否相等时,不能使用普通的字符串比较函数,XV6包装了一个namecmp函数,比较两个文件名是否相等

int namecmp(const char *s, const char *t) {
  return strncmp(s, t, DIRSIZ);   // 固定只比 14 字节,不靠 \0
}

每个目录通常前两项是..和.,分别表示父目录和当前目录。在创建目录时写入。根目录的..指向自己

dirlookup

dirlookup在目录dp中查找文件名为name的目录项,如果找到就返回对应的inode

找到的方式是通过iget返回对应的inode项,不会持有对应的inode.lock,只是将ref++,拿锁需要靠ilock

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)) {
    if (readi(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
      panic("dirlookup read");
    if (de.inum == 0)
      continue;                       // 空槽,跳过
    if (namecmp(name, de.name) == 0) {
      if (poff)
        *poff = off;                  // 告诉调用者:条目在第 off 字节
      inum = de.inum;
      return iget(dp->dev, inum);     // 返回"已引用但未锁定"的 inode
    }
  }
 
  return 0;                           // 没找到
}

dirlookup返回的inode没有锁定的原因在于

  • 调用dirlookup之前的dp已经被调用者锁住,如果传入.查找本身,如果dirlookup返回的.试图加锁,就是又给自己加锁,就会陷入死锁
  • 如果传入..可能会更复杂,例如父目录去查子目录,子目录想拿父目录,调用dirlookup之前各自都锁住了,那么就会死锁

这只是简单的情况,还有更复杂的,为了降低复杂性,dirlookup不主动加锁

dirlink在目录dp中创建一个新的目录项,名字为name,对应的inode号为inum

int
dirlink(struct inode *dp, char *name, uint inum)
{
  int off;
  struct dirent de;
  struct inode *ip;
 
  // 1) 先查重:名字已存在就报错
  if ((ip = dirlookup(dp, name, 0)) != 0) {
    iput(ip);
    return -1;
  }
 
  // 2) 找一个空闲槽位
  for (off = 0; off < dp->size; off += sizeof(de)) {
    if (readi(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
      panic("dirlink read");
    if (de.inum == 0)
      break;                          // 找到空槽,off 停在它的偏移
  }
 
  // 3) 写进去
  strncpy(de.name, name, DIRSIZ);
  de.inum = inum;
  if (writei(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
    return -1;
 
  return 0;
}

注意off分几种情况

  1. for循环中找到了空槽,off直接指向空槽的偏移,可以直接写入
  2. for循环中没有找到空槽,off会等于dp->size,此时直接指向文件末尾,也就是第一个空闲的数据块,writei会自动分配新的数据块并写入

不会出现写出界(也就是超过最大文件限制)的情况,因为writei检查出界返回-1,走入if分支返回-1

路径名层

namex是路径名层的核心函数,其通过path查找对应的inode,如果nameiparent == 1,则返回最后一个元素的父目录的inode,否则返回最后一个元素的inode

核心在于调用skipelem,这个函数将路径名的第一个元素拷贝到name中,并返回剩余路径名的指针。namex循环调用skipelem,每次处理一个路径元素,直到走到最后一个元素

static struct inode *
namex(char *path, int nameiparent, char *name)
{
  struct inode *ip, *next;
 
  // 起点
  if (*path == '/')
    ip = iget(ROOTDEV, ROOTINO);      // 绝对路径:根目录 inode 1
  else
    ip = idup(myproc()->cwd);         // 相对路径:当前工作目录
 
  // 一个元素一个元素地走
  while ((path = skipelem(path, name)) != 0) {
    ilock(ip);                        // 锁住当前目录
    if (ip->type != T_DIR) {          // 必须是目录
      iunlockput(ip);
      return 0;
    }
    if (ip->nlink == 0) {             // 已被删除的目录
      iunlockput(ip);
      return 0;
    }
    if (nameiparent && *path == '\0') {  // 最后一个元素就停
      iunlock(ip);
      return ip;
    }
    if ((next = dirlookup(ip, name, 0)) == 0) {  // 找下一层
      iunlockput(ip);
      return 0;
    }
    iunlockput(ip);                   // 先放当前,再
    ip = next;                        // 把 next 留到下一轮再锁
  }
 
  if (nameiparent) {                  // 走到头却不是最后一个元素
    iput(ip);
    return 0;
  }
  return ip;
}

描述符层

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

对于每一个进程而言,都有一个自己独有的fd文件描述符表,这个表是一个数组,存储指向file结构体的指针

struct file *ofile[NOFILE]

平时调用open等拿到的文件描述符整数,就是这个数组的索引

对于系统全局有一个ftable,所有进程共用。真正的file结构体存储在这个表中

struct file file[NFILE]

进程的文件描述符表中的指针指向全局ftable中的file结构体,使得多个描述符,甚至多个进程,可以共享同一个file结构体

每次调用open时实际在全局ftable中分配一个file结构体,因此就算两次open同一个文件,其file结构体中的偏移量off也是独立的,互不影响

dup在进程的文件描述符表中分配一个新的索引,指向同一个file结构体,因此共享偏移量off,互相影响

fork直接拷贝整个文件描述符表,父子进程指向的file结构体是同一个