MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言UDP编程的实用技巧

2024-08-221.7k 阅读

UDP 编程基础

UDP(User Datagram Protocol)即用户数据报协议,是一种无连接的传输层协议。与 TCP 相比,UDP 不保证数据的可靠传输,没有确认机制、重传机制,也不保证数据按序到达。但 UDP 具有简单、高效的特点,适用于对实时性要求较高,对数据准确性要求相对较低的场景,如实时视频流、音频流传输等。

在 Linux 环境下进行 C 语言的 UDP 编程,主要涉及以下几个系统调用:

  1. socket():创建一个套接字描述符,用于后续的网络通信操作。其函数原型为:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

对于 UDP 编程,domain 通常设置为 AF_INET(表示 IPv4 协议),type 设置为 SOCK_DGRAM(表示 UDP 套接字类型),protocol 一般设为 0,表示使用默认协议(UDP)。例如:

int sockfd;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}
  1. bind():将套接字绑定到一个特定的地址和端口上。函数原型为:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

其中,sockfd 是由 socket() 创建的套接字描述符,addr 是一个指向 struct sockaddr 结构体的指针,addrlen 是地址结构体的长度。对于 IPv4,struct sockaddr 通常被 struct sockaddr_in 替代,struct sockaddr_in 定义如下:

struct sockaddr_in {
    sa_family_t    sin_family; /* 地址族,AF_INET */
    in_port_t      sin_port;   /* 端口号 */
    struct in_addr sin_addr;   /* IPv4 地址 */
    unsigned char  sin_zero[8];/* 填充字段,使其与 struct sockaddr 大小相同 */
};

struct in_addr 定义如下:

struct in_addr {
    in_addr_t s_addr; /* 32 位 IPv4 地址 */
};

绑定示例代码如下:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
memset(&cliaddr, 0, sizeof(cliaddr));
// 填充服务器端地址结构
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
servaddr.sin_addr.s_addr = INADDR_ANY;
// 绑定套接字到地址和端口
if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. sendto():用于向指定的地址发送数据。函数原型为:
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

buf 是要发送的数据缓冲区指针,len 是数据长度,flags 通常设为 0,dest_addr 是目标地址,addrlen 是目标地址长度。 4. recvfrom():用于从指定的地址接收数据。函数原型为:

#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

buf 是接收数据的缓冲区指针,len 是缓冲区长度,flags 通常设为 0,src_addr 用于存储源地址,addrlen 是源地址长度的指针(传入时为初始长度,返回时为实际地址长度)。

UDP 服务器端编程

下面是一个简单的 UDP 服务器端代码示例:

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

#define PORT    8080
#define MAXLINE 1024

// Driver code
int main() {
    int sockfd;
    char buffer[MAXLINE];
    char *hello = "Hello from server";
    struct sockaddr_in servaddr, cliaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 填充服务器端地址结构
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定套接字到地址和端口
    if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    int len, n;
    len = sizeof(cliaddr);
    // 接收数据
    n = recvfrom(sockfd, (char *)buffer, MAXLINE, MSG_WAITALL, (struct sockaddr *) &cliaddr, &len);
    buffer[n] = '\0';
    printf("Client : %s\n", buffer);
    // 发送数据
    sendto(sockfd, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *) &cliaddr, len);
    printf("Hello message sent.\n");
    close(sockfd);
    return 0;
}

在上述代码中,首先创建了一个 UDP 套接字,然后绑定到指定的端口。接着通过 recvfrom() 接收客户端发送的数据,并使用 sendto() 向客户端发送响应数据。

UDP 客户端编程

下面是一个简单的 UDP 客户端代码示例:

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

#define PORT    8080
#define MAXLINE 1024

// Driver code
int main() {
    int sockfd;
    char buffer[MAXLINE];
    char *hello = "Hello from client";
    struct sockaddr_in servaddr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));

    // 填充服务器端地址结构
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr = INADDR_ANY;

    int n, len;
    // 发送数据
    sendto(sockfd, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *) &servaddr, sizeof(servaddr));
    printf("Hello message sent.\n");
    // 接收数据
    n = recvfrom(sockfd, (char *)buffer, MAXLINE, MSG_WAITALL, (const struct sockaddr *) &servaddr, &len);
    buffer[n] = '\0';
    printf("Server : %s\n", buffer);
    close(sockfd);
    return 0;
}

