open 设备文件全过程(以块设备为例)

Preface

不知不觉接触 Linux 已经两年了,一直很想总结一下读源代码的笔记,但是总是看见网上分享的博客比自己写的好了不知道多少,于是一直都没有动笔,直到自己真正开了博客,就希望写一下别人没有提及的部分,或者是提及的比较少但是比较关键的点。

现在想来,博客应该是记录自己的笔记的一个所在地,记录自己学习的过程,于是这篇文章就这样诞生了,去年我是重点在 VFS 和 设备驱动 这一部分下了功夫,那段时间学到的东西真的好多,今年想起来却发现有些细节已经不记得了,于是干脆把现在还能记得的部分都记录下来,当然我是回去再看了一遍的,所以现在的映像还是比较深刻的。

open() 的过程

写这篇博客的时候,我最开始的想法从底至上,就是从设备文件被创建那里开始,不过这样感觉不会太直观,所以还是从顶往下的顺序开始讲述把。因为的我们的标题就是指设备文件,所以我们就以下面这句代码开始把。

open("/dev/sda" ...);  //这里省略了参数,一般是 READ_ONLY

这里不会涉及具体的函数的调用过程,因为其实调用的过程随着内核版本的变更,变化还是不小,但是实质通过函数指针实现的多态性,这一点是一点都没有改变呀。

简单介绍一下这个函数的调用过程把,首先这句代码会触发软中断,然后参数存入寄存器,简单的说,路径字符串传递到了其他函数。来到了内核态(中断导致),接着根据我们调用的参数,设置路径查询的参数,比如,我们在打开一般文件的时候可以指定,如果文件不存在就创建 等参数。当然,我们设备文件一般是内核自动为我们创建的哟,以前还得自己手动。

这个函数最关键就是根据路径名最终找到了属于它的 inode,这其实不难想到对把,最简单的办法就是连成一棵搜索树,然后根据路径名搜索就是,最后得到一个结构,里面有一个指针就得到了 inode。inode 是我们在加载这个块设备驱动的时候为它创建的,inode 决定了 read write open 等等一系列操作的流程,因为它提供了一个函数指针。

struct inode {
    ...
    const struct inode_operations    *i_op;
    const struct file_operations    *i_fop;    /* former ->i_op->default_file_ops */
    ...
};
/* 
inode 结构其实真正决定了一个文件到底是怎么操作的,它提供的 i_fop 
最终是要赋值给 file 结构的,这里我假设读者熟悉这个结构,因为要面面俱到
那怕是都将不清楚,总之 这个地址赋值给 file->f_ops->open() 
那么就是真正的 open 函数了
*/

// 对于目录 还有 readdir 功能 总之 目录也是一个文件
struct file_operations {
    ...
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*readdir) (struct file *, void *, filldir_t);
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    ...
};

// 
struct inode_operations {
    ...
    int (*create) (struct inode *,struct dentry *,int, struct nameidata *);
    struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
    int (*link) (struct dentry *,struct inode *,struct dentry *);
    int (*unlink) (struct inode *,struct dentry *);
    int (*symlink) (struct inode *,struct dentry *,const char *);
    int (*mkdir) (struct inode *,struct dentry *,int);
    int (*rmdir) (struct inode *,struct dentry *);
    int (*mknod) (struct inode *,struct dentry *,int,dev_t);
    int (*rename) (struct inode *, struct dentry *,
            struct inode *, struct dentry *);
    ...
};

请读者认真看看上面的注释,file_operations 这个结构决定了一个文件具体操作的过程,而内核只需要拿到指针,负责调用,其实就是一种 OOP 的思想。好奇心强的读者想必会问,那么 inode 的 i_fop 的值是如何得来的,那么又得知道 inode 是如何创建的了,决定 inode 如何创建的是文件系统的超级块,即 super_block

struct super_block {
    ...
    struct super_block_operations * sbo;
    ...
};

struct super_operations {
       struct inode *(*alloc_inode)(struct super_block *sb);
};

这里一下子出现很多结构,所以读者急需补一下 linux 文件系统的一些基本知识。

是不是很相似,超级块决定了inode 如何创建,那么好奇的读者肯定又会问,那么超级块如何创建,答案就是文件系统驱动的编写者,创建一个超级块然后做初始化, 函数最关键的一部必然就是设置super_operations 的值为自己编写的结构了。

sb->sbo = &my_own_sb_ops; 

struct super_operations my_own_sb_ops {
       alloc_inode : my_own_func
};

struct inode * my_own_func (struct super_block *sb) {
   do_something
}

当然,赋值给 alloc_inode 函数指针的函数最关键的一步,就是在创建 inode 的时候,将 i_fop 设置为自己编写的函数,这样是不是全部能联系在一起了。这里关键思想就是,“写函数,然后把函数的地址给内核,接着内核调用”。

