Linux系统编程之TCP/UDP协议

本章节集中分析网络编程必备知识TCP,UDP数据报格式,熟悉其原理,优缺点以及通信细节,尤其是对于TCP下的三次握手,状态转换,滑动窗口等复杂机制的理解,为后续Socket编程打下牢牢的基础。

TCP/UDP

UDP

从之前网络基础中,我们了解到,UDP协议不面向连接,也不保证可靠性,有点像寄信,写好信放到邮筒里,既不能保证信件在邮递过程中不会丢失,也不能保证信件是按顺序寄到目的地的。 使用UDP协议的应用程序需要自己完成丢包重发、消息排序等工作。

其协议相对简单,并且更加灵活,对服务器和网络资源的负载也相对小一些。底层协议提供的数据不保障可靠性,那么就需要在上层应用中自己试试对应的政策来保证数据传输的有效性了。

UDP数据报格式

UDP数据段如下图所示:

图示很简单,UDP格式中8字节64bit的数据是必须的,只包含了源端口号,目的端口号,UDP长度以及校验位,结合之前网络基础的各层协议数据报格式,这里具体分析一下。

UDP通信分析

下面分析一帧基于UDP的TFTP协议帧

以太网首部
0000: 00055d67d0b100055d6158a80800
IP首部
0000: 45 00
0010: 005393250000801125ecc0a80037c0a8
0020: 00 01
UDP首部
0020: 05 d4 00 45 00 3f ac 40
TFTP协议
0020: 00 01 ‘c”:‘”’q’
0030: ‘w’‘e’‘r’‘q”.‘’q’‘w’‘e’00 ’n’‘e”t’‘a”s’‘c’‘i’ 
0040: ‘i’00 ’b’‘l’‘k”s’‘i’‘z’‘e’00 ’5’‘1’‘2’00 ’t’‘i’ 
0050: ’m’‘e’‘o’‘u”t’00 ‘1’‘0’00 ’t”s’‘i’‘z’‘e’00 ’0’ 
0060: 00
  1. 以太网首部: 源MAC地址是 00:05:5d:61:58:a8,目的MAC地址是 00:05:5d:67:d0:b1,上层协议类型 0x0800表示IP。
  2. IP首部: 每一个字节0x45包含4位版本号和4位首部长度,版本号为4,即IPv4,首部长度为5,说明IP首部不带有选项字段。服务类型为0,没有使用服务。16位总长度字段(包括IP首部和IP层payload的长度)为0x0053,即83字节,加上以太网首部14字节可知整个帧长度是97字节。IP报标识是0x9325,标志字段和片偏移字段设置为0x0000,就是DF=0允许分片,MF=0此数据报没有更多分片,没有分片偏移。TTL是0x80,也就是128。上层协议0x11表示UDP协议。IP首部校验和为0x25ec,源主机IP是 c0 a8 00 37(192.168.0.55),目的主机IP是 c0 a8 00 01(192.168.0.1)。
  3. UDP首部: 源端口号 0x05d4(1492)是客户端的端口号,目的端口号 0x0045(69)是TFTP服务的well-known端口号。UDP报长度为0x003f,即63字节,包括UDP首部和UDP层pay-load的长度。UDP首部和UDP层payload的校验和为0xac40。
  4. TFTP是基于文本的协议,各字段之间用字节0分隔,开头的00 01表示请求读取一个文件,接下来的各字段是:

    c:\qwerq.qwe
    netascii
    blksize 512
    timeout 10
    tsize 0
    

