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

692 阅读20分钟

在前两篇文章 《Socket 源码浅析 - 实现自定义 Socket 协议簇(1)Socket 简介》《Socket 源码浅析 - 实现自定义 Socket 协议簇(2)Linux 文件系统》

里面,我们分别对 socket 套接字做了简单的介绍以及参数介绍,然后为了解释 socket 的 “文件描述符”,所以我们又介绍了一下 “文件系统”,接下来我们就该谈论一下 “socket 文件系统了”。


Socket 文件系统

我们说过 socket 套接字也是一种特殊的基于内存的文件类型。我们通过 cat /proc/filesystems 查看一下:

不难发现,其中就有一个叫做 sockfs 的文件系统(顺便提一下,最上面咱们用 strace 跟踪 nodejs demo 的时候用的那个 strace,你看它也在这里头,是一个叫做 tracefs 的文件系统)。

所以按照上面“文件系统”那部分咱们说的,文件系统想被使用,一定要先想内核注册自己,所以我们可以尝试着在内核源码中搜一下 sockfs 相关的内容:

果不其然,一下就搜到了 sockfs,可以看到它也和 minix 文件系统一样,有一个提前在内核中定义好的,类型是 file_system_type 的结构体。不太一样的是它上面没有 mount 这个方法,取而代之的是一个叫做 init_fs_context 的方法。这是因为 minix 是基于磁盘的文件系统,在使用的时候需要手动挂载,而像 sockfs 这种基于内存的文件系统,内核会自动帮你挂载好,所以就不需要 mount 方法了,至于那个 init_fs_context 是干啥的一会儿再说~

当然注册用的 register_filesystem 方法也是一定会有的:

在 register_filesystem 的正下方,紧接着就会调用相关 mount 方法:

可以看到,这里和一般的基于的磁盘的文件系统不一样,基于磁盘的文件系统我们在上面分析过是要通过手动 mount 后才能使用,但是对于 socket 这种基于内存的文件系统来说,是内核直接通过 kern_mount 来进行挂载的。

在这个 kern_mount 方法中,主要就是为这种基于内存的文件系统创建“假的” super_block 以及 dentry、inode 等 vfs 层的数据结构,并把 sockfs 自身实现的一些 operations 挂到 super_block 上。

我们简单梳理一下 kern_mount 的执行流程:

kern_mount
→ vfs_kern_mount
    → fc = fs_context_for_mount
    → init_fs_context(fc)
→ fc_mount
    → vfs_get_tree
        → pseudo_fs_get_tree
            → get_tree_nodev
                → vfs_get_super
                    → pseudo_fs_fill_super
    → vfs_create_mount

简单解释一下个别方法:

1. fc = fs_context_for_mount

fc 是该方法创建出一个该文件系统的 context,context 主要是对该文件系统的一些信息进行绑定,比如注册时候传进去的那个数据结构,注册时默认的网络命名空间,根目录等。

static struct fs_context *alloc_fs_context(......) {
  // 省略一些代码......
  struct fs_context *fc;
  // 分配一个 fc 也就是一个关于该文件系统的 context
  fc = kzalloc(sizeof(struct fs_context), GFP_KERNEL);
  // fs_type 就是调用 register_filesystem 时候传进来的那个玩意儿
  fc->fs_type  = get_filesystem(fs_type);
  // 获取默认的网络命名空间
  fc->net_ns  = get_net(current->nsproxy->net_ns);
  // 获取根节点的 inode
  fc->root = dget(reference);
  // init_fs_context 被赋值为 register_filesystem 调用时
  // 传进来的那个参数中的 init_fs_context
  // 对于 sockfs 来说就是 sockfs_init_fs_context 方法
  init_fs_context = fc->fs_type->init_fs_context;
  ret = init_fs_context(fc);
  return fc;
}

2. init_fs_context(fc)

这个方法其实就是注册 sockfs 文件系统时,sockfs 自己定义的那个 sockfs_init_fs_context 方法,该方法中把上面创建的 context 也就是这个 fc 给:

fc->fs_private->ops = &sockfs_ops

fc->fs_private->dops = &sockfs_dentry_operations

