Socket 源码浅析 - 实现自定义 Socket 协议簇(2)Linux 文件系统

349 阅读17分钟

在上一篇文章 《Socket 源码浅析 - 实现自定义 Socket 协议簇(1)Socket 简介》

里面我们只是简单地看了几个 demo,并且简单描述了一下 socket 系统调用的几个参数的含义,然后结尾的时候我们准备介绍一下 socket 系统调用返回的”文件描述符”。这里我们就要针对这个“文件描述符”简单讲一讲了。


“文件描述符”,看名字就知道是和文件挂钩的,所以为了解释清楚文件描述符,我们还需要先简单介绍一下所谓的 “文件系统”。


文件系统

对于文件系统不太感兴趣或者已经很熟悉的同学可以直接跳到下面的 “Socket 文件描述符” 那部分。

可能大家都听说过,Linux 的设计哲学(KISS 原则)中有个一致性原则,在 Linux 中对于一致性的体现,有一个实现叫做 “一切皆文件”,也就是说,Linux 把任何东西都给抽象成了文件去使用。在 Linux 中的文件类型有:普通文件、目录文件、字符设备文件、块儿设备文件、管道文件、套接字文件等。

举个例子,大家在用 windows 的时候怎么存数据?往那些什么 C/D/E/F 盘中创建文件然后存是吧。那 Linux 中没有这种什么盘的概念,那它就不能存储东西了么?当然能,不然还玩个球儿~如果有同学试过往 Linux 系统上插个硬盘的话,可能就会发现,当你插上一块儿硬盘之后,Linux 会在根目录的 /dev 这个目录下给你创建出一个叫做 sda 或 sdb 或 sdc 之类的文件,具体到底叫 a 还是 b 还是 c,取决于插了几块儿硬盘,如果主板上只连接这一块儿硬盘的话就只有一个 sda,如果有两块儿,就有一个 sda 以及 sdb。

这个 sdx,其实就是 Linux 把硬件设备,也就是硬盘给抽象成了文件!同理你往主板上插个什么 U 盘啥的,在 /dev 下都有个文件与之对应。当然有些硬件设备比如硬盘是 Linux 自主帮你创建的,有些其他的设备可能需要自己手动创建。

那假设现在往 Linux 上插了一块儿硬盘,这块儿硬盘叫 /dev/sda,那我们是直接用就行么?当然不是~很简单的道理,假如真的就是直接往里头写东西,那第一次你往里存了 1k 的东西,第二次往里存了 10k 的东西,第三次往里存了 3.1415926 k 的东西,好了现在要取数据了,你咋知道你的数据在哪个位置,没有任何的对数据或者说对文件的描述就咔咔往里存,存时候挺爽,取时候就火葬场了。

所以我们在真正使用一个硬盘设备之前,要先对其进行“格式化”。“格式化”这个词挺眼熟的是吧,我还记得我小时候把我妈一个很重要的 U 盘给格式化了,我被我妈这顿揍啊!

所谓的“格式化”是啥呢,其实就是操作系统提前帮你在磁盘上,创建出一堆用来描述这块儿磁盘,以及描述你磁盘中文件的数据的行为。

具体来说,当你在 Linux 上格式化一块儿磁盘的时候,操作系统首先在这块儿磁盘上,写入了一个叫做“超级块儿(super_block)”的东西,然后又写入了“inode 位图”,“data block 位图”,以及“inode block”的几样东西。现在可能大家不知道是啥, 后面我们再介绍。

上面说的这几样东西,都是实打实地真正写入到了磁盘上,而且是在你使用磁盘之前就被写入了,这就是为啥咱们买完 U 盘或者硬盘插到电脑上,卖家告诉你有 100G 的空间,而实际使用的量却不到 100G,不是卖家骗你昂,是因为这块儿盘被操作系统给“格式化”了,因为只有格式化之后,操作系统才能知道这块儿盘上的数据使用情况,以及应该怎么样往里存取数据。


现在我们知道了想使用一块儿硬盘就需要先格式化,在硬盘上格式化出上面说的什么“超级块儿”,“位图”啥的,那这些东西长什么样呢?

这个其实就跟选择使用什么类型的“文件系统”有关了。

操作系统不管是 Linux 还是 Windows 都支持很多种文件系统,我们可以在 Linux 下简单看一下:

哇支持好多~其中比较常用的就是 ext2,3,4,vfat 等。

前面标了 nodev 的表示基于内存的文件系统,没标的表示基于磁盘的文件系统,对于像 ext 这种就是基于磁盘的,真的能往硬盘上写东西的文件系统。

当我们选择了一种文件系统之后,需要使用这种文件系统来对磁盘进行格式化,举个例子:

