TCP/IP 网络编程(五)---基于 UDP 的服务器端/客户端

142 阅读14分钟

什么是 UDP

UDP (User Datagram Protocol,用户数据报协议)是一种通信协议,属于传输层协议

(一)通过与 TCP 对比更好的理解 UDP

特性TCPUDP
连接管理面向连接,需要三次握手建立连接无连接,不需要建立连接
可靠性可靠,提供确认应答、重传机制、数据排序不可靠,数据包可能丢失、重复或乱序
流量控制提供流量控制,防止接收方数据淹没没有流量控制,发送方和接收方速率不受控制
头部开销头部较大,包含序列号、确认号、窗口大小头部较小,仅包含源端口、目的端口、长度、校验和
数据传输面向字节流,数据被视为连续的字节流面向数据报,每个数据报独立处理
速度相对较慢,因涉及连接管理和数据确认相对较快,数据传输过程中没有连接管理和确认
适用场景适用于需要高可靠性的应用,如文件传输、网页浏览、电子邮件适用于对实时性要求高且可以容忍丢包的应用,如视频流、在线游戏

(二)详细说明

UDP 在结构上比 TCP 更加简洁,UDP 不会发送类似 ACK 的应答消息,也不会像 SEQ 那样给数据包分配序号,因此,UDP 的性能有时比 TCP 高出很多;编程中实现 UDP 也比 TCP 简单。

流控制(Flow Control)是一种用于管理数据传输速率的机制,确保发送方不会以超出接收方处理能力的速度发送数据,从而防止接收方被淹没。

TCP 在不可靠的 IP 层进行流控制,所以它提供可靠的数据传输服务,而 UDP 就缺少这种流控制机制。

UDP 的可靠性虽然比不上 TCP,但也不会像想象中那么频繁地发生数据损毁,所以在更重视性能而非可靠性的情况下,UDP 是一种很好的选择。

TCP 比 UDP 慢的原因

  • 收发数据前后进行的连接设置及清除过程。
  • 收发数据过程中为保证可靠性而添加的流控制。

实现基于 UDP 的服务器端/客户端

与 TCP 不同,UDP 服务器端/客户端无需经过连接过程,也就是说,不必调用 TCP 连接过程中调用的 listen 函数和 accept 函数,UDP 中只有创建套接字的过程和数据交换过程

TCP 中,套接字之间应该是一对一的关系,若要向10个客户端提供服务,除了守门的服务器套接字以外,还需要10个服务器端套接字。但在 UDP 中,不管是服务器端还是客户端都只需要一个套接字就能和多台主机通信

image.png

(一)基于 UDP 的数据 I/O 函数

TCP 套接字创建好之后,传输数据时无需再添加地址信息,因为 TCP 套接字将保持与对方套接字的连接。然而 UDP 不会保持这种连接状态,所以每次传输数据都要添加目标地址信息

(1)sendto 函数

#include <sys/socket.h>
// 用于向指定的目标地址发送数据,成功时返回传输的字节数,失败时返回-1
ssize_t sendto(int sock, void* buff, size_t nbytes, int flags, struct sockaddr* to, socklen_t addrlen);
  • sock

    • 类型:int
    • 描述:套接字描述符。
  • buff

    • 类型:const void*
    • 描述:指向要发送的数据缓冲区的指针。数据从这个缓冲区中读取并通过套接字发送出去。
  • nbytes

    • 类型:size_t
    • 描述:要发送的数据字节数。指定了 buff 中数据的长度。
  • flags

    • 类型:int

    • 描述:发送选项标志。通常是 0,但可以使用以下标志之一:

      • MSG_OOB:发送带外数据。
      • MSG_DONTROUTE:不通过路由表发送数据。
      • MSG_NOSIGNAL:防止 SIGPIPE 信号的生成(某些系统中使用)。
  • to

    • 类型:const struct sockaddr*
    • 描述:指向 sockaddr 结构的指针,包含了目的地地址和端口信息。sockaddr 结构体用于存储目标主机的地址信息。
  • addrlen

    • 类型:socklen_t
    • 描述:to 参数中地址结构的长度,以字节为单位。通常是 sizeof(struct sockaddr_in)sizeof(struct sockaddr_in6),具体取决于地址族(IPv4 或 IPv6)。

(2)recvfrom 函数

