UDP Socket 详解:从入门到上手实战

目录

一、引言

[UDP 就是对讲机,TCP 就是电话。](#UDP 就是对讲机,TCP 就是电话。)

[二、UDP 协议基础](#二、UDP 协议基础)

[2.1 协议栈中的位置](#2.1 协议栈中的位置)

[2.2 UDP 的特点](#2.2 UDP 的特点)

[2.3 UDP 数据报结构](#2.3 UDP 数据报结构)

[三、什么是 UDP Socket](#三、什么是 UDP Socket)

[3.1 Socket 的本质](#3.1 Socket 的本质)

[3.2 UDP Socket vs TCP Socket](#3.2 UDP Socket vs TCP Socket)

[3.3 核心函数一览](#3.3 核心函数一览)

[四、UDP Socket 核心操作流程](#四、UDP Socket 核心操作流程)

[4.1 服务端步骤](#4.1 服务端步骤)

[4.2 客户端步骤](#4.2 客户端步骤)

[五、C++ 代码示例](#五、C++ 代码示例)

[5.1 服务端代码:udp_echo_server.cpp](#5.1 服务端代码:udp_echo_server.cpp)

[5.2 客户端代码:udp_echo_client.cpp](#5.2 客户端代码:udp_echo_client.cpp)

[5.3 编译与运行](#5.3 编译与运行)

[六、UDP Socket 的关键特性与坑点](#六、UDP Socket 的关键特性与坑点)

[6.1 无连接导致的"假成功"](#6.1 无连接导致的“假成功”)

[6.2 数据边界与 recvfrom 的对应关系](#6.2 数据边界与 recvfrom 的对应关系)

[6.3 数据大小限制](#6.3 数据大小限制)

[6.4 丢包与乱序](#6.4 丢包与乱序)

七、典型应用场景

八、常见问题与优化建议

[8.1 丢包如何感知与处理](#8.1 丢包如何感知与处理)

[8.2 接收缓冲区调整](#8.2 接收缓冲区调整)

[8.3 简单"可靠 UDP"思路说明](#8.3 简单“可靠 UDP”思路说明)

九、总结


一、引言

想象一下,你手里有两台设备:一台对讲机,一台电话。对讲机的好处是------你按下通话键就能直接喊话,不需要先拨号、等待对方接听。但坏处也很明显:你无法确认对方是否真的听到了,可能信号不好,可能对方根本没开机。电话则相反:你需要先建立连接(拨号、等待接通),通话过程中双方都能确认对方在线,但建立连接的过程需要时间,且协议开销更大。

UDP 就是对讲机,TCP 就是电话。

特性 UDP TCP
连接方式 无连接 面向连接
可靠性 不可靠(可能丢包、乱序) 可靠(确认、重传、排序)
数据边界 保留报文边界 字节流
开销 低(8字节头部) 高(20字节头部 + 连接管理)
传输效率 相对较低

本文的目标是:从零开始,掌握 Linux 下 UDP Socket 编程的核心原理与实战技巧。我会从协议基础讲起,给出完整的 C++ 代码示例,并深入剖析那些新手最容易踩的坑------比如"发送成功但对方没收到"的假象、数据边界陷阱、MTU 限制等。最后,我们还会探讨如何在应用层构建一个简单的"可靠 UDP"。


二、UDP 协议基础

2.1 协议栈中的位置

UDP(User Datagram Protocol)位于传输层,直接构建在 IP 层之上。它的协议号是 17。

应用层 ------ HTTP / DNS / NTP / 你的程序

传输层 ------ UDP(8字节头部)

网络层 ------ IP(20字节头部)

链路层 ------ Ethernet(14字节头部)

2.2 UDP 的特点

  • 无连接:发送数据前不需要握手,直接发。这意味着一方可以随时退出,另一方完全不知情。
  • 不可靠:数据报可能丢失、重复、乱序,UDP 本身不提供任何保证。
  • 面向报文 :应用层每次 sendto 对应一个完整的 UDP 数据报,接收方 recvfrom 一次读取一个完整报文。不会像 TCP 那样拆成字节流。
  • 低开销:头部仅 8 字节(源端口、目的端口、长度、校验和),没有 TCP 的序列号、确认号、窗口等复杂字段。

2.3 UDP 数据报结构

  • 长度:包含头部在内的总长度,最小 8(无数据),最大 65535。
  • 校验和:可选(IPv4 下可以置 0,但建议启用),覆盖头部和数据。

三、什么是 UDP Socket

3.1 Socket 的本质

Socket 是操作系统提供的一个抽象接口,让应用层能够通过文件描述符来收发网络数据。在 Linux 中,一切皆文件 ------Socket 也不例外,你可以像操作文件一样 readwriteclose 它。

3.2 UDP Socket vs TCP Socket

创建方式不同:

cpp 复制代码
// TCP Socket
int tcp_sock = socket(AF_INET, SOCK_STREAM, 0);

// UDP Socket
int udp_sock = socket(AF_INET, SOCK_DGRAM, 0);

SOCK_DGRAM 表示数据报套接字,对应 UDP;SOCK_STREAM 表示流式套接字,对应 TCP。

3.3 核心函数一览

函数 作用 备注
socket() 创建套接字 返回文件描述符
bind() 绑定本地地址和端口 服务端必须调用
sendto() 发送数据报 需要指定目标地址
recvfrom() 接收数据报 同时获取发送方地址
close() 关闭套接字 释放资源

UDP 不需要 listen()accept(),这是与 TCP 最大的区别之一。


四、UDP Socket 核心操作流程

4.1 服务端步骤

  1. socket() ------ 创建 UDP 套接字

  2. bind() ------ 绑定固定端口(必须!)

  3. recvfrom() ------ 阻塞等待客户端消息

  4. sendto() ------ 可选:回复客户端

  5. goto step 3 ------ 循环处理
    为什么服务端必须 bind?

因为客户端需要知道服务端的 IP 和端口才能发送数据。如果不 bind,操作系统会随机分配一个临时端口,客户端无法提前知晓。

4.2 客户端步骤

  1. socket() ------ 创建 UDP 套接字

  2. sendto() ------ 发送数据给服务端(指定目标地址)

  3. recvfrom() ------ 可选:等待服务端回复

  4. close() ------ 关闭套接字
    为什么客户端通常不 bind?

客户端不需要固定端口,操作系统会在第一次 sendto() 时自动分配一个临时端口(ephemeral port)。如果客户端手动 bind 一个固定端口,反而可能与其他应用冲突。


五、C++ 代码示例

下面给出一个完整的 UDP Echo Server(回显服务器)和对应的客户端。代码使用 C++17 标准,兼容 Linux。

5.1 服务端代码:udp_echo_server.cpp

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

constexpr int PORT = 8888;
constexpr int BUFFER_SIZE = 1024;

class UdpEchoServer {
public:
    UdpEchoServer() : sockfd_(-1) {}

    ~UdpEchoServer() {
        if (sockfd_ != -1) {
            close(sockfd_);
        }
    }

    bool init() {
        // 1. 创建 socket
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0) {
            perror("socket");
            return false;
        }

        // 2. 绑定地址
        struct sockaddr_in addr;
        std::memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(PORT);
        addr.sin_addr.s_addr = INADDR_ANY;  // 绑定所有网卡

        if (bind(sockfd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            perror("bind");
            return false;
        }

        std::cout << "UDP Echo Server started on port " << PORT << std::endl;
        return true;
    }

    void start() {
        char buffer[BUFFER_SIZE];
        struct sockaddr_in client_addr;
        socklen_t addr_len = sizeof(client_addr);

        while (true) {
            // 3. 接收数据
            ssize_t n = recvfrom(sockfd_, buffer, BUFFER_SIZE - 1, 0,
                                 (struct sockaddr*)&client_addr, &addr_len);
            if (n < 0) {
                perror("recvfrom");
                continue;
            }

            buffer[n] = '\0';
            char client_ip[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
            int client_port = ntohs(client_addr.sin_port);

            std::cout << "Received from " << client_ip << ":" << client_port
                      << " -> " << buffer << std::endl;

            // 4. 回显数据
            ssize_t sent = sendto(sockfd_, buffer, n, 0,
                                  (struct sockaddr*)&client_addr, addr_len);
            if (sent < 0) {
                perror("sendto");
            }
        }
    }

private:
    int sockfd_;
};

int main() {
    UdpEchoServer server;
    if (!server.init()) {
        return 1;
    }
    server.start();
    return 0;
}

5.2 客户端代码:udp_echo_client.cpp

cpp 复制代码
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

constexpr int PORT = 8888;
constexpr int BUFFER_SIZE = 1024;

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cerr << "Usage: " << argv[0] << " <message>" << std::endl;
        return 1;
    }

    // 1. 创建 socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

    // 2. 设置服务端地址
    struct sockaddr_in server_addr;
    std::memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

    const char* message = argv[1];
    size_t len = std::strlen(message);

    // 3. 发送数据
    ssize_t sent = sendto(sockfd, message, len, 0,
                          (struct sockaddr*)&server_addr, sizeof(server_addr));
    if (sent < 0) {
        perror("sendto");
        close(sockfd);
        return 1;
    }

    std::cout << "Sent: " << message << std::endl;

    // 4. 接收回复
    char buffer[BUFFER_SIZE];
    struct sockaddr_in from_addr;
    socklen_t from_len = sizeof(from_addr);

    ssize_t n = recvfrom(sockfd, buffer, BUFFER_SIZE - 1, 0,
                         (struct sockaddr*)&from_addr, &from_len);
    if (n < 0) {
        perror("recvfrom");
    } else {
        buffer[n] = '\0';
        std::cout << "Received: " << buffer << std::endl;
    }

    close(sockfd);
    return 0;
}

5.3 编译与运行

cpp 复制代码
# 编译
g++ -std=c++17 -o udp_echo_server udp_echo_server.cpp
g++ -std=c++17 -o udp_echo_client udp_echo_client.cpp

# 运行(先启动服务端)
./udp_echo_server

# 另一个终端运行客户端
./udp_echo_client "Hello, UDP!"

预期输出:

服务端:

cpp 复制代码
UDP Echo Server started on port 8888
Received from 127.0.0.1:xxxxx -> Hello, UDP!

客户端:

cpp 复制代码
Sent: Hello, UDP!
Received: Hello, UDP!

六、UDP Socket 的关键特性与坑点

6.1 无连接导致的"假成功"

这是 UDP 最大的陷阱。sendto() 返回成功只表示数据已经交给了内核协议栈 ,绝不代表对方收到了。如果对方没有运行服务端,或者网络不通,sendto() 依然会返回成功。

后果 :你的程序以为发送成功了,实际上数据在半路就丢了。这在 TCP 中不会发生------TCP 的 send() 成功至少意味着数据进入了对方的接收缓冲区。

处理建议:应用层必须实现确认机制(ACK),否则你永远不知道对方是否收到。

6.2 数据边界与 recvfrom 的对应关系

UDP 是面向报文的:一次 sendto 对应一次 recvfrom

  • 如果发送方发送了 100 字节,接收方调用 recvfrom 时,缓冲区至少需要 100 字节,否则数据会被截断(剩余部分丢弃)。
  • 如果接收方缓冲区很大(比如 65536),一次 recvfrom 只会读取一个完整的数据报,不会读到多个。

与 TCP 的对比 :TCP 是字节流,没有消息边界。你 send 了两次 100 字节,对方可能一次 recv 就收到 200 字节,也可能分两次各收 100 字节。

6.3 数据大小限制

UDP 数据报的最大理论负载是 65535 - 20(IP 头部) - 8(UDP 头部) = 65507 字节

但在实际网络中,MTU(最大传输单元) 是一个更严格的限制。以太网的 MTU 通常是 1500 字节,减去 IP 头部(20)和 UDP 头部(8),留给应用层的最大安全值是 1472 字节

数据大小 风险
< 1472 字节 安全,不会 IP 分片
1472 ~ 65507 可能触发 IP 分片,增加丢包风险
> 65507 直接失败

工程建议:应用层数据尽量控制在 1400 字节以内,避免 IP 分片。

6.4 丢包与乱序

UDP 不保证数据报按序到达,也不保证不丢失。在高负载或网络拥塞时,丢包和乱序是常态。

处理建议

  • 每个数据报携带序列号(sequence number),接收方据此检测丢包和乱序。
  • 实现应用层 ACK 和重传机制(见第八节)。

七、典型应用场景

UDP 虽然"不可靠",但它的低延迟和简单性使其在以下场景中无可替代:

场景 为什么用 UDP
DNS 查询 一次请求一次响应,简单快速,丢包就重试
音视频通话 / 直播 允许少量丢包,但延迟必须低(TCP 重传会导致卡顿)
NTP 时间同步 精确时间同步,UDP 延迟更可控
广播 / 组播 UDP 原生支持广播和组播,TCP 不支持
在线游戏 位置同步、状态更新等,延迟敏感,丢包可容忍
物联网 / 传感器 设备资源有限,UDP 开销小

八、常见问题与优化建议

8.1 丢包如何感知与处理

UDP 本身不提供丢包通知,必须由应用层实现。

最简单的可靠 UDP 思路

cpp 复制代码
发送方:
  1. 发送数据报(携带序列号 seq)
  2. 启动定时器(比如 500ms)
  3. 等待 ACK(确认包,携带相同的 seq)
  4. 超时未收到 ACK → 重传

接收方:
  1. 收到数据报
  2. 发送 ACK(携带相同的 seq)
  3. 如果收到重复的 seq(重传导致),直接丢弃数据,但仍需回复 ACK

注意:这个方案只是"尽力可靠",无法完全保证(比如网络断开、接收方崩溃等)。真正的可靠传输需要更复杂的协议(如 QUIC 或 KCP)。

8.2 接收缓冲区调整

UDP 的接收缓冲区默认大小可能不够(特别是高并发场景),可以通过 setsockopt 调整:

cpp 复制代码
int recv_buf_size = 1024 * 1024;  // 1MB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));

同理,发送缓冲区也可以调整:

cpp 复制代码
int send_buf_size = 1024 * 1024;
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &send_buf_size, sizeof(send_buf_size));

8.3 简单"可靠 UDP"思路说明

上面提到的 ACK + 超时重传是最基础的方案。更完善的方案还包括:

  • 滑动窗口:允许连续发送多个数据报,提高吞吐量。
  • 选择性重传:只重传丢失的包,而不是从丢失点开始全部重传。
  • 拥塞控制:检测网络拥塞,动态调整发送速率。

这些思想构成了 KCPQUIC 等协议的基础。如果你需要在 UDP 上实现可靠传输,建议直接使用成熟的库(如 KCP),而不是自己从头造轮子。


九、总结

UDP Socket 编程的核心要点:

  1. 创建socket(AF_INET, SOCK_DGRAM, 0)
  2. 服务端必须 bind,客户端通常不 bind
  3. sendto / recvfrom 是核心收发函数,注意数据边界
  4. sendto 成功 ≠ 对方收到,应用层需要 ACK 机制
  5. 数据大小建议 < 1472 字节,避免 IP 分片
  6. UDP 适合延迟敏感、可容忍丢包的场景,如音视频、游戏、DNS

UDP 是一把双刃剑------它简单高效,但也把可靠性责任完全交给了应用层。理解它的特性和局限,才能写出健壮的网络程序。