一般的网络通信都是像TFTP协议这样,通信的双方分别是客户端和服务器,客户端主动发起请求(上面的例子就是客户端发起的请求帧),而服务器被动地等待、接收和应答请求。客户端的IP地址和端口号唯一标识了该主机上的TFTP客户端进程,服务器的IP地址和端口号唯一标识了该主机上的TFTP服务进程,由于客户端是主动发起请求的一方,它必须知道服务器的IP地址和TFTP服务进程的端口号,所以,一些常见的网络协议有默认的服务器端口,例如HTTP服务默认TCP协议的80端口,FTP服务默认TCP协议的21端口,TFTP服务默认UDP协议的69端口(如上例所示)。在使用客户端程序时,必须指定服务器的主机名或IP地址, 如果不明确指定端口号则采用默认端口,请读者查阅ftp、tftp等程序的man page了解如何指定端口号。/etc/services中列出了所有well-known的服务端口和对应的传输层协议,这是由IANA(Internet Assigned Numbers Authority)规定的,其中有些服务既可以用TCP也可以用UDP,为了清晰,IANA规定这样的服务采用相同的TCP或UDP默认端口号,而另外一些TCP和UDP的相同端口号却对应不同的服务。

很多服务有well-known的端口号,然而客户端程序的端口号却不必是well-known的,往往是每次运行客户端程序时由系统自动分配一个空闲的端口号,用完就释放掉,称为 ephemeral的端口号.

发送端的UDP协议层只管把应用层传来的数据封装成段交给IP协议层就算完成任务了, 如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。接收端的UDP协议层只管把收到的数据根据端口号交给相应的应用程序就算完成任务了,如果发送端发来多个数据包并且在网络上经过不同的路由,到达接收端时顺序已经错乱了,UDP协议层也不保证按发送时的顺序交给应用层。

通常接收端的UDP协议层将收到的数据放在一个固定大小的缓冲区中等待应用程序来提取和处理,如果应用程序提取和处理的速度很慢,而发送端发送的速度很快,就会丢失数据包,UDP协议层并不报告这种错误。

因此,使用UDP协议的应用程序必须考虑到这些可能的问题并实现适当的解决方案,例如等待应答、超时重发、为数据包编号、流量控制等。一般使用UDP协议的应用程序实现都比较简单,只是发送一些对可靠性要求不高的消息,而不发送大量的数据。例如,基于UDP的TFTP协议一般只用于传送小文件(所以才叫trivial的ftp),而基于TCP的FTP协议适用于各种文件的传输。

TCP

TCP是一种面向连接的、可靠的协议。TCP传输的双方需要首先建立连接,之后由TCP协议保证数据收发的可靠性,丢失的数据包自动重发,上层应用程序收到的总是可靠的数据流,通讯之后关闭连接。

TCP数据报格式

作为一种可靠的协议,其格式相比UDP来说自然更加复杂些,具体如下图所示:

和UDP协议一样也有源端口号和目的端口号,通讯的双方由IP地址和端口号标识。32位序号、32位确认序号、窗口大小稍后详细解释。4位首部长度和IP协议头类似,表示TCP协议头的长度,以4字节为单位,因此TCP协议头最长可以是4x15=60字节,如果没有选项字段,TCP协议头最短20字节。URG、ACK、PSH、RST、SYN、FIN是六个控制位,本节稍后将解释SYN、ACK、FIN、RST四个位,其它位的解释从略。16位检验和将TCP协议头和数据都计算在内。紧急指针和各种选项的解释从略。

TCP通信时序

下图是一次TCP通讯的时序图:

首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-10,各段中的主要信息在箭头上标出,例如段2的箭头上标着 SYN, 8000(0), ACK 1001,表示该段中的 SYN 位置1,32位序号是 8000,该段不携带有效载荷(数据字节数为0),ACK 位置1,32位确认序号是 1001,带有一个 mss 选项值为1024。

建立连接

TCP建立连接的过程,或者称为三次握手,如下:

  1. 客户端发出段1,SYN 位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况,另外,规定 SYN 位和 FIN 位也要占一个序号,这次虽然没发数据,但是由于发了 SYN 位,因此下次再发送应该用序号 1001。mss 表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
  2. 服务器发出段2,也带有 SYN 位,同时置 ACK 位表示确认,确认序号是 1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。
  3. 客户端发出段3,对服务器的连接请求进行应答,其中带有标志 ACK,,确认序号在接受数据上加一,是8001。

在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为“三方握手(three-way-handshake)”。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。

在TCP通讯中,如果一方收到另一方发来的段,读出其中的目的端口号,发现本机并没有任何进程使用这个端口,就会应答一个包含 RST 位的段给另一方。

