从零入门计算机网络系列(十三):UDP协议详解——从五元组与报文格式到面向数据报机制
前面我们已经花了不少篇幅来讲解应用层协议。从最开始学习如何自定义应用层协议,到动手实现一个网络版本计算器,再到后来连续用了四篇文章,系统地梳理了 HTTP 协议的工作原理和相关知识。
到这里,应用层的内容也算是告一段落了。不过,大家有没有想过一个问题:应用层负责组织和处理数据,那这些数据究竟是怎么从一台主机传输到另一台主机的呢?
这就要说到我们接下来准备学习的内容了。按照本专栏一直以来自上而下、逐层深入的讲解思路,从本文开始,我们就要正式进入网络协议栈的下一层——传输层(Transport Layer)。
说到传输层,相信大家对UDP和TCP这两个协议应该都不陌生。毕竟,前面编写网络程序的时候,我们就已经接触过它们了。
不过,之前更多关注的是如何使用Socket接口完成网络通信,对于协议本身的一些设计思想和工作原理,还没有真正展开。所以,这一篇我们就先从UDP协议入手,在之前学习的基础上,进一步了解它的基本原理、报文结构以及核心特性,看看UDP究竟是如何完成数据传输的。好了,话不多说,我们正式开始!

目录
一、重谈端口号与五元组:网络通信如何定位进程
前面我们在学习Socket网络编程时,已经接触过端口号(Port)这个概念,相信大家应该都不陌生了。
简单来说,IP地址主要用于标识网络中的主机,而端口号则用于区分同一台主机上不同的网络通信端点,帮助操作系统将收到的数据交给对应的应用程序。
比如,一台服务器上可能同时运行着HTTP服务、数据库服务以及其他网络程序。虽然它们使用的是同一台主机的IP地址,但可以通过不同的端口号来区分各自的网络服务。
不过,光有IP地址和端口号,就足以完整描述一次网络通信了吗?显然还不够!
这就要引出我们接下来要讲的另一个重要概念——五元组。
1.1 什么是五元组:一条网络通信如何被唯一标识


