写点什么

C++ socket 通讯详解及注意事项

用户头像
赖猫
关注
发布于: 2021 年 03 月 24 日

Socket 是什么


Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面,对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。


socket 起源于 Unix,而 Unix/Linux 基本哲学之一就是“一切皆文件”,都可以用“打开 open –> 读写 write/read –> 关闭 close”模式来操作。我的理解就是 Socket 就是该模式的一个实现,socket 即是一种特殊的文件,一些 socket 函数就是对其进行的操作(读/写 IO、打开、关闭),这些函数我们在后面进行介绍。


将 socket 通信类比为打电话这一生活场景。这里我把 TCP 服务器比作政府某一服务部门能,TCP 客户端比作企业中某一部门电话,描述这一过程,恰好就像是 socket 通信,服务部门提供服务,企业部门申请服务。

要实现通信,首先政府部门都必须申请一个电话(socket_fd),并向有关部门注册(我们的系统),提供地址(sockadrr)以及属于哪个部门的(port),录入系统后,就算是合约生效了(bind),于是乎,政府广而告之,这个服务热线就算开通了,在部门里面的人员所需要做的事情,就是等待企业家拨打热线(listen)。

企业家拨打电话对地点和部门没有这么多的要求了,他并不需要绑定地址和部门,在任何一个可以拨打电话的地方(可能是同个部门,也可以同公司不同部门,甚至可能是竞争对手),他只需要拿起一个已经注册的电话(socket_fd),拨打电话(connect)

政府部门接通电话(accept)后,桥梁就打通了(服务者 client_fd、顾客 server_fd),可以进行听说了(read write)。企业家咨询完成(close),政府到点下班关闭服务(close)


什么是 TCP/IP、UDP


 `TCP/IP(Transmission Control Protocol/Internet Protocol)即传输控制协议/网间协议,是一个工业标准的协议集,它是为广域网(WANs)设计的。     UDP(User Data Protocol,用户数据报协议)是与TCP相对应的协议。它是属于TCP/IP协议族中的一种。    这里有一张图,表明了这些协议的关系。` 
* 1* 2* 3

复制代码


在这里插入图片描述


TCP/IP 协议族包括运输层、网络层、链路层。现在你应该知道 TCP/IP 与 UDP 的区别了吧,Socket 在哪里呢?

在图 1 中,我们没有看到 Socket 的影子,那么它到底在哪里呢?还是用图来说话,一目了然。


在这里插入图片描述


socket 中 TCP 的三次握手建立连接


我们知道 tcp 建立连接要进行“三次握手”,即交换三个分组。大致流程如下:


客户端向服务器发送一个 SYN J

服务器向客户端响应一个 SYN K,并对 SYN J 进行确认 ACK J+1

客户端再想服务器发一个确认 ACK K+1

只有就完了三次握手,但是这个三次握手发生在 socket 的那几个函数中呢?请看下图:


在这里插入图片描述


从图中可以看出,当客户端调用 connect 时,触发了连接请求,向服务器发送了 SYN J 包,这时 connect 进入阻塞状态;服务器监听到连接请求,即收到 SYN J 包,调用 accept 函数接收请求向客户端发送 SYN K ,ACK J+1,这时 accept 进入阻塞状态;客户端收到服务器的 SYN K ,ACK J+1 之后,这时 connect 返回,并对 SYN K 进行确认;服务器收到 ACK K+1 时,accept 返回,至此三次握手完毕,连接建立。


总结:客户端的 connect 在三次握手的第二个次返回,而服务器端的 accept 在三次握手的第三次返回,这也是 dos 攻击的基本原理。


socket 中 TCP 的四次握手释放连接


上面介绍了 socket 中 TCP 的三次握手建立过程,及其涉及的 socket 函数。现在我们介绍 socket 中的四次握手释放连接的过程,请看下图:


在这里插入图片描述


图示过程如下:

某个应用进程首先调用 close 主动关闭连接,这时 TCP 发送一个 FIN M;

另一端接收到 FIN M 之后,执行被动关闭,对这个 FIN 进行确认。它的接收也作为文件结束符传递给应用进程,因为 FIN 的接收意味着应用进程在相应的连接上再也接收不到额外数据;

一段时间之后,接收到文件结束符的应用进程调用 close 关闭它的 socket。这导致它的 TCP 也发送一个 FIN N;

接收到这个 FIN 的源发送端 TCP 对它进行确认。

这样每个方向上都有一个 FIN 和 ACK。


socket 通信流程


socket 是"打开—读/写—关闭"模式的实现,以使用 TCP 协议通讯的 socket 为例,其交互流程基本如下图所示:


在这里插入图片描述


socket 的基本操作


既然 socket 是“open—write/read—close”模式的一种实现,那么 socket 就提供了这些操作对应的函数接口。下面以 TCP 为例,介绍几个基本的 socket 接口函数。


socket()函数


int socket(int domain, int type, int protocol);

socket 函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而 socket()用于创建一个 socket 描述符(socket descriptor),它唯一标识一个 socket。这个 socket 描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。


正如可以给 fopen 的传入不同参数值,以打开不同的文件。创建 socket 的时候,也可以指定不同的参数创建不同的 socket 描述符,socket 函数的三个参数分别为:


domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称 AF_UNIX,Unix 域 socket)、AF_ROUTE 等等。协议族决定了 socket 的地址类型,在通信中必须采用对应的地址,如 AF_INET 决定了要用 ipv4 地址(32 位的)与端口号(16 位的)的组合、AF_UNIX 决定了要用一个绝对路径名作为地址。

type:指定 socket 类型。常用的 socket 类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET 等等(socket 的类型有哪些?)。

protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC 等,它们分别对应 TCP 传输协议、UDP 传输协议、STCP 传输协议、TIPC 传输协议。

注意:并不是上面的 type 和 protocol 可以随意组合的,如 SOCK_STREAM 不可以跟 IPPROTO_UDP 组合。当 protocol 为 0 时,会自动选择 type 类型对应的默认协议。


当我们调用 socket 创建一个 socket 时,返回的 socket 描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用 bind()函数,否则就当调用 connect()、listen()时系统会自动随机分配一个端口。


除了 socket()函数之外还有其它 bind(),accept()等函数,这些函数在下面的例子中依次介绍:


新 LinuxC/C++服务器开发/架构师面试题、学习资料、教学视频和学习路线脑图(资料包括 C/C++,Linux,golang 技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等),免费分享有需要的可以自行添加学习交流群 960994558 或 VX:lingsheng_1314 领取!~



服务器端代码


