编写一个TCP/IP栈1-以太网和ARP

来自程序员技术小站
跳转到导航 跳转到搜索

编写自己的TCP/IP栈可能看起来是一项艰巨的任务。事实上,TCP在其三十多年的生命周期中积累了许多规范。然而,1核心规范看似紧凑——重要的部分是TCP头解析、状态机、拥塞控制以及传输超时计算。

最常见的第2层和第3层协议,分别是以太网和IP,与TCP的复杂性相形见绌。在本博客系列中,我们将实现一个适用于 Linux 的最小用户空间 TCP/IP 栈。

这些帖子和生成软件的目的纯粹是教育性的——在更深层次上学习网络和系统编程。

TUN/TAP 设备

为了拦截来自Linux内核的低级别网络流量,我们将使用Linux TAP设备。简而言之,TUN/TAP 设备经常被网络用户空间应用程序用于操作 L3/L2 流量。一个流行的例子是隧道,其中数据包被包裹在另一个数据包的有效载荷内。

TUN/TAP 设备的优点在于在用户空间程序中易于设置,并且已在众多程序(如 OpenVPN)中使用。

由于我们希望从第2层向上构建网络栈,因此需要一个TAP设备。我们这样进行即时验证:

/*
 * Taken from Kernel Documentation/networking/tuntap.txt
 */
