4、UDP通信实战:UDP特点、sendto/recvfrom函数、UDP广播与组播、UDP丢包处理

UDP 这个协议,说实话,刚入行的时候我总觉得它「不太靠谱」。没有连接、没有确认、没有重传,数据发出去就像扔出去的纸飞机——能不能到、到了是不是完整,全看运气。但做了几年网络编程之后,我越来越喜欢它。为什么?因为它快,而且足够简单。

这一章,我们就来把 UDP 彻底聊透。从它的核心特点,到 sendto/recvfrom 这对黄金搭档,再到广播、组播,最后聊聊那个让人头疼的丢包问题。

4.1 UDP 到底「野」在哪?

UDP 的全称是 User Datagram Protocol,用户数据报协议。它和 TCP 最大的区别就一个字:无连接

TCP 像打电话——先拨号、接通、说话、挂断。UDP 像寄明信片——写好地址扔邮筒,对方收不收得到,你不管。

我个人总结了 UDP 的三个核心特点:

  • 无连接:不需要三次握手,直接发数据。节省了建立连接的时间。
  • 不可靠:数据可能丢、可能乱序、可能重复。UDP 不保证送达。
  • 面向报文:应用层交给 UDP 多大的数据,UDP 就原样发送,不会像 TCP 那样拆分成段。

你想想看,这三个特点放在一起,意味着什么?意味着 UDP 的延迟极低,适合实时性要求高的场景。比如视频通话、在线游戏、DNS 查询。我在项目中做过一个实时数据采集系统,用的就是 UDP。因为数据每秒来几千条,丢几条无所谓,但延迟必须控制在毫秒级。TCP 的重传机制反而成了累赘。

适用场景速查:
  • 视频/音频直播
  • 在线游戏(尤其是 FPS 类)
  • DNS 查询
  • SNMP 网络管理
  • 物联网传感器数据上报

4.2 sendto 和 recvfrom:UDP 的收发双雄

TCP 用 connect/accept 建立连接后,直接用 read/write 收发数据。UDP 不一样,它用 sendtorecvfrom。这两个函数每次调用都要带上对方的地址信息。

先看原型:

#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

参数看着多,其实核心就两个:dest_addrsrc_addr。sendto 告诉内核「发给谁」,recvfrom 告诉内核「谁发的」。

我刚开始用的时候犯过一个低级错误:recvfrom 的 addrlen 参数忘记初始化。结果 recvfrom 一直返回 -1,errno 是 EINVAL。查了半天才发现,addrlen 必须初始化为 sizeof(struct sockaddr_in)。嗯,这里要注意。

下面是一个最简单的 UDP 收发示例:

// UDP 发送端
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

char *msg = "Hello UDP!";
sendto(sockfd, msg, strlen(msg), 0,
       (struct sockaddr *)&server_addr, sizeof(server_addr));
close(sockfd);
// UDP 接收端
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in local_addr;
local_addr.sin_family = AF_INET;
local_addr.sin_port = htons(8888);
local_addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *)&local_addr, sizeof(local_addr));

char buf[1024];
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int n = recvfrom(sockfd, buf, sizeof(buf)-1, 0,
                 (struct sockaddr *)&client_addr, &addr_len);
buf[n] = '\0';
printf("收到: %s\n", buf);
close(sockfd);

注意看,接收端必须调用 bind 绑定端口,否则内核不知道把数据交给谁。发送端可以不 bind,内核会自动分配一个临时端口。

4.3 UDP 广播:一呼百应

广播是 UDP 独有的能力。TCP 是点对点的,UDP 可以一发多收。广播地址通常是 xxx.xxx.xxx.255,或者 255.255.255.255。

实现广播需要两步:

  1. 设置 socket 选项 SO_BROADCAST
  2. 发送到广播地址
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
int broadcast = 1;
setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));

struct sockaddr_in broadcast_addr;
broadcast_addr.sin_family = AF_INET;
broadcast_addr.sin_port = htons(9999);
inet_pton(AF_INET, "192.168.1.255", &broadcast_addr.sin_addr);

