看完之后保证你对socket编程步骤胸有成竹。 C++ Socket网络编程基础详解(TCP)

C++ Socket网络编程基础详解(TCP版)

​    网络编程,就是编写程序使得两台计算机交换数据,其实从本质上来讲,网络编程最终所实现的功能,和我们文件的输入输出很相似,只是文件输入输出的对象是磁盘,但网络编程输入输出的对象时整个网络。下面我会根据windows下中使用C++以及socket进行网络编程的步骤,一步步的讲解网络编程的步骤,以及这些步骤中涉及到的定义,方法。

什么是Socket:

​    Socket套接字,我的理解是:socket是一种抽象,是一种标准,连接上网络的计算机设备,在使用socket之后就相当于将网线直接插入了对方的接口中,可以无视网络,距离。直接进行输入输出,就像是在使用自己的磁盘。

UNIX/Linux下的Socket:

​    这一概念在UNIX/Linux下则有着更为直观的体现:在 UNIX/Linux 系统中,为了统一对各种硬件的操作,简化接口,不同的硬件设备都被看成一个文件。对这些文件的操作,等同于对磁盘上普通文件的操作。为了表示和区分已经打开的文件,UNIX/Linux 会给每个文件分配一个 ID,这个 ID 就是一个整数,被称为文件描述符(File Descriptor)

​    UNIX/Linux 程序在执行任何形式的 I/O 操作时,都是在读取或者写入一个文件描述符。一个文件描述符只是一个和打开的文件相关联的整数,它的背后可能是一个硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。

​    网络连接也是一个文件,它也有文件描述符。我们可以通过 socket() 函数来创建一个网络连接,或者说打开一个网络文件,socket() 的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,所以我们可以用 read() 读取从远程计算机传来的数据;用 write() 向远程计算机写入数据。

Window 系统中的 socket :

​    Windows 也有类似“文件描述符”的概念,但通常被称为“文件句柄”。与 UNIX/Linux 不同的是,Windows 会区分 socket 和文件,Windows 就把 socket 当做一个网络连接来对待,因此需要调用专门针对 socket 而设计的数据传输函数,针对普通文件的输入输出函数就无效了。不过这些也并不复杂。

windows 下 socket编程示范:

      因为linux下和windows下的Socket编程差别并不大,所以我这里选择我比较熟悉的windows下进行讲解。

      Windows 下的 socket 程序和 Linux 思路相同,但细节有所差别:

  1. Windows 下的 socket 程序依赖 Winsock.dll 或 ws2_32.dll,必须提前加载。DLL 有两种加载方式,请查看:动态链接库DLL的加载
  2. Linux 使用“文件描述符”的概念,而 Windows 使用“文件句柄”的概念;Linux 不区分 socket 文件和普通文件,而 Windows 区分,Linux 下 socket() 函数的返回值为 int 类型,而 Windows 下为 SOCKET 类型,也就是句柄。
  3. Linux 下使用 read() / write() 函数读写,而 Windows 下使用 recv() / send() 函数发送和接收。
  4. 关闭 socket 时,Linux 使用 close() 函数,而 Windows 使用 closesocket() 函数。

Server:

#include 
#include 
#pragma comment (lib, "ws2_32.lib")  //静态加载 ws2_32.dll,在编译时加载ws2-32.dll这个动态链接库。
#pragma warning( disable : 4996 )
int main(){
    //初始化 DLL
    WSADATA wsaData;
    WSAStartup( MAKEWORD(2, 2), &wsaData);

    //创建套接字
    SOCKET servSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

    //绑定套接字
    struct sockaddr_in sockAddr;
    memset(&sockAddr, 0, sizeof(sockAddr));  //每个字节都用0填充
    sockAddr.sin_family = PF_INET;  //使用IPv4地址
    sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具体的IP地址
    sockAddr.sin_port = htons(1234);  //端口
    bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

    //进入监听状态
    listen(servSock, 20);

    //接收客户端请求
    SOCKADDR clntAddr;
    int nSize = sizeof(SOCKADDR);
    SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);

    //向客户端发送数据
    char str[] = "Hello World!";
    send(clntSock, str, strlen(str)+sizeof(char), NULL);

    //关闭套接字
    closesocket(clntSock);
    closesocket(servSock);

    //终止 DLL 的使用
    WSACleanup();

    return 0;
}

步骤详解:

1.加载动态链接库ws2-32.dll:

原因:

   windows下socket编程依赖于系统提供的动态链接库,现在最新的socket动态链接库是ws2-32.dll,对应的头文件为winsock2.h。我们使用DLL前需要将DLL加载到当前程序。(此处插一嘴,DLL是干什么用的呢,我们一般编写C++程序,经常会用到两个文件,.h和.cpp文件,.h中是函数的声明,是为了告诉编译器,我这些函数的实现在其他地方,但是我已经实现了,所以请你到别的地方找找,不要报错。所以.h一般还会对应一个.cpp文件,里面就放的是这些函数的声明。那我们在使用这些函数时,可以看到这些函数的实现,有些时候开发者不希望我们使用者看到这些函数,他可以把.h和.cpp编译成DLL动态链接库,或者lib静态链接库,那么我们可以使用这些函数,但是却看不到函数的实现。)