在 TCP/IP 网络通信中,我们通常可以使用一个五元组(Five-Tuple)来描述和区分一条通信流。
所谓五元组,其实就是由以下五个信息组合而成:
-
源IP地址(Source IP): 标识发送数据的一方的IP地址。
-
源端口号(Source Port): 标识发送端使用的端口号。
-
目的IP地址(Destination IP): 标识数据准备发送到哪台主机。
-
目的端口号(Destination Port): 标识接收端对应的端口号。
-
协议号(Protocol): 标识使用的传输层协议,比如TCP或UDP。
这五个信息组合起来,就构成了我们所说的五元组。
举个简单的例子。
假设我们的客户端正在访问一台 Web 服务器,对应的通信信息如下:
源 IP: 192.168.1.100
源端口号: 50000
目的 IP: 192.168.1.200
目的端口号: 80
协议: TCP
那么,这次通信就可以使用下面这个五元组来表示:
(192.168.1.100, 50000, 192.168.1.200, 80, TCP)
你看,即使同一台服务器同时与多个客户端通信,我们也可以通过不同的五元组来区分这些通信流。
而且,五元组中的协议号也非常重要。比如,两组通信即使使用相同的源 IP、源端口、目的 IP 和目的端口,只要一个使用TCP,另一个使用UDP,它们依然属于不同协议下的通信。
那么问题来了,我们能不能在Linux中查看这些网络连接的信息呢?
当然可以!我们可以使用之前接触过的netstat命令:
netstat -an
其中,-a表示显示相关的监听端点和网络连接,-n则表示直接以数字形式显示IP地址和端口号,而不是尝试将它们解析成主机名或服务名称。
如果只想查看当前已经建立的TCP连接,也可以使用:
netstat -nt
通过命令输出中的本地地址、远端地址以及协议类型,我们就可以观察这些网络连接对应的通信信息。
说白了,IP地址解决的是“数据要发送到哪台主机”的问题,端口号解决的是“数据要交给哪个通信端点”的问题,而五元组则把通信双方的信息和协议类型组合起来,用于区分不同的网络通信流。
理解了五元组,后面我们继续学习UDP和TCP的通信原理时,也就更容易弄清楚操作系统是如何区分不同网络通信的了。
1.2 端口号的范围划分与分类
前面我们已经知道,端口号(Port)主要用于区分同一台主机上不同的网络通信端点。那么问题来了,端口号是不是可以随便使用?有没有规定哪些端口应该分配给哪些服务呢?
当然有!
在TCP/IP网络通信中,端口号使用16位无符号整数表示,因此它的取值范围是0~65535,总共有65536个可能的端口号。
不过,这些端口号并不是完全随意使用的。按照IANA的划分,我们通常可以将它们分为以下三类:
| 端口号范围 | 类型 | 主要用途 |
|---|---|---|
| 0~1023 | 知名端口(Well-Known Ports) | 为HTTP、FTP、SSH等常见服务分配的标准端口,其中0为保留端口 |
| 1024~49151 | 注册端口(Registered Ports) | 供各种应用程序和网络服务使用 |
| 49152~65535 | 动态或私有端口(Dynamic/Private Ports) | 通常用于临时通信,由系统或应用程序动态选择 |
这里有一个需要注意的地方。
虽然动态端口的标准划分范围是49152~65535,但并不意味着操作系统只能从这个范围内分配临时端口。
比如,在Linux中,客户端建立网络连接时,操作系统通常会自动分配一个临时端口,而具体的分配范围可以通过系统参数进行配置。
我们可以使用下面这条命令查看:
cat /proc/sys/net/ipv4/ip_local_port_range
在一些Linux系统中,可能会看到这样的结果:
32768 60999
这就表示,当前系统配置的IPv4本地临时端口范围是32768~60999。
所以,端口号的标准分类和操作系统实际分配临时端口的范围,并不一定完全相同。
1.2.1 认识知名端口号:常见服务与端口的对应关系
在网络通信中,有些服务实在是太常见了,比如我们每天访问的网页,以及平时远程连接 Linux 服务器时使用的 SSH。
大家想一想,如果每台服务器都随意选择端口号,那么客户端每次连接时,岂不是还得提前打听一下服务到底运行在哪个端口?
这显然很麻烦。所以,为了方便使用和管理,人们为一些常见的网络服务约定了标准端口号。
这类端口号就叫做知名端口号(Well-Known Port Numbers)。
下面是几个比较常见的例子:
-
SSH:22端口,主要用于远程登录和安全管理服务器。
-
FTP:21端口,主要用于文件传输协议中的控制连接。
-
Telnet:23端口,用于远程终端登录,但由于缺乏加密保护,如今通常更推荐使用SSH。
-
HTTP:80端口,主要用于普通的HTTP Web服务。
-
HTTPS:443端口,主要用于通过TLS加密保护的HTTP服务。
这些端口号大家应该并不陌生,尤其是22、80和443,在我们之前的网络编程和HTTP学习中都接触过。不过,知名端口号只是约定的标准端口,并不代表对应的服务必须运行在这个端口上。
比如,我们之前编写迷你HTTP服务器时,就可以让它监听8080端口,而不一定非得使用80端口。
那么问题来了:在Linux中,我们该如何查看这些知名端口号呢?
其实很简单!Linux系统中通常有一个/etc/services文件,里面保存了常见网络服务名称与端口号、传输协议之间的对应关系。我们可以直接使用:
cat /etc/services
执行后,就能看到系统记录的各种服务及其端口信息。如果只想查看HTTP对应的端口,也可以使用:
grep -w http /etc/services
可能会看到类似下面的结果:
http 80/tcp www
这里的http表示服务名称,80表示对应的端口号,而tcp则表示使用TCP协议。
注意,/etc/services记录的是服务名称与端口号之间的约定关系,并不代表这些端口当前一定有程序正在监听。
如果我们想查看某个端口是否真的被程序占用,就需要借助ss、netstat等工具进行检查。
说白了,知名端口号就是为了让常见网络服务有一个大家都认可的默认地址,方便客户端找到对应的服务。
理解了端口号的范围和分类,后面再去学习TCP、UDP的通信过程,以及操作系统如何根据端口号分发数据时,也就更容易理解了。
二、UDP协议报文格式:从首部结构理解数据传输
前面我们已经重新认识了端口号和五元组,知道了操作系统可以通过这些信息来区分不同的网络通信。那么问题来了,UDP协议究竟是如何组织数据的?它又是怎么知道应该把收到的数据交给哪个应用程序的呢?
要弄清楚这些问题,我们就得先来看看UDP的报文格式。
其实,UDP的报文结构非常简单。它的首部固定为8字节,由四个16位字段组成,每个字段各占2字节。
这四个字段分别是:
-
16位源端口号: 标识发送方使用的端口号,方便接收方知道数据来自哪个通信端点。如果不需要对方回复,在IPv4的某些场景下,源端口号也可以设置为0。
-
16位目的端口号: 标识接收方的端口号。操作系统收到UDP数据报后,可以根据目的IP、目的端口号等信息,找到对应的套接字,将数据交给相应的应用程序。
-
16位UDP长度: 表示整个 UDP 数据报的长度,包含UDP首部和UDP数据。注意,这里记录的是当前数据报的实际长度,而不是最大长度。由于该字段只有16位,普通UDP报文能够表示的长度受到相应限制。
-
16位UDP检验和: 用于检测UDP首部和数据在传输过程中是否发生错误,同时还会结合IP伪首部中的部分信息进行校验。如果接收方发现检验和不正确,通常会直接丢弃该数据报。

