Socket 源码浅析 - 实现自定义 Socket 协议簇(4)动手实现自定义套接字

619 阅读14分钟

我们在前几篇文章 《Socket 源码浅析 - 实现自定义 Socket 协议簇(1)Socket 简介》《Socket 源码浅析 - 实现自定义 Socket 协议簇(2)Linux 文件系统》 以及 《Socket 源码浅析 - 实现自定义 Socket 协议簇(3)Sockfs 文件系统》

里面简单地介绍了 socket 系统调用的使用方法以及 socket 和文件系统的关系,也简单地介绍了一下其在源码中的实现。

现在我们回到最初的目的上:实现一个私有的套接字协议簇。


现在有了上面的基础,我们就可以尝试自己实现一套私有的套接字协议了。但是在实现之前,还有个地方我们要说一下,刚刚上面没有说,内核中内置了很多协议簇,并且限制了这些协议簇的数量。首先在 Linux 源码目录中的 include/linux/socket.h 文件下:

大概在低两百一十多行的位置,有个 AF_MAX,这个就是内核写死的支持的最多的协议簇的数量,这里原本是 45,我们为了实现自己的协议簇,就需要修改这个,我们给它改成 46,并在上头随便加个什么自己起名的协议簇名字,我这里叫 AF_DINGTESTSOCK,对应的数字是 45 号,没别的含义,就是随便起。

然后在下面大概二百六十多行的位置也需要把自定义的这个名字加上:

接下来是在源码目录的个文件下 security/selinux/hooks.c 的socket_type_to_security_class 这个方法中:

大概第一千三百二十多行的位置也要把我们自定义的协议簇照猫画虎地加在这里,同时下面那个 PF_MAX 也要改成大于 46。

好了准备工作完成了,开整!


首先第一步,创建一个用来描述协议簇的结构体:

static const struct net_proto_family ding_test_afsock_family_ops = {
  .owner  = THIS_MODULE,
  .family = PF_DINGTEST, // 这里是 include/linux/socket.h 中自己加的编号
  .create = ding_test_afsock_create, // 执行 socket 系统调用创建一个 socket 时候就会调用这个 create
};

ding_test_afsock_create 方法的实现一会儿再看,此时可以先给它个空的函数壳子占位。

第二步,将这个协议簇注册到内核:

int __init ding_test_afsock_init(void) {
  int rc = 0;
  // 注册自己实现的协议簇
  rc = sock_register(&ding_test_afsock_family_ops);
  if(rc) goto sock_failed;
  return 0;
  
sock_failed:
  sock_unregister(PF_DINGTEST);
  return rc;
}

void __exit ding_test_afsock_exit(void) {
  sock_unregister(PF_DINGTEST);
}
module_init(ding_test_afsock_init);
module_exit(ding_test_afsock_exit);

注意这里,module_init 方法是内核提供的向内核动态注入模块儿的方法,我们需要在一个现成的操作系统上动态地注入我们定义的套接字,需要使用命令 insmod,这个 insmod 方法底层会调用这个 module_init 方法。

然后我们的初始化函数中调用 sock_register 方法,以上面那个结构体作为参数,sock_register 是内核提供好的用于注册协议簇的方法。

另外记得模块儿被卸载的时候记得调用 sock_unregister 方法。

第三步,注册完了我们就可以调用 socket 了,我们知道在创建 socket 中会调用协议簇自己的 create 方法,所以这里我们来实现这个 create 方法:

static int ding_test_afsock_create(struct net *net, struct socket *sock, int proto, int kern) {
  struct sock *sk = NULL;
  // 将自定义协议簇的操作集赋给 socket 结构体
  sock->ops = &test_afsock_pf_ops;
  // 创建要使用具体协议栈中操作集的 sock 结构体的实例
  // 创建的时候需要指定使用自定义的协议簇
  // 以及指定 ding_test_proto 也就是四层协议栈的类型
  sk = sk_alloc(net, PF_DINGTEST, GFP_KERNEL, &ding_test_proto, kern);
  if (!sk) return -ENOMEM;
  
  // 初始化 sock 和 sk 中的一些东西, 并让它俩能互相引用
  // 往下基本上可以说是一些固定的流程
  sock_init_data(sock, sk);
  sk->sk_protocol = proto;
  sk->sk_family   = PF_DINGTEST;
  return 0;
}

