上一篇文章中,我们已经对UDP协议进行了比较深入的讲解,从报文格式到核心特性,再到一些容易忽略的细节,相信大家对UDP已经有了更清晰的认识。不过,传输层可不只有UDP,还有一个我们经常接触、却远比UDP复杂的协议——TCP(Transmission Control Protocol,传输控制协议)。

从本文开始,我们就要正式进入TCP的学习了。和UDP不同,TCP涉及的知识点非常多,背后的设计思想也更加复杂。 所以,接下来我会花费相当多的篇幅,带大家一步步深入TCP的底层原理,弄清楚它究竟是如何实现可靠传输的。

而且,这部分内容也是校招面试中的高频考点密集区。无论是三次握手、四次挥手,还是确认应答、超时重传、滑动窗口、流量控制和拥塞控制,都是面试官非常喜欢考察的知识点。

不过,我还是想强调一点:理解原理,远比死记硬背八股文重要。

毕竟,面试官稍微换个问法,或者多追问几句,如果只是机械地背答案,很容易就答不上来了。而真正理解了TCP为什么要这样设计、每种机制究竟解决了什么问题,很多面试题自然也就能想明白了。所以,这一系列文章,我们不急着背结论,而是尽量从问题出发,一点点推导出TCP背后的设计思路。好了,话不多说,我们正式开始!

目录

一、重新认识网络报文:从内核管理到封装与解包

1.1 网络报文如何管理:从“先描述,再组织”说起

1.2 封装与解包的本质:仅仅是移动data指针吗?

1.3 回顾网络报文的封装过程:从数据组织到并发处理

二、TCP报头与字节流:深入理解数据交付与报文边界

2.1 TCP报头的两个核心问题:如何交付数据,如何分离首部与载荷?

2.2 为什么TCP报头没有独立的数据长度字段:从报文段到字节流的本质


一、重新认识网络报文:从内核管理到封装与解包

前面我们在学习UDP和HTTP协议时,已经接触过不少报文相关的知识。不过,对于报文,我们似乎一直都在关注它的格式、字段以及如何发送和接收,却很少思考另一个问题:这些报文到了操作系统内部,究竟是如何被管理的呢?

我们先来看一个比较常见的网络通信场景。在实际开发中,客户端和服务器之间往往是一种N:1(多对一) 的关系。也就是说,一台服务器可能同时为成百上千,甚至更多的客户端提供网络服务。

那么问题来了:当大量客户端同时向服务器发送数据时,会发生什么呢?

很显然,服务器需要接收和处理来自不同客户端的网络报文。而这些报文不一定能够被应用程序立即处理,部分报文还需要暂时存放在操作系统内核的相关缓冲区或队列中。

这样一来,随着并发请求数量不断增加,操作系统内部就可能需要维护大量等待处理的报文。

既然存在大量报文,那操作系统就必须想办法对它们进行管理!

毕竟,操作系统不可能把这些报文随意堆放在那里,总得知道哪些报文已经到达、哪些还在等待处理,以及应该将它们交给哪个套接字。所以,接下来我们就换一个角度,不再只是把报文看成一串需要传输的二进制数据,而是站在操作系统的角度,重新理解报文的组织与管理方式。

而这,恰恰是我们继续深入TCP底层原理时,一个非常重要的切入点。

1.1 网络报文如何管理:从“先描述,再组织”说起

前面我们已经知道,当大量客户端同时向服务器发送数据时,操作系统内部就可能需要管理大量的网络报文。

那么问题来了,面对这么多报文,操作系统究竟是怎么管理它们的呢?其实,还是我们之前学习操作系统时反复强调的那套思想——先描述,再组织!什么意思呢?

很简单。操作系统想要管理某个对象,就得先把这个对象的相关信息描述出来,再利用合适的数据结构将它们组织起来,实现统一管理。

对于网络报文来说,同样如此。