方法:#pragma comment (lib, “ws2_32.lib”)

   #pragma comment是预处理指令, 常用lib关键字,可以帮我们连入一个库文件。也就是在链接过程中将运行过程中要使用的ws2-32.lib和我们的程序链接形成可执行文件。

2.初始化 DLL:

原因:

   在链接DLL后,需要选择我们使用的WinSock的版本。方法是使用WSAStartup函数,和DLL协商我们将要使用的版本号。协商成功后,DLL将要使用的版本号会被写入WSADATA中。

方法:

   协商的过程简单来说:如果请求的版本高于或者等于所支持的最高版本,那么将会使用二者较小的版本,并将版本信息交给 lpWSAData,返回值为0 ,如果请求的版本低于所支持的最低版本,那么将返回错误码。

WSAData结构:
typedef struct WSAData {
        WORD                    wVersion;//ws2_32.dll 希望我们使用的版本号
        WORD                    wHighVersion;//ws2_32.dll 支持的最高版本号现在为2.2
        unsigned short          iMaxSockets;//2.0以后不再使用
        unsigned short          iMaxUdpDg;//2.0以后不再使用
        char FAR *              lpVendorInfo;//2.0以后不再使用
        char                    szDescription[WSADESCRIPTION_LEN+1];//一个以 null 结尾的字符串,用来说明 ws2_32.dll 的实现以及厂商信息
        char                    szSystemStatus[WSASYS_STATUS_LEN+1];//一个以 null 结尾的字符串,用来说明 ws2_32.dll 的状态以及配置信息
} WSADATA, FAR * LPWSADATA;
WSAStartup()函数原型:
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

   wVersionRequested 参数用来指明我们希望使用的版本号,它的类型为 WORD,等价于 unsigned short,是一个整数,所以需要用 MAKEWORD() 宏函数对版本号进行转换,将两个Byte类型,拼接为一个WORD类型,主版本号在低八位,副版本号在高八位。例如:

MAKEWORD(1, 2);  //主版本号为1,副版本号为2,返回 0x0201
MAKEWORD(2, 2);  //主版本号为2,副版本号为2,返回 0x0202

3.创建套接字:

SOCKET servSock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
SOCKET类型:

   网络套接字句柄,相当于ID,可以根据这个ID到socket池中找到对应的socket。

socket()函数原型:
SOCKET socket(int af, int type, int protocol);

   第一个参数af 为地址族,常用的有有IPV4: AF_INET,IPV6 :AF_INET6等。(曾用名PF,协议族,现在改名叫AF地址族,但是还是可以使用PF_INET)。

   第二个参数为type套接字类型,我们经常会用到的套接字有两种类型:基于TCP协议的流格式套接字SOCK_STREAM,基于UDP协议的数据报格式套接字SOCK_STREAM。

流格式套接字(SOCK_STREAM)

   SOCK_STREAM 是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。

数据报格式套接字(SOCK_STREAM)

   数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。这里的不可靠,只是说它有可能发生数据包丢失。但是实际上,这种情况也是一种小概率时间,不会频繁发生。

   流格式,数据报格式,各自的含义。可以仔细研究一下TCP、UDP协议。

   第三个参数protocal,是传输协议类型,常用的有:TCP:IPPROTO_TCP,UDP:IPPROTO_UDP。实际上有了前两个参数就足够推导出传输协议类型了。我们平时使用也可以传0,操作系统会自动推导所使用的传输协议类型。但是如果出现新的传输协议,可能会用上这个参数。

4.将套接字和要监听的ip地址及端口绑定:

struct  sockaddr_in sockAddr
sockAddr.sin_family = PF_INET;  //使用IPv4地址
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具体的IP地址
sockAddr.sin_port = htons(1234);  //端口

bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

原因:

   创建套接字,确定它的属性之后,就需要使用bind()函数,将套接字和特定的IP地址和端口绑定。这样经过这个IP地址的指定端口的数据才会交给这个套接字处理。同样,数据也可以使用这个套接字,从这个IP地址的指定端口发送出去。

   此处需要用到sockaddr_in结构体和bind()函数。

