linux 文件系统之 vfs (1)

unix 有一句口号“一切都是文件”,说的是普通文件,外设甚至网络都可以当成一个文件来看待:从磁盘上的文件中读取数据,从鼠标/键盘等外设获得输入,接收网络信息等就是从“文件”中读取数据;把数据保存到文件,将文字显示在屏幕上,播放音乐和电影,发送网络信息等可以认为向“文件”写入数据。这个高度统一的接口为操作提供了巨大的便利,用户不需知道实际的对象是什么,只使用系统提供的 read/write 接口就能完成几乎所有的 IO 操作。

既然所有的设备都能被抽象成文件并且用统一的接口进行操作,而且个人觉得在操作系统的几个组成部分(内存管理,进程调度等)中文件系统是相对简单的部分,从这个统一的接口开始了解其内部工作机制或许是个不错的选择。

虚拟文件系统(Virtual Filesystem Switch)

早在 1985 年 SUN 发表了一篇论文(参考资料 [1]),提出了一个用于 Sun OS 的文件系统接口。论文开头说明了设计的目的:

  • 将与文件系统实现相关的部分和与实现无关的部分分开,两者之间由一个经过良好设计的接口连接;
  • 这些接口需要支持 unix 文件系统的操作原语(如创建/删除文件和目录等操作),并且支持各种各样的文件系统;
  • 接口要满足远程文件系统对于 client 的请求操作(例如一些连接/断开的操作,对应于 open/close);
  • 接口提供的操作都是原子性的,锁操作留给具体实现。

整个接口所在的位置和提供的功能如下:

                            +--------------+
                            | System Calls |
                            +--------------+
                                   |
                            +--------------+
                            | Vnode Layer  |
                            +--------------+
                                   |
        /--------------------------+----------------------------\
        |                          |                            |
+----------------+      +---------------------+       +------------------+
| PC File System |      | 4.2 BSD File System |       | NFS | NFS Server |
+----------------+      +---------------------+       +------------------+

其中主要的结构 vnode 如下:

enum vtype      { VNON, VREG, VDIR, VBLK, VCHR, VLNK, VSOCK, VBAD };
struct vnode {
        u_short         v_flag;                 /* vnode flags */
        u_short         v_count;                /* reference count */
        u_short         v_shlockc;              /* # of shared locks */
        u_short         v_exlockc;              /* # of exclusive locks */
        struct vfs      *v_vfsmountedhere;      /* covering vfs */
        struct vnodeops *v_op;                  /* vnode operations */
        union {
                struct socket   *v_Socket;      /* unix ipc */
                struct stdata   *v_Stream;      /* stream */
        };
        struct vfs      *v_vfsp;                /* vfs we are in */
        enum vtype      v_type;                 /* vnode type */
        caddr_t         v_data;                 /* private data */
};
struct vnodeops {
        int     (*vn_open)();
        int     (*vn_close)();
        int     (*vn_rdwr)();
        int     (*vn_ioctl)();
        int     (*vn_select)();
        int     (*vn_getattr)();
        int     (*vn_setattr)();
        int     (*vn_access)();
        int     (*vn_lookup)();
        int     (*vn_create)();
        int     (*vn_remove)();
        int     (*vn_link)();
        int     (*vn_rename)();
        int     (*vn_mkdir)();
        int     (*vn_rmdir)();
        int     (*vn_readdir)();
        int     (*vn_symlink)();
        int     (*vn_readlink)();
        int     (*vn_fsync)();
        int     (*vn_inactive)();
        int     (*vn_bmap)();
        int     (*vn_strategy)();
        int     (*vn_bread)();
        int     (*vn_brelse)();
};

套用面向对象的术语来说,struct vnode 相当于基类;struct vnodeops 相当于虚函数表,每个具体的文件系统实现其中的某些或全部函数。

一个打印文件内容的小程序(参考资料 [2])

下面的小程序读取一个文本文件的内容并把内容打印到屏幕上:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    int fd, nbytes;
    char buf[BUFSIZ];

    fd = open("/tmp/text.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return -1;
    }

    while (1) {
        nbytes = read(fd, buf, BUFSIZ);
        if (nbytes < 0) {
            perror("read");
            close(fd);
            return -1;
        }

        if (nbytes == 0)
            break;

        write(1, buf, nbytes);
    }

    close(fd);

    return 0;
}

对一个文件进行读写要先进行“打开文件”的操作:

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

open() 的第一个参数是要打开的文件的路径,可以是绝对路径也可以是相对路径,相对路径是对于进程启动时的目录(不是可执行文件所在的目录)来说的。第二个参数 flags 是打开文件的方式,可以是 O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读可写),还有其它的一些参数,它们可以通过“|”操作结合。当 flags 包含 O_CREAT 时会创建一个权限由 mode 指定的文件。如果 flags 中没有 O_CREAT,那么 mode 被忽略。