这里我们顺便思考一个问题。为什么之前一直强调,TCP和UDP的端口号都是16位的呢?
现在答案就很清楚了。因为在TCP和UDP的协议首部中,源端口号和目的端口号字段都被设计成了16位。也就是说,端口号的取值范围并不是我们随便规定的,而是由传输层协议本身的字段宽度决定的。
既然只有16位,那么端口号的取值范围自然就是0~65535。
2.1 UDP首部详解:如何实现数据交付与报文校验
前面我们在学习自定义应用层协议时,曾经反复强调过一个问题:
任何一个需要解析和分发报文的协议,都得解决两个关键问题:如何分离?如何分用?
那么,UDP是怎么解决这两个问题的呢?
(1)如何分离:通过定长报头区分首部与有效载荷
UDP采用的是固定长度的报头设计。
它的首部长度始终为8字节,也就是说,当操作系统收到一个UDP数据报后,只需要按照协议格式读取前8个字节,就能解析出UDP的首部信息。
而后面的部分,就是UDP携带的有效载荷(Payload)。这样一来,报头和数据之间的边界就非常清楚了。
同时,UDP首部中还有一个16位长度字段,用于表示整个UDP数据报的长度。
固定长度的报头,再配合长度字段,就让UDP能够方便地识别报头与有效载荷的边界。
而且,UDP本身是面向数据报的协议。它不像TCP那样提供连续的字节流,而是保留每个数据报的消息边界。
所以,在正常接收的情况下,应用程序通过recvfrom()获取的就是一个独立UDP数据报中的数据。当然,如果接收缓冲区太小,数据报也可能被截断。
(2)如何分用:通过目的端口号找到对应的套接字
解决了报头和有效载荷的分离问题,接下来就是分用。
什么叫分用呢?其实很简单。假设一台服务器上同时运行着多个网络程序,有的监听 8080 端口,有的监听 9000 端口。当服务器收到一个UDP数据报时,操作系统怎么知道应该把这份数据交给哪个程序呢?
答案就在UDP首部中的16位目的端口号。操作系统会解析UDP首部,获取其中的目的端口号,再结合目的 IP 地址等信息,查找对应的UDP套接字。找到之后,就可以把数据交给相应的接收队列,等待应用程序读取。
比如,一个UDP数据报的目的端口号是8080,那么操作系统就会尝试将它交给匹配该端口及相关地址条件的UDP套接字。
说白了,分离解决的是“报头和数据从哪里分开”的问题,而分用解决的是“收到的数据应该交给谁”的问题。
理解了这两个问题,我们也就能进一步明白,为什么之前编写UDP网络程序时,经常需要使用sendto(),并提供目标服务器的IP地址和端口号。
因为UDP是无连接协议,发送数据之前不需要像TCP那样先建立连接。
所以,在常见的未连接UDP套接字中,应用程序需要在发送数据时,告诉操作系统这份数据应该发送给谁。
例如:
struct sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &server_addr.sin_addr);
sendto(server_socket,
buffer,
len,
0,
reinterpret_cast<struct sockaddr*>(&server_addr),
sizeof(server_addr));
这里我们重点关注三个信息:
-
server_socket: 表示当前用于发送数据的套接字。 -
serverip: 表示目标服务器的IP地址。 -
serverport: 表示目标服务器的端口号。
其中,目标IP地址和端口号会被组织到sockaddr_in结构体中,再作为sendto()的参数传入。
操作系统拿到这些信息后,就可以根据网络协议栈的处理流程,构建对应的UDP数据报,并交给下层协议继续发送。
不过,这里还得注意一点:UDP无连接,并不代表每次发送都必须使用sendto()指定目标地址。
如果UDP套接字已经通过connect()设置了默认对端,那么同样可以使用send()来发送数据。只不过,这里的connect()并不会像TCP那样进行三次握手。
到这里,我们就能够理解UDP首部的设计思路了:通过固定长度的报头解决数据分离问题,再通过目的端口号等信息完成分用,让数据最终到达正确的应用程序。
三、UDP核心特性:从无连接到面向数据报
前面我们已经了解了UDP的报文格式,也知道了UDP是如何通过固定长度的报头完成数据分离,以及通过目的端口号实现数据分用的。
那么问题来了,UDP作为传输层协议,它到底有哪些特点?和我们之前接触过的TCP又有什么不同呢?
其实,UDP的工作方式非常简单。如果非要打个比方,UDP传输数据的过程,就有点像我们平时寄信。
寄信的时候,只要知道收件人的地址,就可以把信寄出去。至于对方有没有收到、什么时候收到,甚至信件会不会在途中丢失,寄信人并不能直接保证。UDP也是类似的道理。
3.1 UDP有哪些特点:无连接、不可靠与高效传输
UDP主要有三个比较典型的特点:无连接、不可靠、面向数据报。
-
无连接: UDP在发送数据之前,不需要像TCP那样进行三次握手来建立连接。只要知道对端的IP地址和端口号,就可以直接发送数据。整个过程比较简单,也减少了建立连接所带来的额外开销。
-
不可靠: UDP本身不提供确认应答、超时重传以及按序交付等可靠性机制。也就是说,发送方把数据交给UDP协议后,并不能保证数据一定能够到达接收方。如果数据在网络传输过程中发生丢失,UDP不会自动重新发送。不过,这并不意味着UDP完全不会报告错误,某些网络错误仍然可能通过Socket接口反馈给应用程序。
-
面向数据报: UDP会保留应用层交付的数据报边界。应用程序每次发送一个UDP数据报,接收端就按照一个个独立的数据报进行接收,而不是像TCP那样,将数据看成连续的字节流。
看到这里,大家应该就能发现,UDP的设计思路其实非常直接:尽可能减少额外的协议控制,把数据发送出去。
至于数据能不能可靠到达、是否需要重传,以及如何处理丢包等问题,如果业务确实需要,就得由应用层额外设计相应的机制。不过,也正因为UDP没有TCP那套复杂的连接管理和可靠传输机制,它在一些对实时性要求较高、能够容忍少量丢包的场景中,反而具有一定的优势。
3.2 面向数据报:UDP如何保留消息边界
前面我们提到,UDP是一个面向数据报(Datagram-Oriented)的协议。
那么,什么叫面向数据报呢?其实,核心就是一句话:
应用层每次交给UDP一个数据报,UDP就会把它当作一个独立的消息进行处理,并保留这个消息的边界。
比如,应用程序调用一次sendto(),向服务器发送了100个字节的数据。
sendto(sockfd, buffer, 100, 0,
(struct sockaddr*)&serverAddr,
sizeof(serverAddr));
那么,对于接收端来说,这100个字节就属于同一个UDP数据报。
如果接收端希望完整接收这个数据报,就应该提供足够大的缓冲区,通过一次recvfrom()将它读取出来。
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer,
sizeof(buffer), 0,
nullptr, nullptr);
假设这个数据报正常到达,而且接收缓冲区足够大,那么这次recvfrom()就可以读取到完整的100字节数据。
这里有一个非常重要的细节:UDP不能像TCP那样,随意把一个数据报拆成多次接收操作来读取。
什么意思呢?假设发送端一次发送了100个字节,而接收端每次只准备10个字节的缓冲区。
你可能会想:既然一次只能接收10个字节,那我连续调用10次recvfrom(),不就能把这100个字节全部读出来了吗?
还真不行!因为UDP是面向数据报的。对于普通UDP Socket来说,如果接收缓冲区不足以容纳整个数据报,那么超出缓冲区容量的部分通常会被截断并丢弃,而不是留给下一次recvfrom()继续读取。
也就是说,第一次可能只读取到10个字节,剩下的90个字节并不会像TCP字节流那样,继续等待后续读取。所以,使用UDP接收数据时,我们需要根据实际业务,准备足够大的接收缓冲区,避免因为缓冲区过小而造成数据截断。
不过,这里还需要区分两个概念。UDP在传输层保留数据报边界,并不意味着一个较大的UDP数据报在网络中绝对不会发生拆分。
比如,在IPv4网络中,较大的IP数据报可能发生IP分片,并在接收端重新组装。但这些属于网络层的处理过程,并不会改变UDP面向数据报的基本语义。
说白了,UDP关注的是一条条独立的消息,而 TCP 关注的则是一段连续的字节流。
这也是UDP和TCP在数据收发方式上一个非常重要的区别。
3.3 UDP缓冲区:理解数据的发送与接收过程
前面我们已经知道,UDP是面向数据报的。那么,数据在发送和接收时,又是如何经过缓冲区的呢?
这里我们需要分别来看。
(1)UDP 的发送缓冲区
我们经常会听到一种说法:UDP没有真正意义上的发送缓冲区。
这句话应该怎么理解呢?其实,并不是说UDP在发送过程中完全不会使用内核缓冲区,而是说它不像TCP那样,需要维护一套用于可靠传输、确认应答和超时重传的发送队列。
对于UDP来说,当应用程序调用sendto()发送数据时,操作系统会将数据交给 UDP 协议进行处理,随后交由网络层继续完成发送。
比如:
sendto(sockfd, buffer, len, 0,
(struct sockaddr*)&serverAddr,
sizeof(serverAddr));
这里需要注意,sendto()调用成功,并不代表数据已经成功到达对方。
它通常只能说明,操作系统已经接受了本次发送请求,或者完成了相应的本地发送处理。至于数据在后续网络传输过程中有没有丢失、对方是否真正收到,UDP本身并不会提供可靠性保证。
所以,我们可以这样理解:UDP的发送过程比较直接,不需要像TCP那样长期保留未确认的数据,等待接收方的确认应答。
当然,如果内核发送资源不足,或者数据报不符合发送条件,sendto()也可能失败,并返回相应的错误信息。
(2)UDP的接收缓冲区
虽然UDP不维护TCP那样的可靠重传发送队列,但它是有接收缓冲区的。
当网络数据到达主机后,经过网络协议栈处理,如果找到对应的UDP Socket,内核就会尝试将收到的数据报放入该Socket的接收队列中。应用程序再通过recvfrom(),从接收队列中读取对应的数据报。
不过,这里有两个问题需要特别注意。
-
无法保证数据报的到达顺序: UDP本身不提供按序交付机制。如果发送端先发送数据报A,再发送数据报B,那么接收端并不能保证一定先收到A,再收到B。数据报可能因为网络传输路径、延迟等因素发生乱序。
-
接收缓冲区可能被占满: 如果发送方发送数据的速度太快,而接收方处理数据的速度跟不上,那么UDP Socket的接收队列就可能逐渐被占满。当新的数据报到达时,如果内核没有足够的缓冲空间,就可能直接丢弃这些数据报。
也就是说,UDP既不保证数据一定到达,也不保证数据一定按照发送顺序到达。
如果我们的业务对数据完整性和顺序有严格要求,就需要在应用层额外设计序号、确认应答、超时重传等机制,或者直接选择TCP。
(3)UDP支持全双工通信
最后,我们再补充一个比较重要的概念——全双工(Full-Duplex)。
所谓全双工,就是通信双方都能够进行数据发送和接收,而且这两个方向的通信可以同时进行。
对于UDP Socket来说,它既可以通过sendto()发送数据,也可以通过recvfrom()接收数据。
比如,客户端可以向服务器发送消息,服务器也可以向客户端返回消息。双方不需要像单工通信那样,只允许数据朝着一个方向传输。
因此,UDP同样支持全双工通信。
不过,全双工只是描述数据能够双向传输,并不代表UDP会自动保证数据的可靠性或者传输顺序。
总结一下,UDP的核心特点就是无连接、不可靠和面向数据报。它的协议机制相对简单,发送数据不需要提前建立连接,也不负责维护复杂的可靠传输状态。
正因为如此,UDP在设计上减少了不少额外开销,但也把数据可靠性、顺序控制等问题留给了上层应用。
所以,UDP并不是简单地比TCP差,而是两者的设计目标不同。至于实际开发中应该选择哪一种,还得看具体的业务需求。