在Linux内核中,有一个非常重要的数据结构,叫做struct sk_buff,也就是我们常说的Socket Buffer,简称SKB。

它主要用于描述网络数据包,记录与报文相关的各种信息,并关联实际存放报文数据的内存缓冲区。而且,在struct sk_buff中,还包含两个比较重要的成员:

struct sk_buff *next;
struct sk_buff *prev;

看到这里,大家应该觉得很熟悉吧?

没错!这不就是我们之前学习过的双向链表吗?

  • next:用于指向链表中的下一个sk_buff节点。

  • prev:用于指向链表中的上一个sk_buff节点。

有了这两个指针,Linux内核就可以根据需要,将多个sk_buff组织成双向链表队列,方便进行报文的插入、删除和管理。当然,内核并不是所有场景都使用双向链表管理报文,还会根据具体需求采用其他数据结构。不过,这里真正值得我们关注的,是操作系统管理网络报文的思想:先用结构体描述报文,再通过数据结构组织这些报文。

这样一来,原本需要管理的大量网络报文,就可以转化为对一个个内核数据结构的管理。

由此,我们就能得到一个非常重要的理解:

从Linux内核管理报文的角度来看,可以把一个报文抽象为:描述报文的结构体 + 存放实际数据的内存缓冲区。

不过,这里需要注意,sk_buff本身主要保存的是元数据,并不是把整个报文内容直接存放在结构体内部。

那么,结构体和实际存放报文数据的内存之间,又是怎么关联起来的呢?

这就要继续看看sk_buff的内部结构了。

把这个结构抽象出来看:

在sk_buff中,有四个非常重要的成员,分别是:

  • head:指向主要线性数据缓冲区的起始位置。

  • data:指向当前有效数据的起始位置。

  • tail:标记当前线性数据区域的结束位置。

  • end:标记主要数据缓冲区的边界。

这里需要说明,head和data是指针,而tail和end在现代Linux内核的部分配置下使用的是偏移量,并不一定都是指针。不过,为了方便理解,我们可以先把它们看作标记缓冲区不同位置的边界。

那么,这块缓冲区里面究竟放了些什么呢?在一个典型的网络报文中,线性数据区域可能包含以下内容:

预留头空间(Headroom)→ 数据链路层报头 → IP 协议报头 → TCP/UDP 协议报头 → 应用层数据 → 预留尾空间(Tailroom)

当然,这只是为了方便理解而画出的简化布局。实际报文在不同协议层之间传递时,相关报头的位置可能发生变化;而且,Linux还支持非线性sk_buff,报文数据不一定全部存放在同一块连续内存中。

那么,为什么要设计出head、data、tail、end这些成员呢?

因为网络报文在协议栈中传递时,经常需要添加或者移除不同协议层的报头。

比如,应用层数据向下交付时,传输层可能需要添加TCP报头,网络层需要添加IP报头,到了数据链路层,还需要添加对应的帧头。

如果每增加一层报头,都重新申请内存、复制整份数据,那岂不是太浪费资源了?

所以,Linux内核可以利用sk_buff提供的缓冲区管理机制,通过调整数据边界等方式,高效地完成报头的添加和移除,减少不必要的数据复制。

说白了,sk_buff不只是用来描述一个网络报文,更重要的是,它为Linux内核提供了一套统一管理和操作网络数据的机制。

到这里,我们就能进一步理解操作系统中那句非常经典的话了:先描述,再组织。

先通过struct sk_buff描述网络报文,再利用链表等数据结构组织和管理这些报文。至于实际的数据内容,则通过关联的内存缓冲区进行保存。这样一来,操作系统就能够更加高效地管理网络协议栈中大量流转的报文。

1.2 封装与解包的本质:仅仅是移动data指针吗?

前面我们已经了解了struct sk_buff的基本结构,知道了Linux内核是如何通过结构体和内存缓冲区来描述、管理网络报文的。