#include <sys/socket.h>
// 成功时返回接收的字节数,失败时返回-1
ssize_t recvfrom(int sock, void* buff, size_t nbytes, int flags, struct sockaddr* from, socklen_t addrlen);
  • sock

    • 类型:int
    • 描述:套接字描述符。
  • buff

    • 类型:void*
    • 描述:指向接收数据的缓冲区。数据将被读取到这个缓冲区中。
  • nbytes

    • 类型:size_t
    • 描述:缓冲区的大小(以字节为单位)。指定最多可以接收的数据量。
  • flags

    • 类型:int

    • 描述:接收选项标志。常见的标志包括:

      • MSG_OOB:接收带外数据。
      • MSG_PEEK:窥探数据,即读取数据但不从接收缓冲区中移除。
      • MSG_WAITALL:等待接收足够的数据量(如果数据量超过缓冲区大小)。
  • from

    • 类型:struct sockaddr*
    • 描述:指向 sockaddr 结构的指针,用于接收发送方的地址信息。调用后,from 中的 sockaddr 结构将包含发送方的地址信息。
  • addrlen

    • 类型:socklen_t*
    • 描述:指向 socklen_t 变量的指针,初始时应设置为 from 参数所指向的地址结构的长度。调用后这个变量将被设置为实际的地址长度。

(二)基于 UDP 的回声服务器端/客户端

UDP 不同于 TCP,不存在请求连接和受理过程,因此在某种意义上无法明确区分服务器端和客户端,只因其提供服务而称为服务器端。

