ch11_2
11.2 创建套接字(Creating Sockets)
上一节我们像看菜单一样扫过了 Socket 的基本类型。现在,让我们推开内核的大门,看看当你调用那个简单的 socket() 时,机器内部到底发生了什么。
这比看起来要复杂——因为内核需要处理好两件事:对上像个文件,对下像个网络协议端点。 为了同时搞定这两个截然不同的角色,内核把一个套接字拆成了两个结构体:struct socket 和 struct sock。
这两个名字只差一个字母,这种「双胞胎」设计曾经让无数初学者(包括我)晕头转向。让我们把它们拆开来看。
两个结构体,两张脸
内核里代表套接字的其实有两个结构:
struct socket:这是面向用户空间的接口,由sys_socket()创建。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结构体实例,里面装了一堆回调函数,比如connect、listen、sendmsg、recvmsg等。- 这些回调是用户空间接口的具体实现。比如,你在用户空间调用
write()、send()、sendto()或sendmsg(),在内核里最终都会落到ops->sendmsg这个回调上。recvmsg同理,它承载了read()、recv()等一系列调用。 - 每个协议都会定义自己的
proto_ops。对于 TCP,它的proto_ops里有真实的inet_listen()和inet_accept();但对于 UDP,它压根不需要listen和accept,所以它的这两个回调被设为了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_DEAD或SOCK_DBG。sk_sndbuf:发送缓冲区的大小(字节)。sk_write_queue:发送队列。准备发出的数据包会在这里排队。-
⚠️ 注意:在后面的「TCP Socket 初始化」一节,我们会详细聊这两个缓冲区是怎么初始化的,以及如何通过
/proc接口修改它们。现在你只需要知道它们是存在的。
-
sk_no_check:禁用校验和标志。可以通过SO_NO_CHECKsocket 选项来设置。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,传
0或IPPROTO_TCP。 - 对于 UDP,传
0或IPPROTO_UDP。 - 对于 Raw Socket,这里必须传一个明确的 IP 协议标识符(比如
IPPROTO_ICMP),参考 RFC 1700。
- 对于 TCP,传
调用成功后,返回值 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;
}
这里发生的事其实很清晰:
sock_create():它负责干活。它内部会根据地址族调用特定的创建方法。对于 IPv4,就是inet_create()(参见net/ipv4/af_inet.c)。- 在
inet_create()里,内核不仅分配了上面提到的struct socket,还顺带把关联的struct sock(也就是那个sk对象)给分配并初始化好了。
- 在
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。它是所有协议里最直来直去的,没有握手,没有复杂的重传机制,非常适合作为我们理解内核网络子系统的第一个落脚点。