不过,讲到这里,大家有没有想过一个问题:我们之前一直在说,数据从应用层向下传递时,需要不断添加协议报头;而数据从底层向上传递时,又需要逐层去掉对应的报头。那么,这个过程在操作系统内部究竟是怎么实现的呢?

难道每经过一层协议,操作系统都要重新申请一块内存,再把原来的数据复制过去吗?如果每次都这样操作,那开销可就太大了!

其实,在Linux内核中,sk_buff提供了一种非常巧妙的处理方式:通过调整data指针的位置,就可以高效地完成部分报头的添加与剥离操作,而不必每次都复制整个数据包。

我们先来看数据封装的过程。

(1)数据封装:data指针向低地址方向移动

假设现在有一份应用层数据,需要通过UDP协议进行发送。

那么,当数据交给传输层时,就需要在应用层数据前面添加一个UDP报头。

我们知道,UDP报头的大小固定为8字节。

在sk_buff的线性缓冲区中,如果前面已经预留了足够的头空间(Headroom),就可以让data指针向低地址方向移动8字节,为UDP报头腾出位置。

抽象成代码,就是:

data -= sizeof(struct udphdr);

注意,这里只是帮助大家理解指针移动的原理,并不是建议直接这样修改内核中的 skb->data。

在Linux内核中,通常可以使用skb_push()完成类似的操作:

struct udphdr *uh;

uh = (struct udphdr *)skb_push(skb, sizeof(struct udphdr));

这里的skb_push()会将data向低地址方向移动,同时更新sk_buff中记录的数据长度。

这样一来,原本位于data后面的应用层数据不需要整体移动,只需要利用新腾出来的空间填写UDP报头即可。

比如,我们可以通过获取到的uh指针访问UDP报头字段:

uh->source = htons(source_port);
uh->dest   = htons(dest_port);

当然,这只是用于理解报头填充的局部示例,真正构建UDP报文时,还需要正确设置长度、检验和等字段,并确保缓冲区具有足够的头空间以及相应的写入权限。

这里还有一个细节:如果只是向数据区头部添加报头,通常不需要移动tail所表示的数据结束位置。

因为我们是在已有数据的前面扩展空间,而不是在数据末尾追加内容。所以,从原理上看,封装就是让data向前移动,为新的协议报头预留位置,再将对应的字段填写进去。

(2)数据解包:data指针向高地址方向移动

既然封装是添加报头,那么解包自然就是反过来的过程。当一个网络数据包从底层向上传递时,操作系统需要根据协议格式,逐层解析对应的报头。

比如,网络层完成IP报头的相关处理后,需要将数据交给传输层。这时候,就可以在适当的处理阶段,将data指针向高地址方向移动,跳过已经处理的协议报头。

如果我们仍然以UDP报头为例,抽象成代码就是:

data += sizeof(struct udphdr);

而在Linux内核中,可以使用skb_pull()来完成类似的操作:

skb_pull(skb, sizeof(struct udphdr));

这个函数会将data向高地址方向移动,并相应减少当前sk_buff的有效数据长度。

注意,这里所谓的“剥离报头”,并不一定是把报头对应的字节从内存中真正删除,而是通过调整数据起始位置,让后续处理逻辑不再把这部分内容当作当前有效载荷。

不过,实际的网络解包过程也不只是简单移动指针,还需要完成协议字段解析、长度检查、检验和校验等工作。

所以,我们可以这样理解:

  • 封装: 根据协议需要,在数据前面预留空间,添加对应的协议报头。

  • 解包: 解析当前协议报头,并在合适的时候调整数据起始位置,将有效载荷交给上层协议继续处理。

说白了,data指针的移动,是Linux内核高效处理网络报文封装与解包的重要手段之一。

(3)报文在不同协议层之间是如何传递的?

看到这里,可能有人会产生这样一种理解:

报文在哪一层,就应该放在哪一层对应的队列中。那么,网络协议栈有四层,是不是就意味着内核中固定存在四个sk_buff队列呢?