总结一下,以打开 "/dev/sda" 为例子,那么 open(),然后经过路径搜索,找到了 /dev/sda 的 inode,它的字段 i_fops,决定了open 的具体操作, 而 i_fops 是文件系统编写者写,然后注册到内核当中,当我们新建一个inode的时候就会被赋值,也就是创建一个新文件的时候,那个 inode 就会被创建,接着赋值。这里其实涉及了文件系统的编写了,总之我们得留下一个映像,内核不知道文件怎么打开,它只是调用文件系统提供的功能,这就是 VFS 的实质

也就是说, open 函数,其实就是调用了驱动编写者提供的 open 函数?那么问题来了,这里是文件系统提供的函数对把,那么块设备文件可指不定创建在哪个文件系统上( 可能 ext2 xfs ext4 minix ...),而且软盘,硬盘,cd 都是块设备,一个文件系统驱动又怎么可能自带块设备节点的处理呢? 所以这些功能,是不是应该由块设备的编写者来提供呢? 那么一个文件系统又怎么帮助一个设备文件找到真正属于它的 file_operations ,那就看看一个设备文件到底怎么被创建的把。

从设备文件被创建开始

这篇博客一直都建立在一个前提上,就是读者对于 VFS 有一些基础的认识,比如 open() 这一系统调用,file 结构,然后最终因为它们 inode 的 f_ops 指针不一样,所以导致执行的操作不一样等等,我们重点关注的是这些指针是如何被赋值到 file 结构上的,以及为什么根据设备名能最终找到不同的设备,这些 tricks 是真正的重点。

冤有头,债有主。想知道设备文件读写的全过程,我们必须得知道这个文件是如何被创建的。这里先提一点,可能新手对于设备能被抽象为文件很是不解,我当初也是如此,但是你得知道所谓文件,就是你的读写最终是和磁盘沟通,所以可不可以理解成我们之前理解的文件,其实就是硬盘的存储空间呢?所以是不是也是设备文件,所以啊,其实所有的文件都可以理解为设备文件(先不考虑管道这些通信文件),随着学习的深入,我们会发现其实文件充当的作用只有一个,那就是作为一个沟通的媒介

那么设备文件到底怎么创建呢。Linux 终端下我们可以 mkdir touch mknod ln 等等指令,其实归根到底都是 inode_operations 提供的。

struct inode_operations {
    ...
    int (*create) (struct inode *,struct dentry *,int, struct nameidata *);
    struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
    int (*link) (struct dentry *,struct inode *,struct dentry *);
    int (*unlink) (struct inode *,struct dentry *);
    int (*symlink) (struct inode *,struct dentry *,const char *);
    int (*mkdir) (struct inode *,struct dentry *,int);
    int (*rmdir) (struct inode *,struct dentry *);
    int (*mknod) (struct inode *,struct dentry *,int,dev_t);
    int (*rename) (struct inode *, struct dentry *,
            struct inode *, struct dentry *);
    ...
};

设备文件就是调用 mknod 创建,跟之前 open 类比,我们知道其实也是文件系统提供的函数,看看这个函数的参数,其中 dev_t 就是设备号,这里不详细介绍,我们在查看设备文件有一字段就是设备号,是在 mknod 的时候指定的。

[trance@centos ~]$ ls -l /dev/sda
brw-rw----. 1 root disk 8, 0 Oct 30 17:25 /dev/sda

以 ext2 文件系统为例子,我们来看看它的 mknod 的函数,关键得知道,究竟文件系统给设备文件的 inode 的 i_fops 施加了什么魔法,首先我们得确定一点,就是的确是 ext2 文件系统给 inode 赋值了文件操作的函数,因为用户在/dev 目录下 调用的 mknod 必然就是由这个文件系统来指定操作。

mknod 创建的一类特殊的设备文件,因为一块设备当然是没有大小的说法,但是却有设备号,所以在 Linux 下的文件系统驱动必然就应该提供这个接口,我们来看看ext2 文件系统的相关函数。

static int ext2_mknod (struct inode * dir, struct dentry *dentry, int mode, dev_t rdev)
{
    struct inode * inode;
    int err;

    if (!new_valid_dev(rdev))
        return -EINVAL;
    dquot_initialize(dir);
    inode = ext2_new_inode (dir, mode, &dentry->d_name);
// 这里分配一个新的 inode , 我们不用关心
    err = PTR_ERR(inode);
    if (!IS_ERR(inode)) {
        init_special_inode(inode, inode->i_mode, rdev);
// 这句是核心,对特殊的 inode 做初始化,即设备文件的inode
#ifdef CONFIG_EXT2_FS_XATTR
        inode->i_op = &ext2_special_inode_operations;
#endif
        mark_inode_dirty(inode);
        err = ext2_add_nondir(dentry, inode);
    }
    return err;
}

