什么是 UDP 协议

UDP 是一个简单的面向数据报的传输协议,它处于传输层中。无论是 TCP 还是 UDP 都是有端口的概念的,端口一般又和 socket 联系在一起。所以说,基本上一个进程的输出,都会对应一个 UDP 或者 TCP 的数据报。

UDP 数据报的组成

UDP 数据报一共可分为5个部分

  1. 目的端口号
  2. 源端口号
  3. UDP 数据报长度(首部+数据部分,最低为8B)
  4. 校验和
  5. 数据部分

目的端口号和源端口号都可以视作为对应了发送端和接收端的两个进程。UDP 数据报的长度包含了首部和数据部分,并且最小不能低于8,因为前4部分构成了 UDP 数据报的首部。这四个字段的字节数是8B。换句话说,网络中是可以传输数据部分为0字节的 UDP 数据报的。

关于校验和字段,UDP 和 TCP 数据报都会有。唯一的区别是,UDP 是可选的,TCP 是必须的。UDP 计算校验和的方式和 IP 数据报计算的校验方式一样。除此之外,为了计算校验和,UDP 或者 TCP 数据报还会包含一个伪首部部分。它包含 IP 数据报的某些内容,通过源 IP 和目的 IP,我们可以知道是否这个数据报不应该由我们这台主机来处理,协议字段可以让我们了解到,这个数据报是应该交由 UDP 端口的进程来处理还是 TCP 端口的进程。

IP 分片

当 IP 数据报的长度,也就是总长度减去首部长度超过了 MTU 大小的时候,可能就会涉及到分片的操作。分片的标准应该是按照发送端所在网络的 MTU 进行的,但是当数据报流动到了其他的网络,并且两个网络之间的 MTU 是不一样的,很可能再次发生分片操作。因为网络层的 IP 协议并不是可靠的,面向连接的。那么,当接收端的网络层接收到一堆一些被分片了但是又属于同一个数据报的报文的时候,就需要按照一定的规则将他们组装起来,提供给传输层。

  1. 标识字段: 在 IP 数据报的首部,通常有一个16bit 的标识字段。它是内存当中维持的一个计数器。每当网络层发送一个 IP 数据报,那么这个标识字段就会被加1。一个比较大的 IP 数据报在分片的时候,原始数据报中的标识字段会被复制到各个分片的数据报中。
  2. 标志字段:在标识字段的后面紧接着3bit 的位置,有一个标志字段。当 IP 数据报发生分片的时候,除了最后一份分片的数据报之外,其余的每一片数据报都需要将某一位置为1,标识还有“更多”的分片数据报,相当于告诉接收端的网络层,这不是最后一份分片数据报。
  3. 片偏移字段:此字段是紧接着标志字段的,一共有13bit 左右。它标识了分片数据报的起始字节距离原始数据报开始处的位置是多少
  4. 总长度值:数据报被分片之后,相应分片的数据报总长度不再为原始数据报的长度,应为该分片数据报的实际长度

IP 数据报因大小问题可能会导致分片,并且在分片之后,对于接收端来说也是可以通过 IP 首部字段将这些分片的数据组装在一起的。但是这里有一个非常严重的问题,IP 分片一旦发生,甚至分片的次数越多,数据报在网络传输的过程中丢失的概率也就越大。由于 IP 协议并不为数据传输提供可靠性,当某一分片的数据报丢失,传输层的 TCP 协议很可能会重传整个数据报。如果这种出错的概率较高但是出错的分片数站总分片数的比例比较低,就会对网络造成很大的负担。并且,很多时候,如果是在通信过程中的某个路由发生分片,我们的发送端甚至都是不知情的,因为它没有任何的超时重传和确认的机制。

如果在传输层使用了 UDP 协议,那么在 IP 数据报数据部分的前8个字节中会存有 UDP的端口号。在 IP 分片的时候,仅仅只有第一片数据会存有 UDP 的端口号。但是所有的分片都会在 IP 数据报首部表明协议字段,代表传输层使用了何种协议。