其实,并不是这样。虽然我们经常将TCP/IP网络协议栈划分为应用层、传输层、网络层和数据链路层,但这只是一种便于理解网络通信的分层模型,并不代表Linux内核必须按照这四层,分别维护四个固定的报文队列。

实际情况要复杂一些。在Linux内核中,网络报文会根据协议处理流程,在不同的函数、模块以及必要的缓冲队列之间传递。比如,网卡接收队列、Socket接收队列以及网络设备发送队列等,都可能涉及sk_buff的组织和管理。

有些处理过程需要将报文加入队列等待,有些则可以直接将sk_buff交给下一阶段的处理函数,并不一定每经过一个协议层都要入队一次。因此,我们不能简单地认为网络协议栈有四层,操作系统内部就一定存在四个对应的sk_buff队列。

不过,有一点是可以确定的:sk_buff为Linux网络协议栈提供了统一的报文描述与缓冲区管理机制,使数据能够在不同协议处理阶段之间高效传递。

而data指针的调整,则是其中一个非常重要的实现技巧。到这里,我们也就能够把前面学习过的知识联系起来了。

操作系统通过sk_buff描述报文,通过相关数据结构组织报文,再利用缓冲区管理机制完成协议报头的添加、解析以及数据交付。

这样一来,网络协议栈就不需要在每次封装和解包时,都重新复制整份报文数据,从而减少不必要的内存操作,提高网络通信的处理效率。

1.3 回顾网络报文的封装过程:从数据组织到并发处理

前面我们已经从Linux内核的角度,了解了sk_buff的基本结构,以及如何通过调整 data 指针,实现协议报头的添加与剥离。现在,我们不妨回过头来,再看看之前对网络报文封装过程的理解。在最开始学习Socket网络编程时,我们通常会把数据发送过程理解为:

应用程序创建缓冲区,填入需要发送的数据 → 调用发送接口 → 操作系统将数据交给传输层处理 → 添加对应的协议报头 → 继续向下交付。

这个理解当然没什么问题,只不过,当时我们更多关注的是数据如何从应用层一步步交付给底层,并没有深入了解操作系统内部究竟是怎么管理这些报文的。

现在有了sk_buff的知识,我们就可以把这个过程理解得更加具体一些。以普通的Socket发送过程为例,应用程序先在用户空间准备好数据,再通过send()、sendto()等接口发起发送请求。

操作系统收到请求后,会根据具体的协议处理流程,分配或组织相应的内核缓冲区,并将用户数据复制或映射到内核能够处理的区域,随后完成协议报头的构建与数据发送。

当然,具体实现还会受到协议类型和发送方式的影响,并不是所有场景都必须重新复制整份数据。

整个过程和思路,我已经整理成了一张板书,大家可以结合前面的内容看一看。

不过,讲到这里,我突然想到一个问题:如果应用层正在解析、处理某个报文,会不会影响操作系统继续从网络中接收新的报文呢?为什么?

这个问题其实挺有意思的。我们知道,应用层程序通常运行在用户态,而网络数据的接收涉及网卡、驱动程序以及操作系统内核中的网络协议栈。当网卡收到数据时,并不需要等应用程序把上一个报文处理完,才能继续接收新的网络数据。

在现代操作系统中,网络数据的接收通常可以借助硬件中断、NAPI轮询以及DMA(直接内存访问)等机制完成。其中,DMA能够让设备与内存之间进行数据传输,减少CPU直接参与数据搬运的开销。而操作系统则可以通过中断、软中断以及相关调度机制,对收到的数据进行后续处理。

也就是说,应用程序处理报文和操作系统接收网络数据,并不需要严格按照“一方处理完,另一方才能开始”的顺序执行。

比如,应用程序正在解析上一批收到的数据时,网卡依然可以继续接收新的网络数据,内核也可以将符合条件的报文放入相应的接收队列,等待应用程序后续读取。

