linux c套接字编程(6) - 深入探讨TCP协议

2018-06-28 12:26:07

了解一些 TCP 协议的操作细节有助于我们调试使用 TCP 套接字程序。

TCP 报文的格式

源端口号:这是TCP发送端的端口号

目的端口号:这是TCP接收端的端口号。

序列号:这是该报文的序列号,是报文开头到数据的第一个字节的偏移量。

确认序号::如果设定了ACK位(见下文),那么这个字段包含了接收方期望从发送方接收到的下一个数据字节的序列号。

首部长度:该字段用来表示TCP报文首部的长度,首部长度单位是 32位。由于这个字段只占4个比特位,因此首部总长度最大可达到60字节。该字段使得TCP接收端可以确定变长的选项字段( options)的长度,以及数据域的起始点。

保留位:该字段包含4个未使用的比特位(必须置为0)。

控制位:该字段由8个比特位组成,能进一步指定报文的含义。
    CWR:拥塞窗口减小标记( congestion window reduced flag)。
    ECE:显式的拥塞通知回显标记( explicit congestion notification echo flag)。CWR 和 ECE标记用在TCP/IP的显示拥塞通知(ECN)算法中。Linux自从2.4版 内核以来就实现了ECN,可以为 Linux专有的文件 /proc/sys/net/ipv4/tcp_ecn 设置一个非零值来开启这个功能。
    URG:如果设置了该位,那么紧急指针字段包含的信息就是有效的。
    ACK:如果设置了该位,那么确认序号字段包含的信息就是有效的(即,该字段 可用来确认由对端发送过来的上一个数据)。
    PSH:将所有收到的数据发给接收的进程。
    RST:重置连接。该字段用来处理多种错误情况。
    SYN:同步序列号。在建立连接时,双方需要交换设置了该位的报文。这样使得 TCP连接的两端可以指定初始序列号,稍后用于在双向传输数据。
    FIN:发送端提示已经完成了发送任务。

可以在报文段中设定多个控制位(或者全都不设置),使得单个报文段能用于多种用途。例如,稍后我们将看到在建立TCP连接时,报文段会同时设置SYN和ACK。 

窗口大小:该字段用在接收端发送ACK确认时提示自己可接受数据的空间大小。(该字段同滑动窗口机制有关) 。

校验和( checksum):16位的检验和包括TCP首部和TCP的数据域。 

TCP校验和不只包含TCP首部和数据域,还包含了常被称为TCP伪首部的12个字节。 伪首部由如下部分组成:源地址和目的地址IP(各占4字节);2字节用来指定TCP报文的大小(这个值是计算出来的,但既不属于IP首部也不属于TCP首部);TCP/IP协议族中针对 TCP的唯一协议号,单字节,值为6;以及1个字节的填充域,该字节全为0(这样伪首部的长度就是16位的整数倍了)。在计算校验和时要包含伪首部的原因是允许TCP的接收端可以重新核对接收到的报文是否已经到达正确的目的地(即,IP层没有错误地将应该发往另一台 主机的数据报接收,或者将应该发往另一个上层协议的数据包转发给了TCP层)。UDP计算校验和的方式和原因都类似于TCP。

紧急指针( Urgent pointer):如果设定了URG位,那么就表示从发送端到接收端传输的数据为紧急数据。我们将在下面简单讨论紧急数据。

选项( Options):这是一个变长的字段,包含了控制TCP连接操作的选项。

数据(Data):这个字段包含了该报文段中传输的用户数据。如果报文段没有包含任何数据的话,这个字段的长度就为0(例如,如果只是一个简单的ACK报文)。


TCP 序列号和确认机制

每个通过TCP连接传送的字节都由TCP协议分配了一个逻辑序列号。(在一条连接中,双向数据流都有各自的序列号。)当传送一个报文时,该报文的序列号字段被设为该传输方向上的报文段数据域第一个字节的逻辑偏移。这样TCP接收端就可以按照正确的顺序对接收到的报文段重新组装,并且当发送一个确认报文给发送端时就表明自己接收到的是哪一个数据。 

