说明
- 阅读的代码是 xv6-riscv 版本的
七层结构
xv6文件系统实现分为七层
文件描述符(File descriptor) |
---|
路径名(Pathname) |
目录(Directory) |
索引结点(Inode) |
日志(Logging) |
缓冲区高速缓存(Buffer cache) |
磁盘(Disk) |
- disk 层从一个虚拟硬盘(vitro hard drive)上读取 block
- buffer cache 层缓存 block
保证同一个 block 只能被一个内核进程同时访问 - logging 层记录一些日志信息,用于出错恢复
- inode 层是一个独立的文件,唯一的 i-number 和一些存储数据的块
- directory 层把文件夹封装称为一些特殊的 inode
内容为一些列目录项,包括文件名和 i-number - pathname 层提供一个层次化的树结构,处理了递归包含的情况
- file descriptor 层把 pipes, devices, files 等都抽象为文件,使用统一的文件系统的接口
磁盘(Disk)
文件系统必须有将索引节点和内容块存储在磁盘上哪些位置的方案。为此,xv6将磁盘划分为几个部分,如图8.2所示。
-
boot:文件系统不使用块0(它保存引导扇区)
-
super:它包含有关文件系统的元数据(文件系统大小(以块为单位)、数据块数、索引节点数和日志中的块数)。
-
log:保存日志(用于出现crash后进行数据恢复)
-
inodes: 是索引节点,每个块有多个索引节点
-
bit map:位图,跟踪正在使用的数据块,用来保存那些块是被使用或空闲的。
-
data:剩下的就是数据块,是否被占用保存在bitmap中
缓冲区高速缓存(Buffer cache)
设计概述
-
buffe catch 的两个任务
- 1.同步对磁盘块的访问,以确保磁盘块在内存中只有一个副本,并且一次只有一个内核线程使用该副本
- 2.缓存常用块,以便不需要从慢速磁盘重新读取它们
-
对上提供的接口
- bread() ,获取一个磁盘块,拷贝到缓存中
- bwrite(),将缓存中的数据写回磁盘
- brelse(),用完需要释放缓存快
-
通过给每一个 buffer 分配一个 sleeplock 的方式实现一个磁盘块的拷贝只能被一个内核线程使用
-
Buffer cache中保存磁盘块的缓冲区数量固定,如果文件系统请求还未存放在缓存中的块,Buffer cache必须回收当前保存其他块内容的缓冲区。Buffer cache为新块回收最近使用最少的缓冲区。
- 通过 virtio_disk_rw(b, 0) 实现
缓冲区高速缓存(代码实现)
Buffer cache是以双链表表示的缓冲区(为了实现LRU替换算法)
- 每一个buf结构体如下
- valid 字段表示这个 buffer 是否对应磁盘上的某一个块(1 表示有对应)
- disk 字段表示是否将 buffer 中的信息写回了磁盘上(1 表示修改了未写回)
- refcnt 表示引用计数
- blockno 扇区号
- dev 不同设备
struct buf {
int valid; // has data been read from disk?
int disk; // does disk "own" buf?
uint dev;
uint blockno;
struct sleeplock lock;
uint refcnt;
struct buf *prev; // LRU cache list
struct buf *next;
uchar data[BSIZE];
};
main(kernel/main.c)调用的函数binit使用静态数组buf(kernel/bio.c)中的NBUF个缓冲区初始化列表。对Buffer cache的所有其他访问都通过bcache.head引用链表,而不是buf数组。
struct {
struct spinlock lock;
struct buf buf[NBUF];
// 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;
binit函数
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;
}
}
- 对buf进行初始化,构建双向循环链表(NBUF个节点)
- 为bcache初始化一把大锁(bcache),再为每一个节点初始化一把小锁(buffer)
bget函数
bget扫描缓冲区列表,查找具有给定设备和扇区号的缓冲区。
static struct buf*
bget(uint dev, uint blockno)
{
struct buf *b;
acquire(&bcache.lock);
// Is the block already cached?
for(b = bcache.head.next; b != &bcache.head; b = b->next){
if(b->dev == dev && b->blockno == blockno){
b->refcnt++;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
// Not cached.
// Recycle the least recently used (LRU) unused buffer.
for(b = bcache.head.prev; b != &bcache.head; b = b->prev){
if(b->refcnt == 0) {
b->dev = dev;
b->blockno = blockno;
b->valid = 0;
b->refcnt = 1;
release(&bcache.lock);
acquiresleep(&b->lock);
return b;
}
}
panic("bget: no buffers");
}
- 若在缓冲区找到了,返回锁定的buf
- refcnt引用计数要增加1。
- 若没有找到,它再次扫描缓冲区列表,查找未在使用中的缓冲区(b->refcnt = 0):任何这样的缓冲区都可以使用。
- 给dev和blockno初始化。
- valid=0,表示没有对应磁盘上的某一块。
- 引用计数初始化为1。
bread函数
// Return a locked buf with the contents of the indicated block.
struct buf*
bread(uint dev, uint blockno)
{
struct buf *b;
b = bget(dev, blockno);
if(!b->valid) {
virtio_disk_rw(b, 0);
b->valid = 1;
}
return b;
}
- 调用bget函数从bcatch获取指定buf
- 若b->valid==0,表示没有对应磁盘上的某一块
- 调用 virtio_disk_rw(b, 0);从磁盘中将其读入内存(buffer 中)
bwrite函数
该函数作用,将内存(buffer中)的数据写回到磁盘
// 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);
}
日志(Logging)
设计概述
-
目的
解决了文件系统操作期间的崩溃问题
-
解决方法
- xv6系统调用不会直接写入磁盘上的文件系统数据结构。相反,它会在磁盘上的log(日志)中放置它希望进行的所有磁盘写入的描述。
- 一旦系统调用记录了它的所有写入操作,它就会向磁盘写入一条特殊的commit(提交)记录,表明日志包含一个完整的操作。
- 当完成commit后,再将写操作复制到磁盘当中,然后擦除磁盘上的日志
- 如果系统崩溃并重新启动
- 日志标记为包含完整操作,则恢复代码会将写操作复制到磁盘文件系统中它们所属的位置。
- 如果日志没有标记为包含完整操作,则恢复代码将忽略该日志。
- 擦除记录
-
log设计
- 日志驻留在超级块中指定的已知固定位置。它由一个头块(header block)和一系列更新块的副本(logged block)组成。
- 头块包含一个扇区号数组(每个logged block对应一个扇区号)以及日志块的计数
- 磁盘上的头块中的计数或者为零,表示日志中没有事务;
- 非零,表示日志包含一个完整的已提交事务 ;
- 头块包含一个扇区号数组(每个logged block对应一个扇区号)以及日志块的计数
- 日志驻留在超级块中指定的已知固定位置。它由一个头块(header block)和一系列更新块的副本(logged block)组成。
struct logheader {
int n;
int block[LOGSIZE];
};
日志(代码实现)
下面看一下log结构体
struct log {
struct spinlock lock;
int start;
int size;
int outstanding; // 有多少FS系统调用正在执行调用正在执行
int committing; // 是否在commit当中
int dev;
struct logheader lh;
};
在系统调用中一个典型的日志使用就像这样:
begin_op();
...
bp = bread(...);
bp->data[...] = ...;
log_write(bp);
...
end_op();
下面我们就上面过程,进行逐一分析
begin_op函数
- begin_op等待直到日志系统当前未处于提交中,并且直到有足够的未被占用的日志空间来保存此调用的写入。
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 {
log.outstanding += 1;
release(&log.lock);
break;
}
}
}
- 先判断是否处于提交中
- 在判断是否有足够空间
(xv6 认为每个系统调用只会使用 MAXOPBLOCKS(10) 个 block) - outstanding+1,然后break
log_write函数
- log_write(kernel/log.c:214)充当bwrite的代理。它将块的扇区号记录在内存中,在磁盘上的日志中预定一个槽位,并调用bpin将缓存固定在block cache中,以防止block cache将其逐出。
void
log_write(struct buf *b)
{
int i;
acquire(&log.lock);
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
break;
}
log.lh.block[i] = b->blockno;
if (i == log.lh.n) { // Add new block to log?
bpin(b);
log.lh.n++;
}
release(&log.lock);
}
- for循环,进行优化,log_write会注意到在单个事务中多次写入一个块的情况,并在日志中这种优化通常称为合并(absorption)。
- 调用 bpin(b)对b的引用计数++,防止被替换出去。
end_op函数
- end_op(kernel/log.c:146)首先减少未完成系统调用的计数。如果计数现在为零,则通过调用commit()提交当前事务。
// called at the end of each FS system call.
// commits if this was the last outstanding operation.
void
end_op(void)
{
int do_commit = 0;
acquire(&log.lock);
log.outstanding -= 1;
if(log.committing)
panic("log.committing");
if(log.outstanding == 0){
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.
wakeup(&log);
}
release(&log.lock);
if(do_commit){
// call commit w/o holding locks, since not allowed
// to sleep with locks.
commit();
acquire(&log.lock);
log.committing = 0;
wakeup(&log);
release(&log.lock);
}
}
- 首先将计数 log.outstanding -1(系统调用数 -1)
- 如果此时计数变成 0,则调用 commit() 进行 commit
commit函数
commit有四个步骤,代码如下
static void
commit()
{
if (log.lh.n > 0) {
write_log(); // Write modified blocks from cache to log
write_head(); // Write header to disk -- the real commit
install_trans(0); // Now install writes to home locations
log.lh.n = 0;
write_head(); // Erase the transaction from the 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++) {
struct buf *to = bread(log.dev, log.start+tail+1); // log block
struct buf *from = bread(log.dev, log.lh.block[tail]); // cache block
memmove(to->data, from->data, BSIZE);
bwrite(to); // write the log
brelse(from);
brelse(to);
}
}
- write_log():把具体的内容写入 log 对应的磁盘块里
通过调用 bread() 读取磁盘块、memmove() 内存复制、bwrite() 写入磁盘完成
需要写入的磁盘区域 (\to) 对应的 log 块 - 注意这里的 log 数组中记录的磁盘块写入对应下标+1的 log 块中,因为第一块留给了 log 的 head
write_head()函数
将头块写入磁盘:这是提交点,写入后的崩溃将导致从日志恢复重演事务的写入操作。
static void
write_head(void)
{
struct buf *buf = bread(log.dev, log.start);
struct logheader *hb = (struct logheader *) (buf->data);
int i;
hb->n = log.lh.n;
for (i = 0; i < log.lh.n; i++) {
hb->block[i] = log.lh.block[i];
}
bwrite(buf);
brelse(buf);
}
write_head():把 log 块的头信息写入磁盘
log 块区域的第一块是 log 的 header 块
install_trans函数
// 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
memmove(dbuf->data, lbuf->data, BSIZE); // copy block to dst
bwrite(dbuf); // write dst to disk
if(recovering == 0)
bunpin(dbuf);
brelse(lbuf);
brelse(dbuf);
}
}
- install_trans():执行原来要求的写操作
recover_from_log()函数
static void
recover_from_log(void)
{
read_head();
install_trans(1); // if committed, copy from log to disk
log.lh.n = 0;
write_head(); // clear the log
}
- 在启动时会调用这段代码进行恢复
- 先将磁盘中的日志头读入到内存中的日志头
- 调用install_trans函数,如果commited,将日志中保存的操作拷贝到磁盘
- 将内存中的日志头操作数置为0
- 将内存中的日志头重新写会磁盘
用于crash恢复
块分配器(bitmap)
-
xv6的块分配器在磁盘上维护一个空闲位图,每一位代表一个块。0表示对应的块是空闲的;1表示它正在使用中。
-
程序mkfs设置对应于引导扇区、超级块、日志块、inode块和位图块的比特位。
-
balloc() 函数从磁盘中找一个空闲的磁盘块并返回(bitmap 中标记为 0)
-
bfree() 函数释放一个指定的磁盘块
索引节点(Inode)
两种含义
- 磁盘上的数据结构(on-disk),包含文件的大小和一个数据存放位置的列表
- 内存中的数据结构(in-memory),包括磁盘上数据结构的一个拷贝以及一些内核需要用到的内容
on-disk
- 磁盘上的 inode 被放置在 inode blocks 中,大小都是相同的,给定一个 n(i-number)就能找到第 n 个 inode 的位置
- 数据结构如下
- type:file、directory、special files(devices)、0(表示空闲)
- nlink:引用数(用于判断数据块是否该被释放)
- size:文件所占据的字节数
- addrs:文件所在数据块的地址
// 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; // Number of links to inode in file system
uint size; // Size of file (bytes)
uint addrs[NDIRECT+1]; // Data block addresses
};
in-memory
struct inode(kernel/file.h:17)是磁盘上struct dinode的内存副本。只有当有C指针引用某个inode时,内核才会在内存中存储该inode。
结构如下:
- ref:引用计数,如果引用计数降至零,内核将从内存中丢弃该inode。
- iget和iput函数,修改引用计数
- 指向inode的指针可以来自文件描述符、当前工作目录和如exec的瞬态内核代码。
- dev:设备号
- inum:inode编号
- valid:表示是否已从磁盘读取
// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock; // protects everything below here
int valid; // inode has been read from disk?
short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};
-
有 4 种锁或类锁的机制
- icache.lock 保证一个 inode 只会在缓存中出现一次,保证引用计数 ref 的正确
- 每一个 inode 都有一个 sleeplock,用于保证其内部字段的独占访问
- ref 不为 0 的时候这个 entry 不会被回收
- nlink 字段表示这个文件的引用数(他不为 0,xv6 就不会释放 entry)
-
iget() 返回一个 inode
- 如果是新使用的,valid = 0,之后使用的时候会从磁盘读入(ilock() 实现读入),这块实现和磁盘缓存十分的相似
-
ilock() 是加锁的,iget() 不加锁
-
inode cache 是 write-through(直写的),调用 iupdate() 直接反应到磁盘上
索引节点(代码实现)
分配一个新的 inode 结点(新建文件等)的流程如下
- 调用 ialloc()
- 从标号为 1的 inode 的开始查找,找到一个空闲的 inode 之后,调用 iget() 返回
- 根节点也是从 1 开始的(#define ROOTINO 1)
- iget()
- 先看看有没有缓存,若有缓存,则引用数 ref +1,直接返回
- 如果没有找到,则将第一个空闲的 inode 返回
- ilock()
- 想要对 inode 进行读写操作的时候,需要先对 inode 获取锁(调用 ilock)
- 同时 ilock 会检查这个内容有没有从磁盘冲读进内存,如果没有则读入
- iunlock()
- 释放锁
- iput()
- 引用数 ref -1
- 如果减完之后计数为 0,同时 valid=1,nlink=0 的话,释放数据块
- 可能会出现这样一种情况,nlink=0,ref 不为 0,此时 inode 没有被释放
- 当 ref 被减为 0 的瞬间,系统崩溃了
- 重启的时候我们知道 nlink=0 的文件是没有用的,但是我们在磁盘上这块空间并未被释放
- xv6 没有针对这个问题进行补救,因此可能出现磁盘空间耗尽的情况
目录层(代码实现)
目录的内部实现很像文件。
// kernel/fs.h
struct dirent {
ushort inum;
char name[DIRSIZ];
};
- name 最长为 DIRSIZ=14 个字符
- dirlookup()
在一个目录中查找指定 name 的文件,返回 inode 指针 - dirlink()
按照给定的 name,新建一个新的文件夹
遍历 entry,找到一个空闲的返回
如果已经存在,则报错