在计算机网络中,传输层是一个关键的层级,它为应用进程之间的通信提供了端到端的传输服务。常见的传输层协议有 TCP 和 UDP。前者强调可靠、面向连接的传输,而后者则提供轻量级、无连接的通信方式。传输层位于网络层之上、应用层之下,为进程之间提供端到端的数据传输服务。要理解 UDP 协议,我们需要先了解 传输层的功能与常见协议,再深入探讨为什么 UDP 在今天的网络环境中仍占据着举足轻重的地位。
在 OSI 七层模型 和 TCP/IP 五层模型 中,传输层是第四层,它的主要任务是 为应用层提供端到端的数据传输服务。

相比之下:
在 TCP/IP 协议族中,传输层主要有两个核心协议:TCP 和 UDP。
可以说,TCP 更可靠,UDP 更高效。这也是二者在实际应用中常常互补的原因。值得注意的是,可靠不可靠,稳定不稳定都是指的他们的特性,而不是缺点,这里并不是一个缺陷而是一个特性。
在有了 TCP 之后,为什么还需要 UDP 呢?这是很多初学者的疑问。
其实,网络传输并不是只有“可靠”这一种需求。很多时候,低延迟比可靠性更重要。例如:
因此,UDP 在实时性要求高、对少量丢包容忍度高的应用中有不可替代的优势。
由于TCP要保证数据的可靠性,所以他会显得更复杂,我们要介绍它的篇幅自然会越长,所以我们等会单独出一篇TCP的文章来进行介绍。
端口号(Port)标识了一个主机上进行通信的不同的应用程序:

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

端口号是有一个专门的规定的划分的:
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些
固定的端口号:
执行下面的命令, 可以看到知名端口号
cat /etc/services我们自己写一个程序使用端口号时, 要避开这些知名端口号,否则就容易跟这些服务撞上。
向大家提出两个问题:
bind(),指定一个端口号,这个 socket 就固定在该端口收发数据。
(协议, 本地IP, 端口号) 唯一标识的,通常不能被多个进程同时绑定。否则系统无法知道该端口收到的数据应该交给哪个进程。但是,有几种特殊情况:
(127.0.0.1, 8080)
(192.168.1.10, 8080)
它们端口号相同,但本地 IP 不同,因此不会冲突。
SO_REUSEADDR 或 SO_REUSEPORTSO_REUSEADDR 或 SO_REUSEPORT 选项,多个进程可以同时绑定到同一个 IP+端口,用于接收同样的多播/广播数据。
SO_REUSEPORT 让多个进程(如 Nginx worker 进程)同时监听同一个端口,比如 80 端口。
我们先来介绍一下UDP协议的结构,UDP 协议端格式如图:

我们如何对
其中,16位源端口号以及目的端口号,分别代表发送方进程的端口号与接收方进程的端口号。
可以看见,我们的端口号的范围正好就是2的16次方,也就是65536.
而16位的UDP长度是一个占16位(2字节)的字段,用于指明整个UDP数据报的总长度。这种自己描述自己的字段,我们一般称为:自描述字段。
它包括了 UDP首部(8字节) 和 UDP数据载荷 的总和。 公式:16位长度 = 8字节首部 + 数据载荷长度
由于是16位,所以其表示的范围是 0 ~ 65,535字节(即 2^16 - 1)。
为什么我们需要这个参数呢?
这个字段限制了单个UDP数据报所能携带的最大数据量。如果需要传输超过64KB的数据,必须在应用层进行分包和重组,UDP本身不提供这个功能(与之对比,TCP是面向流的,没有这个限制)。或者使用其他协议,比如我们后面要介绍的TCP。
而16位UDP检验和是一个占16位(2字节)的字段,用于检测UDP数据报在传输过程中是否发生了错误(如比特翻转)。
它校验的范围是一个“伪首部 + UDP首部 + UDP数据”的拼接结构(仅了解)。
计算过程(发送方)
验证过程(接收方)
为什么需要它?
值得一提是:
我们可以肯定,协议在内核中其实就是结构体,我们给从应用层传下来的数据进行添加报头,其实就是创建了一个该结构体对象,随后把该结构体对象与之前的数据合并起来!!


