跳到主要内容

ch11_2

11.2 创建套接字(Creating Sockets)

上一节我们像看菜单一样扫过了 Socket 的基本类型。现在,让我们推开内核的大门,看看当你调用那个简单的 socket() 时,机器内部到底发生了什么。

这比看起来要复杂——因为内核需要处理好两件事:对上像个文件,对下像个网络协议端点。 为了同时搞定这两个截然不同的角色,内核把一个套接字拆成了两个结构体:struct socketstruct sock

这两个名字只差一个字母,这种「双胞胎」设计曾经让无数初学者(包括我)晕头转向。让我们把它们拆开来看。


两个结构体,两张脸

内核里代表套接字的其实有两个结构:

  1. struct socket:这是面向用户空间的接口,由 sys_socket() 创建。
  2. struct sock:这是面向网络层(L3) 的接口,它位于协议层内部,是协议无关的。

我们一个个看。先看 struct socket,这是内核给用户空间展示的「脸面」:

struct socket {
socket_state state;

kmemcheck_bitfield_begin(type);
short type;
kmemcheck_bitfield_end(type);

unsigned long flags;

. . .

struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};

这个结构虽然不长,但每一个字段都是关键:

  • state:套接字的状态。比如 SS_UNCONNECTED(未连接)或 SS_CONNECTED(已连接)。当创建一个 INET 套接字时,它的初始状态是 SS_UNCONNECTED(参见 inet_create() 方法)。一旦流套接字(如 TCP)成功连接到远程主机,状态就会变成 SS_CONNECTED。这些枚举值定义在 include/uapi/linux/net.h 里。
  • type:套接字类型。这和你调用 socket() 时传的第二个参数对应,比如 SOCK_STREAM(流)或 SOCK_DGRAM(数据报)。
  • flags:套接字标志位。比如 SOCK_EXTERNALLY_ALLOCATED,这个标志会在 TUN 设备分配套接字时设置,而不是通过普通的 socket() 系统调用(见 drivers/net/tun.c 里的 tun_chr_open())。
  • file:指向与该套接字关联的文件结构体。这也是为什么 Socket 可以用 read()/write() 操作的原因——它在内核眼里就是个文件。
  • sk:这是关键。它指向一个 struct sock 对象。struct sock 才是那个代表「网络层接口」的家伙。创建 Socket 时,内核会把这两个对象绑在一起。比如在 IPv4 的实现里,inet_create() 方法会分配一个 sock 对象(sk),并将其关联到当前的 socket 对象上。
  • ops:这是一个 proto_ops 结构体实例,里面装了一堆回调函数,比如 connectlistensendmsgrecvmsg 等。
    • 这些回调是用户空间接口的具体实现。比如,你在用户空间调用 write()send()sendto()sendmsg(),在内核里最终都会落到 ops->sendmsg 这个回调上。recvmsg 同理,它承载了 read()recv() 等一系列调用。
    • 每个协议都会定义自己的 proto_ops。对于 TCP,它的 proto_ops 里有真实的 inet_listen()inet_accept();但对于 UDP,它压根不需要 listenaccept,所以它的这两个回调被设为了 sock_no_listen()sock_no_accept()——这两个函数唯一的动作就是返回 -EOPNOTSUPP(操作不支持)。

深入网络层:struct sock

现在,我们转过头来看那个更底层、也更复杂的家伙——struct sock

如果说 struct socket 是「门面」,那 struct sock 就是「引擎房」。它是网络层(L3)对套接字的表示。这个结构体非常长,我们只挑和本章讨论最相关的字段看:

struct sock {
struct sk_buff_head sk_receive_queue;
int sk_rcvbuf;

unsigned long sk_flags;

int sk_sndbuf;
struct sk_buff_head sk_write_queue;
. . .
unsigned int sk_shutdown : 2,
sk_no_check : 2,
sk_protocol : 8,
sk_type : 16;
. . .

void (*sk_data_ready)(struct sock *sk, int bytes);
void (*sk_write_space)(struct sock *sk);
};
  • sk_receive_queue:接收队列。所有收到的数据包都会先挂在这里,等着被用户空间读走。
  • sk_rcvbuf:接收缓冲区的大小(字节)。
  • sk_flags:各种标志,比如 SOCK_DEADSOCK_DBG
  • sk_sndbuf:发送缓冲区的大小(字节)。
  • sk_write_queue:发送队列。准备发出的数据包会在这里排队。
    • ⚠️ 注意:在后面的「TCP Socket 初始化」一节,我们会详细聊这两个缓冲区是怎么初始化的,以及如何通过 /proc 接口修改它们。现在你只需要知道它们是存在的。

  • sk_no_check:禁用校验和标志。可以通过 SO_NO_CHECK socket 选项来设置。
  • sk_protocol:协议标识符。这个值是根据你调用 socket() 时的第三个参数来设置的。
  • sk_type:套接字类型(又是它,因为底层也需要知道你是流还是报)。
  • sk_data_ready:一个回调函数。当新数据到达时,内核会调用它来通知套接字「嘿,有货了」。
  • sk_write_space:一个回调函数。当发送缓冲区腾出空间了,内核会调用它通知「可以继续写了」。