读者类比 open 便可以猜想到内核是怎么来到 ext2_mknod,我们关心的是 init_special_inode 这个函数,因为传入了设备号,很显然 trick 一定是在这了。init_special_inode 是 Linux 内核提供的函数,思考一下为什么是这样,因为 mknod 必须得创建 inode,而不同的文件系统创建的 inode 不一样( 因为设备节点是与创建它的文件系统有关,而设备节点具体的操作却是与特定的设备有关,文件系统需要做的事情是让设备节点找到自己的驱动,或者借助操作系统来完成这一件事,但是设备节点创建的时候不一定有驱动 ),所以需要文件系统提供 mknod 接口,创建了 inode 之后,文件系统又如何知道这个内核版本的设备文件如何处理呢?所以就使用了内核提供的接口,分工明确,各司其职。

下面来揭开这个 init_special_inode 的神秘面纱

// fs/inode.c:1703:2.6.39.4
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
    inode->i_mode = mode;
    if (S_ISCHR(mode)) {
        inode->i_fop = &def_chr_fops;
        inode->i_rdev = rdev;
    } else if (S_ISBLK(mode)) {
        inode->i_fop = &def_blk_fops;
        inode->i_rdev = rdev;
    } else if (S_ISFIFO(mode))
        inode->i_fop = &def_fifo_fops;
    else if (S_ISSOCK(mode))
        inode->i_fop = &bad_sock_fops;
    else
        printk(KERN_DEBUG "init_special_inode: bogus i_mode (%o) for"
                  " inode %s:%lu\n", mode, inode->i_sb->s_id,
                  inode->i_ino);
}

读者肯定也不惊讶,最终是内核设置了 inode 的文件操作函数,以块设备为例,块设备文件创建之后,文件系统完成了 inode 的创建,而内核则是负责赋值了它的操作函数。这就是 magic 所在拉。

keke,到了敲黑板时间。总结一下,首先 mknod 这一命令最终来到了文件系统提供的接口,文件系统创建了 inode,接着对它的初始化交给了内核,于是乎,内核给它的 inode->i_fop 赋值,这便是我们上一节所提及的关键。

设备文件的面纱

下面的关键就是, init_special_inode 所设置的文件操作函数拉。还是以块设备为例子。

// fs/block_dev.c:1592:2.6.39.4

const struct file_operations def_blk_fops = {
    .open        = blkdev_open,
    .release    = blkdev_close,
    .llseek        = block_llseek,
    .read        = do_sync_read,
    .write        = do_sync_write,
      .aio_read    = generic_file_aio_read,
    .aio_write    = blkdev_aio_write,
    .mmap        = generic_file_mmap,
    .fsync        = blkdev_fsync,
    .unlocked_ioctl    = block_ioctl,
#ifdef CONFIG_COMPAT
    .compat_ioctl    = compat_blkdev_ioctl,
#endif
    .splice_read    = generic_file_splice_read,
    .splice_write    = generic_file_splice_write,
};

当然,我们只需要关注 open 函数,其它都是类似的,读者有兴趣应该自己了解,我们关注的是它的tricks,它的实现思想。

// fs/block_dev.c, line 1402, v2.6.39.4

static int blkdev_open(struct inode * inode, struct file * filp)
{
    struct block_device *bdev;

    /*
     * Preserve backwards compatibility and allow large file access
     * even if userspace doesn't ask for it explicitly. Some mkfs
     * binary needs it. We might want to drop this workaround
     * during an unstable branch.
     */
    filp->f_flags |= O_LARGEFILE;

    if (filp->f_flags & O_NDELAY)
        filp->f_mode |= FMODE_NDELAY;
    if (filp->f_flags & O_EXCL)
        filp->f_mode |= FMODE_EXCL;
    if ((filp->f_flags & O_ACCMODE) == 3)
        filp->f_mode |= FMODE_WRITE_IOCTL;

    bdev = bd_acquire(inode);
    if (bdev == NULL)
        return -ENOMEM;

    filp->f_mapping = bdev->bd_inode->i_mapping;

    return blkdev_get(bdev, filp->f_mode, filp);
}

22行的代码就是获取块设备的关键,但是我们这里不用关心。

需要提醒的一点是,此时的操作都是由内核块设备模块开发那一部分的人提供的,文件系统没有需求,没有必要管这一部分,它只需要提供 mknod 接口函数,负责自己的 inode 创建,接着调用内核提供的接口 init_special_inode 就OK,不同的文件系统在这部分处理都应该是相似的,因为设备结构是其它模块的事情,文件系统只需要调用接口。

