Linux socket编程(10):UDP详解、聊天室实现及进阶知识

首先来回顾以下TCP的知识,TCP是一种面向连接的、可靠的传输协议,具有以下特点:

  • TCP通过三次握手建立连接,确保通信的可靠性和完整性
  • 使用流控制和拥塞控制机制,有效地调整数据传输的速率,防止网络拥塞
  • TCP提供错误检测和重传机制,以确保数据在传输过程中不会丢失或损坏
  • TCP支持全双工通信,允许双方同时发送和接收数据,从而提高通信效率

然而,与TCP不同,UDP是一种无连接的、不可靠的传输协议。相比于TCP,UDP更加轻量级,没有连接的建立和断开过程,也没有复杂的流控制和拥塞控制机制。UDP直接将数据包发送到目标地址,不保证数据的顺序和可靠性,因此在某些实时性要求较高、可以容忍少量数据丢失的应用场景中表现得更为适用。

1 相关函数

首先来看一下UDP的流程图:
在这里插入图片描述

对于服务端来说,UDP无需像TCP一样listen,因为UDP是无连接的。同样地,对于客户端来说,UDP也不需要调用connect建立连接。对于socket/bind等函数可以参考之前TCP的文章socket函数介绍及C/S模型代码实现中的介绍。这里就来介绍一下recvfromsendto

1.1 socket

socket()函数用于创建套接字,即通信的端点。

int socket(int domain, int type, int protocol);
  • 参数
    • domain:协议族,常用的是 AF_INET 表示IPv4协议族。
    • type:套接字类型,常用的是 SOCK_DGRAM 表示UDP套接字。
    • protocol:协议,通常设置为 0 表示使用默认协议。
  • 示例
int udp_socket = socket(AF_INET, SOCK_DGRAM, 0);

1.2 bind

bind()函数用于将套接字绑定到特定的地址和端口。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 参数
    • sockfd:套接字文件描述符。
    • addr:指向包含地址信息的结构体的指针,通常是struct sockaddr_in
    • addrlen:结构体的长度,使用sizeof(struct sockaddr_in)
  • 示例
struct sockaddr_in server_address;
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
server_address.sin_port = htons(8080);

bind(udp_socket, (struct sockaddr*)&server_address, sizeof(server_address));

1.3 sendto

sendto()函数用于向指定的目标地址发送数据。

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
  • 参数
    • sockfd:套接字文件描述符。
    • buf:要发送的数据的指针。
    • len:要发送的数据的字节数。
    • flags:发送标志,通常设置为 0
    • dest_addr:指向目标地址信息的结构体的指针,通常是struct sockaddr_in
    • addrlen:结构体的长度,使用sizeof(struct sockaddr_in)
  • 示例
char message[] = "Hello,world";
struct sockaddr_in client_address;
client_address.sin_family = AF_INET;
client_address.sin_addr.s_addr = inet_addr("127.0.0.1");
client_address.sin_port = htons(8081);

sendto(udp_socket, message, sizeof(message), 0, (struct sockaddr*)&client_address, sizeof(client_address));

1.4 recvfrom

recvfrom()函数用于接收数据以及发送方的地址。

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
  • 参数
    • sockfd:套接字文件描述符。
    • buf:用于存放接收数据的缓冲区的指针。
    • len:缓冲区的大小。
    • flags:接收标志,通常设置为 0
    • src_addr:指向发送方地址信息的结构体的指针,通常是struct sockaddr_in
    • addrlen:指向存储发送方地址结构体长度的变量的指针。
  • 示例:
char buffer[1024];
struct sockaddr_in client_address;
socklen_t client_address_len = sizeof(client_address);

recvfrom(udp_socket, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_address, &client_address_len);

2 UDP聊天室

现在来实现一个UDP聊天室,在代码编写的过程中,我们能够学到一些细节。

  • 目的:服务端能够将收到的客户端的消息转发给其它的所有客户端,从而实现多个客户端之间的通信。

2.1 客户端

1、创建UDP套接字

int udp_socket;
udp_socket = socket(AF_INET, SOCK_DGRAM, 0));

2、服务端地址配置

这里使用本地环回地址127.0.0.1作为服务端IP:

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8080
memset(&server_address, 0, sizeof(server_address));
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = inet_addr(SERVER_IP);
server_address.sin_port = htons(SERVER_PORT);

