Windows网络与通信程序设计实验一:基于TCP的C/S通信仿真
1. 实验要求:
1.1 实验目的介绍:
模拟实现TCP协议通信过程,要求编程实现服务器端与客户端之间双向数据传递。 客户端向服务器端发送“我是集美大学网络工程专业学生”,服务器回应“我也是集美大学网络工程专业学生”。
1.2 实验相关提示:
服务器端创建监听套接字,并为它关联一个本地地址(指定IP地址和端口号),然后进入监听状态准备接受客户的连接请求。为了接受客户端的连接请求,服务器端必须调用accept函数。
客户端创建套接字后即可调用connect函数去试图连接服务器监听套接字。当服务器端的accept函数返回后,connect函数也返回。此时客户端使用socket函数创建的套接字,服务器端使用accept函数创建的套接字,双方实现通信。
2. 实验环境准备:
- 想要在Windows操作系统上进行网络协议编程,我们需要首先是一个可以编译C++程序的环境,这个环境可以是VSCode,也可以是VS;我个人选择的集成开发环境是VS2019.
- 使用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()
相比,它提供了更多的参数。
套接字的分类:
- SOCK_STREAM:流式套接字,使用TCP提供有连接的可靠的传输。
- SOCK_DGRAM:数据报套接字,使用UDP提供无连接的不可靠的传输。
- 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
为例: - 大尾:按
0x12
、0x34
、0x56
、0x78
的顺序来存。 - 小尾:按
0x78
、0x56
、0x34
、0x12
的顺序来存。
- 以
四个重要的字节序转换函数:
- 之所以要使用字节序转换函数的原因是,网络字节顺序和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. 实验结果展示:
- 产生一个解决方案:
- 打开两个终端:
- 打开刚才生成的解决方案所在的文件目录下的Debug文件:
可以看到其中生成了两个可执行的文件:
- 先启动服务器,把Server.exe拖入命令行中,并随意指定一个端口号,这里用9190:
- 然后用同样的方法打开客户端的文件,但是要额外指定设定好的服务器所在的主机IP和端口号。
- 成功向服务器发送数据,也成功接收到来自服务器发送的数据信息。
- 由于服务器是循环监听,所以处理完客户端的通信请求后,服务器端会依然保持监听的状态,而客户端则是处理完自己的事情后就将Socket消解了。