这个方法的实现和上面咱们在看源码的时候很类似,基本上就是一个固定的套路,创建 socket 结构体,之后把 socket 自己实现的 operations(上面有什么 sendto 之类的方法)和 socket 做绑定,但是这里的 sendto 之类的方法只是个壳子,真正的发送方法在真正的协议栈(注意是协议栈不是协议簇)上实现,所以这里的 socket 结构体其实也有点像是 vfs 一样是虚拟的一层:

// 这些操作集就是自定义协议簇的一些接口
static const struct proto_ops test_afsock_pf_ops = {
  .family      = PF_DINGTEST,
  .owner       = THIS_MODULE,
  .bind       = ding_test_afsock_bind,
  .setsockopt    = sock_common_setsockopt, // 这里用内核提供的默认方法就行
  .getsockopt    = sock_common_getsockopt, // 这里用内核提供的默认方法就行
  // 下边这三个是最主要的
  .release     = ding_test_afsock_release, // 关闭套接字时调用的
  .sendmsg     = ding_afsock_sendmsg, // 使用套接字发送时用
  .recvmsg     = ding_test_afsock_recvmsg, // 使用套接字接收时用
};

这里的大部分函数都是壳子:

// 可以自己提供一个 bind 方法, 这里提供一个假的 bind 方法
static int ding_test_afsock_bind(struct socket *sock, struct sockaddr *myaddr, int sockaddr_len) { printk("这里是 ding_test_afsock_bind 方法"); return 0; }

static int ding_test_afsock_release(struct socket *sock) {
  struct sock *sk = sock->sk;
  // sk_common_release 是内核提供用来析构的 socket 的方法
  if(sk) sk_common_release(sk);
  return 0;
}

int  ding_afsock_sendmsg(struct socket *sock, struct msghdr *m, size_t total_len) {
  struct sock *sk = sock->sk;
  printk("内核中 ding socket 发送消息");
  // 这里调用指定的四层协议栈的 sendmsg 函数
  // 也就是下面 ding_test_proto 中的 sendmsg
  sk->sk_prot->sendmsg(sk, m, total_len);
  return 0;
}

int ding_test_afsock_recvmsg(struct socket *sock, struct msghdr *m, size_t total_len, int flags) {
  struct sock *sk = sock->sk;
  // printk("内核中 ding socket 接收消息");
  // 这里调用指定的四层协议栈指定的 recvmsg 函数
  // 也就是下面 ding_test_proto 中的 recvmsg
  sk->sk_prot->recvmsg(sk, m, total_len, 1, 0 ,0);
  return 0;
}

对于其他的比如用作 release 析构的方法可以简单的调用一下内核提供的通用的析构方法。然后 sendmsg 和 recvmsg 都是一个壳子,目的是用来调用真正的 4 层协议栈实现的方法。

说完结 test_afsock_pf_ops 构体中的方法后我们回到 ding_test_afsock_create 中。

接下来通过 sk_alloc 方法创建 sock 结构体,这个 sk_alloc 是内核提供的,具体做了什么在上面讲过了,主要其实也就是把真正的四层协议栈和 sock 结构体做一个绑定。

下一步的 sock_init_data 用来绑定 socket 和 sock 这俩结构体。

之后把四层协议栈的一些属性和 sk 变量也就是 sock 结构体做一个绑定。

第四步,实现完自定义的套接字之后,我们别忘了在 sk_alloc 中要对 sk 结构和协议栈的 operations 进行绑定,这里我们作为 4 层协议栈给 sk_alloc 传进去的是 ding_test_proto 这个结构体,我们接下来需要实现这个结构体:

// 这里使用自定义的四层协议栈类型, 也可以直接使用 tcp 或者 udp 以及 ICMP 之类的
static struct proto ding_test_proto = {
  .name    = "DING_TEST",
  .owner    = THIS_MODULE,
  // 必须实现的方法有如下三个
  .unhash    = ding_test_proto_unhash, // 释放 socket 时调用
  // 下面俩函数中要进行具体的传输层协议栈的收发包实现
  .sendmsg    = ding_test_proto_sendmsg, // 发包时调用
  .recvmsg    = ding_test_proto_recvmsg, // 收包时调用
  .obj_size = sizeof(struct sock),
};

这里就不能像是协议簇中的 ops 一样只是实现个壳子了,这里需要真的实现 4 层协议栈的逻辑:

static void ding_test_proto_unhash(struct sock *sk) { printk("这里是 ding_test_proto_unhash 方法"); return; }

unhash 是在 socket 作为文件被释放的时候调用的,我们这里暂时不考虑这个,所以可以随便写点东西。

// 在套接字发送接收系统调用流程中,send/recv,sendto/recvfrom,sendmsg/recvmsg 最终都会使用内核中的 msghdr 来组织数据
// msg_iter 是 iov_iter 这样的一个结构体, 里面的信息就可以描述数据
static int ding_test_proto_sendmsg(struct sock *sk, struct msghdr *msg, size_t len) {
  // copy_from_iter_full 用来把 msg_iter 报文内容拷贝到自定义的 buffer 中
  if (!copy_from_iter_full(pkt_buf, len, &msg->msg_iter)) return -EFAULT;
  pkt_len = len;
  printk("内核发送: %s", pkt_buf);
  return 0;
}

这里我们可以简单实现一下,仅仅只是将用户从用户空间中发来的数据,做一个简单的拷贝,通过 copy_from_iter_full 方法,把数据拷贝到 pkt_buf 这个缓冲区中,这个 pkt_buf 就是个数组:

static char pkt_buf[2048] = {0};
static int pkt_len = 0;

然后来看 recvfrom 方法:

static int ding_test_proto_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int noblock, int flags, int *addr_len) {
  int n = 0;
  if(!pkt_len) return 0;
  
  // 通过 copy_to_iter 将自定义的 pkt_buf 这个 buffer 中的数据从内核空间拷贝到数据空间
  // 简单点说就是将 pkt_len 这么长字节数的 pkt_buf 中的数据, 拷贝到 msg_iter 这个迭代器指向的用户空间地址
  n = copy_to_iter(pkt_buf, pkt_len, &msg->msg_iter);
  printk("内核接收: %s", pkt_buf);
  if (n != pkt_len) return -EFAULT;
  pkt_len = 0;
  return n;
}

也是非常简单的实现,主要就是将 pkt_buf 这个缓冲区中的数据拷贝到用户空间中去。

到这儿基本上一个简单的自定义套接字代码我们就完成了,全部代码如下:

// SPDX-License-Identifier: GPL-2.0-or-later

#include <linux/socket.h>
#include <linux/module.h>
#include <linux/skbuff.h>
#include <linux/uio.h>

#include <net/sock.h>
static char pkt_buf[2048] = {0};
static int pkt_len = 0;

static int ding_test_proto_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int noblock, int flags, int *addr_len) {
  int n = 0;
  if(!pkt_len) return 0;
  
  // 通过 copy_to_iter 将自定义的 pkt_buf 这个 buffer 中的数据从内核空间拷贝到数据空间
  // 简单点说就是将 pkt_len 这么长字节数的 pkt_buf 中的数据, 拷贝到 msg_iter 这个迭代器指向的用户空间地址
  n = copy_to_iter(pkt_buf, pkt_len, &msg->msg_iter);
  printk("内核接收: %s", pkt_buf);
  if (n != pkt_len) return -EFAULT;
  pkt_len = 0;
  return n;
}

// 在套接字发送接收系统调用流程中,send/recv,sendto/recvfrom,sendmsg/recvmsg最终都会使用内核中的msghdr来组织数据
// msg_iter 是 iov_iter 这样的一个结构体, 里面的信息就可以描述数据
static int ding_test_proto_sendmsg(struct sock *sk, struct msghdr *msg, size_t len) {
  // copy_from_iter_full 用来把 msg_iter 报文内容拷贝到自定义的 buffer 中
  if (!copy_from_iter_full(pkt_buf, len, &msg->msg_iter)) return -EFAULT;
  pkt_len = len;
  printk("内核发送: %s", pkt_buf);
  return 0;
}

