原文链接:https://mp.weixin.qq.com/s/GlZs42qnahajM0MJqlJl4Q
本文要点
-
引言
-
分片
-
ip_optcopy函数
-
重装
-
ip_reass函数
-
ip_slowtimo函数
-
小结
引言
本文将详细讨论在概说《TCP/IP详解 卷2》第8章 IP:网际协议中省略的IP分片与重装处理的部分。
IP具有一种重要功能,就是当分组过大而不适合在所选硬件接口上发送时,能够对分组进行分片。过大的分组被分成两个或者多个大小合适的在所选定网络上发送的IP分片。而在去目的主机的路途中,分片还可能被中间的路由器继续分片。因此,在目的主机上,一个IP数据报可能放在一个IP分组内,或者如果被分片,就放在多个IP分组内。因为各个分片可能以不同的路径到达目的主机,所有只有目的主机才能看到所有分片。因此,也只有目的主机才能把所有分片重装成一个完整的数据报,提交给相应的运输层协议。
IP首部内有三个字段实现分片和重装:标识字段(ip_id)、标志字段(ip_off的3个高位比特)和偏移字段(ip_off的13个低位比特)。标志字段由三个1bit标志组成。比特0是保留的,必须为0;比特1是“不分片”标志;比特2是“更多分片”标志。在Net/3中,标志和偏移字段结合起来,由ip_off访问,如图1所示。
图1 ip_off控制IP分组的分片
Net/3通过使用IP_DF和IP_MF掩去ip_off来访问DF和MF。ip_off的其它13bit指出在原始数据报内分片的位置,以8字节为单元计算。因而,除最后一个分片外,其它每个分片都希望是一个8字节倍数的数据,从而使后面的分片从8字节边界开始。图2显示了在原始数据报内的字节偏移关系,以及在分片的IP首部内分片的偏移(ip_off的低位13bit)。
图2显示了把一个最大的IP数据报分成8190个分片,除最后一个分片包含3个字节外,其它每个分片都包含8个字节;而且除最后一个分片外,其余分片都设置了MF比特。
图2 65535字节的数据报分片
原始数据报上面的数字是该数据部分在数据报内的字节偏移。分片偏移(ip_off)是从数据报的数据部分开始计算的。分片不可能含有偏移超过65514的字节,因为如果这样的话,重装的数据报会大于65535字节,这是ip_len字段的最大值。这就限制了ip_off的最大值为8189(8189x8=65512),只为最后一个分片留下3字节空间。如果有IP选项,则偏移还要小些。
因为IP互联网是无连接的,所以,在目的主机上,来自一个数据报的分片必然会与来自其它数据报的分片交错。ip_id是唯一地标识某个特定数据报的分片。源系统用相同的源地址(ip_src)、目的地址(ip_dst)和协议(ip_p)值,作为数据报在互联网上生命期的值,把每个数据报的ip_id设置成一个唯一的值。
总而言之,ip_id标识了特定数据报的分片,ip_off确定了分片在原始数据报内的位置,除最后一个分片外,MF标识每个分片。
分片
我们现在回到ip_output,分析分片代码。在概说《TCP/IP详解 卷2》第8章 IP:网际协议图20中,如果分组的大小不超过选定出接口的MTU,就在一个链路级帧中发送它。否则,必须对分组分片,并在多个帧中将其发送。分组可以是一个完整的数据报或者它自己也是前面系统创建的分片。我们分三个部分讨论分片代码:
-
确定分片大小(图3)
-
构造分片(图4)
-
构造第一个分片并发送分片(图5)
图3 函数ip_output:确定分片大小
253~261 分片算法很简单,但由于对mbuf结构链的操作使实现很复杂。如果DF比特禁止分片,则ip_output丢弃该分组,并返回EMSGSIZE。如果数据报是在本地生成的,则运输协议把错误传回该进程;如果分组是被转发的,则ip_forward生成一个ICMP目的地不可达差错报文,并指出不分片就无法转发该分组。
262~266 每个分片中的数据字节数len的计算是用接口的MTU减去分组首部的长度后,舍去低位的3个比特(&~7),成为8字节倍数。如果MTU太小,使每个分片中无法含有8字节的数据,则ip_output返回EMSGSIZE。
每个新的分片中都包含:一个IP首部、某些原始分组中的选项以及最多len长度的数据。
图4中的代码,以一个C的复合语句开始,构造从第2个分片开始的分片表。在分片表生成后(图5),原来的分组被转换成第一个分片。
图4 函数ip_output:构造分片
图5 函数ip_output:发送分片
267~269 外部块允许在函数中离使用点更近一点的地方定义mhlen、firstlen和mnext。这些变量的范围一直到块的末尾,它们隐藏其它在块外定义的有相同名字的变量。
270~276 因为原来的缓存链现在成了第一个分片,所以for循环从第2个分片的依稀开始:hlen+len。对于每个分片,ip_output采取以下动作:
-
277~284 分配一个新的分组缓存,调整m_data指针,为一个16字节链路层首部(max_linkhdr)腾出空间。如果ip_output不这么做,则网络接口驱动器就必须再分配一个mbuf来存储链路层首部或者移动数据。两种工作都耗时,在这里容易处理从而避免问题。
-
285~290 从原来的分组中把IP首部和IP选项复制到新的分组中。前者复制在一个结构中;ip_optcopy只复制那些将被复制到每个分片中的选项。
-
291~297 设置分片包括MF比特的偏移字段(ip_off)。如果原来分组中已设置MF比特,则把所有分片中都把MF置位;如果原来分组中没有设置MF比特,则除了最后一个分片外,其它所有分片中的MF都置位。
-
298 为分片设置长度,解决首部小一些(ip_optcopy可能没有复制所有选项),以及最后一个分片的数据区小一些的问题。以网络字节序列存储长度。
-
299~305 从原始分组中把数据复制到该分片中。如果必要,m_copy会再分配一个mbuf。如果m_copy失败,则发出ENOBUFS。sendorfree丢弃所有已分配的缓存。
-
306~314 调整新创建的分片的mbuf分组首部,使其具有正确的全长。把新分片的接口指针清零,把ip_off转换成网络字节序列,计算新分片的检验和。通过m_nextpkt把该分片与前面的分片链接起来。
在图5中,ip_output构造了第一个分片,并把每个分片传递到接口层。
315~325 把末尾多余的数据截断后,原来的分组就转换成第一个分片,同时设置MF比特,把ip_len和ip_off转换成网络字节序列,计算新的检验和。在这个分片中,保留所有的IP选项。在目的主机重装时,只保留数据中的第一个分片的IP选项(图25)。某些选项,如源路由选项,必须被复制到每个分片中,即使在重装时都被丢弃了。
326~338 此时,ip_output可能有一个完整的分片表,或者已经产生了错误,都必须把生成的那部分的分片表丢弃。for循环遍历该表,发送分片或者由于error而丢弃该分片。
ip_optcopy函数
在分片过程中,ip_optcopy(图6)复制到达分组(如果分组是被转发的)或者原始数据报中(如果该数据报是本地生成的)中的选项到外出的分片中。
图6 函数ip_optcopy
395~422 ip_optcopy的参数是:ip,一个指向原始分组的IP首部指针;jp,一个指向新生成的分片的IP首部的指针;ip_optcopy初始化cp和dp指向每个分组的第一个选项,并在处理每个选项时把cp和dp向前移动。for循环每次复制一个选项,当它遇到EOL选项或者检查完所有选项时。NOP选项被复制,用来维持后续选项的对齐限制。
如果IPOPT_COPIED指示的copied比特被置位,则ip_optcopy把选项复制到新片中。如果某个选项的长度太大,就被截断;ip_dooptions应该已经发现这种错误了。
423~426 第2个for循环把选项表填充到4字节的边界。由于分组首部长度(ip_hlen)是以4字节为单位计算的,所以需要这个操作。这也保证后面跟着的运输层首部与4字节边界对齐。这样会提高性能,因为在许多运输层协议的设计中,如果运输层首部从一个32bit边界开始,那么32bit首部将按照32bit边界对齐。
图7显示了ip_optcopy的运行。
图7 在分片中并不复制所有选项
在图7中,我们看到ip_optcopy不复制时间戳选项(它的copied比特为0),但却复制LSRR选项(它的copied比特为1)。为了把新选项与4字节边界对齐,ip_optcopy也增加了一个EOL选项。
重装
到目前为止,我们已经讨论了数据报的分片,现在再回到ipintr讨论重装过程。ipintr将分片重装成一个完整的数据报,然后整个交给运输层处理。ipintr接收的分片被传给ip_reass,由它尝试把分片重装成一个完整的数据报。图8显示了ipintr的代码。
图8 函数ipintr:分片处理
271~279 我们知道ip_off包含DF比特、MF比特和分片偏移。如果MF比特或者分片偏移非零,则DF就被掩盖掉了,分组就是一个必须被重装的分片。如果两者都为零,则分组就是一个完整的数据报。跳过重装代码,执行图8中最后的else语句,它从全部数据报长度中排除了首部长度。
280~286 m_pullup把位于外部簇上的数据移动到mbuf的数据区。在mtod宏开始工作之前,m_pullup必须把IP首部从外部簇移到mbuf的数据区中去。
287~297 Net/3在一个全局双向链表ipq上记录不完整的数据报。这个名字可能容易产生误解,因为这个数据结构并不是一个队列。也就是说,可能在表的任何地方插入和删除,并不限制一定要在末尾。
ipintr对表进行线性搜索,为当前分片找到合适的数据报。记住分片是由4元组{ip_id、ip_src、ip_dst和ip_p}唯一标识。ipq的每个入口是一个分片表,如果ipintr找到一个匹配,则fp指向匹配的表。
298~303 在found语句,ipintr为方便重装,修改了分组:
-
304 ipintr修改了ip_len,从中减去标准IP首部和任何选项长度。
-
305~307 ipintr把MF标志复制到ipf_mff的低位,把ip_top覆盖掉(&=~1只清除低位)。注意,在ipf_mff成为一个有效成员之前,必须把ip指向一个ipasfrag结构(图11)。此时,可以把ip_off作为一个16bit的偏移,而不是3个标志比特和一个13bit偏移来访问了。
-
308 用8乘以if_off,把它从以8字节为单元转换成以1字节为单元。ipf_mff和ip_off决定ipintr是否应该重装。图9描述了不同的情况以及相应的动作,其中fp指向的是系统以前为该数据报接收的分片表。许多工作是由ip_reass做的。
图9 ipintr和ip_reass中的IP分片处理
309~322 如果ip_reass通过把当前分片与以前收到的分片组合在一起,能重装成一个完整的数据报,它就返回指向该重装好的数据报的指针。如果没有重装好,则ip_reass保存该分片,ipintr跳到next去处理下一个分片。
323~324 当到达一个完整的数据报时,就选择这个else分支,并按照前面的一样修改ip_hlen。大部分执行流程会到达else分支,因为收到的数据报大多数不是分片。
如果重装处理产生一个完整的数据报,ipintr就把这个完成的数据报上传给相应的运输层协议:
(*inetsw[ip_protox[ip->ip_p]].pr_input)(m, hlen)
ip_reass函数
ipintr把一个要处理的分片和一个指针传给ip_reass,其中指针指向是的ipq中匹配的重装首部。ip_reass可能重装成功并返回一个完整的数据报,可能把该分片链接到数据报的重装链表上,等待其它分片到达后重装。每个重装链表的表头是一个ipq结构,如图10所求。
图10 ipq结构
52~60 用来标识一个数据报分片的四个字段,ip_id、ip_src、ip_dst和ip_p,被保存在每个重装链表静养的ipq结构中。Net/3用next和prev构造数据报链表,用ipq_next和ipq_pre构造分片的链表。
到达分组的IP首部在被放在重装链表之前,首先被转换成一个ipasfrag结构,如图11所示。
图11 ipasfrag结构
66~86 ip_reass在一个由ipf_next和ipf_prev链接起来的双向循环链表上,收集某个数据报的分片。这些指针覆盖了IP首部的源地址和目的地址。ipf_mff成员覆盖ip结构中的ip_tos。其它成员是相同的。
图12显示了分片首部链表(ipq)和分片(ipasfrag)之间的关系。
图12 分片首部链表ipq和分片
图12的左下部是重装首部的链表。表中第一个节点是全局ipq结构,ipq。它永远不会有自己的相关分片表。ipq表是一个双向链表,用于支持快速插入和删除。next和prev指针指向前一个和后一个ipq结构。
图12仍然没有显示重装结构的所有复杂性。重装代码很难跟踪,因为它完全依靠指针指向底层mbuf上的三个不同的结构。
图13显示了mubf、ipq结构、ipasfrag结构和ip结构之间的关系,包含了大量信息:
-
所有结构都放在一个mbuf的数据区内。
-
ipq链表由next和prev链接起来的ipq结构组成。每个ipq结构保存了唯一标识一个IP数据报的四个字段(图13中阴影部分)。
-
当作为分片链表的头访问时,每个ipq结构被看成一个ipasfrag结构。这些分片由ipf_next和ipf_prev链接起来,分别覆盖了ipq结构的ipq_next和ipq_prev成员。
-
每个ipasfrag结构都覆盖了到达分片的ip结构,与分片一起到达的数据在缓存中跟在该结构之后。ipasfrag结构的阴影部分是成员和含义与其在ip结构中不太相同。
图13 可通过多种结构访问的一段内存区
图12显示了这些重装结构之间的物理连接,图13显示了ip_reass使用的覆盖技术。图14从逻辑的观点说明重装结构:该图显示了三个数据报的重装,以及ipq链表和ipasfrag结构之间的关系。
图14 三个IP数据报的重装
每个重装链表的表头包含原始数据报的标识符、协议、源和目的地址。图14中只显示了ip_id字段。分片表通过偏移字段排序,如果MF比特被置位,则用MF标志分片,缺少的分片出现在阴影里。每个分片的数字显示了该分片的开始和结束字节相对于原始数据报数据区的相对偏移,而不是相对于原始数据报的IP首部。
这个例子用来说明三个没有IP选项的UDP数据报,其中每个数据报都有1024字节的数据。每个数据报的全长是1052(20+8+1024)字节,正好适合1500字节的以太网MTU。在到目的主机的途中,这些数据报会遇到一个SLIP链路,该链路上的路由器对数据报分片,使其大小适于放在典型的296字节的SLIP MTU中。每个数据报分4个分片到达。第1个分片中包含一个标准的20字节IP首部,8字节UDP首部和264字节数据。第2个和第3个分片中包含一个20字节的IP首部和272字节的数据。最后一个分片中在一个20字节首部和216字节数据(1032=272x3+216)。
在图14中,数据报5缺少一个包含272~543字节的分片。数据报6缺少第一个分片0~271字节,以及最后一个从偏移816开始的分片。数据报7缺少前三个分片0~815。
图15列出了ip_reass。前面讲到,当目的地是本机的某个IP分片到达时,在处理完所有选项后,ipintr会调用ip_reass。
图15 函数ip_reass:数据报重装
343~358 当调用ip_reass时,ip指向分片,fp指向匹配的ipq结构或者为空。因为重装只涉及每个分片的数据部分,所以ip_reass调整含有该分片的mbuf的m_data和m_len,减去每个分片的IP首部。
465~469 在重装过程中,如果产生错误,该函数就跳到dropfrag。dropfrag增加ips_fragdropped,丢弃该分片,并返回一个空指针。
在运输层丢弃分片通常会严重降低性能,因为必须重传整个数据报。TCP谨慎地避免分片,但是UDP应用程序必须采取步骤以避免对自己分片。
所有IP实现必须能够重装最多576字节的数据报。没有通用的方法来确定远程主机能重装的最大数据报大小。我们将在27章看到TCP提供一个机制,可以确定远程主机所能处理的最大数据报大小。UDP没有这样的机制,所以许多基于UDP的协议,都限制在576字节左右。
我们将分7个部分显示重装代码,从图16开始。
图16 函数ip_reass:创建重装表
a. 创建重装表
359~366 当fp为空时,ip_reass用新的数据报的第一个分片创建一个重装表。它分配一个mbuf来存放新表的头(一个ipq结构),并调用insque,把该结构插入到重装表的链表中。
图17列出了操作数据报和分片链表的函数。
图17 ip_reass采用的队列函数
b. 重装超时
367 ipq_ttl字段用于限制等待分片以重装成一个完整数据报的时间。这与IP首部的TTL字段是不同的,IP首部的TTL字段是为了限制分组在互联网中最大的跳数。
在Net/3中,重装超时的初始值设为60(IPFRAGTTL)。因为每次内核调用ip_slowtimo时,ipq_ttl就减去1,而内核每500ms调用ip_slowtimo一次。那么,如果系统在第一次接收到数据报的任一分片30秒后,还没有组装好一个完整的IP数据报,就丢弃该IP重装链表。重装定时器在链表被创建后的第一次调用ip_slowtimo时开始计时。
重装时间一般推荐为60~120秒,并且当收到数据报的第一个分片且定时器超时后,向源主机发出一个ICMP超时差错报文。重装后,总是丢弃其它分片的首部和选项,并且在ICMP差错报文中必须包含出错数据报的前64bit数据。所以,如果内核还没收到分片0,它就不能发ICMP报文。
c. 数据报标识符
368~375 ip_reass在分配给该数据报的ipq结构中保存ip_p、ip_id、ip_src和ip_dst,让ipq_next和ipq_pre指针指向该ipq结构,让q指向这个结构,并跳到insert(图22),把第一个分片ip插入到新的重装表中去。
ip_reass的下一个部分(图18),此时fp已不为空了,然后为新的分片找到正确的插入位置。
图18 函数ip_reass:在重装链表中找位置
376~381 因为fp不为空,所以for循环搜索数据报的分片链表,找到一个偏移大于ip_off的分片。
在目的主机上,分片包含的字节范围可能会相互覆盖。发生这种情况的原因是,当一个运输层协议重传某个数据报时,采用与原来数据报不同的路由;而且,分片的模式也可能不同,这就导致在目的主机上的相互覆盖;同时传输协议必须能强制IP使用原来的ID字段,这样才能使分片到达目的主机原先已存在(相同四元组)的重装列队中,才能识别该数据报可能是重传的。
Net/3并不为运输层协议提供机制保证在重传的数据报中重用IP ID字段。在准备新数据时,在概说《TCP/IP详解 卷2》第8章 IP:网际协议图17中,ip_output通过增加全局整数ip_id来赋一个新值,尽管如此,Net/3系统也能从让运输层用相同IP字段重传的IP数据报的系统上接收重叠分片。
图19说明分片可能以不同的方式与已到达的分片重叠。分片是按照它们到达目的主机的顺序编号的。重装的分片在图19底部显示,分片的阴影部分是被丢弃的多余字节。
图19 可能会在目的主机重叠的分片的字节范围
图20中代码截断或者丢弃到达的分片。
图20 函数ip_reass:截断到达分组
382~396 ip_reass把新片中与早到分片末尾重叠的字节丢弃,截断重复的部分(图20中分片5的前部),或者如果新分片的所有字节已经在早先的分片中(分片4)出现,就丢弃整个新分片(分片6)。
图21中的代码截断或者丢弃已有的分片。
图21 函数ip_reass:截断已有分组
397~412 如果当前分片部分地与早到分片的前端部分重叠,就把早到分片中重复的数据截掉(图19中分片2的前部)。丢弃所有与当前分片完全重叠的早到分片(分片3)。
图22中,到达分片被插入到重装链表。
图22 函数ip_reass:插入分组
413~426 在截断后,ip_enq把该分片插入链表,并扫描链表,确定是否所有分片全部到达。如果还缺少分片,或者链表最后一个分片的ipf_mff被置位,ip_reass就返回0,并等待更多的分片。
当目前的分片完成一个数据报后,整个链表被图23所示的代码转换成一个mbuf链。
图23 函数ip_reass:重装数据报
427~440 如果某个数据报的所有分片都被接收下来,while循环用m_cat把分片重新构造成数据报。
图24显示了一个有三个分片的数据报的mbuf和ipq结构之间的关系。
图24 m_cat重装缓存内的分片
图24中最暗的区域是分组的数据部分,稍淡的阴影部分是mbuf中未用的部分。有三个分片,每个分片都被存放在一个含有两个mbuf的链上:一个分组首部和一个簇。每个分片的第一个缓存上的m_data指针指向分组数据,而不是分组的首部。因此,由m_cat构造的缓存只包含分片的数据部分。
当一个分片含有多于208字节时,情况通常是这样。缓存的“frag”部分是分片的IP首部。由于图15中代码中m_data指针后移了hlen,所以各缓存链第一个缓存的m_data指针指向“opt”之后。
图25显示了用所有分片的缓存重装的数据报。注意,分片2和3的IP部分和选项不在重装的数据报里。
图25 重装的数据报
第一个分片的首部仍然被用作ipasfrag结构。它被图26中的代码恢复成一个有效的IP数据报首部。
图26 函数ip_reass:数据报重装
d. 重建数据报首部
441~456 ip_reass把ip指向链表的第一个分片,将ipasfrag结构恢复成ip结构:把数据报长度恢复成ip_len,源站地址恢复成ip_src,目的地址恢复成ip_dst;并把ipf_mff的低位清零。
ip_reass用remque所整个分组从重装链表中移走,丢弃链表表头ipq结构,调整第一个缓存中的m_len和m_data,把前端被隐藏起来的第一个分片的首部和选项包含进来。
e. 计算分组长度
457~464 此处的代码总是被执行,因为数据报的第一个缓存总是一个分组首部。for循环计算缓存链中的数据的字节数,并把值保存在m_pkthdr.len中。
在选项类型字段中,copied比特的意义现在应该很明白了。因为目的主机只保留那些出现在第一个分片的中的选项,而且只有那些在分组去往目的主机的途中控制分组处理的选项才被复制下来。不复制那些在传送过程中收集信息的选项,因为当分组在目的主机上重装时,所有收集的信息都被丢弃了。
ip_slowtimo函数
Net/3的各项协议可能指定每500ms调用一个函数。对IP而言,这个函数是ip_slowtimo,如图27所示,为重装链表上的分片计时。
图27 函数ip_slowtimo
515~534 ip_slowtimo遍历部分数据报的链表,减少重装TTL字段。当该字段减为0时,就调用ip_freef,把与该数据报相关的分片都丢弃。在splnet外运行ip_slowtimo,避免到达分组修改链表。
ip_freef显示如图28所示。
图28 函数ip_freef
474~486 ip_freef移走并释放链表上的fp指向的各分片,然后释放链表本身。
在第7章中,我们讲到IP把ip_drain定义成一个当内核需要更多内存时才调用的函数。这种情况通常发生在分配缓存时内存不够时。ip_drain显示如图29所示。
图29 函数ip_drain
538~545 IP释放内存的最简单办法就是丢弃重装链表上的所有IP分片。对属于某个TCP报文段的分片,TCP最终会重传该数据。属于UDP数据报的IP分片就丢失了,基于UDP的协议必须在应用程序层处理这种情况。
小结
本文介绍了当一个外出数据报过大而不适合在选定的网络上发送时,ip_output如何对数据报分片。由于分片在向目的地传送的途中可能继续被分片,也有可能走不同的路径,所以只有目的主机才能组装原来的数据报。
ip_reass接收到达的分片,并试图重装数据报。如果重装成功则传回ipintr,然后提交给相应的运输层。所有IP实现必须能够重装最多576字节的数据报。如果在一段合理时间内,分片未能重装成一个完整的数据报,ip_slowtimo就丢弃不完整的数据报。
更多最新文章尽在公众号:大白爱爬山,欢迎关注!