TCP/IP之传输层详解

1. 传输层特征

传输层位于TCP/IP协议族中的第4层,负责在网络主机中创建虚拟传输控制协议TCP,或用户数据报协议UDP,也叫“Transport Layer”。此层向其主机上运行的应用程序发送和接收数据。传输层将端口号分配给在主机上的应用程序中运行的进程,并将TCP或UDP标头添加到从应用程序接收的消息中,用于详细地说明源和目标端口号。

图片

此图来自Microchip

传输层提供了可靠的通信总端至端的解决方案,TCP/IP依靠传输层有效地控制两个主机之间的通信。当IP通信会话必须开始或结束时,将使用传输层来建立此连接。它具有一下几个特征:
1. 传输层保证了两个地址空间之间可靠的端到端连接。
2. 数据可以以任何长度的非结构化字节序列的形式双向发送。
3. 应支持不同的运输机制。

2. 传输层协议

在传输层,有两个重要的传输协议,分别是:提供可靠性传输、面向连接的传输控制协议(TCP);及不可靠、无连接的用户数据报协议(UDP)。

对于传输控制协议(TCP):它具有消息纠错、数据重传和消息确认机制。它总是能够保证以正确的顺序去传递无错误的消息。换言之,主机B总是能够如预期的接收到主机A所发送的消息序列。此外,还具备着消息重排序功能。因为通信过程中,某个消息报文的数据可能会超过网络层所能接收的数据包大小,此时,该消息不能通过一次交互而传达给对方。必须进行所谓的“拆包”流程,即将消息拆分为若干个数据包,且在该组中的每个数据包分配一个编号。传输层在接收到这些所有的数据包之后,会根据其中的包编号进行正常排序组合,合并成一个完整的消息。

图片

对于用户数据报协议(UDP),它是无连接的过程,即在发送消息时候不会去确认连接状态。它可能会出错,并可能无法保证消息顺序的逐一传递。

2.1 TCP协议

2.1.1 TCP首部结构

传输控制协议(TCP)的标头(Header)结构如下图所示:

图片

此图来自 NMAP.ORG

若觉得上图因没有着色而带来视觉上的疲劳与混淆,那么可以参考下图。该图中对每个字段部分分别着色,带来视觉感官上的冲击,让人一目了然、清晰明了。

图片

上面两个图是在RFC 793中的TCP标头(数据结构)示意图。本质上TCP/IP协议族是一个服务,因此,每一层都会有相应的一个结构声明,通过初始化该层的结构体变量来达到各层之间交互的目的。也就说是应用层会有用户自己声明的结构化数据类型,传输层会有系统声明的结构体类型,网络层、数据链路层和物理层同样如此。比如下面的TCP结构类型声明一和声明而分别来自Linux源码中的声明及tcpdump抓包工具中的结构类型声明。

TCP头结构结构声明一(来自tcpdump 4.9.2源码中tcp.h头文件声明)

typedef  uint32_t  tcp_seq;
/*
 * TCP header.
 * Per RFC 793, September, 1981.
 */
struct tcphdr {
uint16_t  th_sport;    /* source port */
uint16_t  th_dport;    /* destination port */
  tcp_seq    th_seq;      /* sequence number */
  tcp_seq    th_ack;      /* acknowledgement number */
uint8_t    th_offx2;    /* data offset, rsvd */
uint8_t    th_flags;
uint16_t  th_win;      /* window */
uint16_t  th_sum;      /* checksum */
uint16_t  th_urp;      /* urgent pointer */
} UNALIGNED;
复制代码

TCP头结构结构声明二(来自linux kernel 5.4.3中tcp.h头文件定义)

struct tcphdr {
__be16  source;
__be16  dest;
__be32  seq;
__be32  ack_seq;
//系统架构大小端判断
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u16  res1:4,
doff:4,
fin:1,
syn:1,
rst:1,
psh:1,
ack:1,
urg:1,
ece:1,
cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u16  doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error  "Adjust your <asm/byteorder.h> defines"
#endif  
__be16  window;
__sum16  check;
__be16  urg_ptr;
};
复制代码

从上面的两个结构体数据类型声明的成员列表可以看出,它是和 TCP标头结构相对应的。关于TCP标头中各字段的含义,可以参考 TCP Segment Header

2.1.2 TCP三次握手

传输控制协议(TCP)是面向连接的,即每次事务(一次完整通信)过程通过三次握手来建立一个可靠的连接,且连接是双向的,即主机A可以向主机B发起握手连接,也可以是主机B向主机A发起握手连接过程。

