Linux 网络基础之UDP协议(四)传输层协议 UDP,再谈端口号,UDP 特点

目录

一、从应用层到传输层

二、再谈端口号

​编辑

认识五元组

什么是五元组?

[相关问题 :](#相关问题 :)

端口号划分

认识知名端口号

[一个进程是否可以 bind 绑定多个端口号?](#一个进程是否可以 bind 绑定多个端口号?)

[一个端口号是否可以被多个进程 bind 绑定?](#一个端口号是否可以被多个进程 bind 绑定?)

[三、UDP 协议](#三、UDP 协议)

[UDP 协议端格式](#UDP 协议端格式)

流程总结:

相关问题:

[问题 1:为什么我们之前 socket 编程时,port 是 16 位的?](#问题 1:为什么我们之前 socket 编程时,port 是 16 位的?)

[问题 2:a. 报头和有效载荷如何分离? b. 有效载荷分用问题?](#问题 2:a. 报头和有效载荷如何分离? b. 有效载荷分用问题?)

[问题 3:udp 存在数据报粘包问题吗?](#问题 3:udp 存在数据报粘包问题吗?)

[问题 4:什么是封装和分用?](#问题 4:什么是封装和分用?)

[问题 5:如何理解 UDP 协议报头?](#问题 5:如何理解 UDP 协议报头?)

[问题 6:所以,我们如何理解封装?](#问题 6:所以,我们如何理解封装?)

先描述

再组织

[inet_sock 结构体](#inet_sock 结构体)

[udp_sock 结构体](#udp_sock 结构体)

[总结(重要) :](#总结(重要) :)

[四、UDP 的特点](#四、UDP 的特点)

[UDP 核心特点](#UDP 核心特点)

[UDP 面向数据报的独有特性](#UDP 面向数据报的独有特性)

[对 UDP "不可靠" 的本质理解](#对 UDP “不可靠” 的本质理解)

[UDP 的缓冲区](#UDP 的缓冲区)

[UDP 使用注意事项](#UDP 使用注意事项)

五、总结


一、从应用层到传输层

前面我们主要围绕 HTTP 协议 展开了讲述,也就是围绕应用层 展开的。但如果把网络传输比作快递配送,应用层只是确定了 "包裹里装什么、装的格式以及要寄给谁",而包裹怎么高效、稳定地传输,靠的是应用层之下的传输层 。也是网络分层中的关键一层,专门负责端到端 的数据传输管控。而传输层里,最基础、最简洁的协议,就是UDP 协议,它和我们后续要学的 TCP 协议,共同构成了传输层的核心,却有着完全不同的传输逻辑。

所以本篇文章我们就再对传输层的 UDP 协议展开讲述。

二、再谈端口号

在对 UDP 协议展开讲述之前,我们再谈一下端口号:

通过之前的讲解我们已经知道了 :IP 地址的作用,是帮我们定位互联网上某一台主机,就像小区的门牌号,能找到是哪一栋楼、哪一户房子。但一台电脑上可能同时跑着好多程序:浏览器、微信、抖音、后台服务等各种软件,光靠 IP 只能找到这台电脑,没法区分数据该交给哪个应用程序。这时候,端口号就登场了。

所以 :

IP 地址只能帮我们定位到互联网上的某一台主机,就像知道了小区的地址,但这台主机上同时运行着多个网络应用程序,比如图里的 FTP 服务器、SSH 服务器、HTTP 服务器,还有对应的客户端进程,它们都需要收发数据。如果只有 IP 地址,操作系统收到数据后,就不知道该把数据交给哪个程序处理。而端口号就是给每个网络应用分配的唯一标识,它相当于进程级别的 "门牌号",和 IP 地址配合起来,组成 "IP + 端口" 的完整地址,让操作系统能精准地把收到的数据转发给对应的应用程序。从上图也能看出,服务器端的服务大多使用固定的知名端口,比如 HTTP 用 80 端口、SSH 用 22 端口,方便客户端直接访问;而客户端进程则使用操作系统分配的动态端口,用完就释放,这样就能在同一台主机上同时运行多个网络程序,实现互不干扰的并发通信。

这里再补充一个知识 :其实服务器不是只有机房里的大机器才叫服务器,我们自己的电脑上,也能跑各种 "服务器程序",甚至平时用的软件里,很多都自带了服务端的角色。简单来说,"服务器" 和 "客户端" 不是硬件,而是程序的角色:谁等着别人来连接、提供服务,谁就是服务器;谁主动去连接别人、请求服务,谁就是客户端。

就拿我们电脑来说:我们用浏览器访问抖音,抖音机房里的机器是 HTTP 服务器,我们的浏览器是客户端;但如果我们的电脑上如果跑了一个本地的 Web 服务 (比如用 VS Code 编写并运行一个简单的 HTTP 服务器或者 MySQL 数据库),那我们自己的电脑,此刻就成了 "服务器",别的程序都可以连上来请求数据。再比如我们电脑上的 SSH 服务、FTP 服务,甚至一些游戏的联机服务,只要是 "监听某个端口,等着别人来连" 的程序,本质上都是服务器程序,和机房里的机器只是规模、性能不同,但是核心逻辑是一样的。机房里的服务器只是为了稳定、高性能、24 小时不关机,专门用来跑这些服务而已,和我们电脑上的本地服务,底层原理是一样的。

认识五元组

观察上图,我们能清晰的看到同一台服务器是如何同时处理多个客户端请求的:服务器主机 IP 为 172.20.100.32,上面运行着多个 HTTP 服务,都监听在 80 端口;客户端 A 的 IP 是 172.20.100.34,它的浏览器开了两个标签页,分别用 2001 和 2002 两个不同的源端口号向服务器的 80 端口发起请求;客户端 B 的 IP 是 172.20.100.33,用 2901 端口发起了同样的请求。

这三组请求的目标 IP 和目标端口完全相同,但服务器依然能精准区分它们,靠的就是数据包里的源 IP 地址、目标 IP 地址、协议号、源端口号和目标端口号 这五个关键信息,这组信息就被称为 "五元组"

什么是五元组?

在 TCP/IP 协议中,通信连接的唯一性,正是由源 IP、源端口号、目的 IP、目的端口号、协议号这五元组共同标识的,操作系统会通过这五个值,区分同一台主机上成千上万条并发通信连接,避免数据串线。

我们可以通过 netstat -n 命令直接查看当前主机所有网络连接的五元组信息,用来验证和排查网络状态。

相关问题 :

1. 关于这个 IP,之前我们讲过几种不同的 IP,比如公网 IP、本地回环、内网 IP。 在我的理解中,我一直认为 IP 的作用和桥梁一样,比如说访问同一个端口号可以用公网 IP,也可以用内网 IP 和本地回环 IP。和我们现在说的 IP 定位一台主机好像不是一个概念。怎么理解?

首先,"IP 定位主机" 和 "IP 是桥梁" 这两种理解,是从不同层面看同一件事,本质上并不矛盾。IP 地址的核心作用,就是在网络中唯一标识一台主机,这是它最底层的定义;而我理解的 "桥梁的作用",是它在不同网络场景下,帮我们建立连接的方式。

"访问同一个端口号,可以用公网 IP、内网 IP、回环 IP",本质上是因为这三个 IP,最终都指向了同一台主机上:

  1. 用 127.0.0.1:80 访问:数据在本机内,定位的是 "我自己这台电脑";
  2. 用 192.168.1.100:80 访问:数据在局域网内,定位的是 "局域网里的这台设备";
  3. 用公网 IP 访问:数据在互联网上,定位的是 "互联网上的这台设备"。

不管是公网 IP、内网 IP 还是本地回环 IP,本质上都是在各自对应的网络范围内定位主机,它们最终的目标都是同一个:找到我们这台主机,再通过端口号找到对应的进程,完成通信。所以,IP 的本质始终是定位主机的唯一标识,而不同类型的 IP,只是在不同的网络环境下,帮我们完成定位的不同方式。

2. 客户端的 IP 和端口号

客户端去连服务器的时候,只需要主动指定填:服务器 IP + 服务器端口 。我们不用手动填自己客户端的 IP、也不用手动选自己的端口 。客户端自己的 IP,是系统自带、自动携带 在数据包里的,不用我们声明和配置,发包的时候系统会自动给加上 IP;客户端自己的端口,是操作系统自动随机分配一个空闲的临时端口 ,也不用我们手动指定。数据包发到服务器那边,服务器收到包,自动就能从数据包里解析出客户端 IP 和客户端端口,就能知道是谁发的请求、该把数据回传给谁。

3. 回顾之前我们写的 UDP 套接字,我们分别写了服务端和客户端,运行时如下图,在这里我们需要重新梳理一下客户端与服务端在 IP 地址、端口号的使用逻辑,以及要说明客户端为何只需指定服务端 IP 和端口、而服务端不需要显式的声明 IP 地址,以及 INADDR_ANY 在其中的作用。

在客户端使用不同 IP 访问服务端时,公网 IP、内网 IP、本地回环 IP 的作用都是定位服务器主机,只是作用范围不同:用公网 IP 访问,就是在整个互联网范围内找到对应的服务器主机;用内网 IP 访问,则是在同一个局域网内找到服务器主机。

客户端运行时,除了要指定服务器的 IP 地址外,还必须声明服务端绑定的端口号,比如 8080,因为服务端程序已经绑定了这个端口,客户端只有同时定位到正确的 IP 和端口,才能连接到目标服务。

而服务端这边,只需要声明自己要绑定的端口号即可 ,这个端口号就是服务端程序对外提供服务的标识,客户端必须匹配这个端口才能建立通信。INADDR_ANY的作用,正是为了适配客户端通过不同 IP 定位服务器主机的场景:客户端可能通过公网 IP、内网 IP 或本地回环 IP 访问,服务端需要同时接收来自这些不同 IP 的请求,因此通过INADDR_ANY监听本机所有可用 IP 地址,只要请求的目标端口是服务端绑定的 8080,就能被正确接收并建立通信连接。

端口号划分

认识知名端口号

我们先讲一下知名端口号,上图列的 22/21/23/80/443 这些,就是知名端口号,它们的特点是:

  1. 范围固定在 0-1023,是互联网上约定俗成的标准端口;
  2. 专门分配给大家公认的常用服务,比如 SSH 用 22、HTTP 用 80、HTTPS 用 443;
  3. 操作系统默认会保护这些端口,普通用户程序不能随便绑定,避免冲突;
  4. 并且可以用 cat /etc/services 查看系统里所有已注册的知名端口列表。

除了知名端口之外,剩下的端口号被分为了注册端口和临时端口 :

  1. 知名端口(0-1023):刚才说的固定服务端口,标准约定,不能乱用;
  2. 注册端口(1024-49151):也叫用户端口 / 静态端口,这些端口没有被系统强制保护,我们自己写的服务程序,比如之前用的8080,就属于这一类;
  3. 临时端口(49152-65535):也叫动态私有端口,操作系统会自动从这里分配端口给客户端程序使用,用完就释放,不会被固定占用。

首先,知名端口(0-1023)是被系统和行业标准固定占用的,普通用户程序不能随意绑定,比如 HTTP 的 80 端口、SSH 的 22 端口,这些端口的用途是约定俗成的,操作系统也会对它们做权限保护,避免被随意占用。

然后是注册端口(1024-49151),这部分端口没有被系统强制保护,普通用户也可以绑定。其中有一部分端口也被行业约定俗成地分配给了特定服务,比如 MySQL 的 3306、Redis 的 6379,但这些约定也不是强制的,我们也可以修改这些服务的配置,让它们监听其他端口,我们自己写的服务程序,比如之前用的 8080 端口,就属于这一类,是我们手动指定、固定绑定的端口,用来对外提供服务,方便客户端稳定找到它。

最后是临时端口(49152-65535),这部分端口是操作系统专门为客户端程序准备的,客户端发起连接时,系统会自动从这里分配一个空闲端口,用完就释放,不会被长期占用,我们写客户端程序时,不需要手动指定这部分端口,系统会自动处理,所以平时我们自己开发服务端时,基本不会主动去绑定这部分端口。

简单说,我们手动使用端口号时,主要就是在注册端口里选一个不冲突的端口来绑定服务端,临时端口交给操作系统自动分配就行。

一个进程是否可以 bind 绑定多个端口号?

一个进程可以 bind 绑定 **多个端口号。**进程可以创建多个 socket 文件描述符,每个 socket 分别 bind 不同的端口号,这样就能同时监听多个端口,处理不同端口的网络请求,很多服务端程序就是通过这种方式同时提供多种服务的。

一个端口号是否可以被多个进程 bind 绑定?

在默认情况下,一个端口号不能被多个进程同时 bind,因为操作系统会保证同一协议下 (比如 TCP 或 UDP),同一个 IP + 端口的组合只能被一个进程占用,避免数据无法正确分发。

不过,我们可以通过设置特定的 socket 选项 (如 SO_REUSEPORT),在 Linux 等系统中可以实现多个进程同时 bind 同一个端口,让多个进程同时处理同一端口的请求,这种方式常被用于负载均衡场景。

再清楚了 IP、端口号、五元组这些基础概念后,下面我们就正式开启传输层 UDP 协议的学习,揭开 UDP 神奇的面纱。

三、UDP 协议

UDP 全称用户数据报协议(User Datagram Protocol),我们先来看一下它的格式:

UDP 协议端格式

UDP 的整个报文格式分为两大部分:8 字节的报头 加上后面的数据载荷。如下图 :

前面的四个字段,就是组成这8 字节报头 的全部内容。我们先看报头的四个字段,它们每个都是 16 位 (2 字节),刚好拼成 8 个字节:

(1). 16 位源端口号:发送方的端口号,用来告诉接收方 "这个数据是谁发的",方便接收方回传响应数据。如果不需要回传,这个字段也可以设为 0。

(2). 16 位目的端口号:接收方的端口号,用来告诉操作系统 "这个数据要交给哪个进程处理",也就是我们之前说的五元组里的 "目的端口",是服务端绑定的那个端口。

(3). 16 位 UDP 长度:表示整个 UDP 报文的总长度,包括报头和后面的数据部分,单位是字节。因为是 16 位,所以一个 UDP 报文的最大长度是 65535 字节。

(4). 16 位 UDP 检验和:用来校验数据在传输过程中有没有出错。发送方会计算一个校验和并填入,接收方收到后重新计算校验和,如果不一致,就说明数据在传输中被篡改或损坏了。

剩下的最后一部分就是数据载荷,也就是我们应用层交给 UDP 协议发送的原始数据。UDP 不会对这些数据做任何的修改、拆分或重组,只是把它们原封不动地装在自己 8 字节的报头后面发送出去,接收方收到后,再把这部分数据完整地交给自己的应用层处理。也正因为如此,UDP 是 "面向数据报" 的,你发一个数据报,对方就会收到一个完整的数据报,不会出现粘包的问题。

流程总结:

作为客户端,客户端应用层的数据,会原封不动地交给下面的传输层。如果传输层用的是 UDP 协议,它就会把这份数据直接 "打包"------ 加上 8 字节的 UDP 报头(源端口、目的端口、长度、校验和),然后继续向下传递给网络层,并且不会对应用层的数据做任何修改,这符合 UDP "面向数据报" 的特性。

有个问题,这里的应用层的数据是上一篇我们在讲 HTTP 协议时请求报文的数据吗?

答案并不是。HTTP 协议本身是基于 TCP 的,它的请求和响应报文只会交给 TCP 传输层,不会走交给 UDP 传输层。而应用层交给 UDP 的数据,可以是各种不同的格式,取决于我们用的应用层协议是什么:

  1. 比如 DNS 查询,应用层交给 UDP 的是 DNS 协议的查询报文;
  2. 比如视频通话(RTP 协议),应用层交给 UDP 的是视频帧数据;
  3. 比如我们之前写的 UDP 聊天程序,应用层交给 UDP 的就是用户输入的纯文本字符串;

甚至我们也可以自定义一个简单的协议,比如 "类型 + 长度 + 数据" 的格式,应用层按这个格式组织数据,交给 UDP 发送就行。换句话说,应用层交给传输层的数据,完全由我们用的应用层协议决定,UDP 只是个 "搬运工",它不关心数据的内容是什么,只负责把数据完整地打包发送出去。

这里就顺便补充一下 : 绝大多数场景下 HTTP 协议都是基于 TCP 的,传统的 HTTP/1.0、HTTP/ 1.1 和 HTTP/2 协议,都是基于 TCP 的,和 UDP 没有关系。它们的请求和响应报文,会全部交给 TCP 传输层来处理,通过 TCP 的可靠连接来保证数据的完整、有序传输。但有一个特殊情况需要补充:HTTP/3 协议是个例外,它底层改用了基于 UDP 的 QUIC 协议来传输数据,目的是降低延迟、提升性能。但 HTTP/3 目前还没有完全普及,我们平时接触的大部分 HTTP 服务,依然是基于 TCP 的。

相关问题:

问题 1:为什么我们之前 socket 编程时,port 是 16 位的?

因为 UDP 协议在设计时,就把端口号字段定义为16 位,这是内核层面的硬性规定。16 位的取值范围是 0~65535,刚好对应了我们之前讲的所有端口号(知名端口、注册端口、动态端口),所以 socket 编程中端口号用 16 位来表示。

问题 2:a. 报头和有效载荷如何分离? b. 有效载荷分用问题?

a. UDP 的报头是固定 8 字节长度的,接收方收到数据后,直接跳过前 8 个字节,剩下的部分就是有效载荷(应用层数据);再配合报头里的 16 位UDP长度字段 (整个 UDP 报文的总长度),用总长度减去 8 字节报头长度,就能精准得到有效载荷的字节数,从而进行报头和有效载荷的分离。

b. 有效载荷的分用,靠的是报头里的 16 位目的端口号。内核收到 UDP 数据报后,会根据目的端口号,把数据交给绑定了这个端口的进程处理,这样就能把不同端口的数据包分发给对应的应用。

问题 3:udp 存在数据报粘包问题吗?

UDP不存在粘包问题。因为 UDP 是 "面向数据报" 的协议,每个数据报都是独立的单元,发送方发一个数据报,接收方就会完整地收到一个数据报,不会出现多个数据报被合并接收的情况,所以也就没有 TCP 那样的粘包 / 拆包问题。

问题 4:什么是封装和分用?

封装 (Multiplexing) :

封装是发送方的操作,也就是我们之前讲的 "打包数据":当应用层的数据交给传输层 (比如 UDP) 时,传输层会给它加上报头 (源端口、目的端口等),把多个应用进程的数据,统一封装成 UDP 数据报,再交给网络层发送出去。

分用 (Demultiplexing) :

分用是接收方的操作,是封装的逆过程:**当 UDP 数据报到达接收方的主机后,传输层会根据报头里的目的端口号,把数据报里的有效载荷,交给绑定了这个端口的目标应用进程。**让每个进程只拿到属于自己的数据。


问题 5:如何理解 UDP 协议报头?

我们之前讲过,协议本质上就是结构体,并且传输层是在操作系统内核中实现的,操作系统的内核 是 C 语言写的,所以 UDP 协议的报头就是一个 C 语言结构体 ,在操作系统内部就用这个结构体来直接操作报头中各个字段的数据。

怎么证明? 我们直接在 Linux 内核源码中能找到这个结构体的定义 :

**这里我们要补充的就是内核中的 UDP 报头结构体,和网络字节流是 "天生对齐" 的。**这个又怎么理解呢?

我们都知道,只要是在网络中进行传输的数据,数据的形式必须是原始的二进制字节流,没有例外。不管是 UDP 报头、TCP 报头、HTTP 报文,还是我们自己发送的聊天字符串或自定义结构体数据,一旦进入网线传输,都会统一变成一串连续的字节流,不会存在其他的数据格式,只剩下 0 和 1 组成的二进制字节流。我们编程中使用的字符串,本质上就属于字节流的一种表现形式,字符串在计算机内部会按照 ASCII、UTF-8 等编码方式存储为一串字节流,因此字符串天生就适配网络传输的要求,不需要再对字符串进行额外的转换,直接发送到网络中即可

对于 UDP、TCP 这类传输层协议报头来说,操作系统内核已经按照网络协议的标准,提前定义好了对应的结构体,让结构体内存排布和网络二进制字节流排布一致。内存中它是可以直接访问成员的结构体,向外发送时直接把结构体对应的数据当作字节流发送即可,不需要再进行序列化和反序列化操作。

而应用层的数据,无论是 HTTP 请求响应报文、普通文本字符串,还是 JSON 数据等,它们不一样,内核没有为这些应用层的数据预定义专用结构体,网络本身也无法识别这些自定义数据格式。这就需要我们自己处理,先把应用层数据按照约定格式整理好,转换成网络可传输的字节流,这个过程就是序列化;接收端拿到原始字节流后,再自行解析拆解,还原成程序可识别的业务数据,这就是反序列化。

那我们又如何理解 UDP 报头结构体的内存排布和网络二进制字节流排布一致呢?

互联网标准明确规定了 UDP 报头的固定格式,整体固定占用八个字节,依次划分出源端口、目的端口、UDP 总长度和校验和四个部分,每一项内容各占两个字节。Linux 内核在定义 struct udphdr 结构体时,也遵循这个标准,不仅复刻了字段的先后顺序,也匹配了每个字段占用的字节大小,整个结构体的内存大小恰好为八个字节。该结构体在内存中是以连续八个字节的形式依次排列的,向外进行网络传输时,会直接将这部分内存对应的字节数据原样发送,网络传输的二进制字节顺序,和内核结构体在内存中的字节顺序完全对应,因此无需额外做格式转换与翻译,也不用进行序列化操作,只需通过指针强制转换,就能直接读取结构体中的各个字段。
也就是说当内核收到一个 UDP 数据包时,它会拿到一个指向这段字节流的指针,然后直接把这个指针强制转换成 struct udphdr*,就能直接访问 UDP 报头里 source、dest 这些字段,不需要任何额外的反序列化和序列化解析。

问题 6:所以,我们如何理解封装?

封装的本质就是逐层在应用层数据前面拼接各类协议头部,而操作系统内核,正是依靠一块专用缓冲区、指针偏移操作和各层协议结构体配合 来完成整个封装流程的。我们也可以理解为封装就是对结构体变量的拷贝,底层原理是每一层协议报头都是由固定格式的内核结构体定义的,内核在缓冲区提前预留出对应的空间后,就能对结构体内的成员进行赋值,就等同于把一份标准的协议报头完整的写入数据前方,从而完成逻辑上的拷贝封装。这里需要多思考一下。

我们可以通过一个发送方发送数据的例子理解封装的过程 :

  1. 当应用层有业务数据 (比如 "你好") 需要发送时,操作系统内核会专门开辟一块连续的内存缓冲区 ,首先会把应用层下发的数据完整拷贝到这块缓冲区的末尾位置 ,此时缓冲区前方会留出空闲的内存,专门用来存放后续传输层、网络层、数据链路层的各类协议头部。进入传输层 UDP 封装环节时,内核会拿着指向数据起始位置的缓冲区指针,向左偏移 UDP 报头固定的 8 字节大小,让指针定位到应用层数据前方的空闲区域 ,这片空间就是为 UDP 报头专门预留的位置,此时我们如果把当前指针强转成 UDP 报头结构体指针时,就可以直接给源端口、目的端口、报文长度、校验和这些字段赋值,赋值完成后 UDP 报头就规整排布在应用层数据前面,传输层的封装就此完成。

  2. 紧接着进入网络层 IP 封装,内核继续将指针向左偏移最小 20 字节的 IP 报头长度,定位到 UDP 报头前方的空闲位置,同样通过强制转换为 IP 结构体指针的方式,填写源 IP、目的 IP、上层协议标识等头部信息,此时缓冲区内部数据排布就变成了 IP 报头、UDP 报头、应用层数据的结构。最后进行数据链路层封装,指针再次向左偏移 14 字节的以太网帧头长度,填写源 MAC 地址、目的 MAC 地址和帧类型字段,最终组装出以太网帧头、IP 报头、UDP 报头、应用层数据的完整数据包,格式规整后就可以直接交给网卡向外发送。

  3. 整个流程里的指针,本质就是指向缓冲区内存起始地址的标识。初始状态下指针指向缓冲区里应用层数据的起始地址,每进行一层协议封装,都会让指针向地址变小的左方移动 ,移动的字节数严格等于当前层协议头部的固定大小,偏移后的指针刚好落在预留的空闲内存位置,**再通过结构体指针强转,就能直接操作这片内存空间、批量填写协议头部字段。**用具体地址举例就能更直观理解,假设应用层 4 字节数据存放在 0x96 到 0x99 地址区间,指针初始指向 0x96,左移 8 字节后定位到 0x8E,对应 8 字节空间存放 UDP 报头;再左移 20 字节定位到 0x7A,对应 20 字节空间存放 IP 报头;最后左移 14 字节定位到 0x6C,对应空间存放以太网帧头,每层都精准占用预留区域。

  4. 而封装本质等同于结构体变量拷贝的原因也很好理解,**TCP、UDP、IP、以太网每一层协议报头,在内核中都有严格对应标准格式的结构体定义,内核只需在缓冲区预留好对应大小的内存,直接对结构体各个成员字段赋值即可。**整个过程不会产生多余的内存数据搬运,只是在同一块缓冲区里依靠指针偏移定位各层头部位置,赋值完成后,缓冲区每一段内存布局,都和网络传输时协议二进制排布一致,就等同于把标准协议结构体完整复刻到了数据包前方。

  5. 和发送方封装流程完全相反的就是接收方的解封装过程 ,当网络数据包到达本机网卡后,会被送入内核缓冲区,首先由数据链路层解析并剥离最外层以太网帧头,把剩余数据上交网络层;网络层读取并去掉 IP 报头后,再把剩余数据上交传输层;传输层解析剥离 UDP 报头,最后只把纯粹的应用层数据交付给应用层程序,一步步拆去每层协议头,还原出原始业务数据。

整体总结来看,网络封装的核心逻辑十分清晰,内核依托一块专用缓冲区,通过指针不断向左偏移为各层协议头预留空间,再配合对应协议的结构体指针直接赋值填充头部信息,自上而下逐层拼接以太网帧头、IP 报头、UDP 报头和应用层数据,最终组装成可在网络中传输的完整数据包,整个封装过程就是依靠缓冲区、指针偏移与协议结构体三者配合实现的。


这个缓冲区是我们之前在 Socket 编程那里说的两个收发缓冲区是一个缓冲区吗?

我们之前在 socket 编程时讲的收发缓冲区和现在这个缓冲区不是同一个东西,但这两个缓冲区之间的关系是紧密相关的,我们在后面会详细介绍。


问题 6:如何理解报文? 在问题 5 的基础上重新理解封装和解封装的过程

我们所说的报文,整体就是由各层协议报头加上应用层的有效载荷数据组成的完整数据单元,而且报文在协议栈每一层所呈现的范围都不一样;传输层的报文,就是自身传输层报头加上应用层有效载荷;到了网络层的报文,是网络层报头再把传输层整个报文当作自己的有效载荷;到了数据链路层的报文,又是链路层报头把网络层整个报文当作自己的有效载荷,每一层都把上一层的整体内容当成自己的数据载荷,再加上本层报头,逐层封装,越往下报文包含的头部就越多,整体体量也越大。由于我们当前讲的是传输层,所以这里讲的报文就是传输层报文,即传输层协议报头加上应用层有效载荷组成的数据单元,不包含网络层、数据链路层添加的协议报头。

这里我们说的报文和前面我们讲 HTTP 时的请求相应报文完全不是一个东西,二者有着本质的区别。我们前几篇文章讲的 HTTP 请求响应报文属于应用层范畴,是单纯的业务数据,仅包含 HTTP 请求行、请求头、请求体或响应行、响应头、响应体,不携带任何传输层、网络层的协议头部,只由应用层的 HTTP 客户端和服,和操作系统内核无关。简单来说,HTTP 报文只是内核报文里的有效载荷部分,会被层层封装进内核管理的完整报文中,二者是包含与被包含、应用层数据与底层传输数据包的关系。

下面继续引出下一个问题,在操作系统运行过程中,同一时间肯定会存在大量报文,这些报文在不同阶段,有的报文可能在传输层,有的报文可能在网络层,有的报文可能在数据链路层,那么此时操作系统必然要对这些处于协议栈不同阶段的报文进行管理。管理方法依旧是先描述,再组织

先描述

在 Linux 内核里,内核用统一的结构体 struct sk_buff 来 "描述" 单个报文的结构。下面我们就对这个 sk_buff 结构体展开讲述,首先我们现在内核源代码中找到对这个结构体的表述,如下:

sk_buff 结构体里存储的是处理这个报文需要的所有信息。我们一个个来看:

首先最开始有两个 sk_buff 同类型的指针 next 和 prev,这两个指针就表明了 OS 是如何 "再组织" 这些报文的。OS 会把多个 sk_buff 结构体串成一个双向链表 ,这样内核就能高效地管理同一个 socket 上的所有报文,比如在发送队列上就是等待网卡发送的报文按顺序排队。接收队列上就是等待应用层读取的报文按顺序排队。这样内核就能通过遍历链表,对大量报文进行增删改查,而不是零散地处理单个报文。

sk_buff = 结构体 + 内核缓冲区
再组织

然后接下来 struct sk_buff 里有一个 struct sock *sk 指针,这个指针就是把报文和它所属的 socket 绑定起来的关键纽带,而我们之前在讲 socket 编程时讲到的的两个缓冲区 (接收缓冲区和发送缓冲区),本质上就是用 sk_buff 的双向链表实现的。

sk_buff 结构体中存在一个 struct sock 类型的 sk 指针,这个指针就是连接 socket 与网络报文的核心纽带。这里我们纠正一个前面讲的误区,我们在 socket 编程中提到的发送缓冲区与接收缓冲区,本质并不是单纯存放数据的缓冲区,而是依靠 sk_buff 结构体中的 next、prev 双向指针构建而成的双向链表队列。每一个 sk_buff 都通过 next、prev 指针相互串联,形成完整双向链表,按照从尾部入队、头部取出的规则,就天然实现了先进先出的队列特性。因此内核 socket 结构体内部维护着两个队列的头结点 (sk_buff_head 类型,head 就是头结点的意思),分别是接收队列与发送队列,二者本质都是双向链表表头,发送队列存放等待封装下发的报文,接收队列存放网卡收到等待交付应用层的报文。逻辑上我们习惯称它们为 socket 收发缓冲区,但实际上它们只负责报文排队管理,并不直接存储原始数据。正是因为 sk_buff 拥有 sk 指针,内核在调用 send 发送数据时,会将封装好报文数据的 sk_buff,通过 sk 指针绑定到对应的 socket,然后挂入到发送队列中;接收数据时同理,内核通过 sk 指针匹配归属 socket,将收到报文的 sk_buff 挂入接收队列中,等待应用层读取处理。
整个发送流程就是应用层调用 send/sendto 发送数据 → 内核直接创建 sk_buff 结构体 → 将应用层数据拷贝到 sk_buff 自带的数据缓冲区 → 再通过 sk 指针绑定对应 socket → 将 sk_buff 挂入 socket 的发送队列(逻辑上的发送缓冲区)排队 → 然后内核从队列取出 sk_buff,通过缓冲区指针偏移,逐层添加 UDP/TCP 报头、IP 报头、以太网帧头 → 最后封装完成后交给网卡发送。

整个接收流程就是网卡接收网络数据 → 内核创建 sk_buff,将数据存入 sk_buff 的缓冲区中 → 逐层解封装、剥离各层协议头 → 通过报文信息找到对应 socket,通过 sk 指针绑定 → 将 sk_buff 挂入 socket 的接收队列→应用层调用 recv/recvfrom 时,直接从接收队列拿到 sk_ buff 缓冲区并读取数据。

这里需要注意的就是必须先将未封装的 sk_buff 报文挂载到 socket 发送队列中排队,此时报文只存放原始应用数据,还没有添加任何协议头部。等到内核需要下发发送时,再从发送队列中取出对应的 sk_buff,通过指针偏移逐层封装各层报头,封装完成后直接交给网卡发送。绝对不能先封装报文再挂入发送队列,因为报文入队后不一定会立刻发送,提前封装会造成 TCP 窗口限流错乱、重传重复计算、多线程顺序混乱等一系列问题,因此 Linux 协议栈严格规定先入队排队,再取出封装发送,这个先后顺序不能颠倒。
UDP、TCP 都遵守这套机制。不管 TCP 还是 UDP,socket 内核本体都是 struct sock,收发缓冲区全都是 sk_buff 双向链表队列,所有的报文数据,都在 sk_buff 自带的缓冲区中,指针偏移加报头封装等逻辑都通用,唯一的区别就是 TCP 会涉及到序号、滑动窗口、超时重传、乱序重组等问题,所以会多一套逻辑去管理 sk_buff 链表。但缓冲区结构、sk_buff 用法、队列链表原理、sk 指针绑定、报文存储方式 TCP 和 UDP 是完全相同的。
协议栈中报文在各层之间的流转,本质上就是以 sk_buff 指针为载体的函数调用过程 。发送方向上,报文从应用层进入内核后,先被封装为 sk_buff 并挂入 socket 发送队列,内核调度时从队列取出 sk_buff ,再通过依次调用传输层、网络层、数据链路层的处理函数,将sk_buff 指针逐层向下传递,每一层函 数通过移动 sk_buff 的 data 指针完成协议头封装,最终交给网卡驱动发送;接收方向上,网卡收到数据后创建 sk_buff,通过调用各层回调函数 将sk_buff 指针逐层向上传递,每一层函数通过移动 data 指针剥离协议头,解析完成后再将sk_buff 挂入对应 socket 的接收队列,等待应用层读取。可以说,sk_buff 是报文的"实体",函数调用与指针传递则是报文在协议栈中流动的"通道",二者结合,才完整实现了网络数据从应用层到网卡、再从网卡到应用层的流转。

sk_buff 结构体中还有 head、data、tail、end 四个核心指针字段,这四个指针和我们上面说的内核缓冲区直接相关,sk_buff 不只是单纯用来描述报文属性信息的结构体,同时还绑定了和报文相关的内存缓冲区。网络报文逐层封装、解封装的全过程,核心就是依靠这四个指针的移动来完成。在这四个之阵中,其中 head 与 end 是固定边界指针,分别指向整块缓冲区的起始地址与末尾地址,全程不会发生移动,用来限定缓冲区的可用范围;data 与 tail 是可灵活移动的动态指针,data 指向当前报文有效数据的起始位置,tail 指向当前报文有效数据的结束位置。发送封装报文时,内核按照各层协议头部大小,通过 sizeof() 计算长度让 data 指针向左偏移,依次预留出以太网帧头、IP 协议头、TCP/UDP 协议头的空间,完成逐层报文封装;接收解封装报文时,则让 data 指针向右偏移,依次跳过剥离各层协议头部,最终定位到纯净的应用层有效数据,整个报文在协议栈各层流转处理,本质就是这组数据指针不断偏移调整的过程。和我们在问题 6 讲的内核缓冲区的封装和解封装形成闭合

inet_sock 结构体

我们再讲下一个结构体 inet_sock :

struct inet_sock 结构体是 Linux 内核中专门为 IPv4 协议实现的 socket 扩展结构体,和我们之前讲的 struct sock、sk_buff 是一脉相承的,struct inet_sock 内部的第一个成员就是 struct sock sk,这是内核里典型的**"继承"**写法,也就是说 struct inet_sock 本身就是一个 struct sock,只是在此基础上额外扩展了 IPv4 协议的专属信息,比如源 IP、目的 IP、源端口、目的端口这些关键字段。这些字段会在 socket 创建、绑定、连接的过程中被设置好,后续发送报文时,内核就可以直接从这个结构体里读取这些信息,用来填充 IP 头和传输层报头。

当 sk_buff 被挂入 socket 发送队列、后续取出封装时,内核会通过 sk_buff -> sk 指针找到对应的 struct sock,再强转成 struct inet_sock,就能直接拿到预先设置好的 IP 和端口信息,不用再去解析报文内容,直接完成报头填充;而接收报文时,内核也会根据报文中的 IP 和端口信息,匹配到对应的 struct inet_sock,再把 sk_buff 挂入它的接收队列。可以说,struct inet_sock 就是 struct sock 的 IPv4 协议 "专用扩展版",它把 socket 的 IP、端口信息提前管理好,让报文封装和匹配过程更高效,和 sk_buff 指针传递、队列管理的流程完全打通,共同构成了 Linux 网络协议栈的完整处理链路。

udp_sock 结构体

接着我们再讲下一个结构体 udp_sock :

struct udp_sock 是 Linux 内核中专门为 UDP 协议实现的 socket 结构体,它和前面的 struct sock、inet_sock 同样也是继承关系。它的第一个成员是 struct inet_sock inet,这意味着 udp_sock 本身就是一个 inet_sock,自然也就包含了 inet_sock 里的 struct sock 和 IP、端口信息。在此基础上,它又增加了 UDP 协议的专属字段,比如 pending、corkflag、len 等,用来处理 UDP 特有的逻辑,比如 "UDP cork" 机制,或者记录待发送报文的总长度。

当一个 UDP socket 创建时,内核会为它生成一个 struct udp_sock ,它包含了所有层级的信息:struct sock 中的收发队列、inet_sock 中的 IP 和端口、以及 udp_sock 自身的 UDP 协议配置。当应用层发送数据时,内核创建 sk_buff,将其 sk 指针指向这个 udp_sock,然后挂入发送队列。后续封装报文时,内核可以通过 sk_buff -> sk 找到 udp_sock,再依次拿到 inet_sock 里的 IP / 端口信息,以及 udp_sock 里的长度信息,一次性完成 UDP 报头和 IP 报头的填充。

可以说,从 struct sock 到 inet_sock,再到 udp_sock,是一个层层递进的扩展关系,它们共同组成了 UDP socket 的完整内核表示,让 sk_buff 能精准地找到归属、获取所有封装信息,从而高效地完成网络数据的收发。

总结(重要) :

  1. Linux 内核并没有 C++ 面向对象的 class 继承语法,而是通过结构体首成员嵌套实现了继承思想。struct sock 是所有网络套接字最底层的通用基类结构体,作为父结构,它只存放所有 TCP、UDP 协议通用的核心内容,也就是 socket 收发 sk_buff 双向链表队列、内存引用计数、报文归属管理等底基础能力;struct inet_sock 继承自 sock 结构体,在父结构通用能力的基础上,额外扩展了 IPv4 协议专属信息,也就是源 IP 地址、目的 IP 地址、源端口号、目的端口号这些网络层地址标识;而 struct udp_sock 又继续继承了 inet_sock,在前两层结构体的基础上,再次补充 UDP 传输层独有的协议参数,比如待发送报文状态、UDP 粘包标记、报文总长度等专属逻辑。三层结构体层层嵌套、向下继承,每一层都会完整保留上层所有字段与能力,只追加自身协议独有的信息,互不冲突、层级清晰。

  2. UDP 套接字在内核的创建顺序,严格遵循**从底层通用基类,向上逐层扩展子类的顺序执行。**当应用层调用 socket() 函数创建 UDP 套接字时,内核第一步会优先初始化最基础的 struct sock 结构体,完成发送队列、接收队列两个 sk_buff 双向链表的初始化,搭建好 socket 最核心的报文排队管理底座;第二步基于已经初始化完成的 sock 结构体,嵌套创建 struct inet_sock 结构体,填充 IPv4 协议对应的 IP 地址、端口相关字段,补齐网络层寻址所需的全部信息;第三步再依托完整的inet_sock,嵌套实例化最终的 struct udp_sock 结构体,初始化 UDP 专属的阻塞标记、报文长度、粘包配置等传输层参数。三层结构体依次创建、层层包裹,最终组合成一个完整可用、绑定 UDP 协议的内核套接字实体。

  3. 完整流程 : 客户端调用 socket() 创建 UDP 套接字时,内核完成 sock→ inet_sock → udp_sock三层结构体嵌套初始化;后续调用 sendto 发送数据时,内核新建 sk_buff 并拷贝应用层数据,通过 sk_buff 内部的 sk 指针绑定所属 udp_sock,依托结构体继承关系,即可同时拿到 sock 的发送队列、inet_sock 的 IP 端口地址、udp_sock 的协议长度信息,随后将 sk_buff 挂入 socket 发送队列排队。内核后台异步调度时,从队列取出 sk_buff,调用下层处理函数并传递报文指针,通过移动 head/data/tail/end 指针逐层偏移,依次填充 UDP 报头、IP 报头、以太网帧头,最终交由网卡发送至网络。服务端网卡收到报文后,内核新建 sk_buff 存储网络数据,逐层解封装协议头部,通过 IP 与端口匹配到服务端对应的 udp_sock,将 sk_buff 挂入 sock 对应的接收队列;等待应用层调用 recv 读取数据时,内核从队列取出报文,剥离全部协议头部后将纯净应用数据拷贝至用户空间,依托三层套接字结构体 + sk_buff 指针流转,完成一次完整双向网络通信。

四、UDP 的特点

UDP 核心特点

UDP 属于无连接传输层协议,整体传输过程类似寄信。

  1. 首先它具备无连接性,只要知晓对端的 IP 地址与端口号,就可以直接发送数据,通信前后不需要经过三次握手建立、断开专属连接;

  2. 其次它是不可靠传输,协议本身没有报文确认应答、超时重传、序号排序机制,报文丢失、错乱到达时,UDP 底层不会主动重传,也不会主动向上层应用报告传输错误;

  3. 同时 UDP 是面向数据报传输,严格以完整报文为单位处理数据,不会对应用层数据进行拆分与合并。

UDP 面向数据报的独有特性

UDP 严格匹配一次发送、一次接收的完整数据报边界,应用层调用一次 sendto 发送多长的数据,接收端就必须用一次 recvfrom 完整接收对应长度的全部数据。它不会像 TCP 一样多次读写拆分合并数据流,发送 100 字节数据就必须单次完整接收 100 字节,无法拆分多次分批读取,也不会出现多次发送数据合并成一次读取的情况,从协议层面天然解决了 TCP 的粘包问题。

对 UDP "不可靠" 的本质理解

UDP 的不可靠并不是协议缺陷、不是缺点,恰恰是 UDP 本身的核心设计特点。不可靠只代表协议不保障报文 100% 送达、不做丢包重传、乱序排序、超时校验这些可靠性管控,很多实时场景本身就不在意少量丢包,偶尔丢失数据包不会影响业务体验。也正因剥离了所有复杂可靠管控逻辑,UDP 协议逻辑更简单、内核处理开销极低、传输延迟极小、收发速度更快,在音视频直播、游戏实时联机等场景,远比 TCP 更适配业务需求。

UDP 的缓冲区

UDP 的缓冲区设计与 TCP 存在明显区别,**UDP 没有真正意义上的发送缓冲区,**这里说 UDP 没有真正意义上的发送缓冲区,是对比 TCP 而言:TCP 的发送队列是长期的,报文发出去不会删除,等待对方 ACK 确认、支持丢包重传,是带可靠性保障的缓存;UDP 发送队列只是临时的,报文交给网卡发送后,内核就直接销毁 sk_buff,不保存数据副本、没有确认应答、不做超时重传,只负责异步限流调度,不承担缓存保障功能,因此不算 TCP 那种真正的发送缓冲区。

但是 UDP 拥有正常的接收缓冲区队列,报文未被应用读取前会一直留存。

同时,UDP socket 是全双工的,同一个套接字可以同时进行收发操作,收发方向相互独立,这与它的缓冲区设计并不矛盾:发送端短暂调度不缓存、接收端临时排队不重传,两者共同构成了 UDP 轻量、无状态的传输特性。

UDP 使用注意事项

UDP 协议首部包含一个 16 位的长度字段,因此单个 UDP 报文的最大传输长度为 64KB(包含 UDP 首部),在现代网络环境中这个容量非常有限。如果应用层需要传输的数据超过 64KB,UDP 协议本身无法自动处理拆分与合并,必须由开发者在应用层手动实现分包与组包逻辑:发送端将大数据拆分成多个小于 64KB 的片段,多次调用sendto发送;接收端则需要接收这些片段,并按照约定的规则手动拼装还原。

五、总结

本文深入解析了传输层UDP协议的核心机制。首先阐述了端口号与五元组的概念,指出端口号是进程级标识,与IP地址共同构成网络通信的完整地址。随后详细剖析了UDP协议格式,包括8字节固定报头(源/目的端口、长度、校验和)和数据载荷部分,强调其"面向数据报"的特性不会产生粘包问题。文章通过Linux内核源码分析,揭示了UDP协议在操作系统中的实现原理,包括sk_buff结构体的报文管理机制,以及从sock到inet_sock再到udp_sock的三层继承结构。最后总结了UDP无连接、不可靠但高效的核心特点,说明其在实时应用场景中的独特优势。全文系统性地呈现了UDP协议从应用层接口到底层实现的完整技术栈。

谢谢大家的观看!

相关推荐
逻辑驱动的ken1 小时前
Java高频面试考点场景题27
java·开发语言·面试·职场和发展·求职招聘
emiya_saber1 小时前
docker cmd
linux·运维·docker
清水白石0081 小时前
从手写初始化到 pytest fixture:让 Python 测试既干净、可复用,又能驾驭异步并发
开发语言·python·pytest
艾莉丝努力练剑1 小时前
【Linux网络】Linux 网络编程:应用层自定义协议与序列化(1)初识
linux·运维·服务器·网络·c++·udp·tcp
南境十里·墨染春水1 小时前
C++ 日志 4—— 多线程安全与异步日志优化
数据库·c++·安全
不知名的老吴1 小时前
关于C++中new的基本使用方法介绍
开发语言·c++
在角落发呆1 小时前
c socket 服务器转发,c socket 服务器转发的方法
服务器·c语言·开发语言
yujunl1 小时前
U9一种客开方案的解决
开发语言
wjs20241 小时前
Python pass 语句详解
开发语言