3、UDP Socket编程:服务端创建流程(socket、bind、recvfrom)、客户端创建流程(socket、sendto)、无连接特性详解。

聊到UDP,很多人的第一反应是“不可靠”。这话没错,但不够全面。我做了这么多年网络编程,UDP其实是个很有意思的协议——它把控制权完全交给你,让你在性能和可靠性之间做取舍。说白了,TCP像个保姆,什么都帮你管好;UDP像个快递员,把包裹扔给你就走,剩下的你自己看着办。

这一章,咱们就手把手把UDP的服务端和客户端搭起来。你会发现,UDP的代码比TCP简洁不少,但坑也不少。

3.1 UDP服务端创建流程

UDP服务端的创建,核心就三步:创建套接字、绑定地址、接收数据。没有listen,没有accept。为什么?因为UDP是无连接的,服务端不需要为每个客户端维护一个独立的连接。

3.1.1 创建套接字:socket()

这一步和TCP几乎一样,只是协议类型换成SOCK_DGRAM。

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}

SOCK_DGRAM告诉内核:我要用UDP,数据报模式。第三个参数传0,让内核自动选择UDP协议。我个人习惯在这里显式检查返回值,因为很多嵌入式环境或者容器环境里,socket创建可能因为权限或资源限制失败。

小提示:UDP套接字也可以调用connect(),但它的语义和TCP完全不同。UDP的connect只是绑定对端地址,后续可以用send()/recv()代替sendto()/recvfrom(),本质上还是无连接的。这个技巧在需要频繁与同一对端通信时能省点事。

3.1.2 绑定地址:bind()

bind的作用是告诉内核:这个套接字监听哪个IP和端口。UDP服务端必须bind,否则客户端不知道把数据发到哪。

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
servaddr.sin_port = htons(8888);               // 端口号

if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

这里有个细节:INADDR_ANY表示监听本机所有IP地址。如果你只想让某个特定IP接收数据,可以用inet_addr("192.168.1.100")。我在项目中遇到过一个问题——服务器有两块网卡,一块内网一块外网,结果bind了外网IP,内网客户端死活连不上。嗯,后来改成INADDR_ANY就解决了。

3.1.3 接收数据:recvfrom()

这是UDP服务端的核心函数。它不光接收数据,还能告诉你数据是谁发的。

char buffer[1024];
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);

ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                     (struct sockaddr *)&cliaddr, &len);
if (n < 0) {
    perror("recvfrom error");
} else {
    buffer[n] = '\0';
    printf("Received %ld bytes from %s:%d\n", n,
           inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
    printf("Data: %s\n", buffer);
}

注意cliaddr这个参数。它是个输出参数,recvfrom返回时,里面填了客户端的IP和端口。这样你就知道该给谁回数据了。

注意:UDP的recvfrom一次只能读取一个数据报。如果缓冲区太小,数据会被截断,剩余部分直接丢弃。我曾经踩过这个坑——缓冲区设了100字节,客户端发了200字节,结果后半段数据人间蒸发了。所以,UDP缓冲区最好设大一点,至少MTU大小(1500字节)起步。

3.2 UDP客户端创建流程

UDP客户端更简单,两步走:创建套接字,然后直接发数据。不需要bind(虽然也可以bind),不需要connect。

3.2.1 创建套接字:socket()

和服务端一模一样:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}

3.2.2 发送数据:sendto()

sendto需要指定目标地址,因为UDP没有连接,内核不知道把数据发到哪。

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
servaddr.sin_port = htons(8888);

char *message = "Hello, UDP server!";
ssize_t n = sendto(sockfd, message, strlen(message), 0,
                   (struct sockaddr *)&servaddr, sizeof(servaddr));
if (n < 0) {
    perror("sendto error");
} else {
    printf("Sent %ld bytes to server\n", n);
}

你看,客户端不需要bind。内核会自动给客户端分配一个临时端口。这个端口是随机的,范围通常在32768到60999之间(Linux默认)。

小技巧:如果你想让客户端使用固定端口(比如防火墙只开放了某个端口),可以在sendto之前调用bind。UDP客户端bind的用法和服务端完全一样。

3.3 无连接特性详解

这是UDP和TCP最本质的区别。理解了这个,你就理解了UDP的灵魂。

3.3.1 什么叫“无连接”?

说白了,UDP在发送数据之前,不需要和对方建立一条专用的“通道”。你想想看,TCP发数据前要三次握手,维护连接状态,断开时要四次挥手。UDP呢?直接扔数据包,扔完就不管了。

