Linux --传输层协议 TCP

先搞懂TCP在哪里:网络分层模型

要理解TCP,先得知道它在网络体系中的位置。

OSI参考模型 TCP/IP分层模型 说明
应用层 应用层 DNS、HTTP、SMTP、SSH、FTP等
表示层 应用层 TLS/SSL、MIME等
会话层 应用层 RTP、SIP等
传输层 传输层 TCP、UDP、SCTP、DCCP
网络层 互联网层 IP、ICMP、ARP
数据链路层 网卡层 设备驱动与网络接口
物理层 硬件 物理传输介质

关键点 :TCP属于传输层,是操作系统内核的一部分,向下依赖网络层(IP),向上为应用层提供服务。应用程序通过系统调用(socket API)与TCP交互。

TCP协议段格式:每个字段都是干什么的?

TCP报文段(Segment)的头部结构是理解一切机制的基础。

复制代码
 0                15 16                  31
+-------------------+-------------------+
|   16位源端口号     |   16位目的端口号     |
+-------------------+-------------------+
|              32位序号                  |
+---------------------------------------+
|             32位确认序号                |
+-------+-------+-----------------------+
|4位首部|保留6位 | U A P R S F            |
|长度   |       | R C S S Y I            |
|      |       | G K H T N N            |
+-------+-------+-----------------------+
|   16位窗口大小     |   16位校验和        |
+-------------------+-------------------+
|   16位紧急指针     |       选项         |
+-------------------+-------------------+
|              数据                      |
+---------------------------------------+

核心字段详解

  • 源/目的端口号:标识数据从哪个进程来、到哪个进程去。IP地址标识主机,端口号标识进程。

  • 32位序号:TCP对每个字节都编号。序号是本报文段第一个数据字节的编号。

  • 32位确认序号:期望收到的下一个字节的编号。比如收到1~1000,确认序号就是1001。

  • 4位首部长度:表示TCP头部有多少个32位字(4字节)。最大15,所以头部最大60字节。通常没有选项时是20字节。

  • 6位标志位:

    • URG:紧急指针有效

    • ACK:确认序号有效(连接建立后通常都置1)

    • PSH:提示接收端立刻把数据交给应用层

    • RST:复位连接(对方要求重新建立连接)

    • SYN:同步序号,用于建立连接

    • FIN:发送方准备关闭连接

  • 16位窗口大小:接收端告诉发送端自己还能接收多少字节(流量控制的核心)。

  • 16位校验和:CRC校验,覆盖TCP首部+数据部分。

  • 16位紧急指针:标识紧急数据的位置。

扩展:选项字段中的"窗口扩大因子"

16位窗口最大只能表示65535字节。为了支持更大的窗口,TCP选项中有窗口扩大因子M,实际窗口大小 = 窗口字段值 × 2^M。这在高速网络中非常重要。

TCP的核心机制(上):可靠传输

确认应答(ACK)机制

TCP把每个字节都编号,接收端收到数据后返回ACK,告诉发送端:"我已经收到了X之前的所有数据,下次从X开始发。"

生活类比:唐僧讲经,每讲一段就问"听懂了吗?";徒弟回答"听懂了,继续下一段"。

关键规则:

  • 每个ACK都携带确认序号,表示"这个序号之前的数据我都收到了"。

  • 发送端收到ACK后,才知道数据被正确接收。

超时重传机制

数据或ACK可能丢失。发送端在特定时间间隔内没收到ACK,就重发。

核心问题:超时时间怎么定?

  • 太长:重传慢,效率低。

  • 太短:频繁重传,浪费带宽。

TCP的动态策略(Linux实现):

  • 以500ms为基本单位。

  • 每次重传后,等待时间翻倍:500ms → 1000ms → 2000ms → 4000ms...

  • 累计到一定次数后,认为网络或对端异常,强制关闭连接。

扩展:自适应重传(Karn算法)

实际TCP实现中,RTT(往返时间)是动态测量的。TCP维护一个加权平均RTT(SRTT)和偏差(RTTVAR),超时时间 = SRTT + 4×RTTVAR。这比固定500ms更智能。

去重与排序

由于重传,接收端可能收到重复数据。TCP利用序号可以轻松去重:如果收到的数据序号小于已确认的序号,直接丢弃。