static void ding_test_proto_unhash(struct sock *sk) { printk("这里是 ding_test_proto_unhash 方法"); return; }
static int ding_test_afsock_bind(struct socket *sock, struct sockaddr *myaddr, int sockaddr_len) { printk("这里是 ding_test_afsock_bind 方法"); return 0; }

static int ding_test_afsock_release(struct socket *sock) {
  struct sock *sk = sock->sk;
  // sk_common_release 是内核提供用来析构的 socket 的方法
  if(sk) sk_common_release(sk);
  return 0;
}

int  ding_afsock_sendmsg(struct socket *sock, struct msghdr *m, size_t total_len) {
  struct sock *sk = sock->sk;
  printk("内核中 ding socket 发送消息");
  // 这里调用指定的四层协议栈的 sendmsg 函数
  // 也就是下面 ding_test_proto 中的 sendmsg
  sk->sk_prot->sendmsg(sk, m, total_len);
  return 0;
}

int ding_test_afsock_recvmsg(struct socket *sock, struct msghdr *m, size_t total_len, int flags) {
  struct sock *sk = sock->sk;
  // printk("内核中 ding socket 接收消息");
  // 这里调用指定的四层协议栈指定的 recvmsg 函数
  // 也就是下面 ding_test_proto 中的 recvmsg
  sk->sk_prot->recvmsg(sk, m, total_len, 1, 0 ,0);
  return 0;
}

// 这些操作集就是自定义协议簇的一些接口
static const struct proto_ops test_afsock_pf_ops = {
  .family      = PF_DINGTEST,
  .owner       = THIS_MODULE,
  .bind       = ding_test_afsock_bind,
  .setsockopt    = sock_common_setsockopt,
  .getsockopt    = sock_common_getsockopt,
  // 下边这三个是最主要的
  .release     = ding_test_afsock_release, // 关闭套接字时调用的
  .sendmsg     = ding_afsock_sendmsg, // 使用套接字发送时用
  .recvmsg     = ding_test_afsock_recvmsg, // 使用套接字接收时用
};

// 这里使用自定义的四层协议栈类型, 也可以直接使用 tcp 或者 udp 以及 ICMP 之类的
static struct proto ding_test_proto = {
  .name    = "DING_TEST",
  .owner    = THIS_MODULE,
  // 必须实现的方法有如下三个
  .unhash    = ding_test_proto_unhash, // 释放 socket 时调用
  // 下面俩函数中要进行具体的传输层协议栈的收发包实现
  .sendmsg    = ding_test_proto_sendmsg, // 发包时调用
  .recvmsg    = ding_test_proto_recvmsg, // 收包时调用
  .obj_size = sizeof(struct sock),
};

// net 是网络命令空间 task_struct -> nsproxy -> net_ns
static int ding_test_afsock_create(struct net *net, struct socket *sock, int proto, int kern) {
  struct sock *sk = NULL;
  
  // 将自定义协议簇的操作集赋给 socket 结构体
  sock->ops = &test_afsock_pf_ops;
  // 创建要使用具体协议栈中操作集的 sock 结构体的实例
  // 创建的时候需要指定使用自定义的协议簇
  // 以及指定 ding_test_proto 也就是四层协议栈的类型
  sk = sk_alloc(net, PF_DINGTEST, GFP_KERNEL, &ding_test_proto, kern);
  if (!sk) return -ENOMEM;
  
  // 初始化 sock 和 sk 中的一些东西, 并让它俩能互相引用
  // 往下基本上可以说是一些固定的流程
  sock_init_data(sock, sk);
  sk->sk_protocol = proto;
  sk->sk_family   = PF_DINGTEST;
  // 接收队列已经满了的时候会暂时先将报文放到 backlog_rcv 队列中
  sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;
  
  return 0;
}

