基于 UDP 协议的双向通信程序实现与解析

引言

用户数据报协议(UDP)作为 TCP/IP 协议簇中的核心协议之一,以其无连接、低开销的特性,在实时通信、物联网数据传输等场景中发挥着重要作用。与 TCP 协议的面向连接、可靠传输不同,UDP 协议提供的是一种尽最大努力交付的服务,不保证数据的有序到达和不丢失,这使得其在对实时性要求较高而对可靠性要求相对较低的场景中具有显著优势。

本文将对一套基于 UDP 协议实现的双向通信程序进行深入解析,包括客户端与服务器端的代码结构、关键函数调用、程序运行逻辑以及实际通信案例分析,旨在揭示 UDP 协议在实际编程中的应用方式与技术细节。

一、程序整体架构与设计思路

本次分析的程序采用 C 语言编写,基于 Linux 系统的套接字(socket)API 实现,包含客户端和服务器端两个独立的程序模块。两套程序均采用了 I/O 多路复用技术(select 函数)实现标准输入和网络套接字的同时监听,从而实现双向通信功能。

程序的核心设计思路在于:

  • 利用 UDP 协议的无连接特性,无需建立连接即可直接发送数据

  • 通过 select 函数实现 I/O 多路复用,同时监控标准输入和网络套接字

  • 采用事件驱动模型,根据不同的 I/O 事件(输入就绪或接收就绪)执行相应操作

  • 使用sendtorecvfrom函数实现数据的发送与接收,这两个函数是 UDP 通信的核心函数

二、客户端程序代码解析

2.1代码结构概览

客户端程序的主要功能是向指定的服务器发送数据,并接收服务器返回的数据。其代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <my_header.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

/* Usage:通过UDP实现通信 */
int main(int argc, char *argv[]) {
// 套接字创建与初始化
// 地址结构设置
// 事件循环与I/O多路复用
// 数据发送与接收处理
// 程序退出处理
}

2.2 关键代码解析

套接字创建

1
2
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
ERROR_CHECK(sockfd, -1, "error socket");
  • socket()函数用于创建一个套接字描述符,是网络编程的入口点

  • 第一个参数AF_INET指定使用 IPv4 地址族

  • 第二个参数SOCK_DGRAM指定创建 UDP 类型的套接字

  • 第三个参数0表示使用默认的 UDP 协议

  • ERROR_CHECK宏用于错误检查,当套接字创建失败时输出错误信息

服务器地址结构初始化

1
2
3
4
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2]));
addr.sin_addr.s_addr = inet_addr(argv[1]);
  • sockaddr_in结构体用于存储 IPv4 地址信息

  • sin_family字段指定地址族为AF_INET(IPv4)

  • sin_port字段设置服务器端口号,htons()函数将主机字节序转换为网络字节序

  • sin_addr.s_addr字段设置服务器 IP 地址,inet_addr()函数将点分十进制 IP 地址转换为 32 位二进制网络字节序地址

  • 程序通过命令行参数(argv[1]为 IP 地址,argv[2]为端口号)指定服务器地址

I/O 多路复用实现

1
2
3
4
5
6
7
8
fd_set sockset;
while (1) {
FD_ZERO(&sockset);
FD_SET(STDIN_FILENO, &sockset);
FD_SET(sockfd, &sockset);
select(1024, &sockset, NULL, NULL, NULL);
// 事件处理逻辑
}
  • fd_set结构体表示文件描述符集合,用于select函数的参数

  • FD_ZERO宏初始化文件描述符集合,将其清空

  • FD_SET宏将标准输入文件描述符(STDIN_FILENO)和套接字描述符(sockfd)添加到集合中

  • select函数用于监听集合中的文件描述符,第一个参数1024表示监听的文件描述符范围,第二个参数为读事件集合,后三个参数分别为写事件集合、异常事件集合和超时时间(NULL表示无限等待)

  • 这种机制使得程序可以同时等待多个 I/O 事件,而无需阻塞在单一的readrecvfrom调用上

标准输入事件处理

1
2
3
4
5
6
7
8
9
if (FD_ISSET(STDIN_FILENO, &sockset)) {
int ret = read(STDIN_FILENO, buf, sizeof(buf));
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&addr, sizeof(addr));
if (strcmp(buf, "exit\n") == 0 || ret == 0) {
printf("告诉俺娘俺不中嘞\n");
exit(1);
}
bzero(buf, ret);
}
  • FD_ISSET宏检查标准输入文件描述符是否在就绪集合中

  • read函数从标准输入读取数据到缓冲区buf中

  • sendto函数将缓冲区中的数据发送到指定的服务器地址,参数包括套接字描述符、数据缓冲区、数据长度、标志位、目标地址结构及地址长度

  • 程序包含退出机制,当输入 "exit\n" 或读取到文件结束符时,输出提示信息并退出程序

  • bzero函数清空缓冲区,为下一次读取做准备