数据传输的过程

由图中中间段数据传输图示,可以得知:

  1. 客户端发出段4,包含从序号1001开始的20个字节数据, ACK 确认序号 8001(确认三次握手建立连接的到的 ACK 8001)。
  2. 服务器发出段5,确认序号 ACK 为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback。
  3. 客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。

断开连接

TCP关闭连接的过程,或称为四次握手:

  1. 客户端发出段7,FIN 位表示客户端主动关闭彼此连接的请求。
  2. 服务器发出段8,应答客户端的关闭连接请求, ACK 在之前 1021 基础上加一。
  3. 服务器发出段9,其中也包含 FIN 位,服务端开始关闭,向客户端发送关闭连接的请求。
  4. 客户端发出段10,应答服务器的关闭连接请求,表示以确认服务端关闭。

以上,基本描述了TCP一次完整通信过程中的详细细节。

TCP状态转换图

相信对于上面的时序图来说,大多数都不陌生。上述过程在非常理想状态下是完全ok的,也就说是排除了定位网络或系统故障。对于具体的每个状态及其装换过程,尤其涉及到网络波动时,就需要对每个状态之间的切换作了解,同时在了解了状态图后,那么对于时序图的理解会更加充分。下面对这张图的11种状态详细解析一下,以便加强记忆!不过在这之前,先回顾一下TCP建立连接的三次握手过程,以及关闭连接的四次握手过程。

TCP的连接与断开

三次握手建立连接
  1. 客户端发送一个带 SYN 标志的TCP报文到服务器。这是三次握手过程中的报文1。
  2. 服务器端回应客户端的,这是三次握手中的第2个报文,这个报文同时带 ACK 标志和 SYN 标志。因此它表示对刚才客户端 SYN 报文的回应;同时又标志 SYN 给客户端,询问客户端是否准备好进行数据通讯。
  3. 客户必须再次回应服务段一个 ACK 报文,这是报文段3。
四次握手终止连接

由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个 FIN 来终止这个方向的连接。收到一个 FIN 只意味着这一方向上没有数据流动,一个TCP连接在收到一个 FIN 后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。

  1. TCP客户端发送一个 FIN ,用来关闭客户到服务器的数据传送(报文段4)。
  2. 服务器收到这个 FIN ,它发回一个 ACK ,确认序号为收到的序号加1(报文段5)。和 SYN 一样,一个 FIN 将占用一个序号。
  3. 服务器关闭客户端的连接,发送一个 FIN 给客户端(报文段6)。
  4. 客户段发回 ACK 报文确认,并将确认序号设置为收到序号加1(报文段7)。

状态转换图

TCP通信过程中,各个阶段涉及的具体的状态如下图所示:

其图示中涉及的具体状态点如下解释:

  1. CLOSED: 这个没什么好说的了,表示初始状态。
  2. LISTEN: 这个也是非常容易理解的一个状态,表示服务器端的某个SOCKET处于监听状态,可以接受连接了。
  3. SYN_RCVD: 这个状态表示接受到了SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用netstat你是很难看到这种状态的,除非你特意写了一个客户端测试程序,故意将三次TCP握手过程中最后一个ACK报文不予发送。因此这种状态时,当收到客户端的ACK报文
    后,它会进入到ESTABLISHED状态。
  4. SYN_SENT: 这个状态与SYN_RCVD遥想呼应,当客户端SOCKET执行CONNECT连接时,它首先发送 SYN 报文,因此也随即它会进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送 SYN 报文。
  5. ESTABLISHED:这个容易理解了,表示连接已经建立了。
  6. FIN_WAIT_1: 这个状态要好好解释一下,其实FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了 FIN 报文,此时该SOCKET即进入到FIN_WAIT_1状态。而当对方回应 ACK 报文后,则进入到FIN_WAIT_2状态,当然在实际的正常情况下,无论对方何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1状态一般是比较难见到的,而FIN_WAIT_2状态还有时常常可以用netstat看到。
  7. FIN_WAIT_2:上面已经详细解释了这种状态,实际上FIN_WAIT_2状态下的SOCKET,表示半连接,也即有一方要求close连接,但另外还告诉对方,我暂时还有点数据需要传送给你,稍后再关闭连接。
  8. TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带 FIN 标志和 ACK 标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。
  9. CLOSING: 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送 FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的 FIN 报文。什么情况下会出现此种情况
    呢?其实细想一下,也不难得出结论:那就是如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送 FIN 报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。
  10. CLOSE_WAIT: 这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方close一个SOCKET后发送 FIN 报文给自己,你系统毫无疑问地会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以close这个SOCKET,发送 FIN 报文给对方,也即关闭连接。所以你在CLOSE_WAIT状态下,需要完成的事情是等待你去关闭连接。
  11. LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送 FIN 报文后,最后等待对方的 ACK 报文。当收到 ACK 报文后,也即可以进入到CLOSED可用状态了。

