概述
UDP 用户数据报协议
引言
UDP是一种保留消息边界(不合并,不拆分)的简单的面向数据报的传输层协议。使用UDP协议的时候,一般来说,每个被应用程序请求的UDP输出操作只生产一个UDP数据报,并组装成一份待发送的IP数据报(与面向数据流的协议不同,如TCP,应用程序写入的数据与真正在单个IP数据报里传送的内容可能没有什么联系)。UDP不提供差错纠正、队列管理、重复消除、流量控制和拥塞控制,只提供差错校验(校验和)。(注:对于UDP网上有各种各样的描述,但是我个人觉得对UDP描述最到位的还是UDP自己的正式规范,也就是**[RFC0768]**)
前面提到:
- UDP不提供差错纠正、队列管理、重复消除意味着UDP不提供可靠性:它吧应用程序传给IP层的数据发出去,但是不能够保证它们能够到达目的地。
- UDP不提供流量控制和拥塞控制意味着UDP不提供保护性:没有协议机制防止高速UDP流量对其他网路用户的消极影响。
但是凡事总有两面性,[RFC0768]中明确提到"This protocol provides a procedure for application programs to send messages to other programs with a minimum of protocol mechanism",作为minimum的协议,自然UDP是没有那么多花里胡哨的内容。由于UDP相比TCP等协议更“轻”,无连接特征,所以UDP的开销更小,速度更快。
下面画出了一个UDP数据报作为单个IPv4数据报的封装。IPv4中Protocol字段(IPv6是Next Header)用值17标识UDP。值得一提的是,在使用UDP的时候,我们还是需要对IP数据报的长度进行关注,应为如果超过了MTU的时候,那么就会对IP数据报进行分片,这个我们下面会详细聊这个问题。
|<-------- UDP数据报 -------------->|
+--------+--------+----------+------------------------+
| IPv4头部 | UDP头部 | UDP数据 |
| 20字节,不带IP选项 | 8字节 | |
+--------+--------+----------+------------------------+
|<-------------------- IPv4数据报 -------------------->|
UDP头部
首先我们来看一下包含负载和UDP头部的UDP数据报。
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| | |
| Length | Checksum |
+--------+--------+--------+--------+
|
| data octets ...
+---------------- ...
端口号(Port)的作用是帮助协议辨认发送和接收的进程。这两个端口号是纯抽象的,不与主机上任何物理实体相关。在图中可以看出,在UDP中两个端口号都是正的16比特的数字。源端口号(Source Port)是可选的,如果数据报的发送者不要求对方回复的话,这里可以直接置成零。目标端口号(Destination Port)的作用是帮助啊分离从IP层进入的数据。IP层会根据IP协议中头部的标识分离到特定的传输协议,也就是说端口号在不同的传输协议之间是独立的。这样导致一个直接结果就是两个完全不同的服务器可以使用相同的端口号和IP地址,只要他们使用不同的传输协议。
长度(Length)字段是UDP头部和UDP数据的总长度,以字节为单位。这个字段的最小值是8。
本来这个长度字段是没啥可说的,但是网上对于这个字段是否冗余以及为什么要设计这个字段却众说纷纭。因为不管是IPv4还是IPv6,在网络层其实都是可以通过“IP总长度 - IP Header长度 - UDP Header长度”计算出Length的值。而且在《TCP/IP详解 卷1:协议》中,也明确提到“**This UDP length is redundant**” UDP长度字段是冗余的。 首先关于该字段是否冗余,基本上在UDP的下层是IP的时候,大家都认为这个字段是冗余的,当然也有人觉得处理UDP的时候还要去读IP的字段会导致“违反分层 layering violation”(我个人不是很认可后者,因为后面提到的UDP伪头部其实整个都是从网络层复制过来的,这个是明显导致“违反分层 layering violation”的,但是UDP设计的时候还是这么设计了,就证明在设计网络协议的时候,如果没有对协议实现产生较大影响,“分层”这个思想其实是可以灵活看待的)。 其次是为什么会设计这个字段,我看了之后觉得比较有可能的几种说法分别是:1.网络层可能不是IP协议,其他协议不一定能计算出Length字段。2.UDP报头设计时发现有一些空间多余,为了方便处理,遵守“协议分层”的思想。3.有可能是UDP和IP协议并行发展的结果,UDP并不是等IP完全占领主导地位之后才开发的协议。 不管是在知乎还是stackoverflow基本上就是上面总结的三种说法,如果问我的话,其实我更倾向于“留着先,有可能有用”这种说法,感觉这个字段不是必要的,但是确实可以留着,疑问如果传输层的字段要依赖网络层的信息计算确实有点“受制于人”的意思。
UDP校验和(Checksum)覆盖了UDP头部、UDP数据和一个伪头部。校验和由发送方计算得到,由最终的目的方校验,在传输过程中不会被修改(除非是通过一个NAT)。IPv4中的检验和只覆盖整个头部,并不覆盖IP分组中的任何数据但是传输协议的校验和则会覆盖它们的头部和数据。传输层协议例如UDP,TCP在投递数据到接收方应用程序之前必须通过差错检测机制(计算校验和或者其他方式)。所以对于UDP来说,尽管计算校验和是可选的,但是如果网络层用的是IPv6,那么就会强制使用校验和,因为IPv6没有本身没有头部校验和。
这里给出UDP校验和的计算方式:
- 按每16位求和并在高位补0得出一个32位的求和结果。
- 如果这个32位的求和结果高16位不为0,则高16位加低16位并且高位补0在得到一个32位的结果。
- 重复第二部直到高16位为0,将16位取反,得到校验和。
这里有一个细节需要注意:UDP在计算头部校验和的时候,包含了一个衍生自IPv4头部大小为12字节(或IPv6头部大小为40字节)的字段伪头部
。这个伪头部仅仅用作校验和的计算,并不会传送出去。这个伪头部包含了来自IP头部的源和目的地地址以及IPv4中Protocol字段(IPv6是Next Header)。原因也很明显,为了保证:1. 该IP没有接受地址错误的数据报。2. 没有给UDP一个本该传输给其他协议的数据报。
下面这个图就是伪头部:
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| source address |
+--------+--------+--------+--------+
| destination address |
+--------+--------+--------+--------+
| zero |protocol| UDP length |
+--------+--------+--------+--------+
这里有第二个细节需要了解,可以看到左下角补了一个字节的0,原因很简单,校验和算法只相加16位字(总数是偶数个字节)。那么肯定有人问了,那UDP报文字节如果是奇数怎么办,很简单,就是在数据报尾部补一个值为0的虚字节用于校验和计算,但是不传出去。这个时候聪明的小伙伴一定发现,UDP length这个字段出现了两次,算是从侧面验证了上面我们认为UDP头部长度字段冗余的观点。因为哪怕是在**[RFC0768]**中也完全没有提到为什么要出现两次。
这里其实很明显能发现,伪头部的出现是明显导致所谓的“违反分层 layering violation”,因为UDP协议(传输层)直接操作IP(网络层)。虽然这个操作本身对于协议的实现只产生了微小的影响,但是还直接影响到了NAT的实现,因为当一个数据报穿过一个NAT的时候,不仅IP层头部的校验和要被修改,而且UDP伪头部的校验和也必须被正确的修改,因为IP层的地址和或UDP层的端口号可能会改变。正式由于伪头部的存在本身就是违反分层规则的,导致NAT没有选择。
通过UDP校验和的机制,可以基本保证发送和接收数据中不存在比特差错,但是UDP现在的校验和有点不是特别灵活,因为要么是全覆盖,要么是没有。有一些应用对差错其实是不完全看重的,这时候一个新的概念就诞生了,部分校验和。部分校验和只覆盖有应用程序指定的负载的一部分,比校验和更灵活,UDP-Lite也就出现了。
UDP-Lite
有些应用是可以容忍在发送和接收的数据里引入比特差错的。针对UDP的校验和要么没有要么全覆盖的问题,UDP-Lite应运而生,UDP-Lite通过修改传统的UDP协议,通过提供部分校验和来解决这个问题。这些校验和只覆盖每个UDP数据报里面的一部分负载,UDP-Lite有自己的IPv4Protocol和IPv6Next Header字段值(136),因此UDP-Lite实际上是独立的传输协议。UDP-Lite中没有了冗余的长度字段,取而代之的是校验和覆盖范围Checksum Coverage字段。
UDP-Lite的头部结构和UDP头部非常相似:
0 15 16 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| Checksum | |
| Coverage | Checksum |
+--------+--------+--------+--------+
| |
: Payload :
| |
+-----------------------------------+
IP分片
分片
由于链路层通常对可传输的每个帧的最大长度有一个上限,为了保持IP数据报抽象与链路层细节的一致和分离,IP引入了分片和重组。当IP层接收到一个需要发送的数据报的时候,它会判断该数据报应该从哪个本地接口发送以及要求的MTU(最大传输单元 Maximum Transmission Unit)是多少。IP层会比较数据报长度与MTU长度,如果数据报比MTU长则会进行分片。IPv4的分片可以使在原始发送方的主机或端到端路径上的任何中间路由器上进行。数据报分片本身也是可以被分片的。IPv6有些不同,IPv6只允许在源主机进行分片。
重组
IP数据报被分片后,必然就涉及重组的问题。IP分片的重组是直到分片到达目的地才会被重组的。
原因有两个:
- 在网络中不进行重组要比重组更能减轻路由器转发的负担。
- 统一数据报的不同分片可能兼有不同的路径到达形同的目的地。
对于这两个原因,其实第一个原因没有那么重要,因为考虑到现在路由器的性能,重组其实压力并不大。第二个原因就是关键了,因为如果出现第二种情况,路径上的路由器根本没有重组的分片的前置条件,因为每个路由器都只能看到所有分片的一个子集。
一个例子
如果使用UDP的应用希望避开IP层分片,那就要控制生成的IP数据报大小了。如果书包大小超过链路的MTU,那么IP数据报就要被分割成多个IP分组,这里会存在性能问题,如果任何一个分片丢失了,这个数据报就丢失了。
下面画一个图描述一个3020字节的UDP/IPv4被分组的情况(这里默认MTU使用比较常见的1500字节):
|<---------- IPv4数据报 ------------->|
+----------+--------+----------------+
| IPv4头部 | UDP头部 | UDP数据 | 第一个分片
+----------+--------+----------------+ 总长度 = 1500
偏移 = 0 |<------IP负载 1480字节--->| UDP长度 = 3000
MF = 1
+----------+--------+--------+--------+
|IPv4头部 | UDP数据 | 第二个分片
+----------+--------+--------+--------+ 总长度 = 1500
偏移 = 185
MF = 1
+--------+--------+--------+
|IPv4头部 | UDP数据 | 最后一个分片长度
+--------+--------+--------+ 总长度 = 60
偏移 = 370 |<--IP负载 40字节-->|
MF = 0
原始IPv4数据报总长度 = 20 + 8 + 2992 = 3020字节
一个带有2992字节的UDP负载的UDP数据报被分成三个UDP/IPv4分组。包含源和目的端口号的UDP头部只出现在第一个分片里面。分片由IPv4头部的标识(Identification),分片偏移(Fragment Offset)和更多分片(More Fragments, MF)控制。从上面这个例子可以看出,一个2992 + 8 字节的UDP数据报 + 20字节IPv4头部合计总长度位3020字节的IP数据报,经过分片之后,生成了3个分组时,总长度增加到了3060,IPv4增加的开销大概是1.3%。其中标识字段由发送方复制到每个分片,分片到达后就是利用这个字段进行分组重组。分片偏移就是指该分片的第一个字节在原始的IPv4数据中的偏移量,更多分片字段则是指明该数据报后面是否还有更多的分组,最后一个分片MF值为0。只有当MF = 0的分片到达时,重组程序才能确定原始数据报的长度。
IP分片尽管是透明的,但是IP分片有个特征使得它不太理想,就是任何一个分片丢失,这个数据报就丢失了,原因很简单,IP协议自身是没有差错纠正机制的,超时和重传是更高层的责任(例如TCP),但是UDP本身是没有重传机制的。当TCP报文段的一个分片丢失,TCP会重传整个TCP报文,值重发数据报的一个分片是不可能的,因为如果分片由中间的路由器来做,而不是原始系统,那么原始系统就不知道数据是怎么被分片的了。所以,最好的方法时避免分片。
纸上得来终觉浅,绝知此事要躬行,之前说了这么多,下面就用实际的代码测试一下。用《TCP/IP详解》中的提到的sock程序模拟发包,用tcpdump进行监听。
sock -u -i -n1 -w1471 10.0.0.3 discard
sock -u -i -n1 -w1472 10.0.0.3 discard
sock -u -i -n1 -w1473 10.0.0.3 discard
sock -u -i -n1 -w1474 10.0.0.3 discard
14:49:17.035013 IP (tos 0x0, ttl 64, id 16592, offset 0, flags [DF], proto UDP (17), length 1499)
172.24.235.26.49400 > 10.0.0.3.discard: UDP, length 1471
14:49:17.036399 IP (tos 0x0, ttl 64, id 16594, offset 0, flags [DF], proto UDP (17), length 1500)
172.24.235.26.38255 > 10.0.0.3.discard: UDP, length 1472
14:49:17.037621 IP (tos 0x0, ttl 64, id 16595, offset 0, flags [+], proto UDP (17), length 1500)
172.24.235.26.37455 > 10.0.0.3.discard: UDP, bad length 1473 > 1472
14:49:17.037627 IP (tos 0x0, ttl 64, id 16595, offset 1480, flags [none], proto UDP (17), length 21)
172.24.235.26 > 10.0.0.3: ip-proto-17
14:49:36.754837 IP (tos 0x0, ttl 64, id 23763, offset 0, flags [+], proto UDP (17), length 1500)
172.24.235.26.59120 > 10.0.0.3.discard: UDP, bad length 1474 > 1472
14:49:36.754844 IP (tos 0x0, ttl 64, id 23763, offset 1480, flags [none], proto UDP (17), length 22)
172.24.235.26 > 10.0.0.3: ip-proto-17
从结果可以很明显看出,前两组由于产生的总长度没有超过1500(报文长度 + 8字节头部大小),所以前两个包并没有被分片,但是从第三条开始,tcpdump监听到的结果就出现了分片。从分片的输出也可以看出之前提到过的一些内容 例如标识id(这个地方可能是tcpdump的参数写少了什么,书里面版本的不是id是frag)、offset偏移量、flags[+]就是MF,+号表示后面还有组成这个数据报的分组。
在《TCP/IP详解》提到一点,有个非常有意思的现象:分片的投递顺序是优先投递偏移量最大的分片。原因是最后一片个分片先被投递,接收主机就可以确定所需的缓存空间的最大值。
书里面的结果确实是偏移量更大的先投递,但是我自己在测试的时候得到的结果并不是这样的,上面可以明确看出先打印的是偏移量小的。一开始我以为是翻译或者版本问题,我就查了下原版,第一版虽然没有提到这个点,但是第一版的tcpdump输出结果确实是偏移量大的先输出,第二版的原版确实也是这么写的。
然后我仔细看了下,bad length后面的输出居然在第一条输出就还原了UDP报文的总长度,这就很不科学了,明显是有问题的,因为第一个分片的信息不可能可以计算出UDP报文的总长度。所以我怀疑tcpdump这个输出是重组分片之后经过tcpdump自己排序后的输出,由于我对tcpdump的参数确实不是特别熟悉,所以之后我会去研究下这玩意儿,这里不做过多纠结。
这里再吐槽下《TCP/IP详解》这本书,这本书内容确实写得挺好的,但是第二版翻译确实很有问题,经常一句话中文看不懂要去搜原版的英文才能明白。而且书里面的程序还要自己另外去网上找,我用的这个版本还是一个外国老哥自己修复的版本。很多执行的语句并没有写出来,要靠自己去摸索,神TM还留下一句本书只讨论网络协议的概念,所以代码就不贴出来了…
超时重组
一个数据报的任何一个分片首先到达时,IP层就得启动一个计时器。如果不这么做的话,不能到达的分片最终有可能会导致接收方的缓存被耗尽。
Traceroute:差错报文类型的使用
大家应该都知道差错报文,例如终点不可达,源抑制等等。对于UDP来说,它会使用 ICMP 的规则,故意制造一些能够产生错误的场景,例如traceroute。
说起Traceroute,主要的作用有两个:
-
Traceroute 的第一个作用就是故意设置特殊的 TTL,来追踪去往目的地时沿途经过的路由器。
Traceroute 的参数指向某个目的 IP 地址,它会发送一个 UDP 的数据包。将 TTL 设置成 1,也就是说一旦遇到一个路由器或者一个关卡,就表示它“牺牲”了。如果中间的路由器不止一个,当然碰到第一个就“牺牲”。于是,返回一个 ICMP 包,也就是网络差错包,类型是时间超时。接下来,将 TTL 设置为 2。第一关过了,第二关就“牺牲”了,那我就知道第二关有多远。如此反复,直到到达目的主机。这样,Traceroute 就拿到了所有的路由器 IP。当然,有的路由器压根不会回这个 ICMP。这也是 Traceroute 一个公网的地址,看不到中间路由的原因。怎么知道 UDP 有没有到达目的主机呢?Traceroute 程序会发送一份 UDP 数据报给目的主机,但它会选择一个不可能的值作为 UDP 端口号(大于 30000)。当该数据报到达时,将使目的主机的 UDP 模块产生一份“端口不可达”错误 ICMP 报文。如果数据报没有到达,则可能是超时,因为 UDP 是无连接的,所以无法确定。
-
Traceroute 的第二个作用是故意设置不分片,从而确定路径的 MTU
要做的工作首先是发送分组,并设置“不分片”标志。发送的第一个分组的长度正好与出口 MTU 相等。如果中间遇到窄的关口会被卡住,会发送 ICMP 网络差错包,类型为“需要进行分片但设置了不分片位”。每次收到 ICMP“不能分片”差错时就减小分组的长度,直到到达目标主机。
关于流量和拥塞控制的缺失
大多数UDP服务器是迭代服务器。也就是说单个服务器线程(或进程)在单个UDP端口处理所有客户端的请求。通常一个应用程序的每个UDP端口均有一个大小有限的队列与之对应。也就是说来自不同客户机的、几乎同时到达的请求会被UDP自动排入队列里。然而,这个队列是有可能溢出的,一旦溢出UDP就会丢弃进入的数据报。UDP本身不提供流量控制,所以无法通知客户端。UDP自身是一个无连接协议,自身没有可靠机制,导致应用程序没有办法知道什么时候UDP输入队列产生了溢出。随之而来就有另一个问题,发送方和接收方之间的IP路由器也有类似的队列,这些队列满了之后,流量也有被直接丢弃的风险,这种情况就是平时常说的拥塞。和UDP输入问题不同的是,前面UDP只会影响单个程序,但是网络拥塞影响范围则会大得多。所以拥塞控制就特别关键。后面讨论TCP的时候,会重新讨论这个问题。
总结
UDP是一个相对简单的协议,UDP的正式规范[RFC0768]算上最后的文献才三页。从本质来说,UDP就是给用户进程提供端口号和校验和服务的。UDP没有流量控制、拥塞控制和差错纠正。UDP只有差错检测和消息边界保留。UDP的特点造就了它的优缺点,有点是速度快,开销更小,缺点就是不可靠,有可能造成拥塞。UDP的优缺点又决定了它的普通用途(流媒体,广播,组播)和特殊用途(Traceroute 等)。总之目前来说没有放之四海而皆准的协议,针对不同的情况做不同的取舍才是正道。
最后
以上就是冷静水蜜桃为你收集整理的UDP 用户数据报协议UDP 用户数据报协议的全部内容,希望文章能够帮你解决UDP 用户数据报协议UDP 用户数据报协议所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复