ICMP 不可达差错报文

这里要说的 ICMP 不可达差错报文和 traceroute 程序到达目的主机所发送的不是一回事。traceroute 程序发送的 TTL 值递增的 ICMP 请求报文,当到达目的主机 TTL 值大于等于0时,会向源主机发送一个 ICMP 端口不可达差错报文。告诉源主机,发送这个差错报文的主机就是目的主机了,trace 的过程已经结束了。但是我们现在要说的这个 ICMP 不可达差错报文是和 IP 分片有关系的。

在 IP 数据报首部有一个标志字段,它一共有三位,目前只有前两位是有意义的。低位为1代表还有更多的分片,中间一位为1代表该数据报不能分片。这个字段和 ICMP 不可达差错报文结合起来,主要用作我们发现通信路径当中最小的 MTU 是多少,即路径 MTU 发现机制。之所以这么办,也是为了能够在网络中尽量减少分片,路径 MTU 其实还是遵循了「木桶原理」,最短的板往往决定了整条链路中实际使用的 MTU 大小。

这种 ICMP 不可达差错报文,和之前的不同之处在于,在 ICMP 的首部中,存有下一站网络的MTU。也就是说,当我们为 IP 数据报设置了不可分片的标志位,路径中如果发现 MTU 小于 IP 数据报长度的,返回的这个 ICMP 数据报内部的 MTU 值即可作作为下一次 IP 数据报的长度。这样就可以顺利的通过这个发送 ICMP 回显应答的路由器或者主机了。

路径 MTU 发现机制

排除以太网内相互通信的机器,大部分主机在进行通信的时候,还是需要跨越多个不同的链路的。我们都知道,不同的链路上有不同的 MTU。整条通信路径上最小的 MTU 被称作是路径 MTU。如果想尽可能的减少在通信过程中进行分片导致传输成功率降低的风险的话,我们需要去确认即将通信的链路上的路径 MTU。使用上面提到的 ICMP 不可达差错报文,即可实现这一点。

通过改动 traceroute 和 ping,可以指定发送数据报的长度。在发送第一份数据报的时候,长度为当前主机所在链路的 MTU,并且将 IP 数据报的标志位的中间位置为1,代表不可被分片。在到达目的主机的过程中,如果遇到了链路 MTU 比当前数据报长度小的时候,所经过的路由器就会丢弃这份数据报,并且向源主机发送一份 ICMP 不可达差错报文,里面可能会有之前没有通过的那条链路上的MTU 值。

即使我们通过上述过程获得了当前路径 MTU 进行正常的通讯,但是在观察向目的主机发送数据报分片现象的时候,每隔一段时间,源主机还是会向目的主机发送一份不可分片的数据报,来检测一下路径 MTU 有没有变化。路径 MTU 发现机制其实最重要的意义,笔者觉得还是想把分片这件事尽量控制在发送端上面,甚至是不分片。例如,如果 IP 数据报的首部没有设置不让分片,如果到了一些 MTU 比较小的链路上,还是会被中间的路由器所分片的,只不过这个时候我们是不知道的。并且对数据报进行分片次数太多,确实是一个风险比较高的事情,IP 层是没办法保证数据传输的可靠性的。只能依靠IP 数据报中的标识符,偏移量,标志位等字段来拼接好分片的数据报。

UDP 和 ARP 之间的相互作用

当我们在发送一个很大的 UDP 数据报的时候,在网络层就需要分片。但与此同时目的主机的物理地址我们是不知道的。这个时候,分片和 发送 ARP 请求报文获取目的主机物理地址的两件事情进行的先后顺序会对数据报的发送造成影响。

