目录
[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 也不例外,你可以像操作文件一样
read、write、close它。
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 服务端步骤
socket() ------ 创建 UDP 套接字
bind() ------ 绑定固定端口(必须!)
recvfrom() ------ 阻塞等待客户端消息
sendto() ------ 可选:回复客户端
goto step 3 ------ 循环处理
为什么服务端必须 bind?因为客户端需要知道服务端的 IP 和端口才能发送数据。如果不 bind,操作系统会随机分配一个临时端口,客户端无法提前知晓。
4.2 客户端步骤
socket() ------ 创建 UDP 套接字
sendto() ------ 发送数据给服务端(指定目标地址)
recvfrom() ------ 可选:等待服务端回复
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!"
预期输出:
服务端:
cppUDP Echo Server started on port 8888 Received from 127.0.0.1:xxxxx -> Hello, UDP!客户端:
cppSent: 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调整:
cppint recv_buf_size = 1024 * 1024; // 1MB setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));同理,发送缓冲区也可以调整:
cppint send_buf_size = 1024 * 1024; setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &send_buf_size, sizeof(send_buf_size));
8.3 简单"可靠 UDP"思路说明
上面提到的 ACK + 超时重传是最基础的方案。更完善的方案还包括:
- 滑动窗口:允许连续发送多个数据报,提高吞吐量。
- 选择性重传:只重传丢失的包,而不是从丢失点开始全部重传。
- 拥塞控制:检测网络拥塞,动态调整发送速率。
这些思想构成了 KCP 、QUIC 等协议的基础。如果你需要在 UDP 上实现可靠传输,建议直接使用成熟的库(如 KCP),而不是自己从头造轮子。
九、总结
UDP Socket 编程的核心要点:
- 创建 :
socket(AF_INET, SOCK_DGRAM, 0)- 服务端必须 bind,客户端通常不 bind
- sendto / recvfrom 是核心收发函数,注意数据边界
- sendto 成功 ≠ 对方收到,应用层需要 ACK 机制
- 数据大小建议 < 1472 字节,避免 IP 分片
- UDP 适合延迟敏感、可容忍丢包的场景,如音视频、游戏、DNS
UDP 是一把双刃剑------它简单高效,但也把可靠性责任完全交给了应用层。理解它的特性和局限,才能写出健壮的网络程序。
