第7章 基本UDP套接字编程
UDP编程
一、导读
7.1 本章学习目标
前6章聚焦于TCP编程,本章转向TCP/IP协议族的另一个重要成员——UDP。UDP看似简单(头部仅8字节),但它的编程模型与TCP有本质区别,理解这些差异对于编写正确的UDP程序至关重要。本章将系统讲解UDP套接字编程的核心API、特有的问题(如数据报丢失、乱序、重复)以及解决方案。
本章的核心学习目标包括:
- 掌握UDP套接字编程的基本API:
recvfrom/sendto与read/write的区别。 - 理解UDP的"connected"模式:如何用
connect为UDP套接字指定对端?有什么好处? - 处理UDP特有的问题:数据报丢失、乱序、重复、碎片化。
- 理解UDP数据报的长度限制:MTU、IP分片、推荐的最大数据报大小。
- 掌握UDP的客户/服务器模型:与TCP有何不同?
- 了解UDP的可靠传输方案:如何在UDP之上实现可靠性?
7.2 为什么本章如此重要
很多程序员认为"UDP就是不可靠的TCP",这种简化认识会导致严重的编程错误。UDP和TCP是两种完全不同的传输范式,各有其适用场景。
UDP的独特优势:
- 低延迟:没有连接建立/释放的开销,没有确认/重传的延迟
- 低开销:头部仅8字节(TCP最少20字节)
- 保留消息边界:每个sendto对应一个recvfrom(如果不丢失的话)
- 支持多播和广播:可以向多个目标同时发送数据
- 无拥塞控制:不会因为网络拥塞而自动降低发送速率(对实时应用是优势)
UDP的广泛应用:
- DNS查询/响应
- DHCP(动态主机配置)
- NTP(网络时间协议)
- SNMP(简单网络管理协议)
- 实时音视频(VoIP、视频会议)
- 在线游戏
- QUIC协议(HTTP/3的底层传输)
7.3 核心问题引导
在阅读本章时,请带着以下问题思考:
UDP的 sendto 和TCP的 write 有什么本质区别?
为什么UDP需要 recvfrom 而不是简单的 read?
UDP数据报超过MTU会发生什么?与TCP的处理方式有何不同?
为什么UDP客户通常不需要 bind,而服务器必须 bind?
UDP的"connected"模式是什么意思?它改变了UDP的哪些行为?
如何在UDP之上实现可靠传输?QUIC协议是怎么做的?
二、核心概念详解
7.1 UDP套接字的基本操作
7.1.1 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// SOCK_DGRAM 指定数据报套接字(UDP)
// protocol 通常为0,自动选择UDP协议7.1.2 sendto和recvfrom
UDP是无连接的,每次发送数据报都需要指定目的地址,每次接收数据报都能获取源地址。
#include <sys/socket.h>
// 发送UDP数据报
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// 接收UDP数据报
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);// UDP服务器示例
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
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(9999);
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
char buf[1024];
struct sockaddr_in cliaddr;
socklen_t clilen;
for (;;) {
clilen = sizeof(cliaddr);
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr *)&cliaddr, &clilen);
// buf中是完整的数据报(UDP保留消息边界)
// cliaddr中包含发送方的地址
// 回显给发送方
sendto(sockfd, buf, n, 0, (struct sockaddr *)&cliaddr, clilen);
}// UDP客户端示例
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(9999);
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
char sendline[] = "Hello UDP";
sendto(sockfd, sendline, strlen(sendline), 0,
(struct sockaddr *)&servaddr, sizeof(servaddr));
char recvline[1024];
struct sockaddr_in fromaddr;
socklen_t fromlen = sizeof(fromaddr);
ssize_t n = recvfrom(sockfd, recvline, sizeof(recvline), 0,
(struct sockaddr *)&fromaddr, &fromlen);
recvline[n] = '\0';
printf("Received: %s\n", recvline);7.1.3 sendto/recvfrom vs write/read
| 特性 | TCP (write/read) | UDP (sendto/recvfrom) |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 地址 | 连接时指定对端 | 每次sendto指定目的 |
| 消息边界 | 无(字节流) | 有(数据报) |
| 可靠性 | 可靠 | 不可靠 |
| 最小数据量 | 无限制 | 0字节也可以 |
| 最大数据量 | 无限制(受缓冲区限制) | 受MTU限制 |
UDP可以使用write/read吗? 可以,但必须先调用 connect(见7..3节)。
7.2 UDP数据报的长度问题
7.2.1 理论最大值
UDP数据报的长度字段是16位,最大值为65535。减去UDP头部8字节,UDP数据的理论最大值为65527字节。再加上IP头部20字节,IP数据报最大65535字节。
7.2.2 实际限制
实际中,UDP数据报的大小受以下因素限制:
MTU(最大传输单元):以太网MTU通常为1500字节。UDP数据报加上IP头部和UDP头部不能超过MTU,否则会被IP分片。
IP分片的风险:如果UDP数据报被分片,任一分片丢失,整个数据报都会丢失。分片越多,丢包概率越大。
重组缓冲区大小:IPv4要求主机至少能重组576字节的数据报。超过此大小可能在某些主机上无法重组。
推荐实践:
- 互联网应用:UDP数据报不超过512字节(DNS的标准限制)
- 局域网应用:不超过1472字节(1500 - 20 IP头 - 8 UDP头)
- 避免分片:如果可能,保持数据报小于路径MTU
// 检测路径MTU
// 方法1:使用ping
// ping -M do -s 1472 target_host
// 1472 + 8(UDP) + 20(IP) = 1500 (以太网MTU)
// 方法2:在代码中设置DF标志
int val = 1;
setsockopt(sockfd, IPPROTO_IP, IP_MTU_DISCOVER, &val, sizeof(val));
// 之后如果sendto的数据报超过路径MTU,会返回EMSGSIZE错误
// 方法3:使用较小的数据报
// 推荐:不超过512字节(互联网)或1472字节(局域网)7.2.3 UDP数据报截断
如果 recvfrom 的缓冲区小于到达的数据报,数据会被截断——多余的数据被丢弃,recvfrom 返回缓冲区大小。
char buf[10]; // 只有10字节
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);
// 如果数据报是100字节,n=10,buf中只有前10字节
// 剩余的90字节被丢弃!
// 解决方案:使用足够大的缓冲区
// 或者先使用MSG_PEEK获取数据报大小
char peek_buf[1];
ssize_t n = recvfrom(sockfd, peek_buf, sizeof(peek_buf), MSG_PEEK | MSG_TRUNC, NULL, NULL);
// MSG_TRUNC表示数据报被截断7.3 UDP的"connected"模式
7.3.1 为UDP套接字调用connect
虽然UDP是无连接的,但可以在UDP套接字上调用 connect,指定一个固定的对端地址。
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in servaddr;
// ... 设置servaddr ...
connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
// 之后可以使用write/read代替sendto/recvfrom
write(sockfd, "Hello", 5); // 等价于 sendto(sockfd, "Hello", 5, 0, &servaddr, ...)
char buf[1024];
read(sockfd, buf, sizeof(buf)); // 等价于 recvfrom(sockfd, buf, ..., &fromaddr, ...)7.3.2 connect改变了什么
对UDP套接字调用 connect 后:
可以使用write/read:不需要每次指定目的/源地址
只接收来自对端的数据:内核只交付来自connect指定地址的数据报,其他数据报被丢弃
ICMP错误会交付给应用:未connect的UDP套接字会忽略ICMP端口不可达错误;connect后,这些错误会导致下一次write/read返回ECONNREFUSED
// 未connect的UDP套接字
// 发送数据报到一个没有进程监听的端口
sendto(sockfd, "Hello", 5, 0, (struct sockaddr *)&addr, sizeof(addr));
// 目的主机返回ICMP Port Unreachable
// 但UDP套接字忽略此错误
// 下次sendto仍然成功(不会报告错误)
// connect后的UDP套接字
connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
write(sockfd, "Hello", 5);
// 目的主机返回ICMP Port Unreachable
// 下次write返回-1,errno=ECONNREFUSED7.3.3 多次connect
可以对同一个UDP套接字多次调用 connect:
- 第一次
connect:指定对端A - 第二次
connect:更改对端为B - 要断开连接:将地址族设为AF_UNSPEC
// 更改对端
struct sockaddr_in new_addr;
// ... 设置new_addr ...
connect(sockfd, (struct sockaddr *)&new_addr, sizeof(new_addr));
// 现在只接收来自new_addr的数据报
// 断开连接(Linux特有)
struct sockaddr addr;
addr.sa_family = AF_UNSPEC;
connect(sockfd, &addr, sizeof(addr));
// 之后可以重新connect到其他地址7.4 UDP客户/服务器模型
7.4.1 UDP服务器不需要accept
UDP服务器与TCP服务器的关键区别:
- TCP服务器:socket → bind → listen → accept → read/write → close
- UDP服务器:socket → bind → recvfrom/sendto(循环)
UDP没有连接的概念,不需要listen和accept。服务器从recvfrom获取客户地址,用sendto回复。
// UDP服务器
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bind(sockfd, ...);
char buf[1024];
struct sockaddr_in cliaddr;
socklen_t clilen;
for (;;) {
clilen = sizeof(cliaddr);
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr *)&cliaddr, &clilen);
// 处理请求
process_request(buf, n);
// 回复
sendto(sockfd, response, response_len, 0,
(struct sockaddr *)&cliaddr, clilen);
}7.4.2 UDP服务器的并发问题
UDP服务器默认是迭代的——一次处理一个请求。如果某个请求处理时间很长,其他请求就会等待。
解决方案:
每请求一个线程/进程:recvfrom后fork或创建线程处理
使用I/O多路复用:select/poll/epoll监控多个套接字
使用多个套接字:每个套接字绑定不同端口,分配给不同线程
// 每请求一个线程的UDP服务器
void *handle_request(void *arg) {
// 处理请求...
return NULL;
}
for (;;) {
clilen = sizeof(cliaddr);
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr *)&cliaddr, &clilen);
// 复制数据(因为buf是共享的)
char *data = malloc(n);
memcpy(data, buf, n);
pthread_t tid;
pthread_create(&tid, NULL, handle_request, data);
pthread_detach(tid);
}7.4.3 UDP客户不需要connect
UDP客户通常不需要调用 connect,直接使用 sendto/recvfrom 即可。但如7.3节所述,connect 在某些场景下很有用。
// 标准UDP客户
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 不需要bind(内核自动分配临时端口)
// 不需要connect
sendto(sockfd, request, request_len, 0,
(struct sockaddr *)&servaddr, sizeof(servaddr));
char buf[1024];
struct sockaddr_in fromaddr;
socklen_t fromlen = sizeof(fromaddr);
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr *)&fromaddr, &fromlen);7.5 UDP的可靠传输
7.5.1 问题
UDP不保证:
- 数据报到达(可能丢失)
- 数据报按序到达(可能乱序)
- 数据报不重复(可能重复)
- 流量控制(可能淹没接收方)
- 拥塞控制(可能淹没网络)
7.5.2 在UDP之上实现可靠性
许多应用协议在UDP之上实现了自己的可靠性机制:
TFTP(Trivial File Transfer Protocol):
发送方 接收方
│ 数据块1 (data 1) │
│ ─────────────────→ │
│ ←───────────────── │ ACK 1
│ 数据块2 (data 2) │
│ ─────────────────→ │
│ ←───────────────── │ ACK 2
│ ... │
// 如果发送方在一定时间内没有收到ACK,重传数据块
// 简单的停止等待协议QUIC(Quick UDP Internet Connections):
Google开发的传输层协议,HTTP/3的底层。在UDP之上实现了:
- 可靠传输(类似TCP的确认/重传)
- 多路复用(多个流共享一个连接,无队头阻塞)
- 连接迁移(使用Connection ID而不是四元组标识连接)
- 0-RTT连接建立(首次握手1-RTT,后续0-RTT)
- 前向纠错(FEC)
// QUIC的基本思想(伪代码)
// 每个数据报包含多个帧(Frame)
// 每个帧属于一个流(Stream)
// 每个流独立确认和重传
struct quic_packet {
uint32_t connection_id;
uint64_t packet_number;
struct frame frames[]; // 多个帧
};
struct frame {
uint32_t stream_id;
uint64_t offset;
uint16_t length;
uint8_t data[];
};7.5.3 简单的可靠UDP实现
// 简单的可靠UDP协议(停止等待ARQ)
#define TIMEOUT_MS 1000
#define MAX_RETRIES 5
struct rudp_header {
uint16_t seq; // 序列号
uint16_t ack; // 确认号
uint8_t flags; // 标志位
uint16_t length; // 数据长度
};
#define FLAG_DATA 0x01
#define FLAG_ACK 0x02
// 发送端
int reliable_send(int sockfd, const char *data, size_t len,
struct sockaddr *dest, socklen_t addrlen) {
char buf[1500];
struct rudp_header *hdr = (struct rudp_header *)buf;
uint16_t seq = 0;
size_t offset = 0;
while (offset < len) {
size_t chunk = len - offset;
if (chunk > 1400) chunk = 1400;
hdr->seq = seq;
hdr->flags = FLAG_DATA;
hdr->length = chunk;
memcpy(buf + sizeof(*hdr), data + offset, chunk);
int retries = 0;
while (retries < MAX_RETRIES) {
sendto(sockfd, buf, sizeof(*hdr) + chunk, 0, dest, addrlen);
// 等待ACK
struct timeval tv = {TIMEOUT_MS / 1000, (TIMEOUT_MS % 1000) * 1000};
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
char ack_buf[100];
socklen_t fromlen = addrlen;
ssize_t n = recvfrom(sockfd, ack_buf, sizeof(ack_buf), 0, dest, &fromlen);
if (n > 0) {
struct rudp_header *ack_hdr = (struct rudp_header *)ack_buf;
if (ack_hdr->flags == FLAG_ACK && ack_hdr->ack == seq) {
break; // 收到正确的ACK
}
}
retries++;
}
if (retries >= MAX_RETRIES) return -1; // 超时
offset += chunk;
seq++;
}
return 0;
}7.6 UDP多播
7.6.1 多播基本概念
UDP支持三种传输模式:
- 单播(Unicast):一对一
- 广播(Broadcast):一对所有(同一子网)
- 多播(Multicast):一对多(加入多播组的主机)
多播地址范围:224.0.0.0 到 239.255.255.255(D类IP地址)。
7.6.2 多播发送
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 设置多播TTL(默认为1,限制在本地子网)
int ttl = 32;
setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl));
// 发送多播数据
struct sockaddr_in multicast_addr;
memset(&multicast_addr, 0, sizeof(multicast_addr));
multicast_addr.sin_family = AF_INET;
multicast_addr.sin_addr.s_addr = inet_addr("239.255.0.1"); // 多播组
multicast_addr.sin_port = htons(5000);
sendto(sockfd, "Hello Multicast", 15, 0,
(struct sockaddr *)&multicast_addr, sizeof(multicast_addr));7.6.3 多播接收
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 绑定到多播端口
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(5000);
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
// 加入多播组
struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("239.255.0.1");
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));
// 接收多播数据
char buf[1024];
while (1) {
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);
buf[n] = '\0';
printf("Received: %s\n", buf);
}三、实战案例
7.1 简单的UDP回显服务器和客户端
// UDP回显服务器
int main(void) {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
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(9999);
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
printf("UDP echo server on port 9999\n");
char buf[1024];
struct sockaddr_in cliaddr;
socklen_t clilen;
for (;;) {
clilen = sizeof(cliaddr);
ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
(struct sockaddr *)&cliaddr, &clilen);
if (n < 0) {
perror("recvfrom");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cliaddr.sin_addr, ip, sizeof(ip));
printf("From %s:%d (%zd bytes)\n", ip, ntohs(cliaddr.sin_port), n);
sendto(sockfd, buf, n, 0, (struct sockaddr *)&cliaddr, clilen);
}
}7.2 UDP超时重传客户端
// 带超时重传的UDP客户端
#define MAX_RETRIES 3
#define TIMEOUT_SEC 2
int main(int argc, char **argv) {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(9999);
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
// 设置接收超时
struct timeval tv;
tv.tv_sec = TIMEOUT_SEC;
tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
char sendline[] = "Hello UDP Server";
char recvline[1024];
for (int retry = 0; retry < MAX_RETRIES; retry++) {
sendto(sockfd, sendline, strlen(sendline), 0,
(struct sockaddr *)&servaddr, sizeof(servaddr));
ssize_t n = recvfrom(sockfd, recvline, sizeof(recvline), 0, NULL, NULL);
if (n > 0) {
recvline[n] = '\0';
printf("Received: %s\n", recvline);
break;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("Timeout, retry %d/%d\n", retry + 1, MAX_RETRIES);
} else {
perror("recvfrom");
break;
}
}
close(sockfd);
return 0;
}四、本章小结
本章系统讲解了UDP套接字编程,核心要点回顾:
UDP基本API:sendto/recvfrom 用于无连接的数据报传输。每次发送需要指定目的地址,每次接收可以获取源地址。
数据报长度:理论最大65527字节,但受MTU限制。推荐互联网应用不超过512字节,局域网不超过1472字节,避免IP分片。
connected模式:对UDP套接字调用 connect 后可以使用 write/read,只接收来自对端的数据报,ICMP错误会交付给应用。
UDP服务器:不需要listen/accept,直接recvfrom/sendto循环。并发处理需要多线程/多进程或I/O复用。
可靠传输:UDP本身不可靠,但可以在应用层实现可靠性(确认/重传/排序)。QUIC是UDP之上实现可靠传输的现代范例。
多播:UDP支持一对多的多播传输。使用 IP_ADD_MEMBERSHIP 加入多播组,使用多播地址(224.0.0.0-239.255.255.255)发送。
五、思考与练习
思考题
为什么DNS使用UDP而不是TCP?DNS在什么情况下会切换到TCP?
如果UDP数据报在传输过程中被分片,接收端的recvfrom会怎样?
UDP的connected模式下,如果调用connect两次指定不同的对端,之前的数据会怎样?
为什么QUIC选择UDP而不是TCP作为底层传输?TCP的哪些特性对QUIC的场景是不利的?
UDP多播和广播有什么区别?为什么多播可以跨路由器而广播不能?
编程练习
可靠UDP库:实现一个简单的可靠UDP协议,支持序列号、确认、超时重传。测试在模拟丢包环境下的可靠性。
UDP聊天室:使用多播实现一个局域网聊天室。所有参与者加入同一个多播组,发送的消息被所有成员接收。
UDP性能测试工具:编写一个工具,测量UDP的吞吐量、丢包率和延迟。逐步增加数据报大小,观察IP分片对性能的影响。