要实现可靠的通信,TCP采用了主动确认的方式。也就是,当一个报文段被成功接收后,TCP接收端会发送一个确认消息(即,设置了ACK位的报文段)给TCP发送端,如图所示。该消息的确认序号字段被设置为接收方所期望接收的下一个数据字节的逻辑序列号。(换句话说,确认序号字段的值就是上一个成功收到的分段确认数据字节的序列号加1。) 

当TCP发送端发送报文时会设置一个定时器。如果在定时器超时前没有接收到确认报文,那么该报文会重新发送。


TCP 协议状态机以及状态迁移图

维护一个TCP 链接需要同步协调这个链接的两端。为了减少这项任务的复杂度,TCP 节点以状态机的方式来建模。TCP 的状态有以下几种:

LISTEN:TCP正等待从对端TCP结点发来的连接请求。
SYN_SENT:TCP发送了一个SYN报文,代表应用程序执行了一个主动打开的操作并等待对端回应以此完成连接的建立。
SYN_RECV:之前处于 LISTEN 状态的TCP结点收到了对端发送的SYN报文,并已经通过发送 SYN/ACK报文做出了响应(即,这个TCP报文同时设置了SYN和ACK 位),正等待对端TCP结点发送一个ACK以此完成连接的建立。
ESTABLISHED:与对端TCP结点间的连接建立完成。数据报文此时可以在两个TCP 结点间双向交换。
FIN_WAIT1:应用程序关闭了连接。TCP结点发送一个FIN报文到对端,以此终止本端的连接,并等待对端发来的 ACK。这个状态以及接下来的3中状态都与应用程序执行主动关闭有关。也就是,首先关闭本端连接的应用程序。
FIN_WAIT2:之前处于 FIN_WAIT1 状态的 TCP 节点现在已经收到了对端TCP结点发来的ACK。
CLOSING:之前处于 FIN_WAIT1 状态的TCP节点正在等待对端发送ACK,但却收到了FIN。这表示对端也正在尝试执行一个主动关闭。(换句话说,这两个TCP结点几乎在同一时刻发送了FIN报文。这种情况非常罕见。)
TIME_WAIT:完成主动关闭后,TCP结点接收到了FIN报文。这表示对端执行了被动关闭。此时这个TCP结点在响应给对端 ACK 后将在 TIME_WAIT状态中等待一段固定的时间,这是为了确保TCP连接能够可靠地终止,同时也是为了确保任何老的重复报文在重新建立同样的连接之前在网络中超时消失。当这个固定的时间段超时后,连接就关闭了,相关的内核资源都得到释放。


CLOSE_WAIT:TCP结点从对端收到FIN报文后将处于 CLOSE_WAIT状态。该状态以及接下来的一个状态都同应用程序执行的被动关闭有关,也就是第二个执行关闭操作的应用。
LAST_ACK:应用程序执行被动关闭,而之前处于 CLOSE_WAIT 状态的TCP结点发送一个FIN报文给对端,并等待对端的确认。当收到对端发来的确认ACK报文时, 连接关闭,相关的内核资源都会得到释放。


TCP 连接的建立

在套接字API层,两个流式套接字通过以下步骤来建立连接。
1.服务器调用 listen() 在套接字上执行被动打开,然后调用 accept() 阻塞服务器进程直到连接建立完成。

2.客户端调用 connect() 在套接字上执行主动打开,以此来同服务器端的被动打开套接字之间建立连接。

TCP协议建立连接所执行的步骤请参见下图。这几个步骤通常被称为3次握手,因为 在两个TCP结点间有3个报文需要传递。步骤如下。

1. connect()调用导致客户端TCP结点发送一个SYN报文到服务器端TCP结点。这个报文将告知服务器有关客户端TCP结点的初始序列号(在图中以M来标记)。这个信息是必要的,因为序列号不会从0开始。

2.服务器端TCP结点必须确认客户端发送来的 TCP SYN报文,并告知客户端自己的初始序列号(在图中以N来标记)。(需要两个序列号是因为流式套接字是双向的。)服务器端TCP结点返回一个同时设定了SYN和ACK控制位的报文,这样就能同时执行这两种操作。(我们说ACK承载在SYN上。) 