sockaddr_in结构体:
typedef struct sockaddr_in {//sockaddr_in为IPV4 sockaddr_in6为IPV6

    short   sin_family;//地址族(Address Family),也就是地址类型
    
    USHORT sin_port; //16位的端口号
    IN_ADDR sin_addr;//32位IP地址
    CHAR sin_zero[8];//不使用,一般用0填充

} SOCKADDR_IN, *PSOCKADDR_IN;

   sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致。

   sin_prot 为端口号。USHORT的长度为两个字节,端口号的取值范围为 065536,但被称之为熟知端口号的01023端口一般由系统分配给特定的服务程序,例如 Web 服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。

   sin_addrin_addr 结构体类型的变量。

typedef struct in_addr {
        union {
                struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
                struct { USHORT s_w1,s_w2; } S_un_w;
                ULONG S_addr;
        } S_un;
} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

   ULONG32位,S_addr是32位的IP地址。看起来是兜了一圈,把IP地址放在in_addr中,再把in_addr放在sockaddr_in中。可能是为了向sockaddr_in提供不同格式的IPV4地址吗?,毕竟S_un_b是四个u_char的 IPv4 地址。S_un_w是两个USHORT格式的IPv4 地址。而S_addr是ULONG格式的IPv4地址。

初始化sockaddr_in结构体:
sockAddr.sin_family = PF_INET;  //使用IPv4地址
//S_addr是一个整数,但是IP地址是一个字符串,所以要使用inet_addr()进行转换。
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具体的IP地址
//选择一个1024-65535之间的数字做端口号,这里注意还要使用htons将1234从主机序转为网络字节序。
sockAddr.sin_port = htons(1234);  //端口

   S_addr是一个整数,但是IP地址是一个字符串,所以要使用inet_addr()进行转换。建议大家在进行转换前,对ip地址是否符合规范进行检查。可以参考468. 验证IP地址 - 力扣(LeetCode)。

   然后设置端口号时,需要使用htons()将short类型的端口号1234,从主机小端序转换为网络传输使用的大端序。这里我的理解是。因为port,ip地址都是要被网络中间设备使用的,所以要转换成大端序。inet_addr()中也进行了小端序和大端序的转换。

bind()函数原型:
int bind(_In_ SOCKET s,_In_reads_bytes_(namelen) const struct sockaddr FAR * name,_In_ int namelen);

   这里没有什么特殊的,就是将已创建好的socket,和IP地址127.0.0.1,port:1234绑定。需要注意一点是,这里使用了sockaaddr_in,强制转换为sockaddr类型。

   sockaaddr_in,sockaddr两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。我猜测这个一次转换,是为了提供对不同格式的IP地址,PORT号的兼容,也就是说,除了IPv4地址和端口号之外的其他组合,也可以转换为sockaddr格式。难道是因为C语言没有重载嘛。所以针对不同参数格式,搞这玩意转换一下??

sockaddr结构体:
struct sockaddr {
    u_short sa_family;//地址族(Address Family),也就是地址类型

    CHAR sa_data[14]; //IP地址和端口号,Up to 14 bytes of direct address.

} SOCKADDR, *PSOCKADDR, FAR *LPSOCKADDR;

5.套接字进入监听状态:

  listen(servSock, 20);

   对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态(也就是TCP协议中的LISTENING状态),再调用 accept() 函数,就可以随时响应客户端的请求了。

listen()函数原型:
int listen(SOCKET sock, int backlog);  //Windows

   第一个参数SOCKET为需要设置状态的套接字句柄。

   第二个参数backlog为请求队列的最大长度,超过队列长度的请求将会被丢弃。

请求队列

   scok正在处理客户端请求时,如果有新的请求进来,sock是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区就称为请求队列(Request Queue)。

   缓冲区的长度(也就是队列中最大容纳的请求个数)可以通过 listen() 函数的 backlog 参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。

   当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。

6.接收客户端请求:

SOCKADDR clntAddr;
int nSize = sizeof(SOCKADDR);
SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);

   仅仅设置sock处于监听状态还不够,还要调用 accept() 函数来接收客户端请求。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。

accept()函数原型:
SOCKET accept(_In_ SOCKET s,_Out_writes_bytes_opt_(*addrlen) struct sockaddr FAR * addr,_Inout_opt_ int FAR * addrlen);

   accept() 接受到请求后会返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。

7.向客户端发送数据:(此处留一个坑,send最多可以一次发送多少数据??)

char str[] = "Hello World!";
send(clntSock, str, strlen(str)+sizeof(char), NULL);

   要注意的是,现在已经不允许下方的赋值方式。因为后面的字符串在常量区,被看做是*const char ,自然不能给char *str赋值。使用数组就没事,为啥呢,因为数组相当于拷贝了一份"Hello World!“,但是指针却是直接指向"Hello World!”。若真的让,非 常指针指向它就有修改它的风险。

char *str = "Hello World!";
send()函数原型:
int send(SOCKET sock, const char *buf, int len, int flags);

   sock 为要发送数据的套接字,buf 为要发送的数据的缓冲区地址,len 为要发送的数据的字节数,flags 为发送数据时的选项,一般用不到(0:表示无特殊设置; MSG_DONTROUTE:告诉内核,目标主机在本地网络,不用查路由表 ;MSG_DONTWAIT:将单个I/O操作设置为非阻塞模式; MSG_OOB:指明发送的是带外信息)。