客户端代码同样先创建 UDP 套接字,然后向服务器发送数据,并接收服务器的响应。

UDP 编程实用技巧

  1. 设置套接字选项
    • SO_REUSEADDR:该选项允许在同一地址和端口上重新绑定套接字。在服务器程序重启时,如果之前绑定的端口没有完全释放,使用 SO_REUSEADDR 可以避免 bind() 失败。设置方法如下:
int opt = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
    perror("setsockopt SO_REUSEADDR failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  • SO_RCVBUF 和 SO_SNDBUF:这两个选项分别用于设置接收缓冲区和发送缓冲区的大小。适当调整缓冲区大小可以提高 UDP 数据传输的性能。例如,增大接收缓冲区可以减少数据丢失的可能性。设置接收缓冲区大小的示例如下:
int recvbuf = 1024 * 1024; // 1MB 接收缓冲区
if (setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recvbuf, sizeof(recvbuf)) < 0) {
    perror("setsockopt SO_RCVBUF failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
  1. 处理数据分包与组包 UDP 没有内置的机制来处理大数据的分包和组包。当要发送的数据量大于底层网络的 MTU(Maximum Transmission Unit,最大传输单元,通常以太网为 1500 字节)时,数据会被自动分包。在接收端,需要正确地处理这些分包数据,将其组装成完整的数据。

一种简单的方法是在应用层协议中定义数据包头,包头中包含数据总长度等信息。发送端根据 MTU 大小将数据分包,并在每个包的包头中填写该包在整个数据中的位置和总长度等信息。接收端根据包头信息将接收到的分包数据组装成完整的数据。

例如,定义如下简单的包头结构:

struct udp_packet_header {
    uint16_t total_length; // 整个数据的长度
    uint16_t packet_num;   // 当前包的序号
    uint16_t total_packets; // 总包数
};

发送端代码示例:

#define MTU 1472 // 考虑 UDP 头 8 字节和 IP 头 20 字节,减去后约为 1472 字节
char *data = "a very long string that needs to be fragmented";
size_t data_len = strlen(data);
struct udp_packet_header header;
header.total_length = data_len;
header.total_packets = (data_len + MTU - 1) / MTU;
for (int i = 0; i < header.total_packets; i++) {
    header.packet_num = i;
    char packet[MTU + sizeof(struct udp_packet_header)];
    memcpy(packet, &header, sizeof(struct udp_packet_header));
    size_t packet_data_len = (i == header.total_packets - 1)? data_len % MTU : MTU;
    memcpy(packet + sizeof(struct udp_packet_header), data + i * MTU, packet_data_len);
    sendto(sockfd, packet, packet_data_len + sizeof(struct udp_packet_header), MSG_CONFIRM, (const struct sockaddr *) &servaddr, sizeof(servaddr));
}

接收端代码示例:

char received_data[1024 * 1024]; // 假设最大接收数据量为 1MB
struct udp_packet_header received_header;
char packet[MTU + sizeof(struct udp_packet_header)];
int received_packets = 0;
while (received_packets < received_header.total_packets) {
    int n = recvfrom(sockfd, packet, MTU + sizeof(struct udp_packet_header), MSG_WAITALL, (const struct sockaddr *) &servaddr, &len);
    memcpy(&received_header, packet, sizeof(struct udp_packet_header));
    size_t packet_data_len = (received_header.packet_num == received_header.total_packets - 1)? received_header.total_length % MTU : MTU;
    memcpy(received_data + received_header.packet_num * MTU, packet + sizeof(struct udp_packet_header), packet_data_len);
    received_packets++;
}
received_data[received_header.total_length] = '\0';
printf("Received data: %s\n", received_data);
  1. UDP 广播与多播
    • 广播:UDP 支持广播,即向网络中的所有主机发送数据。要发送广播数据,首先需要设置套接字的 SO_BROADCAST 选项,允许套接字发送广播消息。示例代码如下:
int opt = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &opt, sizeof(opt)) < 0) {
    perror("setsockopt SO_BROADCAST failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
struct sockaddr_in broadcast_addr;
memset(&broadcast_addr, 0, sizeof(broadcast_addr));
broadcast_addr.sin_family = AF_INET;
broadcast_addr.sin_port = htons(PORT);
broadcast_addr.sin_addr.s_addr = INADDR_BROADCAST;
sendto(sockfd, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *) &broadcast_addr, sizeof(broadcast_addr));
  • 多播:多播允许将数据发送到一组特定的主机(多播组)。在 IPv4 中,多播地址范围是 224.0.0.0239.255.255.255。要加入一个多播组,需要使用 setsockopt() 设置 IP_ADD_MEMBERSHIP 选项。示例代码如下:
struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.1"); // 多播组地址
mreq.imr_interface.s_addr = INADDR_ANY; // 本地接口地址
if (setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
    perror("setsockopt IP_ADD_MEMBERSHIP failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
// 发送数据到多播组
struct sockaddr_in multicast_addr;
memset(&multicast_addr, 0, sizeof(multicast_addr));
multicast_addr.sin_family = AF_INET;
multicast_addr.sin_port = htons(PORT);
multicast_addr.sin_addr.s_addr = inet_addr("224.0.0.1");
sendto(sockfd, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *) &multicast_addr, sizeof(multicast_addr));
  1. 错误处理 在 UDP 编程中,错误处理非常重要。除了在 socket()bind()sendto()recvfrom() 等函数调用后检查返回值并进行相应的错误处理外,还需要注意一些特殊情况。例如,sendto() 成功返回并不一定意味着数据已成功发送到目标主机,因为 UDP 没有确认机制。

可以通过设置 SO_ERROR 选项获取套接字上的错误信息。示例代码如下:

int error;
socklen_t errlen = sizeof(error);
if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &errlen) < 0) {
    perror("getsockopt SO_ERROR failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
if (error != 0) {
    printf("Socket error: %s\n", strerror(error));
}
  1. 性能优化
    • 使用异步 I/O:传统的 recvfrom()sendto() 是阻塞式的,会占用主线程。在高并发场景下,可以使用异步 I/O 机制,如 epoll 结合非阻塞套接字。首先将套接字设置为非阻塞:
int flags = fcntl(sockfd, F_GETFL, 0);
if (flags < 0) {
    perror("fcntl F_GETFL failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) < 0) {
    perror("fcntl F_SETFL O_NONBLOCK failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

然后使用 epoll 监听套接字事件:

int epollfd = epoll_create1(0);
if (epollfd < 0) {
    perror("epoll_create1 failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
struct epoll_event event;
event.data.fd = sockfd;
event.events = EPOLLIN | EPOLLOUT;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &event) < 0) {
    perror("epoll_ctl EPOLL_CTL_ADD failed");
    close(sockfd);
    close(epollfd);
    exit(EXIT_FAILURE);
}
struct epoll_event events[10];
int nfds = epoll_wait(epollfd, events, 10, -1);
for (int i = 0; i < nfds; i++) {
    if (events[i].events & EPOLLIN) {
        // 处理接收数据
        char buffer[MAXLINE];
        struct sockaddr_in cliaddr;
        socklen_t len = sizeof(cliaddr);
        int n = recvfrom(sockfd, buffer, MAXLINE, MSG_DONTWAIT, (struct sockaddr *) &cliaddr, &len);
        if (n > 0) {
            buffer[n] = '\0';
            printf("Received: %s\n", buffer);
        }
    } else if (events[i].events & EPOLLOUT) {
        // 处理发送数据
        char *hello = "Hello";
        struct sockaddr_in servaddr;
        memset(&servaddr, 0, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_port = htons(PORT);
        servaddr.sin_addr.s_addr = INADDR_ANY;
        sendto(sockfd, hello, strlen(hello), MSG_DONTWAIT, (const struct sockaddr *) &servaddr, sizeof(servaddr));
    }
}
  • 减少系统调用开销:尽量合并多次小数据的发送和接收操作,减少 sendto()recvfrom() 的调用次数。例如,可以使用缓冲区来累积数据,当缓冲区达到一定大小后再进行发送。

总结

通过掌握上述 Linux C 语言 UDP 编程的实用技巧,开发者可以编写出高效、可靠且适用于多种场景的 UDP 应用程序。从基本的套接字操作到高级的性能优化和特殊功能实现,每一个环节都需要仔细考虑和精心设计,以满足不同应用对 UDP 传输的需求。无论是开发实时通信软件、网络游戏,还是物联网设备间的通信,这些技巧都将发挥重要作用。在实际应用中,还需要根据具体的业务需求和网络环境进一步调整和优化代码,以达到最佳的性能和用户体验。