3.客户端TCP结点发送一个ACK报文来确认服务器端TCP结点的SYN报文。

在3次握手中,前两个步骤中交换的SYN报文可能会包含TCP首部中的 options字段信息,这是用来确定连接的多个相关参数的。

图中尖括号中的标记(例如<LISTEN>)表示TCP连接中任意一侧的状态。

SYN标记占据了序列号字段中的1个字节,这么做是必要的,因为设定了SYN位的报文可能还会包含数据字节,因此这样才能准确确认这个标记。这就是为什么在图中我们通 过ACK M+1报文来确认SYN M。


TCP连接的终止

关闭一个TCP连接通常会以如下几种方式进行。

1.在一个TCP连接中,其中一端的应用程序执行 close() 调用。(通常是由客户端发起, 但这并不是必须的。)我们说这个应用程序正在执行一个主动关闭。

2.稍后,连接另一端的应用程序(服务器)也执行一个 close() 调用。这被称为被动关闭。 

图展示了TCP协议所执行的相关步骤(这里,我们假设是由客户端发起主动关闭) 步骤如下:

1.客户端执行一个主动关闭,这将导致客户端TCP结点发送一个FIN报文给服务器。

2.在接收到 FIN 报文后,服务器端TCP结点发出ACK报文作为响应。之后在服务器端, 任何对 read() 操作的尝试都会产生文件结尾(即返回0)。

3.稍后,当服务器关闭自己这端的连接时,服务器端TCP结点发送FIN报文到客户端。

4.客户端TCP结点发送ACK报文作为响应,以此来确认服务器端发来的FIN报文。

以 SYN 标记为例,基于同样的理由,FIN标记也会占据序列号字段的1个字节。这就是为什么我们在图61-6中展示对FIN M报文的确认时,确认报文应该是ACK M+1。


TIME_WAIT 状态

这个状态会出现现在主动关闭的一方,存在主要有两个目的

1.实现可靠的连接终止
2.让老的重复的报文段在网络中过期失效,这样的建立新的连接时将不再接收它们。

TIME_WAIT 状态区别于其他状态的地方在于:导致从该状态迁移到其他状态(到 CLOSED状态)的事件是超时。这个超时时间为2倍的MSL(2MSL),这里的MSL(报文最大生存时间)是TCP报文在网络中的最大生存时间。

IP首部中有一个8位的生存时间字段(TTL),如果在报文从源主机到目的主机间传递时,在规定的跳数(经过的路由器)内报文没有到达目的地,那么该字段用来确保所有的 IP报文最终都会被丢弃。MSL是IP报文在超过TTL限制前可在网络中生存的最大估计时间。由于TTL只有8位,因此允许最大跳数为255跳。通常,IP报文在完成整个转发过程中需要的跳数比这个最大值要小很多。当路由器出现几种特定类型的异常(例如,路由器配置问题)导致报文在网络中循环直到超过了TTL限制,此时IP报文就会遇到这个限制。

BSD的套接字实现假设 MSL 为30秒,而 Linux遵循了BSD规范。因而, Linux上的 TIME_WAIT 状态将持续60秒。但是,RFC1122建议MSL的值为2分钟,因此在遵循了这个建议的实现中, TIME_WAIT 状态将持续4分钟。

我们可以理解 TIME_WAIT状态的第一个目的了——确保能可靠地终止连接。从上图我们可以看到在终止TCP连接时有4个报文需要交换。其中最后个ACK报文是从执行主动关闭的一方发往执行被动关闭的一方。现在假设这个ACK在网络中被丢弃了,如果发生了这种情况,那么执行TCP被动关闭的一方最终会重传它的FIN报文。让执行TCP主动关闭的一方保持在 TIME_WAIT状态一段时间,可以确保它在这种情况 下可以重新发送最后的ACK确认报文。如果执行主动关闭的一方已经不存在了,那么——由于它不再持有关于连接的任何状态信息—TCP协议将针对对端重发的FIN发送一个RST(重置)给执行被动关闭的一方以作为响应。而这个RST会被解释为一个错误。(这就解释了为何 TIME_WAIT状态的持续时间为2倍的MSL:1个MSL时间留给最后的ACK确认报文到达对端TCP结点,另一个MSL时间留给必须发送的FIN报文。)