四、UDP协议的使用细节与底层通信机制
前面我们已经了解了UDP的报文格式和基本特性。不过,光知道UDP是无连接、不可靠、面向数据报的还不够。在实际开发中,还有几个比较容易忽略的细节,比如UDP一次最多能发送多少数据、哪些应用层协议使用UDP,以及操作系统究竟是如何处理UDP报头的。
接下来,我们就把这些问题补充一下。
4.1 UDP使用注意事项:报文长度限制与应用层分包
前面分析UDP报文格式时,我们注意到,UDP首部中有一个16位的长度字段,用来表示整个UDP数据报的长度。
那么问题来了,既然这个字段只有16位,是不是意味着UDP能传输的数据大小存在限制呢?
没错!16位无符号整数最大能够表示65535,所以在普通UDP报文中,UDP首部和有效载荷的总长度最多为65535字节,也就是我们常说的约64KB。不过,这里还得注意一个细节。
这65535字节并不全是应用层能够使用的数据空间。因为UDP自身的首部就占了8字节,而且还要受到下层IP协议的限制。
比如,在普通IPv4网络中,假设IP首部没有额外选项,占用20字节,那么UDP能够承载的最大应用层数据长度就是:
65535 - 20 - 8 = 65507 字节
也就是说,在这种常见的IPv4场景下,一个UDP数据报最多可以携带65507字节的应用层数据。
当然,对于某些特殊的IPv6扩展机制,UDP还存在超大数据报的处理方式,这里我们暂时不展开。
那么,问题又来了。现在随便一张高清图片就可能有几MB,甚至几十MB。如果我们想通过UDP传输这么大的文件,该怎么办呢?总不能直接调用一次sendto(),就把整个文件发送出去吧?显然不行!
如果需要发送的数据超过单个UDP数据报所能承载的大小,我们就需要在应用层自行设计分包机制。
比如,把一个较大的文件拆分成多个较小的数据块,分别通过UDP发送,再由接收端按照约定的规则重新组装。不过,这里可不是简单地拆开再拼起来就完事了。
别忘了,我们前面讲过,UDP本身不保证数据一定到达,也不保证数据按照发送顺序到达。
因此,如果需要可靠地传输完整文件,应用层还得考虑数据块编号、丢包检测、超时重传以及乱序重组等问题。另外,即使单个UDP数据报没有超过协议允许的最大长度,也不代表我们就应该把它设置得特别大。因为数据报过大时,可能超过网络路径的最大传输单元(MTU),导致IP分片。一旦其中某个分片丢失,整个原始数据报就可能无法完成重组。
所以,在实际开发中,我们通常会尽量控制UDP数据报的大小,避免不必要的IP分片。
4.2 基于UDP的应用层协议:常见协议与应用场景
讲到这里,可能有人会觉得:既然UDP不保证可靠性,数据还有可能丢失、乱序,那它在实际开发中还有什么用呢?用处可不少!
虽然UDP没有TCP那样完善的可靠传输机制,但它不需要提前建立连接,协议首部也比较简单,因此在某些场景中反而非常合适。实际上,不少常见的应用层协议都会使用UDP来完成通信。比如:
| 应用层协议 | 主要用途 |
|---|---|
| NFS | 网络文件系统(部分版本或配置支持UDP,现代部署通常使用TCP) |
| TFTP | 简单文件传输协议,常用于小型文件传输 |
| DHCP | 动态主机配置协议,用于自动分配IP地址等网络参数 |
| BOOTP | 引导协议,可用于设备启动时获取网络配置信息 |
| DNS | 域名解析协议,通常使用UDP,也支持TCP |
这里需要注意,并不是说这些协议在任何情况下都只使用UDP。
比如DNS,普通的域名查询经常使用UDP,但在某些情况下,也会通过TCP进行通信。
同样,NFS 也并不是只能使用UDP,现代NFS服务通常使用TCP。
另外,除了这些已经标准化的应用层协议,我们自己编写UDP网络程序时,也完全可以根据业务需求,设计一套自定义的应用层协议。
还记得前面我们编写的网络版本计算器吗?
应用层协议本身负责规定数据的组织格式和业务含义,而UDP负责在通信双方之间传输这些数据报。
至于数据是否需要确认、丢失后是否重传,就要看我们自己的协议是怎么设计的了。
4.3 深入理解面向数据报:报文边界与传输层首部
前面我们一直在强调,UDP是面向数据报的协议。
那么,这种特性到底是怎么实现的呢?操作系统又是如何识别 UDP 数据报的边界,并完成数据交付的?
我们接着往下看。
4.3.1 为什么UDP被称为面向数据报的协议
UDP 之所以被称为面向数据报的协议,一个重要原因就是它能够保留每个数据报的边界。
什么意思呢?
假设客户端连续发送了三个 UDP 数据报,每个数据报的大小分别为 100 字节、200 字节和 300 字节。
那么,接收端看到的依然是三个独立的数据报,而不是一个没有边界的 600 字节数据流。
这是怎么做到的呢?
答案其实就在 UDP 的报文格式中。
还记得我们前面讲过的 UDP 首部吗?
UDP 的报头固定为 8 字节,并且包含一个 16 位的 UDP 长度字段。
当操作系统收到一个完整的 IP 数据报,并确定其中承载的是 UDP 报文时,就可以按照 UDP 协议的格式进行解析。
整个过程可以简单理解为:
-
解析 UDP 报头: UDP 首部固定占用 8 字节,操作系统可以按照规定的位置读取源端口号、目的端口号、长度和检验和等字段。
-
获取 UDP 长度: 从报头中提取 16 位的 UDP 长度字段,确定整个 UDP 数据报的长度。
-
确定有效载荷长度: 对普通 UDP 数据报来说,用 UDP 长度减去固定的 8 字节报头,就可以得到应用层数据的长度。
-
向应用层交付数据: 操作系统完成必要的长度、检验和等检查后,将有效载荷放入匹配套接字的接收队列,等待应用程序读取。
这样一来,操作系统就能清楚地知道,每一个 UDP 数据报从哪里开始、到哪里结束。
这就是 UDP 能够保留数据报边界的重要原因。
不过,这里还得强调一下:
能够识别数据报边界,并不意味着数据报一定能成功到达。
因为 UDP 依然是不可靠的,网络传输过程中可能发生丢包、乱序等情况。
所以,面向数据报解决的是数据边界的问题,而不是数据可靠性的问题。这两个概念可不能搞混了。
4.3.2 操作系统如何完成UDP首部的封装与解析
前面我们在学习应用层协议时,经常提到两个概念:序列化和反序列化。
为什么要进行序列化呢?因为不同的应用程序可能使用不同的编程语言,而且各自的数据结构和内存布局也可能存在差异。
比如,我们在C++中定义了一个结构体,并不能保证另一台主机上的程序,能够直接按照完全相同的内存布局来理解这份数据。
因此,在设计应用层协议时,我们通常需要按照约定好的格式,把数据转换成可以传输的字节序列。接收方再按照相同的规则进行解析。
那么问题来了:UDP作为传输层协议,它的报头又是如何在不同操作系统之间传递的呢?
其实,思路是相通的。只不过,UDP的报头格式已经由协议标准明确规定好了。
比如,在Linux中,我们可以找到类似struct udphdr这样的结构体,用来描述UDP首部中的各个字段。但这里要注意,并不是说操作系统直接把C语言的结构体对象原封不动地发送给另一台主机。真正通过网络传输的,始终是一串符合UDP协议格式的字节数据。
也就是说,发送端需要按照UDP规定的字段长度、顺序以及网络字节序来组织报头;接收端则按照相同的协议规范进行解析。
举个简单的例子。UDP的源端口号和目的端口号都是16位,而网络协议通常采用网络字节序,也就是大端字节序来组织这些多字节字段。
因此,在编写网络程序时,我们经常会使用htons()、ntohs()等函数,完成主机字节序与网络字节序之间的转换。至于结构体对齐、内存填充等问题,属于编译器和平台相关的实现细节。我们不能简单地认为,只要两个系统都定义了同名结构体,就可以直接互相传输和解析。
说白了,跨主机通信靠的不是双方使用了相同的结构体,而是双方遵守了相同的协议格式。
这也是为什么不同操作系统、不同编程语言编写的程序,依然能够通过UDP完成网络通信。
因为它们最终遵守的,都是同一套UDP协议规范。
到这里,我们就能发现,应用层和传输层虽然各自负责不同的工作,但在协议设计上有很多相通的地方。
无论是自定义应用层协议,还是UDP这样的标准传输层协议,都需要明确数据的组织方式,让通信双方能够正确理解彼此发送的信息。只不过,UDP的报头格式已经由协议规定好了,而我们自己的应用层协议,还需要由开发者根据业务需求进行设计。
好了,到这里,本篇关于UDP协议的内容就讲得差不多了。
这一篇也是我们正式进入传输层后的第一篇文章。我们从之前熟悉的端口号入手,重新认识了五元组,接着分析UDP的报文格式,弄清楚了它是如何通过固定长度的报头解决数据分离问题,又是如何利用目的端口号完成数据分用的。
后面我们还聊到了UDP的几个核心特点:无连接、不可靠、面向数据报,以及它在数据收发、缓冲区管理和报文长度方面的一些细节。顺便也回顾了几个常见的基于UDP的应用层协议,并结合操作系统的处理过程,进一步理解了传输层报头是如何组织和解析的。
其实,学到这里大家应该也能发现,UDP的设计并不复杂,甚至可以说相当简单。 它没有TCP那样完善的可靠传输机制,也不需要提前建立连接,而是尽可能减少额外的协议控制,把数据传输这件事交给下层网络去完成。
不过,简单并不代表没有价值。恰恰相反,正因为UDP足够简单,在一些追求低延迟、能够容忍少量丢包的场景中,它反而有着自己的优势。
学习网络协议,重要的不只是记住报头有几个字段、每个字段占多少字节,更要弄清楚:协议为什么要这样设计?它解决了哪些问题,又把哪些问题留给了上层应用?
毕竟,真正理解了这些设计思想,后面无论是编写网络程序,还是继续学习TCP的可靠传输机制,思路都会清晰不少。
当然,UDP并不是传输层的全部。接下来,我们还要认识另一个更加复杂、也更加重要的传输层协议——TCP。
同样是传输数据,TCP为什么需要建立连接?它又是如何保证数据可靠、有序到达的?这些问题,我们留到后面的文章慢慢展开。
好了,本篇文章就先聊到这里。如果文章中有什么理解不到位或者讲错的地方,也欢迎大家在评论区指出。技术学习本来就是一个不断积累、不断完善的过程,大家一起交流,才能进步得更快。
感谢大家的阅读,我们下篇文章再见!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)