这也是为什么我们之前强调,操作系统需要通过sk_buff等数据结构,对大量网络报文进行统一管理。不过,这里有一个细节需要注意。

能够并发处理,并不代表应用层的处理速度完全不会影响网络接收。

如果应用程序处理数据的速度太慢,长时间不读取Socket中的数据,那么内核接收缓冲区就可能逐渐被占满。

对于UDP来说,缓冲区不足可能导致后续到达的数据报被丢弃;而对于TCP来说,还可能通过接收窗口等流量控制机制,限制发送方继续发送数据。

所以,操作系统可以在应用程序处理数据的同时继续接收网络报文,但这种并发能力并不是无限的。

说到这里,我们就能把之前学习的操作系统知识与网络协议联系起来了。

硬件负责接收数据,内核负责组织和管理报文,应用程序则负责读取并处理数据。三者相互配合,才能完成整个网络通信过程。

而我们前面学习的sk_buff,正是Linux内核管理网络报文的重要数据结构之一。

二、TCP报头与字节流:深入理解数据交付与报文边界

TCP的全称是传输控制协议(Transmission Control Protocol)。人如其名,TCP的核心职责,就是对数据的传输过程进行更加精细、可靠的控制。

不过,在正式分析TCP的报头结构之前,我们不妨先换个角度,从操作系统的层面,重新理解一下 TCP究竟是怎么传输数据的。还记得前面学习文件系统时,我们讲过的文件缓冲区吗?

在Linux中,操作系统会通过struct file等数据结构管理打开的文件。当应用程序调用write()向普通文件写入数据时,在常见的缓冲I/O场景下,数据通常会先进入内核的页缓存,而不是每次都立即写入磁盘。

其实,TCP的数据发送过程,也有类似的设计思想。对于TCP来说,操作系统同样会维护相应的发送缓冲区和接收缓冲区。

假设应用程序想通过TCP向另一台主机发送一段数据,那么它就可以调用write()或send(),将需要发送的数据交给操作系统。

write(sockfd, buffer, strlen(buffer));

在常见的发送流程中,数据会先被复制到内核的TCP发送缓冲区,随后由TCP协议栈根据当前的连接状态、发送窗口等条件,决定什么时候发送、发送多少数据。

也就是说,应用程序调用write(),并不意味着数据会立刻通过网卡发送出去,更不代表对方已经成功接收。

对于阻塞式Socket来说,只要发送缓冲区有足够的可用空间,数据通常就能较快地交给内核,应用程序也就可以继续执行后面的业务逻辑。

当然,如果发送缓冲区已经满了,write()也可能阻塞等待;对于非阻塞Socket,则可能出现部分写入或者返回EAGAIN等情况。

所以,我们不能简单地认为TCP的write()永远不会阻塞。

不过,这里真正值得关注的,是应用程序的数据写入操作,与 TCP 底层实际的数据发送过程,并不要求完全同步进行。

我们可以把这个过程理解成一种比较复杂的“缓冲与刷新”机制。

应用程序负责把数据交给操作系统,而TCP协议栈则负责后续的数据组织、发送、确认以及必要时的重传等工作。

这样一来,应用程序就不需要每发送一段数据,都等待对方确认接收后才能继续执行,从而提高整体的通信效率。

而在接收端,TCP同样会维护接收缓冲区。网络数据到达后,内核会完成相应的协议处理,再将数据放入接收缓冲区,等待应用程序通过read()或recv()读取。不过,这里还有一个非常重要的细节:

TCP 是面向字节流的协议,并不会保留应用程序每次write()所对应的消息边界。

比如,发送端连续调用两次write(),并不意味着接收端一定要调用两次read()才能把数据读取完整。接收端可能一次读到两次写入的数据,也可能需要多次读取才能获得完整内容。

因此,如果应用程序需要传输具有明确结构的消息,就得自行设计应用层协议,完成数据的序列化、报文边界识别与反序列化。

