Windows网络与通信程序设计实验一:基于TCP的C/S通信仿真

Windows网络与通信程序设计实验一:基于TCP的C/S通信仿真

1. 实验要求:

1.1 实验目的介绍:

模拟实现TCP协议通信过程,要求编程实现服务器端与客户端之间双向数据传递。 客户端向服务器端发送“我是集美大学网络工程专业学生”,服务器回应“我也是集美大学网络工程专业学生”。

1.2 实验相关提示:

服务器端创建监听套接字,并为它关联一个本地地址(指定IP地址和端口号),然后进入监听状态准备接受客户的连接请求。为了接受客户端的连接请求,服务器端必须调用accept函数。
客户端创建套接字后即可调用connect函数去试图连接服务器监听套接字。当服务器端的accept函数返回后,connect函数也返回。此时客户端使用socket函数创建的套接字,服务器端使用accept函数创建的套接字,双方实现通信。

2. 实验环境准备:

  1. 想要在Windows操作系统上进行网络协议编程,我们需要首先是一个可以编译C++程序的环境,这个环境可以是VSCode,也可以是VS;我个人选择的集成开发环境是VS2019.
  2. 使用VS2019要能够进行Winsock编程我们首先要进行如下的操作:

Windows下使用VS2019搭建Winsock编程环境

其中最重要的环境有以下的这几步:

  • 我们是使用空项目来开始构建一个Winsock的编程项目:
    在这里插入图片描述
  • 由于我们是要在同一台主机上同时运行服务器端客户端,所以我们必然在同一个解决方案下得有两个工程项目,一个工程项目是服务器端的工程;另一个工程项目是客户端的工程,所以我们在新建工程的时候,不能选择把工程和解决方案放在同一个目录下。
    在这里插入图片描述
  • 接下来是准备对应的环境,首先我们要先将SDL检查给关闭,之所以关闭SDL检查的原因是,VS2019本身的编译等级太高了,在教材中的很多原始的函数在SDL检查打开的时候是无法正常使用的。
    在这里插入图片描述
  • 其次由于Winsock库的使用中,我们必须要使用到WS2_32.lib库,所以在项目中必须添加该库的链接:【每个工程都要!】
    在这里插入图片描述
    在这里插入图片描述
  • 由于每次写网络程序都必须编写代码载入和释放Winsock库,所以书本上给定了一个封装好的CInitSock类来管理Winsock库,代码如下:
  • 该代码以initsock.h的头文件的形式保存。
// initsock.h文件
#include <winsock2.h>
#pragma comment(lib, "WS2_32")  // 链接到 WS2_32.lib

class CInitSock
{
public:
    /*CInitSock 的构造器*/
    CInitSock(BYTE minorVer = 2, BYTE majorVer = 2)
    {
        // 初始化WS2_32.dll
        WSADATA wsaData;
        WORD sockVersion = MAKEWORD(minorVer, majorVer);
        if (::WSAStartup(sockVersion, &wsaData) != 0)
        {
            exit(0);
        }
    }

    /*CInitSock 的析构器*/
    ~CInitSock()
    {
        ::WSACleanup();
    }
};
  • 所有的Winsock函数都是从WS2_32.DLL导出的,VS2019在默认情况下并没有链接到该库,如果想使用Winsock API就必须包含相应的库文件:
    #pragma comment(lib, "WS2_32") // 链接到 WS2_32.lib
  • 以上代码在进行封装的时候,构造器使用的函数是WSAStartup()函数,该函数用于加载Winsock库,该函数需要传入两个参数,一个参数是WORD wVersionRequested这个参数用于指定想要加载的Winsock库的版本号,该结构体有两个元素,一个是minorVer次版本号,另一个是majorVer主版本号,在创建这样的结构体的时候需要用MAKEWORD函数来创建这样的一个结构体。
  • 当然,每一个对WSAStartup的调用都必须对应一个对WSACleanup的调用,所以我们需要在析构器中调用这个函数来实现对Winsock库的释放。

3. 实验步骤和具体代码理解:

  • TCP通信过程的流程图:
    在这里插入图片描述

3.1 以下是服务器端的代码:

#include "initsock.h"
#include <iostream>
using namespace std;

CInitSock initSock;     // 初始化Winsock库