`#define _CRT_SECURE_NO_WARNINGS#define _WINSOCK_DEPRECATED_NO_WARNINGS#include<iostream>#include<cstdlib>#include<cstring>#include<winsock2.h> //引用头文件#pragma comment(lib,"ws2_32.lib") //链接库文件using namespace std;
char Ip[20][200] = { '0' };int iConnect = 0; //当前客户端数量
/*DWORD是无符号的,相当于unsigned long ,它是MFC的数据类型。DWORD一般用于返回值不会出现负值情况WINAPI是一个宏,所代表的符号是__stdcall, 函数名前加上这个符号表示这个函数的调用约定是标准调用约定,windows API函数采用这种调用约定LPVOID是一个没有类型的指针,也就是说你可以将任意类型的指针赋值给LPVOID类型的变量(一般作为参数传递),然后在使用的时候再转换回来。 可以将其理解为long型的指针,指向void型*/
DWORD WINAPI threadpro(LPVOID pParam) //创建多线程函数,函数返回值为DWORD WINAPI{ SOCKET hsock = (SOCKET)pParam; //把pParam转换为SOCKET型指针 char buffer[1024]; char sendBuffer[1024]; if (hsock != INVALID_SOCKET) INVALID_SOCKET表示无效 cout<<"Start receive information from IP:"<< Ip[iConnect] << endl << endl; while (true) //循环接收发送的内容 {
int num = recv(hsock, buffer, 1024, 0); //阻塞函数,等待接受内容 if (num <= 0) { cout <<"Client with IP:"<<Ip[iConnect]<< " disconnected!" << endl<<endl; break; } if (num >= 0) cout << "Information from:" << Ip[iConnect] << ":" << buffer << endl << endl; if (!strcmp(buffer, "AAA")) //如果接受到 AAA 返回 BBB { memset(sendBuffer, 0, 1024); strcpy(sendBuffer, "BBB"); int ires = send(hsock, sendBuffer, sizeof(sendBuffer), 0); //回送信息 cout << "The message sent to IP:"<<Ip[iConnect]<<"is: " << sendBuffer << endl << endl<<endl; } else if (!strcmp(buffer, "CCC")) //如果接受到 CCC 返回 DDD { memset(sendBuffer, 0, 1024); strcpy(sendBuffer, "DDD"); int ires = send(hsock, sendBuffer, sizeof(sendBuffer), 0); //回送信息 cout << "The message sent to IP:" << Ip[iConnect] << "is: " << sendBuffer << endl << endl << endl; } else if (!strcmp(buffer, "exit")) //如果接受到 exit 结束该进程 { cout << "Client with IP:" << Ip[iConnect] << " disconnected!" << endl; cout << "Server Process Close: " << endl<<endl; return 0; } else //如果接受到 其它指令 返回 Commend error { memset(sendBuffer, 0, 1024); strcpy(sendBuffer, "Commend error"); int ires = send(hsock, sendBuffer, sizeof(sendBuffer), 0); cout << "The message sent to IP:" << Ip[iConnect] << "is: " << sendBuffer << endl << endl << endl; } } return 0;};int main(void){ WSADATA wsd; //定义WSADATA对象
/* WSAStartup错误码介绍
WSASYSNOTREADY 网络通信中下层的网络子系统没准备好
WSAVERNOTSUPPORTED Socket实现提供版本和socket需要的版本不符
WSAEINPROGRESS 一个阻塞的Socket操作正在进行
WSAEPROCLIM Socket的实现超过Socket支持的任务数限制
WSAEFAULT lpWSAData参数不是一个合法的指针*/ /* 当一个应用程序调用WSAStartup函数时,操作系统根据请求的Socket版本来搜索相应的Socket库,然后绑定找到的Socket库到该应用程序中。以后应用程序就可以调用所请求的Socket库中的其它Socket函数了。该函数执行成功后返回0。例:假如一个程序要使用2.2版本的Socket,那么程序代码如下*/ //WSAStartup(MAKEWORD(2, 2), &wsd); //初始化套接字 if (WSAStartup(MAKEWORD(2, 2), &wsd)) { printf("Initlalization Error!"); return -1; }
SOCKET m_SockServer; //创建socket对象 /* sockaddr_in的定义如下 struct sockaddr_in { short int sin_family; // Address family 一般来说 AF_INET(地址族)PF_INET(协议族 ) unsigned short int sin_port; //sin_port存储端口号(使用网络字节顺序),在linux下,端口号的范围0~65535,同时0~1024范围的端口号已经被系统使用或保留 struct in_addr sin_addr; //存储IP地址 unsigned char sin_zero[8]; //sin_zero是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节 };*/
sockaddr_in serveraddr; //创建sockaddr_in对象储存自身信息 sockaddr_in serveraddrfrom;
SOCKET m_Server[20]; //创建socket数组来存放来自客户端的信息最大连接数为20
serveraddr.sin_family = AF_INET; //设置服务器地址家族 serveraddr.sin_port = htons(4600); //设置服务器端口号
/* inet_addr语法如下 unsigned long PASCAL FAR inet_addr( const struct FAR* cp); 本函数解释cp参数中的字符串,这个字符串用Internet的“.”间隔格式表示一个数字的Internet地址。返回值可用作Internet地址。所有Internet地址以网络字节顺序返回(字节从左到右排列)*/ serveraddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
/* socket的定义如下 SOCKET WSAAPI socket( _In_ int af, _In_ int type, _In_ int protocol );
建立一个socket用于连接 af:address family,如AF_INET type:连接类型,通常是SOCK_STREAM或SOCK_DGRAM protocol:协议类型,通常是IPPROTO_TCP或IPPROTO_UDP 返回值:socket的编号,为-1表示失败*/ m_SockServer = socket(AF_INET, SOCK_STREAM, 0); //创建一个临时变量并赋值给m_SockServer
/* bind的语法:int bind( int sockfd, struct sockaddr* addr, socklen_t addrlen) 返回:0──成功, -1──失败 参数sockfd 指定地址与哪个套接字绑定,这是一个由之前的socket函数调用返回的套接字。调用bind的函数之后,该套接字与一个相应的地址关联,发送到这个地址的数据可以通过这个套接字来读取与使用 参数addr 指定地址。这是一个地址结构,并且是一个已经经过填写的有效的地址结构。调用bind之后这个地址与参数sockfd指定的套接字关联,从而实现上面所说的效果 参数addrlen 正如大多数socket接口一样,内核不关心地址结构,当它复制或传递地址给驱动的时候,它依据这个值来确定需要复制多少数据。这已经成为socket接口中最常见的参数之一了
*/ int i = bind(m_SockServer, (sockaddr*)&serveraddr, sizeof(serveraddr)); //把名字和套接字绑定 cout << "bind:" << i << endl<<endl;
int iMaxConnect = 20; //最大连接数 int iLisRet; char buf[] = "THIS IS SERVER0"; char WarnBuf[] = "It,is voer Max connect0"; int len = sizeof(serveraddr); //serveraddr所占的字节大小
while (true) {/* listen函数在一般在调用bind之后-调用accept之前调用,语法如下: int listen(int sockfd, int backlog) 返回:0──成功, -1──失败
参数sockfd 被listen函数作用的套接字,sockfd之前由socket函数返回。在被socket函数返回的套接字fd之时,它是一个主动连接的套接字,也就是此时系统假设用户会对这个套接字调用connect函数,期待它主动与其它进程连接,然后在服务器编程中,用户希望这个套接字可以接受外来的连接请求,也就是被动等待用户来连接。由于系统默认时认为一个套接字是主动连接的,所以需要通过某种方式来告诉系统,用户进程通过系统调用listen来完成这件事 参数backlog 这个参数涉及到一些网络的细节。在进程正理一个一个连接请求的时候,可能还存在其它的连接请求。因为TCP连接是一个过程,所以可能存在一种半连接的状态,有时由于同时尝试连接的用户过多,使得服务器进程无法快速地完成连接请求。如果这个情况出现了,服务器进程希望内核如何处理呢?内核会在自己的进程空间里维护一个队列以跟踪这些完成的连接但服务器进程还没有接手处理或正在进行的连接,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限。这个backlog告诉内核使用这个数值作为上限。服务器进程不能随便指定一个数值,内核有一个许可的范围。这个范围是实现相关的。很难有某种统一,一般这个值会小30以内*/ iLisRet = listen(m_SockServer, 0); //进行监听
/*
accept语法如下: SOCKET accept(SOCKET sock, struct sockaddr *addr, int *addrlen); 它的参数与 listen() 和 connect() 是相同的:sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得 accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。
accept()系统调用主要用在基于连接的套接字类型,比如SOCK_STREAM和SOCK_SEQPACKET。它提取出所监听套接字的等待连接队列中第一个连接请求,创建一个新的套接字,并返回指向该套接字的文件描述符。新建立的套接字不在监听状态,原来所监听的套接字也不受该系统调用的影响
备注:新建立的套接字准备发送send()和接收数据recv()
*/ int temp=0; int Len = sizeof(serveraddrfrom); m_Server[iConnect] = accept(m_SockServer, (sockaddr*)&serveraddrfrom, &len);
if (m_Server[iConnect] != INVALID_SOCKET) //INVALID_SOCKET表示无效 { //int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen)
if (getsockname(m_Server[iConnect], (struct sockaddr*)&serveraddrfrom, &Len) != -1) { printf("listen address = %s:%dn", inet_ntoa(serveraddrfrom.sin_addr), ntohs(serveraddrfrom.sin_port)); sprintf(Ip[iConnect], "%s", inet_ntoa(serveraddrfrom.sin_addr)); } else { printf("getsockname errorn"); exit(0); }
/* send语法如下: int send( SOCKET s, const char FAR *buf, int len, int flags ); s:指定发送端套接字描述符; *buf:指明一个存放应用程序要发送数据的缓冲区;
len:指明实际要发送的数据的字节数; flags:一般置0。
不论是客户还是服务器应用程序都用send函数来向TCP连接的另一端发送数据。客户程序一般用send函数向服务器发送请求,而服务器则通常用send函数来向客户程序发送应答。
*/ int ires = send(m_Server[iConnect], buf, sizeof(buf), 0); //发送字符过去 cout << "accept" << ires << endl<<endl; //显示已经连接次数 iConnect++; if (iConnect > iMaxConnect) //判断连接数是否大于最大连接数 { int ires = send(m_Server[iConnect], WarnBuf, sizeof(WarnBuf), 0); } else { HANDLE m_Handle; //线程句柄 DWORD nThreadId = 0; //线程ID nThreadId++;
/* 当使用CreateProcess调用时,系统将创建一个进程和一个主线程。CreateThread将在主线程的基础上创建一个新线程,大致做如下步骤: 1在内核对象中分配一个线程标识/句柄,可供管理,由CreateThread返回 2把线程退出码置为STILL_ACTIVE,把线程挂起计数置1 3分配context结构 4分配两页的物理存储以准备栈,保护页设置为PAGE_READWRITE,第2页设为PAGE_GUARD 5lpStartAddr和lpvThread值被放在栈顶,使它们成为传送给StartOfThread的参数 6把context结构的栈指针指向栈顶(第5步)指令指针指向startOfThread函数 GreateThread语法如下: hThread = CreateThread (&security_attributes, dwStackSize, ThreadProc,pParam, dwFlags, &idThread) ; 第一个参数是指向SECURITY_ATTRIBUTES型态的结构的指针。在Windows 98中忽略该参数。在Windows NT中,它被设为NULL。
第二个参数是用于新线程的初始堆栈大小,默认值为0。在任何情况下,Windows根据需要动态延长堆栈的大小。   第三个参数是指向线程函数的指针。函数名称没有限制,但是必须以下列形式声明: DWORD WINAPI ThreadPro (PVOID pParam) ;   CreateThread的第四个参数为传递给ThreadProc的参数。这样主线程和从属线程就可以共享数据。   CreateThread的第五个参数通常为0,但当建立的线程不马上执行时为旗标CREATE_SUSPENDED。线程将暂停直到呼叫ResumeThread来恢复线程的执行为止。
第六个参数是一个指标,指向接受执行绪ID值的变量。
*/ m_Handle = (HANDLE)::CreateThread(NULL, 0, threadpro, (LPVOID)m_Server[--iConnect], 0, &nThreadId); //启动线程 } } }
/*
WSACleanum语法如下: int WSACleanup(void);
*/ WSACleanup(); //用于释放ws2_32.dll动态链接库初始化时分配的资源}`