通过 dd 命令将 /dev/zero 上的内容给拷贝出来做成一块儿磁盘镜像,/dev/zero 是个伪文件系统,从它里头可以无限多地读取出一堆 0,所以这段命令的意思就是“从 /dev/zero 这个文件中读出 0,以 1M/block 的大小读取,一共读取 4 块儿,也就是共 4M 的 0,将其读取到一个虚拟的 test.img 磁盘上”。简单那来将就是抽象出一块儿 4M 的虚拟磁盘。当然有条件的也可以直接插一块儿硬盘,或者使用虚拟机调节一下硬盘大小,这里为了方便演示就直接用这个命令做个假的。

现在有了这么一块儿磁盘之后,我们没法正常使用它,需要按刚才说的一样对其进行格式化,可以通过 mkfs 命令去做:

可以看到该命令可以使用这么多种文件系统来进行格式化,我们这里使用一个最简单的叫做 “minix” 的来做:

可以看到终端提示格式化完有 1376 个 inode,4096 个 block 等。

接下来还需要将它挂载到一个目录下才能正常使用,我们使用 mount 命令进行挂载:

挂载后使用 df 命令可以查看磁盘使用情况,可以发现这个 test 目录已经被使用了 1%,这 1% 就是刚刚上面 mkfs 格式化时候创建的什么“超级块儿”“位图”啥占的地儿。


现在咱们要回过头来,为啥要说这些东西呢,是因为上面说过 socket 在 Linux 的 “一切皆文件” 的哲学之下也被抽象成了文件。之所以说这些文件系统相关的东西是为了之后能更好的理解 socket。所以大家不要误会,我不是说着说着就扯到别的东西去了哈哈~

刚才上面我们提到格式化之后会在磁盘上创建什么“超级块儿”“位图”“inode 节点”等东西是吧,这些到底是啥呢,我们接下来简单说一说。

首先开局一张图:

首先磁盘是个块儿设备,所谓块儿设备就是按照 block 做随机存储,虽然是随机存储,但是在逻辑上我们还是认为文件是连续存储的,所以看图片中 Minix 文件系统被分为了“引导块”,“超级块”,“inode 位图”,“逻辑块位图”,“inode 节点”,“数据区” 这几个部分(有些文件系统在引导块后面会有“块组描述符”,这里我们使用 Minix 文件系统为例,所以暂时不考虑这个东西 )。

也就是说,在使用 mkfs 的瞬间,操作系统已经将除了后面数据区部分之前的内容都填好了,坑位都占上了。

我们简单描述一下这些东西都是干啥的:

  1. 引导块儿:它的作用是用来存储一小段程序,用来引导操作系统,一般使用 mkfs 初始化完的可能这部分都是 0,也就是没有东西,不过在操作系统刚启动的时候的第一个扇区,也可以被成为引导块儿,他就是所谓的“MBR”。

  2. 超级块儿:用来描述这整个文件系统的信息的,比如它可能记录着这块儿盘上的 inode 数量,可使用的位图,文件的最大尺寸等关于这个文件系统的全部关键参数。

  3. inode 位图:经常位图是一种用来表示可使用容量的数据结构,这里记录着这块儿盘上还可以使用的 inode 的数量。

  4. 逻辑块儿位图:记录着这块儿盘上可使用的用来存储真实数据的 block 数量。

  5. inode:一个文件至少绑定一个 inode,inode 就记录了这个文件的基础信息,比如创建者的 uid,文件权限,尺寸,以及真实的数据在磁盘的第几块儿的位置等等信息。

  6. 数据区:这里就是用来存储真实数据的地方,以 block 为单位,一块儿一般是 4k,可以自由配置。

简单描述之后,我们可以简单看一下 minix 这个文件系统的内核源码:

在源码的 include/uapi/linux/minix_fs.h 目录下可以看到它相关的 inode,super_block 等数据结构的定义,这里不都贴出来了,感性的同学可以自己再去看。每种文件系统的这些数据结构的实现都不一样,minix 是最简单的一种,如果大家想深入学习的话建议可以通过这个 minix 文件系统入手。

另外如果大家想对磁盘上真实的数据和内核源码的数据结构做个对比校验的话,可以通过 hexdump 命令来查看:

hexdump 这个命令可以用十六进制的方式查看磁盘上的数据,从上到下依次就是 “引导块”,“超级块”,两个“位图”,“inode”等在磁盘上真实的数据表现,感兴趣的同学可以自己用这个去 linux 的 minix 文件系统的源码中去做一下对比看看能不能对得上(肯定对得上)。


