Let's code a TCP/IP stack, 1: Ethernet & ARP
2025-3-15
| 2025-3-15
Words 3827Read Time 10 min
type
status
date
slug
summary
tags
category
icon
password
编写自己的 TCP/IP 协议栈似乎是一项艰巨的任务。事实上,在其 30 多年的发展过程中,TCP 已经积累了许多规范。然而,核心规范相对紧凑——其中最重要的部分包括 TCP 头解析、状态机、拥塞控制和重传超时计算。
相比之下,最常见的二层和三层协议(即以太网和 IP)要比 TCP 简单得多。在本系列博客中,我们将在 Linux 上实现一个最小化的用户态 TCP/IP 协议栈。
这些文章及其最终的软件仅用于教育目的——旨在更深入地学习网络和系统编程。

TUN/TAP devices

为了从 Linux 内核拦截底层网络流量,我们将使用 Linux TAP 设备。简单来说,TUN/TAP 设备通常被用户态的网络应用程序用于操作 L3/L2 层流量。一个常见的应用场景是隧道技术(tunneling),其中一个数据包会被封装到另一个数据包的有效载荷中。
TUN/TAP 设备的优势在于,它们易于在用户态程序中设置,并且已经被广泛应用于许多程序,例如 OpenVPN
由于我们希望从**数据链路层(Layer 2)**开始构建网络协议栈,因此我们需要一个 TAP 设备。我们可以通过以下代码创建一个 TAP 设备:
在成功创建 TAP 设备 之后,返回的文件描述符 fd 可用于读取和写入虚拟设备的以太网缓冲区。这意味着,我们可以通过 read(fd, buf, len) 读取来自 TAP 设备的以太网帧,并通过 write(fd, buf, len) 发送数据帧。
在创建 TAP 设备时,IFF_NO_PI 这个标志至关重要。如果没有它,内核会在以太网帧前面附加额外的包信息,这会导致解析数据帧时出现偏移问题。
你可以查看内核的 tun 设备驱动代码,以确认这一点。例如,在 Linux 内核的 drivers/net/tun.c 文件中,可以找到 IFF_NO_PI 选项的具体作用。这个选项确保我们直接获得标准的以太网帧,而不会受到额外的协议信息干扰。

以太网帧格式 (Ethernet Frame Format)

各种不同的 以太网技术 组成了局域网 (LAN) 连接计算机的核心。从 1980 年 Digital Equipment Corporation、Intel 和 Xerox 发布第一版以太网标准以来,以太网技术已经发生了巨大的变化。
最初的以太网标准在今天看来非常慢——仅有 10Mb/s,并且它采用半双工通信(half-duplex),意味着数据在同一时间只能发送或接收,而不能同时进行。这也是介质访问控制 (MAC) 协议 存在的原因,它用于组织数据流。即使在今天,如果一个以太网接口运行在半双工模式,仍然需要 CSMA/CD (Carrier Sense, Multiple Access with Collision Detection, 载波监听多路访问/碰撞检测) 协议 作为 MAC 机制。
随着 100BASE-T 以太网标准的出现,它采用双绞线 (twisted-pair wiring) 实现了全双工 (full-duplex) 通信,并提高了传输速率。此外,以太网交换机的广泛应用使得 CSMA/CD 基本上已经过时。
所有不同的以太网标准都由 IEEE 802.3 工作组 维护。

以太网帧头 (Ethernet Frame Header)

下面是以太网帧的 C 结构体表示:
字段解析
  • dmac (Destination MAC Address) 和 smac (Source MAC Address) 分别表示目标 MAC 地址源 MAC 地址,这两个字段用于标识通信双方的 MAC 地址。
  • ethertype (以太网类型字段) 是一个 2 字节(16-bit)字段,其值决定了负载数据的类型
    • 如果值 ≥ 1536 (0x0600),则该字段表示负载的协议类型(如 IPv4、ARP 等)。
    • 如果值 < 1536,则表示负载的长度(适用于 802.3 以太网)。
  • payload 指向 以太网帧的负载数据,在我们的实现中,它可能包含 ARPIPv4 数据包。