到这里,我们就能发现,TCP的发送与接收并不是简单地把一份数据从A主机搬到B主机,而是涉及缓冲区管理、数据组织以及一系列传输控制机制。

那么问题来了:TCP究竟是如何管理这些数据的?它又是怎么保证数据可靠到达对方的呢?

想要真正理解这些问题,我们就得从TCP的报头结构入手,逐步分析它背后的设计思想。接下来,我们就正式进入TCP协议的核心内容。

2.1 TCP报头的两个核心问题:如何交付数据,如何分离首部与载荷?

前面我们已经了解了TCP的基本工作方式,接下来就正式看看TCP报头究竟长什么样。

这里我画了一张 TCP 报文格式的抽象图,大家可以结合图中的字段来看一下。

其实,不管是之前学习的UDP,还是现在的TCP,网络协议在设计时,都需要解决两个非常核心的问题:

  • 如何分离: 操作系统收到一个报文后,怎么知道哪部分是协议报头,哪部分才是真正的有效载荷?

  • 如何交付(分用): 报文已经到达主机,操作系统又该怎么知道,应该把数据交给哪个应用程序?

我们先来看比较容易理解的一个问题。

(1)如何交付?

还记得前面学习UDP时,我们是怎么解决这个问题的吗?没错,就是通过报头中的目的端口号。

TCP同样如此,它的报头中也有一个16位的目的端口号字段。当操作系统收到TCP报文后,就可以提取目的端口号,再结合源 IP、目的 IP、源端口号等信息,找到对应的TCP Socket,最终让应用程序读取数据。

这里需要注意,对于已经建立的TCP连接,操作系统并不是只靠目的端口号就能唯一确定对应连接的,而是需要结合通信双方的地址和端口信息。

好了,交付的问题解决了。那么,报头和有效载荷又该怎么分离呢?

(2)如何分离?

这个问题就稍微有点意思了。

我们观察TCP的报文格式,会发现其中有一个非常重要的字段,叫做4位首部长度(Data Offset)。顾名思义,它就是用来表示TCP报头长度的。但是,问题来了。

4位二进制能够表示的数值范围只有0000~1111,也就是十进制的0~15。

如果一个数值代表1个字节,那TCP报头最多只能有15个字节,这显然不够用啊!

毕竟,TCP光是固定的基本报头,就已经占用了20个字节。

怎么办呢?其实,TCP 的设计者采用了一个很巧妙的办法:

不再让首部长度字段以1字节为单位,而是以4字节为一个基本单位。

也就是说,首部长度字段中的数值并不直接表示字节数,而是表示报头占用了多少个4字节。

这样一来,TCP报头的实际长度就可以通过下面这个公式计算:

TCP 报头长度 = 首部长度字段的值 × 4 字节

比如,当首部长度字段的值为5时:

5 × 4 = 20 字节

这就表示TCP报头一共有20个字节。

如果这个字段的值为15,那么:

15 × 4 = 60 字节

此时,TCP报头的长度就达到了60字节。

不过,虽然4位二进制理论上能够表示0~15,但TCP报头不能小于固定的20字节。

所以,首部长度字段真正有效的取值范围是5~15,而TCP报头的实际长度范围就是20~60字节。

对应的二进制范围为:

0101 ~ 1111

这里还有一个细节。

TCP固定报头占用20字节,剩下的部分就是可变长度的选项区域(Options)以及必要的填充(Padding)。因此,我们也可以通过下面这个公式,计算选项与填充区域的总长度:

选项与填充长度 = 首部长度 × 4 − 20

注意,要求按照4字节对齐的是TCP报头的总长度,并不是整个TCP报文。 如果选项本身的长度不是 4 字节的整数倍,就可以通过填充字节完成对齐。

讲到这里,大家应该就能明白,TCP为什么需要这个4位的首部长度字段了。

(3)报头与有效载荷的分离过程