在了解了类似 minix 这种基于磁盘的文件系统之后,我们思考一个问题,上面说过每种文件系统实现的 inode,super_block 都不一样,大小也都不一样,这也就意味着操作系统如果想往磁盘上写入每种文件系统的数据结构的话就需要有不同的方法,这样无疑是一种不好的设计,将如此多的操作文件系统的函数都放进内核,内核将越来越臃肿。因此内核采用了“加一层”的策略,所谓的“加一层”就是在操作系统和文件系统中间加一个通用层,在通用层定义一些接口,然后由具体的文件系统来实现这些接口,这样操作系统在一套固定的流程中调用这个 “通用层” 的接口,然后这些接口实际是由具体的文件系统进行填充,这样就做到了更好地解耦。

看下图可以更直观一点:

也就是说其实在应用层面我们调用的类似 create,read,write 之类的操作文件的 API 其实都是调用的“通用层”的接口,也就是图中的 “虚拟文件系统(VFS)”这一层的接口。


上面我们说文件系统要按照 vfs 层实现 api,然后把自己实现的 api 挂载到 vfs 上是吧~那这个 vfs 到底长啥样呢?

其实操作系统为了让 vfs 能和真正的文件系统对应上,所以 vfs 也提供了 super_block、inode 等相应的结构体,只不过这些结构体是只存在于内存中的,类型也更加通用。举个例子,我们看下内核中的 inode 结构体,在 include/linux/fs.h 这个头文件中有定义 super_block、inode 等:

对于这些结构体来讲,上面都有一个叫做 xxx_op 的属性,super_block 叫 s_op,inode 叫 i_op,他们分别对应 super_operations、inode_operations 结构体:

可以看到这里的接口就是定义了一堆函数,这些函数只定义名字,接受的参数以及返回值。也就是说,其实我们如果想自己实现一个文件系统的话,就可以自己按照这些接口的定义来实现自己的文件系统的逻辑~

当然内核中不止这些数据结构,其实 vfs 中还抽象了很多很重要的数据结构,比如 dentry 目录项,也可以叫目录缓存项,还有 file 结构体等,这些结构体上也都有相应的 operations 操作集,以便让每种文件系统实现自己的操作函数。这里我们不一一介绍了,因为太多了,并且也很复杂,我个人也仅是看了九牛一毛而已,再深入说我就该黔驴技穷了哈哈~

虽然我不能把这些结构介绍得特别详细,但是我希望让大家能对文件系统和 VFS 这一层更有点感觉,所以我尝试简单地通过源码给大家介绍一下:

  1. 首先,操作系统需要感知到自己能够使用哪些类型的文件系统,所以就需要文件系统自己来向操作系统注册自己。这是通过内核提供的 register_filesystem方法完成的:

可以看到图片中有个 module_init 方法,这种方法是在内核启动时会触发的函数。它会执行 init_minix_fs 方法,该方法中通过调用 register_filesystem 注册了一个 minix_fs_type,minix_fs_type 是一个写死在内核代码中的结构体,里面定义了 name,mount,kill_sb 等属性。其实对于大部分的文件系统都是这样操作的,全局搜索的话可以看到一堆:

register_filesystem 这个方法其实就是往全局的一条链表上,挂了每种文件系统对应的作为参数的那个结构体。

  1. 当文件系统在内核启动的时候向内核注册了自己之后,使用 mkfs 这种命令格式化一个文件系统之后,会在对应的磁盘上创建 super_block,inode,dentry 等真实的数据,具体可以向上文说的似的使用 hexdump 命令查看。

  2. 之后要对文件系统进行 mount 操作,mount 操作的本质就是通过调用在 register_filesystem(&xxxxx_fs_type) 时候传进去的那个 xxxxx_fs_type 上的 mount 方法来获取刚才在 mkfs 之后从磁盘上创建的 super_block 的数据,用刚刚在磁盘上的 super_block 里的数据填充内核的 super_block 里的数据,并将一些文件系统自己定义的 operations 挂载到 super_block 上面:

COMPAT_SYSCALL_DEFINE5(mount, ......) {
  // ......
  // 执行 do_mount
  retval = do_mount(......);
  // ......
}

long do_mount(......) {
    // ......
    // 第一次挂载的话会执行到这里
    // 在这里面就会执行每个文件系统自己的 mount 方法
    retval = do_new_mount(......);
    // ......
}

static int do_new_mount(......) {
    // ......
    err = do_new_mount_fc(fc, path, mnt_flags); 
    // ......
}

static int do_new_mount_fc(......) {
  // ......
  mnt = vfs_create_mount(fc);
  // ......
}

在一连串儿的调用中,主要就是通过每个文件系统自己定义的 mount 方法获取到磁盘中真实的数据,然后用这些数据把内存中的 super_block、dentry、根 inode 等数据结构填充上(早期的 mount 方法还不叫 mount,叫 get_sb,名字倒是比现在直观),并且在这个 mount 方法中会把 minix 自己实现的和 super_block 相关的 operations 操作集挂载到内存中的 super_block 结构上:

在 minix_mount 方法中把 minix_fill_super 传给了内核提供的 mount_bdev 方法,这个方法中就会调用 minix_fill_super,而 minix_fill_super 里面将内存中的 super_block 结构体的 operations 操作集赋值为 minix 文件系统自己的操作集。

  1. 然后当创建文件的时候,内核会在内存中,为文件创建对应的一个 inode 结构体,注意这个结构体是内存中的,身上带有 operations 的那个,不是文件系统自己真实的 inode:

这里的 ksys_open 其实就是创建文件的系统调用函数的入口,接下来的调用流程是:do_sys_open → do_filp_open→path_openat → do_last → lookup_open → (dir_inode->i_op->create) ,流程挺长的,无法一一解释,但是可以注意到最后调用的是 dir_inode这个结构体上的i_op属性中的create方法,这个方法是谁呢,其实就是 minix 文件系统自己提供的 minix_create 方法:

minix_create 这个方大致法如下:

static int minix_mknod(struct inode * dir, struct dentry *dentry, umode_t mode, dev_t rdev) {
  // ......
  struct inode *inode;
  inode = minix_new_inode(dir, mode, &error);
  minix_set_inode(inode, rdev);
  // ......
}

可以比较清晰地看到,里面 new 了一个 minix 的 inode,其中 minix_set_inode 会将各种 operations 还有一些其他属性设置到这个新创建的 inode 上:

所以每次创建出一个 inode 之后都会在它上面挂上这个文件系统对应的 operations,这个 operations 上就有创建新的 inode 的方法。

可能有的同学会好奇,上面第三步中进行 mount 后目的是为了创建 super_block,但是我们这里看到的用于创建 inode 的 create 方法是 minix_dir_inode_operations 结构体上提供的,并不是 super_block 中的 operations 提供的,那上面说到的:

这里的 dir_inode -> i_op -> create 是哪儿来的呢?

其实都是统一在 mount 初始化 super_block 的时候一并挂上的,上面我们说 minix 提供了一个 minix_fill_super 函数用来给 super_block 做一些赋值,除此之外,我们还可以通过源码看到:

它里面同时还初始化了一个 root_inode,root 是谁呢,是个目录,所以其实目录也是个文件类型,也有自己的 inode。

  1. 除了上面的格式化和挂载之外,对于文件的操作还有类似 open、read、write 等操作,都可以在内核中找到对应的 vfs 层的操作:

这里我们不再做更多地介绍了,快收不住了,感兴趣的同学可以自己去研究一下源码。


上面所介绍的那些文件系统相关的东西,其实还是蛮乱挺的,主要是小弟的水平实在有限,而且文件系统相关的东西实在太多了。最后我尝试简单概括一下:

  1. 每种文件系统都有自己的 inode、super_block 等数据结构,每个文件至少对应一个 inode,每种文件系统挂载到一个目录的时候,就会在磁盘上写入一个自己的 super_block,同时初始化一堆 inode 节点,这些都是真的写在磁盘上的。

  2. 除了文件系统有专属自己的 inode 等数据结构外,内存中也会有相应的 inode、super_block 等数据结构,这些数据结构是给 VFS 层使用的,VFS 层通过调用真实文件系统挂载在这些数据结构上的 operations,来操作真实文件系统的 inode 等数据。

  3. 文件系统需要像内核注册自己,注册一个属于自己的结构体。

  4. 使用文件系统需要挂载,挂载的过程就是通过文件系统自己的 mount 方法获取到磁盘上的该文件系统的真实的 super_block 信息。

  5. 获取到 super_block 信息之后,还要同时创建内存中的 super_block、根目录的 inode,dentry,等内存中的数据结构。

  6. 并且需要将每种文件系统自己实现的用于操作 inode、super_block 等数据结构的 operations 挂载到对应的内存中的结构体上。

  7. 当需要对文件做 创建/读/写 等操作的时候,内核就从内存中的 inode、super_block 等结构体上,获取对应的文件系统挂载的 operations,然后调用文件系统自己实现的 operations 以用来在磁盘上写入真实的文件系统的数据。

......诶,兄弟尽力了,水平有限,文笔有限,以后若是我能更进一步的话,我会回过头来,尝试再把文件系统这个事儿解释地更清楚一些,giegie 们凑合看吧先。


好了,文件系统这块儿解释了这么多,我们接下来终于该谈一谈 “socket 文件系统了”。由于控制篇幅长度,关于 ”socket 文件系统“ 这部分我们放到下一章再说。