补充说明
  • VLAN & QoS Tags: 在 ethertype 之后,可能会出现 VLAN 或 QoS 标签,这些标签用于描述虚拟局域网 (VLAN)服务质量 (QoS) 信息。然而,在我们的实现中不涉及 VLAN 和 QoS,因此不会在结构体中显示这些字段。
  • 填充 (Padding): 如果负载长度小于最小要求的 48 字节(不包含标签),则会在负载的末尾填充字节,以满足最小帧长度的要求。
  • FCS (Frame Check Sequence) - 帧校验序列: 以太网帧的最后包含 FCS 字段,它使用循环冗余校验 (CRC) 机制检查帧的完整性。由于我们的实现不会处理 FCS,所以我们将忽略这个字段。

在下一步,我们将使用 TAP 设备 捕获以太网帧,并解析 以太网头部,以实现我们用户态的 TCP/IP 协议栈。

以太网帧解析 (Ethernet Frame Parsing)

在 C 语言的结构体声明中,__attribute__((packed)) 是一个 实现细节,用于指示 GNU C 编译器不要自动填充字节 (padding) 来对齐内存布局
由于我们的解析方式是直接对数据缓冲区进行类型转换 (type cast),因此使用 packed 是为了确保结构体和以太网帧的字节布局完全一致。
例如,解析以太网帧时,我们可以这样转换:

更通用的解析方式

虽然 packed 方式非常直观,但它在不同的处理器架构上可能存在对齐问题。一个更便携 (portable)、但稍微繁琐的方法是手动序列化协议数据
这样,编译器可以自由地填充字节以优化数据对齐,从而提高访问速度,特别是在某些 RISC 处理器(如 ARM)上可能会有性能优势。

解析和处理以太网帧

整个解析和处理 以太网帧 的流程相当直接:

解析步骤

  1. 读取以太网帧数据
      • tun_read(buf, BUFLEN) 通过 TAP 设备 读取数据帧到 buf
      • 如果读取失败,打印错误信息。
  1. 解析以太网头部
      • init_eth_hdr(buf)buf 转换为 eth_hdr 结构体指针,以解析帧的头部信息。
  1. 处理以太网帧
      • handle_frame(&netdev, hdr) 解析 ethertype 字段,并根据其值执行相应的处理。

handle_frame 函数

handle_frame 主要根据 ethertype 字段决定下一步动作,例如:
解析逻辑
  • 如果 ethertype 是 ARP (0x0806),调用 handle_arp() 处理 ARP 数据包。
  • 如果 ethertype 是 IPv4 (0x0800),调用 handle_ip() 处理 IPv4 数据包。
  • 如果 ethertype 不是已知类型,打印错误信息。

总结

  1. 使用 packed 结构体直接解析数据,但要注意跨架构的对齐问题。
  1. 解析 ethertype 字段,决定是 ARP 还是 IPv4
  1. 基于协议类型执行相应的处理handle_arp / handle_ip)。
  1. 错误处理:对于未知的 ethertype,打印日志信息。
在下一步,我们将实现 ARP (地址解析协议) 的解析与处理

地址解析协议 (ARP - Address Resolution Protocol)

地址解析协议 (ARP) 用于动态映射 48 位的 以太网 MAC 地址 到一个协议地址 (Protocol Address),例如 IPv4 地址。ARP 的关键在于它不仅支持 IPv4,还支持其他 L3 协议,例如 CHAOS,该协议使用 16-bit 协议地址

ARP 的作用

通常,我们在本地网络 (LAN) 中知道目标设备的 IP 地址,但要建立实际通信,还需要知道其 MAC 地址。这时,ARP 通过广播 (broadcast) 查询网络,请求目标 IP 地址的所有者返回其硬件 (MAC) 地址

ARP 数据包格式

ARP 报文结构体如下:

字段解析

  1. hwtype (2 字节):表示链路层 (L2) 类型,即该 ARP 适用于哪种链路层协议。
      • 对于 以太网,其值为 0x0001
  1. protype (2 字节):表示上层协议 (L3) 类型
      • 对于 IPv4,其值为 0x0800
  1. hwsize (1 字节):表示硬件地址 (MAC) 的长度
      • 对于 以太网 MAC 地址,其值为 6
  1. prosize (1 字节):表示协议地址 (IP) 的长度
      • 对于 IPv4 地址,其值为 4
  1. opcode (2 字节):表示 ARP 操作码
      • 1 = ARP 请求 (ARP Request)
      • 2 = ARP 响应 (ARP Reply)
      • 3 = RARP 请求 (Reverse ARP Request)
      • 4 = RARP 响应 (Reverse ARP Reply)
  1. data (变长):存放协议特定的数据,对于 IPv4,结构如下:

IPv4 相关的 ARP 数据结构

字段解析

  • smac (Sender MAC Address):发送方的 MAC 地址
  • sip (Sender IP Address):发送方的 IP 地址
  • dmac (Target MAC Address):目标设备的 MAC 地址
  • dip (Target IP Address):目标设备的 IP 地址

ARP 工作原理

  1. ARP 请求 (ARP Request)
      • 发送方知道目标设备的 IP 地址,但不知道 MAC 地址
      • 发送方发送 ARP 广播请求,询问「谁拥有这个 IP 地址?」。
      • 数据包示例:
    1. ARP 响应 (ARP Reply)
        • 目标设备收到 ARP 请求后,发现目标 IP 正好是自己
        • 目标设备发送 ARP 单播响应,返回自己的 MAC 地址。
        • 数据包示例:

      下一步

      在下一步,我们将实现 ARP 解析和响应逻辑,从而使我们的用户态 TCP/IP 协议栈能够处理基本的 ARP 请求和响应

      地址解析算法 (Address Resolution Algorithm)

      ARP 原始规范 定义了一个简单的算法来解析 IP 地址到 MAC 地址的映射。以下是其核心逻辑:

      ARP 解析步骤

      1. 检查硬件类型 (ar$hrd)
          • 是否支持该硬件类型?
          • 通常为 以太网 (Ethernet = 0x0001)
      1. 检查协议类型 (ar$pro)
          • 是否支持该协议类型?
          • 一般为 IPv4 (0x0800)
      1. 检查 ARP 缓存 (Translation Table / ARP Cache)
          • 先查询ARP 表,检查是否已经存储了 <协议类型, 发送方 IP 地址> 的映射:
            • 如果已存在:更新发送方的 MAC 地址,并设置 Merge_flag = true
            • 如果不存在:后续会加入 ARP 缓存。
      1. 检查自己是否是目标设备
          • 如果我是目标设备
            • 如果 Merge_flag = false,将 <协议类型, 发送方 IP, 发送方 MAC> 存入 ARP 表
            • 检查 Opcode (操作码)
              • 如果是 ARP 请求 (REQUEST, opcode = 1)
                  1. 交换 发送方和目标方的 MAC/IP 地址
                  1. 更新 Opcode = ARP 响应 (REPLY, opcode = 2)
                  1. 发送 ARP 响应 给请求者。

      ARP 解析 & 处理代码

      handle_arp() - 处理 ARP 请求/响应


      发送 ARP 响应

      send_arp_reply()


      测试 ARP 响应

      当 ARP 协议实现完成后,我们可以用 arping 命令测试:

      检查 Linux ARP 缓存

      成功! 内核网络协议栈正确接收了来自我们的用户态 TCP/IP 协议栈的 ARP 响应,并将其存入 ARP 缓存

      总结 (Conclusion)

      以太网帧 (Ethernet Frame) 处理和 ARP 实现相对简单,可以用较少的代码完成。然而,它的回报是巨大的——你可以使用自己实现的以太网设备填充 Linux 主机的 ARP 缓存,从而在用户态模拟低层网络通信!🚀
      完整项目的源代码 可以在 GitHub 上找到。

      下一步

      在下一篇文章中,我们将继续实现: ✅ ICMP 回显请求 & 回显应答 (ping)IPv4 数据包解析

      Sources

       
    2. tcp/ip
    3. 网络
    4. Build a Container Image from ScratchOn Troubleshooting
      Loading...