【 0. 引言 】
- 文件的最早起源于我们需要把数据持久保存在 Persistent Storage 持久存储设备 上的需求。
大家不要被 持久存储设备 这个词给吓住了,这就是指计算机远古时代的 卡片、纸带、磁芯、磁鼓,和现在还在使用的 磁带、磁盘、硬盘,还有近期逐渐普及的 U盘、闪存、固态硬盘 (SSD, Solid-State Drive)等存储设备。我们可以把这些设备叫做 外存 。在此之前我们仅使用一种存储,也就是 内存(或称 RAM)。
- 相比内存,外存的读写速度较慢,容量较大,但内存掉电后信息会丢失,外存在掉电之后并不会丢失数据。因此,将需要持久保存的数据从内存写入到外存,或是从外存读入到内存 是应用和操作系统必不可少的一种需求。
- 本章任务
- 本章我们将实现一个简单的文件系统 – easyfs,能够对 持久存储设备 这种 I/O 资源进行管理。对于应用访问持久存储设备的需求,内核需要新增两种文件:常规文件和目录文件,它们均以文件系统所维护的 磁盘文件 形式被组织并保存在持久存储设备上。
- 同时,由于我们进一步完善了对 文件 这一抽象概念的实现,我们可以更容易建立 Everything is a file 一切皆文件 的UNIX的重要设计哲学。我们可扩展与应用程序执行相关的 exec 系统调用,加入对程序运行参数的支持,并进一步改进了对shell程序自身的实现,加入对重定向符号 > 、 < 的识别和处理。这样我们也可以像UNIX中的shell程序一样,基于文件机制实现灵活的I/O重定位和管道操作,更加灵活地把应用程序组合在一起实现复杂功能。
【 1. 文件系统接口 】
- 本节我们首先以 Linux 上的常规文件和目录为例,站在访问文件的应用的角度,介绍文件中值得注意的地方及文件使用方法。由于 Linux 上的文件系统模型还是比较复杂,在我们的内核实现中对它进行了很大程度的简化,我们会对简化的具体情形进行介绍。最后,我们介绍我们内核上应用的开发者应该如何使用我们简化后的文件系统和一些相关知识。
1.1 文件和目录
1.1.1 常规文件
- 在操作系统的用户看来, 常规文件是保存在持久存储设备上的一个字节序列,每个常规文件都有一个 文件名 (Filename) ,用户需要通过它来区分不同的常规文件。方便起见,在下面的描述中,“文件”有可能指的是常规文件、目录,也可能是之前提到的若干种进程可以读写的 标准输出、标准输入、管道等I/O 资源,请读者自行根据上下文判断取哪种含义。
- 在 Linux 系统上, stat 工具可以获取文件的一些信息。下面以我们项目中的一个源代码文件 os/main.c 为例,stat 工具展示了 main.c 的如下信息:
- File 表明它的文件名为 main.c 。
- Size 表明它的字节大小为 491 字节。
- Blocks 表明它占据 8 个 块 (Block) 来存储。在文件系统中,文件的数据以块为单位进行存储,在 IO Block 可以看出在 Ubuntu 系统中每个块的大小为 4096 字节。
- regular file 表明这个文件是一个常规文件。事实上,其他类型的文件也可以通过文件名来进行访问。
- Device 当文件是一个特殊文件(如块设备文件或者字符设备文件的时候),Device 将指出该特殊文件的 major/minor ID 。对于一个常规文件,我们无需关心它。
- Inode 表示文件的底层编号。在文件系统的底层实现中,并不是直接通过文件名来索引文件,而是首先需要将文件名转化为文件的底层编号,再根据这个编号去索引文件。然而,用户无需关心这一信息。
- Links 给出文件的硬链接数。同一个文件系统中如果两个文件(目录也是文件)具有相同的inode号码,那么就称它们是 硬链接 关系。这样links的值其实是 一个文件的不同文件名的数量。(本章的练习需要你在文件系统中实现硬链接!)
- Uid 给出该文件的所属的用户 ID , Gid 给出该文件所属的用户组 ID 。
- Access 的其中一种表示是一个长度为 10 的字符串(这里是 -rw-r–r-- ),其中第 1 位给出该文件的类型,这个文件是一个常规文件,因此这第 1 位为 - 。后面的 9 位可以分为三组,分别表示该文件的所有者/在该文件所属的用户组内的其他用户以及剩下的所有用户能够读取/写入/将该文件作为一个可执行文件来执行。
- Access/Modify 分别给出该文件的最近一次访问/最近一次修改时间。
$ stat os/main.c
File: os/main.c
Size: 491 Blocks: 8 IO Block: 4096 regular file
Device: 805h/2053d Inode: 4726542 Links: 1
Access: (0664/-rw-rw-r--) Uid: ( 1000/deathwish) Gid: ( 1000/deathwish)
Access: 2021-09-08 17:52:06.915389371 +0800
Modify: 2021-09-08 17:52:06.127425836 +0800
Change: 2021-09-08 17:52:06.127425836 +0800
Birth: -
- 用户常常通过文件的 拓展名 (Filename extension) 来推断该文件的用途,如 main.c 的拓展名是 .c ,我们由此知道它是一个 C 代码文件。但从内核的角度来看,它会将所有文件无差别的看成一个字节序列,文件内容的结构和含义则是交给对应的应用进行解析。
1.1.2 目录
- 最早的文件系统 仅仅通过文件名来区分文件,但是这会造成一些归档和管理上的困难。如今我们的使用习惯是将文件根据功能、属性的不同分类归档到不同层级的目录之下,这样我们就很容易逐级找到想要的文件。结合用户和用户组的概念,目录的存在也使得权限控制更加容易,只需要对于目录进行设置就可以间接设置用户/用户组对该目录下所有文件的访问权限,这使得操作系统能够更加安全的支持多用户。
- 同样可以通过 stat 工具获取目录的一些信息:
- directory 表明 os 是一个目录,从 Access 字符串的首位 d 也可以看出这一点。对于目录而言, Access 的 rwx 含义有所不同:r 表示是否允许获取该目录下有哪些文件和子目录;w 表示是否允许在该目录下创建/删除文件和子目录;x 表示是否允许“通过”该目录。
- Blocks 给出 os 目录也占用 8 个块进行存储。实际上目录也可以看作一种常规文件,它也有属于自己的底层编号,它的内容中保存着若干 目录项 (Dirent, Directory Entry) ,可以看成一组映射,根据它下面的文件或子目录的文件名或目录名能够查到文件和子目录在文件系统中的底层编号,即 Inode 编号。但是与常规文件不同的是,用户无法 直接 修改目录的内容,只能通过创建/删除它下面的文件或子目录才能间接做到这一点。
$ stat os
File: os/main.c
Size: 491 Blocks: 8 IO Block: 4096 regular file
Device: 805h/2053d Inode: 4726542 Links: 1
Access: (0664/-rw-rw-r--) Uid: ( 1000/deathwish) Gid: ( 1000/deathwish)
Access: 2021-09-08 17:52:06.915389371 +0800
Modify: 2021-09-08 17:52:06.127425836 +0800
Change: 2021-09-08 17:52:06.127425836 +0800
Birth: -
- 目录树
有了目录之后,我们就可以将所有的文件和目录组织为一种被称为 目录树 (Directory Tree) 的有根树结构(不考虑软链接)。树中的每个节点都是一个文件或目录,一个目录下面的所有的文件和子目录都是它的孩子。可以看出 所有的文件都是目录树的叶子节点。目录树的根节点也是一个目录,它被称为 根目录 (Root Directory)。 - 绝对路径
目录树中的每个目录和文件都可以用它的 绝对路径 (Absolute Path) 来进行索引, 绝对路径是目录树上的根节点到待索引的目录和文件所在的节点之间自上而下的路径上的所有节点的文件或目录名两两之间加上路径分隔符拼接得到的。例如,在 Linux 上,根目录的绝对路径是 / ,路径分隔符也是 / ,例如: main.c 的绝对路径是 /home/oslab/workspace/UCORE/uCore-Tutorial-v2/os/main.c
- 相对路径
一般情况下,绝对路径都很长,用起来颇为不便。而且,在日常使用中,我们通常固定在一个工作目录下而不会频繁切换目录。因此更为常用的是 相对路径 (Relative Path) 而非绝对路径。
- 每个进程都会记录自己当前所在的工作目录,当它在索引文件或目录的时候,如果传给它的路径并未以 / 开头则会被内核认为是一个相对于进程当前工作目录的相对路径,这个路径会被拼接在进程当前路径的后面组成一个绝对路径,实际索引的是这个绝对路径对应的文件或目录。其中, ./ 表示当前目录,而 …/ 表示当前目录的父目录,这在通过相对路径进行索引的时候非常实用。
- 在使用终端的时候, pwd 工具可以打印终端进程当前所在的目录,而通过 cd 可以切换终端进程的工作目录。
- 引入目录后文件的索引流程
一旦引入目录之后,我们就不再单纯的通过文件名来索引文件,而是通过路径(绝对或相对)进行索引。在文件系统的底层实现中,也是对应的先将路径转化为一个文件或目录的底层编号,然后再通过这个编号具体索引文件或目录。将路径转化为底层编号的过程是逐级进行的,对于绝对路径的情况,需要从根目录出发,每次根据当前目录底层编号获取到它的内容,根据下一级子目录的目录名查到该子目录的底层编号,然后从该子目录继续向下遍历,依此类推。在这个过程目录的权限控制位将会起到保护作用,阻止无权限用户进行访问。
- 基于路径的索引难以并行或分布式化,因为我们总是需要查到一级目录的底层编号才能查到下一级,这是一个天然串行的过程。在一些性能需求极高的环境中,可以考虑弱化目录的权限控制职能,将目录树结构扁平化,将文件系统的磁盘布局变为类键值对存储。
1.1.3 文件系统
- 常规文件和目录都是实际保存在持久存储设备中的。持久存储设备仅支持以扇区为单位的随机读写,这和上面介绍的通过路径即可索引到文件并进行读写的用户视角有很大的不同。负责中间转换的便是 文件系统 (File System) 。具体而言, 文件系统负责将逻辑上的目录树结构(包括其中每个文件或目录的数据和其他信息)映射到持久存储设备上,决定设备上的每个扇区各应存储哪些内容。反过来,文件系统也可以从持久存储设备还原出逻辑上的目录树结构。
- 文件系统有很多种不同的实现,每一种都能将同一个逻辑上目录树结构转化为一个不同的持久存储设备上的扇区布局。最著名的文件系统有 Windows 上的 FAT/NTFS 和 Linux 上的 ext3/ext4 等。
- 在一个计算机系统中,可以同时包含 多个持久存储设备,它们上面的数据可能是以不同文件系统格式存储的。为了能够对它们进行统一管理,在内核中有一层 虚拟文件系统 (VFS, Virtual File System) ,它规定了逻辑上目录树结构的通用格式及相关操作的抽象接口,只要不同的底层文件系统均实现虚拟文件系统要求的那些抽象接口,再加上 挂载 (Mount) 等方式,这些持久存储设备上的不同文件系统便可以用一个统一的逻辑目录树结构一并进行管理。
1.2 简易文件与目录抽象
- 我们的内核实现对于目录树结构进行了很大程度上的简化,这样做的目的是为了能够完整的展示文件系统的工作原理,但代码量又不至于太多。我们进行的简化如下:
- 扁平化:仅存在根目录 / 一个目录,剩下所有的文件都放在根目录内。在索引一个文件的时候,我们直接使用文件的文件名而不是它含有 / 的绝对路径。
- 权限控制:我们不设置用户和用户组概念,全程只有单用户。同时根目录和其他文件也都没有权限控制位,即完全不限制文件的访问方式,不会区分文件是否可执行。
- 不记录文件访问/修改的任何时间戳。
- 不支持软硬链接。
- 除了下面即将介绍的系统调用之外,其他的很多文件系统相关系统调用均未实现。
1.3 打开文件与读写文件的系统调用
1.3.1 文件的打开
- 在读写一个常规文件之前,应用首先需要通过内核提供的 sys_open 系统调用让该文件在进程的文件描述符表中占一项,并得到操作系统的返回值–文件描述符,即文件关联的表项在文件描述表中的索引值:
int open(int dirfd, char* path, unsigned int flags, unsigned int mode);
- 目前我们的内核支持以下几种标志(多种不同标志可能共存):
- 如果 flags 为 0,则表示以只读模式 RDONLY 打开;
- 如果 flags 第 0 位被设置(0x001),表示以只写模式 WRONLY 打开;
- 如果 flags 第 1 位被设置(0x002),表示既可读又可写 RDWR ;
- 如果 flags 第 9 位被设置(0x200),表示允许创建文件 CREATE ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零;
- 如果 flags 第 10 位被设置(0x400),则在打开文件的时候应该清空文件的内容并将该文件的大小归零,也即 TRUNC 。我们本章不涉及这个flags。
- 注意 flags 里面的权限设置只能控制进程对本次打开的文件的访问。一般情况下,在打开文件的时候首先需要经过文件系统的权限检查,比如一个文件自身不允许写入,那么进程自然也就不能以 WRONLY 或 RDWR 标志打开文件。但在我们简化版的文件系统中文件不进行权限设置,这一步就可以绕过。
1.3.2 文件的顺序读写
- 在打开一个文件获得其fd之后,我们就可以用之前的 sys_read/sys_write 两个系统调用来对它进行读写了。需要注意的是,常规文件的读写模式和之前介绍过的几种文件有所不同。标准输入输出和匿名管道都属于一种流式读写,而常规文件则是顺序读写和随机读写的结合:由于常规文件可以看成一段字节序列,我们应该能够随意读写它的任一段区间的数据,即随机读写;然而用户仅仅通过 sys_read/sys_write 两个系统调用不能做到这一点。
- 大家应该使用C时应该知道,读写文件都是有一个偏移量的,即下一次读写的起始位置是由上一次读写的结束位置决定的。我们可以使用lseek函数来改变这个偏移的位置(本章不需实现)。顺带一提,在文件系统的底层实现中都是对文件进行随机读写的。
【 2. nfs 文件系统 】
- 本节我们简单介绍一下我们实现的nfs操作系统。本章中新增加的代码很多,但是大家如果想研读的话理解起来不太困难。很多函数只看名字也可以知道其功效,不需要再探究其实现的方式。
2.1 文件系统布局
- 我们的nfs文件系统十分类似ext4文件系统,下面我们可以看一下nfs文件系统的布局:
- 注意:不推荐同学们修改该布局,除非你完全看懂了 fs 的逻辑,所以最好不要改变 disk_inode 这个结构的大小,如果想要增删字段,一定使用 pad。这个布局具体定义的位置在nfs/fs.c之中。
- 我们定义的inode和data blocks实际上和ext4中同名的结构功能几乎是一样的。索引节点 (Inode, Index Node) 是文件系统中的一种重要数据结构。逻辑目录树结构中的每个文件和目录都对应一个 inode ,我们前面提到的在文件系统实现中文件/目录的底层编号实际上就是指 inode 编号。在 inode 中不仅包含了我们通过 stat 工具能够看到的文件/目录的元数据(大小/访问权限/类型等信息),还包含实际保存对应文件/目录数据的数据块(位于最后的数据块区域中)的索引信息,从而能够找到文件/目录的数据被保存在磁盘的哪些块中。从索引方式上看,同时支持直接索引和间接索引。
- 下面我们看一下它们在我们C中对应的具体结构体:
struct superblock {
uint magic;
uint size;
uint nblocks;
uint ninodes;
uint inodestart;
uint bmapstart;
};
struct dinode {
short type;
short pad[3];
uint size;
uint addrs[NDIRECT + 1];
};
struct inode {
uint dev;
uint inum;
int ref;
int valid;
short type;
uint size;
uint addrs[NDIRECT+1];
};
struct dirent {
ushort inum;
char name[DIRSIZ];
};
struct buf {
int valid;
int disk;
uint dev;
uint blockno;
uint refcnt;
struct buf *prev;
struct buf *next;
uchar data[BSIZE];
};
- 注意几个量的概念: - block num: 表示某一个磁盘块的编号。我们操作数据块会把它读入内存的数据块缓存之中,其结构体见上。 - inode num: 表示某一个 inode 在所有 inode 项里的编号。注意 inode blocks 其实就是一个 inode 的大数组。
- 同时,目录本身是一个 filename 到 file对应的inode_num的map,可以完成 filename 到 inode_num 的转化。
- OS启动后是没有inode的内存缓存的。下面我们自底向上走一遍OS打开已存在在磁盘上文件的过程,让大家熟悉一下nfs的具体实现方式。
2.2 virtio 磁盘驱动
- 注意:这一部分代码不需要同学们详细了解细节,但需要知道大概的过程。在 uCore-Tutorial 中磁盘块的读写是通过中断处理的。在 virtio.h 和 virtio-disk.c 中我们按照 qemu 对 virtio 的定义,实现了 virtio_disk_init 和 virtio_disk_rw 两个函数,前者完成磁盘设备的初始化和对其管理的初始化。virtio_disk_rw 实际完成磁盘IO,当设定好读写信息后会通过 MMIO 的方式通知磁盘开始写。然后,os 会开启中断并开始死等磁盘读写完成。当磁盘完成 IO 后,磁盘会触发一个外部中断,在中断处理中会把死循环条件解除。内核态只会在处理磁盘读写的时候短暂开启中断,之后会马上关闭。
virtio_disk_rw(struct buf *b, int write) {
*R(VIRTIO_MMIO_QUEUE_NOTIFY) = 0;
struct buf volatile * _b = b;
intr_on();
while(_b->disk == 1);
intr_off();
}
static inline void intr_on() { w_sstatus(r_sstatus() | SSTATUS_SIE); }
static inline void intr_off() { w_sstatus(r_sstatus() & ~SSTATUS_SIE); }
- 对于内核中断处理的修改在trap.c之中。之前我们的trap from kernel会直接panic,现在我们需要添加对外部中断的处理。kerneltrap也需要类似usertrap的保存上下文以及回到原处的kernelvec以及kernelret函数。进入内核之后要单独设置stvec指向kernelvec处。
# kernelvec.S
kernelvec:
addi sp, sp, -256
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
sd t4, 224(sp)
sd t5, 232(sp)
sd t6, 240(sp)
call kerneltrap
kernelret:
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
ld t4, 224(sp)
ld t5, 232(sp)
ld t6, 240(sp)
addi sp, sp, 256
sret
void kerneltrap() {
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();
if ((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
if (scause & (1ULL << 63)) {
devintr(scause & 0xff);
} else {
error("invalid trap from kernel: %p, stval = %p sepc = %p\n", scause, r_stval(), sepc);
exit(-1);
}
}
void devintr(uint64 cause) {
int irq;
switch (cause) {
case SupervisorTimer:
set_next_timer();
if((r_sstatus() & SSTATUS_SPP) == 0) {
yield();
}
break;
case SupervisorExternal:
irq = plic_claim();
if (irq == UART0_IRQ) {
} else if (irq == VIRTIO0_IRQ) {
virtio_disk_intr();
}
if (irq)
plic_complete(irq);
break;
}
}
- virtio_disk_intr() 会把 buf->disk 置零,这样中断返回后死循环条件解除,程序可以继续运行。具体代码在 virtio-disk.c 中。
- 这里还需要注意的一点是,为什么始终不允许内核发生进程切换呢?只是由于我们的内核并没有并发的支持,相关的数据结构没有锁或者其他机制保护。考虑这样一种情况,一个进程读写一个文件,内核处理等待磁盘相应时,发生时钟中断切换到了其他进程,然而另一个进程也要读写同一个文件,这就可能发生数据访问上的冲突,甚至导致磁盘出现错误的行为。这也是为什么内核态一直不处理时钟中断,我们必须保证每一次内核的操作都是原子的,不能被打断。大家可以想一想,如果内核可以随时切换,当前有那些数据结构可能被破坏。提示:想想 kalloc 分配到一半,进程 switch 切换到一半之类的。
2.3 磁盘块缓存
- 为了加快磁盘访问的速度,在内核中设置了磁盘缓存 struct buf,一个 buf 对应一个磁盘 block,这一部分代码也不要求同学们深入掌握。大致的作用机制是,对磁盘的读写都会被转化为对 buf 的读写,当 buf 有效时,读写 buf,buf 无效时(类似页表缺页和 TLB 缺失),就实际读写磁盘,将 buf 变得有效,然后继续读写 buf。详细的内容在 buf.h 和 bio.c 中。buf 写回的时机是 buf 池满需要替换的时候(类似内存的 swap 策略) 手动写回。如果 buf 没有写回,一但掉电就 GG 了,所以手动写回还是挺重要的。
struct buf *
bread(uint dev, uint blockno) {
struct buf *b;
b = bget(dev, blockno);
if (!b->valid) {
virtio_disk_rw(b, R);
b->valid = 1;
}
return b;
}
void bwrite(struct buf *b) {
virtio_disk_rw(b, W);
}
- 读取文件数据实际就是读取文件inode指向数据块的数据。读数据块到缓存的数据需要使用bread,而写回缓存需要用到bwrite函数。文件系统首先使用bget去查缓存中是否已有对应的block,如果没有会分配内存来缓存对应的块。之后会调用bread/bwrite进行从磁盘读数据块、写回数据块。要注意释放块缓存的brelse函数。
void brelse(struct buf *b) {
b->refcnt--;
if (b->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;
}
}
- 需要特别注意的是 brelse 不会真的如字面意思释放一个 buf。它的准确含义是暂时不操作该 buf 了并把它放置在bcache链表的首部,buf 的真正释放会被推迟到 buf 池满,无法分配的时候,就会把最近最久未使用的 buf 释放掉(释放 = 写回 + 清空)。这是为了尽可能保留内存缓存,因为读写磁盘真的太太太太慢了。
- 此外,brelse 的数量必须和 bget 相同,因为 bget 会是的引用计数加一。如果没有相匹配的 brelse,就好比 new 了之后没有 delete。千万注意。
2.4 inode的操作
- 现在我们来看看nfs如何读取磁盘上的dinode到内存之中。我们通过file name对应的inode num去从磁盘读取对应的inode。为了解决共享问题(不同进程可以打开同一个磁盘文件),也有一个全局的 inode table,每当新打开一个文件的时候,会把一个空闲的 inode 绑定为对应 dinode 的缓存,这一步通过 iget 完成。
static struct inode *
iget(uint dev, uint inum) {
struct inode *ip, *empty;
for (ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++) {
if (ip->ref > 0 && ip->dev == dev && ip->inum == inum) {
ip->ref++;
return ip;
}
}
empty = find_empty()
if (empty == 0)
panic("iget: no inodes");
ip = empty;
ip->dev = dev;
ip->inum = inum;
ip->ref = 1;
ip->valid = 0;
return ip;
}
- 当已经得到一个文件对应的 inode 后,可以通过 ivalid 函数确保其是有效的。
void ivalid(struct inode *ip) {
struct buf *bp;
struct dinode *dip;
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->size = dip->size;
memmove(ip->addrs, dip->addrs, sizeof(ip->addrs));
brelse(bp);
ip->valid = 1;
}
}
- 在 inode 有效之后,可以通过 writei, readi 完成读写。这又是bwrite和bread的上级接口了。和其他OS支持的文件系统一样,我们首先计算出文件的偏移量,并通过bmap得到对应的block num。之后调用bwrite/bread来进行文件的读写操作。
int readi(struct inode *ip, char* dst, uint off, uint n) {
uint tot, m;
struct buf *bp;
for (tot = 0; tot < n; tot += m, off += m, dst += m) {
bp = bread(ip->dev, bmap(ip, off / BSIZE));
m = MIN(n - tot, BSIZE - off % BSIZE);
memmove(dst, (char*)bp->data + (off % BSIZE), m);
brelse(bp);
}
return tot;
}
int writei(struct inode *ip, char* src, uint off, uint n) {
uint tot, m;
struct buf *bp;
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);
memmove(src, (char*)bp->data + (off % BSIZE), m);
bwrite(bp);
brelse(bp);
}
if (off > ip->size)
ip->size = off;
iupdate(ip);
return tot;
}
- 其中bmap函数是连接inode和block的重要函数。但由于我们支持了间接索引,同时还设计到文件大小的改变,所以也拉出来看看:
uint bmap(struct inode *ip, uint bn) {
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;
if (bn < NINDIRECT) {
if ((addr = ip->addrs[NDIRECT]) == 0)
ip->addrs[NDIRECT] = addr = balloc(ip->dev);
bp = bread(ip->dev, addr);
a = (uint *) bp->data;
if ((addr = a[bn]) == 0) {
a[bn] = addr = balloc(ip->dev);
bwrite(bp);
}
brelse(bp);
return addr;
}
panic("bmap: out of range");
return 0;
}
- balloc(位于nfs/fs.c)会分配一个新的buf缓存。而iupdate函数则是把修改之后的inode重新写回到磁盘上。不然掉电了就凉了。
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;
dip->size = ip->size;
memmove(dip->addrs, ip->addrs, sizeof(ip->addrs));
bwrite(bp);
brelse(bp);
}
2.5 文件在进程中的结构
- inode是由操作系统统一控制的dinode在内存中的映射,但是每个进程在具体使用文件的时候,除了需要考虑使用的是哪个inode对应的文件外,还需要根据对文件的使用情况来记录其它特性,因此,在进程中我们使用file结构体来标识一个被进程使用的文件:
struct file {
enum { FD_NONE = 0,FD_INODE, FD_STDIO } type;
int ref;
char readable;
char writable;
struct inode *ip;
uint off;
};
struct file filepool[FILEPOOLSIZE];
- 我们采用预分配的方式来对file进行分配,每一个需要使用的file都要与filepool中的某一个file完成绑定。file结构中,ref记录了其引用次数,type表示了文件的类型,在本章中我们主要使用FD_NONE和FD_INODE属性,其中FD_INODE表示file已经绑定了一个文件(可能是目录或普通文件),FD_NONE表示该file还没完成绑定,FD_STDIO用来做标准输入输出,这里不做讨论;readbale和writeble规定了进程对文件的读写权限;ip标识了file所对应的磁盘中的inode编号,off即文件指针,用作记录文件读写时的偏移量。
- 分配文件时,我们从filepool中寻找还没有被分配的file进行分配:
struct file* filealloc() {
for(int i = 0; i < FILE_MAX; ++i) {
if(filepool[i].ref == 0) {
filepool[i].ref = 1;
return &filepool[i];
}
}
return 0;
}
- 进程关闭文件时,也要去filepool中放回:(注意需要根据ref来判断是否需要回收该file)
void fileclose(struct file *f)
{
if (f->ref < 1)
panic("fileclose");
if (--f->ref > 0) {
return;
}
switch (f->type) {
case FD_STDIO:
break;
case FD_INODE:
iput(f->ip);
break;
default:
panic("unknown file type %d\n", f->type);
}
f->off = 0;
f->readable = 0;
f->writable = 0;
f->ref = 0;
f->type = FD_NONE;
}
- 注意文件对于进程而言也是其需要记录的一种资源,因此我们在进程对应的PCB结构体之中也需要记录进程打开的文件信息。我们给PCB增加文件指针数组。
struct proc {
+ struct file* files[16];
};
int fdalloc(struct file* f) {
struct proc* p = curr_proc();
for(int i = 3; i < FD_MAX; ++i) {
if(p->files[i] == 0) {
p->files[i] = f;
return i;
}
}
return -1;
}
- 一个进程能打开的文件是有限的(我们设置为16)。一个进程如果要打开某一个文件,其文件指针数组必须有空位。如果有,就把下标做为文件的fd,并把指定文件指针存入数组之中。
2.6 获取文件对应的inode
- 现在我们回到文件-inode的关系上。我们怎么获取文件对应的inode呢?上文中提到了我们是去查file name对应inode的表来实现这个过程的。这个功能由目录来提供。我们看一下代码是如何实现这个过程的。
- 首先用户程序要打开指定文件名文件,发起系统调用sys_openat:
- 打开文件的方式根据flags有很多种。我们先来看最简单的,就是打开已经存在的文件的方法。fileopen在处理这类打开时调用了namei这个函数。
struct inode *namei(char *path) {
struct inode *dp = root_dir();
return dirlookup(dp, path, 0);
}
struct inode *root_dir() {
struct inode* r = iget(ROOTDEV, ROOTINO);
ivalid(r);
return r;
}
struct inode *
dirlookup(struct inode *dp, char *name, uint *poff) {
uint off, inum;
struct dirent de;
for (off = 0; off < dp->size; off += sizeof(de)) {
readi(dp, 0, (uint64) &de, off, sizeof(de));
if (strncmp(name, de.name, DIRSIZ) == 0) {
if (poff)
*poff = off;
inum = de.inum;
return iget(dp->dev, inum);
}
}
return 0;
}
- 由于我们是单目录结构。因此首先我们调用root_dir获取根目录对应的inode。之后就遍历这个inode索引的数据块中存储的文件信息到dirent结构体之中,比较名称和给定的文件名是否一致。dirlookup的逻辑对于我们本章的练习十分重要。
- fileopen 还可能会导致文件 truncate,也就是截断,具体做法是舍弃全部现有内容,释放inode所有 data block 并添加到 free bitmap 里。这也是目前 nfs 中唯一的文件变短方式。
- 比较复杂的就是使用fileopen以创建的方式打开一个文件。fileopen函数调用了
create这个函数。
static struct inode *
create(char *path, short type) {
struct inode *ip, *dp;
if(ip = namei(path) != 0) {
return ip;
}
ip = ialloc(dp->dev, type);
ivalid(ip);
dirlink(dp, path, ip->inum);
iput(dp);
return ip;
}
uint ialloc(ushort type) {
uint inum = freeinode++;
struct dinode din;
bzero(&din, sizeof(din));
din.type = xshort(type);
din.size = xint(0);
winode(inum, &din);
return inum;
}
int
dirlink(struct inode *dp, char *name, uint inum)
{
int off;
struct dirent de;
struct inode *ip;
if((ip = dirlookup(dp, name, 0)) != 0){
iput(ip);
return -1;
}
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;
}
strncpy(de.name, name, DIRSIZ);
de.inum = inum;
if(writei(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
panic("dirlink");
return 0;
}
- ialloc 干的事情:遍历 inode blocks 找到一个空闲的inode,初始化并返回。dirlink对于本章的练习也十分重要。和dirlookup不同,我们没有现成的dirent存储在磁盘上,而是要在磁盘上创建一个新的dirent。他遍历根目录数据块,找到一个空的 dirent,设置 dirent = {inum, filename} 然后返回,注意这一步可能找不到空位,这时需要找一个新的数据块,并扩大 root_dir size,这是由 bmap 自动完成的。需要注意本章创建硬链接时对应inode num的处理。
2.7 文件关闭
- 文件读写结束后需要fclose释放掉其inode,同时释放OS中对应的file结构体和fd。其实 inode 文件的关闭只需要调用 iput 就好了,iput 的实现简单到让人感觉迷惑,就是 inode 引用计数减一。诶?为什么没有计数为 0 就写回然后释放 inode 的操作?和 buf 的释放同理,这里会等 inode 池满了之后自行被替换出去,重新读磁盘实在太太太太慢了。对了,千万记得 iput 和 iget 数量相同,一定要一一对应,否则你懂的。
void
fileclose(struct file *f)
{
if(--f->ref > 0) {
return;
}
if(f->type == FD_INODE) {
iput(f->ip);
}
f->off = 0;
f->readable = 0;
f->writable = 0;
f->ref = 0;
f->type = FD_NONE;
}
void iput(struct inode *ip) {
ip->ref--;
}