一、UDP 到底是什么?核心本质与特点
UDP(用户数据报协议)和 TCP 同属传输层协议,但设计理念完全相反。如果说 TCP 套接字像打专线电话 (先接通再说话、可靠有序、掉线有通知),那 UDP 套接字就像寄快递:
- 不用提前打招呼建立连接,填好收件地址直接发包
- 不保证对方一定收到、不保证顺序、丢包也不会通知你
- 好处是开销极小、延迟极低、灵活度高,是低延迟场景的首选
用合租公寓体系类比:
- TCP = 房间内线电话:先拨号确认对方在,再通话,内容保证传达到,顺序不乱
- UDP = 公寓快递站:包裹写清「楼栋号 + 房间号」(IP + 端口)直接寄,驿站不担保送达,也不保证先后,但想发就发,不用等接通
UDP 四大核心特性
- 无连接:没有三次握手、没有连接状态,收发双方不需要维护连接,启动就能发数据
- 不可靠:不保证送达、不保证顺序、不保证不重复,丢包不会自动重传,一切交给应用层处理
- 面向数据报 :有明确的消息边界,发一个包就是一个独立整体,接收方一次收一个完整包,不存在 TCP 的「粘包」问题
- 支持广播 / 多播:一个包可以同时发给多台设备,这是 TCP 做不到的能力
适用场景对比
表格
| 协议 | 核心优势 | 典型场景 |
|---|---|---|
| TCP | 可靠有序、字节流 | 文件传输、网页浏览、远程登录、支付接口 |
| UDP | 低延迟、低开销、灵活 | 直播、语音通话、在线游戏、DNS 查询、物联网数据上报 |
二、UDP Socket 编程全流程
UDP 因为没有「连接」概念,编程流程比 TCP 简单很多:没有 listen 监听、没有 accept 接客、没有强制的 connect 建连,全程围绕「发包 + 收包」两个动作展开。
服务端流程(固定地址的快递驿站)
- 创建 socket 文件描述符 → 购置一个快递收发柜
- 绑定 IP + 端口 → 给快递柜挂上固定门牌号,别人知道往哪寄
- 调用
recvfrom()接收数据 → 坐等快递上门,同时拿到寄件人地址 - 处理数据后调用
sendto()回复 → 根据寄件人地址回寄包裹 - 关闭 socket → 驿站关门
客户端流程(灵活寄件的用户)
- 创建 socket 文件描述符 → 获得寄件渠道
- (可选)绑定端口 → 一般不用,系统自动分配临时寄件端口
- 调用
sendto()直接发送数据 → 填好收件地址,直接发包 - 调用
recvfrom()接收回复 → 等待对方回包 - 关闭 socket
关键区别:TCP 服务端一个监听 socket 只能用来接客,每个客户端会生成新 socket 通信;而 UDP 全程只用一个 socket,靠每次收到的「对端地址」区分不同客户端,一个 socket 就能同时和成千上万台设备通信。
三、核心函数逐行详解
所有 UDP 编程都围绕 5 个核心函数展开,我们结合参数、作用、注意点逐一拆解。
1. socket ():创建套接字
拿到一个 socket 文件描述符,相当于拿到快递柜的使用权。
c
运行
cpp
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:地址族,IPv4 填AF_INET,IPv6 填AF_INET6,本机通信填AF_UNIXtype:套接字类型,UDP 固定填SOCK_DGRAM(数据报套接字),TCP 是SOCK_STREAMprotocol:协议,一般填 0,系统自动匹配对应协议- 返回值:成功返回非负文件描述符,失败返回 -1
2. bind ():绑定地址与端口
给 socket 绑定固定的 IP 地址和端口号,服务端必须调用,客户端一般不调用(系统自动分配临时端口)。
c
运行
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:socket 文件描述符addr:地址结构体,包含 IP 和端口addrlen:地址结构体的长度- 返回值:成功返回 0,失败返回 -1
必知:地址结构体与字节序
IPv4 场景下使用 struct sockaddr_in,使用前需要注意网络字节序转换:
- 网络传输统一用大端字节序,主机大多是小端,端口号必须用
htons()转成网络字节序 - IP 地址字符串要用
inet_addr()或inet_pton()转成网络字节序整数
c
运行
cpp
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(8888); // 端口号,转网络字节序
server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定本机所有网卡
3. recvfrom ():接收数据 + 获取对端地址
阻塞等待接收数据包,同时拿到发送方的地址,方便后续回复。
c
运行
cpp
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
buf:接收数据的缓冲区len:缓冲区最大长度flags:接收标志,普通场景填 0src_addr:输出参数,存发送方的 IP + 端口地址addrlen:输入输出参数,传入时是结构体大小,传出时是实际地址长度- 返回值:成功返回收到的字节数,失败返回 -1
注意:UDP 没有「连接断开」的概念,所以不会像 TCP 那样返回 0 表示对端关闭。
4. sendto ():指定地址发送数据
直接向指定目标地址发送数据包,不需要提前建立连接。
c
运行
cpp
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
buf:要发送的数据len:数据长度dest_addr:目标地址(IP + 端口)addrlen:地址结构体长度- 返回值:成功返回发送的字节数,失败返回 -1
关键提醒:
sendto成功返回,只代表数据成功放进了内核发送缓冲区,不代表对方已经收到。
5. close ():关闭套接字
和普通文件一样,用完调用 close(sockfd) 释放内核资源。
四、C 语言完整可运行示例
我们实现一个经典的 UDP 回声服务:客户端发送字符串,服务端收到后原封不动发回客户端,可直接编译运行。
服务端代码(udp_server.c)
c
运行
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUF_SIZE 1024
int main() {
// 1. 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 2. 填充服务端地址,绑定端口
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
close(sockfd);
exit(1);
}
printf("UDP服务端启动,监听端口 %d...\n", PORT);
char buf[BUF_SIZE];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
while (1) {
// 3. 接收客户端数据,同时获取客户端地址
memset(buf, 0, BUF_SIZE);
ssize_t recv_len = recvfrom(sockfd, buf, BUF_SIZE - 1, 0,
(struct sockaddr*)&client_addr, &client_len);
if (recv_len < 0) {
perror("recvfrom failed");
continue;
}
printf("收到客户端[%s:%d]消息: %s\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buf);
// 4. 把数据原封不动发回客户端
sendto(sockfd, buf, recv_len, 0,
(struct sockaddr*)&client_addr, client_len);
printf("已回复客户端\n");
}
close(sockfd);
return 0;
}
客户端代码(udp_client.c)
c
运行
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("用法: %s 服务端IP 端口号\n", argv[0]);
exit(1);
}
// 1. 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 2. 填充服务端地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(atoi(argv[2]));
if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) {
perror("invalid IP address");
close(sockfd);
exit(1);
}
char buf[BUF_SIZE];
socklen_t server_len = sizeof(server_addr);
printf("请输入要发送的消息(输入exit退出):\n");
while (1) {
printf("> ");
fgets(buf, BUF_SIZE, stdin);
buf[strcspn(buf, "\n")] = 0; // 去掉换行符
if (strcmp(buf, "exit") == 0) {
break;
}
// 3. 向服务端发送数据
sendto(sockfd, buf, strlen(buf), 0,
(struct sockaddr*)&server_addr, server_len);
// 4. 接收服务端回复
memset(buf, 0, BUF_SIZE);
ssize_t recv_len = recvfrom(sockfd, buf, BUF_SIZE - 1, 0, NULL, NULL);
if (recv_len < 0) {
perror("recvfrom failed");
continue;
}
printf("收到服务端回复: %s\n", buf);
}
close(sockfd);
return 0;
}
编译与运行方法
bash
运行
cpp
# 编译
gcc udp_server.c -o server
gcc udp_client.c -o client
# 终端1:启动服务端
./server
# 终端2:启动客户端(本机测试用127.0.0.1)
./client 127.0.0.1 8888
五、UDP 进阶核心知识点
1. 面向数据报 vs 字节流:最容易踩的坑
这是 UDP 和 TCP 最本质的区别:
- UDP 有明确消息边界 :调用两次
sendto各发 50 字节,接收方必须调用两次recvfrom,每次刚好收 50 字节,不会多也不会少,包与包之间独立。 - TCP 是无边界字节流 :调用两次
send各发 50 字节,接收方可能一次recv就收到 100 字节,也可能分多次收,数据是连续的流,没有包的概念。
结论:UDP 没有「粘包」问题,但要注意包的完整性;TCP 必须自己处理粘包。
2. UDP 也能调用 connect?有什么用?
UDP 是无连接协议,但确实可以调用 connect(),但不是建立真正的连接,只是在内核里给 socket 绑定一个固定的对端地址:
- 绑定后可以用
read/write/recv/send收发数据,不用每次传地址参数 - 内核会自动过滤掉其他地址发来的数据包,只接收绑定地址的数据
- 没有三次握手,没有连接状态,依然是无连接数据报
适合场景:客户端长期只和一个服务端通信,简化代码、提升安全性。
3. UDP 包的大小限制
- 理论最大值:UDP 首部长度字段占 16 位,整个包最大 65535 字节,去掉 8 字节 UDP 首部,数据最大 65527 字节。
- 工程建议:以太网 MTU 通常是 1500 字节,超过后 IP 层会分片,只要丢一个分片,整个 UDP 包就全部失效。因此生产环境一般把 UDP 数据包控制在 1400 字节以内,避免分片。
4. 怎么让 UDP 变得可靠?
UDP 本身不保证可靠,但可以在应用层自行实现可靠机制,本质就是用代码实现简化版 TCP:
- 给每个包加序号,接收方排序、去重
- 接收方收到包回 ACK 确认,发送方超时未收到就重传
- 实现流量控制、拥塞控制
典型案例:HTTP/3 的 QUIC 协议就是基于 UDP 实现的可靠传输,比 TCP 延迟更低、握手更快。
六、常见坑点汇总
- 缓冲区不足导致丢包 :
recvfrom缓冲区小于包大小时,多余数据会被直接丢弃,不会留到下一次读取。 - 字节序错误:端口号、IP 地址忘记转网络字节序,导致绑定失败、收不到数据。
- 误以为 sendto 成功就是送达:只代表数据进了内核缓冲区,网络差的时候丢包是常态。
- 多线程并发读写 :系统调用本身是原子的,但多线程同时
recvfrom会导致数据包随机分发,业务逻辑容易乱,建议单线程收包、多线程处理。 - UDP 无法感知对端退出:没有连接就没有断开通知,需要应用层自己做心跳包检测在线状态。
一句话总结
UDP Socket 的核心是「无连接、面向数据报的快递式通信」,牺牲了可靠性换来了极低的开销和极高的灵活性。只要抓住「每个包独立、不保证到达、靠地址定位收发方」这三个核心点,所有 UDP 的用法、特性和坑点就都能顺理成章地理解。
谢谢