网络接收事件处理

1
2
3
4
5
if (FD_ISSET(sockfd, &sockset)) {
ssize_t ret_r = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&server_addr, &socklen);
printf("buf = %sip = %s,port = %d\n", buf, inet_ntoa(server_addr.sin_addr), ntohs(server_addr.sin_port));
bzero(buf, ret_r);
}
  • 检查套接字描述符是否在就绪集合中

  • recvfrom函数用于从套接字接收数据,参数包括套接字描述符、接收缓冲区、缓冲区大小、标志位、发送方地址结构及地址长度指针

  • inet_ntoa函数将 32 位网络字节序 IP 地址转换为点分十进制字符串

  • ntohs函数将网络字节序端口号转换为主机字节序

  • 打印接收到的数据内容、发送方 IP 地址和端口号

  • 清空缓冲区,准备下一次接收

三、服务器端程序代码解析

3.1 代码结构概览

服务器端程序的主要功能是监听指定端口,接收客户端发送的数据,并向客户端回复数据。其代码结构与客户端类似,但增加了绑定(bind)操作,这是服务器端程序的重要特征。

3.2 关键代码解析

套接字绑定操作

1
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
  • bind函数将套接字描述符与特定的 IP 地址和端口号绑定

  • 对于服务器端程序,绑定操作是必要的,它指定了程序监听的网络接口和端口

  • 客户端程序通常不需要显式绑定,系统会自动分配一个临时端口

事件循环与客户端地址记录

1
2
3
4
5
6
7
struct sockaddr_in client_addr;
socklen_t socklen = sizeof(client_addr);
// ...
if (FD_ISSET(sockfd, &sockset)) {
ssize_t ret_r = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &socklen);
// 数据处理逻辑
}
  • client_addr结构体用于存储发送数据的客户端地址信息

  • recvfrom函数在接收数据的同时,会将发送方的地址信息填充到client_addr结构体中

  • 这个地址信息在服务器向客户端发送回复时会被使用,确保数据能够正确发送到对应的客户端

服务器向客户端发送数据

1
2
3
4
5
if (FD_ISSET(STDIN_FILENO, &sockset)) {
int ret = read(STDIN_FILENO, buf, sizeof(buf));
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&client_addr, sizeof(client_addr));
bzero(buf, ret);
}
  • 服务器端从标准输入读取数据后,通过sendto函数发送到客户端

  • 这里使用的是之前通过recvfrom获取的client_addr作为目标地址,确保数据发送到正确的客户端

  • 这种方式使得服务器可以与多个客户端进行通信,只需记录每个客户端的地址信息

客户端退出检测

1
2
3
4
if (strcmp(buf, "exit\n") == 0 || ret_r == 0) {
printf("对面退出聊天\n");
exit(1);
}
  • 服务器端通过检查接收到的数据是否为 "exit\n" 来判断客户端是否退出

  • 当接收到空数据(ret_r==0)时,也判断为客户端退出

  • 检测到客户端退出后,服务器输出提示信息并退出程序

四、程序运行案例分析

4.1 环境准备

为了演示程序的运行效果,我们需要准备两台 Linux 主机(或一台主机上的两个终端),假设:

4.2 运行步骤

启动服务器端程序

1
./server 192.168.1.100 8888

服务器端输出:

1
2
UDP服务器已启动,监听 192.168.1.100:8888
等待UDP客户端发送信息

启动客户端程序

1
./client 192.168.1.100 8888

客户端输出:

1
UDP客户端已启动,输入消息后按Enter发送,输入'exit'退出

客户端发送消息

在客户端终端输入 "Hello, Server!" 并回车,客户端通过sendto函数将消息发送到服务器。

服务器接收消息

服务器端通过recvfrom函数接收到消息,并输出:

1
buf = Hello, Server!ip = 192.168.1.101,port = 54321

其中54321是客户端的临时端口号,由系统自动分配。

服务器回复消息

在服务器端输入 "Hello, Client!" 并回车,服务器将消息发送到客户端。

客户端接收消息

客户端接收到服务器回复,输出:

1
buf = Hello, Client!ip = 192.168.1.100,port = 8888

客户端退出

在客户端输入 "exit" 并回车,客户端输出 "告诉俺娘俺不中嘞" 并退出。

服务器检测到客户端退出

服务器端输出 "对面退出聊天" 并退出。

4.3 通信流程分析

