在 TCP 编程中,我们常说:
listen()将当前文件描述符设置为监听状态,可以接收外界的三次握手;accept()将建立成功的连接从全连接队列中取出。
这两句话背后藏着两个值得深究的问题:
- 文件描述符(fd)和内核中的 socket 对象是如何联系起来的?
- 我们常说 "端口号用来和进程绑定",实际情况真的是这样吗?
我们从第一个问题入手。
一、文件描述符与 socket 的关联
1.1 完整链路:fd → file → socket → sock
要回答 "fd 如何找到 socket",必须把整条链路走完:
进程的 fd
→ current->files->fd_array[fd] (进程文件描述符表)
→ struct file * (VFS 文件对象)
→ file->private_data (void* 通用指针)
→ struct socket * (BSD socket 层对象)
→ socket->sk (INET 协议栈对象)
→ struct sock * (可强转为 tcp_sock)
用户态拿到的只是一个整数 fd,内核通过进程的文件描述符表 fd_array 定位到 struct file,再通过 file->private_data 找到 struct socket,最终通过 socket->sk 找到协议栈的 struct sock。
1.2 struct file:一切皆文件的载体
struct file {
...
const struct file_operations *f_op; // 文件操作函数表
atomic_long_t f_count; // 引用计数
...
void *private_data; // ✅ 关键:指向 socket
...
};
重点关注 void *private_data。这是一个通用指针,VFS 层不关心它指向什么,由具体文件系统 / 设备驱动自行赋值。对于 socket 文件,内核在创建时将 private_data 指向 struct socket。
这就是 "Linux 一切皆文件" 的具体体现:socket 本质上也是一个文件,它有自己的
struct file,有自己的file_operations(read/write/poll等被映射到 socket 的收发操作),用户态用操作文件的方式操作 socket。
1.3 struct socket:BSD 层与协议栈的桥梁
struct socket {
socket_state state;
short type; // SOCK_STREAM / SOCK_DGRAM
unsigned long flags;
struct socket_wq *wq; // 等待队列(阻塞 IO 用)
struct file *file; // ✅ 反向指向 file
struct sock *sk; // ✅ 指向协议栈 sock
const struct proto_ops *ops; // 协议族操作函数集
};
struct file 和 struct socket 是互相指向的:
file->private_data→socketsocket->file→file
为什么要双向指向?
- 从用户态系统调用(
read(fd, ...))出发:fd → file → private_data → socket → sk,找到协议栈对象。 - 从内核协议栈出发(数据到达,需要唤醒阻塞的进程):sk → socket → file → 找到等待队列,唤醒进程。
1.4 为什么 socket () 要指定协议类型?这不是和分层矛盾吗?
你可能会问:网络协议不是分层的吗?为什么 socket(AF_INET, SOCK_STREAM, 0) 要在创建时就指定 IP 类型和传输层类型?这不是把各层 "揉在一起" 了吗?
这并不矛盾。 分层是协议栈内部数据处理 的层次划分(链路层→IP 层→传输层→应用层),而 socket() 是用户态创建通信端点的统一入口。
Linux 的 socket API 支持多种协议族:AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本地域套接字)、AF_NETLINK(内核通信)等等。不同协议族的地址格式、头部结构、路由逻辑完全不同。创建时必须指定 family,内核才能:
- 选择对应的协议族操作函数集
proto_ops(IPv4 是inet_stream_ops,IPv6 是inet6_stream_ops); - 选择对应的传输协议
proto(TCP 是tcp_prot,UDP 是udp_prot); - 分配对应大小的内存(TCP 分配
tcp_sock大小,UDP 分配inet_sock大小)。
打个比方:分层像是工厂里的流水线(每一层做自己的事),而
socket()像是下订单时选择 "用哪条流水线"------ 你得先告诉工厂你要生产什么产品,工厂才能安排对应的流水线。这两件事不冲突。
后续我们会看到,正是因为创建时选定了协议族,收包时才能通过五元组 (源 IP + 源端口 + 目的 IP + 目的端口 + 协议)精确锁定到特定的 sock。
二、TCP 服务端内核对象的创建与生命周期
2.1 socket ():创建 socket 与 tcp_sock
当服务端调用 socket(AF_INET, SOCK_STREAM, 0) 时,内核做了两件事:
-
创建一个
struct socket(BSD 层对象); -
调用
inet_create(),分配一块内存,布局如下:┌──────────────────────────────────────────┐
│ struct tcp_sock │
│ struct inet_connection_sock inet_conn; │ ← 第一个成员
│ struct inet_sock icsk_inet; │ ← 第一个成员
│ struct sock sk; │ ← 第一个成员
│ ... TCP 私有字段(序列号、拥塞窗口等) │
└──────────────────────────────────────────┘
inet_create() 返回的是 struct sock *(指向这块内存的起始地址,也就是 sk 的位置),然后 socket->sk = 这个指针。
C 语言模拟继承的关键 :由于 tcp_sock 的第一个成员是 inet_connection_sock,其第一个成员是 inet_sock,其第一个成员是 sock,它们的首地址完全重合。因此:
struct sock *sk = ...;
struct tcp_sock *tp = (struct tcp_sock *)sk; // ✅ 直接强转
struct inet_connection_sock *icsk = (struct inet_connection_sock *)sk; // ✅
struct inet_sock *inet = inet_sk(sk); // ✅ 宏封装
内核提供了一系列内联宏来做这种转换:
tcp_sk(sk)→struct tcp_sock *inet_csk(sk)→struct inet_connection_sock *inet_sk(sk)→struct inet_sock *
2.2 bind ():绑定 IP + 端口
bind() 将 socket 与特定的本地 IP + 端口绑定。内核会:
- 检查端口是否被占用(通过
bhash绑定哈希表); - 将 IP、端口写入
inet_sock的对应字段; - 如果端口可用,将 sock 加入
bhash。
2.3 listen ():设置监听状态,分配半连接队列
listen(fd, backlog) 不只是 "设置一个状态标志",内核具体做了:
- 将
sk->sk_state设为TCP_LISTEN; - 分配
struct inet_connection_sock中的icsk_accept_queue.listen_opt(即struct listen_sock),其中包含半连接队列 (syn_table[]哈希数组),用于存放 SYN_RCVD 状态的连接; - 设置
sk_max_ack_backlog = backlog(全连接队列最大长度); - 将监听 sock 加入
inet_hashinfo的listening_hash(监听哈希表),这样后续收到 SYN 时才能找到它。
注意:只有 LISTEN 状态的 socket 才有
listen_opt(半连接队列),普通已连接 socket 的这个指针是 NULL。
2.4 三次握手:request_sock 的创建与流转
这是原文最需要修正的地方。request_sock 不是在三次握手成功时才创建的,完整时序是:
第一步:服务端收到客户端 SYN
- 内核在监听 socket 的半连接队列(
listen_opt->syn_table)中创建一个struct request_sock; - 从 SYN 报文中提取客户端 IP、客户端端口、本机 IP、本机端口、MSS、时间戳、SACK、窗口缩放 等信息,全部存入
request_sock; - 此时
request_sock状态为 SYN_RCVD,服务端回复 SYN+ACK。
第二步:服务端收到客户端 ACK(三次握手完成)
-
内核从半连接队列中找到对应的
request_sock; -
将其从半连接队列移除,移入全连接队列 (
icsk_accept_queue的rskq_accept_head/tail链表); -
此时
request_sock仍然是一个轻量半成品 ,它不是完整的tcp_sock,没有收发缓冲区,只保存握手信息。struct request_sock {
struct sock *rsk_listener; // 指向父监听 socket
__be32 rsk_daddr; // ✅ 对端 IP
__be32 rsk_saddr; // 本端 IP
__be16 rsk_dport; // ✅ 对端端口
__be16 rsk_sport; // 本端端口
// MSS、时间戳、SACK、窗口缩放等握手协商参数
...
};
为什么用 request_sock 而不是直接创建 tcp_sock? 因为高并发下,半连接队列可能同时存在大量尚未完成握手的连接。
tcp_sock非常大(包含序列号、拥塞窗口、RTT、SACK 等大量字段),而request_sock很小。握手阶段用轻量的request_sock可以大幅节省内存,直到accept()真正需要处理连接时才分配完整的tcp_sock。
2.5 accept ():取出 request_sock,创建新连接
用户调用 accept(listen_fd, &addr, &addrlen) 时,内核做了以下事情:
第一步:从全连接队列取出 request_sock
- 通过
listen_fd找到监听 socket 的sock; - 从
inet_csk(sk)->icsk_accept_queue的全连接链表头部取出一个request_sock。
第二步:创建完整的 tcp_sock(核心)
-
调用
tcp_create_openreq_child(),分配一块全新的内存,布局就是前面讲过的tcp_sock → inet_connection_sock → inet_sock → sock; -
将
request_sock中保存的信息拷贝到新创建的 sock 中:// net/ipv4/tcp_minisocks.c 中的关键逻辑(简化版)
struct sock *tcp_create_openreq_child(struct sock *sk,
struct request_sock *req,
struct sk_buff *skb)
{
// 分配完整的 tcp_sock(内含 inet_connection_sock / inet_sock / sock)
struct sock *newsk = inet_csk_alloc(sk->sk_family, GFP_ATOMIC);
struct inet_sock *newinet = inet_sk(newsk);// ✅ 从 request_sock 拷贝对端 IP 和端口到新 inet_sock newinet->inet_daddr = req->rsk_daddr; // 对端 IP newinet->inet_dport = req->rsk_dport; // 对端端口 newinet->inet_saddr = req->rsk_saddr; // 本端 IP newinet->inet_sport = req->rsk_sport; // 本端端口 // ✅ 拷贝握手协商好的 MSS、时间戳、SACK、窗口缩放 tcp_sk(newsk)->rx_opt = req->rsk_rx_opt; // ... 其他初始化 newsk->sk_state = TCP_ESTABLISHED; return newsk;}
这就回答了你之前的疑问:accept 之后新 socket 的对端 IP 信息,是从 request_sock 里拷贝过来的 。request_sock 在收到 SYN 时就已经把对端 IP / 端口存好了,accept 时只是做一次信息转移。
第三步:创建新的 socket 和 file,分配新 fd
- 创建一个新的
struct socket,socket->sk = newsk; - 创建一个新的
struct file,file->private_data = socket,socket->file = file; - 在进程的文件描述符表中分配一个新 fd,指向这个新 file;
- 将新 sock 加入
inet_hashinfo的ehash(已连接哈希表),这样后续数据包才能通过五元组找到它。
第四步:释放 request_sock
- 临时的
request_sock完成了它的使命,被kfree释放。
最终,accept() 返回新 fd 给用户态。监听 socket 本身完全不受影响,继续监听新的连接。
至此,一条 TCP 连接在内核中的完整对象体系就建立了:
新fd → 新file → 新socket → 新sock(tcp_sock)而这个新 tcp_sock 中的对端 IP / 端口,正是从 request_sock 拷贝而来。
三、端口号的真实作用:不是绑定进程,而是映射 sock
3.1 纠正误解:端口号不直接绑定进程
我们常说 "端口号标识一个进程",这是一个宏观现象的简化描述,不是内核的实际机制。
真实情况是:
- 端口号绑定在
sock上 (通过bind()写入inet_sock的inet_num字段); sock通过socket → file → fd被进程持有;- 一个进程可以持有多个 sock,占用多个端口;
- 多个进程也可以通过
SO_REUSEPORT共享同一个端口(内核做负载均衡)。
所以,端口号和进程之间没有直接的映射关系 ,中间隔着 sock 这一层。
3.2 inet_hashinfo:用五元组查找 sock
TCP 和 UDP 各自维护一个全局的 struct inet_hashinfo 实例(tcp_hashinfo / udp_table),它是所有 sock 的 "总目录":
struct inet_hashinfo {
struct inet_ehash_bucket *ehash; // 已连接 sock 哈希表
struct inet_listen_hashbucket *listening_hash; // 监听 sock 哈希表
struct inet_bind_bucket **bhash; // bind 端口冲突检测表
};
| 哈希表 | 存什么 | 查找 key |
|---|---|---|
ehash |
所有已建立连接的 sock | 完整五元组 |
listening_hash |
所有 LISTEN 状态的 sock | 本地 IP + 本地端口 |
bhash |
已被 bind 的端口 | 本地端口 |
注意:
inet_hashinfo本身不存储五元组 ,它存储的是 sock 的哈希表。五元组字段(源 IP、源端口、目的 IP、目的端口)存储在每个sock内嵌的sock_common中,五元组只是作为查找 key 用来计算哈希值、定位桶、再在桶内逐字段比对。
3.3 收包完整路径:从网卡到 sock 接收缓冲区
当一个 TCP 报文到达服务端时:
- 网卡 收到帧,驱动生成
struct sk_buff(skb),整个帧(以太网头 + IP 头 + TCP 头 + 载荷)都在 skb 的一块连续内存中; - 链路层 剥掉以太网头(
skb_pull移动data指针,头部数据仍保留在内存中,通过mac_header偏移书签访问); - IP 层 解析 IP 头,提取源 IP、目的 IP,查路由表确认是本机包,根据
protocol字段(TCP=6)交给 TCP 模块; - TCP 模块 (
tcp_v4_rcv):- 从 skb 的
network_header书签找回 IP 头,拿到源 IP、目的 IP; - 从 TCP 头拿到源端口、目的端口;
- 组成五元组 :
{协议, 源IP, 源端口, 目的IP, 目的端口}; - 调用
__inet_lookup(),先用五元组算哈希值定位ehash桶,遍历桶内链表逐字段比对,找到对应的struct sock; - 如果
ehash没找到(可能是新连接的 SYN),再查listening_hash,找到监听 sock;
- 从 skb 的
- 找到 sock 后,将 skb 挂入
sock->sk_receive_queue(该 sock 的接收缓冲区); - 用户态进程调用
read(fd, buf)/recv(fd, buf),通过 fd → file → socket → sk 找到这个 sock,将sk_receive_queue中的数据拷贝到用户空间。
接收缓冲区和发送缓冲区都维护在内核内存中 ,是
struct sock里的sk_buff_head链表(sk_receive_queue/sk_write_queue),不是用户进程的内存。每个 sock 有自己独立的收发缓冲区。
3.4 为什么必须用五元组,端口号不够?
服务端 8080 端口同时服务两个客户端:
- 客户端 A:
10.0.0.5:50001 - 客户端 B:
10.0.0.6:49152
两个报文的目的端口都是 8080,如果只凭端口号,只能找到 "8080 端口对应的那一组 sock",但区分不开 A 和 B 各自的连接。必须追加对端 IP + 对端端口,组成完整五元组,才能定位到各自独立的 sock,进而进入各自独立的接收缓冲区。
这就是为什么
socket()创建时要指定协议类型 ------ 协议类型(TCP/UDP)本身就是五元组的组成部分之一,TCP 和 UDP 各自有独立的hashinfo,天然分开。
四、总结

- fd 与 socket 的关联 :
fd → 进程文件描述符表 → struct file → file->private_data → struct socket → socket->sk → struct sock。file和socket互相指向,socket和sock互相指向,形成完整的对象链。 - TCP 服务端对象生命周期 :
socket():创建socket+tcp_sock(内嵌inet_connection_sock → inet_sock → sock);bind():绑定本地 IP + 端口;listen():设置 LISTEN 状态,分配半连接队列,加入监听哈希表;- 收到 SYN :创建
request_sock(轻量半成品,存对端 IP / 端口 / 握手参数),放入半连接队列; - 三次握手完成 :
request_sock从半连接队列移入全连接队列; accept():取出request_sock,创建新的tcp_sock+socket+file+ fd,将request_sock中的对端 IP / 端口拷贝到新 sock ,释放request_sock。
- 端口号的真实作用 :端口号不直接绑定进程,而是绑定在
sock上。收包时内核通过五元组 查inet_hashinfo哈希表,定位到特定sock,数据包进入该 sock 的接收缓冲区。进程只是通过 fd 持有这个 sock。 - C 语言模拟继承 :
tcp_sock→inet_connection_sock→inet_sock→sock,每层第一个成员地址重合,通过指针强转实现 "继承" 效果,内核用tcp_sk()/inet_csk()/inet_sk()等宏封装转换。
这就是 TCP 连接在内核中的完整对象模型,也是 "Linux 一切皆文件" 在网络子系统中的具体体现。