动手时刻:socket() 系统调用

我们在纸上谈兵够久了,现在来看看实际的操作。你在用户空间是这样发起请求的:

sockfd = socket(int socket_family, int socket_type, int protocol);

这里三个参数分别代表什么?

  • socket_family(地址族):比如 AF_INET(IPv4)、AF_INET6(IPv6),或者本机通信用的 AF_UNIX
  • socket_type(套接字类型):SOCK_STREAM(流)、SOCK_DGRAM(数据报)或者 SOCK_RAW(原始套接字)。
  • protocol(协议):
    • 对于 TCP,传 0IPPROTO_TCP
    • 对于 UDP,传 0IPPROTO_UDP
    • 对于 Raw Socket,这里必须传一个明确的 IP 协议标识符(比如 IPPROTO_ICMP),参考 RFC 1700。

调用成功后,返回值 sockfd 就是一个文件描述符。以后你想操作这个 Socket,全靠它。

那么,内核是怎么接住这个调用的?它是由 sys_socket() 方法处理的。我们来看看它的源码片段(去掉了无关的 error handling):

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
int retval;
struct socket *sock;
int flags;

. . .
retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
goto out;
. . .
retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
if (retval < 0)
goto out_release;
out:
. . .
return retval;
}

这里发生的事其实很清晰:

  1. sock_create():它负责干活。它内部会根据地址族调用特定的创建方法。对于 IPv4,就是 inet_create()(参见 net/ipv4/af_inet.c)。
    • inet_create() 里,内核不仅分配了上面提到的 struct socket,还顺带把关联的 struct sock(也就是那个 sk 对象)给分配并初始化好了。
  2. sock_map_fd():分配完了总得给个凭证吧。这个方法返回一个文件描述符(fd),并把 fd 和刚才创建的 socket 结构关联起来。这个 fd,最终就是回到你手里的 sockfd

数据的旅行箱:struct msghdr

既然 Socket 创建好了,接下来就是收发数据。在内核里,处理发送和接收的核心函数分别是 sendmsg()recvmsg()。你会发现,这两个函数都极其依赖一个结构体:struct msghdr

你可以把它想象成数据在用户空间和内核之间穿梭时用的**「旅行箱」**。这个箱子里不仅装着真正的数据(msg_iov),还夹带着一些控制信息(比如你想用哪个网卡发、源 IP 是什么)。

struct msghdr {
void *msg_name; /* Socket name */
int msg_namelen; /* Length of name */
struct iovec *msg_iov; /* Data blocks */
__kernel_size_t msg_iovlen; /* Number of blocks */
void *msg_control; /* Per protocol magic (eg BSD file descriptor passing) */
__kernel_size_t msg_controllen; /* Length of cmsg list */
unsigned int msg_flags;
};

这个箱子里的每个隔层都有它的用武之地:

  • msg_name:目标套接字地址。这其实是个 void* 指针,用的时候通常要把它强转成 struct sockaddr_in* 才能拿到目标 IP 和端口(参见 udp_sendmsg() 里的用法)。
  • msg_namelen:地址的长度。
  • msg_iov:这是一个 iovec 向量,也就是真正装载数据的地方。它支持分散/聚集 I/O,意味着你可以把多个不连续的内存块一次性发出去,而不需要自己先拼起来。
  • msg_iovlen:有多少个数据块。
  • msg_control:辅助数据。这也就是所谓的「控制信息」,比如传递一些特殊的包选项(详见后面章节提到的 IP_PKTINFO)。
  • msg_controllen:辅助数据的长度。
    • ⚠️ 注意:内核能处理的辅助缓冲区长度是有限制的,这个限制由 sysctl_optmem_max 决定(路径在 /proc/sys/net/core/optmem_max)。别试图把一吨的控制信息塞进去。

  • msg_flags:接收消息时的标志位,比如 MSG_MORE(意思是「别急,后面还有数据,等等再发」)。

本章路标

好了,现在我们在内核里不仅有了 Socket,还知道了数据是怎么打包进 msghdr 的。准备工作已经做完了。

接下来,我们将正式进入传输层(L4)协议的实战。我们会从最简单的开始——UDP。它是所有协议里最直来直去的,没有握手,没有复杂的重传机制,非常适合作为我们理解内核网络子系统的第一个落脚点。