UDP 专章:不握手的快件——简单、粗暴、好用也易踩坑

AI2天前发布 beixibaobao
6 0 0

专栏:《计算机网络基础》· 第三篇·传输层
承接:《快递选「挂号」还是「平邮」?传输层一次讲清》
讲解维度: 概念 → 原理 → 应用 → 问题定位 + 可运行示例
读完你能: 亲手写出 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 的无情

  1. 先别开服务端,只跑客户端 → 应打印 timeout!

  2. 这说明: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()

服务端对 9998bind + recvfrom 即可看到谁在喊。
DHCP、部分设备发现就有类似「先喊一嗓子」的味道。

五、问题定位:UDP 翻车现场

5.1 症状速查

现象

常见原因

怎么查

客户端总超时

服务没开、IP/端口错、防火墙丢 UDP

先本机环回;再关防火墙对比;抓包

本机通、跨网不通

中间只放行 TCP、NAT 映射过期

查安全组是否放行 UDP 端口

偶尔丢包

Wi‑Fi 干扰、拥塞、缓冲满

对比有线;看是否需要应用重传

大包异常

MTU/分片问题

减小 payload 试验

能发不能收

绑错网卡/地址、权限、SELinux

ss -ulnp 看监听

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:更多是「有没有包、对不对端口、超时几次」

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 专章:不握手的快件——简单、粗暴、好用也易踩坑

本章小结

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

相关文章