TCP协议段格式

1.TCP报头至少20字节,每一行四字节,则至少5行,还可以选择十个选项,每个选项是一行,即四字节,那就是说tcp报头最大60字节
- 4位首部长度表示的不是tcp报头的字节数,而是tcp报头的行数,所以叫做4位首部行数更合适,这个值的范围应该是5~15。两个字节共十六位,去掉4位首部行数,保留6位什么都不干,然后还有6位是6个标志位
3.URG标志位(urgent)和16位紧急指针需要共同使用,URG表示有紧急数据,紧急指针是紧急数据的字节数,从有效载荷的第一个字节开始数
- 例如:紧急指针值为
10
,则紧急数据为有效载荷的前 10 个字节。
ACK(acknowlege)标志位表示应答,纯ACK报文其有效载荷是没有数据的,也不会消耗序列号
PSH(push)表示提交数据,其有效载荷就是要提交的数据,PSH和ACK可以一块用,即捎带应答
RST(reset)表示重新建立连接,当收到报文后,硬件中断进入协议栈,解包到tcp层后,根据五元组找到套接字,然后将套接字完全释放,重新建立连接需要应用层的帮助,重新connect才行,RST是做不到依靠协议栈来重新建立连接的
SYN(Synchronize)表示要建立连接(同步),其有效载荷并没有数据,但会消耗一个序列号,逻辑上视为 1 字节虚拟数据
FIN(final)表示要断开连接,其有效载荷并没有数据,但会消耗一个序列号,逻辑上视为 1 字节虚拟数据
4.序号和确认序号
在tcp三次握手时会商议初始序号(ISN Initial Serialize Number),后续的整个数据通信和四次挥手都是基于ISN一直递增。
序号用来给发送缓冲区中的数据编号,一个字节数据占一个序号,SYN和FIN也会消耗一个序号
确认序号是期望收到的下一个序号

