(一)深入浅出TCPIP之理解TCP报文格式和交互流程

目录

1.引入TCP:         

1.1 TCP用户代码

2. TCP数据报文格式

3 TCP栈及socket的初始化

4. 服务器端bind和listen的实现

4.1 sockaddr与sockaddr_in结构体

4.2 网络字节序

5.服务器墙accept的实现

6.客户端connect的实现(发起三次握手)

7.TCP报文接收


 

 

专栏其他文章:

 

理论篇:

(一)深入浅出TCPIP之理解TCP报文格式和交互流程

  (二)深入浅出TCPIP之再识TCP,理解TCP三次握手(上)

  (三)深入浅出TCPIP之再识TCP,理解TCP四次挥手(上)

  (四)深入浅出TCPIP之TCP三次握手和四次挥手(下)的抓包分析

  (五)深入浅出TCPIP之TCP流量控制

  (六)深入浅出TCPIP之TCP拥塞控制

  (七)深入浅出TCPIP之深入浅出TCPIP之TCP重传机制

 (八)深入浅出TCPIP之TCP长连接与短连接详解

 (九)深入浅出TCPIP之网络同步异步

 (十)深入浅出TCPIP之网络阻塞和非阻塞

(十一)深入浅出TCPIP之TCP粘包问题

  (十二)深入浅出TCPIP之Nagle算法

  (十三) 深入浅出TCPIP之TCP套接字参数

  (十四)深入浅出TCPIP之初识UDP理解报文格式和交互流程

  (十五)非常全面的TCPIP面试宝典-进入大厂必备总结

 (十六)深入浅出TCPIP之Hello CDN

 ....

(二十)深入浅出TCPIP之epoll的一些思考

实践篇:

   深入浅出TCPIP之实战篇—用c++开发一个http服务器(二十一)

其他实践篇+游戏开发中的网络问题疑难杂症解读 正在完善。。。

 

1.引入TCP:         

        TCP和UDP是完全迥异的传输层协议,被设计为做不同的事情。二者的共性是都使用IP作为其网络层协议。TCP和UDP之间的主要差别在于可靠性。TCP 是高可用性的,而 UDP是一个简单的、尽力转发数据报的协议。这个基本的差别暗示TCP更复杂,需要大量功能开销,然而UDP是简单和高效的。建立1个socket,如果没有用它来监听连入请求,那么就能用它来发连出请求。对于面向无连接的协议如UDP来说,这个socket 操作并不做许 多事,但对于面向连接的协议如TCP来说,这一操作包括 了在两个应用间建立一个虚连接。

1.1 TCP用户代码

TCP跟UDP不一样的地方就是:TCP是一个数据流,UDP是数据报。应该真正理解了 下面这段话:

TCP是面向字节流的,意味着消息的描述必须由应用程序来完成,而且要在消息结束的时候显示通知TCP模块以迫使其立即发送相应的字节数据。简单地说,即TCP的recv的字 节数可以是多少就是多少,只要对方有发送,不是按send 的次数来recv的,是按发送的字节数来recv的,也就是说发送方可以进行十次send调用,每次发送5个Bytc,而接收方可 以一次recv 50个Byte,也可以50次reev,每次一个Byte。UDP的sendto与recvfrom的次 数是对应的,一次 sendto就是一个数据报,这个数据包就只能recvfrom一次,不要想着分几 次来recvfrom,那是不可能的。即使用MSG_PEEK,你也不要想着recvfrom部分数据,UDP 数据包是有边界的.

当然还有老生常谈的TCP是先建立连接再发送数据的,UDP不是。但这并不能解释为 什么TCP是流,而UDP是包。不是吗?好吧,先看看一般的TCP应用程序是如何交互的:

 下面是两份代码,分别是服务器端和客户端的代码:

/*
server.c 创建TCP服务器实现服务器和客户端的通信
*/

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