UDP 数据报过大到达网络层会进行分片,假设现在分成了8片的 IP 数据报。因为这8份数据报都带有目的 IP 地址。所以,在获取目的主机 ARP 请求报文的数量上也应该和分片数是相等的。在返回第一个ARP应答报文的时候,只会发送最后一个分片的数据给目的端主机,这是和 ARP 协议实现有关的。并且,当目的端接收到第一个数据报分片的时候,会启动一个定时器,一段时间内如果没有收到完成的数据报,就会将已经接收到的内容丢弃,并且尝试发送给源主机一份 ICMP 差错报文。但是,也要看,是否第一个分片的数据报到达了目的端。因为只有第一个分片的数据报内才有源主机的 UDP 端口号,目的主机如果拿不到这个端口号,也是没办法给源主机发送 ICMP 差错报文的。

由于写这篇文章的时候,笔者身边没有多余的电脑,使用手机和本机在局域网内测试的时候,并没有发现 arp 数据报的数量等于分片数。经人提醒,应该是无线网络环境的原因,稍后条件满足会把这部分抓包的实例图补上来。暂时先补一张 TCP/IP 协议卷一书上面的图

最大 UDP 数据报长度与 ICMP 源站抑制差错报文

UDP 数据报也是被封装在 IP 数据报内部传输的,所以 UDP 数据报的长度,理应受到 IP 数据报长度的限制。IP 数据报首部有一个总长度的字段,占16位,也就是说 IP 数据报的最大长度可以达到2的16次幂个字节,也就是65535B。减去 IP 首部字段20B,UDP 数据报头部8B,UDP 数据报可以达到65507B。但是在现实的网络环境中,UDP 数据报的长度是远远小于这个值的。

首先,UDP 数据报的长度受限于使用 UDP 协议的应用程序对发送和接受缓存大小的限制。比如,我的某个使用 UDP 协议的程序只要求读取最大大小不超过512B 的 UDP 数据报(不包括首部长度),但是却收到一个长度大于这个阈值的 UDP 数据报,此时根据TCP/IP协议在内核的实现不同,可能会发生不同的行为,最常见的就是截断并且丢弃多余的部分,并且还不会通知接收者。

在 TCP 协议当中,如果发送端发送数据报的速度过快,接收端可能会使用滑动窗口机制等措施来限制通信的流量。UDP 同样有解决这种问题的措施,但是却不一定会这么做。UDP 协议依赖的是 ICMP 源站抑制差错报文来通知发送端发送数据速度过快。这个 ICMP 数据报是否会发送,一个是和不同系统对 TCP/IP协议的实现有关,另外一个是,可能发送了但是被接收端忽略了,因为这个时候引起源站抑制的进程在发送端已经终止了。通过这个现象,也可以证明了,UDP 是一个不可靠的通信协议。

Experiment1: UDP 输入队列溢出

这里使用 TCP/IP 协议卷一作者提供的 sock 程序

在 A 机器启动一个提供 UDP 的服务器,启动 sock 作为服务端进程监听客户端发送的 UDP 数据报

在 B 机器启动一个客户端 sock 进程,用于发送 UDP 数据报给 A 机器的服务端进程。并且发送一份数据报12345

A 机器此时会接收到一个来自 B 的 UDP 数据报,并打印出相关信息。可以同时观察,服务端进程的输出以及 tcpdump 的结果

但是现在,我们发送一个比较长的 UDP 数据报,411B,就会发现。虽然 A 机器上的 tcpdump 抓取到了发过来的 UDP 数据报,但是 A 上的 sock 进程却没有打印出这份数据报的信息。这说明了什么,说明客户端的 UDP 数据报被发送过来了,但是服务端并没有处理。在启动 A 机器上面的 sock 服务端进程的时候,我们使用-R 参数设置了 UDP 接收缓存为256B,很明显,我们发送的411B已经超出了服务端进程的 UDP 缓存大小,所以被服务端丢弃处理了。通过上面的实验我们就可以看出,在笔者所在的机器以及操作系统上面,服务端接收到了超过其接收缓存大小的 UDP 数据报将会被直接丢弃。

