lab / 2023.11.02

6.s081 lab8 File system

现在开始进入文件系统的阶段。

现在开始进入文件系统的阶段。

Preparation

切换到对应分支

1$ git fetch
2$ git checkout fs
3$ make clean

Task1: Large files

该任务要求我们为 inode 实现二级间接索引

原本的 inode 含有 11 个直接索引和 1 个一级间接索引,已知一个 disk block 大小为 1024,一个块地址大小为 4B,那一个 block 内部可以容纳 256 条地址,即当前文件系统仅支持最大 (11+256)*BSIZE = 267KB 大小的文件。

这里,我们需要将 1 个直接索引拿来升级为二级间接索引(指向一个包含 256 个一级间接索引的 block)。那么首先要修改 inode 结构体。xv6 中,除了表示磁盘上的 inode struct dinode 外,内存中还包含磁盘 inode 的拷贝 struct inode,这两者分别位于 kernel/fs.hkernel/file.h。同时,还要修改一些宏字段如 MAXFILE,以适配我们「升级」后的文件系统。

 1...
 2#define NDIRECT 11                                                      // (!new)
 3#define NINDIRECT (BSIZE / sizeof(uint))
 4#define NDOUBLYINDIRECT ((BSIZE / sizeof(uint))*(BSIZE / sizeof(uint))) // (!new)
 5#define MAXFILE (NDIRECT + NINDIRECT + NDOUBLYINDIRECT)                 // (!new)
 6
 7// On-disk inode structure
 8struct dinode {
 9  ...
10  uint addrs[NDIRECT+1+1];  // (!new)
11};
12...
1// in-memory copy of an inode
2struct inode {
3  ...
4  uint addrs[NDIRECT+1+1];  // (!new)
5};

修改完后,便可以通过 make fs.img 重构 qemu 的文件系统了。

现在,我们就能尝试创建更大的文件了。当然,最开始创建一个文件时,文件系统仅仅是为其分配了一个 inode,还没有分配任何 disk block,只有当对文件进行写入时,才根据写入文件的 offset 检查是否需要为其分配 block。我们查看写文件的 sys_write() 操作,会发现其调用了 filewrite(),然后进一步调用 writei()writei() 里有个 for 循环,每次取文件中一个 block 的大小进行写入,那么第 i 次写入数据对应的文件偏移量为 i * BSIZE,相当于写入了 inode 对应的逻辑块号为 i 的 disk block。

bread() 函数就是根据 block number 找到 block cache 中对应的 block 的,那么 block number 怎么求?我们发现 bread() 的第二个参数是根据函数 bmap() 得来的,而这个 bmap() 就是根据 inode 中的逻辑块号获取物理块号的。进去看了一眼发现,当前 bmap() 仅支持一级间接索引,所以我们要做的就是修改该函数,令其支持二级间接索引。

bmap() 的基本思路很简单,首先查直接索引,然后查一级间接索引。如果某一逻辑块号没有对应的索引块,那就为其分配一个索引块。从这也能看出,索引块是按需分配的,即便支持了二级间接索引,也不会因此导致大量的磁盘块分配。

参考一级索引的查找方式,很容易能写出二级的:

 1static uint
 2bmap(struct inode *ip, uint bn)
 3{
 4  uint addr, *a;
 5  struct buf *bp;
 6
 7  if(bn < NDIRECT){
 8    ...
 9  }
10  bn -= NDIRECT;
11
12  if(bn < NINDIRECT){
13    // Load indirect block, allocating if necessary.
14    ...
15  }
16  bn -= NINDIRECT;
17
18  // 在二级索引块中,包含了 256 个一级间接索引块号,相当于其 `addr[]` 中的每个下标都覆盖了 256 个物理块
19  // 由于这里逻辑块号 bn 已经被映射到 0~256*256-1 的范围,所以
20  // bn/NINDIRECT 为二级索引块的下标
21  // bn%NINDIRECT 为二级索引块指向的一级索引块的下标
22  if (bn < NDOUBLYINDIRECT) {
23    // Load doubly-indirect block, allocating if necessary.
24    if((addr = ip->addrs[NDIRECT+1]) == 0)
25      ip->addrs[NDIRECT+1] = addr = balloc(ip->dev);
26    bp = bread(ip->dev, addr);
27    a = (uint*)bp->data;
28
29    if ((addr = a[bn/NINDIRECT]) == 0) {
30      a[bn/NINDIRECT] = addr = balloc(ip->dev);
31      log_write(bp);
32    }
33    brelse(bp); // 在指向新的 block 前,释放原来的,下同
34
35    bp = bread(ip->dev, addr);
36    a = (uint*)bp->data;
37    bn %= NINDIRECT;
38
39    if((addr = a[bn]) == 0){
40      a[bn] = addr = balloc(ip->dev);
41      log_write(bp);
42    }
43    brelse(bp);
44    return addr;
45  }
46  panic("bmap: out of range");
47}