#define LINE     10 
int main()
{ 
	int serfd=0;
	serfd=socket(AF_INET,SOCK_STREAM,0);
	if(serfd<0)
	{
		perror("socket failed");
		return -1;
	}
	printf("socket ok!\n");
    //通过调用bind绑定IP地址和端口号
	int ret=0;
	struct sockaddr_in seraddr={0};
	seraddr.sin_family=AF_INET;
	seraddr.sin_port=htons(8888);
	seraddr.sin_addr.s_addr=inet_addr("192.168.0.2");
	ret=bind(serfd,(struct sockaddr *)&seraddr,sizeof(seraddr));
	if(ret<0)
	{
		perror("bind failed");
		close(serfd);
		return -1;
	}
	printf("bind success\n");
//通过调用listen将套接字设置为监听模式
	int lis=0;
	lis=listen(serfd,LINE);
	if(lis<0)
	{
		perror("listen failed");
		close(serfd);
		return -1;
	}
	printf("listen success\n");
    //服务器等待客户端连接中,游客户端连接时调用accept产生一个新的套接字
	int confd=0;
	socklen_t addrlen;
	struct sockaddr_in clientaddr={0};
	addrlen=sizeof(clientaddr);
	confd=accept(serfd,(struct sockaddr *)&clientaddr,&addrlen);
	if(confd<0)
	{
		perror("accept failed");
		close(serfd);
		return -1;
	}
	printf("connect success!\n");
	printf("ip=%s,port=%u\n",inet_ntoa(clientaddr.sin_addr),ntohs(clientaddr.sin_port));
    //调用recv接收客户端的消息
    while(1)
    {
        int rev=0;
    	int sed=0;
	    char buf[1024]={0};
    	rev=recv(confd,buf,sizeof(buf),0);
    	if(rev>0)
    	{
        	printf("本次收到了%d个字节\n",rev);
	        printf("receive: %s\n",buf);
    	}
	
	    memset(buf,0,sizeof(buf));
	    gets(buf);
	    sed=send(confd,buf,strlen(buf),0);
	    if(sed<0)
	    {
		    perror("send failed");
		    close(serfd);
		    return -1;
	    }
	    printf("send success\n");
    }
	close(confd);
	close(serfd);
	
	return 0;
}
/*
client.c 创建TCP服务器实现服务器和客户端的通信
*/

#include<stdio.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<string.h>
#include<unistd.h>
#include<netinet/in.h> 
int main()
{

    //创建socket套接字
	int clientfd=0;
	clientfd=socket(AF_INET,SOCK_STREAM,0);
	if(clientfd<0)
	{
		perror("socket failed");
		return -1;
	}
	printf("socket ok!\n");
    //客户端可以不绑定IP地址和端口号,系统会随机分配
    //客户端连接服务器
	int ret=0;
	int addrlen=0;
	struct sockaddr_in seraddr={0};
	seraddr.sin_family=AF_INET;
	seraddr.sin_port=htons(8888);
	seraddr.sin_addr.s_addr=inet_addr("192.168.59.131");
	addrlen=sizeof(seraddr);
	ret=connect(clientfd,(struct sockaddr *)&seraddr,addrlen);
	if(ret<0)
	{
		perror("connect failed");
		close(clientfd);
		return -1;
	}
	printf("connect success\n");
    //调用send向服务器发送消息
    while(1)
    {	
	    int sed=0;
	    int rev=0;
	    char buf[1024]={0};
    	gets(buf);
    	sed=send(clientfd,buf,strlen(buf),0);
    	if(sed<0)
    	{
    		perror("send failed");
	    	close(clientfd);
    		return -1;
	    }
    	printf("send success\n");
	
	    memset(buf,0,sizeof(buf));
	    rev=recv(clientfd,buf,sizeof(buf),0);
    	if(rev>0)
    	{
    	printf("本次收到了%d个字节\n",rev);
    	printf("receive: %s\n",buf);
	    }	
    }
	close(clientfd);
	return 0;

}

这块和UDP程序的区别还是挺大的,首先是服务器端多了一个listen和accept,客户端多了一个connect.这几个函数底下完成什么工作呢?本篇文章将这个问题。(UDP的实现将在后边文章说明)。

2. TCP数据报文格式