1. 三次握手(ISN协商)
-
**Client → Server: SYN, Seq=1000(Seq是序号,Ack是确认序号)**
- 客户端发送 SYN,初始序列号
ISN_C=1000
。
- 客户端发送 SYN,初始序列号
-
Server → Client: SYN-ACK, Seq=5000, Ack=1001
-
服务器确认客户端的
ISN_C
,并发送自己的ISN_S=5000
。 -
Ack=1001
表示期望客户端下次发送Seq=1001
。
-
-
Client → Server: ACK, Seq=1001, Ack=5001
- 客户端确认服务器的
ISN_S
,握手完成。
- 客户端确认服务器的
2. 数据传输(基于ISN递增)
-
Client → Server: PSH-ACK, Seq=1001, Ack=5001, data="Hello"
- 客户端发送数据(假设长度 5 字节),下次
Seq=1006
。
- 客户端发送数据(假设长度 5 字节),下次
-
Server → Client: ACK, Seq=5001, Ack=1006
- 服务器确认收到数据,期望下次客户端发送
Seq=1006
。
- 服务器确认收到数据,期望下次客户端发送
-
Server → Client: PSH-ACK, Seq=5001, Ack=1006,data="nihao"
-
Client → Server: ACK, Seq=1006, Ack=5006
.......
3. 四次挥手(正常关闭)
-
Client → Server: FIN-ACK, Seq=2000, Ack=5001
- 客户端发起关闭,序列号从上次结束位置递增。
-
Server → Client: ACK, Seq=5001, Ack=2001
- 服务器确认客户端的 FIN。
-
Server → Client: FIN-ACK, Seq=5001, Ack=2001
- 服务器发起自己的 FIN(数据已发完)。
-
Client → Server: ACK, Seq=2001, Ack=5002
- 客户端确认服务器的 FIN,连接完全关闭。
确认序号(期望收到的下一个序号)= 接收到的报文的序号+有效载荷的字节长度
序号 = 接收到的报文的确认序号(因为你期望得到的下一个序号,就是我将要为之发出的)
- 16位窗口大小
十六位窗口大小指的是接收窗口的大小,接收窗口是套接字接收缓冲区还能接收数据的大小
-
16位校验和
-
伪头部(Pseudo-Header):
包含IP层的关键信息(源IP、目的IP、协议号、TCP长度),用于确保这些字段未被篡改。
源IP(32位) | 目的IP(32位) | 保留(8位) | 协议号(8位) | TCP总长度(16位)
共12字节 -
TCP头部:
包括所有字段(源端口、目的端口、序列号、确认号等),校验和字段本身置为0。
-
有效载荷:
应用层数据(若长度为奇数,补0对齐)。
2. 校验和的计算方法
校验和是通过16位反码求和(One's Complement Sum)计算的,具体步骤如下:
-
初始化:将校验和字段置为0。
-
分段求和:将伪头部、TCP头部和有效载荷按16位(2字节)分段,累加所有分段。
-
反码处理:将累加结果取反码(按位取反),得到最终校验和。
-
验证 :接收方对所有字段(包括校验和)再次求和,若结果为
0xFFFF
,则校验通过。
公式表示:
校验和 = ~(伪头部之和 + TCP头部之和 + 数据之和)
TCP保证可靠传输的机制
1.确认应答机制
对于除了ACK/RST以外的tcp请求报文,必须有ACK报文进行确认应答
ack报文的生成规则
确认序号 = 收到的报文的序号 + 有效载荷长度
2.超时重传机制
对于除了ACK/RST这种不需要应答的报文以外,其余请求报文必须有ACK进行确认应答,倘若等待一段时间没收到确认应答,那就会超时重传请求报文。OS发送这种需要应答的报文会创建对应定时器,并通过时钟中断推动定时器,实现超时重传的功能,收到ack后释放对应定时器
超时时间是动态计算的:
第一次500ms
第二次2*500ms
第三次4*500ms
.....
若超时了五次,发送FIN也没什么意义,因为FIN也收不到确认应答,所以会直接释放自个通信套接字!
3.三次握手四次挥手连接管理机制

我们首先要明确一件事,那就是进程调用系统调用和TCP层的行为是既有同步也有异步的,即应用层和传输层的同步和异步,应用层是主要靠进程来推动,传输层主要是靠OS硬件中断、时钟中断来推动的
三次握手过程:
服务器进程调用socket先创建一个套接字,然后调bind将套接字绑定地址,接着调用listen将套接字设置为监听套接字,调用accept,这时因为监听套接字的全连接队列是空,所以服务器进程会在全连接队列的等待队列上阻塞等待。客户端进程用socket创建一个套接字,然后用connect,套接字会先自动绑定一个地址,修改套接字状态为SYN_SENT,开始组装SYN报文,然后交给IP层,封装IP报头,根据目的ip查路由表找到下一跳IP和发送网卡接口,然后将这些都交给数据链路层,网卡驱动会查arp缓存,得到目的mac地址,如果缓存中没有那就广播arp请求,然后目的主机会单播返回应答,最终也是得到了目的mac地址,然后接下来就是封装mac头和CRC校验,封装好之后,将mac帧写进发送网卡的发送缓冲区中,接着写发送网卡的TDT寄存器,通知网卡数据就绪,网卡会利用DMA将数据读出,然后HVY将数字信号转成光电信号,用接口发出,客户端进程会在套接字通用等待队列上阻塞。服务器网卡接收到光电信号后用HVY转成数字信号,然后DMA写进网卡接收缓冲区,接着网卡触发硬件中断,中断控制器生成中断号,给目标cpu设置高电平中断引脚,cpu执行完执行周期后进入中断周期,cpu检查if寄存器和中断引脚,然后将pc保存在用户栈栈顶,cpu陷入内核,将用户态寄存器保存在内核栈中,然后从中断控制器获取中断号,查中断向量表执行中断方法,实际上就是走协议栈,数据链路层网卡驱动从接收缓冲区中读出数据,然后检查mac头和crc校验位,无误之后交给ip层,IP层接收到全部分片后组装出tcp报文,交给tcp层,tcp层一看是SYN报文,那就去三元组里查套接字,查到监听套接字后,在半连接队列里创建struct request_sock,通信套接字雏形就出来了,其状态为SYN_RCVD,然后该次硬件中断还没完,会组建捎带应答SYN+ACK报文,然后发出。客户端网卡收到后同理会触发硬件中断,在传输层根据五元组找到通信套接字,然后唤醒套接字通用等待队列上的进程,套接字状态改成ESTABLISHED,接着还要再组建ACK应答发过去。客户端进程被唤醒后connect返回,接着执行剩下逻辑。服务器端网卡收到ACK后,触发硬件中断,然后层层解包到传输层,先去五元组里查找,发现找不到对应套接字,那就去三元组查找,找到监听套接字,然后将半连接队列中的struct request_sock给升级成struct sock加进全连接队列里,然后通信套接字状态改成ESTABLISHED,唤醒监听套接字全连接队列的等待队列上的服务器进程,服务器进程执行accept,将全连接队列中的struct sock创建对应的struct socket 和 struct file,返回对应的文件描述符
数据传输过程:
客户端主机收到SYN+ACK后,客户端进程被从套接字通用等待队列上唤醒,然后会调用write,将应用层数据写进通信套接字发送缓冲区中,等待数据达到MSS或没有发出但还未收到应答的数据那就开始组装报文,如果这两个条件都不满足还有保底的定时器机制,总之发送缓冲区数据会被加到tcp报文的有效载荷里面,然后走协议栈层层封装报头,最后发出去,服务器网卡收到后触发硬件中断,走协议栈层层解包,到tcp层会通过五元组找通信套接字,找到后将有效载荷弄进套接字接收缓冲区中,然后唤醒通信套接字接收缓冲区等待队列上的服务器进程,硬件中断接着组装ACK确认应答,然后发出去,毕竟发送数据是PSH报文,这是需要确认应答的。然后服务器进程被唤醒后会从接收缓冲区中读数据,然后处理。我们假设一下假如客户端发的数据包丢了,但是客户端进程的write很明显是异步的,进程并不会等待确认应答的到来,也就是说,write将数据写进发送缓冲区后,write就结束了,然后客户端进程会执行read,因为没数据而在套接字接收缓冲区阻塞等待,如果长时间没有收到确认应答,那么时钟中断使定时器到期后,会自动超时重传,假如ack响应客户端收到了,那就把定时器给销毁,就这样,发送非ACK/RST报文,OS都创建并管理对应定时器,除非收到对应的ACK,不然就依靠时钟中断来进行超时重传。
四次挥手过程:
假如客户端主动close(fd)关闭套接字,那么会释放掉struct file 和 struct socket,struct sock由OS来管理,并发送FIN包,套接字状态为FIN_WAIT_1,服务器网卡收到后触发硬件中断,然后走协议栈,到tcp层靠五元组找到套接字然后修改状态为CLOSE_WAIT,并组装ACK包发回去,剩下的事情已经和客户端进程没什么关系了,客户端网卡收到后触发硬件中断,走协议栈解包到tcp层然后修改通信套接字状态,并释放超时定时器。服务器进程也close(fd),同样的释放了struct file 和struct socket,然后struct sock给操作系统管理,修改通信套接字状态为LAST_ACK,发送FIN包给客户端,然后客户端网卡接收,触发硬件中断,解包到tcp层后根据五元组找到套接字,然后修改套接字状态为TIME_WAIT,并发送ACK包,服务器收到后这次解包到tcp层,然后把struct sock给释放,服务器端套接字彻底释放,客户端如果2*MSL没有收到那就会释放struct sock,至此,该连接优雅结束
总结:
1.对于需要应答的报文,OS会维护定时器并通过时钟中断来实现超时重传,当然如果收到确认应答ack那就释放定时器
2.套接字状态保存在struct sock中,甚至struct request_sock中也有,这就是为什么整个过程中套接字状态可以一直被保存(SYN_SENT、TIME_WAIT这些可不是连接状态,是套接字状态,如果是连接状态两边情况又怎么可能不同呢?我们也可以这样理解)
TIME_WAIT状态的理解
1、TIME_WAIT是通信套接字的一个状态,在四次挥手中主动关闭连接的一方,将会在第二阶段拥有这个状态。
2、MSL是max segment lifetime最大段生命周期,传输层报文我们一般称之为段,MSL就是tcp报文的最大生命周期,当然,MSL本质其实是一个估值,这个估值几乎可以保证所有tcp报文都会在这段时间内到达接收端,从而结束生命周期。
3.我们知道,一个报文被网卡接收,然后触发硬件中断,然后会走协议栈进行解包,而数据链路层是以目标mac地址来标识是不是发给主机,无法分辨新旧报文,IP层看ip地址,也分不出来,传输层看五元组,假如新连接和旧连接的五元组相同,也是分不出来的(但这一层也分不出来的概率挺低的,因为客户端套接字地址是随机的),所以如果新连接是在旧连接刚关闭后就建立好了,是完全有可能出现新连接误处理旧报文的情况,所以会有一个TIME_WAIT的2*MSL时间,来使旧报文全部到达接收端,这样网络中就不会有旧报文的残留,在此期间,该地址是被TIME_WAIT状态的套接字绑定的,其他套接字是无法bind的,所以2*MSL的时间保证了旧报文都已被接收,又保证了新连接是无法建立的,从而避免旧报文被新连接处理的情况
TIME_WAIT状态作用一:如果对方没有收到ACK,对方就会超时重传FIN包,处于TIME_WAIT状态的套接字如果在2*MSL内收到了对方超时重传的FIN包,这时TIME_WAIT一方就会重发ACK,这也是TIME_WAIT状态的意义之一,使对方的套接字能顺利释放
TIME_WAIT状态作用二:TIME_WAIT状态有2*MSL的时间,保证了本次连接的旧报文都已被接收,又保证了相同五元组新连接是无法建立的,从而避免旧报文被新连接处理的情况(这会引起一个问题,就是如果是服务器主动关闭连接,短时间内无法重启服务器,因为地址正被TIME_WAIT的套接字绑定,因此该地址没法bind其他套接字)
解决TIME_WAIT状态引起的bind失败
1.显式 bind
vs 隐式绑定
类型 | 定义 | 示例 |
---|---|---|
显式 bind |
通过 bind() 系统调用明确绑定地址和端口 |
bind(sockfd, &addr, sizeof(addr)) |
隐式绑定 | 由内核自动关联地址 | accept() 返回的通信套接字复用监听套接字的地址 |
**2. SO_REUSEADDR
**
- 只许一个 套接字显式bind处于
TIME_WAIT
状态的地址。 - 主要解决服务器重启问题
边界条件验证
cpp
// 场景1:前一个套接字处于 TIME_WAIT
setsockopt(sock1, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock1, &addr, sizeof(addr)); // 成功(即使端口在 TIME_WAIT)
// 场景2:前一个套接字仍活跃(未关闭)
setsockopt(sock2, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sock2, &addr, sizeof(addr)); // 失败(errno=EADDRINUSE)
**3. SO_REUSEPORT
**
- 允许多个套接字显式bind绑定同一地址,无论该地址是否处于TIME_WAIT状态。
- 隐含
SO_REUSEADDR
的功能。
理解CLOSE_WAIT状态
被动断开连接一方的套接字,在第一阶段将会处于CLOSE_WAIT状态
CLOSE_WAIT状态一般是和read等函数配合使用,read从接收缓冲区中读,如果有数据那就读,如果没数据那就看套接字状态,倘若是ESTABLISHED,那就阻塞等待,如果是CLOSE_WAIT,那read就返回0,然后被动断开连接一方的进程可以继续执行close(fd),从而进入四次挥手的第二阶段,这里就不得不提,倘若应用层代码有BUG,被动方迟迟不close,那四次挥手就无法继续执行,双方套接字将会卡在FIN_WAIT_2和CLOSE_WAIT
TCP保证高效传输的机制
1.滑动窗口
滑动窗口里的数据可以用来连续发送报文段,无需等待单个ACK,一发一答的方式使整个通信中传输时间过长,效率很低,一次连续发送多个报文,使传输时间重叠在一起,效率就高了。
TCP的报文发送算法--nagle算法
1.当tcp发送缓冲区中没有未确认的数据或数据达到MSS,那就直接发送,否则不发送
2.nagle算法来实现数据的实际发送,我们也可以看出,tcp报文的发送其实是有条件的,其主要作用是尽可能合并小数据,减少报文发送次数
注意:
滑动窗口决定一次可以发送多少数据,那滑动窗口必须由网络状况和接收端接受能力来动态调整,所以 滑动窗口的大小 = min(接收窗口,拥塞窗口)
流量控制
根据接收端的处理能力(接收窗口), 来决定发送端的发送速度(滑动窗口). 这个机制就叫做流量控制(Flow Control)
接收端将自己接收缓冲区的剩余空间大小放入 TCP 报头中的 "窗口大小" 字段,倘若接收缓冲区满了,那就将报头中的窗口大小设为0,这时发送方不再发送数据, 但是发送方会定期发送一个窗口探测数据段,本质就是一个纯ack包,这样不会影响序列号什么的,接收端接收到ack后把窗口大小告诉发送端,同样用ack包回应
窗口大小是16 位数字最大表示 65535, 但TCP 接收缓冲区最大就是 65535 字节么?
实际上, TCP 报头 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口大小字段的值左移 M 位
拥塞控制
发送方会维护一个值叫做拥塞窗口,该值是用来衡量网络状况的,根据丢包情况和报文应答来动态调整拥塞窗口大小
拥塞窗口的单位不再是字节,而是MSS,最大段大小,这是由ip层的MTU最大传输单元来得到的,MTU是ip报文的最大值,MSS是tcp报文有效载荷的最大值
拥塞窗口是用来衡量网络状况的,那肯定是动态的
拥塞窗口一开始初始值为1,慢启动阈值一开始为16,然后每发送一个请求并收到对应ack应答,即完成一轮传输,那拥塞窗口值就乘2,达到慢启动阈值后,每完成一轮传输,拥塞窗口值加1,倘若遇到超时重传的丢包,也就是发送了请求但没收到ack导致超时,我们认为网络情况很不好,那就慢启动阈值变为拥塞窗口的一半,然后拥塞窗口变为1,倘若出现快重传的丢包,也就是一次发送多个请求报文,然后前面有几个请求丢了,但后面有请求正常到达,我们认为网络状况些许不好,那就慢启动阈值变为拥塞窗口的一半,然后拥塞窗口也变为拥塞窗口的一半,这叫做快恢复,相当于就是直接跳过了慢启动的过程,直接进入拥塞避免
概念介绍
慢启动(慢开始):拥塞窗口为1,然后每轮传输乘2,拥塞窗口指数级增长
拥塞避免:拥塞窗口达到慢启动阈值后,每轮传输拥塞窗口加1,线性增长
快恢复:倘如发生快重传的丢包情况,那就慢启动阈值变为拥塞窗口的一半,然后拥塞窗口也变为拥塞窗口的一半
2.快重传和累计确认
依靠滑动窗口的机制,发送方一次可以发送多个tcp报文,那这几个tcp报文的传输过程是有可能出现丢包的,可能是ack包丢了,也可能直接就是请求包丢了,对于不同丢包情况,tcp有不同策略来应对
累计确认

当前面的ack包丢了后,发送端可以通过后续的ack包来得知自己前面的数据包有没有到达,如上,确认序号为1001的ack包丢了,但当收到确认序号2001的ack包,那就可以得知1~1000数据包肯定已到达,因此1~1000的定时器直接释放就是了
快重传

滑动窗口内的数据可以用来连续发送报文,而不用等待ack,假设一个数据包丢了,比如上面的1001~2000数据包,丢了之后,接收到2001~3000的数据包,然后解包到tcp层,根据序号空出前面1001~2000的位置,然后将2001~3000的数据写进接收缓冲区对应位置,并组装ack包,其确认序号为1001,后面的多个数据包到来均是如此处理,于是发送方会收到多个相同确认序号的ack包,当收到三次后,不在等超时重传定时器到期了,直接重发1001~2000数据包,这就是快重传
3.延迟应答
如果接收端收到数据后就立即返回ack应答,这时可能套接字接收缓冲区的数据还没有处理多少,那么响应的ack报文的窗口大小字段就比较小,我们可以每两个报文给一个ack,也可以收到报文后等待个200ms再响应ACK包,主要是给接收端一定的时间,使接收缓冲区中的数据尽可能被读出处理,这样能返回ack时能设置一个更大的接收窗口
4.捎带应答
类似三次握手的第二次的SYN+ACK报文就是捎带应答,主要可以合并报文,这样就不用单独发送ack了
捎带应答的本质
-
定义 :将ACK确认信息搭载在数据报文中发送,避免单独发送纯ACK包
-
设计目标:减少报文数量
面向字节流
每一个tcp套接字都拥有一个发送缓冲区和接收缓冲区
1、使用write会将数据写进发送缓冲区,也就是说发送缓冲区中其实都是应用层数据,但报文的发送依赖的其实是滑动窗口和nagle算法这些传输层机制,并不是write这种应用层接口
2.报文被网卡接收,然后写进网卡接收缓冲区,触发硬件中断,层层解包将应用层数据写进套接字接收缓冲区中,然后唤醒接收缓冲区等待队列上的进程,继续调用read从接收缓冲区中读出数据
粘包问题
从tcp接收缓冲区中读出来的数据会存在粘包问题,因为没法区分应用层数据的边界,udp没有这个问题,因为udp接收缓冲区中是udp报文,使用recvfrom会从udp接收缓冲区中读出一个完整报文,然后去掉udp报头将有效载荷写进recvfrom参数的缓冲区里面,而tcp是用write去接收缓冲区中读应用层数据,所以会有粘包问题
解决粘包问题
1、定长的包:就像udp一样,如果应用层数据包是定长的,那就每次读一个定长应用层数据包,没有粘包问题
2、添加应用层长度报头:在变长应用层数据包前面添加长度字段,作为应用层报头,这样从tcp接收缓冲区中读数据就可以根据长度字段读出完整应用层数据报文
3、添加明确分隔符:在应用层数据包后加分隔符对应用层数据包进行划分,这样也可以
TCP连接断开情况详解
1、四次挥手:
通过close释放struct file和struct socket,将struct sock交给操作系统管理,并发送FIN包给对方,对方响应ack,后面自身同样调close,最终双方通信套接字全都释放,非常优雅
2、进程终止:
进程终止的第一阶段会释放pcb的资源,其中就有文件描述符表,同样的调close,然后后面同样进行四次挥手
3、机器重启:
机器重启会退出进程,所以最终还是四次挥手断开连接
4.机器掉电:
假设接收方机器掉电,那发送方发送的报文就不会有响应,五次超时重传后无应答后,直接释放发送方套接字结构,包括struct sock
基于TCP应用层协议
HTTP
HTTPS
SSH
Telnet
FTP
SMTP
当然, 也包括你自己写 TCP 程序时自定义的应用层协议