nfs文件系统¶
本节导读¶
本节我们简单介绍一下我们实现的nfs操作系统。本章中新增加的代码很多,但是大家如果想研读的话理解起来不太困难。很多函数只看名字也可以知道其功效,不需要再探究其实现的方式。
文件系统布局¶
- 导言中提到,我们的nfs文件系统十分类似ext4文件系统,下面我们可以看一下nfs文件系统的布局::
// 基本信息:块大小 BSIZE = 1024B,总容量 FSSIZE = 1000 个 block = 1000 * 1024 B。 // Layout: // 0号块留待后续拓展,可以忽略。superblock 固定为 1 号块,size 固定为一个块。 // 其后是储存 inode 的若干个块,占用块数 = inode 上限 / 每个块上可以容纳的 inode 数量, // 其中 inode 上限固定为 200,每个块的容量 = BSIZE / sizeof(struct disk_inode) // 再之后是数据块相关内容,包含一个 储存空闲块位置的 bitmap 和 实际的数据块,bitmap 块 // 数量固定为 NBITMAP = FSSIZE / (BSIZE * 8) + 1 = 1000 / 8 + 1 = 126 块。 // [ boot block | sb block | inode blocks | free bit map | data blocks ]
注意:不推荐同学们修改该布局,除非你完全看懂了 fs 的逻辑,所以最好不要改变 disk_inode 这个结构的大小,如果想要增删字段,一定使用 pad。这个布局具体定义的位置在nfs/fs.c之中。
我们定义的inode和data blocks实际上和ext4中同名的结构功能几乎是一样的。索引节点 (Inode, Index Node) 是文件系统中的一种重要数据结构。逻辑目录树结构中的每个文件和目录都对应一个 inode ,我们前面提到的在文件系统实现中文件/目录的底层编号实际上就是指 inode 编号。在 inode 中不仅包含了我们通过 stat
工具能够看到的文件/目录的元数据(大小/访问权限/类型等信息),还包含实际保存对应文件/目录数据的数据块(位于最后的数据块区域中)的索引信息,从而能够找到文件/目录的数据被保存在磁盘的哪些块中。从索引方式上看,同时支持直接索引和间接索引。
下面我们看一下它们在我们C中对应的具体结构体:
// 超级块位置固定,用来指示文件系统的一些元数据,这里最重要的是 inodestart 和 bmapstart
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 inodestart;// Block number of first inode block
uint bmapstart; // Block number of first free map block
};
// On-disk inode structure
// 储存磁盘 inode 信息,主要是文件类型和数据块的索引,其大小影响磁盘布局,不要乱改,可以用 pad
struct dinode {
short type; // File type
short pad[3];
uint size; // Size of file (bytes)
uint addrs[NDIRECT + 1];// Data block addresses
};
// in-memory copy of an inode
// dinode 的内存缓存,为了方便,增加了 dev, inum, ref, valid 四项管理信息,大小无所谓,可以随便改。
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
int valid; // inode has been read from disk?
short type; // copy of disk inode
uint size;
uint addrs[NDIRECT+1]; // data block num
};
// 目录对应的数据块的内容本质是 filename 到 file inode_num 的一个 map,这里为了简单,就存为一个 `dirent` 数组,查找的时候遍历对比
struct dirent {
ushort inum;
char name[DIRSIZ];
};
// 数据块缓存结构体。
struct buf {
int valid; // has data been read from disk?
int disk; // does disk "own" buf?
uint dev;
uint blockno;
uint refcnt;
struct buf *prev; // LRU cache list
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的具体实现方式。
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) {
/// ... set IO config
*R(VIRTIO_MMIO_QUEUE_NOTIFY) = 0; // notify the disk to carry out IO
struct buf volatile * _b = b; // Make sure complier will load 'b' form memory
intr_on();
while(_b->disk == 1); // _b->disk == 0 means that this IO is done
intr_off();
}
// 开启和关闭中断的函数。
static inline void intr_on() { w_sstatus(r_sstatus() | SSTATUS_SIE); }
// disable device interrupts
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:
// make room to save registers.
addi sp, sp, -256
// save the registers expect x0
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:
// restore registers.
// 思考:为什么直接就使用了sp?
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
// restore all registers expect x0
ld t4, 224(sp)
ld t5, 232(sp)
ld t6, 240(sp)
addi sp, sp, 256
sret
kerneltrap具体的修改如下:
void kerneltrap() {
// 老三样,不过在这里把处理放到了 C 代码中
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 {
// kernel 发生异常就挣扎了,肯定出问题了,杀掉用户线程跑路
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) { // UART 串口的终端不需要处理,这个 rustsbi 替我们处理好了
// do nothing
} else if (irq == VIRTIO0_IRQ) { // 我们等的就是这个中断
virtio_disk_intr();
}
if (irq)
plic_complete(irq); // 表明中断已经处理完毕
break;
}
}
virtio_disk_intr() 会把 buf->disk 置零,这样中断返回后死循环条件解除,程序可以继续运行。具体代码在 virtio-disk.c 中。
这里还需要注意的一点是,为什么始终不允许内核发生进程切换呢?只是由于我们的内核并没有并发的支持,相关的数据结构没有锁或者其他机制保护。考虑这样一种情况,一个进程读写一个文件,内核处理等待磁盘相应时,发生时钟中断切换到了其他进程,然而另一个进程也要读写同一个文件,这就可能发生数据访问上的冲突,甚至导致磁盘出现错误的行为。这也是为什么内核态一直不处理时钟中断,我们必须保证每一次内核的操作都是原子的,不能被打断。大家可以想一想,如果内核可以随时切换,当前有那些数据结构可能被破坏。提示:想想 kalloc 分配到一半,进程 switch 切换到一半之类的。
磁盘块缓存¶
为了加快磁盘访问的速度,在内核中设置了磁盘缓存 struct buf,一个 buf 对应一个磁盘 block,这一部分代码也不要求同学们深入掌握。大致的作用机制是,对磁盘的读写都会被转化为对 buf 的读写,当 buf 有效时,读写 buf,buf 无效时(类似页表缺页和 TLB 缺失),就实际读写磁盘,将 buf 变得有效,然后继续读写 buf。详细的内容在 buf.h 和 bio.c 中。buf 写回的时机是 buf 池满需要替换的时候(类似内存的 swap 策略) 手动写回。如果 buf 没有写回,一但掉电就 GG 了,所以手动写回还是挺重要的。
// os/bio.c
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;
}
// Write b's contents to disk.
void bwrite(struct buf *b) {
virtio_disk_rw(b, W);
}
读取文件数据实际就是读取文件inode指向数据块的数据。读数据块到缓存的数据需要使用bread,而写回缓存需要用到bwrite函数。文件系统首先使用bget去查缓存中是否已有对应的block,如果没有会分配内存来缓存对应的块。之后会调用bread/bwrite进行从磁盘读数据块、写回数据块。要注意释放块缓存的brelse函数。
// os/bio.c
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。千万注意。
inode的操作¶
现在我们来看看nfs如何读取磁盘上的dinode到内存之中。我们通过file name对应的inode num去从磁盘读取对应的inode。为了解决共享问题(不同进程可以打开同一个磁盘文件),也有一个全局的 inode table,每当新打开一个文件的时候,会把一个空闲的 inode 绑定为对应 dinode 的缓存,这一步通过 iget 完成。
// 找到 inum 号 dinode 绑定的 inode,如果不存在新绑定一个
static struct inode *iget(uint dev, uint inum) {
struct inode *ip, *empty;
// 遍历查找 inode table
for (ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++) {
// 如果有对应的,引用计数 +1并返回
if (ip->ref > 0 && ip->dev == dev && ip->inum == inum) {
ip->ref++;
return ip;
}
}
// 如果没有对于的,找一个空闲 inode 完成绑定
empty = find_empty()
// GG,inode 表满了,果断自杀.lab7正常不会出现这个情况。
if (empty == 0)
panic("iget: no inodes");
// 注意这里仅仅是写了元数据,没有实际读取,实际读取推迟到后面
ip = empty;
ip->dev = dev;
ip->inum = inum;
ip->ref = 1;
ip->valid = 0; // 没有实际读取,valid = 0
return ip;
}
当已经得到一个文件对应的 inode 后,可以通过 ivalid 函数确保其是有效的。
// Reads the inode from disk if necessary.
void ivalid(struct inode *ip) {
struct buf *bp;
struct dinode *dip;
if (ip->valid == 0) {
// bread 可以完成一个块的读取,这个在将 buf 的时候说过了
// IBLOCK 可以计算 inum 在几个 block
bp = bread(ip->dev, IBLOCK(ip->inum, sb));
// 得到 dinode 内容
dip = (struct dinode *) bp->data + ip->inum % IPB;
// 完成实际读取
ip->type = dip->type;
ip->size = dip->size;
memmove(ip->addrs, dip->addrs, sizeof(ip->addrs));
// buf 暂时没用了
brelse(bp);
// 现在有效了
ip->valid = 1;
}
}
在 inode 有效之后,可以通过 writei, readi 完成读写。这又是bwrite和bread的上级接口了。和其他OS支持的文件系统一样,我们首先计算出文件的偏移量,并通过bmap得到对应的block num。之后调用bwrite/bread来进行文件的读写操作。
// 从 ip 对应文件读取 [off, off+n) 这一段数据到 dst
int readi(struct inode *ip, char* dst, uint off, uint n) {
uint tot, m;
// 还记得 buf 吗?
struct buf *bp;
for (tot = 0; tot < n; tot += m, off += m, dst += m) {
// bmap 完成 off 到 block num 的对应,见下
bp = bread(ip->dev, bmap(ip, off / BSIZE));
// 一次最多读一个块,实际读取长度为 m
m = MIN(n - tot, BSIZE - off % BSIZE);
memmove(dst, (char*)bp->data + (off % BSIZE), m);
brelse(bp);
}
return tot;
}
// 同 readi
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);
}
// 文件长度变长,需要更新 inode 里的 size 字段
if (off > ip->size)
ip->size = off;
// 有可能 inode 信息被更新了,写回
iupdate(ip);
return tot;
}
其中bmap函数是连接inode和block的重要函数。但由于我们支持了间接索引,同时还涉及到文件大小的改变,所以也拉出来看看:
// bn = off / BSIZE
uint bmap(struct inode *ip, uint bn) {
uint addr, *a;
struct buf *bp;
// 如果 bn < 12,属于直接索引, block num = ip->addr[bn]
if (bn < NDIRECT) {
// 如果对应的 addr, 也就是 block num = 0,表明文件大小增加,需要给文件分配新的 data block
// 这是通过 balloc 实现的,具体做法是在 bitmap 中找一个空闲 block,置位后返回其编号
if ((addr = ip->addrs[bn]) == 0)
ip->addrs[bn] = addr = balloc(ip->dev);
return addr;
}
bn -= NDIRECT;
// 间接索引块,那么对应的数据块就是一个大 addr 数组。
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 *) 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重新写回到磁盘上。不然掉电了就凉了。
// Copy a modified in-memory inode to disk.
// Must be called after every change to an ip->xxx field
// that lives on disk.
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);
}
文件在进程中的结构¶
inode是由操作系统统一控制的dinode在内存中的映射,但是每个进程在具体使用文件的时候,除了需要考虑使用的是哪个inode对应的文件外,还需要根据对文件的使用情况来记录其它特性,因此,在进程中我们使用file结构体来标识一个被进程使用的文件:
// Defines a file in memory that provides information about the current use of the file and the corresponding inode location
struct file {
enum { FD_NONE = 0,FD_INODE, FD_STDIO } type;
int ref; // reference count
char readable;
char writable;
struct inode *ip; // FD_INODE
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进行分配:
// os/file.c
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:
// Do nothing
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增加文件指针数组。
// proc.h
// Per-process state
struct proc {
// ...
+ struct file* files[16];
};
// os/proc.c
int fdalloc(struct file* f) {
struct proc* p = curr_proc();
// fd = 0,1,2 is reserved for stdio/stdout/stderr
for(int i = 3; i < FD_MAX; ++i) {
if(p->files[i] == 0) {
p->files[i] = f;
return i;
}
}
return -1;
}
一个进程能打开的文件是有限的(我们设置为16)。一个进程如果要打开某一个文件,其文件指针数组必须有空位。如果有,就把下标做为文件的fd,并把指定文件指针存入数组之中。
获取文件对应的inode¶
现在我们回到文件-inode的关系上。我们怎么获取文件对应的inode呢?上文中提到了我们是去查file name对应inode的表来实现这个过程的。这个功能由目录来提供。我们看一下代码是如何实现这个过程的。
首先用户程序要打开指定文件名文件,发起系统调用sys_openat:
打开文件的方式根据flags有很多种。我们先来看最简单的,就是打开已经存在的文件的方法。fileopen在处理这类打开时调用了namei这个函数。
// namei = 获得根目录,然后在其中遍历查找 path
struct inode *namei(char *path) {
struct inode *dp = root_dir();
return dirlookup(dp, path, 0);
}
// root_dir 位置固定
struct inode *root_dir() {
struct inode* r = iget(ROOTDEV, ROOTINO);
ivalid(r);
return r;
}
// 遍历根目录所有的 dirent,找到 name 一样的 inode。
struct inode *dirlookup(struct inode *dp, char *name, uint *poff) {
uint off, inum;
struct dirent de;
// 每次迭代处理一个 block,注意根目录可能有多个 data block
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;
// 找到之后,绑定一个内存 inode 然后返回
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;
}
// 创建一个文件,首先分配一个空闲的 disk inode, 绑定内存 inode 之后返回
ip = ialloc(dp->dev, type);
// 注意 ialloc 不会执行实际读取,必须有 ivalid
ivalid(ip);
// 在根目录创建一个 dirent 指向刚才创建的 inode
dirlink(dp, path, ip->inum);
// dp 不用了,iput 就是释放内存 inode,和 iget 正好相反。
iput(dp);
return ip;
}
// nfs/fs.c
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;
}
// os/fs.c
// Write a new directory entry (name, inum) into the directory dp.
int dirlink(struct inode *dp, char *name, uint inum)
{
int off;
struct dirent de;
struct inode *ip;
// Check that name is not present.
if((ip = dirlookup(dp, name, 0)) != 0){
iput(ip);
return -1;
}
// Look for an empty dirent.
for(off = 0; off < dp->size; off += sizeof(de)){
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的处理。
文件关闭¶
文件读写结束后需要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--;
}