通过 sockfs_ops 可以看出来它上头实现了一些创建 inode 以及释放 inode 的方法。

3.pseudo_fs_fill_super

该方法主要用于创建 VFS 层的 super_block,并把 fc->fs_private 上的两个 ops 赋值给 super_block。同时创建一个根目录的 vfs 层的 inode,用 inode 构建出一个 dentry 结构,让 super_block 的 s_root 指向这个 dentry:

static int pseudo_fs_fill_super(......) {
  struct pseudo_fs_context *ctx = fc->fs_private;
  struct inode *root;
  // 省略一些代码......
  s->s_op = ctx->ops ?: &simple_super_operations;
  root = new_inode(s);
  // s_root 是 dentry 结构
  s->s_root = d_make_root(root);
  s->s_d_op = ctx->dops;
  return 0;
}

4. vfs_create_mount

返回一个 vfsmount 结构,这个结构上就能拿到刚刚创建的 super_block 以及 dentry 等对象。

kern_mount 函数的这条调用链又臭又长又绕,我省略了很多细节部分,这里再简单总结一下:

注册完 sockfs 结构体之后,内核会主动调用 kern_mount 进行 sockfs 的挂载,挂载大概的过程就是以根目录作为挂载点,创建出一个 vfsmount 结构(ext4,minix 等文件系统每挂载一次也会产生这么一个 vfsmount)。这个 vfsmount 结构上可以获取到这个文件系统的 super_block,以及 dentry 等数据结构,然后 sockfs 这个文件系统自己实现的一些对于 inode、dentry 等数据结构的 operations 操作集,就挂在这个 super_block 上。


到这里,大家应该可以感受出来,挂载一个类似 minix 这种真实基于磁盘的文件系统的过程,和挂载 sockfs 这种基于内存的文件系统流程上是差不多的,都是提前定义好自己这个文件系统的描述和一些 operations,然后将其注册到全局链表,之后挂载的时候都要在 vfs 层创建对应的 super_block、dentry、inode 等数据结构,然后再将每个文件系统自己实现的对于 super_block、dentry、inode 的操作函数作为 operations 挂载到 vfs 层对应的 super_block、detry 等结构体上。挂载的过程和挂载点或多或少有些许差别,但是归根结底一定是vfs 层通过 super_block、dentry、inode 等数据结构上的 operations 来调用真实的文件系统自己实现的一些操作集,这些操作集用来在磁盘(或内存)中创建每种文件系统相关的真实数据。


Socket 文件描述符

兜兜转转,圈圈圆圆圈圈,终于回到最开始我们想说的“文件描述符”这个东西了。

文件描述符,顾名思义,用来描述一个文件的。我们来看下 C 语言中是如何读写文件的:

这俩是我网上随便找的通过 read 函数以及 write 函数对文件进行读写的代码。可以看到两套代码都需要先通过 open 方法,并且返回一个叫做 fd 的东西,然后 read 和 write 方法都要把这个 fd 作为参数使用。包括咱们一开始写的 socket 套接字的 demo 中,在调用 sendto 和 recvfrom 都要接收这么一个“文件描述符”。如果你去查看这个文件描述符的类型的话,会发现它其实就是 int 类型的元素,也就是一个整数,那为什么可以通过它来描述整个文件呢?

我们来简单看一下源码,先来简单看一下 open 一个普通文件内核大概做了啥:

首先 open 一个文件,open 系统调用最终调用到的是 do_sys_open 方法:

long do_sys_open(......) {
  // ...... 省略一些代码
  fd = get_unused_fd_flags(flags);
  if (fd >= 0) {
    struct file *f = do_filp_open(dfd, tmp, &op);
    // 省略一些代码......
    fd_install(fd, f);
  }
  // 省略一些代码......
  return fd;
}

可以看到这个 do_sys_open 方法主要做了这么几件事儿:

1. get_unused_fd_flags:从名字来看就可以知道,这个方法是用来获取一个“还未被使用的 fd(文件描述符)”的。

2.do_file_open:这个方法返回了一个 file 结构体:

其实 file 这个结构体,就是每当在一条进程中打开一个文件,就会为这个打开的文件创建这么一个 file 结构体,同一个文件可以在多条进程中被打开多次,并且每次打开都会给它创建一个新的 file 结构体。这个 file 结构体就是用来描述打开的这个文件的,它随着文件的打开而创建,随着文件的关闭而销毁。

我们可以看到这个结构体上有 inode,file_operations 等结构体,还有什么 f_mode 用来表述权限,还有 f_pos 之类的属性用来表示在这条进程中这个文件的偏移量,所谓偏移量就是用来记录这个文件被读到哪儿了,同一个文件可以被不同的进程打开,每个进程都可以读取不同的位置,这个 pos 就是用来记录这个位置的,类似还有 f_path 表示文件的路径,f_owner 这个表示文件的创建者等等,信息还挺多的,我们不一一介绍了,感兴趣的同学可以自己再深入研究一下。

3. fd_install:可以看到这个方法的参数是一开始获取的 int 类型的文件描述符,以及第二步创建的描述这个文件在本条进程中状态的 file 结构体,大家应该已经可以猜到这个函数是干啥的,没错,就是把这条进程上,把这个 fd 文件描述符与创建的那个 file 结构体做一个映射,以便于用户可以用户层简单地通过 fd 来操作文件,而不需要感知 file 这个这么沉重的内核数据结构了。

那这个映射是怎么做的呢?我们简单描述一下:

  1. 首先“进程”这个东西,在内核中被抽象为 PCB,也就是进程描述符,用来表示它的结构体叫做 task_struct。这个 task_struct 结构存储着某条进程的全部状态。

  2. 这个 task_struct 结构上的属性贼多,我们不一个个看了,但是要知道上面有个属性叫做 files:

这个 files 属性是个 files_struce 类型的结构,它上面又有个属性叫 fdtab 还有个属性叫 fd_array:

这俩属性就是用来做 fd 和 file 结构体的映射的。首先操作系统假设一条进程可能会打开的文件很少,所以优先使用 fd_array,这是一个静态数组,这个数组的长度和电脑的位数有关,32 位的话长度就是 32,64 位的话长度就是 64,所以优先使用 fd 作为 file 的索引存放在这个数组中。但是一旦当打开的文件数超过了这个数组的最大容量,这个时候就可以通过 fdtable 也就是文件描述符表来进行查找,在这个表上把 file 结构体做成一条链表,这样对于链表长度可以是无限的,这样就可以根据 fd 在链表上按照顺序找到对应的 file 结构体了~

总结一下:在 open 一个文件的时候,会选择一个还未使用的文件描述符,也就是选择一个整数,然后构建一个 file 结构体,这个结构体是用来描述这个文件在这条进程中的状态的。然后内核会把这个文件描述符作为这个 file 结构体的索引返回给用户层。


了解了对于一个普通文件来说文件描述符意味着啥,其实也就知道 socket 的文件描述符是什么了。其实一样的,因为上面我们一直强调 socket 也是一种文件系统,所以它的文件描述符创建过程和普通文件基本上一样,只不过相应的 open 系统调用变成了 socket 系统调用。

不过也有不一样的,我们在文件系统那部分说过,每创建完一个文件之后,都有创建对应的 vfs 层的 inode 数据结构,对于普通文件来讲,open 的时候文件已经存在了,所以这个 inode 也已经存在了,因此 open 的时候可以只关心文件描述符的创建与绑定,但是对于 socket 来讲,每次调用 socket 系统调用的时候都相当于重新创建一个 socket 文件,所以在进行 socket 系统调用的时候除了创建与绑定 fd 之外,还要给这个 socket 创建对应的 inode:

socket 系统调用的入口函数是这个 __sys_socket:

int __sys_socket(int family, int type, int protocol) {
  int retval;
  struct socket *sock;
  // ...... 省略一些代码
  retval = sock_create(family, type, protocol, &sock);
  // ...... 省略一些代码
  return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

通过函数名字可以看出来,先创建了一个名为 sock 的 socket 类型的结构体,然后对 sock 和 fd 做了 map。我们先来简单看下 sock_create 主要做了什么事情:

int __sock_create(......) {
  struct socket *sock;
  const struct net_proto_family *pf;
  // ...... 省略一些代码
  // 先分配一块儿 socket 的空间, 这个 sock 就是 socket 的实例
  sock = sock_alloc(); 
  // ...... 省略一些代码
  sock->type = type;
  pf = rcu_dereference(net_families[family]);
  // ...... 省略一些代码
  err = pf->create(net, sock, protocol, kern);
  // ...... 省略一些代码
  *res = sock;
  // ...... 省略一些代码
}

我们来简单解释一下:

  1. 首先调用sock_alloc,从名字上看就知道是用来分配一个 socket 类型的结构体的。我们简单看下它:

他里头通过 new_inode_pseudo 凭空做了个假的 inode,你看!inode 这个数据结构出来了吧,和上面文件系统中说的“每创建一个文件都要在内存中创建一个对应的 vfs 层的 inode”这句话对上了吧~

然后将这个 inode 的 i_op 赋值为了 sockfs_inode_ops,这点和普通文件系统中,需要将 inode 的 i_op 也赋值为每种文件系统自己实现的 ops 也是一样的。

  1. 然后我们来看 pf = rcu_dereference(net_families[family])  这句话。它的意思就是根据用户选择的 family 也就是协议簇,在一个全局的叫做 net_families 的数组上把对应的协议簇找到。

在找到对应的协议簇之前,我们应该思考一下,这个数组是什么时候被填充的呢?

其实在操作系统内核启动阶段,会初始化各种子模块或者子系统,当初始化到网络模块儿这部分,就会初始化一堆的内核定义好的协议,初始化的过程就是调用这些模块的 init 方法,然后在这些 init 方法中执行 sock_register 方法,并把自己实现的一个结构体作为参数注册上去,这一步和文件系统的注册还是蛮像的,我们可以全局搜索验证一下:

可以看到有好多文件中调用并传了一个 xxxx_family_ops,我们简单看下 ipv4 和 ipv6 的 net_proto_family。

感觉上还是挺简单的,基本主要的就是一个 family 的名字,那个名字是个 int 类型,还有一个 create,这是要协议簇自己实现的一个方法,后面我们会再看到。然后我们再看下 sock_register 方法:

高亮那部分就是往那个全局的 net_families 数组上添加。大家不用纠结细节,只要知道这里是往数组上干就行了,那个什么 rcu_dereference_protected 是个全局的宏变量,实现还挺复杂的,反正兄弟我是看不懂,感兴趣的大佬可以研究研究~

现在我们回到上面那个 __sock_create 函数中的 pf = rcu_dereference(net_families[family])  现在我们知道拿到的这个 pf 是个啥了哈,就是每个协议簇自己注册的带有 create 函数的那个玩意儿。

  1. 然后我们继续看 sock_create 方法中的 pf->create(net, sock, protocol, kern)  这步。诚如我们上面所说,这个 create 就是 inet_family_ops 结构体的 inet_create 方法:
static int inet_create(......) {
  struct sock *sk;
  struct inet_protosw *answer;
  struct inet_sock *inet;
  struct proto *answer_prot;
  // ...... 省略一些代码
  list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
    // ...... 省略一些代码
  }
  // ...... 省略一些代码

  sock->ops = answer->ops; 
  answer_prot = answer->prot;
  // ...... 省略一些代码
  sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
  // ...... 省略一些代码
  sock_init_data(sock, sk);
  // ...... 省略一些代码
  sk->sk_prot->init(sk);
  // ...... 省略一些代码
}

这个函数还挺复杂的,咱们挑一些重点的说(挑一些我能看懂的说):

首先我们看到这里有个 *struct sock sk 这是一个很重要的结构体,注意它不是上面一直说的 struct socket,这是两个结构体,我们需要记住,socket 结构体对应的变量名儿叫做 “sock”,而 sock 结构体对应的变量名儿叫 “sk”,这个很重要。