既然已经知道TCP是怎么表示报头长度的,那么分离报头和有效载荷就不难了。

整个过程可以这样理解:

  1. 读取固定报头: TCP的基本报头至少有20字节,因此需要先获取并检查这部分数据。

  2. 提取首部长度: 从报头中读取4位首部长度字段,假设它的值为x。

  3. 计算报头总长度: 通过x × 4,得到完整TCP报头的长度,并检查其是否有效。

  4. 计算选项区域长度: 使用4 × x − 20,得到选项和填充部分的总长度。

  5. 分离有效载荷: 在确认整个TCP报头完整后,跳过相应的报头长度,剩下的TCP段数据就是有效载荷。

这样一来,操作系统就能准确区分TCP报头与真正需要交付给上层的数据。

不过,首部长度字段只能告诉我们TCP报头有多长,并不能直接告诉我们整个TCP报文有多长。 实际解析时,还需要结合 IP 层提供的数据长度信息,确定TCP段的完整范围。

说到这里,我们就能发现,TCP和UDP虽然采用了不同的报头设计,但它们解决问题的思路其实是相通的。

UDP使用固定的8字节报头,而TCP则通过首部长度字段,支持20~60字节的可变长度报头。

说白了,UDP靠固定长度区分报头和数据,TCP则通过首部长度字段来确定两者的边界。

不过,TCP能够区分协议报头和有效载荷,并不意味着它能够自动区分应用层每次发送的消息边界。毕竟,TCP本质上还是一个面向字节流的协议。而这,也正是我们后面继续理解TCP时,需要牢牢记住的一个重要特点。

2.2 为什么TCP报头没有独立的数据长度字段:从报文段到字节流的本质

前面我们已经知道,TCP可以通过报头中的4位首部长度字段,准确区分协议报头和有效载荷。

不过,看到这里,可能有人又会产生一个疑问:既然TCP报头中只有首部长度,没有整个报文的长度,那操作系统怎么知道这次应该读取多少数据呢?会不会把当前报文的有效载荷和下一个报文的报头混在一起?

其实,这里我们需要区分两个概念:TCP报文段的边界和应用层消息的边界。

先来看TCP报文段。前面我们已经介绍过,Linux内核会通过struct sk_buff来描述和管理网络数据包。每个 sk_buff 都维护着相应的数据长度等信息,内核并不是毫无边界地接收和解析数据。

不过,sk_buff并不一定与一个TCP报文段严格一一对应,内核还可能通过GRO、GSO等机制合并或拆分网络数据。

那么,TCP报头为什么没有专门设计一个报文段长度字段呢?

原因其实很简单:TCP报文段封装在IP数据报中,而IP层本身就提供了确定数据长度的信息。

比如,在普通IPv4数据报中,我们可以通过IP首部中的总长度字段,计算TCP报文段的长度:

TCP 报文段长度 = IP 总长度 - IP 首部长度

TCP 有效载荷长度 = TCP 报文段长度 - TCP 首部长度

这样一来,TCP就不需要在自己的报头中,再额外设计一个字段来重复记录整个报文段的长度了。不过,真正值得我们关注的,还不是这个问题。

为什么TCP不需要记录应用层每一条消息的长度呢?

这就涉及TCP一个非常重要的设计理念——面向字节流(Byte Stream)。与UDP不同,TCP并不保留应用程序每次发送数据时的消息边界。它主要负责向应用程序提供可靠、有序的字节流传输服务,而不是按照应用层的一条条业务消息进行交付。

我们可以从两个角度来理解。

(1)不关心:这是应用层与传输层之间的职责分工

TCP是传输层协议,它主要负责连接管理、可靠传输、流量控制以及拥塞控制等工作。至于应用层交付的数据究竟是什么,TCP并不关心。比如,这些数据可能是一张图片、一段JSON,也可能是一个HTTP请求。