我画了张图,帮你直观理解这个区别:

UDP 无连接 vs TCP 面向连接 UDP 无连接模型 客户端 服务端 数据报 数据报 TCP 面向连接模型 客户端 服务端 SYN SYN+ACK ACK 核心差异对比 • 连接建立:UDP 不需要握手,TCP 需要三次握手 • 状态维护:UDP 服务端不维护客户端状态,TCP 内核维护连接表 • 资源开销:UDP 几乎零开销,TCP 每个连接都要占用内存 • 并发能力:UDP 一个套接字可服务任意多客户端,TCP 需要多线程/多进程 • 数据边界:UDP 保留消息边界,TCP 是字节流无边界

3.3.2 无连接带来的特性

这个特性带来了一系列连锁反应,我列个表给你看:

特性 UDP TCP 影响
连接建立 不需要 三次握手 UDP延迟更低,适合实时应用
状态维护 服务端无状态 内核维护连接表 UDP服务端可轻松支持数十万客户端
数据边界 保留消息边界 字节流 UDP一次recvfrom正好是一个完整消息
可靠性 不保证 保证有序、不丢失 UDP需要应用层自己处理丢包
广播/组播 原生支持 不支持 UDP适合一对多场景

3.3.3 无连接的实战影响

我在实际项目中遇到过几个典型场景,跟你分享一下:

  • 场景一:DNS查询——DNS就是基于UDP的。客户端发一个查询包,服务端回一个响应包。如果丢包了,客户端超时重发。你看,应用层自己处理可靠性,UDP只负责传输。
  • 场景二:视频直播——直播允许丢几帧画面,但不能卡顿。用UDP的话,丢包了就直接跳过,画面花一下而已。如果用TCP,丢包重传会导致延迟飙升,体验更差。
  • 场景三:物联网传感器——温度传感器每秒上报一次数据。丢一两个包无所谓,反正下一秒还有新数据。用UDP省去了连接管理的开销,设备更省电。
避坑指南:UDP的无连接特性有个隐藏陷阱——端口耗尽。我曾经做过一个高并发UDP服务,客户端用短连接方式频繁发送数据。结果发现服务端日志里出现大量"Address already in use"错误。排查了半天,原来是客户端每次发送都创建新套接字,用完不关闭,导致临时端口被耗尽。解决方案很简单:复用同一个套接字,或者设置SO_REUSEADDR选项。

3.4 完整示例:UDP回声服务

把上面的知识点串起来,写一个完整的UDP回声服务。客户端发什么,服务端原样返回。

服务端代码:

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

int main() {
    int sockfd;
    struct sockaddr_in servaddr, cliaddr;
    char buffer[1024];
    socklen_t len;

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

    // 2. 绑定地址
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(8888);

    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind");
        close(sockfd);
        exit(1);
    }

    printf("UDP echo server listening on port 8888...\n");

    // 3. 循环接收并回显
    while (1) {
        len = sizeof(cliaddr);
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                             (struct sockaddr *)&cliaddr, &len);
        if (n < 0) {
            perror("recvfrom");
            continue;
        }

        buffer[n] = '\0';
        printf("Received: %s from %s:%d\n", buffer,
               inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));

        // 原样返回
        sendto(sockfd, buffer, n, 0,
               (struct sockaddr *)&cliaddr, len);
    }

    close(sockfd);
    return 0;
}

客户端代码:

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

int main() {
    int sockfd;
    struct sockaddr_in servaddr;
    char buffer[1024];
    socklen_t len;

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

    // 2. 设置服务端地址
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servaddr.sin_port = htons(8888);

    // 3. 发送数据
    char *message = "Hello, UDP!";
    sendto(sockfd, message, strlen(message), 0,
           (struct sockaddr *)&servaddr, sizeof(servaddr));

    printf("Sent: %s\n", message);

    // 4. 接收回声
    len = sizeof(servaddr);
    ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                         (struct sockaddr *)&servaddr, &len);
    if (n > 0) {
        buffer[n] = '\0';
        printf("Received echo: %s\n", buffer);
    }

    close(sockfd);
    return 0;
}
核心要点回顾:
  • UDP服务端:socket → bind → recvfrom(循环)
  • UDP客户端:socket → sendto(直接发)
  • 无连接意味着没有握手、没有状态、没有连接表
  • UDP保留消息边界,一次recvfrom就是一个完整数据报
  • 可靠性由应用层自己保证,UDP只负责尽力交付