TCP的核心机制(中):连接管理(三次握手、四次挥手)

三次握手建立连接

复制代码
客户端                                          服务器
  |                                              |
  |  ① SYN (seq=x)                               |
  |  ------------------------------------------->|
  |  SYN_SENT                          SYN_RCVD  |
  |                                              |
  |  ② SYN + ACK (seq=y, ack=x+1)                |
  |  <-------------------------------------------|
  |                                              |
  |  ③ ACK (seq=x+1, ack=y+1)                    |
  |  ------------------------------------------->|
  |  ESTABLISHED                    ESTABLISHED  |
  |                                              |
  |            (连接建立,可以传数据了)            |

每一步在做什么?

第一次握手:客户端 → 服务器
  • 动作 :客户端调用 connect(),发送一个 SYN报文段。

  • 关键字段:

    • SYN = 1(标志位,表示请求建立连接)

    • seq = x(客户端的初始序列号,随机生成)

  • 客户端状态 :CLOSED → SYN_SENT

  • 含义:客户端说"我想和你建立连接,我的初始序列号是x"。

第二次握手:服务器 → 客户端
  • 动作 :服务器收到SYN后,如果同意连接,回复一个 SYN+ACK报文段。

  • 关键字段:

    • SYN = 1(我也请求建立连接)

    • ACK = 1(确认号有效)

    • seq = y(服务器的初始序列号,随机生成)

    • ack = x + 1(确认收到客户端的SYN,期望下次收到x+1)

  • 服务器状态 :LISTEN → SYN_RCVD

  • 含义:服务器说"我同意,我的初始序列号是y,我确认收到你的x了"。

第三次握手:客户端 → 服务器
  • 动作 :客户端收到SYN+ACK后,发送一个 ACK报文段。

  • 关键字段:

    • ACK = 1

    • seq = x + 1

    • ack = y + 1(确认收到服务器的SYN)

  • 客户端状态 :SYN_SENT → ESTABLISHED

  • 服务器状态 :收到ACK后,SYN_RCVD → ESTABLISHED

  • 含义:客户端说"我确认收到你的y了,连接建立!"

三次握手的本质:确认双方收发能力

握手 能确认什么
第一次 服务器能确认:客户端能发,自己能收
第二次 客户端能确认:服务器能发,自己能收;服务器能收,客户端能发
第三次 服务器能确认:客户端能收,自己能发

经过三次握手,双方都确认了自己和对方的收发能力都正常。

为什么是三次,而不是两次或四次?

防止已失效的连接请求突然到达服务器,导致服务器资源浪费。