传输控制协议(TCP)提供了可靠的报文流传输和对上层应用的连接服务,TCP 使用顺序的应答,能够按需重传报文。如果不计任选字段,它通常是20字节。

         每个TCP段都包含源端和目的端的端口号,用于寻找发端和收端应用进程。这两个值加 上IP首部中的源端P地址和目的端IP地址惟一确定一个TCP连接。

         有时,一个I地址和一个端口号也称为一个插口(socket)。这个术语出现在最早的:TCP 规范(RFC793)中,后来它也作为表示伯克利版的编程接口。插口对socket pair《包含客户 IP地址、客户端口号、服务器IP地址和服务器端口号的四元组)可惟一确定互联网络中每 个TCP连接的双方。

         序号用来标识从TCP发端向TCP收端发送的数据字节流,它表示在这个报文段中的第 一个数据字节。如果将字节流看作在两个应用程序间的单向流动,则TCP用序号对每个字节 进行计数。序号是32 bit的无符号数,序号到达2-1后又从О开始。

         当建立一个新的连接时,SYN标志变1。序号字段包含由这个主机选择的该连接的初始 序号ISN(Initial Sequence Number)。该主机要发送数据的第一个字节序号为这个ISN 加1, 因为SYN标志消耗了一个序号(将在下章详细介绍如何建立和终止连接,届时我们将看到 FIN标志也要占用一个序号).

         既然每个传输的字节都被计数,确认序号包含发送确认的一端所期望收到的下一个序号。 因此,确认序号应当是上次已成功收到数据字节序号加1。只有 ACK标志(下面介绍)为1 时确认序号字段才有效。

         发送ACK无需任何代价,因为32 bit的确认序号字段和ACK标志一样,总是TCP首 部的一部分,因此,一旦一个连接建立起来,这个字段总是被设置,ACK标志也总是被设置为1.

         TCP为应用层提供全双工服务。这意味数寨能在两个方向上独立地进行传输。因此,连接的每一端必须保持每个方向上的传输数据序号。

         TCP可以表述为一个没有选搽确认或否认的滑动窗口协议我们说TCP缺少选择确认是 因为TCP首部中的确认序号表示发方已成功收到字节,但还不包含确认序号所指的字节。当 前还无法对数据流中选定的部分进行确认。例如,如果1~1024字节已经成功收到,下一报 文段中包含序号从2049~3072的字节,收端并不能确认这个新的报文段。它所能做的就是发 回一个确认序号为1025的 ACK。它也无法对一个报文段进行否认。例如,如果收到包含1025-2048字节的报文段,但它的检验和错,TCP接收端所能儆的就是发回一个确认序号为1025的ACK.

        首部长度给出首部中32 bit字的数目。需要这个值是因为任选字段的长度是可变的。这 个字段占4 bit,因此TCP最多有60字节的首部。然而,没有任选字段,正常的长度是20 字节.在TCP首部中有6个标志位。它们中的多个可同时被设置为1。在随后的章节中有更详 细的介绍。

       TCP的流量控制由连接的每一端通过声明的窗口大小来提供。窗口大小为字节数,起始 于确认序号字段指明的值,这个值是接收端正期望接收的字节。窗口大小是一个16 bit字段, 因而窗口大小最大为65535字节。

        检验和覆盖了整个的TCP报文段:TCP首部和TCP数据。这是一个强制性的字段, 一定是由发端计算和存储,并由收端进行验证。TCP检验和的计算和UDP检验和的计算 相似。

        只有当URG标志置Ⅰ时紧急指针才有效。紧急指针是一个正的偏移量,和序号字段中 的值相加表示紧急数据最后一个字节的序号。TCP的紧急方式是发送端向另一端发送紧急数 据的一种方式。

        最常见的可选字段是最长报文大小,又称为MSS (Maximum Segment Size)。每个连接方 通常都在通信的第一个报文段(为建立连接而设置SYN标志的那个段)中指明这个选项。它 指明本端所能接收的最大长度的报文段-

        从图1中可以看到 TCP报文段中的数据部分是可选的。将在下一节看到在一个连接建 立和一个连接终止时,双方交换的报文段仅有TCP首部。如果一方没有数据要发送,也使用 没有任何数据的首部来确认收到的数据。在处理超时的许多情况中,也会发送不带任何数据 的报文段。

3 TCP栈及socket的初始化