复制代码


客户端代码


`#define _WINSOCK_DEPRECATED_NO_WARNINGS#include<iostream>#include<cstdlib>#include<time.h>#include<winsock2.h> //引用头文件#pragma comment(lib,"ws2_32.lib") //链接库文件using namespace std;int main(void){    WSADATA wsd;                       //定义WSADATA对象/*    WSAStartup错误码介绍
WSASYSNOTREADY 网络通信中下层的网络子系统没准备好
WSAVERNOTSUPPORTED Socket实现提供版本和socket需要的版本不符
WSAEINPROGRESS 一个阻塞的Socket操作正在进行
WSAEPROCLIM Socket的实现超过Socket支持的任务数限制
WSAEFAULT lpWSAData参数不是一个合法的指针*/
/* 当一个应用程序调用WSAStartup函数时,操作系统根据请求的Socket版本来搜索相应的Socket库,然后绑定找到的Socket库到该应用程序中。以后应用程序就可以调用所请求的Socket库中的其它Socket函数了。该函数执行成功后返回0。例:假如一个程序要使用2.2版本的Socket,那么程序代码如下*///WSAStartup(MAKEWORD(2, 2), &wsd); //初始化套接字 WSAStartup(MAKEWORD(2, 2), &wsd); SOCKET m_SockClient; //创建socket对象/*
sockaddr_in的定义如下 struct sockaddr_in { short int sin_family; // Address family 一般来说 AF_INET(地址族)PF_INET(协议族 ) unsigned short int sin_port; //sin_port存储端口号(使用网络字节顺序),在linux下,端口号的范围0~65535,同时0~1024范围的端口号已经被系统使用或保留 struct in_addr sin_addr; //存储IP地址 unsigned char sin_zero[8]; //sin_zero是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节 };*/ sockaddr_in clientaddr; //服务器信息
clientaddr.sin_family = AF_INET; clientaddr.sin_port = htons(4600); clientaddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); /* socket的定义如下 SOCKET WSAAPI socket( _In_ int af, _In_ int type, _In_ int protocol );
建立一个socket用于连接 af:address family,如AF_INET type:连接类型,通常是SOCK_STREAM或SOCK_DGRAM protocol:协议类型,通常是IPPROTO_TCP或IPPROTO_UDP 返回值:socket的编号,为-1表示失败 */ m_SockClient = socket(AF_INET, SOCK_STREAM, 0);
/*WINSOCK_API_LINKAGEintWSAAPIconnect( SOCKET s, const struct sockaddr FAR * name, int namelen );
第一个参数是客户端的套接字(表明即将发起连接请求),第二个参数是服务端的套接字所在的“地方”(“地方”是我自定义的专有名词),第三个参数是该“地方”的大小 如果请求连接成功,则返回0,否则返回错误码。*/
int i = connect(m_SockClient, (sockaddr*)&clientaddr, sizeof(clientaddr)); cout << "Connection status " << i << endl;
char buffer[1024]; char inBuf[1024]; int num; num = recv(m_SockClient, buffer, 1024, 0); //阻塞函数,等待接受内容 if (num > 0) //阻塞 { cout << "Receive form server" << buffer << endl; while (true) { num = 0; cin >> inBuf; if (!strcmp(inBuf, "exit")) //如果输入的是exit则断开连接 { send(m_SockClient, inBuf, sizeof(inBuf), 0); return 0; } send(m_SockClient, inBuf, sizeof(inBuf), 0); num = recv(m_SockClient, buffer, 1024, 0); if (num >= 0) cout << "Receive form server: " << buffer << endl; //输出接受到的内容 } }}`
复制代码


在 windows 中的运行结果如图:


在这里插入图片描述


用户头像

赖猫

关注

还未添加个人签名 2020.11.28 加入

纸上得来终觉浅,绝知此事要躬行

评论

发布
暂无评论
C++ socket通讯详解及注意事项