【手撕TCPIP协议栈】ARP模块设计

概述

\(ARP\) 模块比较复杂,其主要分为 \(ARP\) 数据包、 \(ARP\) 缓存表、定时器三大模块。

这三大模块互相配合,严丝合缝的完成了 \(ARP\) 模块的运转。

首先 \(ARP\) 缓存表和定时器共同完成了地址对的缓存与生存周期的设计。同时数据包的收发,要么查询缓存表,要么更新缓存表,都需要跟缓存表打交道。因此可以看到,这三大模块最基础的部分就是缓存表。

ARP数据包

本项目中协议栈实现的只有以太网协议与 \(IPV4\) 协议,因此 \(ARP\) 数据包恒定为 2-2-1-1-2-6-4-6-4,总共28字节。

缓存表的设计

首先来看看缓存表是如何完成的,这里画出了其单个表项的结构,示意图如下:

以上的结构记录的就是一个\(ARP\)模块中的地址对,我们记录了每个地址对的隶属网卡,地址对等信息。

我们暂时不用管 \(tmo\)\(retry\) 字段,抛去这两个字段,我们还剩余5个字段,网卡地址对 字段不用赘述,重要的在于 表项状态数据包缓存 字段,这两个字段是配合 \(ARP\) 模块完成请求和响应解析的重要工具。

首先我们需要知道,\(ARP\) 缓存表我们是定义了一个固定大小为50的数组,并移交给定长内存块进行管理。我们的分配和释放都走定长内存块的接口。那么,当这个表项还处于内存块中没有被取用的时候,所有字段都为空,表项状态 则设置为 \(FREE\) ,表示这个表项没有被启用。同时我们定义一个链表结构:

static std::list<arp_entry_t*> cache_list;

这个链表将会存储所有从内存块中取出来的缓存表项。

以上内容告诉我们一个重要信息,那就是 \(ARP\) 缓存表的长度是有上限的,这部分我们后面会进行处理。

\(Ethernet\) 模块来查询一个 \(IP\) 对应的 \(MAC\) 地址的时候,很显然最开始我们的 \(ARP\) 缓存表中什么也没有,因此这个时候,我们就要去内存块中去申请一个表项,填入传过来的 \(IP\) 地址,并将 \(MAC\) 地址留空,这个时候我们就要将其 表项状态 设置为 \(WAITING\) ,表示我们正在等待这个 \(MAC\) 地址的填入。这个时候,就可以构造一个 \(ARP\) 请求,通过 \(Ethernet\) 的接口将请求包发送出去。

需要注意的是,\(Ethernet\) 模块传过来的还有待发送的数据包,也就是说,其移交给了我们两个信息:\(IP\) 与数据包。\(IP\) 我们已经填入了表项中,数据包这个时候就需要放入这个表项的 数据包缓存 中。一旦这个地址对被解析完成,也就是我们获知了 \(MAC\) 地址,就直接从缓存中将这些数据包发送出去。

而当我们收到 \(ARP\) 的数据包,就可以将这个数据包的发送方 \(IP\) 地址和 \(MAC\) 地址对缓存起来。在这个缓存的过程中,我们是首先去遍历缓存链表,看有没有地址对与这个新收到的数据包的地址对一致的表项,再决定是否申请新的表项进行缓存,确切的地址对,被缓存之后的状态就是 \(RESOLVED\) 。而且要注意的是,在这个过程中,涉及到缓存表的更新,也就是说,一旦有一个缓存表项被波及,就将这个表项设置到链表的最前端。因为这说明这个地址对目前是比较活跃的,放在前面可以节省遍历的时间。

由此,我们总结出以下重要经验:

  • 表项状态的意义在于,对于未被解析的表项,可以用来缓存数据包。一旦被解析,就可以将这些数据包发送出去,而不是对一个数据包一直等待。
  • 一旦一个表项被增改查,就把这个表项移动到链表的最前端。

缓存表与定时器的交互

在上面的介绍中,我们忽略了 \(tmo\)\(retry\) 这两个字段,这是因为这两个字段是服务于定时器模块的。我们在这里进行详细讲解。

我们知道 \(ARP\) 缓存表的一项重要功能在于,其内部的缓存表是有生命周期的,只能存在一段时间。而且,对于 \(ARP\) 请求包而言,当我们长时间收不到回复,也不能一直等下去,要么需要设置超时重复发送,要么就是超时多次,需要对表项进行删除。这都需要依赖于定时器模块的运作。

定时器模块工作在工作线程中,我们使用链表将这些定时器串接起来。工作线程一直在 while 循环中渴读消息队列,我们这里不需要另外再开一个线程,而是直接给读取消息队列的 while 循环,设置一个超时时间。