(1) Linux 平台服务器端(uecho_server.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t clnt_adr_sz;
	
	struct sockaddr_in serv_adr, clnt_adr;
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	// 为了创建UDP套接字,向socket函数第二个参数传递SOCK_DGRAM
	serv_sock=socket(PF_INET, SOCK_DGRAM, 0);
	if(serv_sock==-1)
		error_handling("UDP socket creation error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
	serv_adr.sin_port=htons(atoi(argv[1]));
	
	if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
		error_handling("bind() error");

	while(1) 
	{
		clnt_adr_sz=sizeof(clnt_adr);

		// 利用bind函数分配的地址接收数据,不限制数据传输对象
		str_len=recvfrom(serv_sock, message, BUF_SIZE, 0, 
								(struct sockaddr*)&clnt_adr, &clnt_adr_sz);

		// recvfrom函数调用同时获取数据传输端的地址,正是利用该地址将接收的数据进行逆向重传
		sendto(serv_sock, message, str_len, 0, 
								(struct sockaddr*)&clnt_adr, clnt_adr_sz);
	}	
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

(2)Linux 平台客户端(uecho_client.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		// 向服务器端传输数据
		sendto(sock, message, strlen(message), 0, 
					(struct sockaddr*)&serv_adr, sizeof(serv_adr));
		adr_sz=sizeof(from_adr);

		// 接收数据
		str_len=recvfrom(sock, message, BUF_SIZE, 0, 
					(struct sockaddr*)&from_adr, &adr_sz);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

(3)Windows 平台服务器端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 30
void ErrorHandling(char *message);

int main(int argc, char *argv[])
{
	WSADATA wsaData;
	SOCKET servSock;
	char message[BUF_SIZE];
	int strLen;
	int clntAdrSz;
	
	SOCKADDR_IN servAdr, clntAdr;
	if(argc!=2) {
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	if(WSAStartup(MAKEWORD(2, 2), &wsaData)!=0)
		ErrorHandling("WSAStartup() error!"); 
	
	servSock=socket(PF_INET, SOCK_DGRAM, 0);
	if(servSock==INVALID_SOCKET)
		ErrorHandling("UDP socket creation error");
	
	memset(&servAdr, 0, sizeof(servAdr));
	servAdr.sin_family=AF_INET;
	servAdr.sin_addr.s_addr=htonl(INADDR_ANY);
	servAdr.sin_port=htons(atoi(argv[1]));
	
	if(bind(servSock, (SOCKADDR*)&servAdr, sizeof(servAdr))==SOCKET_ERROR)
		ErrorHandling("bind() error");
	
	while(1) 
	{
		clntAdrSz=sizeof(clntAdr);
		strLen=recvfrom(servSock, message, BUF_SIZE, 0, (SOCKADDR*)&clntAdr, &clntAdrSz);
		sendto(servSock, message, strLen, 0, (SOCKADDR*)&clntAdr, sizeof(clntAdr));
	}	
	closesocket(servSock);
	WSACleanup();
	return 0;
}

void ErrorHandling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

(4)Windows 平台客户端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 30
void ErrorHandling(char *message);

int main(int argc, char *argv[])
{
	WSADATA wsaData;
	SOCKET sock;
	char message[BUF_SIZE];
	int strLen;
	
	SOCKADDR_IN servAdr;
	if(argc!=3) {
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	if(WSAStartup(MAKEWORD(2, 2), &wsaData)!=0)
		ErrorHandling("WSAStartup() error!"); 

	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(sock==INVALID_SOCKET)
		ErrorHandling("socket() error");
	
	memset(&servAdr, 0, sizeof(servAdr));
	servAdr.sin_family=AF_INET;
	servAdr.sin_addr.s_addr=inet_addr(argv[1]);
	servAdr.sin_port=htons(atoi(argv[2]));
	
	connect(sock, (SOCKADDR*)&servAdr, sizeof(servAdr));

	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;

		send(sock, message, strlen(message), 0);
		strLen=recv(sock, message, sizeof(message)-1, 0);

		message[strLen]=0;
		printf("Message from server: %s", message);
	}	
	closesocket(sock);
	WSACleanup();
	return 0;
}

void ErrorHandling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

(三)UDP 客户端套接字的地址分配

通过观察 UDP 客户端发现,它缺少把 IP 地址和端口分配给套接字的过程。TCP 客户端调用 connect 函数自动完成此过程,UDP 客户端则在调用 sendto 函数时自动分配 IP 和端口号,所以 UDP 客户端中通常无需额外的地址分配过程。

(四)UDP 数据传输特性:存在数据边界

我们知道 TCP 数据传输中不存在边界,这表示 “数据传输过程中调用 I/O 函数的次数不具有任何意义”。

相反,UDP 是具有数据边界的协议,传输中调用 I/O 函数的次数非常重要。输入函数的调用次数和输出函数的调用次数应完全一致,这样才能保证接收到全部已发送数据。下面通过示例进行验证:

bound_host1.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	struct sockaddr_in my_adr, your_adr;
	socklen_t adr_sz;
	int str_len, i;

	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&my_adr, 0, sizeof(my_adr));
	my_adr.sin_family=AF_INET;
	my_adr.sin_addr.s_addr=htonl(INADDR_ANY);
	my_adr.sin_port=htons(atoi(argv[1]));
	
	if(bind(sock, (struct sockaddr*)&my_adr, sizeof(my_adr))==-1)
		error_handling("bind() error");
	
	for(i=0; i<3; i++)
	{
		// 每隔5s调用一次recvfrom函数
		sleep(5);	// delay 5 sec.
		adr_sz=sizeof(your_adr);
		str_len=recvfrom(sock, message, BUF_SIZE, 0, 
								(struct sockaddr*)&your_adr, &adr_sz);     
	
		printf("Message %d: %s \n", i+1, message);
	}
	close(sock);	
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

bound_host2.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char msg1[]="Hi!";
	char msg2[]="I'm another UDP host!";
	char msg3[]="Nice to meet you";

	struct sockaddr_in your_adr;
	socklen_t your_adr_sz;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&your_adr, 0, sizeof(your_adr));
	your_adr.sin_family=AF_INET;
	your_adr.sin_addr.s_addr=inet_addr(argv[1]);
	your_adr.sin_port=htons(atoi(argv[2]));
	
	// 调用三次sendto函数传输数据
	sendto(sock, msg1, sizeof(msg1), 0, 
					(struct sockaddr*)&your_adr, sizeof(your_adr));
	sendto(sock, msg2, sizeof(msg2), 0, 
					(struct sockaddr*)&your_adr, sizeof(your_adr));
	sendto(sock, msg3, sizeof(msg3), 0, 
					(struct sockaddr*)&your_adr, sizeof(your_adr));
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

recvfrom 函数调用间隔为5s,所以调用 recvfrom 函数前已调用了3次 sendto 函数,也就是说此时数据已经传输到 bound_host1.c ,如果是 TCP 程序,这是只需调用1次输入函数即可读入数据。UDP 则不同,在这种情况下也需要调用3次 recvfrom 函数。

运行结果

root@my_linux:/home/swyoon/tcpip# gcc bound_host1.c -o host1
root@my_linux:/home/swyoon/tcpip# ./host1 
Usage : ./host1 <port>
root@my_linux:/home/swyoon/tcpip# ./host1 9190
Message 1: Hi! 
Message 2: I'm another UDP host! 
Message 3: Nice to meet you 
root@my_linux:/home/swyoon/tcpip# 


root@my_linux:/home/swyoon/tcpip# gcc bound_host2.c -o host2
root@my_linux:/home/swyoon/tcpip# ./host2
Usage : ./host2 <IP> <port>
root@my_linux:/home/swyoon/tcpip# ./host2 127.0.0.1 9190
root@my_linux:/home/swyoon/tcpip# 

(五)已连接的 UDP 套接字与未连接的 UDP 套接字

(1)UDP 套接字的默认状态:未连接

TCP 是一个面向连接的协议,发送方和接收方在传输数据之前需要建立一个持久的连接,而 UDP 不需要建立一个持久的连接。通过 sendto 函数传输数据的过程大致可分为以下三个阶段:

  • ① 向 UDP 套接字注册目标 IP 和端口号
  • ② 传输数据
  • ③ 删除 UDP 套接字中注册的目标地址信息

每次调用 sendto 函数时重复上述过程,每次都变更目标地址,因此可利用同一 UDP 套接字向不同目标传输数据。这种未注册目标地址信息的套接字就被称为未连接(unconnected)套接字,反之被称为连接(connected)套接字UDP 套接字默认属于未连接套接字。

当要与同一主机长时间通信时,将 UDP 套接字变成已连接套接字会提高效率。

(2)创建已连接的 UDP 套接字

创建已连接的 UDP 套接字只需针对 UDP 套接字调用 connect 函数:

sock=socket(PF_INET, SOCK_DGRAM, 0);   
memset(&adr, 0, sizeof(adr));
adr.sin_family=AF_INET;
adr.sin_addr.s_addr=...
adr.sin_port=...
connect(sock, (struct sockaddr*)&adr, sizeof(adr));

针对 UDP 套接字调用 connect 函数并不意味着要与对方 UDP 套接字连接,这只是向 UDP 套接字注册目标 IP 和端口信息。

之后就与 TCP 套接字一样,每次调用 sendto 函数时只需传输数据,因为已经指定了收发对象;而且此时不仅可以使用 sendtorecvfrom 函数,还可以使用 writeread 函数进行通信:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));

	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		
		// sendto(sock, message, strlen(message), 0, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
		
		write(sock, message, strlen(message));

		
		// adr_sz=sizeof(from_adr);
		// str_len=recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&from_adr, &adr_sz);
		
		str_len=read(sock, message, sizeof(message)-1);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

