1. Inode层
首先这里有两个分配硬盘的空闲block的函数。
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){ // Is block free?
bp->data[bi/8] |= m; // Mark block in use.
log_write(bp);
brelse(bp);
bzero(dev, b + bi);
return b + bi;
}
}
brelse(bp);
}
panic("balloc: out of blocks");
}
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;
log_write(bp);
brelse(bp);
}
就是遍历bitmap,然后返回或者回收空闲块而已。
- 这里也可以解释为什么之前说要求bwrite时带buf锁起到简化代码的作用。bread直接返回一个带锁的buf,本来是一个低效的行为,因为它阻止了两个进程同时读一个block。一般来说同时读block是没问题的,但是对于bitmap块就不能允许同时读(否则容易出现一个块分给两个进程的竞争情况)。如果bread不返回一个带锁的buf,这里又需要额外增加对bitmap块的特判,会增加代码的复杂性。
- 注意这里修改block的内容没有发现begin_op请求,因此一定需要先发起begin_op请求后再调用该函数,看后面代码验证。
下面是inode层的数据结构
#define T_DIR 1
#define T_FILE 2
#define T_DEVICE 3
// 上面的宏用于type字段,如果type=0表示该dinode尚未分配
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
};
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];
};
struct {
struct spinlock lock;
struct inode inode[NINODE];
} icache;
上面三个结构分别对应硬盘上的inode(dinode),缓存到内存中的inode以及管理所有内存中的inode的icache。
inode.major和inode.minor应该是遵循linux的习惯,代表驱动设备号和inode所在的设备号,后面会看到major在设备文件读写中的作用。
static struct inode*
iget(uint dev, uint inum)
{
struct inode *ip, *empty;
acquire(&icache.lock);
// Is the inode already cached?
empty = 0;
for(ip = &icache.inode[0]; ip < &icache.inode[NINODE]; ip++){
if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
ip->ref++;
release(&icache.lock);
return ip;
}
if(empty == 0 && ip->ref == 0) // Remember empty slot.
empty = ip;
}
// Recycle an inode cache entry.
if(empty == 0)
panic("iget: no inodes");
ip = empty;
ip->dev = dev;
ip->inum = inum;
ip->ref = 1;
ip->valid = 0;
release(&icache.lock);
return ip;
}
iget函数和cache层的bget函数基本上差不多,有两点不同:缓存inode的icache没有组织成双链表;另外,返回的inode没有带锁。
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;
ip->type = dip->type;
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");
}
}
可以看到,获取inode锁的任务放到了ilock中,ilock同时保证返回的inode中的内容一定是有效的。因此ilock的功能类似于bread函数。
void
iunlock(struct inode *ip)
{
if(ip == 0 || !holdingsleep(&ip->lock) || ip->ref < 1)
panic("iunlock");
releasesleep(&ip->lock);
}
struct inode*
idup(struct inode *ip)
{
acquire(&icache.lock);
ip->ref++;
release(&icache.lock);
return ip;
}
void
iput(struct inode *ip)
{
acquire(&icache.lock);
if(ip->ref == 1 && ip->valid && ip->nlink == 0){
// inode has no links and no other references: truncate and free.
// ip->ref == 1 means no other process can have ip locked,
// so this acquiresleep() won't block (or deadlock).
acquiresleep(&ip->lock);
release(&icache.lock);
itrunc(ip);
ip->type = 0;
iupdate(ip);
ip->valid = 0;
releasesleep(&ip->lock);
acquire(&icache.lock);
}
ip->ref--;
release(&icache.lock);
}
iunlock函数是与ilock函数对称,idup函数类似于bpin函数,而iput函数则与brelse函数相似。当iput函数发现自己是最后一个持有该inode指针且该inode的引用链接数为0时,会将该inode删除。
- 删除的第一步是调用itrunc函数释放该inode对应文件占用的所有块(因此itrunc函数内部是调用bfree,实际上是对bitmap块进行修改,当然也不是实际的写到磁盘,而是调用log_write记上一笔)
- 删除的第二步是调用调用iupdate函数,即把内存中的inode写回到磁盘中的dinode,当然这也是通过log_write实现。
- 安全性:任何尝试获取inode前,需要获得icache锁,任何尝试读写inode内容时,需要获得inode锁。因此在获得icache锁之后,没有进程能够通过iget获取inode,而在释放icache锁之后,因为该inode的link数为0,因此其它进程也没办法获取到该inode(全局看一下,只有ialloc, dirlookup, namex这三个函数内部调用了iget方法,由于函数内部没有调用end_op方法,因此直到函数结束之后,对inode的修改都还没写到磁盘上,故ialloc是不会把这个inode分出去的,而dirlookup和namx都是基于目录项来获取inode,由于link数为0,故这两个函数也没办法获取到正在删除的inode)
- 这里唯一的危险是可能一个进程在持有一个link数为0的inode时,还没有释放这个inode指针(调用iput),系统就崩溃了,这样的话就导致存在一个文件已经占用了内存空间,但是却没有目录项指向它。
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){
bp = bread(ip->dev, bmap(ip, off/BSIZE));
m = min(n - tot, BSIZE - off%BSIZE);
if(either_copyin(bp->data + (off % BSIZE), user_src, src, m) == -1) {
brelse(bp);
n = -1;
break;
}
log_write(bp);
brelse(bp);
}
if(off > ip->size)
ip->size = off;
// write the i-node back to disk even if the size didn't change
// because the loop above might have called bmap() and added a new
// block to ip->addrs[].
iupdate(ip);
return n;
}
inode层提供的最重要的两个函数就是writei和readi了。其中bmap函数的输入为inode指针和inode内的block编号,返回该block实际在硬盘上的扇区号。类似于walk函数,bmap也包括了内部调用balloc,进行block的分配。
- 总的来说,该函数的作用就是给定inode和偏移量,在inode指向的block中写指定数目的字节。显然,该函数必须在begin_op(), end_op()中调用。
- readi与writei类似,唯一的区别是readi不需要在begin_op(),end_op()中调用。
2. inode层的总结
- inode层疑问:inode的refcnt来自哪些方面,有什么作用?
3. directory层
有了inode层的函数,这一层就很简单了。新的数据结构是目录项,每个目录inode对应的
struct dirent {
ushort inum; // 该目录项对应的inode
char name[DIRSIZ]; // 该目录项的名称
};
目录层主要的函数就是用来解析目录,添加目录项这样。
static struct inode*
namex(char *path, int nameiparent, char *name)
int
dirlink(struct inode *dp, char *name, uint inum)
struct inode*
dirlookup(struct inode *dp, char *name, uint *poff)
- dirlookup的作用是根据目录inode参数dp,和目录项名字name,返回该目录项指向的inode,当然也是不带锁的。
- dirlink的作用在目录inode中添加一个新的名字为name的inode项,指向的inode编号为inum(因此调用前需要发起写请求)。
- namex用于路径解析,给定文件路径,依据nameiparent的值返回该路径对应的文件或者目录的inode,或者返回其父目录对应的inode。返回的inode也是不带锁的。
值得注意的是,得益于cache层和inode层的设计,在目录层就不需要过多地考虑加锁,解锁的问题了。对于inode和block的使用,只需要遵循以下的原则
struct inode* i = iget(dev, inum);
//做一些其它的事情
ilock(i); // 需要读或者写inode了
//读写操作
iunlock(i) // 或者iunlockput(i)
struct buf* b = bread(dev, num);
// 读写操作
brelse(b);
因为有refcnt的存在,保证了手上的inode不会被删除(虽然内容可能被修改),在希望读取的时候,调用ilock保证内容的有效性,同时加锁避免竞争。
4. file descriptor层
最后我们来看下前面的函数是如何为顶层调用服务的。
首先还是数据结构。
struct file {
#ifdef LAB_NET
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE, FD_SOCK } type;
#else
enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type;
#endif
int ref; // reference count
char readable;
char writable;
struct pipe *pipe; // FD_PIPE
struct inode *ip; // FD_INODE and FD_DEVICE
#ifdef LAB_NET
struct sock *sock; // FD_SOCK
#endif
uint off; // FD_INODE
short major; // FD_DEVICE
};
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
readable, writable刻画该文件的读写权限,off表示文件读写偏移。
ftable就管理所有打开的文件,文件打开上限为NFILE。
struct file*
filealloc(void)
{
struct file *f;
acquire(&ftable.lock);
for(f = ftable.file; f < ftable.file + NFILE; f++){
if(f->ref == 0){
f->ref = 1;
release(&ftable.lock);
return f;
}
}
release(&ftable.lock);
return 0;
}
// Increment ref count for file f.
struct file*
filedup(struct file *f)
{
acquire(&ftable.lock);
if(f->ref < 1)
panic("filedup");
f->ref++;
release(&ftable.lock);
return f;
}
void
fileclose(struct file *f)
{
struct file ff;
acquire(&ftable.lock);
if(f->ref < 1)
panic("fileclose");
if(--f->ref > 0){
release(&ftable.lock);
return;
}
ff = *f;
f->ref = 0;
f->type = FD_NONE;
release(&ftable.lock);
if(ff.type == FD_PIPE){
pipeclose(ff.pipe, ff.writable);
} else if(ff.type == FD_INODE || ff.type == FD_DEVICE){
begin_op();
iput(ff.ip);
end_op();
}
}
这三个函数与ftable的管理相关,和inode层,cache层那些都差不多了。filealloc分出一个未使用的file,filedup让该file的引用计数加1,fileclose让该file的引用计数减少1。
接下来看下上层系统调用的实现
uint64
sys_link(void)
{
char name[DIRSIZ], new[MAXPATH], old[MAXPATH];
struct inode *dp, *ip;
if(argstr(0, old, MAXPATH) < 0 || argstr(1, new, MAXPATH) < 0)
return -1;
begin_op();
if((ip = namei(old)) == 0){
end_op();
return -1;
}
ilock(ip);
if(ip->type == T_DIR){
iunlockput(ip);
end_op();
return -1;
}
ip->nlink++;
iupdate(ip);
iunlock(ip);
if((dp = nameiparent(new, name)) == 0)
goto bad;
ilock(dp);
if(dp->dev != ip->dev || dirlink(dp, name, ip->inum) < 0){
iunlockput(dp);
goto bad;
}
iunlockput(dp);
iput(ip);
end_op();
return 0;
bad:
ilock(ip);
ip->nlink--;
iupdate(ip);
iunlockput(ip);
end_op();
return -1;
}
可以看到,所有可能对文件系统的修改都用begin_op, end_op包起来了。整个实现也很直接,通过old路径找到inode,然后引用计数+1,再找到新路径的父目录,加上一个指向该inode的目录项即可。
- 要先释放了ip锁,再尝试获取dp锁,否则会有死锁的危险(看下面的unlink代码)。
uint64
sys_unlink(void)
{
struct inode *ip, *dp;
struct dirent de;
char name[DIRSIZ], path[MAXPATH];
uint off;
if(argstr(0, path, MAXPATH) < 0)
return -1;
begin_op();
if((dp = nameiparent(path, name)) == 0){
end_op();
return -1;
}
ilock(dp);
// Cannot unlink "." or "..".
if(namecmp(name, ".") == 0 || namecmp(name, "..") == 0)
goto bad;
if((ip = dirlookup(dp, name, &off)) == 0)
goto bad;
ilock(ip);
if(ip->nlink < 1)
panic("unlink: nlink < 1");
if(ip->type == T_DIR && !isdirempty(ip)){
iunlockput(ip);
goto bad;
}
memset(&de, 0, sizeof(de));
if(writei(dp, 0, (uint64)&de, off, sizeof(de)) != sizeof(de))
panic("unlink: writei");
if(ip->type == T_DIR){
dp->nlink--;
iupdate(dp);
}
iunlockput(dp);
ip->nlink--;
iupdate(ip);
iunlockput(ip);
end_op();
return 0;
bad:
iunlockput(dp);
end_op();
return -1;
}
这里unlink同时持有了两把锁,先获取了待删除项的父目录的inode锁,然后再获取了待删除项的锁。
- 设想如果两个进程,一个进程调用link在同一个目录中试图创造文件A的副本,另一个进程调用unlink试图删除文件A,如果link函数在不释放ip锁的时候获取了dp锁,那么就成了先获取文件A的锁,再获取文件A的父目录锁。此时unlink正好相反,先获取A的父目录的锁,再获取文件A的锁,这里可能造成死锁。
- 那能不能unlink在获取文件A锁的时候释放父目录的锁呢?再设想两个进程同时调用unlink试图删除文件A,如果unlink的获取文件A锁的时候释放了父目录锁,那么一个进程M获得了文件A的锁,另一个进程N可能会卡在获取文件A锁的位置。当M最终退出时,进程获取到文件A锁,却发现A的link数为0,触发panic!。相反,如果M在获取文件A锁时没有释放A的父目录的锁,这样N会卡在获取父目录锁上。当M最终退出时,已经删除了指向A的目录项,N进程调用dirlookup时将会失败,从而返回。
- 在获取多个inode锁时,只要遵循先获取父目录锁,再获取子目录锁的原则就不会死锁。
- 另外,link和unlink在使用时并没有区分设备文件和普通文件。
接下来是与文件创建相关的函数。
uint64
sys_mkdir(void)
{
char path[MAXPATH];
struct inode *ip;
begin_op();
if(argstr(0, path, MAXPATH) < 0 || (ip = create(path, T_DIR, 0, 0)) == 0){
end_op();
return -1;
}
iunlockput(ip);
end_op();
return 0;
}
uint64
sys_mknod(void)
{
struct inode *ip;
char path[MAXPATH];
int major, minor;
begin_op();
if((argstr(0, path, MAXPATH)) < 0 ||
argint(1, &major) < 0 ||
argint(2, &minor) < 0 ||
(ip = create(path, T_DEVICE, major, minor)) == 0){
end_op();
return -1;
}
iunlockput(ip);
end_op();
return 0;
}
uint64
sys_open(void)
{
char path[MAXPATH];
int fd, omode;
struct file *f;
struct inode *ip;
int n;
if((n = argstr(0, path, MAXPATH)) < 0 || argint(1, &omode) < 0)
return -1;
begin_op();
if(omode & O_CREATE){
ip = create(path, T_FILE, 0, 0);
if(ip == 0){
end_op();
return -1;
}
} else {
if((ip = namei(path)) == 0){
end_op();
return -1;
}
ilock(ip);
if(ip->type == T_DIR && omode != O_RDONLY){
iunlockput(ip);
end_op();
return -1;
}
}
if(ip->type == T_DEVICE && (ip->major < 0 || ip->major >= NDEV)){
iunlockput(ip);
end_op();
return -1;
}
if((f = filealloc()) == 0 || (fd = fdalloc(f)) < 0){
if(f)
fileclose(f);
iunlockput(ip);
end_op();
return -1;
}
if(ip->type == T_DEVICE){
f->type = FD_DEVICE;
f->major = ip->major;
} else {
f->type = FD_INODE;
f->off = 0;
}
f->ip = ip;
f->readable = !(omode & O_WRONLY);
f->writable = (omode & O_WRONLY) || (omode & O_RDWR);
if((omode & O_TRUNC) && ip->type == T_FILE){
itrunc(ip);
}
iunlock(ip);
end_op();
return fd;
}
sys_mkdir, sys_mknod, sys_open这三个函数分别用来创建目录文件,设备文件,普通文件。分别内部调用create,传入的type参数对应相应的文件类型。可以看到,xv6允许用只读方式打开目录,也可以用open打开设备文件。另外,在sys_open中只释放了inode锁,并没有调用iput,这是因为file结构持续地持有inode指针,不能够让对应的inode缓存被另外的inode占用。这也意味着一个打开的文件将持续地贡献一个inode的引用计数,直到调用fileclose。
5. 读写操作与设备文件
在file.c和file.h中记录了xv6的驱动
#define CONSOLE 1 //xv6只实现了读取键盘输入和写显示器的驱动
struct devsw {
int (*read)(int, uint64, int);
int (*write)(int, uint64, int);
};
struct devsw devsw[NDEV];
//其中NDEV=10,即xv6最多能有10种不同的设备驱动程序
在console.c中,将键盘输入的读入和写到显示器绑定到devsw上了
void
consoleinit(void)
{
initlock(&cons.lock, "cons");
uartinit();
// connect read and write system calls
// to consoleread and consolewrite.
devsw[CONSOLE].read = consoleread; //读键盘输入
devsw[CONSOLE].write = consolewrite; // 写到显示器
}
接下来看init.c中的一段代码:
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
这里会创建设备文件console,把CONSOLE这个major号绑定到了console文件上。然后打开console。最后看下读写的函数
int
filewrite(struct file *f, uint64 addr, int n)
{
int r, ret = 0;
if(f->writable == 0)
return -1;
if(f->type == FD_PIPE){
ret = pipewrite(f->pipe, addr, n);
} else if(f->type == FD_DEVICE){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
ret = devsw[f->major].write(1, addr, n);
} else if(f->type == FD_INODE){
// write a few blocks at a time to avoid exceeding
// the maximum log transaction size, including
// i-node, indirect block, allocation blocks,
// and 2 blocks of slop for non-aligned writes.
// this really belongs lower down, since writei()
// might be writing a device like the console.
int max = ((MAXOPBLOCKS-1-1-2) / 2) * BSIZE;
int i = 0;
while(i < n){
int n1 = n - i;
if(n1 > max)
n1 = max;
begin_op();
ilock(f->ip);
if ((r = writei(f->ip, 1, addr + i, f->off, n1)) > 0)
f->off += r;
iunlock(f->ip);
end_op();
if(r < 0)
break;
if(r != n1)
panic("short filewrite");
i += r;
}
ret = (i == n ? n : -1);
} else {
panic("filewrite");
}
return ret;
}
- 可以看到,如果file的类型是一个驱动,那么就会调用相应的函数进行读写。这里可以大概明白unix设备文件的含义了,一个设备文件总是绑定了一个驱动函数,打开设备文件后的读写,就相当于调用相应的驱动程序。
- MAXOPBLOCK相当于一个规定,保证一次写请求需要修改的block数目不超过MAXOPBLOCK,用在这里防止一次计算一次writei最多能写多少字节(虽然并没有看懂这是怎么算的)
6. 总结
- 关于inode中引用计数的来历:最常见的一种就是需要调用iget临时读取一个inode,然后很快就释放。另外有可能来自于一个打开的文件,这可能会长时间贡献一个引用计数。还有一种来自于进程的工作目录。查看proc定义,p->cwd也有一个指向inode的指针。比如fork函数的代码就有这么一行。
np->cwd = idup(p->cwd);
- 关于整个xv6的文件系统的感受,可以说文件系统是整个xv6最复杂的部分了,个人感觉尤其体现在锁的运用上,各个地方的锁如何使用,如何避免竞争,如何避免死锁等等问题还是挺麻烦的。