以上分析需要结合上面两张图一起观察,此种涉及到了通信过程中的种种细节,尤其是客户端和服务端发送相应数据后的状态变化,其中还有一些对未知情况的响应处理策略,需要认真回味。

TCP流量控制(滑动窗口)

在介绍UDP时,我们描述了这样的问题:如果发送端发送的速度较快,接收端接收到数据后 处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP协议可以通过’滑动窗口 (Sliding Window)’机制解决这一问题。看下图的通讯过程。

提下对图示进行解释:

  1. 发送端发起连接,声明最大段尺寸mss是1460,初始序号是0,窗口大小是4K,表示“我的接收缓冲区还有4K字节空闲,你发的数据不要超过4K”。接收端应答连接请求,声明最大段尺寸是1024,初始序号是8000,窗口大小是6K。发送端应答,三方握手结束。
  2. 发送端发出段4-9,每个段带1K的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。
  3. 接收端的应用程序提走2K数据,接收缓冲区又有了2K空闲,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K(取走数据2k,就剩下了2k的空余窗口大小)。
  4. 接收端的应用程序又提走2K数据,接收缓冲区有4K空闲,接收端发出段11,重新声明窗口大小为4K。
  5. 发送端发出段12-13,每个段带1K数据,段13同时还包含FIN位。
  6. 接收端应答接收到的2K数据(6145-8192),再加上 FIN 位占一个序号8193,因此应答序号是8194,连接处于半关闭状态,接收端同时声明窗口大小为2K(原剩余空间4k-2k接受新的数据,剩余大小2k)。
  7. 接收端的应用程序提走2K数据,接收端重新声明窗口大小为4K。
  8. 接收端的应用程序提走剩下的2K数据,接收缓冲区全空,接收端重新声明窗口大小为 6K。
  9. 接收端的应用程序在提走全部数据后,决定关闭连接,发出段17包含 FIN 位,发送端应答,连接完全关闭。

上图在接收端用小方块表示1K数据,实心的小方块表示已接收到的数据,虚线框表示接 收缓冲区,因此套在虚线框中的空心小方块表示窗口大小,从图中可以看出,随着应用程序提走数据,虚线框是向右滑动的,因此称为滑动窗口。

从这个例子还可以看出,发送端是1K、1K地发送数据,而接收端的应用程序可以2K、2K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。

TCP半链接状态

当TCP链接中A发送 FIN 请求关闭,另一段B回应 ACK 后,B没有立即发送 FIN 给A时,A方处在半链接状态,此时A可以接收B发送的数据,但是A已不能再向B发送数据。

如下函数原型

#include <sys/socket.h>

int shutdown(int sockfd, int how)

sockfd: 需要关闭的socket的描述符
how: 允许为shutdown操作选择以下几种方式:
        SHUT_RD:关闭连接的读端。也就是该套接字不再接受数据,任何当前在套接字接受缓冲区的数据将被丢弃。进程将不能对该套接字发出任何读操作。对TCP套接字该调用之后接受到的任何数据将被确认然后无声的丢弃掉。
        SHUT_WR:关闭连接的写端,进程不能在对此套接字发出写操作
        SHUT_RDWR:相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR

使用close中止一个连接,但它只是减少描述符的参考数,并不直接关闭连接,只有当描述符的参考数为0时才关闭连接。shutdown可直接关闭描述符,不考虑描述符的参考数,可选择中止一个方向的连接。

注意:

  1. 如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字将被释放。
  2. 在多进程中如果一个进程中shutdown(sfd, SHUT_RDWR)后,其它的进程将无法进行通信。如果一个进程close(sfd)将不会影响到其它进程。

2MSL/TIME_WAIT

TIME_WAIT状态的存在有两个理由:

  1. 让4次握手关闭流程更加可靠;4次握手的最后一个 ACK 是由主动关闭方发送出去的,若这个 ACK 丢失,被动关闭方会再次发一个 FIN 过来。若主动关闭方能够保持一个2MSL的TIME_WAIT状态,则有更大的机会让丢失的 ACK 被再次发送出去。
  2. 防止lost duplicate对后续新建正常链接的传输造成破坏。lost duplicate在实际的网络中非常常见,经常是由于路由器产生故障,路径无法收敛,导致一个packet在路由器A,B,C之间做类似死循环的跳转。IP头部有个TTL,限制了一个包在网络中的最大跳数,因此这个包有两种命运,要么最后TTL变为0,在网络中消失;要么TTL在变为0之前路由器路径收敛,它凭借剩余的TTL跳数终于到达目的地。但非常可惜的是TCP通过超时重传机制在早些时候发送了一个跟它一模一样的包,并先于它达到了目的地,因此它的命运也就注定被TCP协议栈抛弃。另外一个概念叫做incarnation connection,指跟上次的socket pair一摸一样的新连接,叫做incarnation of previous connection。lost duplicate加上incarnation connection,则会对我们的传输造成致命的错误。大家都知道TCP是流式的,所有包到达的顺序是不一致的,依靠序列号由TCP协议栈做顺序的拼接;假设一个incarnation connection这时收到的seq=1000, 来了一个lost duplicate为seq=1000, len=1000, 则tcp认为这个lost duplicate合法,并存放入了receive buffer,导致传输出现错误。通过一个2MSL TIME_WAIT状态,确保所有的lost duplicate都会消失掉,避免对新连接造成错误。

该状态为什么设计在主动关闭这一方:

  1. 发最后 ACK 的是主动关闭一方
  2. 只要有一方保持TIME_WAIT状态,就能起到避免incarnation connection在2MSL内的重新建立,不需要两方都有

如何正确对待2MSL TIME_WAIT?

RFC要求socket pair在处于TIME_WAIT时,不能再起一个incarnation connection。但绝大部分TCP实现,强加了更为严格的限制。在2MSL等待期间,socket中使用的本地端口在默认情况下不能再被使用。若A 10.234.5.5:1234和B 10.55.55.60:6666建立了连接,A主动关闭,那么在A端只要port为1234,无论对方的port和ip是什么,都不允许再起服务。显而易见这是比RFC更为严格的限制,RFC仅仅是要求socket pair不一致,而实现当中只要这个port处于TIME_WAIT,就不允许起连接。这个限制对主动打开方来说是无所谓的,因为一般用的是临时端口;但对于被动打开方,一般是server,就悲剧了,因为server一般是熟知端口。比如http,一般端口是80,不可能允许这个服务在2MSL内不能起来。解决方案是给服务器的socket设置SO_REUSEADDR选项,这样的话就算熟知端口处于TIME_WAIT状态,在这个端口上依旧可以将服务启动。当然,虽然有了SO_REUSEADDR选项,但sockt pair这个限制依旧存在。比如上面的例子,A通过SO_REUSEADDR选项依旧在1234端口上起了监听,但这时我们若是从B通过6666端口去连它,TCP协议会告诉我们连接失败,原因为Address already in use.

总结

本博文涉及众多细节知识点,基本梳理了邢文鹏老师的Linux教学资料中的内容,也相当于自己对这些方面的知识点的回顾。有了之前网络的基础知识,以及这些TCP、UDP通讯细节,对下面的Socket变成来说就能够更加游刃有余。

邢文鹏Linux教学资料

坚持原创技术分享,您的支持将鼓励我继续创作!