问答

(一)UDP 为什么比 TCP 快,为什么 TCP 数据传输可靠而 UDP 数据传输不可靠?

  • UDP比TCP快:因为UDP不需要建立连接、协议开销较小、没有流量控制和拥塞控制机制,这些特点使得UDP在数据传输时具有较低的延迟和更高的速度。

  • TCP数据传输可靠:通过确认机制、重传机制、数据顺序保障和错误检测等措施,确保数据完整、正确地到达接收方。

  • UDP数据传输不可靠:由于缺少确认、重传、排序和错误纠正机制,UDP的传输可能会丢失、重复或乱序数据。

(二)什么是 UDP 数据报

UDP 套接字传输的数据包又称数据报,实际上数据报也属于数据包的一种,只是与 TCP 包不同,其本身可以成为一个完整数据。这与 UDP 的数据传输特性有关,UDP 中存在数据边界,1个数据包即可成为1个完整数据,因此称为数据报。

(三)UDP 数据报向对方主机的 UDP 套接字传递过程中,IP 和 UDP 分别负责哪些部分?

  • IP 协议负责数据包的路由、转发、封装、分片与重组,以及传输到网络层的其他部分。

  • UDP 协议负责在网络层之上的传输,将数据报封装在UDP数据包中,使用端口号进行应用程序级的寻址,并进行基本的错误检测。

(四)UDP 中调用 connect 函数有哪些好处?

  • 简化发送数据:发送数据时不需要重复指定目标地址。

  • 便于接收数据:接收数据时使用 recv 而不是 recvfrom,只接收指定目标的数据。

  • 错误处理:发送方可以检查数据包是否符合连接的目标地址。

  • 协议特性:模拟类似TCP的一些特性,便于实现特定协议。

  • 减少误操作:避免发送到错误的地址,减少出错的机会。