细谈传输层TCP协议API接口的底层原理

在 TCP 编程中,我们常说:

  • listen() 将当前文件描述符设置为监听状态,可以接收外界的三次握手;
  • accept() 将建立成功的连接从全连接队列中取出。

这两句话背后藏着两个值得深究的问题:

  1. 文件描述符(fd)和内核中的 socket 对象是如何联系起来的?
  2. 我们常说 "端口号用来和进程绑定",实际情况真的是这样吗?

我们从第一个问题入手。


一、文件描述符与 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_operationsread/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 filestruct socket互相指向的:

  • file->private_datasocket
  • socket->filefile

为什么要双向指向?

  • 从用户态系统调用(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,内核才能:

  1. 选择对应的协议族操作函数集 proto_ops(IPv4 是 inet_stream_ops,IPv6 是 inet6_stream_ops);
  2. 选择对应的传输协议 proto(TCP 是 tcp_prot,UDP 是 udp_prot);
  3. 分配对应大小的内存(TCP 分配 tcp_sock 大小,UDP 分配 inet_sock 大小)。

打个比方:分层像是工厂里的流水线(每一层做自己的事),而 socket() 像是下订单时选择 "用哪条流水线"------ 你得先告诉工厂你要生产什么产品,工厂才能安排对应的流水线。这两件事不冲突。

后续我们会看到,正是因为创建时选定了协议族,收包时才能通过五元组 (源 IP + 源端口 + 目的 IP + 目的端口 + 协议)精确锁定到特定的 sock


二、TCP 服务端内核对象的创建与生命周期

2.1 socket ():创建 socket 与 tcp_sock

当服务端调用 socket(AF_INET, SOCK_STREAM, 0) 时,内核做了两件事:

  1. 创建一个 struct socket(BSD 层对象);

  2. 调用 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 + 端口绑定。内核会:

  1. 检查端口是否被占用(通过 bhash 绑定哈希表);
  2. 将 IP、端口写入 inet_sock 的对应字段;
  3. 如果端口可用,将 sock 加入 bhash

2.3 listen ():设置监听状态,分配半连接队列

listen(fd, backlog) 不只是 "设置一个状态标志",内核具体做了:

  1. sk->sk_state 设为 TCP_LISTEN
  2. 分配 struct inet_connection_sock 中的 icsk_accept_queue.listen_opt(即 struct listen_sock),其中包含半连接队列syn_table[] 哈希数组),用于存放 SYN_RCVD 状态的连接;
  3. 设置 sk_max_ack_backlog = backlog(全连接队列最大长度);
  4. 将监听 sock 加入 inet_hashinfolistening_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_queuerskq_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 socketsocket->sk = newsk
  • 创建一个新的 struct filefile->private_data = socketsocket->file = file
  • 在进程的文件描述符表中分配一个新 fd,指向这个新 file;
  • 将新 sock 加入 inet_hashinfoehash(已连接哈希表),这样后续数据包才能通过五元组找到它。

第四步:释放 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_sockinet_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 报文到达服务端时:

  1. 网卡 收到帧,驱动生成 struct sk_buff(skb),整个帧(以太网头 + IP 头 + TCP 头 + 载荷)都在 skb 的一块连续内存中;
  2. 链路层 剥掉以太网头(skb_pull 移动 data 指针,头部数据仍保留在内存中,通过 mac_header 偏移书签访问);
  3. IP 层 解析 IP 头,提取源 IP、目的 IP,查路由表确认是本机包,根据 protocol 字段(TCP=6)交给 TCP 模块;
  4. TCP 模块tcp_v4_rcv):
    • 从 skb 的 network_header 书签找回 IP 头,拿到源 IP、目的 IP;
    • 从 TCP 头拿到源端口、目的端口;
    • 组成五元组{协议, 源IP, 源端口, 目的IP, 目的端口}
    • 调用 __inet_lookup(),先用五元组算哈希值定位 ehash 桶,遍历桶内链表逐字段比对,找到对应的 struct sock
    • 如果 ehash 没找到(可能是新连接的 SYN),再查 listening_hash,找到监听 sock;
  5. 找到 sock 后,将 skb 挂入 sock->sk_receive_queue(该 sock 的接收缓冲区);
  6. 用户态进程调用 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,天然分开。


四、总结

  1. fd 与 socket 的关联fd → 进程文件描述符表 → struct file → file->private_data → struct socket → socket->sk → struct sockfilesocket 互相指向,socketsock 互相指向,形成完整的对象链。
  2. 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
  3. 端口号的真实作用 :端口号不直接绑定进程,而是绑定在 sock 上。收包时内核通过五元组inet_hashinfo 哈希表,定位到特定 sock,数据包进入该 sock 的接收缓冲区。进程只是通过 fd 持有这个 sock。
  4. C 语言模拟继承tcp_sockinet_connection_sockinet_socksock,每层第一个成员地址重合,通过指针强转实现 "继承" 效果,内核用 tcp_sk() / inet_csk() / inet_sk() 等宏封装转换。

这就是 TCP 连接在内核中的完整对象模型,也是 "Linux 一切皆文件" 在网络子系统中的具体体现。

相关推荐
Zguigo1 小时前
lesson27-29计算机网络第三章精讲:交换机与VLAN的工作原理、区别与实战应用
网络·计算机网络·负载均衡
网络设计ensp1 小时前
商超网络设计
网络·学习·安全·智能路由器·ensp·华为网络设计
小玮看世界1 小时前
[Python]从40分到100分:一场关于编程思维的“辩论赛”
运维·服务器
goyeer1 小时前
Liunx日志管理与journalctl
java·linux·运维·服务器·运维开发·信息化·信息化企业管理
酷可达拉斯1 小时前
自动化运维-Ansible Role综合应用案例-基于LNMP部署wordpress
linux·运维·服务器·自动化·ansible
Dovis(誓平步青云)2 小时前
从Redis指标采集到异常告警:redis_exporter + Prometheus 完整实战
服务器·数据库·人工智能·redis·架构·prometheus·vibe coding
垂钓的小鱼14 小时前
用关联图谱做量化选股:让基本面惊喜沿着网络扩散
服务器·php·apache
xixiaoyunya10 小时前
内网穿透工具选型分析:从 ngrok 的局限性看国内开发场景下的替代方案
网络
Lonely 净土10 小时前
Linux 运维文件写入操作
linux·运维·服务器·文件管理