从上述案例可以看出,该程序实现了基于 UDP 的双向通信功能,其通信流程具有以下特点:

  • 无连接特性:UDP 通信不需要建立连接,客户端可以直接向服务器发送数据。

  • 地址信息交换:每次数据传输都需要指定目标地址,服务器通过recvfrom获取客户端地址,从而实现回复。

  • 实时响应:通过select函数实现的 I/O 多路复用,使得程序可以即时响应输入和接收事件,提高了程序的交互性。

  • 简单退出机制:通过特定字符串 "exit" 实现通信双方的正常退出。

五、结论

本文对基于 UDP 协议的双向通信程序进行了全面解析,包括客户端和服务器端的代码结构、关键函数调用和程序运行逻辑。该程序虽然简单,但完整展示了 UDP 通信的核心原理和实现方法,包括套接字创建、地址结构处理、I/O 多路复用和数据收发等关键技术点。理解这些基本概念和实现方式,对于深入学习网络编程和理解 UDP 协议的应用具有重要意义。

在实际开发中,应根据具体应用场景选择合适的协议(UDP 或 TCP),并考虑数据可靠性、安全性和性能等多方面因素,设计更加健壮、高效的网络通信程序。

六、完整客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
if (argc != 3) {
printf("Usage: %s <server_ip> <server_port>\n", argv[0]);
exit(-1);
}

// 套接字创建与初始化
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
ERROR_CHECK(sockfd, -1, "error socket");

// 地址结构设置
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2]));
addr.sin_addr.s_addr = inet_addr(argv[1]);

char buf[1024] = {0};
fd_set sockset;
struct sockaddr_in server_addr;
socklen_t socklen = sizeof(server_addr);

while (1) {
FD_ZERO(&sockset);
FD_SET(STDIN_FILENO, &sockset);
FD_SET(sockfd, &sockset);

int ret = select(1024, &sockset, NULL, NULL, NULL);
ERROR_CHECK(ret, -1, "error select");

// 标准输入事件处理
if (FD_ISSET(STDIN_FILENO, &sockset)) {
int ret = read(STDIN_FILENO, buf, sizeof(buf));
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&addr, sizeof(addr));
if (strcmp(buf, "exit\n") == 0 || ret == 0) {
printf("告诉俺娘俺不中嘞\n");
close(sockfd);
exit(1);
}
bzero(buf, ret);
}

// 网络接收事件处理
if (FD_ISSET(sockfd, &sockset)) {
ssize_t ret_r = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&server_addr, &socklen);
printf("buf = %s ip = %s,port = %d\n", buf, inet_ntoa(server_addr.sin_addr), ntohs(server_addr.sin_port));
bzero(buf, ret_r);
}
}

close(sockfd);
return 0;
}

七、完整服务器端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <my_header.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

/* Usage:通过UDP实现通信 */
int main(int argc, char *argv[]){
// 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
ERROR_CHECK(sockfd, -1, "error socket");

// 初始化服务器地址结构
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2])); // 端口号转换为网络字节序
addr.sin_addr.s_addr = inet_addr(argv[1]); // IP地址转换为网络字节序
// 绑定套接字到指定IP和端口
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

// 初始化变量与提示信息
printf("UDP服务器已启动,监听 %s:%s\n", argv[1], argv[2]);
char buf[1024] = {0}; // 数据缓冲区
struct sockaddr_in client_addr; // 存储客户端地址
socklen_t socklen = sizeof(client_addr); // 客户端地址长度
fd_set sockset; // 文件描述符集合(用于I/O多路复用)
printf("等待UDP客户端发送信息\n");

// 事件循环:持续监听并处理输入和网络事件
while(1){
FD_ZERO(&sockset); // 清空文件描述符集合
FD_SET(STDIN_FILENO, &sockset); // 添加标准输入到监听集合
FD_SET(sockfd, &sockset); // 添加套接字到监听集合
// 监听事件(阻塞等待)
select(1024, &sockset, NULL, NULL, NULL);

// 处理标准输入事件(向客户端发送消息)
if(FD_ISSET(STDIN_FILENO, &sockset)){
int ret = read(STDIN_FILENO, buf, sizeof(buf)); // 读取标准输入
// 发送数据到客户端
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&client_addr, sizeof(client_addr));
bzero(buf, ret); // 清空缓冲区
}

// 处理套接字事件(接收客户端消息)
if(FD_ISSET(sockfd, &sockset)){
// 接收客户端数据,并获取客户端地址
ssize_t ret_r = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&client_addr, &socklen);
// 检查客户端退出条件
if(strcmp(buf, "exit\n") == 0 || ret_r == 0){
printf("对面退出聊天\n");
exit(1);
}
// 打印接收的消息及客户端信息
printf("buf = %sip = %s,port = %d\n", buf, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
bzero(buf, ret_r); // 清空缓冲区
}
}

// 关闭套接字(实际不会执行,因循环为无限)
close(sockfd);
return 0;
}