三次握手中,每次握手过程都伴随着一个消息交互和传递, 这三个消息数据建立了连接双方都需要知道的三个重要讯号:

  1. 用于发送数据的ISN(为了阻止黑客,这些应该是不可预测的)。
  2. 本地数据可用的缓冲区空间(窗口),以字节为单位。
  3. 最大段大小(MSS),它是一个TCP选项,它设置本地主机将接受的最大段。MSS通常是链接MTU大小减去TCP和IP报头的40字节,但是许多实现使用512或536字节的段(这是最大值,不是需求)。

注意,三次握手过程中,不会发送任何的数据信息,直到它们之间已经成功建立连接为止。如下图所示:

图片

三次握手过程不会发送任务数据信息

让我们以一段示例代码来开始三次握手过程的讲解。

client.c文件是通信中客户端的代码实现,它通过创建一个socket句柄sock_fd,将其和处于服务端角色的主机(ip是10.66.114.115, port是8888)进行适配绑定(通过connect),并完成三次握手过程,之后便主动向服务端发送一条消息报文:

图片

然后等待服务端响应报文数据并进行判断,若失败则重新上面的过程;反之,则打印接收到来自服务器的响应报文数据,并关闭该链接句柄,且退出进程。这边是客户端client.c文件中代码的主要任务和逻辑。

客户端代码(client.c)

#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/shm.h>

#define DEFAULT_PORT  8888

int main()
{
//定义sockfd
int sock_fd = 0;
if(-1 == (sock_fd = socket(AF_INET,SOCK_STREAM, 0)))
        {
printf("failed to create socket.\n");
exit(-1);
        }

char buf[1024] = {0};
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(DEFAULT_PORT);           //服务器端口
        server.sin_addr.s_addr = inet_addr("10.66.114.115");     //服务器ip

//连接服务器,成功返回0,错误返回-1
if (connect(sock_fd, (struct sockaddr *)&server, sizeof(server)) < 0)
        {
                perror("connect failed.");
exit(-1);
        }


for(;;)
        {
sprintf(buf, "client index %d", 0);
                send(sock_fd, buf, strlen(buf), 0);
memset(buf, 0x00, sizeof(buf));
if(recv(sock_fd, buf, sizeof(buf), 0) && strlen(buf) < 0)
                {
printf("No data received...\n");
continue;
                }
else
                {
printf("client recv data:[%s]\n", buf);
break;
                }

        }
//关闭socket连接句柄,退出进程
        close(sock_fd);
return 0;
}
复制代码

Server.c文件是通信中服务端角色的代码实现。它负责创建指定ip和端口的监听连接。且等待客户端发送三次握手连接请求,在完成建立连接(accept)之后,并等待来自客户端的写数据请求,并进行解析打印。然后响应客户端报文消息。

图片

并释放socket连接句柄。在这里服务端是发起关闭连接的主动方,客户端是属于关闭连接的被动方。代码如下所示:

服务端代码(sever.c)

#include <stdio.h>
#include <string.h>
#include <stddef.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>

#define DEFAULT_PORT 8888