int main()
{
    // 创建套接字
    SOCKET sListen = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (sListen == INVALID_SOCKET)
    {
        cout << "Failed socket()" << endl;
        return 0;
    }

    // 填充sockaddr_in结构
    sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(4567);
    sin.sin_addr.S_un.S_addr = INADDR_ANY;

    // 绑定这个套接字到一个本地地址
    if (::bind(sListen, (LPSOCKADDR)&sin, sizeof(sin)) == SOCKET_ERROR)
    {
        cout << "Failed bind()" << endl;
        return 0;
    }

    // 进入监听模式
    if (::listen(sListen, 2) == SOCKET_ERROR)
    {
        cout << "Failed listen()" << endl;
        return 0;
    }

    // 循环接受客户的连接请求
    sockaddr_in remoteAddr;
    int nAddrLen = sizeof(remoteAddr);
    SOCKET sClient;
    char szText[] = "你好,客户端:我也是集美大学网络工程专业学生!";
    while (TRUE)
    {
        cout << "服务端已启动,正在监听!\n" << endl;

        // 接受一个新连接
        sClient = ::accept(sListen, (SOCKADDR*)&remoteAddr, &nAddrLen);
        if (sClient == INVALID_SOCKET)
        {
            cout << "Failed accept()" << endl;
            continue;
        }

        cout << "与主机 " << inet_ntoa(remoteAddr.sin_addr) << "建立连接:" << endl;

        // 接收数据
        char buff[256];
        int nRecv = ::recv(sClient, buff, 256, 0);
        if (nRecv > 0)
        {
            buff[nRecv] = '\0';
            cout << "接收到数据:" << buff << endl;
        }

        // 向客户端发送数据
        ::send(sClient, szText, strlen(szText), 0);
        // 关闭同客户端的连接
        ::closesocket(sClient);
    }

    // 关闭监听套接字
    ::closesocket(sListen);

    return 0;
}

3.2 以下是客户端的代码:

#include "initsock.h"
#include <iostream>
using namespace std;

CInitSock initSock;     // 初始化Winsock库

int main()
{
    // 创建套接字
    SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s == INVALID_SOCKET)
    {
        cout << " Failed socket()" << endl;
        return 0;
    }

    // 也可以在这里调用bind函数绑定一个本地地址,否则系统将会自动安排
    // 填写远程地址信息
    sockaddr_in servAddr;
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(4567);
    // 填写服务器程序(TCPServer程序)所在机器的IP地址
    char serverAddr[] = "127.0.0.1";
    servAddr.sin_addr.S_un.S_addr = inet_addr(serverAddr);

    //与服务器建立连接
    if (::connect(s, (sockaddr*)&servAddr, sizeof(servAddr)) == -1)
    {
        cout << " Failed connect()" << endl;
        return 0;
    }

    cout << "与服务器 " << serverAddr << "建立连接" << endl;

    //向服务器发送数据
    char szText[] = "你好,服务器:我是集美大学网络工程专业学生!";
    int slen = send(s, szText, 100, 0);
    if (slen > 0)
    {
        cout << "向服务器发送数据:" << szText << endl;
    }

    // 接收数据
    char buff[256];
    int nRecv = ::recv(s, buff, 256, 0);
    if (nRecv > 0)
    {
        buff[nRecv] = '\0';
        cout << "接收到数据:" << buff << endl;
    }

    // 关闭套接字
    ::closesocket(s);
    return 0;
}

3.3 代码细节补充:

3.3.1 套接字

套接字的创建函数(socket()函数):
SOCKET socket(
	int af,	//用来指定套接字使用的地址格式,WinSock中只支持AF_INET
	int type,	//用来指定套接字的类型
	int protocol	//配合type参数使用,用来指定使用的协议类型。可以是IPPPROTO_TCP等
);

除了socket()函数之外,Winsock2还提供了WSASocket()函数来创建套接字,与socket()相比,它提供了更多的参数。

套接字的分类:
  1. SOCK_STREAM:流式套接字,使用TCP提供有连接的可靠的传输。
  2. SOCK_DGRAM:数据报套接字,使用UDP提供无连接的不可靠的传输。
  3. SOCK_RAW:原始套接字,Winsock编程并不使用某种特定的协议去封装它,而是由程序自行处理数据报以及协议首部。
