【计算机网络】——TCP协议简介以及TCP编程

    技术2024-07-17  76

    文章目录

    1、TCP概述1.1TCP含义1.2TCP首部报文格式1.3理解源IP地址和目的IP地址1.4认识端口号 2、网络编程基础API2.1创建socket2.2命名socket——bind2.2.1网络字节序和主机字节序2.2.2IP地址转换 2.3监听socket——listen2.4接收连接——accept2.5发起连接——connect2.6 send()函数2.7 recv()函数 3、TCP编程实例

    1、TCP概述

    1.1TCP含义

    TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

    【特点】

    面向连接型的传输协议:每一次完整的数据传输都要经过建立连接、使用连接、终止连接的过程;仅支持单播传输,支持全双工传输TCP连接是基于字节流的,而非报文;传输单位为数据段,每次发送的TCP数据段大小和数据段数都是可变的;

    【优点】可靠、稳定

    TCP在数据传递之前,会有三次握手来建立连接连接;在数据传递时,采用校验和,序列号,确认应答,超时重传,流量控制,滑动窗口等机制保证了可靠,提高了性能。在数据传送完后,会断开连接以节约资源。

    【缺点】速度慢、效率低、易被攻击

    因为在TCP传送数据前,要建立连接,耗费时间,数据传递中又适用了很多机制来保证其可靠,也会消耗大量的时间。它要维护所有传输连接,每个连接都会占用系统的CPU,内存等资源。在三次握手确认连接时,容易受到DOS、STN洪泛攻击等。

    由此,也可以总结出,TCP适用于对可靠性、数据的传输质量要求高,但对实时性要求不高的场景。

    1.2TCP首部报文格式

    TCP虽然是面向字节流的,但TCP传送的数据单元却是报文段。一个TCP报文段分为了首部和数据部分,而TCP的全部功能都体现在它首部中的各字段的作用。下图则表示的TCP的首部,TCP首部的前20个字节是固定的,后面40个字节段是根据需要而增加的选项,因此TCP的最小长度是20字节。 下面,我们就来说明一下各字段的意义 1、源端口和目的端口 因为我们知道两台主机之间的通信实际上就是主机上两个进程的通信,端口号就是用来标识进程的

    2、32位序号(seq) 因为其占4个字节,序号范围是[0,232-1],共232个序号。在一个TCP连接中传送的字节流中的每一个字节都是按照顺序编号,初始值是系统内核随机产生的值加上报文段的第一个数据在整个字节流中的偏移量,也就是说越往后的数据,32位序号值就越大。 【举个栗子】 一报文段的序号字段值是301,而携带的数据共有100字节。这就表明:本报文段的数据的第一个字节的序号是301,最后一个字节的序号是400.显然,下一个报文段的数据序号应当从401开始,即下一个报文段的序号字段的值应为401,这个字段的名称也叫做“报文段序号”

    3、32位确认号(seq) 占4个字节,是期望收到对方下一个报文段的第一个数据字节的序号。 【举个栗子】 B正确收到了A发送过来的一个报文段,其序号段值是501,数据长度是200字节(序号501-700),这就表明B正确收到了A发送的到序号700为止的数据。因此,B期望收到A的下一个数据序号是701,于是B在发送给A的确认报文段中把确认号置为701.因为接收到的报文段的序号值+报文段长度+1,所以若确认号=N。则表明:到序号N-1为止的所有数据都已正确收到

    有了序号和确认号,我们就来再次解释一下三次握手过程各字段的含义。从下图中可以看到 首先客户端在发送请求连接的时候序号值为i,服务器对其发送一个确认信息,因为没有携带数据,所以ack=i+1,这是服务器发送的请求确认所以序号值为j,最后客户端的确认ack=j+1.

    4、数据偏移 就是指出TCP报文段的首部长度。我们都知道TCP首部固定长度是20字节,但是还有选项部分。 【注意】 数据偏移的单位是32位字(也就是4字节),由于4位二进制能够表示的最大十进制数字是15,所以数据偏移的最大值是60字节,这就是TCP首部的最大长度(即选项长度不能超过40字节)

    5、保留 占6位,保留为今后使用,目前置为0

    6、选项 URG置为1时:表示紧急指针字段有效,它告诉系统此报文段中有紧急数据,应尽快传送而不是按原来的排队顺序来传送

    ACK置为1时:TCP规定,在连接建立后所有传送的报文段都必须把ACK置为1

    PSH置为1时:接收方收到PSH=1的报文段,就尽快地交付给接收应用进程,而不是等到整个缓存都填满了后再向上交付

    RST置为1时:表示TCP连接出现了严重差错,必须释放连接,然后再重新建立运输连接。还可以用来拒绝一个非法的报文段或拒绝打开一个连接

    SYN置为1时:用来同步序号。表示这是一个连接请求或接受接受报文

    FIN置为1时:用来释放一个连接。表明此报文段的发送方的数据已发送完毕。

    7、窗口 该窗口占两个字节,窗口值是[0,216-1]之间的整数。该窗口值作为接收方让发送方设置发送窗口的依据。 【举个栗子】 设确认号是701,窗口字段是1000,这就表明,从701号算起,发送此报文段的一方还要接收1000个字节数据(字节序号是701-1700)的接收缓存空间

    8、校验和 使用CRC冗余校验来检验TCP的头部和数据部分。

    9、紧急指针 占2字节,该字段只有在URG=1的时候才有意义,它指出了本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据)。

    10、选项 长度可变,最长可达40字节。当没有使用选项时,TCP的首部长度是20字节

    1.3理解源IP地址和目的IP地址

    一般的,在IP数据报的头部,有两个IP地址,一个是源IP地址,一个是目的IP地址,他代表着这个数据包从哪里来准备往哪里去。

    【例子】

    西游记大家不陌生吧,唐僧西天取经,每到一个地方有人问唐僧你从哪里来准备去哪,唐僧回答的都是 “贫僧从东土大唐而来,去往西天取经”,那我们就知道了,唐僧的源地址是东土大唐,目的地址是西天,这是一直都不会变的,我们说的源IP地址和目的IP地址也是一样的道理但是我们想想我们是不是有一个IP地址之后就能够完成通信了呢,就像唐僧到西天是不是就可以取经了呢,答案很明显是不是,那唐僧到了西天,应该具体到西天的哪才能取到真经,就像我们应该将信息发到目的IP地址处的哪台主机上的哪个进程才能被接收呢?

    1.4认识端口号

    我们都知道,实现两个主机的通信实际上是实现两个主机上的两个进程之间的通信。其实端口号就是用来标识一个进程的,告诉操作系统,当前的这个数据要交给哪一个进程来处理。 1、特点

    端口号是一个2字节16位的整数IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;PID和端口号都是标识一个唯一进程的。但是他们的区别是进程PID是只要有一个进程都有进程PID。而端口号主要表示需要使用网络的进程才会有端口号

    2、常见的端口号 端口号的范围是从1-65535。对其进行了如下的分类:

    范围含义0~1023被公认的服务占用了,用户不能使用,如Web服务占用80端口1024~49151用户自己使用的端口号,即自己定义TCP编程sockaddr_in结构体的成员port端口号49152~65535用于自动分配,如我们电脑安装的客户端程序

    【一些常见的端口号及其用途如下】 TCP 21端口:FTP 文件传输服务 TCP 23端口:TELNET 终端仿真服务 TCP 25端口:SMTP 简单邮件传输服务 UDP 53端口:DNS 域名解析服务 TCP 80端口:HTTP 超文本传输服务 TCP 110端口:POP3 “邮局协议版本3”使用的端口 TCP 443端口:HTTPS 加密的超文本传输服务 TCP 1521端口:Oracle数据库服务 TCP 1863端口:MSN Messenger的文件传输功能所使用的端口 TCP 3389端口:Microsoft RDP 微软远程桌面使用的端口 TCP 5631端口:Symantec pcAnywhere 远程控制数据传输时使用的端口 UDP 5632端口:Symantec pcAnywhere 主控端扫描被控端时使用的端口 TCP 5000端口:MS SQL Server使用的端口 UDP 8000端口:腾讯QQ

    注:在Linux下使用cat /etc/serveices可查看知名的端口号

    3、端口号补充 3.1同一个端口可以被TCP,UDP应用程序同时使用 首先端口可以形象地比喻成操作系统上的编号唯一的文件,端口的唯一性的标识不是端口号,而是端口号和协议名称的组合,应用程序和协议寻址时就是靠的这个组合。我们可以使用netstat -an查看详细的网络状况,可以看到UDP和TCP的协议号不同。

    3.2同一个应用程序可以创建多个套接字 一个网络应用程序只能绑定一个端口( 一个套接字只能绑定一个端口 )。TCP/IP中识别一个进行通信的应用需要5大要素,它们分别为:源IP地址,目标IP地址,源端口,目标端口,协议号,只要端口不同,就可以建立多个socket。

    2、网络编程基础API

    2.1创建socket

    #include<sys/types.h> #include<sys/socket.h> int socket(int domain, int type, int protocol);

    【功能】 socket就是一个可读、可写、可控制、可关闭的文件描述符,socket系统调用可创建一个soket. 【参数】

    domain:主要是告诉系统使用哪个底层协议簇。 1.1常用的TCP有两种,IPV4的是PF_INET,IPV6的是PF_INET6; 1.2对于UNIX本地域协议族该参数应该设置为PF_UNIX

    type:表示可以接受参数一的服务类型与下面两种重要的标志相与的值。 2.1TCP/IP协议,其取值SOCK_STREAM 2.2UDP协议是SOCK_DGRAM

    protocol:在前面两个参数构成的协议集合下,再选择一个具体的协议。不过这个值通常都是唯一的,所以一般情况下都应该把它设置成0表示使用默认协议

    返回值:成功返回socket文件描述符,失败返回-1并设置errno.

    代码实现:

    int listenfd = socket(AF_INET, SOCK_STREAM, 0); assert(listenfd != -1);

    2.2命名socket——bind

    上述的创建socket相当于我们给它指定了地址簇,但是并没有指定使用该地址簇中的哪个具体socket地址。 就相当于我们买了一个手机但是并没有安装电话卡。将一个socket与socket地址绑定称为给socket命名。其定义如下:

    int bind(int socket, const struct sockaddr *address,socklen_t address_len);

    【功能】

    只有命名后客户端才能知该如何连接它就相当于一个手机有了电话卡,其他的人才知道如何联系到这个手机。但是客户端是不需要命名socket,而是采用匿名的方式,即使用操作系统自动分配的socket地址。

    【参数】

    sockfd:为上一个方法中创建的套接字描述符truct sockaddr* my_addr:服务器程序的地址信息IP和端口号,具体的结构体内部定义如下 struct sockaddr_in { sa_family_t sin_family; unsigned short sin_port; struct in_addr sin_addr; };

    2.1成员一:表示地址簇,IPV4的是PF_INET,IPV6的是PF_INET6; 2.2成员二:端口号 2.3成员三:表示IP地址信息,其结构体内部定义如下:

    struct in_addr { u_int32_t s_addr; };

    参数addrlen:指定结构体大小。返回值:成功返回0,失败返回-1并设置errno. 常见bind失败返回-1并设置errno的值和原因如下: EACCES:被绑定的地址是受保护的地址,仅超级用户才可以,如将socket绑定到知名服务端口(0~1023)上时,就会返回这个。 EADDRINUSE:被绑定的地址正在使用中。

    2.2.1网络字节序和主机字节序

    1、大端和小端 我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分

    小端:高位存在高地址,低位存在低地址大端:高位存低地址,低位存高地址

    具体的,以下面这张图片例子作为解释 2、网络数据流的地址

    小端字节序又被称为主机字节序 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出。接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存。因此,网络数据流的地址规定为:先发出的数据是低地址,后发出的数据是高地址大端字节序也称为网络字节序 TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据

    下面这些函数是用于网络字节序和主机字节序的转换 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回.如果主机是大端字节序,函数不做转换,将参数原封不动的返回

    2.2.2IP地址转换

    通常人们喜欢用可读性好的点分十进制字符串表示IPv4地址,但是编程中我们需要把它们转化为整数(二进制数)才能使用。

    #include<arpa/inet.h> in_addr_t inet_addr(const char* strptr);

    【功能】 下面这个函数可用于点分十进制字符串表示的ipv4地址转化为用网络字节序整数表示的IPV4地址。失败时返回INADDR_NONE。

    最后命名socket——bind的代码实现如下:

    struct sockaddr_in ser_addr; memset(&ser_addr,0,sizeof(ser_addr); ser_addr.sin_family = AF_INET; ser_addr.sin_port = htons(6000); ser_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); int res = bind(listenfd,(struct sockaddr*)&ser_addr,sizeof(ser_addr)); assert(res != -1);

    2.3监听socket——listen

    socket被命名之后,还不能马上接受客户连接,需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接

    #incude<sys/socket.h> int listen(int sockfd,int backlog);

    sockfd:指定被监听的socket

    backbolg:提示内核监听队列的最大长度。监听队列的长度如果超过了这个值,服务器将不受理新的客户连接,客户端也将收到错误信息。注意这个是用于维护当前客户端链接(还没被accept)的队列(已完成连接)的大小,一般为数字5. 在内核版本2.2之前的Linux中,backlog参数是指所有处于半连接状态(未完成三次握手的SYN_RCVD),和完全连接状态(完成三次握手的ESTABLISHED)的socket的上限,即如下图: 但自内核版本2.2之后,它只表示处于完全连接状态的socket的上限,处于半连接状态的socket的上限由/pro/sys/net/ipv4/tcp_max_syn_backlog内核参数定义。

    返回值:成功返回0,失败返回-1并设置errno

    2.4接收连接——accept

    int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); sockfd:执行过listen系统调用的监听socketaddr:用来获取被接受连接的远端socket地址返回值:成功返回一个新的连接socket,该soket唯一的表示了被接受的这个连接,服务器可通过读写该soket来与被接受连接对应的客户端通信;失败返回-1并设置errno.

    【注意】 假设有这样一种情况:监听队列中处在完全链接状态的连接对应的客户端出现网络异常或者提前退出。但是accept调用同样都是正常返回。 由此可见:accpept只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络状况的变化

    2.5发起连接——connect

    如果说服务器通过listen调用来被动接受连接,那么客户端需要通过如下的方式来主动与服务器连接

    int connect(int sockfd,const struct sockaddr *serv_Addr,socklen_t sddrlen); sockfd:由socket系统调用返回一个socket.serv_addr:服务器监听socket地址返回值:成功返回0,一旦成功建立建立连接,sockfd就唯一的标识了这个连接,客户端就可以通过读写sockfd来与服务器通信,失败返回-1,并设置errno

    代码栗子如下:

    # include<sys/types.h> # include<sys/socket.h> struct sockaddr_in ser;//定义socket地址,存储服务器socket地址 memset(&ser,0,sizeof(ser)); ser.sin_family=AF_INET;//地址族 ser.sin_port=htons(6000);//端口号 ser.sin_addr.s_addr=inet_addr("127.0.0.1");//IP地址 int connect(int sockfd,const struct sockaddr* serv_addr,socklen_t addrlen)

    2.6 send()函数

    #include <sys/socket.h> ssize_t send(int sockfd, const void* buf, size_t nbytes, int flags);

    【功能】 发送数据,最后一个参数为 0 时,可以用 write() 替代( send 等同于 write )。注意:不能用 TCP 协议发送 0 长度的数据包。假如,数据没有发送成功,内核会自动重发。

    【参数】

    sockfd: 已建立连接的套接字buf: 发送数据的地址;nbytes: 发送缓数据的大小(以字节为单位);flags: 套接字标志(常为 0)。返回值: 成功:成功发送的字节数 ,失败:<0。

    2.7 recv()函数

    #include <sys/socket.h> ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);

    【功能】 接收网络数据,默认的情况下,如果没有接收到数据,这个函数会阻塞,直到有数据到来。 参数含义以及返回值和send一样。

    3、TCP编程实例

    我们知道要实现通信,我们分为了服务器端和客户端。不同的角色所做的工作也是不同的。下图列出了他们各自要实现的功能和对应的接口。一般的在服务器端会有两个循环,如图中的紫色方框循环代表循环处理多个客户端链接,而我们的蓝色循环方框代表使客户端和服务器不断地收发信息,这个循环分别在服务器和客户端是对应的。 一般服务器和多个客户端连接,所以服务器复杂。客户端和一个服务器连接,客户端就相对于来说简单很多。总结出服务器和客户端所用的函数流程如下: 服务器:

    int socket();创建一个用于监听客户端连接的网络套接字《----》买手机 int bind();将创建的套接字与本端的地址信息进行绑定IP+端口《----》给手机插卡,绑定电话号码,不然别人无法拨号联系我 int listen();启动监听,不会阻塞《----》开机,不用一直等着别人给你打电话 int accept():接受一个客户端的连接,返回的是一个客户端连接套接字《----》如果有人打电话,我就接听电话 int recv()/send();读取数据或者发送数据《----》交谈 int close();关闭文件描述符《----》挂电话

    客户端:

    int socket();创建一个用于整个通讯的套接字《----》买手机 int connect();与服务器程序建立连接《----》拨号 int recv()/send();读取或发送数据《----》交谈 int close();关闭连接《----》挂电话

    但是这样的流程会出现相应的问题,并对此给出了以下的解决方案:

    电话接听后,两个人同时说话,就会无法交谈。即必须规定客户端和服务器发送数据的顺序。不能说一句话挂一次电话,再拨号再说下一句。所以循环多次进行数据交互,故recv/send必须在while循环内,说完后我再挂断电话不能接听一个人的电话就关机一次,也不能只接听一个人的电话。所以服务器要一直开启,循环接受处理客户端连接,故accept在while循环内,直到全部结束,关闭监听套接字。

    有了大致的讲解,我们可以使用下图所示的流程图来表示他们之间的调用关系。

    服务器端编程实现如下:

    #include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<string.h> #include<assert.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main() { //创建socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); assert(listenfd != -1); //socket地址信息 struct sockaddr_in ser_addr; memset(&ser_addr, 0, sizeof(ser_addr)); ser_addr.sin_family = AF_INET; ser_addr.sin_port = htons(6000);//一般用户使用的端口号在6000以上 ser_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // "127.0.0.1"回环地址 //命名socket int res = bind(listenfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr)); assert(res != -1); //监听socket res = listen(listenfd, 5); assert(res != -1); // 循环接收客户端连接并处理 while(1) { //客户端连接地址信息 struct sockaddr_in cli_addr; socklen_t len = sizeof(cli_addr); //接收连接 int clientLinkFd = accept(listenfd, (struct sockaddr*)&cli_addr, &len); //失败给出提示信息 if(clientLinkFd == -1) { printf("one client link error\n"); continue; } //成功打印地址信息和端口号 printf("one clinet success -- %s:%d\n",inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port)); // 处理一个客户端与服务器交互的数据 while(1) { char buff[128] = {0}; int num = recv(clientLinkFd, buff, 127, 0); //出错返回-1 if(num == -1) { printf("recv error\n"); break; } //返回0代表通信双方已经关闭连接 else if(num == 0) { printf("client over\n"); break; } printf("recv data is : %s\n", buff); char *restr = "recv data success"; num = send(clientLinkFd, restr, strlen(restr), 0); if(num == -1) { printf("send data error\n"); break; } } close(clientLinkFd);//关闭客户端连接 } close(listenfd);//关闭服务器 exit(0); }

    客户端编程实现如下:

    #include <stdio.h> #include <stdlib.h> #include <assert.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <sys/types.h> #include <arpa/inet.h> // IP地址转换函数 #include <netinet/in.h> // 字节序转换函数 int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); assert(-1 != sockfd); struct sockaddr_in ser; memset(&ser, 0, sizeof(ser)); ser.sin_family = AF_INET; ser.sin_port = htons(6000); ser.sin_addr.s_addr = inet_addr("127.0.0.1"); int res = connect(sockfd, (struct sockaddr*)&ser, sizeof(ser)); assert(-1 != res); while(1) { printf("please input: "); char data[128] = {0}; fgets(data, 127, stdin); if(strncmp(data, "bye", 3) == 0) { break; } int num = send(sockfd, data, strlen(data) - 1, 0); assert(num != -1); if(num == 0) { printf("the length that send is zero\n"); break; } char buff[128] = {0}; int n = recv(sockfd, buff, 127, 0); assert(n != -1); if(n == 0) { printf("error\n"); break; } printf("recv data); is %s\n",buff); } close(sockfd); exit(0); }

    测试结果如下图所示:

    Processed: 0.015, SQL: 12