系统 在启动时就有初始化TCP协议栈的动作,即在inet_init函数中通过两步实现,第一步——调用tcp_v4_init ,第二部调用tcp_init函数,而在系统部分完成TCP的初始化后,这些准备工作还不足以为客户提供-一个完关的TCP socket,所以当要真的使用TCP时,TCP本身让用户在创建socket时有一个初始化socket的 动作,这个动作在用户调用socket 系统函数时,借由其中的inet. _create 函数完成。  

          这个初始化步骤主要指定关于TCP 协议栈内部一些函数集合,这样可以根据系统实际情 况配置 TCP运行于哪一个下层协议之上。由于目前TCP主要在P之上运行,那么其 icsk_af_ops指向了ipv4_specific这个数据结构。如果哪一天要运行在IPv6之上就把这个指针 指向IPv6的相关的操作集合之上。 

4. 服务器端bind和listen的实现

bind函数声明如下:int bind(int sockfd, struct sockaddr *saddr, int addrlen);

输层协议会遇到bind这个系统调用,而且是服务器端使用的接口。每-种协议实现的 bind都是协议相关的,内枝会完成跟协议特定的工作 。

          sockfd是调用socket函数返回的socket描述符,sadd 是一个指向包含有本机IP地址及 端口号等信息的sockaddr 类型的指针: addrlen 常被设置为sizeof(struct sockaddr)。struct sockaddr结构类型是用来保存socket信息的,在这里我们再一次强调, socket 接口不一定只 为TCPIP协议族工作,还有其他类型的协议也可以使用,但当操作TCP/UDP协议时,还有 另外-种结构来表示,即in_sockadr结构,它和sockadr的对应关系如图2所示。 也就是说此时bind 使用sockadd结构的指针作为参数,说明此接口不仅仅是工作于 TCP/IP协议族,内核会根据第一个字段 sa_family切换不同的协议。TCPIP协议族般为 AF_ INET,代表Internet (TCP/IP) 地址族: sa _data 则对应了该socket的IP地址和端口号。

listen系统调用的声明如下:int listen(int sockfd, int queue_length);

需要在此前调用bind函数将sockfd绑定到一个端口上,否则由系统指定一个随机的端口。

接收队列:一个新的 Client的连接请求先被放在接收队列中,直到Server程序调用accept函数接受连接请求。

第二个参数 queue_length,指的就是刊收队列的长 度也就是在Server程序调用accept函数之前最大允许 的连接请求数,多余的连接请求将被拒绝。 

4.1 sockaddr与sockaddr_in结构体

struct sockaddr {
unsigned short sa_family;     /* address family, AF_xxx */
char sa_data[14];                 /* 14 bytes of protocol address */
};
sa_family是地址家族,一般都是“AF_xxx”的形式。好像通常大多用的是都是AF_INET。
sa_data是14字节协议地址。
此数据结构用做bind、connect、recvfrom、sendto等函数的参数,指明地址信息。

但一般编程中并不直接针对此数据结构操作,而是使用另一个与sockaddr等价的数据结构
sockaddr_in(在netinet/in.h中定义):
struct sockaddr_in {
short int sin_family;                      /* Address family */
unsigned short int sin_port;       /* Port number */
struct in_addr sin_addr;              /* Internet address */
unsigned char sin_zero[8];         /* Same size as struct sockaddr */
};
struct in_addr {
unsigned long s_addr;
};

typedef struct in_addr {
union {
            struct{
                        unsigned char s_b1,
                        s_b2,
                        s_b3,
                        s_b4;
                        } S_un_b;
           struct {
                        unsigned short s_w1,
                        s_w2;
                        } S_un_w;
            unsigned long S_addr;
          } S_un;
} IN_ADDR;

sin_family指代协议族,在socket编程中只能是AF_INET
sin_port存储端口号(使用网络字节顺序)
sin_addr存储IP地址,使用in_addr这个数据结构
sin_zero是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节。
s_addr按照网络字节顺序存储IP地址

