UDP 专章:不握手的快件——简单、粗暴、好用也易踩坑
专栏:《计算机网络基础》· 第三篇·传输层
承接:《快递选「挂号」还是「平邮」?传输层一次讲清》
讲解维度: 概念 → 原理 → 应用 → 问题定位 + 可运行示例
读完你能: 亲手写出 UDP 收发程序;说清为何 DNS/直播爱用它;用抓包和命令定位 UDP 故障
导读:为什么先学 UDP,而不是 TCP?
TCP 功能多、状态多,一上来容易被三次握手绕晕。
UDP 几乎是「最小可用的传输层」:有端口、能发包,其它大多不管。
先把平邮搞懂,再去理解挂号件(TCP)为什么要多那么多手续,会轻松很多。
应用数据
│
▼
┌──────────────┐
│ UDP 头部很小 │ ← 源端口、目的端口、长度、校验和
└──────┬───────┘
▼
IP 包再发出去
一、概念:UDP 是什么?
1.1 一句话
UDP(User Datagram Protocol,用户数据报协议):无连接、尽力而为(best-effort)的传输层协议。
|
它有 |
它没有(默认) |
|---|---|
|
端口(区分进程) |
握手 / 连接状态 |
|
数据报边界(一发一收较清晰) |
自动重传、保证到达 |
|
可选校验和 |
拥塞控制、流量控制 |
|
开销小、延迟低 |
保序(可能乱序) |
1.2 生活类比
TCP = 挂号电话确认:接通→说话→确认收到→挂断
UDP = 往邮箱塞明信片:塞进去就走;丢了邮局不管
1.3 在分层里的位置
┌─────────────────────────┐
│ DNS / 直播 / 游戏 / QUIC │ 应用(或应用自定义)
├─────────────────────────┤
│ UDP │ ← 本专章
├─────────────────────────┤
│ IP │
└─────────────────────────┘
二、原理:报头很小,语义也很「薄」
2.1 UDP 头部(仅 8 字节)
0 7 8 15 16 23 24 31
┌─────────┬─────────┬─────────┬─────────┐
│ 源端口 │ 目的端口 │ 长度 │ 校验和 │
└─────────┴─────────┴─────────┴─────────┘
一共 8 字节,然后是数据
|
字段 |
作用 |
|---|---|
|
源端口 |
回复时寄回哪里(可在某些场景为 0) |
|
目的端口 |
交给对方哪个服务 |
|
长度 |
UDP 头 + 数据的总长度 |
|
校验和 |
查损坏(IPv4 可为 0 表示不用;IPv6 必须用) |
对比:TCP 头部通常 ≥20 字节,还有序号、窗口、标志位等。
2.2 「数据报」边界
UDP 一次 send 对应一次「报文」;对端 recv 通常按报文收(注意缓冲区要够大,否则可能截断)。
发送三次 UDP:
[你好] [世界] [!]
接收三次(理想情况):
第1次收到「你好」
第2次收到「世界」
第3次收到「!」
(不像 TCP 字节流可能粘成「你好世界!」再自己拆)
这就是很多自定义二进制协议爱 UDP 的原因之一:消息边界清楚。
2.3 无连接带来的后果
1) 发送前不必 connect 成功(也可以 connect 固定对端,但是否真连通仍不保证)
2) 发送成功 ≠ 对方收到(只表示本机协议栈收下并尝试送出)
3) 对方进程崩溃,你不一定立刻知道(没有 TCP 那种 RST 语义)
4) NAT/防火墙可能「悄悄丢」,超时后映射消失
2.4 和 IP 的关系
UDP 交给 IP 后,仍可能:
-
分片(过大时,路径 MTU 问题)
-
丢包、乱序、重复
可靠性若需要,必须自己在应用里做(序号、ACK、重传、冗余编码等),或改用 TCP/QUIC。
三、应用:谁在用 UDP?
|
场景 |
为什么选 UDP |
|---|---|
|
DNS 查询 |
一问一答,短小,图快 |
|
DHCP |
开机发现网络,偏广播/轻量 |
|
音视频直播/通话 |
宁可丢帧,也不要卡死等重传 |
|
部分游戏同步 |
状态频繁更新,旧包可扔 |
|
QUIC / HTTP/3 |
借 UDP 做现代传输底座 |
|
日志/指标上报 |
偶尔丢几条可接受(视业务) |
「能容忍丢」→ 倾向 UDP
「必须完整有序」→ 倾向 TCP
「又要多路又要现代握手」→ 看 QUIC
四、示例程序:从零写出 UDP 收发
环境:Python 3(跨平台最好上手)。C 示例见文末附录。
建议开 两个终端:先启动服务端,再启动客户端。
4.1 最小服务端:收一句话并回复
保存为 udp_server.py:
#!/usr/bin/env python3
"""最简单的 UDP 服务端:收到什么,就回 'ECHO: ...'"""
import socket
HOST = "0.0.0.0" # 监听所有网卡
PORT = 9999
BUF = 2048
def main():
# SOCK_DGRAM = UDP
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((HOST, PORT))
print(f"[server] listening on UDP {HOST}:{PORT}")
while True:
data, addr = sock.recvfrom(BUF) # 阻塞等待一个数据报
text = data.decode("utf-8", errors="replace")
print(f"[server] from {addr}: {text!r}")
reply = f"ECHO: {text}".encode("utf-8")
sock.sendto(reply, addr) # 寄回对方地址
if __name__ == "__main__":
main()
4.2 最小客户端:发一句话并等待回复
保存为 udp_client.py:
#!/usr/bin/env python3
"""最简单的 UDP 客户端"""
import socket
import sys
SERVER = ("127.0.0.1", 9999)
BUF = 2048
def main():
msg = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "hello UDP"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3.0) # 3 秒收不到就超时(UDP 不会自动告诉你对端挂了)
print(f"[client] send to {SERVER}: {msg!r}")
sock.sendto(msg.encode("utf-8"), SERVER)
try:
data, addr = sock.recvfrom(BUF)
print(f"[client] reply from {addr}: {data.decode()}")
except socket.timeout:
print("[client] timeout! 包可能丢了,或服务端没开,或被防火墙拦了")
finally:
sock.close()
if __name__ == "__main__":
main()
4.3 运行步骤
# 终端 1
python3 udp_server.py
# 终端 2
python3 udp_client.py
python3 udp_client.py 你好平邮
期望输出:
[server] from ('127.0.0.1', xxxxx): 'hello UDP'
[client] reply from ('127.0.0.1', 9999): ECHO: hello UDP
4.4 故意制造「超时」:体会 UDP 的无情
-
先别开服务端,只跑客户端 → 应打印
timeout! -
这说明:
sendto往往仍「成功」,但 不等于对端收到。
TCP 世界:连接失败/RST 常常很快有反馈
UDP 世界:很多时候只有「等超时」这一种反馈
4.5 进阶示例:带序号的「伪可靠」小协议(教学用)
真实项目请用成熟库;这里演示 应用层自己补序号+ACK:
udp_reliable_demo.py(单文件自测逻辑,也可拆成两端):
#!/usr/bin/env python3
"""教学演示:在 UDP 上用「序号 + ACK」模拟可靠发送(简化版,勿用于生产)"""
import socket
import struct
import time
# 报文格式: !I (4字节序号) + payload
HEADER = struct.Struct("!I")
def send_reliable(sock, peer, payload: bytes, seq: int, timeout=1.0, retries=5) -> bool:
pkt = HEADER.pack(seq) + payload
for attempt in range(1, retries + 1):
sock.sendto(pkt, peer)
sock.settimeout(timeout)
try:
data, addr = sock.recvfrom(2048)
if len(data) >= 4 and HEADER.unpack(data[:4])[0] == seq and data[4:] == b"ACK":
print(f" seq={seq} ACK ok (try {attempt})")
return True
except socket.timeout:
print(f" seq={seq} timeout, retry {attempt}/{retries}")
return False
def server_loop(port=10001):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", port))
print(f"[server] UDP :{port}")
expected = 1
while True:
data, addr = sock.recvfrom(2048)
if len(data) < 4:
continue
seq = HEADER.unpack(data[:4])[0]
payload = data[4:]
print(f"[server] got seq={seq} payload={payload!r} from {addr}")
# 简化:无论是否期望序号,都回 ACK(真实协议会更严格)
sock.sendto(HEADER.pack(seq) + b"ACK", addr)
if seq == expected:
expected += 1
def client_once(host="127.0.0.1", port=10001):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
peer = (host, port)
for seq, text in [(1, b"ping"), (2, b"pong"), (3, b"bye")]:
ok = send_reliable(sock, peer, text, seq)
if not ok:
print(f"give up seq={seq}")
break
time.sleep(0.1)
sock.close()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "server":
server_loop()
else:
client_once()
运行:
# 终端1
python3 udp_reliable_demo.py server
# 终端2
python3 udp_reliable_demo.py
你学到什么:
UDP 不负责可靠;可靠是可以在应用层「加装」的——代价是复杂度上升,这也是大家常直接用 TCP/QUIC 的原因。
4.6 广播/局域网发现(选做)
#!/usr/bin/env python3
"""UDP 广播打招呼(仅教学;注意公司网络策略)"""
import socket
PORT = 9998
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(b"HELLO_LAN", ("255.255.255.255", PORT))
print("broadcast sent")
sock.close()
服务端对 9998 做 bind + recvfrom 即可看到谁在喊。
DHCP、部分设备发现就有类似「先喊一嗓子」的味道。
五、问题定位:UDP 翻车现场
5.1 症状速查
|
现象 |
常见原因 |
怎么查 |
|---|---|---|
|
客户端总超时 |
服务没开、IP/端口错、防火墙丢 UDP |
先本机环回;再关防火墙对比;抓包 |
|
本机通、跨网不通 |
中间只放行 TCP、NAT 映射过期 |
查安全组是否放行 UDP 端口 |
|
偶尔丢包 |
Wi‑Fi 干扰、拥塞、缓冲满 |
对比有线;看是否需要应用重传 |
|
大包异常 |
MTU/分片问题 |
减小 payload 试验 |
|
能发不能收 |
绑错网卡/地址、权限、SELinux |
|
5.2 实操命令
# 看 UDP 监听
ss -ulnp
# 统计(有的系统)
netstat -su # 或 nstat / sar -n UDP
# 抓 UDP 9999
sudo tcpdump -ni any -A udp port 9999
跑客户端时,tcpdump 里应能看见请求与回应两个方向的 UDP 报文。
5.3 和 TCP 排障的思维差异
TCP:常有「连接失败/RST/重传计数」等线索
UDP:更多是「有没有包、对不对端口、超时几次」

