传输层协议
传输层协议主要包括TCP/UDP两个协议,TCP是面向连接,可靠传输的;UDP是面向无连接,不可靠传输的。当然,现在也有一些协议是建立在UDP上面向可靠传输的,也可以把他们归纳到传输层协议中,比如KCP(可以使用UDP作为底层),QUIC等等。但是,传输层协议主要有TCP和UDP两种。
| TCP | UDP |
| :——————: | :——————–: |
| 有连接 | 无连接 |
| 可靠 | 不可靠 |
| 字节流 | 数据报文 |
| 点对点 | 一对一,一对多,多对多 |
| 首部20字节 | 首部8字节 |
| 有接收发送缓冲区 | 只有接收缓冲区 |
| 有拥塞控制和流量控制 | 无拥塞和流量控制 |
UDP协议
UDP协议相对简单,体现在机制简单、报头简单等等
报头
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| | |
| Length | Checksum |
+--------+--------+--------+--------+
|
| 数据 ...
+---------------- ...
ps: 出自 RFC 768, https://datatracker.ietf.org/doc/html/rfc768
- Source Port:源端口
- Destination Port:目的端口
- Length: UDP报文总长度,以8字节为单位
- 校验和:根据IP报头、UDP报头和数据部分构成的伪报头来检验,过程为反码求和再取16位反码,如果奇数长度需填充8比特0为偶数长度(16位为一个单位)
伪报头如下:
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| source address |
+--------+--------+--------+--------+
| destination address |
+--------+--------+--------+--------+
| zero |protocol| UDP length |
+--------+--------+--------+--------+
- source address:源地址
- destination address:目的地址
- zero:零
- protocol: IP数据包的协议,为17(UDP协议就是17)
- UDP length: UDP报头里的
Length
校验和计算
在设置好伪报头就可以计算校验和了,需要计算伪报头-UDP数据报
,其中校验和先置为0,计算方法如下
uint16_t *UDP; // 存储着伪报头-UDP数据报,并且已经实现填充,设长度为 N
uint32_t sum = 0; // 使用32位来存储临时结果,因为可能会溢出
for(int i = 0; i < N; ++i){
sum += UDP[i];
if((sum & 0xFFFF0000) != 0){ // 17位上有进位,需要重新加到最低位
sum &= 0xFFFF0000; // 消除大于16位的所有位
sum += 0x1; // 将17位进1
}
}
uint16_t check_sum = (sum & 0x0000FFFF);
check_sum = ~check_sum; // 取反码,填充到校验和位置即可
UDP[9] = check_sum;
如何检验呢?
可以直接重新计算检验和进行检验,也可以直接计算,最后观察结果是否为0xFFFF
即可。
TCP协议
基础
最大报文长度(MSS)作用:双方协商报文最大的数据长度,在TCP上面首先分片,分片之后保证一片TCP数据尽量能存在于单个IP数据报中。由于IP层不会自动重传,如果某个IP报文丢失了,且这个IP报文中载荷了TCP报文的一部分,那么整个TCP报文都会重传。
超时重传时间(Retransmission Time Out, RTO)指的是重传时间,一般略大于往返时间(Round Trip Time, RTT)。
下面是TCP通信的所有状态,可以看出两部分,一部分是连接建立,一部分是连接释放
+---------+ ---------\ active OPEN
| CLOSED | \ -----------
+---------+<---------\ \ create TCB
| ^ \ \ snd SYN
passive OPEN | | CLOSE \ \
------------ | | ---------- \ \
create TCB | | delete TCB \ \
V | \ \
+---------+ CLOSE | \
| LISTEN | ---------- | |
+---------+ delete TCB | |
rcv SYN | | SEND | |
----------- | | ------- | V
+---------+ snd SYN,ACK / \ snd SYN +---------+
| |<----------------- ------------------>| |
| SYN | rcv SYN | SYN |
| RCVD |<-----------------------------------------------| SENT |
| | snd ACK | |
| |------------------ -------------------| |
+---------+ rcv ACK of SYN \ / rcv SYN,ACK +---------+
| -------------- | | -----------
| x | | snd ACK
| V V
| CLOSE +---------+
| ------- | ESTAB |
| snd FIN +---------+
| CLOSE | | rcv FIN
V ------- | | -------
+---------+ snd FIN / \ snd ACK +---------+
| FIN |<----------------- ------------------>| CLOSE |
| WAIT-1 |------------------ | WAIT |
+---------+ rcv FIN \ +---------+
| rcv ACK of FIN ------- | CLOSE |
| -------------- snd ACK | ------- |
V x V snd FIN V
+---------+ +---------+ +---------+
|FINWAIT-2| | CLOSING | | LAST-ACK|
+---------+ +---------+ +---------+
| rcv ACK of FIN | rcv ACK of FIN |
| rcv FIN -------------- | Timeout=2MSL -------------- |
| ------- x V ------------ x V
\ snd ACK +---------+delete TCB +---------+
------------------------>|TIME WAIT|------------------>| CLOSED |
+---------+ +---------+
节选自 RFC 793: https://datatracker.ietf.org/doc/html/rfc793
头部
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- Source Port:源端口
- Destination Port:目的端口
- Sequence Number: Seq,序列号,起始序号通常是随机生成的,称作(Initial Sequence Number, ISN)
- Acknowledgment Number: Ack,确认号,也可以表示期望号,即期望收到 num 的数据
- Data Offset: 数据偏移量,以字节为单位,标志数据段的偏移量,也可以看作是首部长度,因此最大首部长度是
4B*16=60B
- Reserved: 保留字段,置为0
- URG: 1表示高优先级,尽快发送出去
- ACK:指示
Acknowledgment Number
有效,需要处理 - PSH:1表示尽快交付给应用层,由于上传给用户层的数据可能需要等待一定长度再上传,节省内核态用户态的切换
- RST: 1表示断开连接,有可能会发生网络错误,接收方接收到无效数据报,但是并没有跟对方建立连接,则会返回一个RST,告知对方可以断开这个连接了
- SYN: 1表示连接的第一次握手和第二次握手,第一次握手,SYN=1, ACK=0;第二次握手,SYN=1, ACK=1。
- FIN: 1表示释放TCP连接,用于释放的四次握手的第一次和第三次握手
- Window: 告知对方自身的接收窗口大小,用于流量控制
- Checksum:检验和,TCP首部+数据,方法同UDP
- Urgent Pointer:紧急指针,指向紧急数据的末尾,数据会分为紧急数据和普通数据,紧急数据会放在普通数据之前,也可以表示普通数据的起始位置。
- Options: 长度可变,存放一些选项信息,长度从
0-40B
都可 - Padding: 由于长度是以
4B
为单位的,由于 Options 长度不一定为4B
的整数倍,需要这个来填充
连接建立-三次握手
PS:图片来源小林coding
三次握手的目的在于:
- 防止历史连接初始化连接
- 双方同步初始序列号
- 避免资源浪费
初始化序列号随机:
- 防止历史报文被下个相同四元组连接接收
- 防止黑客伪造攻击
ISN的产生:$\text{ISN}=M+F(\text{localhost, localport, remotehost, remoteport})$
$M$是一个计时器,每隔$4\mu s$就自增
$F$是一个哈希算法,如
MD5
第一次握手丢失:
服务端是
LISTEN
状态,客户端是SYS_SENT
状态那么客户端会一直重传,重传时间为RTO, 2RTO, 4RTO, 8RTO,…,次数取决于
tcp_syn_retries
,可在/proc/sys/net/ipv4/tcp_syn_retries
查看第二次握手丢失:
服务端是
SYN_RCVD
状态,客户端是SYS_SENT
状态客户端仍然会重传第一次握手数据,但是服务端仍然有个第二次握手最大重传次数,
/proc/sys/net/ipv4/tcp_synack_retries
,对于客户端来说,相当于第一次握手丢失效果第三次握手丢失:
服务端是
SYN_RCVD
状态,客户端是ESTABLISHED
状态服务端会一直重传二次握手,知道重传次数超过
tcp_synack_retries
,关闭连接SYN攻击:攻击者只发送第一次握手,并不回答第二次握手数据,导致服务器一直处于
SYS_RCVD
状态
结合Linux
来看TCP三次握手:
全连接,半连接队列:内核存在
SYN
队列(半连接)和Accept
队列(全连接)接收到的
SYN
报文,会把它们放入SYN队列中,内核会依次从队头取出连接,并发送二次握手数据,如果成功了,就放入Accept
队列中当用户调用accept时,会从
Accept
队列中取出连接返回给用户进程accept
函数调用发生在什么时候:最早会发生在LISTEN
之后,但是会阻塞,直到三次连接建立,才会返回。如果调用的时候就是三次连接建立好了,那么就会立刻返回。
连接释放-四次握手
PS:图片来源小林coding
四次挥手的意义在于:每次调用发送FIN
只是代表自己不再发送数据了,但是还可以接收数据。因此需要双方都发送FIN
,并且都需要对FIN
回复ACK
。
如果服务端收到FIN
且刚好他也没有数据发送了,那么也可以跟ACK
一起发送FIN
,这样就变成三次握手了
下面简要介绍几种连接释放失败的情况,以客户端发起关闭为例
第一次挥手丢失:
服务端是
ESTABLISH
状态,客户端是FIN_WAIT_1
状态客户端会一直重传,每次重传时间翻倍RTO,最大重传次数为
/proc/sys/net/ipv4/tcp_orphan_retries
,然后直接关闭连接第二次挥手丢失:
服务端是
CLOSED_WAIT
状态,客户端是FIN_WAIT_1
状态客户端没收到,也会一直重传,跟第一次挥手丢失一样,达到最大重传次数,客户端关闭。
至于服务端,应该主动发送第三次握手数据才会关闭,这是因为
FIN
数据都应该是主动的第三次挥手丢失:假设第二次和第三次没有聚合在一起
服务端是
LAST_ACK
状态,客户端是FIN_WAIT_2
状态服务端会主动发送
FIN
告知客户端断开,服务端会自己切换状态,客户端由于收到第二次握手,也会切换状态客户端如果调用
close
函数关闭连接,那么处于FIN_WAIT_2
的状态只有60秒(具体时间是/proc/sys/net/ipv4/tcp_fin_timeout
)。客户端如果调用
shutdown
函数关闭连接,那么FIN_WAIT_2
的状态就会一直持续,这是非常浪费资源的一种行为。服务端会自动重传
FIN
报文,其行为可参考第一次挥手丢失,本质是一样的第四次挥手丢失:
服务端是
LAST_ACK
状态,客户端是TIME_WAIT
状态服务端由于没收到
ACK
所以会一直重传,参考第三次挥手丢失客户端收到了对方的
FIN
报文,所以会启动2MSL
定时器,一旦定时器归零,那么就关闭连接如果服务端一直重传的
FIN
报文被客户端重复接收,那么接收一次就会重置2MSL
定时器,直到接收不到对方的FIN
报文且2MSL
定时器归零(对方最大重传次数过了)就关闭
2MSL
:最大报文生存时间(Maximum Segment Lifetime, MSL)指任何一个报文可以在网络中最大的存在时间,超过时间这个报文就会被丢弃。和以前IP数据报的TTL字段,不过现在TTL字段表示最大跳数。MSL应该略大于TTL消耗时间,TTL一般是64,Linux中MSL为30秒。设置为2MSL的原因在于,一来一回两个数据报最多存在2MSL。Linux中体现在内核代码#define TCP_TIMEWAIT_LEN (60*HZ)
TIME_WAIT
状态可以防止接收到历史连接,这是因为TIME_WAIT
之后,可以认为网络中不存在此次连接的数据报了,下次连接就是干净的。
窗口
不妨想象发送方的TCP是一串字节流,接收方也会接收一串字节流。
发送方有一个发送窗口,接收方有一个接收窗口,接收窗口检验完接收到的数据就会后移,发送窗口后移只能等待接收方返回的确认号来后移。
发送窗口=min(
流量窗口,拥塞窗口)
下面是一个简单示意图
PS:图片来源于King’s Blog
可靠机制
TCP可靠传输实现是通过确认机制与重传机制来实现的。
重传机制主要有超时重传和快速重传:
- 超时重传:在发送窗口中没有数据可发送了,对于已经发送但是未接收到的数据来说,超过特定时间间隔没有接收到确认帧,那么就会再次重传,重传间隔是RTO(略大于RTT)。
- 快速重传:在接收到连续三次相同的确认帧后,快速把根据确认帧的确认号重传对应的序列。
同样,确认的机制也有:
- 选择确认SACK(Selective Acknowledgment),收到数据后发送期望下一次接收到的序号
- 重复选择确认(Duplicate SACK, D-SACK),不仅发送期望下一次收到的序号,也会发送重复接收的序号,这样可以防止因为
ACK
报文丢失导致超时重传
流量控制
流量控制是接收端为了限制发送端的速度,缓解接收端的负载的。
接收端虽然发送了确认帧,但是由于接收端处理慢,导致成功接收的数据还没从内核中移出导致内核缓冲不够,导致流量浪费,所以会限制发送窗口,在TCP头部中Window
中设定还能接收的窗口大小。接收端会对齐窗口,从后往前对齐,因为前面的不一定接收到了确认。
窗口关闭:当窗口为0的时候,称之为窗口关闭,这样发送方就不会再发送数据了,所以必须由接收方打开,但是接收方由于没收到发送方的数据,所以也不会发送
ACK
,这样会陷入死锁了。因此,发送方如果收到了窗口关闭信息,会定时发送窗口探测报文,探测次数一般是3次,约30-60秒,如果还为0,那就发送RST中断连接。糊涂窗口症:如果接收方繁忙,导致空窗口一会儿空出几个字节就发送几个字节的空窗口,发送方如果收到空窗口就会立马发送数据,这样会导致一个TCP报文只发送几个数据,造成大量的首部浪费现象。
解决方法:
- 当接收方的窗口小于一个阈值(MSS,提交给IP层的最长TCP数据),那就通告窗口为0,这样就不会有比这个长度小的数据被单独发送了
- 发送方等待一个阈值(MSS)长度的可发送数据,或者收到了之前的ACK包,才发送数据(Nagle算法)
拥塞控制
拥塞控制是发送端为了缓解整个网络的流量负载的
拥塞控制主要是控制拥塞窗口大小,进而控制发送窗口大小完成的。
拥塞控制分为,慢启动、拥塞避免、拥塞发生、快速恢复阶段。核心规则如下
- 慢启动:当发送方收到一个ACK,拥塞窗口大小+1
- 拥塞避免:当发送方收到
cwnd
个ACK,拥塞窗口大小+1(cwnd
表示拥塞窗口,可通过ss -nli | grep cwnd
查看)
具体规则:
初始拥塞窗口为1,发送1个数据并接收
拥塞窗口增加1,变为2,发送2个数据并成功接收
拥塞窗口增加2,变为4,发送4个数据并成功接收
拥塞窗口增加4,变为8,发送8个数据并成功接收
重复上述过程,直到拥塞窗口大于慢启动门限值(
ssthresh
,假如为8)拥塞窗口增加1,变为9,发送9个数据并成功接收
拥塞窗口增加1,变为10,发送10个数据并成功接收
直到拥塞发生,即重传机制(超时重传或快速重传)
超时重传:
认为这种情况是网络严重堵塞了,因此设置
ssthresh = cwnd/2
cwnd = 1
快速重传:
不一定是网络出现问题,可能是某个数据丢失了而已,因此设置
ssthresh = cwnd / 2
cwnd = ssthresh
所以快速重传之后仍然进行拥塞避免阶段,而不是慢启动阶段。
初次之外,还可以结合快速恢复来设置窗口大小
快速恢复:
由于接收到了3个ACK,说明网络情况还是良好的,因此可以稍微把窗口设置大一点,即
ssthresh = cwnd / 2
cwnd = ssthresh + 3
,3表示三个重复ACK