TCP 协议整理(残酷残酷

TCP 协议(一

  • 基于《TCP/IP详解 卷一》和谢希仁《计算机网络(第6版)》的简单整理和总结。

TCP是一种面向连接的,基于字节流的,可靠的传输控制协议。

属于OSI七层模型中的传输层。

内容梳理

1569166769008

  • TCP的内容模块整理,方便记忆。

一、TCP报文首部

TCP首部大小在20~60字节,其中标准长度为20个字节。

源端口和目的端口

TCP的四元组为源IP,源端口,目的IP,目的端口,TCP首部中的源端口和目的端口结合IP首部中的源和目的IP地址组成了一个连接的四元组。

序号/序列号(SEQ)

在一个TCP连接中唯一标识TCP报文段,是重传机制的重要字段。

自ISN(初始序列号)起,单调递增。

确认号(ACK)

接收端发送给发送端,是期望对方下一个报文段的第一个字节的序号。

值为N的ACK报文表示的是序号在N之前的报文全部已经收到,希望收到序号N的报文。

数据偏移

表示TCP数据部分相对于整个TCP报文段来说的偏移量,可以简单理解为TCP首部的长度。

控制位

  1. URG - 紧急,置位后首部紧急指针生效
  2. ACK - 确认,置位后确认号生效
  3. PSH - 推送,置位后接收方应尽快给应用程序推送该段数据
  4. RST - 重置,置位后表示该报文为重置报文,取消连接
  5. SYN - 初始化,置位表示为初始化报文,用于初始化TCP连接
  6. FIN - 结束,职位表示当前端结束数据传输工作

URG之前还有CWR - 拥塞窗口 以及 ECE - ECN回显,但是在一些TCP的实现里面并没有实现这两位。

窗口

通常在ACK报文中附带,作为接收方对发送方的背压,是影响发送端发送速率的因素之一。

占16位,单位为字节,所以在没有窗口缩放选项的情况下,最大为65535字节。

校验和

报文段正确性校验使用占两位。

校验范围包括首部和数据部分,和UDP一样需要再生成12字节的伪首部参与计算。

伪首部包括源和目的IP,保证通信双方的正确性。

紧急指针

只有在URG控制位置位的情况下生效。

表示紧急报文在报文段序列号字段上的正偏移,序列号超过紧急指针的即为正常数据。

零窗口的情况下也可以发送紧急报文。

选项

1. MSS - 最大报文段长度

连接中每个TCP报文段的数据字段的最大长度,不包含首部。

在SYN报文中协商,双方都可以指定自己的MSS,甚至可以不同,默认为536字节。

2. SACK - 选择确认

当接收方接受到乱序数据时,就会在接收窗口产生缺口。

设置SACK选项就是为了描述这些缺口信息,使发送方更好,更准确的重传这些缺口数据。

3. WSCALE/WSOPT - 窗口缩放

由于首部的窗口大小字段仅占16位,所以影响的范围也仅在0~2^16(65535)之间。

该选项就是为了增加窗口大小字段的范围,从16位提升至30位。

该选项只能出现在一个SYN报文段中,而SYN报文仅仅在初始化时通信双方各发一次,由此可知:

连接建立之后窗口缩放的比例因子是与方向绑定的,通信双方的比例因子可以不同。

4. TSOPT - 时间戳选项

该选项要求发送方在每一个报文中添加2个4字节的单调递增的时间戳数值。

分别是:TSval/TSV 发送时间戳 以及 TSecr/TSER 时间戳回显

该选项的设置可以很好的解决重传的二义性,也能更加精确的计算RTT。

5. 其他

另外的还有认证选项以及用户超时选项等等。

二、连接管理

建立连接

稍微有点常识的程序猿应该都知道,TCP建立连接的时候需要往返发送三个报文。

1568818645296

连接发起者(客户端)会向服务端发送一个SYN报文,报文中除了目的端口,还包括ISN(初始序列号)以及部分选项字段。

服务端接受后会回复一个SYN报文作为响应,然后将接收到的SEQ+1,作为报文的ACK值,并指明服务端的的初始序列号等信息。

客户端响应一个ACK报文,同样的将服务端SYN报文中的SEQ+1作为ACK值。

为什么要三次握手

首先明确,三次握手的主要目的是交换双方的ISN以及选项。

这些字段,例如是否启用SACK等都将是数据传输时的重要属性。

交换双方的信息至少需要两次握手,而第三次握手则是为了防止已失效的连接请求又被转发到了服务端

意思就是如果客户端在收到服务端的SYN+ACK报文时就建立一个连接,那么在重传时将会出现先后多条连接的情况。

我感觉可能防止建立重复连接的功能可能是意外之喜。

另外可以发现SYN报文也占用了一个序列号。

初始序列号 - ISN

在发送用于建立连接的SYN报文时,通信的双方都会选择一个初始化序列号。

每个连接都会有不同的初始化序列号。

《TCP/IP详解》原文13.2章节:此外,为了确认客户端的SYN,服务器将其包含的ISN(c)数值加1后作为返回的ACK数值。因此,每发送一个SYN,序列号都会自动加1。这样如果出现丢失的情况,该SYN段将会重传

wireshark测试下发现,客户端SYN报文的SEQ(也就是ISN)在重传时也不会改变。

关闭连接

相对来说关闭连接的四次挥手就好理解多了。

1568820312811

  1. 连接的主动关闭方,发送一个FIN。
  2. 被动方回复一个ACK。
  3. 被动方主动发送一个FIN。
  4. 主动方回复一个ACK

为什么要四次挥手

和三次握手的区别,四次握手中被动关闭方的FIN报文和ACK拆开了,而三次握手的SYN报文和ACK是一起发出的。

至于为什么要拆开,我的理解是因为半关闭状态的存在,作为一个全双工的协议,连接的双方都可以互相发送数据。

半关闭状态是指TCP连接双方,有一端发送了FIN,而另一端还在继续传输数据,此时的主动关闭方仍然会对接收的数据作ACK的响应。

一方发送了FIN报文就表示己方的数据发送完毕了,因此就分别需要两个FIN报文和两个ACK才足以完整的关闭一条(全双工)连接。

同时打开和关闭

同时打开

通信双方在收到对方的SYN报文之前,都先发送了SYN报文,此时这种情况就叫做同时打开

算是一种很少出现的特殊情况,但是TCP也能支持,并建立一条正常的连接。

![](https://chenbxxx.oss-cn-beijing.aliyuncs.com/TCP同时打开.png)

我的画图软件不能支持斜线,只能靠盗图了

如图可见,通信的双方同时向对方发送一个SYN,并附带上自己的ISN(SEQ)。

接收方接受之后同样也同时作为被动发起方恢复一个ACK。

此时通信双方即为客户端也为服务端,状态的变化一致,且回复的ACK中ISN与SYN中的一致。

通信双方经历了相同的状态变更:SYN_SENT -> SYN_RCVD -> ESTABLISHED

我感觉TCP内部的实现中应该也是以SEQ作为参考依据。

同时关闭

和同时打开差不多,同时关闭是在收到对方的FIN之前,向对方发送了自己的FIN报文。

同样的通信双方经历了相同的状态变更:FIN_WAIT_1 -> CLOSING -> TIME_WAIT

可以看到双方是都需要等待一个2MSL的。

半打开,半关闭,半连接

以上是TCP连接中的三种特殊状态,就简单的叙述一下吧。

半连接是指服务端发送了SYN+ACK报文之后,等待客户端的ACK报文的这段时间

半连接有类似的攻击手段:大量的请求发送到服务端但是永远不回复最后的ACK,导致服务端存在大量的半连接。

半打开是指如果一方已经关闭或异常终止连接,而另一方却不知道。

半关闭上面也有提到过,通信的一方主动发送FIN之后表示本方不会再主动发送任何数据,但仍然可以接受对方的数据并响应的情况。

三、TCP的有限状态机

上图即为《TCP/IP详解 卷一》中的原图。

图中基本包含了全部的TCP连接状态变更,包括典型、非典型。

ESTABLISHED

ESTABLISHED状态是通信双方正常传输数据的状态。

作为三次握手的终点和四次挥手的起点。

TIME_WAIT状态

TIME_WAIT状态是主动关闭方在连接关闭的最后阶段必须经历的。

WAIT_TIME状态下,主动关闭方会判断本次的四元组不可用,所以此时就算对端重新请求SYN(接收到ACK释放连接之后),也会被拒绝。

进入该状态时,TCP会设置时间等待计时器(TIME_WAIT timer),并等待2MSL的时间才会真正的释放连接,RFC793中建议为2min。

MSL(Maximum Segment Lifetime),也可以称为最大报文生存时间,是报文在所有链路中存在的最大时间,超过就会被丢弃。

这么做的目的有以下两个:

  1. 为了保证最后的ACK能够到达被动关闭方。

从有限状态机的图中也可以看到,被动关闭方的连接真正释放是在收到最后一个ACK之后,所以必须要保证ACK的正确发送。

等待2MSL能够有效避免最终的ACK丢失的情况,ACK不会主动重传,但是对端的FIN会重传直到收到正确的ACK为止。

2MSL可以粗略的看做是己方ACK发送的时间加上对方FIN重传的时间。

当一个报连接处于TIME_WAIT状态时,任何延迟到达的报文都会被丢弃,只接收FIN报文。

另外TIME_WAIT的状态是从最后一个ACK发送开始,所以重新响应ACK之后,TIME_WAIT也会重新计时。

  1. 保证相同四元组额前后连接报文不混淆

等待2MSL,就可以使本次连接的报文在链路中全部消失。

期间TCP会将本次四元组定义为不可用,阻止重连。

如果不等待,相同四元组的连接如果重连,就有可能导致旧报文发送到新连接的情况,造成数据混乱。

2MSL是相对保守的处理方式,在ISN能超过上一次连接的最大序列号或者启用了时间戳选项的时候,感觉上可以跳过。

CLOSING 状态

CLOSING状态是TCP的非典型状态(一般情况下不会出现)

只有在上文提到过地同时关闭的情况下才会出现,同时关闭的通信双方在接收到对方的FIN,在发送ACK之后进入到CLOSING状态。

处于CLOSING状态下的通信双方在接收到对方的ACK之后,都会进入TIME_WAIT状态。

四、TCP的重传机制

TCP协议往下就是网络层的IP协议,但是IP协议并不提供任何可靠传输的服务,所以我们可以简单认为TCP所处链路都是不可靠的。

但是TCP介绍中也说了,它提供的是可靠的传输服务,因此也就要求TCP协议自身来补足IP协议中的不可靠部分。

可靠传输的基础

TCP的重传机制是基于连续ARQ协议实现的。

维基百科对ARQ的解释如下:

ARQ协议,即自动重传请求(Automatic Repeat-reQuest),是OSI模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。如果发送方在发送后一段时间之内没有收到确认帧,它通常会重新发送。

停止等待ARQ协议

一个分组一个分组的发送,在收到确认之前不会发送下一个分组,如果出现超时就重传丢失分组。

该协议能够完全保证通信的可靠新,但是显而易见的该协议的利用率很成问题,在发送完一个分组到确认到达的这段时间信道都是空闲的。

即使整个链路十分可靠,依旧要等待确认信息的到达。

而且判断分组是否丢失的算法就是在一定时间内,是否收到接受的确认信息,过于粗暴很容易出现伪重传的情况。

连续ARQ协议

连续ARQ协议可以说是对停止等待ARQ协议的优化。

在停止等待ARQ协议之上,每次发送多个报文,并等待这些分组的确认信息

连续ARQ协议虽然提高了信道利用率,但是仍然会存在回退N等问题。

确认机制

确认机制指的就是接收端在收到一个正确的报文时,会给发送端回传一个ACK,表明报文已经到达。

延迟确认/累计确认机制

接收端在收到数据之后,并不会立马回传ACK,而是会延迟一定的时间(延迟确认),发送的时候会以最大有序报文的序号作为ACK的数值(累计确认)。

这样的目的很明确就是减少ACK报文的数量,降低ACK造成的网络负担

选择确认SACK

选择确认是TCP首部中的选项,启用SACK功能需要通信双方事先确认,之前也说过SACK字段是为了描述接收端的接收缺口,帮助发送方更加准确的重传丢失报文。

可以在一个ACK报文中,指明多个缺口信息(最多三个),普通的ACK报文可以看做是单个的缺口信息。

超时重传

超时重传又可以称为基于计时器的重传。

TCP每发送一个报文,都会设定一个重传计时器,若在计时器超时时都没有收到确认消息,就会触发重传操作。

超时重传的整体逻辑并不复杂,但是超时时间的选择却是TCP最难的问题之一。

简单超时时间设置,比如SYN的重传 - 每次SYN重传的超时时间都是上一次的简单加倍,比如说上次过了2s之后重传报文,这次就应该等待4s或者6s,这种方式称为二进制指数退避

复杂一点的设置就会根据报文的RTT推算RTO。

RTT (报文段往返时间)- 从报文发出到接受到该报文的ACK所花费的总时间。

RTO (超时重传时间)- 从报文发出到重传报文所花费的时间,也就是所谓的重传超时时间。

这里并不是很懂,就先空着了

快速重传

快速重传是基于接收端反馈信息的重传模式。

首先在TCP中,接收方如果收到一个失序的报文段就会立即发送重复的ACK,而不会选择延迟或者累积。

因此如果接收端的接收缓存中出现缺口,那么后续到达的报文就会 重复确认同一个报文。

简单的举个例子:

接收端的缓存中存在的是报文1,2,3,4,且还未发送ACK,如果此时报文6到达,那么接收端就会立马发送一个ACK=5的报文,如果报文5一直没有到达,那么在报文7,8,9到达时,都会发送一个ACK=5的重复确认报文。

发送方接收到的重复确认报文达到一定阈值(通常为3)之后,就会立马重传确认报文中指定缺失的报文。

快速重传同时也是拥塞控制中的重要算法。

伪重传的判定和响应

伪重传就是指在没有发生数据丢失时,但仍然进行了重传的情况。

导致伪重传的原因有超时时间误差,包失序,包重复或者ACK丢失等。

判定是否是伪重传的方法有以下集中:

  1. DSACK 重复的SACK

    对SACK的增强,可以在第一次SACK块中可以指明接收端中重复收到的报文端序列号。

  2. Eifel检测算法

    该算法需要首部中的时间戳选项支持。

    TCP会在重传的的时候记录下重传报文的TSV,当接收到重传报文是会对比回显TSER和保存的TSV对比。

    如果TSER < TSV,则表示是伪重传。

DSACK只能在接收到重传的ACK之后才能判断此次是否是伪重传,而Eifel检测算法是在第一个ACK到达时,就能判断出来,可能此时重传报文都还没传输到接收端。

重复、失序以及重新组包

失序

包失序可能由IP协议或者链路状态引起,因为IP协议不能保证包的有序发送,而且就算是有序发送但是在动态的网络中也不能保证包有序的到达接收端。

上文也有提到过,当接收到一个失序的报文时,接收端会立马响应一个ACK。

少量的失序并不会造成什么影响,但如果失序报文间隔的报文数目超过快速重传的阈值,就会触发重传,还是伪重传。

重复

《TCP/IP详解 卷一》中也说了IP协议可能出现单次包传输多次的情况,因此也就产生了重复问题。

重复次数过多也就会触发重传。

重新组包

当TCP重传报文时,它并不需要完全重传相同的报文,为了提高性能等原因,可能会发送更大的包。

五、窗口管理

TCP协议中采用滑动窗口机制来实现流量控制。(所以窗口管理也是流量控制的关键)

接受端和发送端各自都会维护一个发送窗口结构和一个接受窗口结构。

窗口结构以字节为单位

上文也说过TCP首部中窗口字段,是接收端回传给发送端的,并以此作为背压控制发送方的发送窗口大小,这也被称作通告窗口

TCP的流量控制

因为TCP的流量控制基本是上基于窗口实现的,所以这块内容我也放到这里了。

流量控制的的主要目的就是在保持相对较高的传输速率的同时,还要保障收发速度平衡。

TCP的流量控制机制就是通过调节ACK数据包中的窗口大小字段实现的,这种方法在控制发送方速率的同时,也明确了接收方的缓存信息,防止接收方的缓存溢出。

发送端窗口结构

1569251203979

上图即为发送端的窗口结构。

中间的发送窗口即为活动窗口,TCP会按照顺序发送区间内的报文。

当接收到返回的数据ACK时,活动窗口也随之右移动,左右两边的相对运动就控制着窗口的大小。

窗口的活动有图中三种:

  1. 关闭(close)- 活动窗口左边界右移,当收到ACK数据时会进行此操作,使窗口减小。

  2. 打开(open)- 活动窗口的右边界右移,当接收的报文被处理时会触发此操作,使窗口增大。

    程序也需要TCP报文中的窗口大小字段判断窗口具体增大多少。

  3. 收缩(shrink)- 活动窗口的右边界左移动,使窗口减小,TCP的协议中强烈不建议此操作。

窗口的左边界明确说明不能左移,因为它代表的是已经被确认的数据

接收端窗口结构

1569252451692

对比于发送端,接收端的窗口结构简单很多,对于活动窗口(接收窗口)内部并不进行细分

如果到达的报文在接受已确认或者无法接受范围,则会被丢弃。

在接受窗口范围内会被缓存,只有在最左边的数据接收到之后整个窗口才能右移。

同样的接受窗口的左边界不能左移。

零窗口问题

当持续收到ACK,但是应用程序并没有及时处理收到的数据(持续关闭,未打开)时,如果左右边界重合,就会出现所谓的零窗口现象。

在接收端窗口扩大重新获得可用窗口空间时,会给发送端发送一个窗口更新报文(window update),通知其可以继续发送数据。

送端也会采用一个持续计时器的机制,当计时器超时就会发送窗口探测报文(window probe),强制要求接收端响应一个ACK报文(首部中包含窗口大小字段)。

Nagle算法

Nagle算法通过减少包发送量来增加网络传输的效率。

小数据包问题 - 即TCP数据包中有效负载较低的问题,一个数据包中至少20字节的TCP首部以及20字节的IP首部,而真实数据甚至可能只有1字节,这就是很严重的浪费。

Nagle算法规定:TCP连接中任意时刻都只能存在一个未经确认的小包,此时不能发送长度小于MSS的包,直到所有数据都ACK之后再合并(coalescing)所有待发送的数据包发送。

Nagle算法的规则(可参考tcp_output.c文件里tcp_nagle_check函数注释):

(1)如果包长度达到MSS,则允许发送;

(2)如果该包含有FIN,则允许发送;

(3)设置了TCP_NODELAY选项,则允许发送;

(4)未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;

(5)上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

这段照抄的…

Nagle算法同时迫使TCP遵循了停止-等待协议,或者说扩展了停止-等待协议。

网络良好的情况下,如果ACK回复的很快,发送端缓存也并没有积累多少数据,此时Nagle算法反而会使整体的传输时间更长。

糊涂窗口综合症(Silly window syndrome

维基百科百科中对糊涂窗口综合症的说明如下:

糊涂窗口综合症Silly window syndrome),亦称愚蠢窗口综合症愚笨窗口综合症,是TCP流量控制实现不良导致的一种计算机网络问题。当发送程序缓慢地创建数据,接收程序缓慢地消耗数据,或者两者同时存在时,滑动窗口运作会出现严重问题。

严重问题就是指的小数据包问题。

举个例子:

若接收端的应用程序处理数据很慢,且每次只处理1个字节的数据,那么接受端的缓存慢慢积累之后就会出现零窗口情况,而处理完1个字节之后缓存有多出了一个字节,此时如果服务端发送窗口更新报文,告诉发送端你只能发送1个字节数据的报文,可想而知效率会有多低。

导致SWS出现的情况有以下几种:

  1. 接收端通告窗口较小
  2. 发送端发送的数据段较小

两端都有可能造成SWS,所以也需要同时从两端解决问题,发送端不应该发送小的报文段(此时Nagle算法可以帮助发送端解决部分发送端的问题),而接收端不应该通告小的窗口。

根据以上情况具体的规则应该按照发送端和接收端区分,

针对发送端而言应该交由Nagle算法控制发送的时间,而且只有满足以下条件,报文才能被传输:

  1. 长度为MSS的报文可以被传输。
  2. 报文长度大于接收端最大窗口值的一半可以发送
  3. 某一ACK不是目前期盼的(重传?)
  4. 连接禁用了Nagle算法

针对于接收端来说,不应该通告小的窗口值,在窗口增长至一个全长的报文段(MSS)或者接收端缓存空间的一半之前,不能通告该窗口。

六、TCP拥塞控制

  • 《TCP/IP详解》里该段内容太复杂了,大概的瞥了眼内容,详细的等我以后有空再看吧 。

拥塞控制的目的就是为了防止过多的包进入链路中,导致链路中的路由器等设备过载而丢弃数据包,引发拥塞。

TCP协议中,由发送方维护一个反映网络传输能力的的变量叫做拥塞窗口(cwnd),所以发送端的活动窗口实际值为拥塞窗口和通告窗口的较小值。

拥塞窗口同时

因为拥塞控制是一个全局性的过程,网络传输能力也不仅仅取决于收发端,所以cwnd也无法取到一个准备的值,只能靠一步步的推测。

慢开始

慢开始的目的是在不清楚网络传输能力的情况下,以少量包慢慢递增的形式进行探测。

拥塞窗口大小在每次接收到一个正确的ACK时+1,所以拥塞窗口大小整体呈指数形式递增。

假设起始的拥塞窗口为n,在每接收到一个ACK之后拥塞窗口加1,所以如果网络良好ACK全部按时收到,那么在第一个RTT时间内拥塞窗口就变为了2n,之后便是4n,以指数增长。

另外由于接收端的延迟确认机制,所以并不会完全按照指数增长。

慢开始的慢并不是增长速度慢,而是初始的拥塞窗口小,在不清楚网络传输能力的情况下,并不会一下子就设置太大的拥塞窗口。

慢开始的触发条件有以下几个:

  1. TCP连接刚初始化
  2. 检测到超时重传(丢包)
  3. 长时间处于空闲状态的连接

另外慢开始还会预先设置一个慢开始门限(ssthresh):

  1. 当cwnd < ssthresh时,执行慢开始算法
  2. 当cwnd > ssthresh时,改用拥塞避免算法
  3. 当cwnd = ssthresh时,慢开始和拥塞避免都可以

慢开始门限并不是固定的,而是会随着时间变化,它代表的是TCP对最佳窗口大小的估计值。

慢启动状态下,TCP判断是否发生拥塞的依据就是是否有丢包。

拥塞避免

拥塞避免的作用就是让cwnd缓慢的线性增长。

虽然是慢开始,但是指数增长的速度过于快速,所以在达到阈值之后会改用拥塞避免。

慢开始和拥塞避免最大的区别就在于ACK到达之后cwnd如何变化。

不论是在慢开始还是用三个避免阶段,只要出现重传的情况(重传就表示TCP判定出现丢包),TCP就会认为此时的cwnd超出网络传输能力,此时会将慢启动门限(ssthresh)减半。

快恢复

快恢复(Fast recovery)是Reno算法新引入的一个阶段,在将丢失的分段重传后,启动一个超时定时器,并等待该丢失分段包的分段确认后,再进入拥塞控制阶段。如果仍然超时,则回到慢启动阶段。

快恢复算法需要快重传配合,在接收到三个连续的ACK(触发快速重传时),快恢复算法会执行如下流程:

  1. 慢开始门限减半(ssthresh/2)
  2. 执行快重传算法,设置拥塞窗口(cwnd)为减半门限(ssthresh/2) + 3MSS(也有不加的TCP实现)
  3. 每接受到一个重复ACK报文,拥塞窗口(cwnd)临时+1
  4. 接收到正确的ACK报文时,cwnd被设置到减半门限(ssthresh/2)

拥塞控制的流程图:

img

TCP Reno和TCP Tahoe

TCP Reno和TCP Tahoe是两种不同的拥塞控制算法。

两种算法对于拥塞的判断都是根据重传超时或者重复确认。

如果发生重传超时两种算法的处理逻辑一致,都会将拥塞窗口设置为1MSS,然后重新开始慢开始算法。

但是对于重复确认来说两种算法不同:

Tahoe算法在收到超过阈值的重复ACK之后先触发的快速重传算法,将慢开始门限设置为当前拥塞窗口(cwnd)的一半,拥塞窗口变为1MSS,再重新开始慢开始算法。

Reno不同的在于快速重传之后的处理,首先慢开始门限是减半,变为当前慢开始门限的一半+3MSS,而且跳过慢开始阶段,直接以减半的慢开始门限作为拥塞窗口(cwnd),直接跑拥塞避免。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!