计算机网络 TCP/UDP程序开发网络聊天室

一、实验名称

TCP/UDP程序开发

二、实验目的

开发TCP/UDP协议应用程序,掌握网络应用程序的工作原理。通过该实验,深入理解UDP和TCP协议的异同点,了解网络协议的工作过程,学会网络通信编程的基本方法,能够编制网络应用程序。

三、实验内容及要求

(1)了解和掌握“基于UDP-面向无连接的应用程序/基于TCP-面向连接的应用程序”的运行机制和编程方法;
(2)编写一个网络通信应用程序:聊天程序;
(3)使用任意网络编程语言(Java、C、VB、Delphi、Python等)编写基于TCP或UDP协议的网络应用程序。
(4)总结实验过程:方案、编程、调试、结果、分析、结论。

四、实验设备

  1. 硬件要求
    计算机、Internet网
  2. 软件要求
    Windows操作系统、Microsoft Visual Studio2019.

五、实验步骤及记录

1. 方案

1.1 Win Sock编程

Win Sock编程是一种网络编程接口,实际上是作为TCP/IP协议的一种封装。可以通过调用WinSock的接口函数来调用TCP/IP的各种功能。
WinSock 编程简单流程:WinSock编程分为服务器端和客户端两部分。

1.2 服务器端编程步骤

2.1 使用WSAStartup()函数检查系统协议栈安装情况;

2.2 使用socket()函数创建服务器端通信套接字;

2.3 使用bind()函数将创建的套接字与服务器地址绑定;

2.4 使用listen()函数使服务器套接字做好接收连接请求准备;

2.5 使用accept()接收来自客户端由connect()函数发出的连接请求;

2.6 根据连接请求建立连接后,使用send()函数发送数据,或者使用recv()函数接收数据;

2.7 使用closesocket()函数关闭套接字;

2.8 最后调用WSACleanup()函数结束Winsock Sockets API。

1.3 客户端编程步骤

3.1 使用WSAStartup()函数检查系统协议栈安装情况;

3.2 使用socket()函数创建客户端套接字;

3.3 使用connect()函数发出也服务器建立连接的请求(调用前可以不用bind()端口号,由系统自动完成);

3.4 连接建立后使用send()函数发送数据,或使用recv()函数接收数据;

3.5 使用closesocet()函数关闭套接字;

3.6 最后调用WSACleanup()函数,结束Winsock Sockets API。

2 应用进程跨越网络通信的编程基本方法(socket)及基本函数:

2.1 socket概述

在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。

socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现:即socket是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)。Socket()函数返回一个整型的Socket描述符,随后的连接建立、数据传输等操作都是通过该Socket实现的。

socket是利用三元组解决网络通信的一个中间件工具,就目前而言,几乎所有的应用程序都是采用socket,如UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰)。

Socket通信的数据传输方式,常用的有两种:

  1. SOCK_STREAM:表示面向连接的数据传输方式。数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送,但效率相对较慢。常见的 http 协议就使用 SOCK_STREAM 传输数据,因为要确保数据的正确性,否则网页不能正常解析。
  2. SOCK_DGRAM:表示无连接的数据传输方式。计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。因为SOCK_DGRAM 所做的校验工作少,所以效率比 SOCK_STREAM 高。

2.2 socket基本函数

2.2.1 socket()函数

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

socket函数的三个参数分别为:

  1. domain:即协议域,又称为协议簇(family)。常用的协议簇有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议簇决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  2. type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等。
  3. 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()时系统会自动随机分配一个端口。

2.2.2 bind()函数

bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。当我们调用socket创建一个socket时,返回的socket描述字它存在于协议簇(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
函数的三个参数分别为:

  1. sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  2. addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议簇的不同而不同,如ipv4对应的是:
    struct sockaddr_in {
    sa_family_t sin_family;
    in_port_t sin_port;
    struct in_addr sin_addr;
    };
    struct in_addr {
    uint32_t s_addr;
    };
    ipv6对应的是:
    struct sockaddr_in6 {
    sa_family_t sin6_family;
    in_port_t sin6_port;
    uint32_t sin6_flowinfo;
    struct in6_addr sin6_addr;
    uint32_t sin6_scope_id;
    };
    struct in6_addr {
    unsigned char s6_addr[16];
    };
    Unix域对应的是:
    #define UNIX_PATH_MAX 108
    struct sockaddr_un {
    sa_family_t sun_family;
    char sun_path[UNIX_PATH_MAX];
    };
  3. addrlen:对应的是地址的长度。
    通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。因此,通常服务器端在listen()之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

2.2.3 listen()、connect()函数

如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。

2.2.4 accept()函数

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。

accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。

注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

2.2.5 read()、write()函数

read()函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。

write()函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节 数。失败时返回-1,并设置error变量。在网络程序中,当我们向套接字文件描述符写时有俩种可能:(1)write的返回值大于0,表示写了部分或者是 全部的数据。(2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示 网络连接出现了问题(对方已经关闭了连接)。

2.2.6 close()函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字。close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

3 编程原理

3.1 连接建立:服务器调用socket()、 bind()、 listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。

3.2 数据传输:建立连接后, TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。

3.3 关闭连接:如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

4 编程

4.1 服务器端代码

#define _CRT_SECURE_NO_WARNINGS 1
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <stdio.h>
#include<WinSock2.h>
#pragma comment(lib,"ws2_32.lib")
#include <windows.h>

SOCKADDR_IN cAddr = {
    
     0 };
int len = sizeof cAddr;
SOCKET clientSocket[1024];

void func(int index)
{
    
    
	//通信
	char buff[1024];
	int r;
	while (1)
	{
    
    
		r = recv(clientSocket[index], buff, 1023, NULL);
		if (r > 0)
		{
    
    
			buff[r] = 0;
			printf("%s发来的数据:%s\n", inet_ntoa(cAddr.sin_addr), buff);
		}
	}
}

int main()
{
    
    
	//确定协议版本
	WSADATA wsaData;
	WSAStartup(MAKEWORD(2, 2), &wsaData);
		if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion)!=2)
		{
    
    
			printf("确定协议版本失败!\n");
			return -1;
	}
	//创建socket
	SOCKET serverSocket=socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (SOCKET_ERROR == serverSocket)
	{
    
    
		printf("创建socket失败:%d\n", GetLastError());
		//清理协议版本信息
		WSACleanup();
		return -1;
	}
	printf("创建socket成功!\n");

	//创建服务器协议地址簇
	SOCKADDR_IN addr = {
    
     0 };
	addr.sin_family = AF_INET;//协议地址簇
	addr.sin_addr.S_un.S_addr = inet_addr("192.168.217.1");
	addr.sin_port = htons(9853);//10000左右 小端转大端

	//绑定
	int r = bind(serverSocket, (struct sockaddr*)&addr, sizeof addr);
	if(-1==r)
	{
    
    
		printf("绑定失败:%d\n", GetLastError());
		//关闭socket
		closesocket(serverSocket);
		//清理协议版本信息
		WSACleanup();
		return -1;
	}
	printf("绑定成功!\n");

	//监听
	r = listen(serverSocket, 10);
	if (r == -1)
	{
    
    
		printf("监听失败:%d\n", GetLastError());
		//关闭socket
		closesocket(serverSocket);
		//清理协议版本信息
		WSACleanup();
		return -1;
	}
	printf("监听成功!\n");

	//等待客户端连接
	for (int i = 0; i < 1024; i++)
	{
    
    
		clientSocket[i] = accept(serverSocket, (sockaddr*)&cAddr, &len);
		if (SOCKET_ERROR == clientSocket[i])
		{
    
    
			printf("客户端连接失败:%d\n", GetLastError());
			closesocket(serverSocket);
			WSACleanup();
			return -1;
		}
		printf("客户端连接成功!\n");
		CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)func, (LPVOID)i, NULL, NULL);
	}
		

	//关闭socket
	closesocket(serverSocket);
	//清理版本协议信息
	WSACleanup();

	while (1);//停顿
	return 0;
}

4.2 客户端代码

#define _CRT_SECURE_NO_WARNINGS 1
#define _CRT_SECURE_NO_WARNINGS 1
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <stdio.h>
#include<WinSock2.h>
#pragma comment(lib,"ws2_32.lib")
#include <windows.h>

int main()
{
    
    
	//确定协议版本
	WSADATA wsaData;
	WSAStartup(MAKEWORD(2, 2), &wsaData);
	if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2)
	{
    
    
		printf("确定协议版本失败!\n");
		return -1;
	}
	//创建socket
	SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (SOCKET_ERROR == clientSocket)
	{
    
    
		printf("创建socket失败:%d\n", GetLastError());
		//清理协议版本信息
		WSACleanup();
		return -1;
	}
	printf("创建socket成功!\n");

	//获取服务器协议地址簇
	SOCKADDR_IN addr = {
    
     0 };
	addr.sin_family = AF_INET;//协议地址簇
	addr.sin_addr.S_un.S_addr = inet_addr("192.168.217.1");
	addr.sin_port = htons(9853);//10000左右 小端转大端

	//连接服务器
	int r = connect(clientSocket, (sockaddr*)&addr, sizeof addr);
	if (-1 == r)
	{
    
    
		printf("连接服务器失败:%d\n", GetLastError());
		closesocket(clientSocket);
		WSACleanup();
		return -1;
	}
	printf("连接服务器成功!\n");

	//通信
	char buff[1024];
	while (1)
	{
    
    
		scanf("%s", buff);//接受用户输入
		send(clientSocket, buff, strlen(buff), NULL);//发送给服务器
	}

	//关闭socket
	closesocket(clientSocket);
	//清理版本协议信息
	WSACleanup();

	while (1);//停顿
	return 0;
}