sockaddr_in和sockaddr是并列的结构,指向sockaddr_in的结构体的指针也可以指向
sockadd的结构体,并代替它。也就是说,你可以使用sockaddr_in建立你所需要的信息,
在最后用进行类型转换就可以了bzero((char*)&mysock,sizeof(mysock));//初始化
mysock结构体名
mysock.sa_family=AF_INET;
mysock.sin_addr.s_addr=inet_addr("192.168.0.1");
……
等到要做转换的时候用:
(struct sockaddr*)mysock

现在再来计算一下socketaddrsocketaddr_in的长度:

socketaddr_in的sin_family以外的字段的长度:sin_port(2) + sin_addr(4) + sin_zero(8) = 14;而巧合的是socketaddr的sa_data的长度也为14
由此我们猜想这两个字段大小和内容应该是一样的,实际上确实如此,在linux的ipv4域中我们可以安全地把指向其中一个结构的指针强制转型为另一个。

4.2 网络字节序

其实数据的顺序是由cpu决定的,与操作系统无关,如 Intel x86结构下,short型数0x1234表示为34 12,int型数0x12345678表示为78 56 34 12(小端数据),如IBM power PC结构下,short型数0x1234表示为12 34,int型数0x12345678表示为12 34 56 78,则为大端数据.在网络传输时需要做好转换,网络字节顺序为大端字节顺序.下面是一些用于转换的函数.

htons:把unsigned short类型从主机序转换到网络序;

htonl:把unsigned long 类型从主机序转换到网络序;

ntohs:把unsigned short类型从网络序转换到主机序;

ntohl:把unsigned long 类型从网络序转换到主机序;

inet_aton(const char *string, struct in_addr*addr):将一个字符串IP地址转换为一个32位的网络序列IP地址

inet_addr:是将一个点分制的IP地址(如192.168.0.1)转换为上述结构中需要的32位IP地址(0xC0A80001),即转换成in_addr,inet_addr()返回的地址已经是网络字节格式,所以无需再调用函数htonl();

inet_ntoa(struct in_addr):返回点分十进制的字符串在静态内存中的指针,所以每次调用 inet_ntoa(),它就将覆盖上次调用时所得的IP地址.

inet_pton(int af, const char *src, void *dst):函数将点分十进制的地址src转换为in_addr的结构体,并复制在dst中.

const char *inet_ntop(int af, const void *src, char *dst, socklen_t cnt):转换网络二进制结构到点分十进制类型的地址

一般编程中并不直接针对sockaddr操作,而是使用sockaddr_in来进行操作.要做转换的时候用:(struct sockaddr*)mysock_addr,填值的时候使用sockaddr_in结构,而作为函数的参数传入的时候转换成sockaddr结构就行了.

my_addr.sin_family = AF_INET;  /* 主机字节序 */
my_addr.sin_port   = htons(MYPORT);  /* short, 网络字节序 */
my_addr.sin_addr.s_addr = htonl(INADDR_ANY);
//还有如此的格式
my_addr.sin_addr.s_addr = inet_addr("192.168.0.1");

5.服务器墙accept的实现

accept的函数声明如下:int accept(int sockfd,struct sockaddr*addr,int *addrlen);

此函数将响应连接请求,建立连接并产生一个新的 socket描述符来描述该连接,该连接 用来与特定的Client交换信息。

函数返回新的连接的socket播述符,错误返回-1

addr将在函数调用后被填入连接对方的地址信息,如对方的IP、端口等。

addrlen作为参数表示addr内存区的大小,在函数返回后将被填入返回的addr结构的大小

accept缺省是阻塞函数,阻塞直到有连接请求。

6.客户端connect的实现(发起三次握手)

connect的函数声明如下: int connect(int sockfd, struct sockaddr *serv_addr,int addrlen);

sockfd就不用再说了。

serv_addr 是包含远端主机IP地址和端口号的指针:

addrlen 是远端地址结构的长度。

connect 函数在出现错误时返回-1,并且设置ermo为相应的错误码。

进行客户端程序设计无须调用bind(),因为这种情况下只需知道目的机器的IP地址和目的机器的端口号,而客户通过本机的哪个端口与目的主机建立连接并不需要关心,socket执行体为程序自动选择一个未被占用的端口,并通知程序数据什么时候到达端口。