char *msg = "广播消息";
sendto(sockfd, msg, strlen(msg), 0,
       (struct sockaddr *)&broadcast_addr, sizeof(broadcast_addr));

接收端不需要特殊设置,只要 bind 到对应端口,就能收到广播消息。

我曾经踩过的坑:广播会消耗网络带宽,所有在同一个广播域内的设备都会收到。生产环境中,不要滥用广播。我曾经在一个 500 台设备的局域网里做广播测试,结果网络瞬间卡死。后来改成了组播。

4.4 UDP 组播:精准投递

组播比广播更优雅。它使用 D 类 IP 地址(224.0.0.0 ~ 239.255.255.255)。只有加入了某个组播组的设备,才能收到发往该组的数据。

组播发送端和普通 UDP 发送几乎一样,只是目标地址换成组播地址:

struct sockaddr_in mcast_addr;
mcast_addr.sin_family = AF_INET;
mcast_addr.sin_port = htons(10000);
inet_pton(AF_INET, "224.0.0.88", &mcast_addr.sin_addr);

接收端需要加入组播组:

struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.88");
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));

组播的好处是:只有感兴趣的主机才会收到数据,不会像广播那样打扰所有设备。我在做视频监控项目时,就用组播把摄像头画面分发给多个客户端,效果很好。

4.5 UDP 丢包处理:别指望协议,自己来

UDP 丢包是常态,不是异常。为什么?因为 UDP 没有流量控制,没有拥塞控制。发送端发得太快,接收端来不及收,或者中间路由器缓冲区满了,数据包就直接被丢弃。

我做过一个测试:在本地回环地址(127.0.0.1)上,UDP 几乎不丢包。但一旦换成真实网络,丢包率可能达到 5%~10%,甚至更高。

那怎么办?我的经验是:应用层自己做可靠性保障

常用的方法有:

  • 序列号:每个数据包带上一个递增的序号,接收端检测是否有缺失。
  • 超时重传:发送端启动定时器,如果超时没收到确认,就重发。
  • ACK 确认:接收端收到数据后,回复一个确认包。
  • 前向纠错(FEC):发送冗余数据,接收端即使丢了一部分也能恢复。

下面是一个简单的序列号 + 超时重传示例:

// 发送端
uint16_t seq = 0;
while (1) {
    struct packet pkt;
    pkt.seq = seq++;
    memcpy(pkt.data, "hello", 5);
    sendto(sockfd, &pkt, sizeof(pkt), 0, ...);
    
    // 等待 ACK,超时重传
    struct timeval tv = {1, 0}; // 1秒超时
    setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    if (recvfrom(sockfd, &ack, sizeof(ack), 0, ...) < 0) {
        // 超时,重传
        sendto(sockfd, &pkt, sizeof(pkt), 0, ...);
    }
}
我的建议:不要试图在 UDP 上实现一个完整的 TCP。如果你需要强可靠性,直接用 TCP。UDP + 应用层可靠性,适合那些「大部分数据可靠即可,偶尔丢几个也能接受」的场景。比如视频通话丢一帧画面,用户根本感觉不到。

4.6 本章知识体系

下面这张图,把 UDP 的核心知识点串起来了。从 socket 创建,到收发函数,再到广播/组播,最后是丢包处理策略。你可以把它当作一个快速参考。

UDP 通信核心 UDP 三大特点 无连接 不可靠 面向报文 sendto / recvfrom 每次携带目标地址 接收端必须 bind 发送端可自动分配端口 广播 / 组播 广播:SO_BROADCAST 组播:IP_ADD_MEMBERSHIP 丢包处理策略 序列号 + 超时重传 ACK 确认机制 前向纠错(FEC)

UDP 看似简单,但用好它并不容易。我个人觉得,UDP 编程的核心不在于「怎么发」,而在于「发丢了怎么办」。理解了这一点,你才算真正掌握了 UDP。

好了,这一章就到这里。代码不多,但每一行都值得你亲手敲一遍。尤其是广播和组播,找个局域网环境试试,你会对 UDP 有更直观的感受。


公众号:蓝海资料掘金营,微信deep3321