static const struct net_proto_family ding_test_afsock_family_ops = {
  .owner  = THIS_MODULE,
  .family = PF_DINGTEST, // 这里是 include/linux/socket.h 中自己加的编号
  .create = ding_test_afsock_create, // 执行 socket 系统调用创建一个 socket 时候就会调用这个 create
};

int __init ding_test_afsock_init(void) {
  int rc = 0;
  // 注册自己实现的协议簇
  rc = sock_register(&ding_test_afsock_family_ops);
  if(rc) goto sock_failed;
  return 0;
  
sock_failed:
  sock_unregister(PF_DINGTEST);
  return rc;
}

void __exit ding_test_afsock_exit(void) {
  sock_unregister(PF_DINGTEST);
}
module_init(ding_test_afsock_init);
module_exit(ding_test_afsock_exit);

MODULE_LICENSE("GPL"); // 这个一定加, 不然编译完可能无法插入到内核

接下来我们来实现对应的用户空间中的测试代码,我们不用一些特别的实现,就直接把最最上面最开始我们用来做实验的小 demo 拿过来改叭改叭就 ok:

client 端:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <strings.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/udp.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
  int fd = socket(45, SOCK_DGRAM, 0);
  if (fd < 0) {
    printf("socket 执行失败, 没有文件描述符: %s\n", strerror(errno));
    return -1;
  }

  struct sockaddr_in addr = {
    .sin_family = 45, // 自定义的协议簇
    .sin_port = htons(3190), // 端口号
    .sin_addr.s_addr = htonl(INADDR_ANY) // 0.0.0.0
  };
  struct sockaddr *_addr = (struct sockaddr *)&addr;

  char buffer[256];
  while (1) {
    bzero(buffer, sizeof(buffer));
    //从标准输入设备取得字符串
    int len = read(STDIN_FILENO, buffer, sizeof(buffer));
    char *prompt = "3 秒后将发送数据\n";
    write(STDOUT_FILENO, prompt, strlen(prompt));
    sleep(3);
    sendto(fd, buffer, sizeof(buffer), 0, _addr, sizeof(addr));
    char *msg = "消息已发送\n";
    write(STDOUT_FILENO, msg, strlen(msg));
  }
  close(fd);
  return 0;
}

基本上就是把上上上面的 demo 中的协议簇的名字改成了我们自定义的那个协议簇的号,也就是 45,当然也可以写那个名字,这里我图省事儿直接写了 45。

server 端:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/udp.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
  int ret = 0;
  int fd = socket(45, SOCK_DGRAM, 0);
  if (fd < 0) {
    printf("socket() failed,%s\n", strerror(errno));
    return -1;
  }

  struct sockaddr_in addr = {
    .sin_family = 45,
    .sin_port = htons(3190),
    .sin_addr.s_addr = htonl(INADDR_ANY)
  };
  bind(fd, (struct sockaddr *)&addr, sizeof(addr));
  char buffer[256];
  while (1) {
    bzero(buffer, sizeof(buffer));
    struct sockaddr_in addr_client;
    socklen_t addr_len = sizeof(addr_client);
    ret = recvfrom(
      fd, buffer, sizeof(buffer), 0,
      (struct sockaddr *)&addr_client, &addr_len
    );
    if (strlen(buffer) != 0) {
      printf("从 client 端接收到: %s\n", buffer);
    }
    sleep(1);
  }
  close(fd);
  return 0;
}

server 端也一样,基本上和 demo 中没啥差别,也就是改了个协议簇的号而已。


现在我们已经自己实现了一个简单的协议簇,以及对应的测试用例,那么接下来该尝试让这几坨代码跑起来了,那怎么跑呢?

由于我们修改了内核的源代码,给里头多加了个 45 号协议簇,因此我们没有办法直接在一个现成 Linux 主机上跑,因此我们需要自己动手创建一个能跑起 Linux 内核的虚拟机。关于如何创建这样的一个虚拟机,可能会比较麻烦,所以大家可以直接参考之前的一篇文章:

《编译一个最小 Linux 系统》