这里解释一下为啥要把 socket 在内核中拆成两个数据结构(个人理解):为啥要区分 socket 和 sock 两个结构体呢,因为 socket 其实也是个文件,可以通过其返回的文件描述符找到,文件描述符要通过 file 结构体找到对应的 inode 结构体,这个 inode 结构体中需要包含一个 socket 结构体,首先如果直接把 sock 就放在这里的话,会导致这个 inode 结构体变得特别大,另外还有就是,sock 是和具体协议相关的结构体, 如果直接把 sock 绑定在 inode 中,inode 在 vfs 这一层就失去了通用性。

接下来我们往下看 list_for_each_entry_rcu 这个方法,先根据 sock->type 也就是协议簇的类型查找协议栈(就是调用 socket 系统调用时候传进来的那第 2 个参数),协议栈就是表示这个协议簇中可使用的各种协议,比如 ipv4 的话这里拿到的就是 inetsw_array,忘了的同学可以返回到上面去回顾一下,大概在做完了最上面的实验之后的部分。

然后根据 protocol (用户层在调用 socket 时候自己传进来的第三个参数)从 inetsw_array 上找到用户选择协议,如果没传的话就根据 type 选个默认的,比如 ipv4 协议簇的话,如果 type 填了 “SOCK_DGRAM” 并且第三个参数给了个 0 的话,这里就会默认把 UDP 选出来,然后塞到 answer 这个变量上。

接下来的 sock->ops = answer->ops 的这一步也很重要,可以看到把 answer 的 ops 赋值给了 sock 的 ops,answer 在上一步我们说过可能是 UDP 的协议,也就是这个:

这个是由协议自己实现的一些 operations。所以到这里我们可以发现,Linux 内核经常会使用这种“底层自己实现操作集,上层通过固定接口调用底层实现”的这种模式,很好地做到了逻辑分层。

然后 answer_prot = answer->prot 把 prot 赋值给 answer_prot 变量,这个 prot 就是上图中 inet_dgram_ops 上面的那个 udp_prot 结构,就是这么个玩意儿:

可以看到它上面定义了协议的名字以及很多操作方法,比如用来发送数据的 sendmsg 方法,接收数据的 recvmsg 方法等。

下一步是 sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern) ,这步可以看到以 pf_inet(family)以及 answer_prot 作为参数,然后返回值是 sock 类型的 sk 变量,也就是说这个函数中主要做的事情就是将 sock 这个类型和用户选择的协议簇以及协议簇对应的 4 层协议做一个绑定:

struct sock *sk_alloc(struct net *net, int family, gfp_t priority,
          struct proto *prot, int kern) {
  struct sock *sk;
  // ...... 省略一些代码
  sk = sk_prot_alloc(prot, priority | __GFP_ZERO, family);
  if (sk) {
    sk->sk_family = family;
    // ...... 省略一些代码
    sk->sk_prot = sk->sk_prot_creator = prot;
    // ...... 省略一些代码
  }
  // ...... 省略一些代码
  return sk;
}

下面一步是sock_init_data(sock, sk)  方法,这个方法非常长,主要就是对 sock 和 sk 做一些填充,我们就不做过多介绍了,主要可以记住这里面让 sock 和 sk 这俩变量互相引用了:

void sock_init_data(struct socket *sock, struct sock *sk) {
  // ...... 省略一大堆代码
  sock->sk=sk;
  // ...... 省略一大堆代码
  sk->sk_socket=sock;
  // ...... 省略一大堆代码
}

最后 sk->sk_prot->init(sk) 这里是调用对应的 4 层协议的 init 方法,以用来初始化上层协议,对于上层协议的 init 方法我们不多说了,感兴趣的同学自己去研究研究吧~

到这儿基本上 sock_create 方法主要的事情都介绍完了,然后我们来看下创建完 socket 之后的 sock_map_fd 这一步做了啥,我们和普通文件的 open 方法做一个对比:

怎么样,有木有发现长得有点点像,他们里头都先后调用了 get_unused_fd_flags,然后调用的 fd_install,所以也就是说,执行 socket 系统调用的时候,会先创建 socket,然后再对 fd 进行创建与绑定。

(注:其实普通文件系统直接调用 open 方法也可以进行创建,具体根据使用 open 方法时的传参来决定是否要创建新的文件)


