目录
-
- [1. 再谈端口号](#1. 再谈端口号)
-
- [1.1 端口号范围划分](#1.1 端口号范围划分)
- [1.2 认识知名端口号(Well-Know Port Number)](#1.2 认识知名端口号(Well-Know Port Number))
- [1.3 一个进程是否可以bind多个端口号?](#1.3 一个进程是否可以bind多个端口号?)
- [1.4 一个端口号是否可以被多个进程bind?](#1.4 一个端口号是否可以被多个进程bind?)
- [2. UDP协议](#2. UDP协议)
-
- [2.1 UDP 协议首部格式](#2.1 UDP 协议首部格式)
- [2.2 UDP 核心特点](#2.2 UDP 核心特点)
- [2.3 UDP 的缓冲区](#2.3 UDP 的缓冲区)
- 补:sk_buff简介
-
- [1. sk_buff 核心指针(基础定义)](#1. sk_buff 核心指针(基础定义))
- [2. 报文封装(发送端,从上到下)](#2. 报文封装(发送端,从上到下))
- [3. 报文解包(接收端,从下到上)](#3. 报文解包(接收端,从下到上))
- [4. 核心总结](#4. 核心总结)
- [2.4 UDP 传输限制与分包处理](#2.4 UDP 传输限制与分包处理)
- [2.5 基于 UDP 的典型应用层协议](#2.5 基于 UDP 的典型应用层协议)
1. 再谈端口号
- 端口号(Port)标识了一个主机上进行通信的不同的应用程序

在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);

1.1 端口号范围划分
-
0 - 1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的。
-
1024 - 65535:操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的。
1.2 认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
-
ssh服务器,使用22端口
-
ftp服务器,使用21端口
-
telnet服务器,使用23端口
-
http服务器,使用80端口
-
https服务器,使用443
执行下面的命令,可以看到知名端口号
bash
cat /etc/services
我们自己写一个程序使用端口号时,要避开这些知名端口号。
1.3 一个进程是否可以bind多个端口号?
答案是:可以。
一个进程完全可以绑定多个不同的端口号,实现这一操作的核心方式是:在进程内创建多个套接字(socket),并分别对每个套接字调用 bind() 系统调用,将其绑定到不同的、未被占用的端口号上。
1.4 一个端口号是否可以被多个进程bind?
一般情况下,不可以。
2. UDP协议
2.1 UDP 协议首部格式

UDP 首部共 8 字节,包含以下字段:
| 字段(16 位) | 说明 |
|---|---|
| 源端口号 | 发送端的端口,可选字段,若不需要可设为 0 |
| 目的端口号 | 接收端的端口,用于交付给上层应用 |
| UDP 长度 | 整个 UDP 数据报(首部 + 数据)的字节数,最小值为 8(仅含首部),最大值为 65535(2¹⁶-1) |
| UDP 检验和 | 用于检测首部和数据在传输中是否出错,若校验失败则直接丢弃报文;该字段为可选,但在 IPv4 中推荐启用,IPv6 中必须启用 |
2.2 UDP 核心特点
-
无连接
通信前不需要建立连接,只需知道对方的 IP 和端口号就可以直接发送数据,减少了连接建立和释放的开销。
-
不可靠
没有确认、重传、排序等机制。如果报文丢失或乱序,UDP 协议层不会向应用层返回错误信息,也不会尝试恢复。
-
面向数据报
应用层交给 UDP 多长的报文,UDP 就原样发送,既不会拆分,也不会合并。
- 发送端调用 1 次
sendto发送 100 字节,接收端必须也调用 1 次recvfrom完整接收 100 字节,不能分多次接收。
- 发送端调用 1 次
-
全双工
UDP 的 socket 支持同时读写,同一连接可以双向传输数据。
2.3 UDP 的缓冲区
- 发送缓冲区 :UDP 没有真正的发送缓冲区。调用
sendto后,数据会直接交给内核,由内核传递给网络层,应用层无法控制数据在发送缓冲区的停留。 - 接收缓冲区:UDP 有接收缓冲区,但不能保证收到的报文顺序与发送顺序一致;如果缓冲区已满,后续到达的 UDP 报文会被直接丢弃。
我们梳理一下UDP的完整传输流程:
发送端:应用层序列化数据 → 调用sendto拷贝到内核 → UDP层封装首部 → IP层封装首部 → 数据链路层封装以太网帧头 → 网卡发送;
接收端:网卡接收报文 → 数据链路层解包 → IP层解包 → UDP层验校并解包 → 应用层调用recvfrom拷贝到用户空间。
在这个流程中,有两个关键问题需要内核解决:
- 如何在不同协议层之间传递UDP报文,同时完整保留各层首部和应用数据?
- 如何实现高效的封装和解包,避免频繁的内存拷贝(毕竟UDP追求轻量化、低开销)?
这两个问题的答案,都指向了Linux内核中的sk_buff结构。它不仅是UDP报文的"载体",更是内核网络协议栈的"血脉"------从UDP数据进入内核的那一刻起,就被封装在sk_buff中,后续所有协议层的处理(封装、解包、验校)都基于这个结构完成,甚至UDP接收缓冲区的实现,也是通过sk_buff链表来管理待读取的报文。
可以说,不理解sk_buff,就无法真正理解UDP缓冲区的底层逻辑,也无法明白UDP为何能实现"无真正发送缓冲区、快速交付"的特性。
补:sk_buff简介
struct sk_buff定义
cpp
struct sk_buff {
// 1. 链表管理:用于将多个 sk_buff 组织成队列/链表(如 UDP 接收缓冲区)
struct sk_buff *next;
struct sk_buff *prev;
struct sock *sk; // 关联的 socket
// 2. 缓冲区指针:核心定位字段,实现无拷贝封装/解包
unsigned char *head; // 缓冲区起始地址(固定不变)
unsigned char *data; // 当前层有效数据起始地址(核心,可移动)
unsigned char *tail; // 当前层有效数据结束地址(可移动)
unsigned char *end; // 缓冲区结束地址(固定不变)
// 3. 长度信息
unsigned int len; // 当前层有效数据长度(tail - data)
unsigned int truesize; // 整个缓冲区的实际大小(end - head)
// 4. 协议头指针:快速访问各层协议头部(通过 union 节省空间)
union {
struct tcphdr *th;
struct udphdr *uh;
struct icmphdr *icmph;
struct igmphdr *igmph;
struct iphdr *iph;
struct ipv6hdr *ipv6h;
unsigned char *raw;
} h; // 指向传输层/网络层头部
union {
struct iphdr *iph;
struct ipv6hdr *ipv6h;
struct arphdr *arph;
unsigned char *raw;
} nh; // 指向网络层头部
union {
unsigned char *raw;
} mac; // 指向数据链路层头部
// 5. 其他控制字段(协议类型、校验和、设备信息等)
__be16 protocol; // 报文所属的协议(如 ETH_P_IP、ETH_P_IPV6)
unsigned int pkt_type; // 报文类型(如广播、多播、单播)
struct net_device *dev; // 接收/发送该报文的网络设备
};
1. sk_buff 核心指针(基础定义)
| 指针名称 | 核心含义 | 是否随协议层变化 | 关键备注 |
|---|---|---|---|
head |
指向整个缓冲区的起始地址(固定不变) | 否 | 缓冲区的 "头部边界",分配后直到释放都不会移动 |
data |
指向当前协议层有效数据的起始地址 | 是 | 封装 / 解包的核心操作指针,用于跳过各层协议头 |
tail |
指向当前协议层有效数据的结束地址 | 是 | 用于标记当前层数据尾部,添加数据时会向后移动 |
end |
指向整个缓冲区的结束地址(固定不变) | 否 | 缓冲区的 "尾部边界",限制 tail 移动的最大范围 |
补充说明
- 缓冲区可用总空间:
end - head - 当前层有效数据长度:
tail - data - 头预留空闲空间:
data - head(用于封装时向前添加协议头) - 尾预留空闲空间:
end - tail(用于封装时向后添加数据)
2. 报文封装(发送端,从上到下)
封装流程:应用层 → 传输层(UDP/TCP) → 网络层(IP) → 数据链路层(以太网)
核心逻辑:先填充数据,再向前(head 方向)移动 data 指针,添加各层协议头(避免内存拷贝,高效封装)
| 处理阶段 | 指针操作步骤 | 对应 sk_buff 变化 |
|---|---|---|
| 1. 应用层数据写入 | 1. 应用层调用 sendto,将数据拷贝到 sk_buff 中2. 内核将 data 指向数据起始位置,tail 移动到数据结束位置 |
data 初始定位,tail = data + 应用层数据长度 |
| 2. 传输层封装(UDP) | 1. 向前移动 data 指针,预留 UDP 首部空间(8 字节)2. 在 data 指向的新位置填充 UDP 首部(源端口、目的端口等)3. 不修改 tail(数据长度不变,仅添加头部) |
data = data - sizeof(udphdr)``tail 保持不变 |
| 3. 网络层封装(IP) | 1. 继续向前移动 data 指针,预留 IP 首部空间(通常 20 字节)2. 在 data 指向的新位置填充 IP 首部(源 IP、目的 IP 等)3. 不修改 tail |
data = data - sizeof(iphdr)``tail 保持不变 |
| 4. 数据链路层封装(以太网) | 1. 继续向前移动 data 指针,预留以太网帧头空间(14 字节)2. 在 data 指向的新位置填充以太网帧头(源 MAC、目的 MAC 等)3. 不修改 tail |
data = data - sizeof(ethhdr)``tail 保持不变 |
| 5. 发送报文 | 此时 sk_buff 中包含完整报文(以太网头 + IP 头 + UDP 头 + 应用数据),内核将其传递给网卡发送 |
最终有效数据长度:tail - data(包含所有层头部 + 应用数据) |
3. 报文解包(接收端,从下到上)
解包流程:数据链路层(以太网) → 网络层(IP) → 传输层(UDP/TCP) → 应用层
核心逻辑:向后(tail 方向)移动 data 指针,跳过各层协议头,最终定位到应用层数据(无内存拷贝,高效解包)
| 处理阶段 | 指针操作步骤 | 对应 sk_buff 变化 |
|---|---|---|
| 1. 接收完整报文 | 1. 网卡接收报文,拷贝到 sk_buff 中2. 内核将 data 指向以太网帧头起始位置,tail 指向报文结束位置 |
data 初始定位到帧头,tail 定位到报文尾部 |
| 2. 数据链路层解包(以太网) | 1. 向后移动 data 指针,跳过以太网帧头(14 字节)2. 不修改 tail(仅跳过头部,数据主体不变)3. 验证帧尾校验和(可选),确认报文完整性 |
data = data + sizeof(ethhdr)``tail 保持不变 |
| 3. 网络层解包(IP) | 1. 向后移动 data 指针,跳过 IP 首部(20 字节,按需读取首部长度字段)2. 不修改 tail3. 验证 IP 校验和,提取源 / 目的 IP 地址 |
data = data + sizeof(iphdr)``tail 保持不变 |
| 4. 传输层解包(UDP) | 1. 向后移动 data 指针,跳过 UDP 首部(8 字节)2. 不修改 tail3. 验证 UDP 校验和,提取源 / 目的端口号 |
data = data + sizeof(udphdr)``tail 保持不变 |
| 5. 应用层读取数据 | 1. 此时 data 指向应用层数据起始位置,tail 指向应用层数据结束位置2. 应用层调用 recvfrom,将 [data, tail) 区间的数据拷贝到用户空间 |
最终获取:tail - data 长度的应用层原始数据 |
4. 核心总结
- 封装 / 解包的本质:仅移动
data指针(核心),不移动head/end,极少修改tail,避免频繁内存拷贝,提升内核处理效率。 - 封装是 "向前预留空间加头部 "(
data向head靠近),解包是 "向后跳过头部找数据 "(data向tail靠近)。 - 所有协议层共享同一个
sk_buff缓冲区,通过指针定位区分各层数据,这是sk_buff设计的核心优势。
2.4 UDP 传输限制与分包处理
UDP 首部的 16 位长度字段,决定了单个 UDP 数据报的最大长度为 64KB(包含首部)。
- 在现代网络环境中,64KB 是一个很小的数值,若需传输超过 64KB 的数据,必须在应用层手动分包,多次发送,并在接收端手动拼装。
2.5 基于 UDP 的典型应用层协议
- DNS:域名解析协议,通过 UDP 快速完成域名与 IP 的映射。
- DHCP:动态主机配置协议,为设备自动分配 IP 地址等网络参数。
- TFTP:简单文件传输协议,用于小文件的简单传输。
- NFS:网络文件系统,实现远程文件共享。
- BOOTP:启动协议,为无盘设备提供启动所需的网络参数。
也包括自定义的 UDP 应用层协议。