每一次循环开始,我们都去获取第一个定时器的倒计时 t,然后将工作线程读取消息队列的超时时间设置为这个倒计时。同时我们使用库函数,更新每次循环的的真实时间 t2。每一次工作线程(要么读取超时,要么处理读取事件完毕)作业完毕之后,就去扫描定时器,将时间周期更新为减去 t2 之后的新时间。这样在扫描之后,超时的定时器重新设置定时周期,插入定时器链表,并且执行超时函数。未超时的定时器,就只更新到最前面那个,后面就不用扫了。

对于 \(ARP\) 模块来说,定时器能够被工作线程智能管理,那么在这个模块中,我们只需要设置好超时函数即可。我们定义一个计时器,定时周期设置为1秒。我们称这个函数超时,就表示被工作线程扫描一次。同时定义:

  • \(tmo\) :剩余的被工作线程扫描次数。因为定时器周期为1秒,因此超时函数差不多每1秒执行一次,每次执行,\(tmo\) 都会减一。
  • \(retry\)\(tmo\) 到期之后,剩余还可以重连的次数。

这两个字段会根据 表项状态 的不同而设置不同的值。当 \(tmo\) 归零,我们就开始真正地执行超时处理逻辑:

  • 如果是 \(RESOLVED\) ,就将表项的状态设置为 \(WAITING\) ,并重新发送一个 \(ARP\) 请求,开始每隔 3 秒重试一次,重试的最大次数为 5。
  • 如果是 \(WAITING\) ,就将 \(retry\) 的次数减1,并且重置 \(tmo\) 为3秒 + 发送 \(ARP\) 请求,当达到最大重试次数,就删除表项。

因此,定时器就给了我们设置缓存周期的能力。对于那些被解析完成的表项,它们每隔20分钟会进入超时,这个时候表项进入待解析状态,我们每隔3秒发送一次 \(ARP\) 请求,如果连续5次都得不到 \(ARP\) 响应,我们就认为这台主机已经不在网络上了,直接将这个表项删除。

而对于那些从 \(Ethernet\) 模块传过来查询的地址来说,其初始状态就是 \(WAITING\) ,我们重试5次就最大程度的保证了这个数据包的正常传输。

ARP输出设计

其实在上面,我们已经大致理解了 \(ARP\) 模块的运转逻辑,但其实还可以进一步地分为输出和输入来讲解。

我们要实现的目标就是,将Ethernet模块递交过来的IP地址转换为MAC地址,然后利用以太网的接口执行发送。因此我们可以看到,\(Ethernet\) 模块的发送其实是大部分依赖于 \(ARP\) 协议的。在这里,\(ARP\) 模块的运转分为三部分,当 \(Ethernet\) 模块问询 \(IP\) 地址对应的 \(MAC\) 地址(递交 数据包 + 目标IP ):

  • 如果 \(ARP\) 缓存表中有对应 有效 表项,直接使用该 \(MAC\) 地址进行发送数据包,同时提高这个缓存表项的优先级到链表最前面。
  • 如果找到了,但是地址对是 尚未解析 的状态,就将数据包放入数据包缓存链表中。
  • 如果没有对应表项,则 强制 创建对应表项,将 \(Ethernet\) 递交过来的数据包缓存到表项中,同时发送 \(ARP\) 请求包,同时设置 \(tmo\) 为3秒,重试次数为5。这样就可以每隔3秒发送一次 \(ARP\) 请求包,尝试5次。
    • 如果这期间收到了对应的 \(ARP\) 响应包,就将表项设置为已解析,更新一下地址对,发送缓存的所有数据包,并将表项放到链表最前端。
    • 如果重试次数用完依旧没有收到响应,就释放所有缓存数据包,并删除这个表项。

ARP输入设计

  • 对于一个 \(ARP\) 包,如果其目的地址是我自己,那么
    • 如果是 \(ARP\) 响应包,说明我之前发送出去了一个请求包,那我就去更新 \(ARP\) 缓存表中的地址对,在这个过程中会发送掉那些处于等待队列的数据包。
    • 如果是 \(ARP\) 请求包,说明是找我要 \(MAC\) 地址,那我就将对方的 \(IP + MAC\) 地址对存储到本地缓存中,再发送 \(ARP\) 响应包。
  • 如果其目的地址不是我自己,说明这是一个 \(Ethernet II\) 的广播包(这样才能保证被网卡捕获),\(ARP\) 就在 不强制 申请内存的条件下,将对方的地址对存储起来。

需要注意的是,之前说过,缓存表的大小容量是50,对于第一种情况,明确要和我通信的情况下,我们需要 强制 去申请缓存表项。也就是说,如果缓存表的容量这个时候用完了,就直接删除最老的表项,其实也就是链表的最后一项。用它作为承载者,来记录我们的地址对。

而第二种情况下的广播包,我们不强求。如果表项用完了,我们就不记录了。

posted @ 2023-07-22 18:57  Suubai  阅读(32)  评论(0编辑  收藏  举报