要理解 TIME_WAIT 状态的第二个目的—确保老的重复的报文在网络中过期失效。我们必须记住TCP协议采用的重传算法意味着可能会生成重复的报文,并且根据路由的选择,这些重复的报文可能会在连接已经终止后才到达。假设我们在两个套接字地址之间有一条TCP连接,比如说204.152.189.116端口21(FTP服务的端口),以及200.0.0.1端口50000。 同时假设这条连接已经关闭了,而之后使用同样的IP和端口重新建立新的连接。这可以看做是原来连接的新化身。在这种情况下,TCP必须确保上一次连接中老的重复报文不会在新的连接中被当成合法数据接收。当有TCP结点处于 TIME_WAIT状态时是无法通过该结点创建新 的连接的,这样就阻止了新连接的建立。在网络论坛中常会看到的一个问题是如何关闭 TIME_WAIT状态,因为当重新启动的服务器进程尝试将套接字绑定到处于 TIME_WAIT状态的地址上时,会导致出现 EADDRINUSE 的错误(“地址已使用”)。尽管的确有办法可以关闭 TIME_WAIT状态(参见[ Stevens et al. 2004]),并且也有办法可以让TCP结点从 TIME_WAIT状态中过早地终止,但还是应该避免这么做。因为这么做会阻碍TIME_WAIT状态所提供的可靠性保证。

在接下来的介绍中,我们会看到 SO_REUSEADDR套接字选项,这个选项可用来避免常常会遇到的 EADDRINUSE错误,同时仍然允许 TIME_WAIT状态提供其可靠性保证。


套接字选项

#include <sys/socket.h>

int getsockopt(int sockfd, int level, int optname, void *optval,
socklen_t *optlen);

int setsockopt(int sockfd, int level, int optname, const void *optval,
socklen_t optlen);

//Both return 0 on success, or –1 on error

一个是获取,一个是设置。下面一个简单的例子是用来找出套接字的类型。SO_TYPE是只读的,不能使用 setsockopt()。

int optval;
socklen_t optlen;
optlen = sizeof(optval);

if (getsockopt(sfd, SOL_SOCKET, SO_TYPE, &optval, &optlen) == -1)
    errExit("getsockopt");

SO_REUSEADDR 套接字选项可以避免 TCP 服务器重启时,尝试将套接字绑定到当前已经同 TCP 节点相关联的端口上时出现的 EADDRINUSE(地址已使用)错误。这个问题通常会在下面两种情况中出现。

1.之前连接到客户端的服务器要么通过 close(),要么是因为崩溃(例如被信号杀死)而执行了一个主动关闭。这就使得TCP结点将处于 TIME_WAIT 状态,直到2倍的MSL 超时过期为止。

2.之前,服务器先创建一个子进程来处理客户端的连接。稍后,服务器终止,而子进程继续服务客户端,因而使得维护的TCP结点使用了服务器的知名端口号( well-known port)。

在以上两种情况中,剩下的TCP结点无法接受新的连接。尽管如此,针对这两种情况,默认情况下大多数的TCP实现会阻止新的监听套接字绑定到服务器的知名端口上。

设置 SO_REUSEADDR 选项

int sockfd, optval;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
    errExit("socket");

optval = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) == -1)
    errExit("socket");

if (bind(sockfd, &addr, addrlen) == -1)
    errExit("bind");
if (listen(sockfd, backlog) == -1)
    errExit("listen");


 备注

1.编译器版本gcc4.8,运行环境centos7 64位
2.原文地址http://www.freecls.com/a/2712/65 

 

©著作权归作者所有
收藏
推荐阅读
简介
天降大任于斯人也,必先苦其心志。