总结一下 socket 系统调用的过程:首先创建 socket,创建的大概过程是,先创建一个 socket 结构体,再创建一个 sock 结构体,其中 socket 结构体更加通用一些,主要也会被这个 socket 的 inode 引用,而 sock 则更加特化一点,sock 不但要和协议簇绑定,也要和对应的 4 层协议进行绑定,在绑定的同时就也把对应协议实现的具体的发送和接受数据等方法也一块儿绑定了。然后创建完 socket 并且和 sock 绑定之后,在找到一个可用的文件描述符,并将这个文件描述符和这个 socket 对应的 file 结构体进行映射。


发送与接受消息

在了解了 socket 的创建过程之后,接下来就要对消息进行发送或者接收了,所以我们来简单看一下 socket 接收和发送用的两个系统调用,一个 sendto,一个 recvfrom。我们先来看 sendto:

用于发送消息的 sendto 系统调用对应的函数入口是这个 __sys_sendto 方法:

int __sys_sendto(......) {
  // ...... 省略一些代码
  sock = sockfd_lookup_light(fd, &err, &fput_needed);
  // ...... 省略一些代码
  err = sock_sendmsg(sock, &msg);
  // ...... 省略一些代码
  return err;
}

int sock_sendmsg(struct socket *sock, struct msghdr *msg) {
   // ...... 省略一些代码
  return err ?: sock_sendmsg_nosec(sock, msg);
}

static inline int sock_sendmsg_nosec(struct socket *sock, struct msghdr *msg) {
  int ret = INDIRECT_CALL_INET(sock->ops->sendmsg, inet6_sendmsg,
             inet_sendmsg, sock, msg,
             msg_data_left(msg));
  BUG_ON(ret == -EIOCBQUEUED);
  return ret;
}

可以看到调用链还是比较简单的,主要 __sys_sendto 里面先调用 sockfd_lookup_light 方法,根据 fd 拿到对应的 sock,然后调用了一个 sock_sendmsg,然后 sock_sendmsg 里又调用了 sock_sendmsg_nosec 方法,最后在这个 sock_sendmsg_nosec 方法中,通过一个全局定义的宏来执行了 sock→ops→sendmsg 方法。

也就是说,其实这个 sendto 系统调用最终主要调用的就是 sock→ops→sendmsg 这个方法。

在上面我们讲 inet_create 方法的时候,就讲到里面有一步是 sock->ops = answer->ops,而 answer 是选出来的对应的 4 层协议,所以这里执行的其实就是下图中的那个 udp_sendmsg 方法:

这个方法具体是做了什么事情我们就不往下看了,这里涉及到具体的协议栈的实现了,这里面会特别复杂,完全可以单独讨论。

看完了 sendto 方法之后,其实 recvfrom 方法的流程也是差不多的:

最终也是调用具体协议栈对应的 recvmsg 方法。

最后我们总结一下 socket 的发送和接受数据过程:在创建 socket 的过程中会把用户选择(或系统默认根据 type 选择)的协议栈的具体实现的 operations 和 socket(socket 中有个 sock 结构体) 进行绑定,然后在调用的时候根据 fd 在进程的 task_struct 结构体中的 files 上的 fd_array 拿到对应的 file 结构体,然后通过 file 结构体上绑定的一些属性就可以拿到这个文件的 socket 结构体,然后 socket 结构体上可以拿到真正内核层面的 sock 结构体,也就能拿到具体的协议栈自己实现的 sendmsg 或者 recvfrom 方法了。


到这儿为止,我们 socket 的创建以及发送和接收的大概过程,并且也能感受到 socket 的文件系统的联系。我们简单串一下 socket 这一套过程:首先进行 socket 系统调用是为了创建 socket,然后创建的过程大概就是通过提前注册好的协议簇找到它对应的能使用的协议们(包括协议本身以及它的上层协议),然后将这些协议和一个 sock 结构体进行绑定,并且把协议簇上的协议栈自己实现的 sendto,recvfrom 等方法绑定到 sock 上,当触发了 send 或者 recv 等系统调用的时候会自动触发。

接下来我们就要回到标题上:实现一个私有的套接字协议簇了。

不过由于篇幅有限,所以我们把私有套接字协议簇的实现放到下一章再说~