从传输层协议到 UDP:轻量高效的传输选择
前言
在计算机网络中,传输层是一个关键的层级,它为应用进程之间的通信提供了端到端的传输服务。常见的传输层协议有 TCP 和 UDP。前者强调可靠、面向连接的传输,而后者则提供轻量级、无连接的通信方式。传输层位于网络层之上、应用层之下,为进程之间提供端到端的数据传输服务。要理解 UDP 协议,我们需要先了解 传输层的功能与常见协议,再深入探讨为什么 UDP 在今天的网络环境中仍占据着举足轻重的地位。
一、传输层的基本概念
在 OSI 七层模型 和 TCP/IP 五层模型 中,传输层是第四层,它的主要任务是 为应用层提供端到端的数据传输服务。
相比之下:
-
网络层 只负责把数据从源主机传送到目标主机,但它并不知道目标主机上具体是哪个应用需要这些数据。
-
传输层 进一步在主机内区分不同的应用进程,让数据准确交付给正确的应用。
传输层的核心功能:
-
端到端通信
通过端口号来区分不同的应用进程,保证数据送达正确的目标。 -
复用与分用
-
复用:多个应用层进程可以同时使用传输层服务。
-
分用:接收端根据端口号把数据交给正确的进程。
-
-
可靠性控制
一些传输层协议(如 TCP)提供确认、重传、顺序控制,确保数据可靠送达。 -
差错检测
使用校验和检测传输过程中的数据错误。
二、传输层的主要协议
在 TCP/IP 协议族中,传输层主要有两个核心协议:TCP 和 UDP。
1. TCP(Transmission Control Protocol)
-
特点:面向连接、可靠、字节流传输。
-
机制:
-
三次握手建立连接。
-
超时重传与确认机制保证可靠性。
-
拥塞控制与流量控制提升传输效率。
-
-
适用场景:文件传输(FTP)、网页浏览(HTTP/HTTPS)、邮件(SMTP/POP3/IMAP)等需要数据可靠性的应用。
2. UDP(User Datagram Protocol)
-
特点:无连接、不可靠、面向报文、开销小。
-
机制:
-
直接发送数据,不建立连接。
-
尽最大努力交付,不保证顺序、不保证可靠性。
-
-
适用场景:DNS 查询、实时音视频传输、在线游戏、物联网通信等。
可以说,TCP 更可靠,UDP 更高效。这也是二者在实际应用中常常互补的原因。值得注意的是,可靠不可靠,稳定不稳定都是指的他们的特性,而不是缺点,这里并不是一个缺陷而是一个特性。
在有了 TCP 之后,为什么还需要 UDP 呢?这是很多初学者的疑问。
其实,网络传输并不是只有“可靠”这一种需求。很多时候,低延迟比可靠性更重要。例如:
-
视频通话:丢失几帧画面比延迟 2 秒更能让人接受。
-
实时游戏:玩家动作必须立刻传输,否则操作感大打折扣。
-
DNS 查询:只需要一次请求-应答,没必要建立 TCP 连接。
因此,UDP 在实时性要求高、对少量丢包容忍度高的应用中有不可替代的优势。
由于TCP要保证数据的可靠性,所以他会显得更复杂,我们要介绍它的篇幅自然会越长,所以我们等会单独出一篇TCP的文章来进行介绍。
三、端口号再理解
端口号(Port)标识了一个主机上进行通信的不同的应用程序:
在 TCP/IP 协议中, 用 “源 IP”, “源端口号”, “目的 IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个通信(可以通过 netstat -n 查看);
端口号的范围划分
端口号是有一个专门的规定的划分的:
- 0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的端口号都是固定的.
- 1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的
知名端口号
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些
固定的端口号:
- ssh 服务器, 使用 22 端口
- ftp 服务器, 使用 21 端口
- telnet 服务器, 使用 23 端口
- http 服务器, 使用 80 端口
- https 服务器, 使用 443 端口
执行下面的命令, 可以看到知名端口号
cat /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号,否则就容易跟这些服务撞上。
向大家提出两个问题:
- 一个进程是否可以 bind 多个端口号?
:
可以,但需要注意方式和限制:
-
默认情况下,一个 socket 只能 bind 一个端口。
在大多数编程场景里,你创建一个 socket,调用一次bind()
,指定一个端口号,这个 socket 就固定在该端口收发数据。 -
如果进程中创建多个 socket,就可以分别绑定不同的端口。
一个进程可以打开多个 socket,每个 socket 对应不同的端口。例如,一个服务器进程既监听 TCP 80 端口(HTTP),又监听 TCP 443 端口(HTTPS),它们都属于同一个进程。
- 一个端口号是否可以被多个进程 bind?
:- 在操作系统里,端口是由
(协议, 本地IP, 端口号)
唯一标识的,通常不能被多个进程同时绑定。否则系统无法知道该端口收到的数据应该交给哪个进程。
- 在操作系统里,端口是由
但是,有几种特殊情况:
- 情况一:SO_REUSEADDR
-
多个进程可以同时绑定同一个端口,但前提是 IP 地址不同。
-
比如:
-
进程 A bind 到
(127.0.0.1, 8080)
-
进程 B bind 到
(192.168.1.10, 8080)
它们端口号相同,但本地 IP 不同,因此不会冲突。
-
- 多播 / 广播 +
SO_REUSEADDR
或SO_REUSEPORT
-
在 Linux 中,如果设置
SO_REUSEADDR
或SO_REUSEPORT
选项,多个进程可以同时绑定到同一个 IP+端口,用于接收同样的多播/广播数据。 -
在这种情况下,内核会把收到的数据复制一份,分发给绑定了该端口的多个进程。
- 情况 3:特殊实现(负载均衡)
-
在一些高并发服务器里,会用
SO_REUSEPORT
让多个进程(如 Nginx worker 进程)同时监听同一个端口,比如 80 端口。 -
内核会把新连接分配给其中一个进程,达到负载均衡的效果。
四、UDP 协议
我们先来介绍一下UDP协议的结构,UDP 协议端格式如图:
我们如何对
其中,16位源端口号以及目的端口号,分别代表发送方进程的端口号与接收方进程的端口号。
可以看见,我们的端口号的范围正好就是2的16次方,也就是65536.
UDP长度
而16位的UDP长度是一个占16位(2字节)的字段,用于指明整个UDP数据报的总长度。这种自己描述自己的字段,我们一般称为:自描述字段。
它包括了 UDP首部(8字节) 和 UDP数据载荷 的总和。
公式:16位长度 = 8字节首部 + 数据载荷长度
由于是16位,所以其表示的范围是 0 ~ 65,535字节(即 2^16 - 1)。
- 最小值:8字节。因为即使没有任何数据载荷,UDP首部本身也占8字节。
- 最大值:65,535字节。这意味着整个UDP数据报(首部+数据)不能超过64KB。
为什么我们需要这个参数呢?
-
界定数据边界:接收方的网络协议栈需要知道从哪里开始、到哪里结束是一个完整的UDP数据报。长度字段告诉内核:“从这个UDP首部开始,往后数X个字节,就是整个数据报的结束。”
-
填充校验范围:长度字段的值会被用于后续检验和的计算,以确定需要校验的数据范围。
这个字段限制了单个UDP数据报所能携带的最大数据量。如果需要传输超过64KB的数据,必须在应用层进行分包和重组,UDP本身不提供这个功能(与之对比,TCP是面向流的,没有这个限制)。或者使用其他协议,比如我们后面要介绍的TCP。
UDP检验和
而16位UDP检验和是一个占16位(2字节)的字段,用于检测UDP数据报在传输过程中是否发生了错误(如比特翻转)。
它校验的范围是一个“伪首部 + UDP首部 + UDP数据”的拼接结构(仅了解)。
-
伪首部:
-
这是一个虚拟的结构,只用于计算检验和,并不会被实际发送。
-
它包含了IP层的关键信息:源IP地址、目的IP地址、协议号(17,代表UDP)和UDP长度。
-
目的:将IP层的部分信息与UDP数据报绑定,验证数据报不仅没有出错,而且确实被送到了正确的IP地址和正确的协议(UDP)。
-
-
真实的UDP首部(包括检验和字段本身,但在计算时此字段暂置为0)。
-
UDP数据载荷。如果数据长度是奇数个字节,会填充一个值为0的字节以便计算(这个填充字节也不被发送)。
计算过程(发送方)
-
将检验和字段置为0。
-
将“伪首部”、“UDP首部”、“UDP数据”拼接起来。
-
将所有内容视为一系列16位的字(2字节一组)。
-
对这些16位的字进行二进制反码求和。
-
将得到的结果取反码,存入UDP首部的检验和字段。
验证过程(接收方)
-
接收方将收到的“伪首部”、“UDP首部”、“UDP数据”同样拼接起来(包括发送方计算好的检验和)。
-
对所有16位的字进行二进制反码求和。
-
如果传输过程中没有发生任何错误,最终的计算结果应该是全1(即16进制0xFFFF)。如果不是全1,则说明数据报有错误。
为什么需要它?
-
可靠性保障:虽然UDP是无连接的、不保证交付的协议,但检验和提供了最基本的数据完整性验证。这防止了应用程序接收到损坏的数据而毫不知情。
-
端到端校验:它提供了从发送进程到接收进程的端到端校验,而不仅仅是在链路层上的校验。
值得一提是:
-
在IPv4中是可选的,但强烈建议开启。如果发送方无法计算检验和,可以将该字段置为0(如果为0,表示未计算检验和)。
-
在IPv6中是强制的。如果IPv6下的UDP数据检验和字段为0,接收方必须丢弃该包。
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 传输的过程类似于寄信,主要有三个特点:
- 无连接: 知道对端的 IP 和端口号就直接进行传输, 不需要建立连接;
- 不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方,UDP 协议层也不会给应用层返回任何错误信息;
- 面向数据报: 不能够灵活的控制读写数据的次数和数量;
如何理解无连接?其实就是寄信,只需要寄信人地址与收信人地址就行,我就能把信件给与对方。
而不可靠,指的就是UDP协议本身不提供任何保障数据报送达的机制。数据报可能丢失、乱序、重复,而协议栈不会尝试修复这些问题。
必须重申,不可靠不是缺点而是特性!!!
面向数据报,我们要重点讲解了:
面向数据报本质其实就是为了维护数据报的边界,读写单位是完整的、独立的数据报。
-
写操作:每次调用
sendto()
,你提供的数据缓冲区都被内核视为一个独立的消息。内核会为这个缓冲区封装成一个独立的UDP数据报,加上UDP头,然后交给IP层。即使你连续调用两次sendto()
分别发送100字节和200字节,接收方也会通过两次recvfrom()
分别接收到100字节和200字节的两个完整数据报。内核绝不会将它们合并。 -
读操作:每次调用
recvfrom()
,应用程序从套接字接收缓冲区中读取的是一个完整的、最初由对端发送的UDP数据报。-
如果你提供的应用程序缓冲区小于数据报的长度,多余的数据会被静默丢弃。在IPv4中,你可以通过
MSG_TRUNC
标志来探测是否发生了截断。 -
你无法像读TCP流那样,先读50字节,再读150字节来拼凑一个200字节的消息。对于UDP,一次
recvfrom()
调用必须读取整个数据报。
-
-
内核实现:UDP套接字的接收缓冲区 (
sk_receive_queue
) 里存放的是一个一个的sk_buff
,每个sk_buff
都完整地封装了一个从网络接收到的UDP数据报。当应用层读取时,内核将整个sk_buff
的数据内容拷贝到用户空间,然后释放该sk_buff
。
“面向数据报”意味着数据传输存在保护消息边界。应用程序的每次写入对应一个网络数据报,每次读取也对应一个完整的数据报。协议栈严格保持了这条边界,不会出现粘包或拆包问题。
这三个特性是相互关联的:
-
因为它无连接,没有复杂的会话状态要维护,所以它可以做得非常简单高效。
-
因为它简单,所以它放弃了保证可靠性的复杂机制(确认、重传),从而变得不可靠。
-
它的传输单位是一个个自包含的数据报,这与它无连接、无状态的特性完美契合,每个数据报都是独立的。
因此,UDP协议栈在内核中的实现相比TCP要轻量得多,其核心任务就是:为应用程序数据封装/解封装UDP首部,并通过IP层进行发送和接收,同时严格保持每个数据报的独立性。 所有更高级的功能(如可靠性、流量控制、有序交付)都需要在应用层自行实现。
UDP的缓冲区
在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 没有真正意义上的 发送缓冲区. 调用 sendto 会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
-
UDP 具有接收缓冲区. 但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃;
UDP 的 socket 既能读, 也能写, 这个概念叫做 全双工。
总结
UDP在传输层扮演着一个“简单高效传输员”的角色。它与TCP形成鲜明互补:TCP追求可靠,为此不惜复杂;UDP追求效率,为此牺牲可靠。这种“不可靠”并非缺陷,而是为了满足特定场景(如实时音视频、在线游戏、DNS查询)对低延迟和低开销的刚性需求而做出的主动设计选择。
理解UDP,关键在于拥抱其 “将复杂性上移” 的设计哲学。它将协议栈本身做到极致的轻量与快速,而将诸如可靠性、流量控制、拥塞控制等高级功能的选择权和实现权交给了上层的应用程序开发者。这使得UDP不仅是一个协议,更成为一个构建自定义传输协议的基石(例如QUIC协议)。在当今对实时性要求越来越高的互联网应用中,UDP的地位愈发不可或缺。