Linux 传输层 UDP 协议详解:端口号、报头格式与数据报特性
摘要:UDP 是传输层中非常典型的协议。它没有连接建立过程,也不提供确认和重传机制,但正因为这种“轻量”,UDP 在 DNS、DHCP、TFTP、NFS 等场景中有很高的使用价值。本文从传输层职责、端口号、五元组、UDP 报头格式、面向数据报、缓冲区和 64K 限制几个角度,系统梳理 UDP 的核心概念和使用注意事项。
前言
学习网络编程时,很多人会先接触 socket、bind、sendto、recvfrom 这些接口,然后再反过来问一个更底层的问题:为什么 UDP 发送数据时不需要建立连接?为什么一次 sendto 发出去的数据,接收端不能像 TCP 那样随便分多次读取?
这些问题的答案,都在 UDP 的协议设计里。
UDP 位于传输层。传输层要解决的核心问题,是让数据从一台主机上的某个进程,交付到另一台主机上的某个进程。网络层能把数据送到目标主机,但到达主机之后,还需要知道该交给哪个应用程序,这就是端口号存在的意义。
一、传输层到底负责什么
可以把网络通信理解成两级定位:
IP 地址用于定位主机,端口号用于定位主机上的应用程序。传输层要做的就是在主机和应用进程之间补上这层映射关系。
在 TCP/IP 通信中,经常用一个五元组标识一条通信关系:
源 IP、源端口、目的 IP、目的端口、协议号
例如同一台服务器上可能同时运行 Web 服务、SSH 服务、DNS 服务。它们可能共享同一个 IP,但端口号不同,所以内核能够把收到的数据交给正确的进程。
可以通过下面的命令观察当前主机上的网络连接:
netstat -n
在一些新系统中,也常用:
ss -n
这类命令输出中看到的本地地址、远端地址、端口号、协议类型,本质上都是在描述通信关系。
二、端口号:定位应用程序的编号
端口号是一个 16 位整数,范围是 0 ~ 65535。它不是进程号,而是网络通信中的应用入口编号。
常见划分如下:
| 范围 | 含义 |
|---|---|
0 ~ 1023 | 知名端口,常见服务通常使用固定端口 |
1024 ~ 65535 | 动态端口,客户端程序常由系统自动分配 |
常见知名端口:
| 服务 | 端口 |
|---|---|
| SSH | 22 |
| FTP | 21 |
| Telnet | 23 |
| HTTP | 80 |
| HTTPS | 443 |
在 Linux 中,可以查看系统记录的常见服务端口:
cat /etc/services
自己写网络程序时,建议避开知名端口。比如做实验可以使用 8080、9090、10000 这类端口,既减少冲突,也避免需要额外权限。
三、关于 bind 的两个问题
1. 一个进程能不能 bind 多个端口
可以。一个进程可以创建多个 socket,每个 socket 绑定不同端口。例如一个服务进程可以同时监听 8080 和 9090。
从内核角度看,绑定关系是建立在 socket 上的,不是简单建立在进程上。只要多个 socket 的本地地址和端口组合不冲突,就可以存在。
2. 一个端口能不能被多个进程 bind
通常不可以。对于同一个 IP、同一个协议、同一个端口,如果已经被一个 socket 绑定,另一个进程再绑定会失败,常见报错是:
Address already in use
实际系统中存在 SO_REUSEADDR、SO_REUSEPORT 等选项,但它们有特定使用条件,不能简单理解成“任意多个进程都能随便绑定同一个端口”。初学阶段先记住默认规则:同一协议下,同一地址端口组合不能重复绑定。
四、UDP 报头格式
UDP 报头非常简单,固定 8 字节:
0 15 16 31
+-----------------+-----------------+
| 16位源端口号 | 16位目的端口号 |
+-----------------+-----------------+
| 16位UDP长度 | 16位UDP校验和 |
+-----------------+-----------------+
| 数据部分 |
+-----------------------------------+
字段说明:
| 字段 | 长度 | 作用 |
|---|---|---|
| 源端口号 | 16 位 | 标识发送方应用程序 |
| 目的端口号 | 16 位 | 标识接收方应用程序 |
| UDP 长度 | 16 位 | 表示 UDP 首部加 UDP 数据的总长度 |
| UDP 校验和 | 16 位 | 用于检查数据在传输过程中是否出错 |
UDP 报头之所以小,是因为它不维护连接状态,也不提供复杂的可靠性控制。它只负责把一个数据报从源端口交给目的端口。
这里需要特别注意 UDP 长度 字段。它是 16 位,理论上能表达的最大值是 65535,也就是常说的 64K 左右。这个长度包含 UDP 首部本身,因此真正能放业务数据的空间还要减去 8 字节首部。
五、UDP 的三个核心特点
1. 无连接
UDP 发送数据前不需要建立连接。只要知道对端 IP 和端口,就可以直接调用发送接口。
这和寄信很像:你只需要知道收件地址,就能把信寄出去;至于对方是否已经准备好接收,UDP 协议本身不做保证。
无连接带来的好处是开销小、流程简单;代价是协议层不会维护通信双方的状态。
2. 不可靠
UDP 没有确认机制,也没有重传机制。数据在网络中丢了、乱序了、对端没有收到,UDP 协议层不会像 TCP 那样主动补救,也不会把这类问题自动反馈给应用层。
这并不表示 UDP 没用。很多场景更看重速度、实时性或实现简单,例如一次 DNS 查询、局域网发现、实时音视频、日志上报等。应用如果需要可靠性,可以在应用层自己设计确认、重传、序号等机制。
3. 面向数据报
这是 UDP 最容易和 TCP 混淆的地方。
TCP 是面向字节流的。应用写入多次数据,对端读取时可能合并,也可能拆分,需要应用层自己处理边界。
UDP 是面向数据报的。应用层交给 UDP 多长的报文,UDP 就按一个完整数据报发送,不会把多个应用报文合并成一个,也不会把一个应用报文拆成多个 UDP 数据报。
假设发送端执行一次:
sendto 100 字节
接收端就应该用一次 recvfrom 接收这个数据报。不能期望像 TCP 那样循环调用 10 次,每次读 10 字节来拼出同一份数据。
对比可以这样理解:
| 对比项 | UDP | TCP |
|---|---|---|
| 数据模型 | 面向数据报 | 面向字节流 |
| 是否保留消息边界 | 保留 | 不保留 |
| 一次发送对应一次接收 | 语义上更接近 | 不保证 |
| 是否需要应用层拆包粘包处理 | 通常不需要处理粘包 | 通常需要 |
六、UDP 缓冲区如何理解
UDP 没有真正意义上的发送缓冲区。应用调用 sendto 后,数据会交给内核,由内核继续交给网络层处理。
UDP 有接收缓冲区。接收缓冲区用于暂存到达本机、还没被应用读取的数据报。但这里有两个重要限制:
- UDP 不保证接收缓冲区中数据报的顺序一定和发送顺序一致;
- 如果接收缓冲区满了,后续到达的数据报可能会被丢弃。
这也是 UDP“不可靠”的具体体现之一。应用层如果读得太慢,或者网络瞬时流量过大,就可能出现丢包。
另外,UDP socket 既能读,也能写,所以它也是全双工的。全双工表示通信双方可以同时发送和接收数据,不表示协议一定可靠。
七、64K 限制与大数据传输
UDP 首部中的长度字段是 16 位,因此单个 UDP 数据报最大长度约为 64K,并且这还包含 UDP 首部。
在真实网络环境中,不建议把 UDP 数据报做得太大。即使没有超过 64K,也可能因为底层链路 MTU 限制而发生 IP 分片。分片一旦丢失,整个数据报都无法正常交付,丢包概率会变高。
如果业务确实需要用 UDP 传输超过单个数据报大小的数据,一般要在应用层处理:
也就是说,大数据传输不是不能用 UDP,而是 UDP 不替你做分片重组、确认、重传和顺序控制。这些能力要么由应用层补上,要么换用更合适的传输方案。
八、一个简单的观察示例
下面这个示例只用来帮助理解“端口”和“数据报边界”,不是完整业务程序。
服务端接收一次 UDP 数据:
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
return 1;
}
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(9090);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
perror("bind");
close(sockfd);
return 1;
}
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&peer, &len);
if (n > 0) {
buffer[n] = '\0';
printf("recv %zd bytes: %s\n", n, buffer);
}
close(sockfd);
return 0;
}
客户端发送一次 UDP 数据:
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket");
return 1;
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(9090);
server.sin_addr.s_addr = inet_addr("127.0.0.1");
const char* message = "hello udp";
sendto(sockfd, message, strlen(message), 0,
(struct sockaddr*)&server, sizeof(server));
close(sockfd);
return 0;
}
编译运行:
gcc udp_server.c -o udp_server
gcc udp_client.c -o udp_client
./udp_server
./udp_client
服务端预期输出:
recv 9 bytes: hello udp
这段代码能看到几个关键点:
- 服务端需要
bind到固定端口,方便客户端发送; - 客户端知道服务端 IP 和端口后,可以直接
sendto; - UDP 使用
SOCK_DGRAM,表示数据报通信; - 一次
sendto对应一个 UDP 数据报,接收端用recvfrom接收。
九、基于 UDP 的常见协议
很多应用层协议会选择 UDP,原因通常是请求响应简单、实时性要求高,或者应用层自己能处理可靠性问题。
常见例子:
| 协议 | 作用 |
|---|---|
| DNS | 域名解析 |
| DHCP | 动态主机配置 |
| BOOTP | 无盘设备启动相关 |
| TFTP | 简单文件传输 |
| NFS | 网络文件系统 |
自己写 UDP 程序时,也可以在 UDP 之上定义应用层协议。比如给每个数据报设计命令字、序列号、正文长度、校验字段等。只要发送方和接收方遵守同一套格式,就能完成业务通信。
十、常见问题与易错点
1. 把 UDP 的“不可靠”理解成“一定会丢包”
不可靠表示协议层不承诺可靠交付,不代表每次都会丢。局域网内简单通信可能长期看起来都正常,但程序设计不能依赖这种偶然稳定。
2. 认为 UDP 没有连接就不需要端口
UDP 不建立连接,但仍然需要源端口和目的端口。没有端口,数据到达主机后就不知道该交给哪个应用进程。
3. 用 TCP 的读写思维理解 UDP
UDP 是面向数据报的。接收时要按“一个报文”来处理,不要把它当成连续字节流随意拆分。
4. 单个数据报发得过大
UDP 最大长度约 64K,但实际使用中不建议接近这个上限。报文越大,出现分片和丢失的风险越高。
5. 忽略接收缓冲区溢出
如果接收端处理太慢,UDP 接收缓冲区可能被填满,后续数据报会被丢弃。高并发或高频率场景中,要关注读取速度、缓冲区大小和应用层限流。
总结
UDP 的设计非常克制:端口定位进程,报头固定 8 字节,无连接、不保证可靠交付、保留数据报边界。它不像 TCP 那样替应用处理确认、重传和顺序控制,但也因此更轻量、延迟更低、实现更简单。
理解 UDP 时,重点不是背“无连接、不可靠、面向数据报”这几个词,而是要知道这些特点在代码里会带来什么影响:客户端可以直接发送,接收端按数据报读取,缓冲区满了可能丢包,大数据需要应用层分片。把这些细节理顺后,再写 UDP 程序时就不会把它误用成 TCP。

804

被折叠的 条评论
为什么被折叠?



