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
指向 以太网帧的负载数据,在我们的实现中,它可能包含 ARP 或 IPv4 数据包。
补充说明:
- 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)上可能会有性能优势。
解析和处理以太网帧
整个解析和处理 以太网帧 的流程相当直接:
解析步骤
- 读取以太网帧数据
tun_read(buf, BUFLEN)
通过 TAP 设备 读取数据帧到buf
。- 如果读取失败,打印错误信息。
- 解析以太网头部
init_eth_hdr(buf)
将buf
转换为eth_hdr
结构体指针,以解析帧的头部信息。
- 处理以太网帧
handle_frame(&netdev, hdr)
解析ethertype
字段,并根据其值执行相应的处理。
handle_frame
函数
handle_frame
主要根据 ethertype 字段决定下一步动作,例如:解析逻辑:
- 如果 ethertype 是 ARP (
0x0806
),调用handle_arp()
处理 ARP 数据包。
- 如果 ethertype 是 IPv4 (
0x0800
),调用handle_ip()
处理 IPv4 数据包。
- 如果 ethertype 不是已知类型,打印错误信息。
总结
- 使用
packed
结构体直接解析数据,但要注意跨架构的对齐问题。
- 解析
ethertype
字段,决定是 ARP 还是 IPv4。
- 基于协议类型执行相应的处理(
handle_arp
/handle_ip
)。
- 错误处理:对于未知的
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 报文结构体如下:
字段解析
- hwtype (2 字节):表示链路层 (L2) 类型,即该 ARP 适用于哪种链路层协议。
- 对于 以太网,其值为
0x0001
。
- protype (2 字节):表示上层协议 (L3) 类型。
- 对于 IPv4,其值为
0x0800
。
- hwsize (1 字节):表示硬件地址 (MAC) 的长度。
- 对于 以太网 MAC 地址,其值为
6
。
- prosize (1 字节):表示协议地址 (IP) 的长度。
- 对于 IPv4 地址,其值为
4
。
- opcode (2 字节):表示 ARP 操作码:
1
= ARP 请求 (ARP Request)2
= ARP 响应 (ARP Reply)3
= RARP 请求 (Reverse ARP Request)4
= RARP 响应 (Reverse ARP Reply)
- 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 工作原理
- ARP 请求 (ARP Request):
- 发送方知道目标设备的 IP 地址,但不知道 MAC 地址。
- 发送方发送 ARP 广播请求,询问「谁拥有这个 IP 地址?」。
- 数据包示例:
- ARP 响应 (ARP Reply):
- 目标设备收到 ARP 请求后,发现目标 IP 正好是自己。
- 目标设备发送 ARP 单播响应,返回自己的 MAC 地址。
- 数据包示例:
下一步
在下一步,我们将实现 ARP 解析和响应逻辑,从而使我们的用户态 TCP/IP 协议栈能够处理基本的 ARP 请求和响应!
地址解析算法 (Address Resolution Algorithm)
ARP 原始规范 定义了一个简单的算法来解析 IP 地址到 MAC 地址的映射。以下是其核心逻辑:
ARP 解析步骤
- 检查硬件类型 (ar$hrd):
- 是否支持该硬件类型?
- 通常为 以太网 (Ethernet = 0x0001)。
- 检查协议类型 (ar$pro):
- 是否支持该协议类型?
- 一般为 IPv4 (0x0800)。
- 检查 ARP 缓存 (Translation Table / ARP Cache)
- 先查询ARP 表,检查是否已经存储了 <协议类型, 发送方 IP 地址> 的映射:
- 如果已存在:更新发送方的 MAC 地址,并设置
Merge_flag = true
。 - 如果不存在:后续会加入 ARP 缓存。
- 检查自己是否是目标设备
- 如果我是目标设备:
- 如果
Merge_flag = false
,将<协议类型, 发送方 IP, 发送方 MAC>
存入 ARP 表。 - 检查 Opcode (操作码):
- 如果是 ARP 请求 (REQUEST, opcode = 1):
- 交换 发送方和目标方的 MAC/IP 地址。
- 更新 Opcode = ARP 响应 (REPLY, opcode = 2)。
- 发送 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
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-Attributes.html#Common-Type-Attributes
5. https://github.com/chobits/tapip