3、使用select监听stdin和服务端消息

现在需要使用select一边从stdin获取用户输入的数据然后发送给服务端,一边从服务端获取消息。

fd_set read_fds;
while (1)
{
    FD_ZERO(&read_fds);
    FD_SET(STDIN_FILENO, &read_fds);
    FD_SET(udp_socket, &read_fds);

    int activity = select(udp_socket + 1, &read_fds, NULL, NULL, NULL);

    if (FD_ISSET(STDIN_FILENO, &read_fds))
    {
        fgets(buffer, sizeof(buffer), stdin);
        buffer[strlen(buffer) - 1] = '\0';  //换行符替换为结束符
        // 发送消息给服务器
        sendto(udp_socket, buffer, strlen(buffer), 0, (struct sockaddr*)&server_address, sizeof(server_address));
    }

    if (FD_ISSET(udp_socket, &read_fds))
    {
        ssize_t bytes_received = recvfrom(udp_socket, buffer, sizeof(buffer), 0, NULL, NULL);
        buffer[bytes_received] = '\0';
        printf("%s\n", buffer);
    }
}

客户端在调用sendto的时候指定了服务端的地址,服务端收到消息后通过recvfromsrc_addr参数知道客户端的地址并保存起来,接着就可以通过这个地址发送消息给客户端。

2.2 服务端

1、创建并绑定套接字

udp_socket = socket(AF_INET, SOCK_DGRAM, 0);

memset(&server_address, 0, sizeof(server_address));
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;//监听所有地址
server_address.sin_port = htons(PORT);

bind(udp_socket, (struct sockaddr*)&server_address, sizeof(server_address));

2、接收并转发客户端的消息

由于服务端需要接收多个客户端的消息,所以需要创建一个数组来保存已经连接上的客户端的IP和端口号信息。

struct Client {
    struct sockaddr_in address;
    socklen_t len;
    int socket;
};
#define MAX_CLIENTS 10
struct Client clients[MAX_CLIENTS];

然后是调用recvfrom来监听新的客户端消息并创建连接:

ssize_t bytes_received = recvfrom(udp_socket, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_address, &client_address_len);

if (bytes_received == -1)
{
    perror("Error receiving data");
    continue;
}

// 查找客户端是否已存在
int client_index = -1;
for (int i = 0; i < client_count; ++i)
{
    if (memcmp(&client_address, &clients[i].address, sizeof(struct sockaddr_in)) == 0)
    {
        client_index = i;
        break;
    }
}