8.关闭套接字:

closesocket(clntSock);
closesocket(servSock);
closesocket()函数原型:
int closesocket(_In_ SOCKET s);//参数很简单就是待关闭socket的句柄。

   close()/closesocket() 来关闭套接字,是将套接字句柄从内存清除,之后再也不能使用该套接字,和这个套接字有关的缓冲区也将被回收。close/closesocket() 。如果说你确定之后不会在用到这个套接字了,那使用这个函数没毛病。但是每次传输结束,就销毁套接字,来一个请求,就新建套接字,未免有些太浪费了。

   所以还需一个函数:shutdown(),shutdown() 是关闭连接,也就是调用它之后,套接字会使用四次挥手断开连接。但是套接字本身还在,还可以为下次连接服务。

   还有一个关键问题,默认情况下close()/closesocket() 会立即向网络中发送FIN包,不管输出缓冲区中是否还有数据,而shutdown() 会等输出缓冲区中的数据传输完毕再发送FIN包。也就意味着,调用 close()/closesocket() 将丢失输出缓冲区中的数据,而调用 shutdown() 不会。

int shutdown(SOCKET s, int howto);  //Windows

   参数1就是待关闭的套接字句柄,参数2是如何关闭套接字的参数。

  1. SHUT_RD(0):关闭连接的读方向,不再接收套接字中的数据且保留在套接字接受缓冲区的数据将被丢弃。进程不能再对套接字进行任何读操作。使用SHUT_RD调用shutdown后,由TCP套据字接收的任何数据都被确认,但数据本身丢掉。

  2. SHUT_WR(1):关闭连接的写方向,当前套接字发送缓冲区中的数据都将被发送,后跟正常的TCP连接终止序列,进程不能再对该套接字执行任何写函数。

  3. SHUT_RDWR(2):连接的读和写都关闭。

9.终止 DLL 的使用:

	  WSACleanup();

   完成了Sockets的使用后,应用程序或DLL必须调用WSACleanup()将其从Windows Sockets的实现中注销,并且释放为应用程序或DLL分配的任何资源。任打开的套接字将被重置并自动解除分配,就像调用closesocket一样。所以调用WSACleanup()前,需使用shutdown关闭所有的连接,以避免可能的数据丢失。

   返回值: 0 操作成功, 否则返回 SOCKET_ERROR。可以调用WSAGetLastError()获得错误代码。

   在Windows下,Socket是以DLL的形式实现的。在DLL内部维持着一个计数器,只有第一次调用WSAStartup()才真正装载DLL,以后的 调用只是简单的增加计数器,而WSACleanup()函数的功能则刚好相反,每调用一次使计数器减1,当计数器减到0时,DLL就从内存中被卸载!因此,你 调用了多少次WSAStartup(),就应相应的调用多少次的WSACleanup().

Client:

#include 
#include 
#include 
#pragma comment(lib, "ws2_32.lib")  //加载 ws2_32.dll
#pragma warning( disable : 4996 )
int main(){
    //初始化DLL
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    //创建套接字
    SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

    //向服务器发起请求
    struct sockaddr_in sockAddr;
    memset(&sockAddr, 0, sizeof(sockAddr));  //每个字节都用0填充
    sockAddr.sin_family = PF_INET;
    sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    sockAddr.sin_port = htons(1234);
    connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

    //接收服务器传回的数据
    char szBuffer[MAXBYTE] = {0};
    recv(sock, szBuffer, MAXBYTE, NULL);

    //输出接收到的数据
    printf("Message form server: %s\n", szBuffer);

    //关闭套接字
    closesocket(sock);

    //终止使用 DLL
    WSACleanup();

    system("pause");
    return 0;
}

   此处讲一下两个函数:

int recv(SOCKET sock, char *buf, int len, int flags);
int connect(SOCKET sock, const struct sockaddr *serv_addr, int addrlen);  //Windows

   recv()用于从对端接受数据,和send()类似。

   connect()用于客户端向服务器请求建立连接,和accept()类似。

小结:

   本文讲解了socket网络编程的基本方法。可以看到socket库的设计还是比较完善的。只是对于部分函数还有些疑惑,比如send(),recv()的缓冲区大小如何设置的。有没有能同时设置port和IP地址的方法,能不能优化一下。还有inet_addr()函数能不能对传入IP地址,进行检查,这个IP是否符合逻辑。等等…

   后续我会就socket编程中的安全性,如缓冲区的设置以及回收。连接异常断开会发生什么,进行分析。加油加油!!!

你可能感兴趣的:(网络编程,网络,c++,tcp/ip)