这里头详细说明了如何使用 qemu 创建一个最小的 Linux 系统。


到这里,我假设大家已经能自己通过 qemu 运行一个 Linux 系统了,所以接下来我们就尝试编译刚刚上面写的那几坨代码:

首先我们需要一个 Makefile 文件(当然也不是必须的,只是为了稍微方便一点)

obj-m:=test_afsock.o

KDIR:=/home/ding/ding-os/lib/modules/5.14.15/build
PWD:=$(shell pwd)
all:
  $(MAKE) -C $(KDIR) M=$(PWD) modules
  gcc test_client.c -o test_client.o -static
  gcc test_server.c -o test_server.o -static

obj-m 表示让这个模块生成一个可独立插入的 .ko 结尾的模块。(注:如果用 obj-y 的话,可以直接将这个模块编译到内核中)

$(MAKE) -C $(KDIR) M=$(PWD) modules

这句话的意思是说,执行这个 makefile 的时候,先假装当前的工作目录是 $(KDIR) 这个目录,在这个目录下使用刚刚我们写的带有 module_init 代码的文件,执行 make modules 进行模块儿的编译。

下面两句 gcc 相关的就是把那俩测试用的 client 端的文件和 server 端的文件进行一下正常的编译,注意这里最后都加上 -static 让他们进行静态编译,不加的话编译完了扔到 qemu 里头可能会报一些乱七八糟找不到模块的问题。

写完这几个文件之后我们执行一下 make 进行编译:

编译完能看到产生了这么一大坨的文件,其中我们会用到 test_afsock.ko 和 test_client.o 以及 test_server.o

然后我们进入到我们可以启动 qemu 的那个目录中。这个目录在上面超链接的《编译一个最小 Linux 系统》 文章中详细介绍过:

为了方便重新配置 rootfs 以及一些其他相关的配置,我把重新挂载 rootfs,挂载磁盘等操作都写在了 remount 文件中,方便我一键重新配置:

其中上面几步都和那篇 《编译一个最小 Linux 系统》(zhuanlan.zhihu.com/p/424240082… cp 命令。一定要把刚才编译完的 test_afsock.ko 以及 test_client.o 还有 test_server.o 拷贝到这个 qemu 启动的 Linux 系统的 rootfs 根文件系统中。

最后都执行完之后就可以一键启动 qemu 了:

执行这个 cmd 脚本,里面就是关于 qemu 的一些基本配置,这些在上边那篇 《编译一个最小 Linux 系统》 都有说到。

执行之后我们就启动最小的 Linux 系统:

可以看到在主机上编译好的那几个文件都被干进来了已经。

然后我们要把 .ko 结尾的模块儿插入到内核中,通过 insmod 命令进行插入:

通过 lsmod 命令可以查看是否插入成功:

接下我们按照最最最上面的 demo 中的方式一样启动 server 端的代码和 client 的代码:

我们首先还是以 & 这种后台的方式启动 server 端代码。然后再以前台进程的方式启动 client 端代码:

可以看到客户端代码已经卡在终端这里了,这是因为在 client 端的代码实现中,我们要从终端输入中读取字符。接下来我们尝试输入点东西然后回车:

可以发现输出的东西已经可以按照我们的内核实现以及测试用例实现的逻辑跑通了,首先 client 端将从终端读取的数据在 3 秒后发送到内核中,然后 server 端由于每隔 1s 就调用一次 recvfrom,所以自然就可以从内核的那个 pkt_buf 缓冲中把数据读取出来并打印在终端上~


芜湖!写了这么长,终于搞定了,我们自己实现了一个可以跑在内核中的简单的私有 socket 协议簇!

这个过程还是挺麻烦的,踩坑也踩了不少,一边自己瞎搞,一边找大佬瞎问。不过看到最后跑起来的时候还是挺激动的哈哈~

总之这次咱们尝试自己在内核中对网络请求搞事情,希望之后如果大家碰到类似的需求或者类似的面试题,能够多少有些思路~

相关的代码我放到了 github 上:

github.com/y805939188/…

如果哪里有什么问题还请大佬们指出,感谢~