// 如果是新客户端,则添加到客户端列表
if (client_index == -1)
{
    if (client_count < MAX_CLIENTS)
    {
        clients[client_count].address = client_address;
        clients[client_count].len = client_address_len;
        clients[client_count].socket = udp_socket;
        client_index = client_count;
        printf("new client:(%s:%d)\n", inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
        client_count++;
    }
    else
    {
        printf("聊天室已满,拒绝新连接\n");
    }
}

最后将其中一个客户端发来的消息转发给其它所有已经建立连接的客户端:

buffer[bytes_received] = '\0';
for (int i = 0; i < client_count; ++i)
{
    if (i != client_index)
    {
        sendto(udp_socket, buffer, bytes_received, 0, (struct sockaddr*)&clients[i].address, clients[i].len);
    }
}

2.3 实验结果

在这里插入图片描述

可以看到,客户端通过发送一个消息与服务端建立连接,建立连接后的所有客户端发送的消息,在服务端收到后会广播给所有的客户端,这样就相当于多个客户端之间的一个聊天室。

2.4 完整代码

1.客户端

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

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8080
#define MAX_BUFFER_SIZE 1024

int main() {
    int udp_socket;
    struct sockaddr_in server_address;
    char buffer[MAX_BUFFER_SIZE];

    // 创建UDP套接字
    if ((udp_socket = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("Error creating socket");
        return EXIT_FAILURE;
    }

    // 设置服务器地址结构
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = inet_addr(SERVER_IP);
    server_address.sin_port = htons(SERVER_PORT);

    printf("UDP聊天室客户端启动,连接到服务器 %s:%d\n", SERVER_IP, SERVER_PORT);

    fd_set read_fds;
    struct timeval timeout;

    while (1) {
        FD_ZERO(&read_fds);
        FD_SET(STDIN_FILENO, &read_fds);
        FD_SET(udp_socket, &read_fds);
        // 使用 select 监听 stdin 和 udp_socket
        int activity = select(udp_socket + 1, &read_fds, NULL, NULL, NULL);

        if (activity < 0) {
            perror("Error in select");
            break;
        }

        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            // 从 stdin 读取用户输入
            fgets(buffer, sizeof(buffer), stdin);
            buffer[strlen(buffer) - 1] = '\0';  // 移除末尾的换行符
            // 发送消息给服务器
            sendto(udp_socket, buffer, strlen(buffer), 0, (struct sockaddr*)&server_address, sizeof(server_address));
        }

        if (FD_ISSET(udp_socket, &read_fds)) {
            // 接收服务器消息
            ssize_t bytes_received = recvfrom(udp_socket, buffer, sizeof(buffer), 0, NULL, NULL);

            if (bytes_received == -1) {
                perror("Error receiving data");
                break;
            } else if (bytes_received == 0) {
                // 服务器断开连接
                printf("server closed\n");
                break;
            }

            buffer[bytes_received] = '\0';
            printf("%s\n", buffer);
        }
    }

    close(udp_socket);
    return EXIT_SUCCESS;
}

2.服务端

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

#define PORT 8080
#define MAX_CLIENTS 10
#define MAX_BUFFER_SIZE 1024

struct Client {
    struct sockaddr_in address;
    socklen_t len;
    int socket;
};

int main() {
    int udp_socket;
    struct sockaddr_in server_address, client_address;
    socklen_t client_address_len = sizeof(client_address);
    char buffer[MAX_BUFFER_SIZE];

    struct Client clients[MAX_CLIENTS];
    int client_count = 0;

    // 创建UDP套接字
    if ((udp_socket = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("Error creating socket");
        return EXIT_FAILURE;
    }

    // 设置服务器地址结构
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = INADDR_ANY;
    server_address.sin_port = htons(PORT);

    // 将套接字绑定到服务器地址和端口
    if (bind(udp_socket, (struct sockaddr*)&server_address, sizeof(server_address)) == -1) {
        perror("Error binding socket");
        close(udp_socket);
        return EXIT_FAILURE;
    }

    printf("UDP聊天室服务端启动,监听端口 %d\n", PORT);

    while (1) {
        // 接收客户端消息
        ssize_t bytes_received = recvfrom(udp_socket, buffer, sizeof(buffer), 0, (struct sockaddr*)&client_address, &client_address_len);

        if (bytes_received == -1) {
            perror("Error receiving data");
            continue;
        }

        // 查找客户端是否已存在
        int client_index = -1;
        for (int i = 0; i < client_count; ++i) {
            if (memcmp(&client_address, &clients[i].address, sizeof(struct sockaddr_in)) == 0) {
                client_index = i;
                break;
            }
        }

        // 如果是新客户端,则添加到客户端列表
        if (client_index == -1) {
            if (client_count < MAX_CLIENTS) {
                clients[client_count].address = client_address;
                clients[client_count].len = client_address_len;
                clients[client_count].socket = udp_socket;
				client_index = client_count;
                printf("new client:(%s:%d)\n", inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port));
                client_count++;
            } else {
                printf("聊天室已满,拒绝新连接\n");
            }
        }
        // 转发消息给所有其他客户端
        buffer[bytes_received] = '\0';
        for (int i = 0; i < client_count; ++i) {
            if (i != client_index) {
                sendto(udp_socket, buffer, bytes_received, 0, (struct sockaddr*)&clients[i].address, clients[i].len);
            }
        }
        printf("来自 (%s:%d) 的消息:%s\n", inet_ntoa(client_address.sin_addr), ntohs(client_address.sin_port), buffer);
    }

    close(udp_socket);
    return EXIT_SUCCESS;
}

3 UDP进阶注意事项

3.1 如何判断对端断开

在UDP中,由于其面向无连接的特性,没有明确的连接状态,因此在传统的意义上无法像TCP一样直接判断连接是否断开。UDP是无连接、无状态的协议,它不维护连接状态,也不会报告连接断开的事件。

我们可以通过收发数据的过程中发生的错误来判断连接状态。如果你在发送数据时遇到错误(如sendto返回 -1),这可能意味着对方无法接收数据,但这并不一定意味着连接已经断开,只是当前无法发送数据而已。

一个常见的方法是在应用层定义一种心跳机制,定期向对端发送一些特殊的数据包,如果长时间没有收到对端的响应,可以认为连接可能已经断开。

  • 在TCP中readrecv返回0的时候表示连接断开,但在UDP中recvfrom返回0时并不代表连接断开,可能只是对端发了一个空的数据

3.2 UDP报文丢失、重复、乱序、流量控制

在UDP协议下,确实可能会发生报文丢失、重复和乱序的情况。UDP是一种无连接的协议,它不提供可靠性,因此没有内建的机制来保证数据的完整性、顺序性和不重复性。

  1. 报文丢失:

    • 原因: UDP不保证报文的可靠传输,因此报文可能在传输过程中丢失。
    • 解决方案: 应用层可以通过在协议中实现重传机制或者使用应用层的确认机制来处理丢失的报文。
  2. 报文重复:

    • 原因: 在网络中,可能由于网络拥塞、重传机制等原因导致报文重复。
    • 解决方案: 应用层可以通过在报文中添加唯一标识符,并在接收端进行去重处理来避免重复的问题。
  3. 报文乱序:

    • 原因: 报文在传输过程中可能会因为经过不同的网络路径而导致乱序。

    • 解决方案: 在报文中添加序列号,并在接收端进行排序操作,将乱序的报文按序组装成完整的数据。

  4. 无流量控制:

    流量控制通常用于防止发送方发送速率过快,导致网络拥塞或接收方无法及时处理数据的情况。TCP通过滑动窗口机制,发送方可以根据接收方的处理能力来动态调整发送速率。

    在UDP中,由于缺少流量控制机制,发送方会以最大速率发送数据,而无法感知网络的拥塞状况。这可能导致一些问题,例如在网络状况不佳时,UDP发送的数据可能会导致丢包,而且无法自动适应网络状况。

应用层可以实现自定义的协议,通过添加额外的信息,如确认机制、重传机制、序列号等来确保数据的可靠性和顺序性。当然,UDP并不适合所有场景的协议,如果对数据的可靠性要求较高,建议TCP。

3.3 UDP数据被丢弃

在UDP中,如果接收端的缓冲区大小无法容纳整个UDP数据包,多出来的数据将被丢弃。修改一下前面的客户端代码的recvfrom中的参数,每次只接收8个字节:

在这里插入图片描述

来看看效果:

在这里插入图片描述

3.4 connect判断对端是否存在

在我们调用sendto发送数据的时候,即使对端不存在,sendto也会返回成功,这是因为这个函数仅仅是完成了将数据拷贝到套接字的过程。**那如果对端不存在的话,我们有没有什么方法可以判断呢?**我们可以使用connect函数。

在UDP通信中,connect函数通常用于绑定本地UDP套接字到指定的远程地址和端口。这与TCP中的connect 有所不同,因为UDP是无连接的协议,connect并不会像在TCP中一样建立连接。在UDP中,connect主要有两个作用:

  1. 指定默认的目标地址和端口: 通过使用 connect函数,你可以在UDP套接字上指定一个默认的目标地址和端口。这样,在使用 send 函数时,就不需要每次都指定目标地址和端口了。这对于一直与同一个目标通信的UDP套接字而言是方便的。
  2. 用于错误检测和筛选数据包: 虽然connect并不会建立连接,但它可以用于检测目标是否可达。当你使用 connect后,如果目标地址不可达,后续的send操作可能会返回错误。这在某种程度上可以用来判断对端是否存在。但需要注意,UDP的特性仍然存在,即使使用了connectsend仍然是无阻塞的,而不会等待连接的建立。

下面来更改一下代码:

在这里插入图片描述

增加connect,然后sendto可以改为send进行发送,此时如果服务端不存在的话,recvfrom会返回-1,errno的值为ECONNREFUSED,表示连接被拒绝。这样我们就知道对端不存在了。

在这里插入图片描述

这个的原理实际上是当数据无法到达的时候,TCP协议栈会产生一个ICMP错误发给客户端,客户端需要在recvfrom中处理。

猜你喜欢

转载自blog.csdn.net/tilblackout/article/details/134609023