当然,有写操作,自然有相应的清空操作。什么时候要清空呢?我们这里写的都是位于内存的 block cache 中的数据,struct inode 也是位于内存的相对于磁盘的缓存。有 cache 那必然有 victim,当一个 cache-inode 要被 victim 时,会将其所有块的数据写入 disk,然后释放,从而实现内存复用。

inode 里有一个 ref 字段,表明该文件的引用计数。我们对 inode 的操作总是伴随着 ilock()iunlock(),事实上还有一个函数叫 iunlockput(),它将 iunlock()iput() 结合到一起。那么 iput() 是干嘛的?通过阅读函数原型,我们发现它就是将 inode.ref--,如果减到 0,并且 inode.nlink 也为 0,说明内存中不要这个文件了,磁盘里也不再需要该文件,此时就可以通过 itruc() 将 inode 所使用的所有块写入磁盘并释放。

同样的,我们现在支持了二级索引,那必然要对用到 inode 索引的所有函数进行修改。itrunc() 即是如此。

 1void
 2itrunc(struct inode *ip)
 3{
 4  ...
 5  for(i = 0; i < NDIRECT; i++){
 6    ...
 7  }
 8
 9  if(ip->addrs[NDIRECT]){
10    ...
11  }
12
13  if (ip->addrs[NDIRECT+1]) {
14    bp = bread(ip->dev, ip->addrs[NDIRECT+1]);
15    a = (uint*)bp->data;
16    for (i = 0; i < NINDIRECT; i++) { // 释放二级索引下的所有一级索引
17      if (a[i]) {
18        struct buf *tmp = bread(ip->dev, a[i]);
19        uint *tmp_addr = (uint*)tmp->data;
20        for (j = 0; j < NINDIRECT; j++) {
21          if (tmp_addr[j]) {
22            bfree(ip->dev, tmp_addr[j]);
23          }
24        }
25        brelse(tmp);
26        bfree(ip->dev, a[i]);
27      }
28    }
29    brelse(bp);
30    bfree(ip->dev, ip->addrs[NDIRECT+1]);
31    ip->addrs[NDIRECT+1] = 0;
32  }
33
34  ip->size = 0;
35  iupdate(ip);
36}

该任务要求我们实现创建符号链接的系统调用,也就是创建一个 SYMLINK 类型的 inode,其指向的磁盘块数据内容为某个文件/目录的路径。这里我们不用实现指向目录的符号链接,只需要实现对文件的符号链接即可。

新增系统调用需要修改的文件就不说了。首先要新增两个宏,一个是用于 inode 类型的 T_SYMLINK,在 kernel/stat.h 中修改,另一个是用于 open 操作的选项 O_NOFOLLOW,在 kernel/fcntl.h 中修改,表明如果传入的路径解析出来是一个符号链接,且设置了该 option,就不用进一步打开链接对象,而是直接打开文件。否则,要逐渐深入,直到某路径对应的文件不是符号链接。

接下来,我们已经创建了系统调用 sys_symlink(),那么该函数要做什么呢?只需要新建一个 inode,然后调用 writei() 将目标文件路径写入即可。对就这么简单。

 1// Create the path with content target in block data.
 2uint64
 3sys_symlink(void)
 4{
 5  char target[MAXPATH], path[MAXPATH];
 6  struct inode* ip;
 7
 8  if (argstr(0, target, MAXPATH) < 0 || argstr(1, path, MAXPATH) < 0) {
 9    return -1;
10  }
11
12  begin_op();
13  {
14    if ((ip = create(path, T_FILE, 0, 0)) == 0) {
15      end_op();
16      return -1;
17    }
18    ip->type = T_SYMLINK;
19    writei(ip, 0, (uint64)target, 0, MAXPATH);
20    iunlockput(ip); // 调用
21  }
22  end_op();
23  return 0;
24}