5.4 安全注意(写 UDP 服务时)
-
不要轻易绑定
0.0.0.0并暴露公网,除非有认证与限流; -
UDP 放大攻击、反射攻击在现实中真实存在;
-
校验输入长度,防缓冲区问题(C 语言尤其危险)。
六、对照实验清单(建议全部做完)
|
实验 |
步骤 |
你应有的体会 |
|---|---|---|
|
A 环回收发 |
4.1+4.2 |
UDP 最小闭环 |
|
B 无服务端 |
只跑 client |
发送成功仍可能超时 |
|
C 抓包 |
tcpdump |
看见 UDP 头与载荷 |
|
D 伪可靠 |
4.5 |
可靠可上移到应用层 |
|
E 防火墙 |
临时挡 UDP 端口再测 |
「悄悄丢」的体感 |
七、附录:C 语言最小示例(Linux)
服务端 udp_server.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(void) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr = {0}, peer;
socklen_t peerlen;
char buf[2048];
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
printf("UDP server :9999n");
for (;;) {
peerlen = sizeof(peer);
ssize_t n = recvfrom(fd, buf, sizeof(buf)-1, 0,
(struct sockaddr *)&peer, &peerlen);
if (n < 0) continue;
buf[n] = '';
printf("from %s:%d %sn",
inet_ntoa(peer.sin_addr), ntohs(peer.sin_port), buf);
sendto(fd, buf, n, 0, (struct sockaddr *)&peer, peerlen); /* echo */
}
}
客户端 udp_client.c
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(int argc, char **argv) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server = {0};
char buf[2048];
const char *msg = argc > 1 ? argv[1] : "hello";
server.sin_family = AF_INET;
server.sin_port = htons(9999);
inet_pton(AF_INET, "127.0.0.1", &server.sin_addr);
sendto(fd, msg, strlen(msg), 0,
(struct sockaddr *)&server, sizeof(server));
ssize_t n = recvfrom(fd, buf, sizeof(buf)-1, 0, NULL, NULL);
if (n > 0) {
buf[n] = '';
printf("reply: %sn", buf);
}
return 0;
}
编译运行:
gcc -o udp_server udp_server.c && gcc -o udp_client udp_client.c
./udp_server &
./udp_client "ping"

本章小结
UDP = 带端口的尽力而为数据报
头只有 8 字节:简单换来不可靠
适合 DNS、直播、游戏、QUIC 底座
示例:socket(SOCK_DGRAM) + sendto/recvfrom
超时是常态反馈;可靠可上移到应用层
排障:ss -ulnp、tcpdump、安全组是否放行 UDP