7UNIX网络编程(第3版)

基本UDP套接字编程

UDP编程

UDP数据报不可靠传输

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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套接字

c
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// SOCK_DGRAM 指定数据报套接字(UDP)
// protocol 通常为0,自动选择UDP协议

7.1.2 sendto和recvfrom

UDP是无连接的,每次发送数据报都需要指定目的地址,每次接收数据报都能获取源地址。

c
#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);
c
// 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);
}
c
// 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
c
// 检测路径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 返回缓冲区大小。

c
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,指定一个固定的对端地址。

c
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

c
// 未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=ECONNREFUSED

7.3.3 多次connect

可以对同一个UDP套接字多次调用 connect:

  • 第一次 connect:指定对端A
  • 第二次 connect:更改对端为B
  • 要断开连接:将地址族设为AF_UNSPEC
c
// 更改对端
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回复。

c
// 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监控多个套接字

使用多个套接字:每个套接字绑定不同端口,分配给不同线程

c
// 每请求一个线程的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 在某些场景下很有用。

c
// 标准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)
c
// 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实现

c
// 简单的可靠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 多播发送

c
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 多播接收

c
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回显服务器和客户端

c
// 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超时重传客户端

c
// 带超时重传的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分片对性能的影响。

7/10基本UD
0%