Experiment2: UDP 限制本地 IP 地址和远端 IP 地址

当我们以某一个端口号启动一个提供 UDP 服务的服务器时,通常不会指定本地的 IP 地址。至于为什么需要指定呢?很多人会比较疑惑,因为按照常理来讲一台主机应该只有一个IP。但是事实的情况是,很多服务器或者路由器都会安装多个网卡,每一块网卡都会有一个自己的 IP 地址和物理硬件的地址。因为这台主机所处的网络情况可能比较复杂,他可以同时连接两个不同的网络,这样对于他来说,就相当于有一个出口地址和入口地址。

首先我们来看一下不指定 IP 地址而启动一个 UDP 服务器的情况:

通过这张图我们就可以看出,在本地启动了一个端口为7777的 UDP 服务器。然后通过 netstat 命令可以观察到,在展示结果的第四列 LocalAddress, 看到了我们所启动的 UDP 服务器的端口状态。*.7777代表了,任何发送到本机的7777端口的 udp 数据报都可以被接收并且处理。这应该是一种比较常见的情况。

那么,再来看一下指定 IP 地址而启动一个 UDP 的服务器的情况:

对比上图我们可以看出,唯一的区别是,LocaAddress 一列中的 端口号7777前面变成了我们本机的 IP 地址,而不再是一个星号。那么这就相当于我们对本地 IP 做了限制。因为 UDP 数据报还是放在 IP 数据报内进行传输的,监听7777端口的 UDP 服务器进程也是通过网络层->运输层->应用层的顺序逐级将该数据报递交到服务器进程中的。这个时候,如果我们在启动 UDP 服务器的时候限制了本地的 IP 地址,那么,如果发送到我们主机的 UDP 数据报的目的 IP 地址不是我们所制定的 IP,运输层是不会将这个数据报交付给他上层的 UDP 服务端进程的。此时,该主机会向这个报文的发送端返回一个 ICMP 端口不可达的错误。

当我们在创建 UDP 端点的时候,如果指定了本地 IP,那么对于同一个端口号来讲,如果恰巧此时本地也有多个可绑定的本地 IP,完全可以启动多个 UDP 端点,它们的 本地 IP 不同,但是端口号相同。不过,此时可能需要在创建 socket 的时候要指定一些特殊的参数才可以,如 SO_REUSEADDR 选项。除此之外,还有一个问题就是,当一个数据报到达目的主机的时候,出现了上述多个不同 IP 但是 port 相同的 UDP 端点,此时,匹配的优先级会按照先具体再模糊的原则,也就是说,带有星号的 UDP 端点可能是最后才会匹配到的,这和 IP 选路的机制其实是有些类似的。

既然可以限制本地 IP 地址(可以理解为接收者的 IP 地址),按道理来说,应该也可以限制远端发送者的 IP 地址和端口号。这个道理就类似,接收者可以通过你的目的地址来过滤我是否要接收这个 UDP 数据报, 也可以通过发送者的 IP 地址和端口号来过滤。

如上图所示,我们限制了远端的 IP 地址和端口,再使用 netstat 命令观察的时候,发现第五列 ForeignAddress 也不再是*.*的形式了。这时,如果再有接收到的 UDP 数据报,就需要同时满足数据报携带的目的 IP 地址和端口号,源 IP 地址和端口号,符合我们制定的规则,才可以被接收并处理,否则,还是会以 ICMP 的形式进行报错。匹配规则和限制本地 IP 时是一样的。

最后:与多播和单播的关联

在一个支持多播的系统上,是允许多个 UDP 服务进程共享一个 UDP 端点的(本地 IP+端口号)。此时,如果该本地 IP 是一个广播地址且规则匹配成功,到达的 UDP 数据报会复制 N 份发送给监听这个端口地址的多个 UDP 服务进程。如果本地 IP 地址是一个单播,那么会依照实现方式的不同,将数据报发送给某一个 UDP 进程。