5 调试

5.1 调试过程中出现的问题

(1)无法让多个客户端同时发送信息给服务器;
(2)出现’inet_addr’: Use inet_pton() or InetPton() instead or define _WINSOCK_DEPREC报错;
(3)出现’inet_ntoa’: Use inet_ntop() or InetNtop() instead or define _WINSOCK_DEPREC报错;

5.2 相应的解决方法

(1)使用CreateThread函数,CreateThread是一种微软在Windows API中提供了建立新的线程的函数,该函数在主线程的基础上创建一个新线程。线程终止运行后,线程对象仍然在系统中,必须通过CloseHandle函数来关闭该线程对象。
(2)该问题出现的原因是在VS2013以后的版本中,增加了inet_pton()、InetPton()之类的新函数,用于IP地址在“点分十进制”和“二进制整数”之间转换,并且能够处理ipv4和ipv6。而inet_addr是老函数,高版本VS在编译时默认使用了新函数,所以会报该错误。因此,只需对VS进行设置让其忽略报错。
(3)该问题出现的原因是socket2已经丢弃inet_ntoa()、inet_addr()这些老版本函数,当在Socket2上调用这些老版本(Socket1版本)函数时,warning变成error了。因此,只需在代码开头加个 #pragma warning(disable:4996) ,用于屏蔽warning,提高兼容性。

5.3调试演示

调试时我们为了验证其稳定性,及多客户端在同一时间发消息时是否会造成一些忙碌问题我们打开并建立五个客户端端口:

5.3.1 服务端总视图
在这里插入图片描述

5.3.2 第一个客户端创建
在这里插入图片描述

5.3.3 第二个客户端创建
在这里插入图片描述

5.3.4 第三个客户端创建
在这里插入图片描述

5.3.5 第四个客户端创建
在这里插入图片描述

5.3.6 第五个客户端创建
在这里插入图片描述

在这里插入图片描述

上述就是同时打开了5个客户端窗口,并通过服务器端窗口可以看出5个客户端全部成功连接。

下面进行测试,看服务端是否可以接收来自每个不同的客户端发来的信息。
5.3.7 第一个客户端发送111
在这里插入图片描述

5.3.8 第二个客户端发送222
在这里插入图片描述

5.3.9 第三个客户端发送333
在这里插入图片描述

5.3.10 第四个客户端发送444
在这里插入图片描述

5.3.11 第五个客户端发送555
在这里插入图片描述

5.3.12 服务端接收到所有客户端发送的信息
在这里插入图片描述

5.3.13 调试结果图总述
在这里插入图片描述

由上述结果可知:5个客户端向服务器发送了不同的信息并且都被服务器收到了。

六、实验总结

本实验是从整体上对于网络协议的应用有了一个全面的认识。首先,在此之前我们没有接触过使用socket接口实现网络协议的一系列编程思想,在实验一总我们学会了socket中简单的协议的用法,服务端的建立,绑定,使用,监听等一系列的问题的研究已经从客户端连接服务端的各项内容。

从整体上对于winsock的使用流程有了更深一步的了解,通过阅读源码,对于TCP/UDP有了更深的理解。并能够深刻认识到二者直接的区别:

  1. 有无连接:
    使用UDP协议的通讯双方是随时可以进行数据传输的,无需建立连接,而TCP是面向连接服务的,在传输数据的时候需要三次握手进行连接。四次挥手释放连接
  2. 是否支持广播或多播
    UDP支持了广播、多播和单播的服务,而TCP只支持单播
  3. 报文是面向字节流或报文段
    UDP是面向应用报文,UDP对应用层交下来的报文,既不会拆分,也不会合并。TCP是面向字节流的,应用程序与TCP交互是一次一个大小不等的数据块,发送方的TCP把应用进程交付下来的报文数据块,看成是无结构的字节流,它不保证接收方收到的数据块和发送方发送的数据块具有对应大小关系。发送方TCP会将数据放入缓冲区,等到可以发送的时候再发送,TCP会根据发送策略,发送给接受方
  4. 首部
    UDP首部开销比较小,只有8字节,TCP最少首部为20字节,最大首部是60字节,主要因为他实现的功能比较多,实现可靠传输、拥塞控制、流量传输等等。
  5. 可靠性
    UDP向其上层提供无连接不可靠传输服务,数据可能会发生丢失,误码,但是传输效率高。所以主要适用于传输效率要求相对高,对准确性要求相对低和实时性要求高的场景,比如视频会议(因为我们可以接受图像稍微模糊一点,声音稍微不清晰一点)、网络语音电话、广播通信(广播、多播)

猜你喜欢

转载自blog.csdn.net/weixin_56935264/article/details/129622177