目录
- [一、什么是 UDP?](#一、什么是 UDP?)
- [二、UDP 报文结构](#二、UDP 报文结构)
- [三、UDP 的核心特性](#三、UDP 的核心特性)
-
- [1. 无连接](#1. 无连接)
- [2. 不可靠](#2. 不可靠)
- [3. 低延迟](#3. 低延迟)
- [4. 数据报边界保留](#4. 数据报边界保留)
- [四、UDP vs TCP](#四、UDP vs TCP)
- [五、Python 示例](#五、Python 示例)
- [六、UDP 适合哪些场景?](#六、UDP 适合哪些场景?)
- 七、小结
一、什么是 UDP?
UDP(User Datagram Protocol,用户数据报协议)是互联网协议族中的传输层协议,与 TCP 并列,但设计哲学完全不同:追求速度,放弃可靠性保证。
生活中的类比:
- UDP 像寄明信片------写好扔进邮箱,对方可能收到也可能收不到,你不会知道结果,也不会补寄
- TCP 像快递------有签收确认,丢了会补发,但慢一些
二、UDP 报文结构
txt
bit: 0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| 源端口 (16位) | 目标端口 (16位) |
+--------+--------+--------+--------+
| 长度 (16位) | 校验和 (16位) |
+--------+--------+--------+--------+
| 数据载荷 ... |
+--------+--------+--------+--------+
顶部数字是 bit 位编号,每行 32 bit = 4 字节。1 字节 = 8 bit。
报头四个字段各占 16 bit(2字节),合计 8 字节,极为精简。
数据载荷上限的计算:
txt
UDP 包总长度上限(16位能表示的最大值 2^16 - 1) 65535 字节
- UDP 报头 - 8 字节
- IP 报头 - 20 字节
──────────
实际可用载荷上限 65507 字节
代码里常见的 recvfrom(65535) 只是"一个足够大的数",实际载荷不会超过 65507 字节。
三、UDP 的核心特性
1. 无连接
TCP 发送数据前需要"三次握手"建立连接,UDP 直接发送,不需要任何预备步骤。
2. 不可靠
- 数据包可能丢失
- 数据包可能乱序到达
- 数据包可能重复
UDP 本身不处理这些问题,由应用层决定如何应对(或者干脆忽略)。
3. 低延迟
没有握手、没有确认、没有重传,延迟极低且稳定。
4. 数据报边界保留
这是 UDP 与 TCP 一个容易忽视的重要区别。
TCP 是字节流,没有消息边界(粘包问题):
txt
发送方:send("Hello") + send("World") + send("!")
接收方:recv() 可能得到 "HelloWorld!"(三条合并)
或者 "Hel" + "loWorld!"(一条被拆开)
接收方必须自己处理粘包,比如用固定长度或在头部写长度字段。
UDP 天然保留边界:
txt
发送方:sendto("Hello") + sendto("World") + sendto("!")
接收方:recvfrom() 一定是 "Hello" → "World" → "!"
一次 sendto 对应一次 recvfrom,永远不会粘包
四、UDP vs TCP
| 对比项 | UDP | TCP |
|---|---|---|
| 连接 | 无连接,直接发 | 三次握手建立连接 |
| 可靠性 | 不保证到达 | 保证到达(重传) |
| 顺序 | 不保证 | 保证 |
| 消息边界 | 天然保留 | 无边界(字节流) |
| 速度 | 快,延迟低 | 慢,有重传开销 |
| 报头大小 | 8 字节 | 20~60 字节 |
| 适用场景 | 实时数据 | 文件传输、网页 |
五、Python 示例
示例一:最简单的发送/接收
接收端(先运行):
py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# ↑ SOCK_DGRAM 指定 UDP 协议
# (TCP 对应的是 SOCK_STREAM)
sock.bind(("0.0.0.0", 9000))
print("等待数据...")
while True:
data, addr = sock.recvfrom(65535)
print(f"收到来自 {addr} 的数据: {data.decode()}")
发送端(后运行):
py
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"Hello, UDP!", ("127.0.0.1", 9000))
# ↑ 无需 connect(),直接带目标地址发出,体现"无连接"特性
sock.close()
运行结果:
txt
# 接收端输出:
等待数据...
收到来自 ('127.0.0.1', 54321) 的数据: Hello, UDP!
54321 是操作系统随机分配给发送端的临时端口,每次运行不同。发送端无任何输出,执行完直接退出。
UDP 体现在哪里:
- SOCK_DGRAM --- 指定 UDP 协议
- 发送端没有 connect(),直接 sendto() --- 无连接
- 接收端没有 listen() / accept() --- 无握手
- recvfrom() 每次返回一个完整数据报 --- 边界保留
示例二:验证数据报边界保留
py
import socket
import threading
import time
def receiver():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 9001))
sock.settimeout(2.0)
print("=== 接收端 ===")
try:
while True:
data, _ = sock.recvfrom(65535)
print(f"recvfrom() 收到: {data.decode()!r}")
except socket.timeout:
print("接收完毕")
sock.close()
def sender():
time.sleep(0.1)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
messages = ["Hello", "World", "!"]
print("=== 发送端 ===")
for msg in messages:
sock.sendto(msg.encode(), ("127.0.0.1", 9001))
print(f"sendto() 发送: {msg!r}")
time.sleep(0.01)
sock.close()
threading.Thread(target=receiver).start()
threading.Thread(target=sender).start()
运行结果:
txt
=== 接收端 ===
=== 发送端 ===
sendto() 发送: 'Hello'
sendto() 发送: 'World'
sendto() 发送: '!'
recvfrom() 收到: 'Hello'
recvfrom() 收到: 'World'
recvfrom() 收到: '!'
接收完毕
三次 sendto 对应三次 recvfrom,消息边界完整保留,不会粘包。
示例三:带序号的实时数据流(模拟遥操场景)
这是最贴近工程实际的用法------发送端持续发送带序号和时间戳的数据,接收端只保留最新帧,丢弃过期数据。
struct 格式字符串详解
发送和接收都用到了 struct.pack/unpack,格式字符串 "!Id3f" 的含义:
| 字符 | 含义 | 字节数 |
|---|---|---|
| ! | 网络字节序(大端序) | - |
| I | unsigned int,无符号32位整数 | 4 |
| d | double,64位浮点数 | 8 |
| 3f | 3个 float,各32位浮点数 | 4*3=12 |
总计:4 + 8 + 12 = 24 字节。pack 和 unpack 的格式字符串必须完全一致,否则解析结果会错乱。
为什么需要 !(网络字节序)?
多字节数值在内存中的存储顺序有两种:
txt
数值 0x12345678
大端序(网络字节序): 12 34 56 78 ← 高位在前,符合人类阅读习惯
小端序(x86主机): 78 56 34 12 ← 低位在前
网络传输约定使用大端序,! 确保不同机器之间收发数据时解析结果一致。跨机器通信必须统一字节序,否则同一段字节在两端会被解析成不同的数值。
发送端(100 Hz 持续发送):
py
import socket
import struct
import time
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
seq = 0
print("开始发送传感器数据 (100 Hz)...")
while True:
timestamp = time.time()
x, y, z = seq * 0.1, seq * 0.2, seq * 0.3
# 打包:序号(I=uint32) + 时间戳(d=double) + 三轴坐标(3f=3个float)
# 总计 4 + 8 + 12 = 24 字节
packet = struct.pack("!Id3f", seq, timestamp, x, y, z)
sock.sendto(packet, ("127.0.0.1", 9002))
seq += 1
time.sleep(1.0 / 100)
接收端(只用最新帧):
py
import socket
import struct
import time
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 9002))
sock.settimeout(1.0)
last_seq = -1
drop_count = 0
while True:
try:
data, _ = sock.recvfrom(65535)
except socket.timeout:
print("超时,连接可能断开")
break
seq, ts, x, y, z = struct.unpack("!Id3f", data)
# 格式字符串与 pack 完全一致,24字节还原为 5 个数值
# 丢弃乱序到达的旧包
if seq <= last_seq:
drop_count += 1
continue
last_seq = seq
latency = (time.time() - ts) * 1000
print(f"seq={seq:4d} pos=({x:.2f}, {y:.2f}, {z:.2f}) "
f"延迟={latency:.1f}ms 累计丢弃={drop_count}")
运行结果:
txt
seq= 0 pos=(0.00, 0.00, 0.00) 延迟=0.8ms 累计丢弃=0
seq= 1 pos=(0.10, 0.20, 0.30) 延迟=0.7ms 累计丢弃=0
seq= 2 pos=(0.20, 0.40, 0.60) 延迟=0.9ms 累计丢弃=0
seq= 3 pos=(0.30, 0.60, 0.90) 延迟=0.8ms 累计丢弃=0
...
六、UDP 适合哪些场景?
适合:
- 机器人遥操 --- 旧的控制指令没有价值,宁可丢包也不要等重传
- 实时音视频 --- 丢一帧比卡顿好(Zoom、直播)
- 在线游戏 --- 需要低延迟,旧状态无意义
- DNS 查询 --- 单次请求/响应,不需要连接开销
- 广播/多播 --- UDP 原生支持,TCP 不支持
不适合:
- 文件传输 --- 不能丢数据
- 网页浏览 --- 需要完整 HTML/CSS/JS
- 数据库操作 --- 不能丢 SQL 指令
七、小结
UDP 的设计哲学是把可靠性的决策权交给应用层。它用最简单的 8 字节报头换来了极低的延迟和最大的灵活性。在实时性比完整性更重要的场景下,UDP 是比 TCP 更合适的选择。理解 UDP 的无连接、不可靠、边界保留三个核心特性,才能写出正确处理丢包和乱序的健壮代码。