如果有非常细心的读者,会发现,上述过程是在设备文件创建的时候才赋值,那么我们关机之后,创建的设备文件节点实际上还在文件系统中,那我们利用的实质其实就变成了文件系统的 open 函数,其实,不难想到的一点,在文件系统打开的函数的时候,必然要实现的一步,就是对 inode->i_fops 的赋值,只要检测它是否属于设备节点,如果是,就赋值为 init_special_inode,又回到了上面这一过程。

// /fs/ext2/inode.c:1291
struct inode *ext2_iget (struct super_block *sb, unsigned long ino)
{
    struct ext2_inode_info *ei;
    struct buffer_head * bh;
    struct ext2_inode *raw_inode;
    struct inode *inode;
    long ret = -EIO;
    int n;

    inode = iget_locked(sb, ino);
    if (!inode)
        return ERR_PTR(-ENOMEM);
    if (!(inode->i_state & I_NEW))
        return inode;

    ei = EXT2_I(inode);
    ei->i_block_alloc_info = NULL;

    raw_inode = ext2_get_inode(inode->i_sb, ino, &bh);
    if (IS_ERR(raw_inode)) {
        ret = PTR_ERR(raw_inode);
         goto bad_inode;
    }
    .....
    .....
    if (S_ISREG(inode->i_mode)) {
        inode->i_op = &ext2_file_inode_operations;
        if (ext2_use_xip(inode->i_sb)) {
            inode->i_mapping->a_ops = &ext2_aops_xip;
            inode->i_fop = &ext2_xip_file_operations;
        } else if (test_opt(inode->i_sb, NOBH)) {
            inode->i_mapping->a_ops = &ext2_nobh_aops;
            inode->i_fop = &ext2_file_operations;
        } else {
            inode->i_mapping->a_ops = &ext2_aops;
            inode->i_fop = &ext2_file_operations;
        }
    } else if (S_ISDIR(inode->i_mode)) {
        inode->i_op = &ext2_dir_inode_operations;
        inode->i_fop = &ext2_dir_operations;
        if (test_opt(inode->i_sb, NOBH))
            inode->i_mapping->a_ops = &ext2_nobh_aops;
        else
            inode->i_mapping->a_ops = &ext2_aops;
    } else if (S_ISLNK(inode->i_mode)) {
        if (ext2_inode_is_fast_symlink(inode)) {
            inode->i_op = &ext2_fast_symlink_inode_operations;
            nd_terminate_link(ei->i_data, inode->i_size,
                sizeof(ei->i_data) - 1);
        } else {
            inode->i_op = &ext2_symlink_inode_operations;
            if (test_opt(inode->i_sb, NOBH))
                inode->i_mapping->a_ops = &ext2_nobh_aops;
            else
                inode->i_mapping->a_ops = &ext2_aops;
        }
    } else { // ******** 最重要的就是这里,初始化特殊的节点
        inode->i_op = &ext2_special_inode_operations;
        if (raw_inode->i_block[0])
            init_special_inode(inode, inode->i_mode,
               old_decode_dev(le32_to_cpu(raw_inode->i_block[0])));
        else 
            init_special_inode(inode, inode->i_mode,
               new_decode_dev(le32_to_cpu(raw_inode->i_block[1])));
    }
    brelse (bh);
    ext2_set_inode_flags(inode);
    unlock_new_inode(inode);
    return inode;

bad_inode:
    iget_failed(inode);
    return ERR_PTR(ret);
}

这篇文章到这里就快结束了,这篇文章的强调的是以下几点。

  • 文件是这是一个沟通的媒介,用户打开文件,内核实际上做的事情很简单,就是从文件对应的 inode 那里拿到处理函数( file_operations ), 接着初始化。

  • 文件系统对于设备文件的支持也很容易实现,常规文件的需要文件系统提供处理函数,为什么?因为一般文本文件内容的分布是和特定的文件系统息息相关的,而设备文件不需要,因为设备文件的内容不由文件系统的决定。

  • 对于设备文件的处理最终是由内核的相关设备模块提供,因为不同的 块/字符 设备文件处理方法都不一样,所以需要相关的来提供。最终,def_blk/char_fops 应运而生。

对于第二点,我们举个例子,当我们读 /dev/sda 的时候,读出来的内容一般有 分区表,文件系统超级块等结构,还有文件系统里面的文件,当然,一般的文本文件内容是分散的,所以我们没办法知道 /dev/sda 有哪些文本文件。

文件系统在初始化的时候,直接读写的就是 /dev/sdax ,也就是这个块设备的分区,之后它能识别上面的文件,为什么?因为是它写进去的,它对于每一个文件的分布都有记录,当然这些记录也在硬盘上,请参考下面那张图。

我们读 /dev/sda 的时候,也可以读出这些记录,但是我们不了解,只有特定的文件系统了解。

所以得出结论,/dev/sda 的内容和特定的文件系统无关(不由文件系统决定),但是一般文本文件和文件系统息息相关。

最后以一张图结束本文。

Last updated