假设只有两次握手:
复制代码
客户端                                    服务器
  |                                        |
  | ① SYN (seq=x)  ------ 网络延迟,滞留 ------      |
  |                                        |
  |  (客户端超时,重发SYN)                  |
  | ② SYN (seq=x')  ---------------------->|
  |  <---------------- SYN+ACK (ack=x'+1)  |
  | ③ ACK  ------------------------------> |  连接建立
  |                                        |
  |  (此时①的SYN姗姗来迟)                   |
  |  <---------------- SYN+ACK (ack=x+1)   |
  |                                        |  服务器认为连接建立!
  |  (客户端不理它,服务器却一直等)           |
问题:服务器收到一个早已失效的SYN,就认为连接建立了,分配资源等待客户端。但客户端根本不认这个连接,导致服务器资源白白浪费。

三次握手解决这个问题:

  • 服务器收到失效SYN后,回复SYN+ACK,进入SYN_RCVD。

  • 客户端不会对这个失效连接回复ACK。

  • 服务器超时后释放资源,不会真正建立连接。

为什么不是四次?

第二次握手时,服务器可以同时发送SYN和ACK(即SYN+ACK),把两个动作合并成一个报文,所以不需要四次。

总结:

  • 三次是最少次数:既能确认双方收发能力,又能防止失效连接。

  • 两次不安全:无法防止历史连接。

  • 四次没必要:SYN和ACK可以合并。

四次挥手断开连接

复制代码
客户端                                          服务器
  |                                              |
  |  ① FIN (seq=u)                               |
  |  ------------------------------------------->|
  |  FIN_WAIT_1                      CLOSE_WAIT  |
  |                                              |
  |  ② ACK (ack=u+1)                             |
  |  <-------------------------------------------|
  |  FIN_WAIT_2                                  |
  |                                              |
  |         (服务器处理剩余数据,可能还要发数据)     |
  |                                              |
  |  ③ FIN (seq=v)                               |
  |  <-------------------------------------------|
  |                                  LAST_ACK    |
  |                                              |
  |  ④ ACK (ack=v+1)                             |
  |  ------------------------------------------->|
  |  TIME_WAIT (等待2MSL)            CLOSED       |
  |                                              |
  |  (2MSL后)CLOSED                             |

每一步在做什么?

第一次挥手:客户端 → 服务器
  • 动作 :客户端调用 close(),发送 FIN报文段。

  • 关键字段:

    • FIN = 1

    • seq = u

  • 客户端状态 :ESTABLISHED → FIN_WAIT_1

  • 含义:客户端说"我没有数据要发了,准备关闭"。

第二次挥手:服务器 → 客户端
  • 动作 :服务器收到FIN后,回复 ACK。

  • 关键字段:

    • ACK = 1

    • ack = u + 1

  • 服务器状态 :ESTABLISHED → CLOSE_WAIT

  • 客户端状态 :收到ACK后,FIN_WAIT_1 → FIN_WAIT_2

  • 含义:服务器说"我知道你要关了,但我可能还有数据要发,你等等"。

注意 :此时连接处于**半关闭(Half-Close)**状态------客户端不能再发数据,但服务器还能发。

第三次挥手:服务器 → 客户端
  • 动作 :服务器处理完所有数据后,调用 close(),发送 FIN。

  • 关键字段:

    • FIN = 1

    • seq = v

  • 服务器状态 :CLOSE_WAIT → LAST_ACK

  • 含义:服务器说"我也没数据要发了,准备关闭"。

第四次挥手:客户端 → 服务器
  • 动作 :客户端收到FIN后,回复 ACK。

  • 关键字段:

    • ACK = 1

    • ack = v + 1

  • 客户端状态 :FIN_WAIT_2 → TIME_WAIT

  • 服务器状态 :收到ACK后,LAST_ACK → CLOSED

  • 含义:客户端说"我确认收到你的FIN了,你可以关了"。

  • 客户端 :等待 2MSL 后,才进入 CLOSED。

为什么是四次,而不是三次?

核心原因:TCP是全双工的,每个方向需要单独关闭。

  • 客户端发FIN:关闭"客户端→服务器"方向。

  • 服务器回ACK:确认收到FIN。

  • 服务器发FIN:关闭"服务器→客户端"方向。

  • 客户端回ACK:确认收到FIN。

为什么不能合并ACK和FIN?

因为服务器收到FIN时,可能还有数据没发完。它必须先回ACK(告诉客户端"我知道你要关了"),等数据发完后再发FIN。

特殊情况 :如果服务器收到FIN时也没有数据要发了,可以把ACK和FIN合并,变成三次挥手。但TCP协议默认按四次处理。

为什么客户端要等待2MSL?

MSL = Maximum Segment Lifetime,报文最大生存时间。RFC 1122规定为2分钟,Linux默认60秒。

两个原因:

原因一:确保最后一个ACK能到达服务器
复制代码
客户端                     服务器
  |                          |
  |<-------- FIN (seq=v) ----|
  |                          |
  |------ ACK (ack=v+1) ---->|  ← 这个ACK如果丢失
  |                          |
  |                          |  服务器超时重发FIN
  |<-------- FIN (seq=v) ----|
  |                          |
  |------ ACK (ack=v+1) ---->|
  |                          |

如果客户端发完ACK立刻关闭,而ACK丢失,服务器会重发FIN。但客户端已经关闭,无法响应,服务器就无法正常关闭。

等待2MSL,客户端还能响应服务器的重发FIN。

原因二:让本次连接的所有报文在网络中消失

如果客户端立刻用相同端口建立新连接,旧连接的延迟报文可能被新连接误收,导致数据错乱。

等待2MSL,确保本次连接的所有报文都已消亡。

状态转换详解

服务器端:

  • CLOSED → LISTEN:调用listen()

  • LISTEN → SYN_RCVD:收到SYN

  • SYN_RCVD → ESTABLISHED:收到ACK

  • ESTABLISHED → CLOSE_WAIT:收到FIN

  • CLOSE_WAIT → LAST_ACK:调用close(),发送FIN

  • LAST_ACK → CLOSED:收到ACK

客户端:

  • CLOSED → SYN_SENT:调用connect()

  • SYN_SENT → ESTABLISHED:收到SYN+ACK,发送ACK

  • ESTABLISHED → FIN_WAIT_1:调用close(),发送FIN

  • FIN_WAIT_1 → FIN_WAIT_2:收到ACK

  • FIN_WAIT_2 → TIME_WAIT:收到FIN,发送ACK

  • TIME_WAIT → CLOSED:等待2MSL

TIME_WAIT 与 CLOSE_WAIT

核心结论:

  • TIME_WAIT :主动关闭方在发送最后一个ACK后进入的状态。

  • CLOSE_WAIT :被动关闭方在收到FIN并回复ACK后进入的状态。

TIME_WAIT 详解

什么是 TIME_WAIT?

TIME_WAIT 是主动关闭连接的一方 在发送最后一个ACK后进入的状态。它需要等待 2MSL(报文最大生存时间) 后才能进入CLOSED状态。

MSL 的值:

  • RFC 1122 规定为 2分钟。

  • Linux 默认 tcp_fin_timeout = 60秒,即 MSL = 60秒,2MSL = 120秒?不,实际上 Linux 的 TIME_WAIT 持续时间是 60秒 (tcp_fin_timeout 直接控制 TIME_WAIT 时长,不是2MSL的2倍)。

为什么需要 TIME_WAIT?(核心!)

TIME_WAIT 的存在有两个根本原因:

原因一:确保最后一个ACK能可靠到达服务器
复制代码
客户端                     服务器
  |                          |
  |<-------- FIN (seq=v) ----|
  |                          |
  |------ ACK (ack=v+1) ---->|  ← 这个ACK如果丢失
  |                          |
  |  (客户端立刻关闭)         |  服务器超时重发FIN
  |                          |
  |<-------- FIN (seq=v) ----|
  |                          |
  |  ✗ 客户端已关闭,无法响应    |
  |                          |
  |              服务器无法正常关闭!

问题:如果客户端发送最后一个ACK后立刻关闭,而这个ACK在网络中丢失,服务器会超时重发FIN。但客户端已经关闭,无法响应,导致服务器一直处于LAST_ACK,无法正常关闭。

解决:客户端等待2MSL,期间如果收到服务器重发的FIN,还能再次发送ACK。

为什么是2MSL?

  • 1MSL:ACK到达服务器的最长时间。

  • 1MSL:服务器重发FIN到达客户端的最长时间。

  • 2MSL = 一来一回,确保能处理所有重发情况。

原因二:让本次连接的所有报文在网络中消失
复制代码
客户端                      服务器
  |                          |
  |  连接1:数据包延迟在网络中   |
  |                          |
  |  连接1关闭                |
  |                          |
  |  连接2:使用相同四元组      |
  |                          |
  |  ✗ 旧数据包到达,被误认为    |
  |    是新连接的数据!         |

问题:TCP连接由四元组(源IP、源端口、目的IP、目的端口)唯一标识。如果客户端立刻用相同端口建立新连接,旧连接的延迟报文可能被新连接误收,导致数据错乱。

解决:等待2MSL,确保本次连接的所有报文都已消亡(超过MSL的报文会被网络丢弃)。

TIME_WAIT 的危害

危害一:端口占用

服务器主动关闭连接后,端口在TIME_WAIT期间不能被重用。如果服务器需要频繁主动关闭连接,会出现:

bash 复制代码
$ ./server
bind error: Address already in use

原因:虽然应用程序终止了,但TCP层的连接还没完全断开。

验证:

bash 复制代码
$ netstat -apn | grep 8000
tcp  0  0 127.0.0.1:33498  127.0.0.1:8000  CLOSE_WAIT  10830/client
tcp  0  0 127.0.0.1:8000   127.0.0.1:33498 FIN_WAIT2   -
危害二:资源浪费

每个TIME_WAIT连接占用一个通信五元组(源IP、源端口、目的IP、目的端口、协议),以及内核中的一些内存资源。

高并发场景:

  • 服务器每秒处理大量短连接。

  • 如果服务器主动关闭,会产生大量TIME_WAIT。

  • 每个TIME_WAIT持续60秒,累积起来可能达到数万甚至数十万。

  • 可能导致端口耗尽、内存耗尽。

如何解决 TIME_WAIT 问题?

方法一:SO_REUSEADDR(最常用)
bash 复制代码
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

作用:允许创建端口号相同但IP地址不同的多个socket描述符,或者绑定处于TIME_WAIT的端口。

原理:通知内核,如果端口处于TIME_WAIT,可以重用。

注意:这不能缩短TIME_WAIT时间,只是允许新连接绑定。

方法二:让客户端主动关闭

核心思想:客户端端口多(65535个),不怕TIME_WAIT;服务器端口少(如80、443),TIME_WAIT影响大。

实现:

  • HTTP协议中,客户端发完请求后主动关闭。

  • 服务器设置 Connection: close,让客户端关闭。

方法三:使用长连接

核心思想:减少连接建立/关闭频率。

CLOSE_WAIT 详解

什么是 CLOSE_WAIT?

CLOSE_WAIT 是被动关闭连接的一方在收到FIN并回复ACK后进入的状态。

关键 :此时连接处于半关闭状态------对方不能再发数据,但自己还能发数据。

正常流程:

  1. 收到FIN。

  2. 回复ACK。

  3. 进入CLOSE_WAIT。

  4. 应用程序处理剩余数据。

  5. 应用程序调用 close()。

  6. 发送FIN,进入LAST_ACK。

  7. 收到ACK,进入CLOSED。

问题 :如果应用程序一直不调用 close(),连接就永远停留在CLOSE_WAIT,导致文件描述符泄漏。

CLOSE_WAIT 的危害

危害一:文件描述符泄漏

每个CLOSE_WAIT连接占用一个文件描述符。Linux默认单进程文件描述符上限是1024(可调整)。

后果:

  • 文件描述符耗尽。

  • 无法接受新连接。

  • 报错 Too many open files。

危害二:内存泄漏

每个连接占用内核内存(socket结构、缓冲区等)。

后果:

  • 内存持续增长。

  • 最终OOM(Out Of Memory)。

危害三:连接堆积

大量CLOSE_WAIT连接堆积,导致:

  • 端口占用。

  • 系统负载升高。

  • 服务不可用。

Q:服务器出现大量CLOSE_WAIT是什么原因?

答:代码BUG------服务器收到FIN后没有调用close()。需要检查代码,确保所有路径都能正确关闭socket。

Q:CLOSE_WAIT会持续多久?

答:取决于应用程序何时调用close()。如果不调用,永远持续。

Q:CLOSE_WAIT和TIME_WAIT哪个更严重?

答:CLOSE_WAIT更严重。因为:

  • TIME_WAIT是正常状态,会自行消失。

  • CLOSE_WAIT是异常状态,不修复代码永远不会消失。

  • TIME_WAIT占用端口,CLOSE_WAIT占用文件描述符和内存。

TCP的核心机制(下):高效传输

滑动窗口

问题 :一发一收性能太差。

解决:一次发送多个段,无需等待每个ACK。

窗口大小:无需等待确认应答而可以继续发送数据的最大值。

  • 发送前4个段(窗口=4000字节),不用等ACK。

  • 收到第一个ACK后,窗口向后滑动,发送第5个段。

  • 发送缓冲区记录未应答的数据,收到ACK后删除。

窗口与吞吐量

吞吐量 ≈ 窗口大小 / RTT。窗口越大,吞吐量越高。

快重传

情况一:ACK丢了 ------不要紧,后续ACK可以确认。

情况二:数据包丢了 ------接收端连续收到相同ACK(如"下一个是1001"),发送端连续3次收到相同ACK,立即重传丢失的数据(不等超时)。这就是快重传。

快恢复

快重传后,不回到慢启动,而是将拥塞窗口减半,然后线性增长(快恢复)。

拥塞控制

问题 :网络本身拥堵,贸然发大量数据会雪上加霜。

解决:慢启动 + 拥塞避免。

慢启动:

  • 初始拥塞窗口=1。

  • 每收到一个ACK,拥塞窗口+1(指数增长)。

  • 达到慢启动阈值后,改为线性增长(拥塞避免)。

超时重传时:

  • 慢启动阈值减半。

  • 拥塞窗口置1,重新慢启动。

延迟应答

问题 :立刻ACK,窗口可能较小。

解决:等一会再ACK,窗口可能变大。

  • 数量限制:每N个包应答一次(N通常=2)。

  • 时间限制:超过200ms必须应答。

捎带应答

问题 :ACK单独发太浪费。

解决:ACK搭应用层响应的顺风车一起发。

  • 例如:客户端问"How are you",服务器回"Fine, thank you"时顺便带上ACK。

TCP的"性格":面向字节流与粘包问题

面向字节流

TCP不保留应用层的消息边界。发送端write 100字节,可能被拆成多个TCP段;接收端read时,可能一次读到多个消息。

全双工:一个连接既有发送缓冲区,也有接收缓冲区,可以同时读写。

粘包问题

本质 :应用层不知道从哪到哪是一个完整消息。

解决:

  • 定长包:固定大小读取。

  • 变长包:包头加长度字段。

  • 分隔符:如HTTP的\r\n\r\n。

UDP会不会粘包?

不会。UDP保留消息边界,要么收到完整报文,要么不收。

TCP异常情况处理

异常 行为
进程终止 释放文件描述符,发送FIN,正常关闭
机器重启 同进程终止
机器掉电/网线断开 接收端认为连接还在,写入时发现异常,发送RST;TCP有保活定时器定期探测
应用层保活 HTTP长连接、QQ断线重连等

扩展

复制代码
┌─────────────────────────────────────────────────────────────┐
│                        进程层                                │
│  ┌──────────────┐                                           │
│  │ task_struct  │                                           │
│  └──────┬───────┘                                           │
│         │ files                                             │
│         ▼                                                   │
│  ┌──────────────────────────────┐                           │
│  │   struct files_struct        │                           │
│  │  ┌────────────────────────┐  │                           │
│  │  │ struct file* fd_array[]│  │                           │
│  │  └───────────┬────────────┘  │                           │
│  └──────────────┼───────────────┘                           │
│                 │ fd_array[n]                               │
│                 ▼                                           │
│  ┌──────────────────────────────┐                           │
│  │       struct file            │                           │
│  │       private_data ──────────┼──────┐                    │
│  └──────────────────────────────┘      │                    │
│                                        │                    │
│  ┌──────────────────────────────┐      │                    │
│  │      struct socket           │◄─────┘                    │
│  │      file ───────────────────┼──────┐                    │
│  │      sk ─────────────────────┼───┐  │                    │
│  └──────────────────────────────┘   │  │                    │
│                                     │  │                    │
│  ┌──────────────────────────────┐   │  │                    │
│  │      struct sock             │◄──┘  │                    │
│  │  ┌────────────────────────┐  │      │                    │
│  │  │  struct inet_sock      │  │      │                    │
│  │  │  ┌──────────────────┐  │  │      │                    │
│  │  │  │struct inet_conn  │  │  │      │                    │
│  │  │  │ ┌──────────────┐ │  │  │      │                    │
│  │  │  │ │structtcp_sock│ │  │  │      │                    │
│  │  │  │ └──────────────┘ │  │  │      │                    │
│  │  │  └──────────────────┘  │  │      │                    │
│  │  └────────────────────────┘  │      │                    │
│  └──────────────────────────────┘      │                    │
│                                        │                    │
│  注:struct file 与 struct socket       │                    │
│      通过 private_data / file 互相指向   └────────────────────┘
└─────────────────────────────────────────────────────────────┘

这张图非常经典,它展示了 Linux 内核中从用户态文件描述符到内核 TCP 协议栈的完整映射关系。

核心结构体全景图(从上到下)

这张图分为四个层级:

  1. 进程层 :task_struct(进程控制块)

  2. 文件系统层 :struct files_struct → struct file

  3. Socket 抽象层 :struct socket

  4. 传输层协议层 :struct sock → struct inet_sock → struct inet_connection_sock → struct tcp_sock(以 TCP 为例)

1. 进程与文件系统层

  • task_struct:代表一个进程。每个进程都有自己打开的文件表。

  • struct files_struct :进程打开文件的表。其中 fd_array[] 是一个指针数组,下标就是文件描述符(fd)。

  • struct file :代表一个打开的文件。对于 socket 来说,它代表一个网络连接。图中的 private_data 指向 struct socket。

2. Socket 抽象层

  • struct socket :这是 BSD Socket 层的抽象。它不关心底层是 TCP 还是 UDP,只提供通用的 socket 接口(如 sock->ops->sendmsg)。

    • 它有两个关键指针:

      • struct file *file:指回上层的 struct file(用于文件系统交互)。

      • struct sock *sk:指向底层的传输层控制块。

3. 传输层协议层(重点!)

图中的底部是一个多层嵌套的结构,这是 Linux 内核网络栈的精髓------继承与多态。

  • struct sock:最基础的传输层控制块。包含了所有协议通用的字段(如状态、接收队列、发送队列、定时器等)。

  • struct inet_sock :继承自 struct sock,增加了 IPv4 相关的字段(如源 IP、目的 IP、TTL 等)。

  • struct inet_connection_sock :继承自 struct inet_sock,增加了面向连接协议特有的字段(如三次握手队列、重传定时器、MSS 等)。UDP 不需要这个结构,TCP 需要。

  • struct tcp_sock :继承自 struct inet_connection_sock,增加了 TCP 特有的字段(如滑动窗口、拥塞窗口、序列号、确认号等)。

内存布局 :这些结构体在内存中是连续嵌套 的。一个 struct tcp_sock 变量的首地址,同时也是 struct inet_connection_sock、struct inet_sock 和 struct sock 的首地址。因此,内核可以通过强制类型转换,实现类似 C++ 继承的"向上转型"。

用户调用 socket() 系统调用时发生了什么?

当用户调用 int fd = socket(AF_INET, SOCK_STREAM, 0); 时,内核会经历以下步骤:

第一步:创建 struct socket

内核调用 sock_create(),分配一个 struct socket 对象。此时它只是一个空壳,还没有绑定具体协议。

第二步:创建 struct file

内核在文件系统中创建一个 struct file 对象,并将它的 private_data 指向刚刚创建的 struct socket。同时,struct socket 的 file 指针也指回这个 struct file。这就是图中 private_data 和 file 互相指向的原因。

第三步:创建底层协议控制块(struct sock)

根据 SOCK_STREAM 参数,内核知道要使用 TCP 协议。

  1. 内核调用 inet_create()。

  2. 根据协议类型,分配对应的底层结构。对于 TCP,它会分配一个 struct tcp_sock 对象(这个对象很大,包含了完整的 struct sock、inet_sock 等)。

  3. 初始化该结构,并将 struct socket 的 sk 指针指向这个 struct tcp_sock 对象。

  4. 关键点 :struct socket 的 ops 指针会被设置为 inet_stream_ops(TCP 的操作函数集)。如果是 UDP,则会设置为 inet_dgram_ops。

第四步:分配文件描述符(fd)

内核在当前进程的 files_struct 的 fd_array[] 中找一个空闲的位置,将 struct file 的指针填入,并返回数组下标作为 fd。

至此,用户拿到的 fd 就与内核的 TCP 协议栈建立了完整的映射关系。

TCP 与 UDP 在内核实现上的差异

这张图展示的是 TCP(因为有 inet_connection_sock 和 tcp_sock)。如果是 UDP,结构会简化很多:

特性 TCP UDP
底层结构 tcp_sock → inet_connection_sock → inet_sock → sock udp_sock → inet_sock → sock
连接管理 有 inet_connection_sock,包含连接队列、重传定时器 无连接,不需要 inet_connection_sock
Socket 操作集 inet_stream_ops inet_dgram_ops
发送数据 tcp_sendmsg(可能阻塞、拆分、重传) udp_sendmsg(直接封装成包发出)
接收数据 tcp_recvmsg(从接收队列读取,处理乱序、去重) udp_recvmsg(从接收队列读取,保留消息边界)
状态机 复杂的 11 种状态(ESTABLISHED, TIME_WAIT 等) 基本只有 CLOSE 和 ESTABLISHED(无连接)

UDP 为什么没有 inet_connection_sock?

因为 UDP 是无连接的,不需要维护三次握手队列、重传定时器、滑动窗口等面向连接的特性。所以它的结构体层级更浅,内存占用更小,处理速度更快。

调用逻辑:从用户态到内核态的数据流

以 TCP 发送数据为例,说明这些结构体是如何协同工作的:

复制代码
用户态:write(fd, buf, len)
   ↓
系统调用:sys_write(fd, buf, len)
   ↓
VFS 层:根据 fd 找到 struct file
   ↓
Socket 层:调用 file->private_data->ops->sendmsg (即 struct socket 的 ops)
   ↓
INET 层:sock_sendmsg -> inet_sendmsg
   ↓
TCP 层:tcp_sendmsg(struct sock *sk, ...)
   ↓
数据被放入 sk->sk_write_queue (发送队列)
   ↓
TCP 输出:tcp_write_xmit() 构建 TCP 报文段
   ↓
IP 层:ip_queue_xmit() 添加 IP 头
   ↓
数据链路层:dev_queue_xmit() 发送到网卡

关键理解:

  • struct socket 是门面,提供统一的文件系统接口(read/write/ioctl)。

  • struct sock(及其子类)是大脑,维护协议状态、队列、定时器。

  • 函数指针(ops)实现了多态 。同一个 write 系统调用,对于 TCP 会走到 tcp_sendmsg,对于 UDP 会走到 udp_sendmsg。

TCP 协议栈是如何形成的?

这里的"协议栈"有两层含义:

1. 静态的代码栈(协议族注册)

Linux 内核启动时,会调用 inet_init(),向内核注册 TCP 和 UDP 协议:

  • 注册 tcp_prot(TCP 协议操作集)和 udp_prot(UDP 协议操作集)。

  • 注册 inet_stream_ops 和 inet_dgram_ops。

  • 注册 TCP 和 UDP 的收包处理函数(tcp_v4_rcv 和 udp_rcv)到 IP 层。

这样,当数据包到达时,IP 层知道该交给谁来处理。

2. 动态的连接栈(Socket 实例)

每次用户调用 socket(),内核就动态生成一个 struct tcp_sock 实例。这个实例包含了该连接的所有状态(序列号、窗口、定时器等)。多个连接对应多个 tcp_sock 实例,它们共享同一套代码逻辑(tcp_prot)。

三次握手的过程,就是内核在这个动态实例上不断更新状态、收发队列、启动定时器的过程。

这张图揭示了 Linux 内核网络实现的三大设计哲学:

  1. 分层抽象 :file → socket → sock,每层各司其职。

  2. 继承与多态:通过结构体嵌套和函数指针,实现 C 语言下的面向对象。

  3. 统一接口,差异化实现 :用户只用 read/write,内核根据 SOCK_STREAM 或 SOCK_DGRAM 走完全不同的协议路径。

相关推荐
wdfk_prog2 小时前
LWIP教程 15:IPv4 与 IPv6——从网络层工作原理到嵌入式工程取舍
运维·开发语言·网络·笔记·学习·智能路由器·ip
程序员Sunday2 小时前
XSS 和 CSRF 有什么区别?HttpOnly、SameSite 和 Token 分别防什么?
网络·xss·csrf
Sylvia-girl2 小时前
【二】:Linux基础指令
linux·运维·服务器
坤坤子吖2 小时前
Linux 进程信号入门——从 Ctrl+C 到 kill
linux·开发语言·c++·笔记·学习
..过云雨2 小时前
18.【网络】多路转接select系统调用详解 - 修正版
网络
江湖有缘2 小时前
Docker实战 | 使用Docker部署ShipShipShip自托管产品更新与功能投票系统
java·linux·服务器·docker
资深电气设计2 小时前
明治MLD25系列激光位移传感器技术解析:3C行业零件厚度测量方案参考
网络
LXY_BUAA2 小时前
《嵌入式操作系统》_framebuffer驱动_20261008
linux·运维·算法
hz567892 小时前
数据本地化音视频系统设计:数据存储、传输路径与访问权限管理
网络·安全·音视频·实时音视频·信息与通信