int main(int argc, char **argv)
{
char buf[1024] = {0};
int sock_fd = 0, conn_fd = 0;
int len = 0;
if(-1 == (sock_fd = socket(AF_INET, SOCK_STREAM, 0))){
printf("failed to create socket.\n");
exit(-1);
        }

struct sockaddr_in server;
memset(&server, 0x00, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_addr.s_addr = htonl(INADDR_ANY);
        server.sin_port = htons(DEFAULT_PORT);

if(bind(sock_fd, (struct sockaddr*)&server, sizeof(server)) == -1){
printf("bind socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(-1);
        }

//开始监听端口
if(listen(sock_fd, 8) < 0 )
        {
printf("listen failed.\n");
exit(-1);
        }

puts("waiting for client's connection ...\n");

//阻塞等待客户端的连接请求
if((conn_fd = accept(sock_fd, (struct sockaddr*)NULL, NULL)) < 0 )
        {
printf("accept socket error: %s(errno: %d)",strerror(errno),errno);
exit(-1);
        }

for(; ;)
        {

                len = recv(conn_fd, buf, sizeof(buf),0);
if(len)
                {
printf("server recv data:[%s]\n", buf);
                }

memset(buf, 0x00, sizeof(buf));
sprintf(buf, "server index %d", 0);
                send(conn_fd, buf, len, 0);
//接收来自客户端的数据之后,则关闭该socket连接(四次挥手)
break;
        }

        close(conn_fd);
        close(sock_fd);
for(;;);  //进程暂不退出
return 0;
}
复制代码

上面代码实现的客户端和服务端之间的通信过程如下图所示。服务端服务起来,并开始监听8888端口;等待客户端发起TCP三次握手请求中的第一次,然后解析报文并响应客户端(第二次握手),客户端在接收到来自服务端的消息报文后,进行系列的解析处理过程,判断报文正确无误后,响应服务端报文(第三次握手)。之后服务器阻塞等待客户端发起数据写请求,然后解析并打印,同时响应客户端报文,之后便主动关闭掉socket连接句柄。

图片

客户端与服务端通信示意图

2.1.2.1 tcupdump网络抓包

为了更加直观的看到TCP建立通信(三次握手)过程中的细节,在当服务端启动监听服务进程之后,便使用tcpdump开始抓包工作。

tcpdump -i bond0 -w dst port 8888 -w ./3WayHandshake.cap

同时打开客户端服务进程。当进程运行起来之后,便会主动向服务器发起握手连接。整个完整过程如下图所示:

图片

截图中红色线框里序号为1、2、3的三个数据段分别对应TCP三次握手过程中的三次消息数据段交互。现对其中每个数据段进行详细截图说明。

下图是TCP建立建立通信(三次握手)中的第一次握手,从图中的红色线框标注可以看到,客户端向服务器发送的第一个消息中,包含了源端口、目的端口、TCP数据长度0、同步序列号SYN值以及MSS(最大数据段长度)等重要值信息。

图片

TCP三次握手建立连接中第一次握手过程

在TCP的第二次握手过程中,服务端收在收到客户端发送的同步序列号SYN、MSS等字段值后,同时向客户端响应了服务端的同步序列号SYN、MSS、源端口、目的端口等信息,此外,还多了一个字段值ACK(确认号)。

图片

TCP三次握手建立连接中第二次握手过程

TCP的第三次握手中,客户端在接收到服务端的响应消息报文之后,进行系列的解析和判断确认;若一切正常,则向服务器发现ACK确认号。到这里时候,本次通信的面向连接已正式成立,接下来就可以开始数据通信操作。

图片

TCP三次握手建立连接中第三次握手过程

以上三次握手过程,现在使用时序图形式表示出来,对理解TCP建立连接时的三次握手过程更加清晰。

图片

TCP三次握手过程

TCP三次握手主要下面重要功能:
它确保双方都知道他们已准备好传输数据,并且还允许双方就握手期间发送并确认的初始序列号达成共识(因此,它们没有错误)

序号4、5、6是TCP三次握手建立之后,客户端与服务端之间数据交互的过程,序号4为客户端使用send函数API向服务器发送“client index 0”这14个字节数据,下面截图中最上方图中红色线框出来了。服务端在收到来自客户端的数据消息之后,便发送一个ACK确认号,同时服务端向客户端发送一个响应消息报文“server index 0”这14个字节。然后服务器主动发起断开连接。

图片

2.1.2.2 关于序列号

每台TCP主机分配自己的序列号。在TCP连接中使用的初始化序列号由主机定义的,它使用一种随机算法去生成难以预测的初始化序列号。
发送确认号 = 收到的序列号 + 收到的数据字节数。确认号字段包含了另一方期待的下一个序列号值。

2.1.3 TCP半开连接

一般情况下,TCP总是会顺利地完成三次握手过程,并建立连接通信。但是,网络世界总是错综复杂,难免会出现一些意外。如图中所示,当某主机(假设左边主机是客户端)向另外一个主机(假设右边主机是服务端)发起第一个 SYN 握手请求数据包时候,服务端主机会使用 ACK + SYN 数据包进行应答,然后客户端主机会通过发送第三个 ACK 数据包来完成握手连接过程。但是,此时主机(客户端)可能丢失了网络连接,或是机器宕掉。因此,尽管主机(服务端)不断地尝试发送 ACK + SYN数据报以渴望得到 主机(客户端)的 ACK响应。但是因为不知道主机(客户端)的状态,因此当握手过程没有以最后一个 ACK 结束时候,就发生了所谓的半开连接。

图片

2.1.4 TCP四次挥手

在2.1.2.1节通过对tcpdump抓包文件的拆包分解,详细地说明了TCP在建立面向连接通信过程中的三次握手。同时也说明抓包文件中的序号4、5、6为客户端与服务端数据交互过程。可以看到这个完整的抓包报文数据段中还剩下序号(编号)为7、8、9、10的共4个数据报文未进行说明。很显然,这4个数据报是TCP在释放本次通信连接时候的四次挥手过程。

图片

TCP四次挥手释放本次事务

从图中可以看到,是服务端先发起关闭socket连接请求的(端口8888->37948),这是四次挥手释放连接中的第一个过程,该数据包中包含了字段值FIN(事务被完成)和ACK(确认号)。客户端在收到了服务端的释放连接请求之后,便应答 一个ACK(确认号)。之后便紧接着发送一个FIN + ACK字段值,这是第三次挥手过程。服务端在接收到客户端的消息报文之后,进行解析和逻辑判断,若序列号和确认号没有问题,则回复ACK(确认号),表示同意关闭本次网络连接事务。

图片

TCP四次挥手

2.1.4.1 再次关于序列号

不知你有没有注意到,在上图的四次挥手过程中,同步序列号SYN、确认号ACK值一下从之前TCP的三次握手过程中的1、1到现在的15、15;16、16啦?

图片

现在让我们再次温习一下这个关于“序列号”的话题。

关于序列号生成问题,下面三点一定、一定、一定要多阅读几次,重要的事情说三遍。(备注:以下三点,外加下图示例均摘自《TCP/IP协议原理与应用-第四版》,作者 Jeffrey L.Carrell, Laura A.Chappel, Ed Tittel, James Pyles. )

  1. 每台主机自己分配自己的序列号(SYN)。
  2. 在TCP连接中使用的初始话序列号是由主机定义的,出于安全目的,初始序列号应该随机选取。
  3. 除了在TCP启动和拆除序列之外,确认号字段仅仅在收到数据时候增加。

图片

由于数据流能够改变方向(主机1向主机2发送数据,之后主机2向主机1发送数据),每一方的序列号字段都可以增加一段时间,之后又随着通信另一方序列号字段开始增加而暂停增加一段时间。

现在大概对“序列号”有了一定认识,接下来通过抓包文件中各数据段信息来仔细分析一下我们示例代码中的关于四次挥手过程中的序列号SYN和确认号ACK的变化。

图片

上图是tcpdump抓包得到的数据段信息,更加“序列号”的增加原则,可以得到序列号SEQ、确认号ACK等变化值的时序图如下:

图片

2.2 UDP协议

图片

此图来自 NMAP.ORG

相较于TCP的标头结构而言,UDP的标头结构显得非常简单,仅仅只有4个字段。它们分别是:源端口号、目的端口号、长度和校验和。

  1. 源端口-这16位信息用于标识数据包的源端口。
  2. 目标端口-这16位信息,用于标识目标计算机上的应用程序级别服务。
  3. 长度-长度字段指定UDP数据包的整个长度(包括标头)。它是16位字段,最小值为8字节,即UDP标头本身的大小。
  4. 校验和-此字段存储发送方在发送之前生成的校验和值。IPv4具有此字段作为可选字段,因此当校验和字段不包含任何值时,它将设为0,并将其所有位设置为零。

由于UDP提供的是无连接、提供不可靠数据交互功能,因此在同样配置环境下,UDP比TCP大约快了40%的速度,如果追求的是响应速度,并且对丢包能够容忍,或说是可以接受,那么UDP一定是最佳的传输层选择协议。当然,如果对丢包问题很苛刻,但是对网络数据延时能够接受,那么响应,TCP是你的唯一选择。下面的一些应用程序实用了UDP来传输数据。
DNS(Domain Name Services 域名系统)
SNMP(Simple Network Management Protocol 简单网络管理协议)
TFTP(Trivial File Transfer Protocol 普通文件传输协议)
RIP(Routing Information Protocol 路由信息协议)

对于TCP与UDP通信过程的区别见下图所示:

图片

此图来自 CLOUDFLARE

· UDP接收到的数据包顺序不一定和发送顺序相同。
· 分组数据包不保证一定能够到达目的地,可能在传输过程中丢失。
· 数据包发送之前不需要建立连接。

3. 总结

本文讲解了传输层的功能和所处TCP/IP协议族位置,以及传输层最为重要的两个协议,即传输控制协议TCP和用户数据报协议UDP。同时,还详细的剖析了TCP协议的标头结构,以及TCP特性,此外,还说明了TCP面向连接中的三次握手过程,通过图文并茂的方式详细说明了连接过程中报文交互的详细信息。另外,也对UDP的功能特性以及标头结构进行了比较详细的说明。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享