补充说明:
  • 当type参数指定为SOCK_STREAM或者SOCK_DGRAM时,系统已经明确使用TCP和UDP来工作,所以protocol参数可以指定为0.
  • 函数执行失败会返回INVALID_SOCKET
套接字的关闭:
int closesocket(SOCKET s);	//函数唯一的参数就是要关闭的套接字的句柄

3.3.2 Winsock寻址

最基本的sockaddr结构
struct sockaddr
{
	u_short sa_family;
	char sa_data[14];
};
sockaddr_in结构
struct sockaddr_in{
	short	sin_family;	//地址家族(即指定地址格式),应为AF_INET
	u_short	sin_port;	//端口号
	struct	in_addr	sin_addr;	//IP地址
	char	sin_zero[8];	//空字节,要设为0,主要是为了和struct sockaddr的长度一致
};
struct in_addr【存IP地址】
struct in_addr {
	union{
		struct {u_char	s_b1,s_b2,s_b3,s_b4;}	S_un_b;	//以4个u_char来描述
		struct {u_short s_w1,s_w2;}	S_un_w;	//以2个u_short来描述
		u_long	S_addr;	//以1个u_long来描述
	} S_un;
};
两个重要的地址转换函数:
unsigned long inet_addr(const char* cp); //将一个“aa.bb.cc.dd”(点分十进制)类型的IP地址字符串转化为32位的二进制数。
char* inet_ntoa(struct in_addr in);	//将32位的二进制数转化为IP地址字符串。
  • inet_addr()返回的32位二进制数是用网络顺序存储【也成为大尾方式】的。
  • 如何区分大尾顺序小尾顺序
    • 0x12345678为例:
    • 大尾:按0x120x340x560x78的顺序来存。
    • 小尾:按0x780x560x340x12的顺序来存。
四个重要的字节序转换函数:
  • 之所以要使用字节序转换函数的原因是,网络字节顺序和IntelCPU的字节顺序刚好相反所以需要字节序转换函数来进行处理。
u_short htons(u_short hostshort) //将u_short类型的变量从主机字节顺序转化到TCP/IP的网络字节顺序
u_long htonl(u_long hostlong) //将u_long类型的变量从主机字节顺序转化到TCP/IP的网络字节顺序
u_short ntohs(u_short netshort) //将u_short类型的变量从转化TCP/IP的网络字节顺序到主机字节顺序
u_long ntohl(u_long netlong) //将u_long类型的变量从转化TCP/IP的网络字节顺序到主机字节顺序
  • 这些字节序的转换函数将会在sockaddr_in的填充中使用:
    // 填充sockaddr_in结构
    sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(4567);
    sin.sin_addr.S_un.S_addr = INADDR_ANY;
  • 当应用程序不关心所使用的地址,我们就可以将Internet地址的值指定为INADDR_ANY,指定端口号为0.
  • 如果Internet地址为INADDR_ANY,系统会自动使用当前主机配置的所有IP地址,简化程序设计。
  • 如果端口号等于0,程序执行时系统会为这个应用程序分配唯一的端口号,其值在1024-5000之间。

应用程序可以在bind之后,使用getsockname来知道它分配的地址。但是得注意,得等连接上之后才能看到。

    // 填写远程地址信息
    sockaddr_in servAddr;
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(4567);
    // 填写服务器程序(TCPServer程序)所在机器的IP地址
    char serverAddr[] = "127.0.0.1";
    servAddr.sin_addr.S_un.S_addr = inet_addr(serverAddr);

127.0.0.1是本地的回环地址,用于本地测试所使用的地址,可以视为是在本地的服务器所在的IP地址,本地的客户端可以通过绑定该IP地址来实现与本地的服务器之间的通信。

3.3.3 TCP通信过程的重要函数

bind函数
int bind(
	SOCKET s,	//套接字的句柄
	const struct sockaddr* name, //要关联的本地地址
	int namelen //地址的长度,一般涉及sizeof函数
);
  • 往往我们填写的地址的用sockaddr_in的结构来填充的,所以往往涉及一个类型转换 (LPSOCKADDR)
	 // 绑定这个套接字到一个本地地址
    if (::bind(sListen, (LPSOCKADDR)&sin, sizeof(sin)) == SOCKET_ERROR)
    {
        cout << "Failed bind()" << endl;
        return 0;
    }
