UDP 通信

UDP 通信

时间:2026/04/09

关键词:报文、无连接、不可靠传输、sendto/recvfrom、MTU、丢包重传、滑动窗口
核心目标:先把 UDP 的“简单”理解透,再搞清楚为什么很多实时协议愿意在 UDP 之上自己补可靠性。


1. UDP 是什么

UDP(User Datagram Protocol)是面向报文的传输层协议。

它的特点很直接:

  • 无连接:通信前不需要三次握手
  • 报文边界保留:发送一次 sendto,接收端看到的是一个完整报文,天然没有 TCP 粘包问题
  • 尽力而为:协议本身不保证送达、不保证顺序、不保证不重复
  • 开销小、时延低:头部只有 8 字节,协议栈处理也更轻

所以 UDP 很适合:

  • 实时音视频
  • 在线游戏
  • DNS
  • 广播 / 组播
  • 能容忍少量丢包,但很在意时延的业务

2. UDP 和 TCP 的核心差异

维度 UDP TCP
连接语义 无连接 面向连接
数据形式 报文 字节流
可靠性 不保证 保证送达、按序、去重
顺序 不保证 保证
重传 应用层自己做 内核协议栈负责
流量控制 没有
拥塞控制 没有
时延 更低 较稳定但更重

最重要的一点:

UDP 只是“帮你发包”,TCP 则是“帮你把传输这件事做完整”。


3. Linux 下 UDP 的常用接口

3.1 创建 socket

1
int fd = socket(AF_INET, SOCK_DGRAM, 0);

SOCK_DGRAM 表示 UDP 套接字。

3.2 服务端绑定地址

1
bind(fd, (struct sockaddr*)&addr, sizeof(addr));

和 TCP 一样,服务端通常需要 bind 到固定 IP/端口。

3.3 接收与发送

1
2
3
4
5
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr*)&peer, &peer_len);

sendto(fd, data, len, 0,
(struct sockaddr*)&peer, peer_len);

这是一对最常见的 UDP I/O 接口:

  • recvfrom:收到数据时,顺便告诉你包来自谁
  • sendto:每次发送时明确指定目标地址

3.4 connect() 对 UDP 也有用

1
connect(fd, (struct sockaddr*)&peer, sizeof(peer));

UDP 上的 connect() 不是建立连接,而是:

  • 给这个 socket 绑定一个默认对端
  • 之后可以直接用 send/recv
  • 内核只接收该对端发来的报文
  • 某些错误能更直接反馈给调用方

所以很多客户端 UDP 程序也会先 connect(),这样代码更简洁。


4. UDP 通信的基本流程

4.1 服务端

  1. socket(AF_INET, SOCK_DGRAM, 0)
  2. bind()
  3. 循环 recvfrom()
  4. 根据来源地址决定回包对象
  5. sendto() 返回结果

4.2 客户端

  1. socket(AF_INET, SOCK_DGRAM, 0)
  2. 可选:connect()
  3. sendto()send()
  4. recvfrom()recv()

和 TCP 相比,UDP 没有:

  • listen()
  • accept()

因为它没有连接队列这一层。


5. UDP 的工程注意点

5.1 没有粘包,不等于没有协议设计

UDP 保留报文边界,但应用层仍然要定义:

  • 包头
  • 消息类型
  • 序号
  • 校验
  • 重传策略

否则出了问题很难排查。

5.2 丢包、乱序、重复都是正常现象

UDP 编程默认就要接受这些情况:

  • 某个包永远收不到
  • 后发的包先到
  • 同一个包被收到两次

这不是异常,而是 UDP 的正常工作方式。

5.3 尽量避免 IP 分片

如果 UDP 报文过大,IP 层可能分片。分片带来的问题:

  • 任一分片丢失,整个报文作废
  • 网络设备对分片不友好
  • 性能和稳定性都变差

工程上通常建议:

  • 单个应用层包尽量控制在 MTU 以下
  • 以太网常见安全值可以先按 1200 字节左右设计

这也是 QUIC 常见的保守做法之一。

5.4 高性能场景通常要配合事件循环

在 Linux 上,大量 UDP socket 或高频收发通常会配合:

  • epoll
  • 非阻塞 socket
  • 定时器管理重传
  • 批量收发(如 recvmmsg/sendmmsg

单线程阻塞式 recvfrom() 适合入门,不适合高并发服务。


6. 一个最小 UDP 回显服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

int main() {
int fd = 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(9999);

bind(fd, (struct sockaddr*)&addr, sizeof(addr));

for (;;) {
char buf[2048];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);

ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr*)&peer, &len);
if (n <= 0) {
continue;
}

sendto(fd, buf, (size_t)n, 0,
(struct sockaddr*)&peer, len);
}

close(fd);
return 0;
}

这个例子足够说明 UDP 服务端的基本模型:

  • 一个 socket
  • 不断收报文
  • 每个报文都自带来源地址
  • 回包时显式指定目标

7. 用 UDP 实现 TCP,应该怎么想

这里更准确的说法不是“把 TCP 原样重写一遍”,而是:

在 UDP 之上补齐 TCP 的核心能力,做出一个“可靠、有序、可控”的传输层。

这是很多实时网络框架的常见思路,例如:

  • KCP:在 UDP 上实现可靠传输,强调低延迟
  • QUIC:在 UDP 上实现更现代的连接、多路复用、拥塞控制与加密

7.1 先分清目标

如果你只是想让消息“更可靠”,那通常只需要做:

  • 序号
  • ACK
  • 超时重传
  • 简单窗口

