欢迎光临
我们一直在努力

计算机网络篇19:传输层——套接字在内核的对象链,从 fd 到 tcp_sock 的四层嵌套

在这里插入图片描述


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统 ⚙️ C++
📊 数据结构与算法 🐍 Python
🔗 LangChain & LangGraph 🗄️ MySQL 数据库
🌿 Git 工具 🌐 计算机网络
🤖 LLM 💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言: 篇18 把窗口、重传、拥塞控制讲完,传输层的机制面收齐了。还剩最后一件事:socket() 返回的那个 fd,在内核里到底牵着哪些对象。这篇顺着 fd 一路往下摸,把 file、socket、sock、sk_buff、tcp_sock 串成一条链,传输层到此收官。


一、fd 摸进内核的第一站:struct file

1.1 从 fd 表到 struct file

  • socket() 返回一个 int,这个 int 在进程手里就叫文件描述符(fd)。它本身不存任何东西,只是个数组下标。
  • 每个进程由 task_struct 描述,里面有一个 files 指针指向 files_struct;files_struct 里藏着 fd 表,老内核里是一张定长数组 fd_array[],新内核由 fdtable 管理,数组内容是 struct file *。
  • task_struct → files_struct → fd 表 → struct file,这条链是「一切皆文件」在数据结构上的样子:磁盘文件和套接字混在同一张表里,下标一样是 int,指向一样是 file。
  • 三个结构的主要成员(简化自内核源码,成员顺序有精简):
  • struct task_struct { // 进程控制块,内核描述一个进程的总部
    /* …成百上千个成员,这里只看文件相关… */
    struct files_struct *files; // 指向该进程打开文件信息的指针
    };

    struct files_struct { // 进程打开的所有文件的总账本
    struct fdtable __rcu *fdt; // 指向 fd 表(可扩容的指针数组)
    struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 默认 fd 表,小进程直接用这块
    };

    struct file { // 内核对「一个被打开的文件」的描述
    const struct file_operations *f_op; // 操作方法集:read/write/poll 等函数指针
    void *private_data; // 私有数据指针,指向具体设备的私有对象
    fmode_t f_mode; // 打开模式:可读、可写
    loff_t f_pos; // 当前读写偏移(文件位置)
    atomic_long_t f_count; // 引用计数,多少个 fd 在共用这个 file
    };

    在这里插入图片描述

  • 白话:fd 是门牌号,struct file 才是房间。拿门牌号去查表,一下就摸到房间;房间里有张名片夹(f_op)写着「这类房间的事找谁办」,还有一把万能钥匙孔(private_data)。
  • 结论:fd 是下标,file 是实体;查表动作只有一次指针跳转,这就是「一切皆文件」能落到代码里的前提。

    1.2 private_data:文件部分和网络部分的接头

    在这里插入图片描述

  • private_data 是 void *,它指向谁,这个 file 就「接」到谁身上:打开磁盘文件,它指向文件系统的私有结构;打开套接字,它指向 struct socket。
  • 配套的 f_op 同样可换:磁盘文件指向磁盘读写方法,套接字指向 socket_file_ops,read/write 这类调用就被引到网络协议栈。
  • struct socket 里有一个 struct file *file 回指 file,两边互相握手,谁都能找到谁。
  • 白话:private_data 是个万能插座,插上谁就跟谁走;回指指针相当于插座背面也留了根线,网络那边随时能顺着摸回文件这边。 在这里插入图片描述
  • 结论:struct file 是统一接口层,private_data 是它伸向具体设备的钩子;钩住 socket,read/write 就通到了网络。

    在这里插入图片描述

    二、struct socket 与 struct sock:一个管接口,一个管协议

    2.1 struct socket 主要成员

  • 先看源码(简化自内核 include/linux/net.h,成员有删减):
  • struct socket { // BSD socket 层的套接字,一个 fd 对应一个
    socket_state state; // 套接字状态:未连接、正在连接、已连接等
    short type; // 类型:SOCK_STREAM(字节流)/ SOCK_DGRAM(数据报)
    unsigned long flags; // 标志位
    struct file *file; // 回指所属的 struct file,两头互相找得到
    struct sock *sk; // 指向协议层状态对象,真正干活的在下面
    const struct proto_ops *ops; // 协议操作表:bind/connect/accept 等函数指针
    struct socket_wq wq; // 等待队列,阻塞在这个 socket 上的进程挂这里
    };

  • 白话:struct socket 是总台接待,登记「什么类型、什么状态、走哪套服务流程」;具体干活的是它身后那个 sk。
  • 它非常薄:收发数据、维护队列、协议状态机,一件都不亲自做,全部转给 struct sock。这就是 BSD socket 层与 INET 协议层的分界线——socket 层保证各种协议族(INET、UNIX 域、NETLINK)对外长得一样,协议层各自实现细节。
  • 结论:struct socket 管接口与身份,struct sock 管队列与协议;一个薄一个厚,薄的那层是为了把「统一接口」和「具体协议」解耦。

    2.2 等待队列:读不到数据时进程去哪

  • 进程调 read 读一个 TCP 套接字,sk_receive_queue 里没有数据:内核不会让 CPU 空转,而是把该进程的 PCB 挂进这个 socket 的等待队列(wq),进程状态改成可中断睡眠,让出 CPU。
  • 数据到达的链路:网卡收到帧 → DMA 写入内存 → 硬件中断,上半部只做简单标记 → 软中断里协议栈逐层剥头 → 传输层按四元组查哈希表直接定位到 struct sock → 数据包成 sk_buff 挂上 sk_receive_queue → 回调 sk_data_ready 唤醒等待队列上的进程 → 进程进运行队列 → 再次调度到它时 read 把数据拷给用户缓冲区。
  • 要纠一个常见误解:中断里不是「检测所有打开的套接字」,而是拿报文的四元组去查已连接哈希表,一步定位到唯一的 sock。扫全表既慢也不必要。
  • 白话:等外卖不是站在门口扫整条街,外卖员(中断)拿着地址(四元组)直接敲你家门;门牌(哈希表)早把「这个四元组住在哪个 sock」登记好了。 在这里插入图片描述
  • 结论:等待队列是阻塞 IO 的实现底座;「谁的数据到了就唤醒谁」靠的是哈希表按四元组定位,不是遍历。

    2.3 struct sock 主要成员

  • 源码(简化自内核源码文件 include/net/sock.h,只留关键成员):
  • struct sock { // 协议层的套接字,收发数据真正发生在它身上
    struct sock_common __sk_common; // 公共部分(网络地址等,各协议族共用)
    struct sk_buff_head sk_receive_queue; // 接收队列:到达的报文排队挂这里
    struct sk_buff_head sk_write_queue; // 发送队列:待发出的报文排队挂这里
    int sk_state; // TCP 状态机:ESTABLISHED、LISTEN 等
    unsigned long sk_flags; // 标志位
    struct socket *sk_socket; // 回指 BSD 层的 struct socket
    const struct proto *sk_prot; // 协议操作函数表(TCP 一套、UDP 一套)
    atomic_t sk_refcnt; // 引用计数
    int sk_err; // 错误码
    };

  • 白话:struct sock 是仓库加状态机。收到的报文一个一个挂在接收队列上,要读就从队头摘;TCP 的连接状态、滑动窗口都记在它(以及它的派生结构)身上。
  • UDP 没有发送队列这层排队语义:它发出去就发出去,不攒着重传;收方就是一个一个 sk_buff 节点往上拿。
  • 读路径收拢:task_struct → files_struct → fd 表 → struct file → private_data → struct socket → sk → struct sock → sk_receive_queue → 有效载荷拷给上层。
  • 对称的写路径一句带过:write 把用户数据包成 sk_buff 挂上 sk_write_queue,协议栈从队列取走、逐层加头往外发;TCP 发出的包收到 ACK 前要留底,丢了靠它重传。
  • 结论:struct sock 管队列与协议状态;读路径七站接力,每一站都是一个结构体成员指针在带路。

    2.4 sk_buff:报文的马甲

  • 队列里排的不是裸报文,是 struct sk_buff。它不是数据本身,更像一张快递单:真实货(报文数据)装在另一块缓冲区里,单子上写清楚货在哪、多大、各层怎么处理。
  • 主要成员(简化自内核 include/linux/skbuff.h):
  • struct sk_buff { // 报文的描述结构,全内核网络子系统的通用货币
    struct sk_buff *next; // 双向链表后继:串成收/发队列
    struct sk_buff *prev; // 双向链表前驱
    unsigned char *head; // 数据缓冲区的起点(整个包裹的边界)
    unsigned char *data; // 当前有效数据的起点(会随各层加头/去头移动)
    unsigned char *tail; // 当前有效数据的终点
    unsigned char *end; // 数据缓冲区的终点(容量边界)
    struct sock *sk; // 这个报文属于哪个 sock(归属指针)
    unsigned int len; // 报文总长度
    };

  • 白话:head 到 end 是整个包裹的纸箱,data 到 tail 是箱里现在真正装货的那段。发数据往下走一层,data 指针往前挪、腾出位置加一层协议头,像穿衣之前先留好袖口;收数据往上走一层,data 往后挪、把头剥掉。整条协议栈跑完,纸箱没换过,动的只有单子上的指针。
  • 因为有 next/prev,sk_buff 天生就是队列元素;同一份数据从链路层一路送到应用层,只挪指针、不拷贝数据。
  • 收回理论:sk_buff 是内核对「报文 + 各层元信息」的统一封装;用一层结构体描述所有层的包,避免了层间传递时的反复拷贝。
  • 在这里插入图片描述

    结论:队列里排的是 sk_buff 不是裸报文;head/data/tail/end 四个指针让「层层穿脱衣服」退化成零拷贝的指针移动。

    三、四层嵌套:sock → inet_sock → inet_connection_sock → tcp_sock

    3.1 为什么要叠四层

  • 直接正面回答那道思考题——sock 和 inet_sock 明明总是一起创建,为什么还要拆成两个结构体?
  • 答案的核心一句话:一起创建不等于需求相同。拆开不是浪费,是按协议族分层装配:很多协议根本用不到 IP 字段,unix 域套接字不走 IP 栈,ICMP 连端口都没有;如果把这些协议和 IP 协议全塞进一个大结构体,每类套接字都要背上用不上的字段,内存全变成无效负重。
  • 分层就是按需装配:sock 是所有协议族共用的底座(队列、状态、回调、引用计数),任何协议族都要;inet_sock 在其上补 IP 协议族字段;再往上按「是否面向连接、是否 TCP」继续细化。
  • 各层成员(简化自内核源码,字段有取舍):
  • struct inet_sock { // IP 协议族在 sock 之上补的字段
    struct sock sk; // 第一个成员:内嵌 sock 本体(不是指针!)
    __be32 inet_saddr; // 本端 IP 地址(源地址)
    __be32 inet_daddr; // 对端 IP 地址(目的地址)
    __be16 inet_sport; // 本端端口(源端口)
    __be16 inet_dport; // 对端端口(目的端口)
    };

    struct inet_connection_sock { // 面向连接协议的公共字段层
    struct inet_sock icsk_inet; // 第一个成员:内嵌 inet_sock
    struct request_sock_queue icsk_accept_queue; // accept 队列,握手完成的连接排队等 accept
    struct timer_list icsk_retransmit_timer; // 重传定时器
    /* …ACK 状态、重传次数等连接跟踪字段… */
    };

    struct tcp_sock { // TCP 独有字段,窗口与序号都在这层
    struct inet_connection_sock inet_conn; // 第一个成员:内嵌 inet_connection_sock
    /* …连接四元组之外的 TCP 状态… */
    u32 snd_una; // 最早未被确认的字节序号
    u32 snd_nxt; // 下一个要发送的字节序号
    u32 rcv_nxt; // 期望收到的下一个字节序号
    u32 snd_wnd; // 发送窗口
    u32 snd_cwnd; // 拥塞窗口
    u32 snd_ssthresh; // 慢启动阈值
    };

    struct udp_sock { // UDP 版:比 TCP 少一层连接字段
    struct inet_sock inet; // 第一个成员:直接内嵌 inet_sock
    /* …UDP 少量私有字段… */
    };

    在这里插入图片描述

  • 读这张叠起来的图要抓一件事:每一层的第一个成员都是上一层的对象本体,不是指针。「内嵌」意味着内存上直接叠:分配一个 tcp_sock,等于一次性把 inet_connection_sock、inet_sock、sock 四块全叠在同一块连续内存里。
  • 白话:这不是四个仓库来回调货,是一栋四层楼一次浇好——一楼 sock 人人都要,二楼 inet_sock 补 IP 门牌,三楼连接跟踪,四楼 TCP 的窗口与序号。UDP 只盖到二楼就封顶,因为用不着三楼。
  • 结论:四层嵌套是「公共底座 + 按协议递进」的装配线,越往下越通用,越往上越具体;拆层省的是用不上的协议的内存,付的代价只是指针强转。

    3.2 首成员嵌入与指针强转:C 语言版的多态

  • C 标准保证:结构体第一个成员的地址等于结构体的地址。所以 &tcp_sock->inet_conn 与 &tcp_sock 相等,(struct sock *)tcp_sock 强转之后拿到的还是同一块内存的起点。
  • 白话:一栋四层楼,不管你站在一楼门口还是四楼门口,门牌地址是同一个。向上转型(tcp_sock 当 sock 用)就是把门牌换个写法,不搬家、不拷贝、零成本。
  • 有了这条保证,内核里大量函数就只收 struct sock *:进来时不问你是 TCP 还是 UDP,要用 IP 字段时 (struct inet_sock *)sk 往上探一层,要用 TCP 窗口时 (struct tcp_sock *)sk 探到顶层。
  • 分发靠 sk_prot 指向的函数表:TCP 挂 TCP 那套 connect/sendmsg,UDP 挂 UDP 那套。同一个函数入口,按函数表走到不同实现——C 语言没有继承,却靠「首成员嵌入 + 函数表」写出了多态。
  • udp_sock 第一个成员是 inet_sock,比 TCP 少了 inet_connection_sock 那层连接字段——UDP 不需要连接跟踪,自然不背那层楼。
  • 结论:首成员嵌入让向上转型退化成零成本指针强转,多态靠 sk_prot 函数表分发;这是 C 语言在内核里复刻面向对象的经典路数。

    在这里插入图片描述 在这里插入图片描述

    3.3 对象什么时候创建:listen 与三次握手各建了什么

  • 服务端调用 socket()/bind()/listen() 时,内核创建的是监听套接字:struct socket 加一个完整的 tcp_sock(监听本体)一起分配,挂进监听哈希表,状态 LISTEN。
  • 三次握手成功时,内核新建的是连接的 tcp_sock(服务端那条连接的对象):四元组回填、状态 ESTABLISHED,从半连接队列摘下、挂进 accept 队列。
  • 这里要精化一句常见表述:「三次握手成功内核创建 tcp_sock」不够准确——监听的那个 tcp_sock 早在 listen 时就存在了,握手创建的是新的连接 tcp_sock,两个对象、两份内存,不能混。
  • 连接建立之后哈希表怎么工作:报文进来按四元组查已连接哈希表,一步定位到对应的 sock;sock 再经 sk_socket 反查 file、经 fd 表反查 fd,最终落到进程身上——哈希表找 sock,进程是反查出来的。
  • 白话:银行(监听 socket)一直在那儿;每个客户握完手,银行给他开一个新账户(连接 tcp_sock),accept 就是叫号,把新账户领回应用层。
  • 结论:监听 socket 与连接 socket 是两个对象;哈希表按四元组定位的是 sock,进程是再反查出来的。

    四、全链路收拢:一次 read 的完整路径

  • 把全篇串成一条线,进程调用 read 后内核的接力路径:
  • fd(int 下标)
    → task_struct → files_struct → fd 表
    → struct file
    → file->private_data
    → struct socket
    → socket->sk
    → struct sock(实际是 tcp_sock 四层嵌套的起点)
    → sk_receive_queue(sk_buff 双向链表)
    → sk_buff->data(有效载荷)
    → 拷贝到用户缓冲区,read 返回

  • 对照课程的对象链图核对:task_struct → files_struct → struct file → private_data → struct socket → struct sock → inet_connection_sock → inet_sock → tcp_sock,与这条 read 路径是同一条链,方向一致、站点一致,课程图里 inet_connection_sock、inet_sock、tcp_sock 那一段就是 3.1 节展开的四层嵌套。
  • 中间那一下「阻塞再唤醒」恰好落在链中间:read 走到 sk_receive_queue 发现是空的,进程挂在等待队列睡去;软中断处理完报文、sk_buff 挂上队列、回调唤醒,read 才从队头摘数据继续往下走。
  • 白话收口:你写的一行 read,内核替你跑了七站接力;每一站都是一个结构体成员指针在带路,一个都不能断。
  • 结论:对象链是「一切皆文件」在内核里的落地实现;fd 是入口,tcp_sock 是终点,中间每站各管一段。

    五、全篇总结

    1. fd 到 file 是统一接口
  • fd 是下标,struct file 是房间;private_data 钩住 socket,网络挂进文件体系,read/write 走统一入口。
  • 2. socket 薄、sock 厚
  • struct socket 管接口与状态,struct sock 管队列与协议;等待队列是阻塞 IO 的底座,唤醒靠四元组查哈希表。
  • 3. 四层嵌套按需装配
  • sock → inet_sock(四元组)→ inet_connection_sock(连接与 accept 队列)→ tcp_sock(窗口与拥塞字段);UDP 只到 inet_sock 就封顶。
  • 4. 首成员嵌入即多态
  • 首成员地址相同,强转零成本;sk_prot 函数表完成分发;这是 C 语言复刻面向对象的路数。
  • 5. 监听与连接是两个对象
  • listen 建监听 tcp_sock,握手成功建连接 tcp_sock;哈希表按四元组找 sock,进程是再反查出来的。
  • 六、文末面试题

    6.1 推导题

    1. struct sock 和 struct inet_sock 总是一起创建,为什么还要拆成两个结构体?

    答(推导):一起创建不等于需求相同。unix 域套接字不走 IP 栈、ICMP 没有端口,它们只需要 sock 这层底座;把 IP 四元组塞进 sock,这些协议族就得为用不上的字段买单。拆开后 sock 服务所有协议族,inet_sock 只加给 IP 协议族,各协议按需嵌套层数,内存不浪费,代价只是指针强转。

    2. 内核函数大量使用 struct sock * 参数,怎么做到一套代码同时服务 TCP 和 UDP?

    答(推导):靠首成员嵌入加函数表。C 标准保证结构体首成员地址与结构体地址相同,tcp_sock、udp_sock 的第一块内存分别是 inet_connection_sock、inet_sock,再往下都是 sock;所以任何 sock * 都能安全当通用底座用,需要具体字段时再强转上探。分发由 sk_prot 指向的函数表完成:TCP 挂 TCP 的实现,UDP 挂 UDP 的实现,同一入口走不同路径,等价于编译型多态。

    3. read 一个无数据的阻塞 socket,进程是怎么睡下去又怎么被唤醒的?

    答(推导):read 经 fd 表找到 file、private_data 找到 socket、sk 找到 sock,查 sk_receive_queue 为空,内核把该进程 PCB 挂进 socket 的等待队列,状态改为可中断睡眠(浅睡眠),让出 CPU。数据到达时网卡硬中断只做标记,软中断里协议栈剥完各层头,按四元组查哈希表定位到这条 sock,把数据包成 sk_buff 挂上接收队列,回调 sk_data_ready 唤醒等待队列,进程进运行队列,再次被调度时 read 从队头摘数据拷给用户缓冲区返回。

    6.2 真题

    1. socket 在内核中的数据结构是怎样的?struct socket 和 struct sock 有什么关系?

    答(转述):struct socket 是 BSD 层的套接字,管类型、状态、协议操作表 ops,一个 fd 对一个;struct sock 是协议层对象,管收发队列、状态机、协议函数表,由 socket 的 sk 指针指向。socket 薄、sock 厚:接口与协议解耦,同一套 socket 层可以挂INET、UNIX 等不同协议族。

    【真题·转述自 小林coding:I/O 多路复用 select/poll/epoll】

    2. 一个 socket(一条 TCP 连接)占用多少内存?

    答(转述):一条 ESTABLISHED 状态的空连接,核心开销就是 tcp_sock 这块结构体,按对象大小算约 2KB 出头;加上 sk_buff 与缓冲区后随数据量增长,实测一条收发过数据的连接在 4KB 上下浮动,具体与内核版本、缓冲区配置有关。

    【真题·转述自 小林coding:腾讯三面,一台服务器最大支持的 TCP 连接数】

    3. sk_buff 的结构与作用是什么?

    答(转述):sk_buff 是报文的描述结构,本身不存数据;head/data/tail/end 四个指针在缓冲区上圈出 headroom、有效数据、tailroom,next/prev 把它串成双向链表挂在收发队列上。发数据往下走一层 data 前移加头,收数据往上走一层 data 后移剥头,一层结构体描述所有层的包,层间传递只动指针不拷贝。

    【真题·转述自 小林coding:Linux 系统如何收发网络包】

    4. 阻塞 IO 的实现原理是什么?等待队列在其中起什么作用?

    答(转述):阻塞读时进程 PCB 挂进 socket 的等待队列、睡眠让出 CPU;数据到达后中断上半部做标记,软中断里协议栈处理完报文,回调唤醒等待队列上的进程,进程进运行队列等调度,read 才返回。等待队列就是阻塞 IO 的底座,把「睡」和「被叫醒」两头接起来。

    【真题·转述自 腾讯云开发者:epoll 性能为什么高】

    5. accept 发生在三次握手的哪个阶段?半连接队列和全连接队列有什么区别?

    答(转述):accept 不参与握手,它在三次握手完成之后从全连接队列取连接。半连接队列存收到 SYN 还没完成握手的连接(SYN_RCVD),全连接队列存握手完成、等 accept 领走的连接(ESTABLISHED);全连接队列满了后续完成握手的连接会被丢弃或回 RST,ss 命令可以观察到。

    【真题·转述自 小林coding:TCP 半连接队列和全连接队列】

    6. epoll 的底层原理是什么?和等待队列是什么关系?

    答(转述):epoll 在内核里用红黑树管理进程关注的 fd,增删查 O(logn);事件就绪时由数据到达的回调把 fd 对应的 epitem 挂进就绪链表,epoll_wait 只返回就绪的那部分,不用遍历全量。回调的注册对象就是 socket 的等待队列,数据到了先唤醒的是 epoll 的回调,再由它通知应用进程。

    【真题·转述自 腾讯云开发者:epoll 性能为什么高】


    💬 结语: fd 背后是一整条对象链:file 挂接口、sock 管队列、tcp_sock 藏着窗口与拥塞;首成员嵌入让四层嵌套零成本强转。传输层到此收官,评论区讲讲你第一次读内核源码时被哪个结构体劝退。

    赞(0)
    未经允许不得转载:171主机测评 » 计算机网络篇19:传输层——套接字在内核的对象链,从 fd 到 tcp_sock 的四层嵌套
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址