在探讨三次握手之前,我们先澄清一个概念:UDP能调用connect吗?答案是:可以 。当发现是UDP协议调用的,它会调用相关的UDP函数。最终调用了ip_route_connect,它内部就是去查找路由cache表,如果找不到,就查找FIB表, 如果找到就放入路由cache中。这是我们在讨论raw_sendmsg时已经介绍过的内容,此函数确实也没什么新意,无非是查路由表。

一个连接操作只能由一个在正确状态下的INET BSD socket来完成,换句话说,socket 不能是已建立连接的,并且有被用来监听连入连接。这意味着 BSD socket结构必须是 SS_UNCONNECTED状态。UDP没有在两个应用间建立虚连接,发出的任何消息都是数据报,这些消息可能到达也可能到达不了目的地。建立在UDP的INET BSD socket上的连接操 作简单地设置远程应用的地址:IP地址和iP端口号。另外,它还设置路由表入口的cache, 以便这一BSD socket在发送UDP包时不用再次查询路由数据库(除非这一路由已经无效). 如果没有给出地址信息,缓存的路由和IP地址信息将自动地被用来发送消息。UDP将sock 的状态改为TCP_ESTABLISHED。注意,UDP完成的connect没有三次握手的动作。这是和 TCP的connect最大的区别。

对于基于TCP的connect操作,TCP必须创建一个包括连接信息的TCP报文,将它送到 目的IP。此TCP报文包含与连接有关的信息,一个惟一标识的报文开始顺序号,通过初始化 主机来管理的报文大小的最大值,及发送与接收窗口大小等。在TCP内,所有的报文都是编 号的,初始的顺序号被用来作为第一报文号。Linux选用一个合理的随机值来避免恶意协议 冲突。每一个从TCP连接的一端成功地传到另一端的报文要确认其已经正确到达。未确认的报文将被重传。发送与接收窗口的大小是第一个确认到达之前报文的个数。消息尺寸的最大 值与网络设备有关,它们在初始化请求的最后时刻确定下来。如果接收端的网络设备的报文应用程序发出连接请求后必须等待目标应用程 序的接受或拒绝连接的响应。TCP同时也开始计时,当目标应用没有响应请求,则连出连接 请求超时。

无论何时一个激活的监听socket接收— 个连入的TCP 连接请求,TCP都要建立一个新 的sock结构来描述它。最终接收时,这个sock 结构将成为TCP连接的底层。它也复制包含连接请求的sk_buf,并将它放到监听 sock结构 的receive_queue中排队。复制的sk_buf包含 一个指向新建立的sock结构的指针。

(关于三次握手我会在后边章节细讲)。

7.TCP报文接收

recv和send函数据供了和read和 write差不多的功能,不过它们提供了第4个参数来控 制读写操作,recv的定义如下:

int recv(int sockfd,void*buff,int len,int flags)

前面的3个参数和read、write一样,第4个参数可以是О或者是以下的组合.

MSG_DONTROUTE不查找路由表,是send函数使用的标志。这个标志告诉协 议,目的主机在本地网络上面,没有必要查找路由表。这个标志一般用在网络诊断和路由程序里面。

MsG_OOB:接收或者发送带外数据,也是send函数使用的标志。

MSG_PEEK;查看数据,并不从系统缓冲区移走数据。是recv函数的使用标志,表 示只是从系统缓冲区中读联内容,而不清楚系统缓冲。

MSG_WAITALL:等待所有数据,是recv函数的使用标志,表示等到所有的信息到 达时才返回。使用这个标志的时候recv回一直阻塞,直到指定的条件满足,或者是发生了错误。

MSG_TRUNC:返回包的真实长度,即使该长度超出了传递的缓存长度。该标志只用于流套接字。也是recv函数的使用标志。

recv函数的返回值如下.

1)当读到了指定的字节时。函数正常返回,返回值等于len。

(2)当读到了文件的结尾时,函致正常返回,返回值小于len.

(3)当操作发生错误时,返回-1,且设置错误为相应的错误号(errno).

如果flags为0,则和read、write一样的操作。还有其他的几个选项,由于用的很少这里 不多细说。可以查看Linux Programmer's Manual得到详细解释。

下一章节:三次握手

已标记关键词 清除标记
实付 9.90元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值