listen函数
int listen(
	SOCKET s,	//套接字的句柄
	int backlog	//监听队列中允许保持的尚未处理的最大连接数量
);

listen函数为到达的连接指定backlog
listen函数仅应用在支持连接的套接字上,如SOCKET_STREAM。

    // 进入监听模式
    if (::listen(sListen, 2) == SOCKET_ERROR)
    {
        cout << "Failed listen()" << endl;
        return 0;
    }
accept函数
SOCKET accept(
	SOCKET s,	//套接字句柄
	struct sockaddr* addr,	//一个指向sockaddr_in结构的指针,包含了要连接的服务器的地址信息
	int* addrlen	//一个指向地址长度的指针 
);

该函数会在s中取出未处理连接中的第一个连接,然后为这个连接创建新的套接字,返回它的句柄。新创建的套接字是专门用来处理实际连接中的通信的套接字。

    // 循环接受客户的连接请求
    sockaddr_in remoteAddr;
    int nAddrLen = sizeof(remoteAddr);
    SOCKET sClient;
    char szText[] = "你好,客户端:我也是集美大学网络工程专业学生!";
    while (TRUE)
    {
        cout << "服务端已启动,正在监听!\n" << endl;

        // 接受一个新连接
        sClient = ::accept(sListen, (SOCKADDR*)&remoteAddr, &nAddrLen);
        if (sClient == INVALID_SOCKET)
        {
            cout << "Failed accept()" << endl;
            continue;
        }

        cout << "与主机 " << inet_ntoa(remoteAddr.sin_addr) << "建立连接:" << endl;

        // 接收数据
        char buff[256];
        int nRecv = ::recv(sClient, buff, 256, 0);
        if (nRecv > 0)
        {
            buff[nRecv] = '\0';
            cout << "接收到数据:" << buff << endl;
        }

        // 向客户端发送数据
        ::send(sClient, szText, strlen(szText), 0);
        // 关闭同客户端的连接
        ::closesocket(sClient);
    }
connect函数
int connect(
	SOCKET s,	//套接字句柄
	const struct sockaddr FAR* name,	//一个指向sockaddr_in结构的指针,包含了要连接的服务器的地址信息
	int namelen	//sockaddr_in结构的长度
);

s是客户端的套接字,name和namelen用于寻址正在监听的处在服务器端的套接字。

    // 填写远程地址信息
    sockaddr_in servAddr;
    servAddr.sin_family = AF_INET;
    servAddr.sin_port = htons(4567);
    // 填写服务器程序(TCPServer程序)所在机器的IP地址
    char serverAddr[] = "127.0.0.1";
    servAddr.sin_addr.S_un.S_addr = inet_addr(serverAddr);

    //与服务器建立连接
    if (::connect(s, (sockaddr*)&servAddr, sizeof(servAddr)) == -1)
    {
        cout << " Failed connect()" << endl;
        return 0;
    }
send函数和recv函数
int send(
	SOCKET s,	//套接字的句柄
	const cahr FAR* buf,	//要发送数据的缓冲区的地址
	int len,	//缓冲区的长度
	int flags	//指定了调用方式,通常设为0
);
int recv(
	SOCKET s,	//套接字的句柄
	cahr FAR* buf,	//要接收的数据的缓冲区的地址
	int len,	//缓冲区的长度
	int flags	//指定了调用方式,通常设为0
);

4. 实验结果展示:

  1. 产生一个解决方案:
    在这里插入图片描述
  2. 打开两个终端:
    在这里插入图片描述
  3. 打开刚才生成的解决方案所在的文件目录下的Debug文件:
    可以看到其中生成了两个可执行的文件:
    在这里插入图片描述
  4. 先启动服务器,把Server.exe拖入命令行中,并随意指定一个端口号,这里用9190:
    在这里插入图片描述
  5. 然后用同样的方法打开客户端的文件,但是要额外指定设定好的服务器所在的主机IP和端口号。
    在这里插入图片描述
  • 成功向服务器发送数据,也成功接收到来自服务器发送的数据信息。
  1. 由于服务器是循环监听,所以处理完客户端的通信请求后,服务器端会依然保持监听的状态,而客户端则是处理完自己的事情后就将Socket消解了。
    在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/m0_54524462/article/details/127456199