对TCP来说,这些都只是需要传输的字节。至于字节背后代表什么业务含义,那是应用层需要考虑的事情。

所以,TCP不需要专门为应用层设计一个记录业务消息大小的字段。

(2)不理解:TCP并不知道应用层消息的边界

TCP看到的是连续的字节序列,并不知道哪些字节属于一条完整的业务消息。比如,应用程序连续发送了两段数据:

send(sockfd, "Hello", 5, 0);
send(sockfd, "World", 5, 0);

那么,接收端就一定会先收到Hello,再收到World 吗?还真不一定!

注意,这里并不是说TCP会把字节顺序打乱,而是说接收端每次recv()读取到的数据范围,不一定与发送端每次send()的范围相同。

接收端可能一次读取到:

HelloWorld

也可能分成多次读取:

Hel
loWo
rld

但无论怎么读取,只要连接正常完成可靠传输,最终得到的字节顺序仍然是HelloWorld。

这就是TCP面向字节流的特点:保证字节流的顺序,却不保证应用层消息的边界。

那么问题来了,如果我们发送的是两条完整的业务报文,接收端该怎么判断它们各自从哪里开始、到哪里结束呢?这就需要应用层自己制定规则了。

比如,我们可以通过固定长度、特殊分隔符,或者在应用层报头中添加长度字段等方式,确定每条消息的边界。

而我们平时经常听到的 TCP 粘包、半包问题,本质上就是应用程序不能直接把一次send()当作一条完整消息,也不能把一次recv()当作接收到一条完整消息。

说白了,TCP负责把字节可靠、有序地送到对方,而如何从这些字节中还原出一条条完整的业务消息,则需要应用层协议自行解决。

所以,TCP报头中没有专门记录应用层消息长度的字段,并不是协议设计不完善,而是因为TCP从一开始就没有打算替应用层管理消息边界。

理解了这一点,我们也就真正抓住了TCP面向字节流的核心思想。


好了,到这里,本篇关于TCP协议的内容就先告一段落了。

这一篇作为我们深入学习TCP的开篇,主要从操作系统的角度出发,重新认识了网络报文的组织与管理方式。从Linux内核中的sk_buff结构,到“先描述,再组织”的管理思想,再到通过调整data指针完成协议报头的封装与解包,相信大家对网络报文在内核中的处理过程,已经有了更加清晰的认识。

随后,我们又正式接触了TCP的报头结构与面向字节流的特性,弄清楚了TCP如何通过首部长度字段区分报头与有效载荷,以及为什么TCP报头不需要单独记录整个报文段的长度。

当然,这里面还有一个非常重要的知识点:TCP负责可靠、有序地传输字节流,但并不负责维护应用层消息的边界。 至于一条完整的业务消息应该从哪里开始、到哪里结束,还得由应用层协议自行解决。

其实,回过头来看,这些知识点看似零散,背后却有着紧密的联系。从报文的内存管理,到协议报头的设计,再到数据的发送与接收,每一种机制都有它存在的原因。

而我们学习TCP,真正重要的也不是死记硬背几个字段、几条结论,而是要搞清楚:TCP 为什么要这样设计?它究竟解决了哪些问题,又是通过什么方式解决的?

毕竟,只有把这些底层原理真正弄明白,后面再去理解TCP的可靠传输机制,思路才会更加清晰。

不过,这还只是TCP的开始。接下来,我们还会继续深入TCP的其他核心机制,包括序列号与确认应答、超时重传、连接管理、滑动窗口等内容。这些不仅是TCP协议的重点,也是校招面试中经常出现的高频考点。还是那句话,理解原理比死记硬背八股文更重要。 当我们能够顺着问题一步步推导出答案时,很多看似复杂的知识点,其实也就没那么难了。

好了,本篇文章就先聊到这里。如果文章中有什么理解不到位或者讲错的地方,也欢迎大家在评论区指出,一起交流、一起进步!感谢大家的阅读,我们下篇文章再见!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