还要改的是 sys_open() 函数,我们需要新增一条特性,以便打开 SYMLINK inode 时能打开其链接的目标文件。

 1uint64
 2sys_open(void)
 3{
 4  ...
 5  if(ip->type == T_DEVICE){
 6    ...
 7  } else {
 8    if (ip->type == T_SYMLINK && (omode & O_NOFOLLOW) == 0) { // 意味着要 follow 下去
 9      char target[MAXPATH];
10      uint threshold = 10; // 以防出现 b->a->b->... 的循环链接
11      while (threshold) {
12        if (readi(ip, 0, (uint64)target, 0, MAXPATH) != MAXPATH) { // 读取 inode 存储的 target 文件路径
13          panic("link error");
14        }
15        iunlockput(ip); // ip 即将赋值为下一个 inode,记得释放
16
17        if ((ip = namei(target)) == 0) { // 根据路径获取相应的 inode
18          end_op();
19          return -1;
20        }
21
22        ilock(ip);
23        if (ip->type != T_SYMLINK) {
24          break;
25        }
26        threshold--;
27      }
28
29      if (threshold <= 0) { // 突破阈值,说明可能存在循环,直接报错
30        iunlockput(ip);
31        end_op();
32        return -1;
33      }
34    }
35    f->type = FD_INODE;
36    f->off = 0;
37  }
38  ...
39}

测试结果

 1$ make grade
 2...
 3== Test running bigfile ==
 4$ make qemu-gdb
 5running bigfile: OK (106.6s)
 6== Test running symlinktest ==
 7$ make qemu-gdb
 8(0.8s)
 9== Test   symlinktest: symlinks ==
10  symlinktest: symlinks: OK
11== Test   symlinktest: concurrent symlinks ==
12  symlinktest: concurrent symlinks: OK
13== Test usertests ==
14$ make qemu-gdb
15usertests: OK (197.8s)
16== Test time ==
17time: OK
18Score: 100/100

最后的工作

  1. git commit -am "" 将所有修改提交到本地;
  2. 执行 make handin。由于 lab0 保存了 APIKey,故直接成功提交;

可选的挑战再说吧,没有什么想做的欲望。

附录:软链接与硬链接的区别

unix 文件系统简述

文件数据存放在若干磁盘块中,unix fs 用索引节点(inode)来定位文件所对应的磁盘块号。C 语言实现中,inode 是一个结构体,存放若干文件属性,例如 xv6 完整的 inode 结构体如下所示:

1// On-disk inode structure
2struct dinode {
3  short type;               // File type
4  short major;              // Major device number (T_DEVICE only)
5  short minor;              // Minor device number (T_DEVICE only)
6  short nlink;              // Number of links to inode in file system
7  uint size;                // Size of file (bytes)
8  uint addrs[NDIRECT+1+1];  // Data block addresses
9};

对于 unix fs 而言,根目录对应的 inode(unix 的万物皆文件理念)位置是全局可知的。一个目录文件对应的磁盘块数据中,有目录下所有子文件的 {文件名 => inode} 索引,根据此即可通过文件名到 inode cache 中拿到相应的数据。

所以对于路径 “/foo/bar/hello.c” 上的文件而言,需要经过多次定位 inode 与读盘操作将 hello.c 的数据读入内存。

软链接

ln -s target symlink

创建软链接相当于创建一个内容为目标文件路径的文件 symlink,为其分配一个全新的 inode 结构体与磁盘空间,通过 symlink 索引文件时只需获取盘块中的路径字符串,再对该路径进行递归访问。

删除 target 后不会影响 symlink,如果在原来的目录下继续新建一个名为 target文件,访问 symlink 依然成功。继续强调一遍,软链接即文件路径,只不过存放在磁盘中,不需要我们手打。在 Linux 中,利用 PATH 创建用户自定义目录下的软链接十分好用,不必进行 cp/mv 等操作。同时,允许用户跨文件系统进行访问。根据「万物即文件」,软链接也可以对目录创建。

缺点是相比直接访问 target 多了一次(如果路径上的文件还是软链接类型文件则需要多次)读盘操作,同时存在一些创建文件的系统调用开销。

硬链接

ln target hardlink

创建硬链接相当于在目录文件中写入一个与 target 相同 inode 号的字段,访问 hardlink 时会定位到 target 同一 inode,不会额外分配空间。

此时需要在 inode 中维护一个 linkRef 的字段,当多个文件 f1,f2,… 在同一 inode 上创建硬链接时,只要有至少一个文件 fi 存在(即 linkRef>0),对应的 inode 就不会被释放;当且仅当链接到那个 inode 的所有文件被删除后(即 linkRef=0),才释放 inode 与磁盘。

根据上述特性,硬链接比较适合对于处于深层次目录下的 target 进行创建,这样可以省去很多目录的读盘开销。但与软链接不同,hardlinktarget 只能在同一文件系统下,且不允许对目录创建硬链接。