int tun_alloc(char *dev)
{
    struct ifreq ifr;
    int fd, err;

    if( (fd = open("/dev/net/tap", O_RDWR)) < 0 ) {
        print_error("Cannot open TUN/TAP dev");
        exit(1);
    }

    CLEAR(ifr);

    /* Flags: IFF_TUN   - TUN device (no Ethernet headers)
     *        IFF_TAP   - TAP device
     *
     *        IFF_NO_PI - Do not provide packet information
     */
    ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
    if( *dev ) {
        strncpy(ifr.ifr_name, dev, IFNAMSIZ);
    }

    if( (err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0 ){
        print_error("ERR: Could not ioctl tun: %s\n", strerror(errno));
        close(fd);
        return err;
    }

    strcpy(dev, ifr.ifr_name);
    return fd;
}

之后,返回的文件描述符fd可以用来read而且write数据到虚拟设备的以太网缓冲区。

旗帜IFF_NO_PI这里至关重要,否则我们最终会收到预设在以太网帧的不必要的数据包信息。你实际上可以查看隧道设备驱动程序的内核源代码,并自行验证。

以太网帧格式

多种不同的以太网网络技术是局域网(LAN)中连接计算机的支柱。与所有物理技术一样,2以太网标准已从其1980年由数字设备公司、英特尔和施乐公司发布的首版2版大有演进。

以太网的首个版本在当今标准中较为缓慢——大约为10兆字节/秒,它采用了半双工通信技术,意味着您发送或接收数据,但并非同时进行。因此,必须采用媒体访问控制(MAC)协议来组织数据流。时至今日,如果在半双工模式下运行以太网接口,仍需要使用“载波感知”、带碰撞检测(CSMA/CD)的多重访问方式。

100BASE-T采用100BASE-T以太网标准,采用双绞线,实现全双工通信和更高的传输速度。此外,以太网交换机的普及程度同时增加,使得CSMA/CD基本过时。

不同的以太网标准由IEEE 802.3 3工作组维护。

接下来,我们将查看以太网帧头。如下所示,可以将其声明为C结构:

#include <linux/if_ether.h>

struct eth_hdr
{
    unsigned char dmac[6];
    unsigned char smac[6];
    uint16_t ethertype;
    unsigned char payload[];
} __attribute__((packed));

dmac而且smac是相当不言自明的领域。它们包含通信方的MAC地址(分别为目的地和来源)。

超载的字段ethertype是一个2-octet 字段,根据其值表示有效载荷的长度或类型。具体来说,如果该字段的值大于或等于1536,则该字段包含有效载荷的类型(例如IPv4、ARP)如果值小于该值,则包含有效载荷的长度。

类型字段之后tags,以太网帧可能会出现多个不同的标签。这些标签可用于描述帧的虚拟局域网(VLAN)或服务质量(QoS)类型。以太网帧标签不在我们的实现中,因此相应的字段也不会显示在我们的协议声明中。

字段payload包含指向以太网帧有效载荷的指针。在我们的情况下,这将包含一个ARP或IPv4数据包。如果有效载荷长度小于所需最小48字节(不含标签),则将空格字节附加到有效载荷的末尾,以满足要求。

我们还包括if_ether.hLinux 头向提供 ethertypes 与其十六进制值之间的映射。

最后,Frame Check Sequence以太网帧格式还包含了帧勾选句字段,Cyclic Redundancy Check该字段与循环冗余检查(CRC)配合使用,以检查帧的完整性。我们将在实现此字段时省略处理。

以太网框架解析

packed结构体声明中包含的属性是一个实现细节——它用于指示GNU 4C编译器不要针对数据对齐进行填充字节4的结构体内存布局进行优化。使用此属性完全源于我们“解析”协议缓冲区的方式,该缓冲区只是使用正确协议结构对数据缓冲区进行的一种类型转换:

struct eth_hdr *hdr = (struct eth_hdr *) buf;

一种便携且略显费力的方法,是手动对协议数据进行序列化。这样,编译器可以自由添加填充字节,以更好地满足不同处理器的数据对齐要求。

解析和处理传入的以太网帧的总体情况很简单:

if (tun_read(buf, BUFLEN) < 0) {
    print_error("ERR: Read from tun_fd: %s\n", strerror(errno));
}

struct eth_hdr *hdr = init_eth_hdr(buf);

handle_frame(&netdev, hdr);

handle_frame函数只是看ethertype以太网标头字段,并根据值决定下一步操作。

地址解析协议(Arp)

Address Resolution Protocol地址解析协议(ARP)用于动态地将48位以太网地址(MAC地址)映射到协议地址(例如IPv4 地址。关键是使用ARP,可以使用多种不同的L3协议:不仅使用IPv4,还可以使用其他协议,例如宣布16位协议地址的CHAOS。

通常情况下,你了解局域网中某个服务的IP地址,但要建立实际通信,硬件地址(MAC)也需要被知晓。因此,ARP 用于广播和查询网络,要求 IP 地址的所有者报告其硬件地址。

ARP 数据包格式相对简单:

ARP头球(arp_hdr包含2-octethwtype确定所使用的链接层类型。这是我们的以太网,实际值是0x0001

2-octetprotype字段表示协议类型。在我们的例子中,这是IPv4,它与值进行通信0x0800

hwsizeprosize字段大小均为1-octet,且分别包含硬件和协议字段的大小。在我们的情况下,这些地址为 MAC 地址为 6 字节,IP 地址为 4 字节。

2-octeopcode声明 ARP 消息的类型。可以是 ARP 请求(1)、ARP 回复(2)、RARP 请求(3)或 RARP 回复(4)。

data字段包含 ARP 消息的实际有效载荷,在我们的情况下,这将包含 IPv4 的特定信息:

struct arp_ipv4
{
    unsigned char smac[6];
    uint32_t sip;
    unsigned char dmac[6];
    uint32_t dip;
} __attribute__((packed));

smac和dmac分别包含发送方和接收方的6字节MAC地址。sip而且dip分别包含发送方和接收方的IP地址。

地址解析算法

原始规范描述了用于地址解析的简单算法:

?Do I have the hardware type in ar$hrd?
Yes: (almost definitely)
  [optionally check the hardware length ar$hln]
  ?Do I speak the protocol in ar$pro?
  Yes:
    [optionally check the protocol length ar$pln]
    Merge_flag := false
    If the pair <protocol type, sender protocol address> is
        already in my translation table, update the sender
        hardware address field of the entry with the new
        information in the packet and set Merge_flag to true.
    ?Am I the target protocol address?
    Yes:
      If Merge_flag is false, add the triplet <protocol type,
          sender protocol address, sender hardware address> to
          the translation table.
      ?Is the opcode ares_op$REQUEST?  (NOW look at the opcode!!)
      Yes:
        Swap hardware and protocol fields, putting the local
            hardware and protocol addresses in the sender fields.
        Set the ar$op field to ares_op$REPLY
        Send the packet to the (new) target hardware address on
            the same hardware on which the request was received.

translation table用于存储 ARP 的结果,以便主机能够查看其缓存中是否已包含该条目。这可以避免为冗余的 ARP 请求发送网络垃圾邮件。

该算法在 arp.c 中实现。

最后,实现ARP的最终测试是,它是否能正确回复ARP请求:

[saminiir@localhost lvl-ip]$ arping -I tap0 10.0.0.4
ARPING 10.0.0.4 from 192.168.1.32 tap0
Unicast reply from 10.0.0.4 [00:0C:29:6D:50:25]  3.170ms
Unicast reply from 10.0.0.4 [00:0C:29:6D:50:25]  13.309ms

[saminiir@localhost lvl-ip]$ arp
Address                  HWtype  HWaddress           Flags Mask            Iface
10.0.0.4                 ether   00:0c:29:6d:50:25   C                     tap0

内核的网络栈从我们的自定义网络堆栈中识别出ARP响应,随后通过输入我们的虚拟网络设备来填充其ARP缓存。成功!

结论

以太网帧处理和自动处理处理的实现非常简单,只需几行代码即可完成。相反,奖励因素相当高,因为你可以使用自己的支持性以太网设备来填充Linux主机的ARP缓存!

该项目的源代码可在 GitHub 上找到。

在下一篇文章中,我们将继续使用ICMP回声与回复(ping)以及IPv4数据包解析来实现。

来源

  1. https://tools.ietf.org/html/rfc7414 ↩↩
  2. http://ethernethistory.typepad.com/papers/EthernetSpec.pdf ↩
  3. https://en.wikipedia.org/wiki/IEEE_802.3 ↩
  4. https://gcc.gnu.org/onlinedocs/gcc/Common-Type-Attrites.html#Common-Type-Attributes ↩
  5. https://github.com/chobits/tapip ↩