我们之前有说过内核中的sk_buff结构体,它是是Linux内核网络栈的核心。
struct sk_buff 通过 head, data, tail, end 这四个核心指针,实现了一种零拷贝的高效数据包构建和解析机制。通过简单地移动 data 和 tail 指针,而不是复制内存,极大地提高了网络协议栈的性能。
他在我们的添加UDP报头时有着什么作用呢?
请大家想一下,当我们从应用层向下传递数据并添加报头时,这些信息存储在哪里?
答案是:数据(包括用户数据和协议头)存储在一个叫做 struct sk_buff 的内存块中,这个内存块在创建时,其 head 和 data 指针之间的“头空间”就已经为所有协议头预留好了位置。
1、首先,假如我们的应用层传下来的正文信息是:“hello world”

在我们的sk_buff结构体中,有着两个指针char * head,end:
这两个指针会分别指向这个信息的开头与结尾。

2、将head指针向前移动,腾出足够的空间:

我们将head移动udphdr固定大小的距离,腾出足够空间:
head -= sizeof(struct udphdr)
3、给新增的空间进行报头数据的填充:

step2:(structudphdr*)head->source=8080;
(structudphdr*)head->dst=9090;这样子,就完成了我们udp报头的填充。
UDP 传输的过程类似于寄信,主要有三个特点:
如何理解无连接?其实就是寄信,只需要寄信人地址与收信人地址就行,我就能把信件给与对方。
而不可靠,指的就是UDP协议本身不提供任何保障数据报送达的机制。数据报可能丢失、乱序、重复,而协议栈不会尝试修复这些问题。
必须重申,不可靠不是缺点而是特性!!!
面向数据报,我们要重点讲解了:
面向数据报本质其实就是为了维护数据报的边界,读写单位是完整的、独立的数据报。
sendto(),你提供的数据缓冲区都被内核视为一个独立的消息。内核会为这个缓冲区封装成一个独立的UDP数据报,加上UDP头,然后交给IP层。即使你连续调用两次 sendto() 分别发送100字节和200字节,接收方也会通过两次 recvfrom() 分别接收到100字节和200字节的两个完整数据报。内核绝不会将它们合并。
recvfrom(),应用程序从套接字接收缓冲区中读取的是一个完整的、最初由对端发送的UDP数据报。
MSG_TRUNC 标志来探测是否发生了截断。
recvfrom() 调用必须读取整个数据报。
sk_receive_queue) 里存放的是一个一个的 sk_buff,每个 sk_buff 都完整地封装了一个从网络接收到的UDP数据报。当应用层读取时,内核将整个 sk_buff 的数据内容拷贝到用户空间,然后释放该 sk_buff。
“面向数据报”意味着数据传输存在保护消息边界。应用程序的每次写入对应一个网络数据报,每次读取也对应一个完整的数据报。协议栈严格保持了这条边界,不会出现粘包或拆包问题。
这三个特性是相互关联的:
因此,UDP协议栈在内核中的实现相比TCP要轻量得多,其核心任务就是:为应用程序数据封装/解封装UDP首部,并通过IP层进行发送和接收,同时严格保持每个数据报的独立性。 所有更高级的功能(如可靠性、流量控制、有序交付)都需要在应用层自行实现。
在Linux内核中,每个UDP套接字都是 struct sock 的一个实例。接收缓冲区的核心是其中的 sk_receive_queue 字段(一个 sk_buff 链表的头)。(这一点我们之前也说过)
struct sock {
// ... 其他大量字段 ...
struct sk_buff_head sk_receive_queue; // 接收数据包队列
int sk_rcvbuf; // 接收缓冲区的总大小限制
// ...
};sk_receive_queue:这是一个链表(或队列),每个节点都是一个 struct sk_buff。每个 sk_buff 完整地封装了一个从网络接收到的UDP数据报。这完美体现了UDP“面向数据报”的特性。
sk_rcvbuf:这个整数定义了该套接字接收缓冲区所能使用的最大字节数
UDP 的 socket 既能读, 也能写, 这个概念叫做 全双工。
UDP在传输层扮演着一个“简单高效传输员”的角色。它与TCP形成鲜明互补:TCP追求可靠,为此不惜复杂;UDP追求效率,为此牺牲可靠。这种“不可靠”并非缺陷,而是为了满足特定场景(如实时音视频、在线游戏、DNS查询)对低延迟和低开销的刚性需求而做出的主动设计选择。
理解UDP,关键在于拥抱其 “将复杂性上移” 的设计哲学。它将协议栈本身做到极致的轻量与快速,而将诸如可靠性、流量控制、拥塞控制等高级功能的选择权和实现权交给了上层的应用程序开发者。这使得UDP不仅是一个协议,更成为一个构建自定义传输协议的基石(例如QUIC协议)。在当今对实时性要求越来越高的互联网应用中,UDP的地位愈发不可或缺。