如果你想完整模拟 TCP,则还要补:

  • 连接管理
  • 按序交付
  • 流量控制
  • 拥塞控制
  • RTT 估计与重传定时器
  • 关闭连接

真正困难的部分其实不是“发包”,而是:

  • 如何在复杂网络环境里稳定地控制发送节奏

7.2 最小协议头可以这样设计

1
2
3
4
5
6
7
8
9
10
struct PacketHeader {
uint32_t conn_id; // 连接标识
uint32_t seq; // 当前包序号
uint32_t ack; // 已确认到的对端序号
uint16_t flags; // SYN / ACK / FIN / RST 等
uint16_t wnd; // 通告接收窗口
uint32_t ts; // 时间戳,用于 RTT 估计
uint16_t len; // payload 长度
uint16_t checksum; // 包头+包体校验
};

如果只是做“可靠消息”而不是“字节流”,这个头已经够搭框架了。

7.3 把 TCP 的能力拆成 6 层功能

1. 连接管理

在 UDP 上自己定义连接状态:

  • CLOSED
  • SYN_SENT
  • SYN_RECV
  • ESTABLISHED
  • FIN_WAIT

可以直接借用 TCP 的思路:

  1. 客户端发 SYN(seq=x)
  2. 服务端回 SYN|ACK(seq=y, ack=x+1)
  3. 客户端再发 ACK(ack=y+1)

这样做的价值是:

  • 双方都知道初始序号
  • 可以建立会话状态
  • 便于后续超时、重传和断线清理

2. 可靠性

发送端维护一个未确认发送队列

  • 每发一个包,都放进 unacked_map
  • 记录发送时间、重传次数
  • 定时扫描超时包并重发

接收端收到包后返回 ACK。

最简单的 ACK 策略可以先做:

  • 累计确认ack = 下一个期待收到的序号

再进一步可做:

  • 选择确认(SACK):显式告诉对端哪些乱序包已经收到

3. 有序交付

如果包乱序到达:

  • 先放入接收缓冲区
  • 只有当 seq == expected_seq 时才向上层提交
  • 提交后继续检查后续缓存是否已连续

这就是 TCP “按序交付”的核心。

4. 流量控制

需要告诉对方:

  • 我这边接收缓冲还有多大

也就是在包头里放 wnd,类似 TCP 的接收窗口。

否则发送方可能发得太快,把接收方内存顶爆。

5. 拥塞控制

这是最难的一层。

最偷懒的版本可以先不做,只设固定发送速率,但这不是真正的 TCP 级能力。

更接近 TCP 的做法是引入:

  • 慢启动
  • 拥塞避免
  • 丢包后乘法减小

如果没有拥塞控制,你的协议在局域网看起来正常,到了公网通常就会失控。

6. 关闭连接

同样可以模仿 TCP:

  • 一端发 FIN
  • 对端回 ACK
  • 双方完成收尾和资源回收

否则 session 容易泄漏。


8. 一个“UDP 版 TCP”最小实现框架

如果从工程结构上设计,可以拆成下面几层:

  1. UdpSocket 层
    负责 socket/bind/sendto/recvfromepoll
  2. Session 层
    peer_addr + conn_id 管理会话状态
  3. Reliability 层
    管理 seq/ack、发送窗口、重传队列、乱序缓冲
  4. Timer 层
    负责 RTO、心跳、超时断线
  5. Congestion / Flow Control 层
    控制能发多少、发多快
  6. Application 层
    真正的业务协议

一个发送端主循环大概是:

1
2
3
4
5
6
7
应用层提交消息
-> 分配 seq
-> 封包并发送
-> 放入未确认队列
-> 等待 ACK
-> 超时则重传
-> 收到 ACK 后从队列删除

一个接收端主循环大概是:

1
2
3
4
5
6
7
收到 UDP 报文
-> 校验包头
-> 根据 conn_id 找到 session
-> 判断 seq 是否重复 / 乱序 / 正常
-> 更新接收窗口
-> 发送 ACK
-> 按序把数据交给上层

9. 真正落地时的几个关键取舍

9.1 先做“可靠消息”,再做“可靠字节流”

如果目标是游戏状态同步、命令包、RPC:

  • 可靠消息协议通常更简单

如果非要完全模拟 TCP 的“字节流语义”,复杂度会显著上升,因为你还要处理:

  • 流式重组
  • 半关闭
  • 更复杂的缓冲管理

9.2 不要一开始就试图复刻内核 TCP

更现实的路线是:

  1. 先做连接 + seq + ack + 重传
  2. 再做乱序缓存 + 滑动窗口
  3. 再补 RTT / RTO
  4. 最后再考虑拥塞控制

否则很容易一上来就把实现写散。

9.3 现代工程更常见的是“在 UDP 上做定制协议”

很多时候我们并不是要“重新发明 TCP”,而是要:

  • 保留 UDP 的低时延和灵活性
  • 只补自己需要的那部分可靠性

例如:

  • 关键帧可靠重传
  • 状态包不重传,只发送最新值
  • 控制消息必须有 ACK

这比完整复刻 TCP 更符合实时系统的实际需求。


10. 小结

可以把 UDP 记成一句话:

UDP 提供的是“轻量发报文”的能力,可靠、有序、流控、拥塞控制都要你自己决定要不要补。

如果要用 UDP 实现类似 TCP 的能力,最关键不是 API,而是这几个机制:

  • 序号 seq
  • 确认 ack
  • 超时重传
  • 滑动窗口
  • 按序交付
  • 流控 / 拥塞控制

把这些拆开理解之后,“UDP 实现 TCP”就不再神秘,本质上就是:

  • 在用户态自己维护传输状态机

参考