如果 open() 执行成功会返回一个大于 0 的整数值。这个值是和文件相关联的,实际上是文件描述符表的一个索引,通过这个索引可以找到文件的相关信息,后续的 read()/write() 等操作都要用到这个描述符。如果返回值小于 0 表示执行出错,用 perror() 打印出错的信息。

接着是一个循环,不断地读取文件的内容:

ssize_t read(int fd, void *buf, size_t count);

read() 的第一个参数为 open() 返回的文件描述符,表示从该文件中读取内容。每次读取内容后系统会把文件位置往前移动相应的长度,不需要我们自己维护当前读到的位置。第二个参数是存放读到的内容的位置;第三个参数是读取的最大字节数。read() 并不对 buf 的长度进行检查,所以 count 的值不能大于 buf 的长度。

read() 返回实际读到的字节数 nbytes(0 < nbytes <= count),nbytes 会小于 count 是因为可能在最后读取的数据中不足 count 字节,因此只把读到的有效字节数返回。返回 0 表示读取终止(例如到达文件末尾,或者网络已经正常断开),返回值小于 0 表示读取出错(如设备出现故障或网络掉线等)。出错信息都可以通过 strerror() 获得。

把内容读到缓冲区后就可以输出到屏幕上:

ssize_t write(int fd, const void *buf, size_t count);

write() 的第一个参数是要输出的文件描述符。程序里 write() 的第一个参数直接使用 1,因为在 linux 中每个进程启动的时候都自动打开了三个文件描述符 0,1 和 2,分别对应于标准输入(stdin,通常是键盘),标准输出(stdout,通常是屏幕)和标准错误输出(stderr,通常也是屏幕)。第二个参数 buf 为指向要输出内容的起始地址,count 为要输出的字节数。buf 的内容会被输出到 fd 所关联的文件的当前位置。

write() 返回成功输出的字节数 nbytes(0 < nbytes <= count)。返回值小于等于 0 表示写入出错。具体错误可以“man 2 write”。

文件使用完后使用 close() 释放资源:

int close(int fd);

系统调用是用户程序和操作系统交互的桥梁,我们将从这些系统调用进入内核。

从 open() 开始(参考资料 [3])

从 http://kernel.org/ 获取源代码:

git clone http://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git

进入与文件系统相关的目录 fs 会发现还有一堆文件和目录。一般来说在 fs 目录下的文件是与 vfs 相关的,而各个子目录就是各个具体的文件系统实现。在 fs 下有一个 open.c,猜测应该是与 open() 相关的:

grep -n '\<open\>' open.c

出来一堆相关的内容。仔细查看可以发现一些特别的东西:

997:SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)

打开 open.c 跳到第 997 行看了下,嗯,应该就是要找的入口了。系统调用由 SYSCALL_DEFINE* 系列宏定义,具体的宏根据系统调用的参数个数可能是 SYSCALL_DEFINE1,SYSCALL_DEFINE2 等等。找到规律后,找 read() 和 write() 就容易便多了:

grep -n '\<read\>' * | grep SYSCALL_DEFINE3
grep -n '\<write\>' * | grep SYSCALL_DEFINE3

接着来看看系统调用 open() 的定义:

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
    long ret;

    if (force_o_largefile())
        flags |= O_LARGEFILE;

    ret = do_sys_open(AT_FDCWD, filename, flags, mode);
    /* avoid REGPARM breakage on x86: */
    asmlinkage_protect(3, ret, filename, flags, mode);
    return ret;
}

实际的工作下放到 do_sys_open() 中:

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
    struct open_flags op;
    int lookup = build_open_flags(flags, mode, &op);
    char *tmp = getname(filename);
    int fd = PTR_ERR(tmp);

    if (!IS_ERR(tmp)) {
        fd = get_unused_fd_flags(flags);
        if (fd >= 0) {
            struct file *f = do_filp_open(dfd, tmp, &op, lookup);
            if (IS_ERR(f)) {
                put_unused_fd(fd);
                fd = PTR_ERR(f);
            } else {
                fsnotify_open(f);
                fd_install(fd, f);
            }
        }
        putname(tmp);
    }
    return fd;
}

从其中用到的函数名称来大概推断一下这个函数的流程:使用 get_unused_fd_flags() 获取一个可用的索引,接着 do_file_open() 根据文件名创建一个关联的 struct file 结构,然后 fd_install() 把 fd 和 struct file 关联起来,最后把 fd 的值返回。这样后续的系统调用就可以通过 fd 来访问与文件相关的信息。

接着来看看 get_unused_fd_flags() 的定义。在我的环境中使用 cscope 不能跳到对应的定义,因此在 vim 中使用 grep 查找:

:grep get_unused_fd_flags -r $HOME/workspace/linux/fs $HOME/workspace/linux/include/linux

其中 $HOME/workspace/linux/ 是 linux 源代码的根目录;在代码根目录下的 include 中又有好几个目录,其中 linux 目录下的是内核主要部分的头文件。然后用“:cw”打开 quickfix 窗口就可以方便地跳到字符串出现的地方。找到 get_unused_fd_flags() 的定义:

#define get_unused_fd_flags(flags) alloc_fd(0, (flags))

实际上是调用了 alloc_fd(),从位置 0 开始找到一个可用的索引并设置相应的 flags:

int alloc_fd(unsigned start, unsigned flags)
{
    struct files_struct *files = current->files;
    unsigned int fd;
    int error;
    struct fdtable *fdt;

    spin_lock(&files->file_lock);
repeat:
    fdt = files_fdtable(files);
    fd = start;

    /* 把查找的起始位置设为最小的可用索引 */
    if (fd < files->next_fd)
        fd = files->next_fd;

    /* 从起始位置开始找下一个可用的索引 */
    if (fd < fdt->max_fds)
        fd = find_next_zero_bit(fdt->open_fds, fdt->max_fds, fd);

    /* 如果不够,对 fdtable 进行扩充,然后再次寻找 */
    error = expand_files(files, fd);
    if (error < 0)
        goto out;

    /*
     * If we needed to expand the fs array we
     * might have blocked - try again.
     */
    if (error)
        goto repeat;

    /* 如果起始查找的位置小于等于下一个可用的描述符位置,说明已找到的可用的 fd 之前的位置已经被使用 */
    if (start <= files->next_fd)
        files->next_fd = fd + 1;

        /* 设置相应的bitmap */
    __set_open_fd(fd, fdt);
    if (flags & O_CLOEXEC)
        __set_close_on_exec(fd, fdt);
    else
        __clear_close_on_exec(fd, fdt);
    error = fd;
#if 1
    /* Sanity check */
    if (rcu_dereference_raw(fdt->fd[fd]) != NULL) {
        printk(KERN_WARNING "alloc_fd: slot %d not NULL!\n", fd);
        rcu_assign_pointer(fdt->fd[fd], NULL);
    }
#endif

out:
    spin_unlock(&files->file_lock);
    return error;
}

current 是一个宏,被替换成 get_current() 函数,返回指向当前进程的 task_struct 结构的指针。从 task_struct 结构体中获得一个指向打开文件信息的 files_struct 结构的指针(参考资料 [4]):

#define NR_OPEN_DEFAULT BITS_PER_LONG
/*
 * Open file table structure
 */
struct files_struct {
  /*
   * read mostly part
   */
    atomic_t count;
    struct fdtable __rcu *fdt;
    struct fdtable fdtab;
  /*
   * written part on a separate cache line in SMP
   */
    spinlock_t file_lock ____cacheline_aligned_in_smp;
    int next_fd;
    unsigned long close_on_exec_init[1];
    unsigned long open_fds_init[1];
    struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

其中 count 为引用计数,next_fd 为下一个可用的文件描述符,fd_array 为当前进程所打开文件对应的 struct file 结构。close_on_exec_init 和 open_fds_init 都是 bitmap 数组,前者是在调用 exec() 时需要关闭的文件,后者是进程开始运行时打开的文件(可能在 fork() 时从父进程继承了一些打开的文件描述符)。另外还有两个域:fdt 和 fdtab,开始的时候 fdt 指向 fdtab。

接着来看看 struct fdtable 的定义:

struct fdtable {
    unsigned int max_fds;
    struct file __rcu **fd;      /* current fd array */
    unsigned long *close_on_exec;
    unsigned long *open_fds;
    struct rcu_head rcu;
    struct fdtable *next;
};

max_fds 表示当前可允许打开的最大文件数,这个值并不是限定的,随着打开文件数的增多可以使用 expand_files() 进行扩充。接下来的 3 个域:fd,close_on_exec 和 open_fds,初始时分别指向 struct files_struct 中的 fd_array,close_on_exec_init 和 open_fds_init。NR_OPEN_DEFAULT 初始值为 BITS_PER_LONG(32 位操作系统该值为32,64 位为 64),一般来说一个进程打开的文件数到不了这个值。但是如果打开的文件数超过了这个值就需要对 fdtable 进行扩充:先申请一个更大的 fdtable 并且把 fdtable 中的各个域指向新申请的区域(expand_files() 函数),接着把原来的 fdtable 的内容复制到新的 fdtable 中(copy_fdtable()函数),最后把 files_struct 中的 fdt 指向新的 fdtable 并且释放旧的 fdtable 的资源(如果 fdt 指向的是 files_struct 中的 fdtab 则不需释放)。

关于为什么要这样设计数据结构的原因在参考资料 [5] 中有说明,因为对 rcu 还不了解所以没搞懂。另外 rcu 的资料见参考资料 [6]。

参考资料

[1] Vnodes: An Architecture for Multiple File System Types in Sun UNIX
[2] open(2), read(2), write(2), close(2)
[3] linux kernel 3.4-rc4
[4] Chapter 8. Professional Linux Kernel Architecture
[5] Documentation/filesystems/files.txt
[6] Documentation/RCU/

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注