Skip to content

教程:Packet01 - 数据包解析

现在你已经完成了教程的基本步骤,准备开始编写数据包处理程序。本课包含 数据包处理的第一个介绍,我们将看到如何解析数据包内容,以及如何确保 内核验证器接受你的程序。

设置说明

我们将使用 xdp-tools 的加载器。此目录中的 Makefile 包含一个规则将其 复制到此目录,因此在运行 make 后可以作为 ./xdp-loader 运行。 testenv.sh 脚本在使用 load 命令运行时会使用它。在下面的示例中, 我们假设你已经安装了 testenv 的别名(通过运行 eval $(./testenv.sh alias)), 因此所有示例都将使用短命令 t 来引用 testenv.sh 脚本。

本课你将学到的内容

本课将教你如何从 XDP 解析数据包数据。这是通过使用指向数据包数据的指针 直接内存访问来完成的,这是使用 XDP 可获得高性能的原因之一。这是因为 内核验证器会检查所有数据访问是否在数据包边界内。我们将看到这是如何工作的, 以及如何处理验证器错误。

你还将学习如何根据数据包数据通过程序返回码决定数据包处置,我们还将 介绍如何构建数据包解析代码以确保可读性和代码重用。

完成作业时需要注意的几点如下。

data 和 data_end 指针

当执行 XDP 程序时,它将接收一个指向 struct xdp_md 对象的指针作为参数, 该对象包含有关数据包的上下文信息。此对象在 bpf.h 中定义如下:

c
struct xdp_md {
	__u32 data;
	__u32 data_end;
	__u32 data_meta;
	/* 以下访问通过 struct xdp_rxq_info */
	__u32 ingress_ifindex; /* rxq->dev->ifindex */
	__u32 rx_queue_index;  /* rxq->queue_index  */
};

此结构体中的最后两项只是数据字段,包含接收数据包的 ifindex 和 RX 队列索引。 程序可以在决策中使用这些信息(连同数据包数据本身)。

前三项实际上是指针,尽管它们是用 __u32 类型定义的。data 字段指向 数据包的开始,data_end 字段指向结束,data_meta 字段指向 XDP 程序 可以用来存储伴随数据包的额外元数据的元数据区域。在本课中,我们只处理 datadata_end 字段。

当程序加载时,验证器会重写指针访问以指向实际的数据包数据。但为了满足 编译器类型检查,我们需要在访问时将字段转换为指针。因此,XDP 程序通常 以这样的赋值开始:

c
	void *data_end = (void *)(long)ctx->data_end;
	void *data = (void *)(long)ctx->data;

数据包边界检查

如上所述,数据包数据是通过直接内存读取访问的,验证器会确保这是安全的。 但是,在运行时对每次指针访问都这样做会导致显著的性能开销。因此, 验证器所做的是检查 XDP 程序是否进行了自己的边界检查;这就是 data_end 指针的目的。

当验证器在加载时执行静态分析时,它会跟踪程序使用的所有内存地址偏移量, 并查找与 data_end 指针的比较,该指针在运行时将被设置为数据包的末尾。 这意味着如果程序执行类似这样的操作:

c
if (data + 10 < data_end)
  /* 对数据的前 10 个字节做些什么 */
else
  /* 跳过数据包访问 */

验证器可以知道 if 语句的 true 分支中的所有指令可以安全地访问数据包的 前 10 个字节,而 else 分支不能。因此,如果程序确实尝试在 else 分支中 访问数据包数据,程序将被拒绝。

头部游标跟踪当前解析位置

当遍历数据包并解析后续头部时,通常需要跟踪当前解析位置。当使用辅助函数 解析数据包头部时,这些辅助函数通常需要修改当前解析器位置。为了避免处理 指向指针的指针运算,我们将其封装在一个 /游标/ 对象中,可以传递给辅助函数。 游标简单地定义为单条目结构体:

c
/* 用于跟踪当前解析位置的头部游标 */
struct hdr_cursor {
	void *pos;
};

程序返回码

XDP 程序处理数据包后发生什么的最终决定通过程序返回码传达给内核。 这些也在 bpf.h 中定义:

c
enum xdp_action {
	XDP_ABORTED = 0,
	XDP_DROP,
	XDP_PASS,
	XDP_TX,
	XDP_REDIRECT,
};

ABORTEDDROP 都会丢弃数据包,但 ABORTED 还会触发跟踪点事件 (xdp:xdp_exception;当跟踪点未到达时开销为零)。PASS 将允许数据包 继续到内核网络堆栈进行处理,TX 将从接收数据包的同一接口重新传输数据包, REDIRECT 将从另一个接口传输数据包(目标接口需要在返回 REDIRECT 之前通过 BPF 辅助调用设置)。

请注意,XDP 程序可以在做出这些决定之前对数据包执行任意修改。对于 TXREDIRECT 动作,通常需要进行一些数据包数据转换(例如重写以太网 头部地址),而对于其他动作则是可选的。我们将在下一课中看到如何使用它。

数据包头部定义和字节序

由于 XDP 程序只接收指向原始数据缓冲区的指针,它需要自己解析数据包头部。 为了帮助实现这一点,内核头文件定义了包含数据包头部字段的结构体。解析 数据包通常涉及大量将数据缓冲区转换为正确结构体类型的操作,正如我们将在 下面的作业中看到的。我们将在本课中使用的头部定义如下:

| 结构体 | 头文件 | |-------------------+----------------------| | struct ethhdr | <linux/if_ether.h> | | struct ipv6hdr | <linux/ipv6.h> | | struct iphdr | <linux/ip.h> | | struct icmp6hdr | <linux/icmpv6.h> | | struct icmphdr | <linux/icmp.h> |

由于数据包数据直接来自网络,数据字段将采用网络字节序。使用 bpf_ntohs()bpf_htons() 函数分别从网络字节序转换为主机字节序和从主机字节序 转换为网络字节序。参见 bpf_endian.h 顶部的注释了解为什么需要 bpf_-前缀的版本。

函数内联和循环展开

因为 eBPF 程序对函数调用的支持有限,辅助函数需要内联到主函数中。 函数定义上的 __always_inline 标记确保这一点,覆盖编译器可能做出的 任何内联决定。

v5.3 之前,因为 eBPF 不支持循环,我们需要展开程序中的任何循环。 这可以通过在循环前一行添加 #pragma unroll 语句来完成,并且只适用于 编译时已知迭代次数的循环(例如具有静态计数器的 for 循环)。从 v5.3 开始, 验证器可以确定循环是否会停止。此后实现了许多额外的循环辅助函数。 详细信息可以从 eBPF 文档 访问。

作业

本课的最终目标是构建一个 XDP 程序,它将检查数据包头部,并丢弃接口上 看到的每隔一个 ICMP 回显请求(即 ping)数据包,同时允许其他所有内容 传递到内核。下面的作业将逐步实现这一目标。

此作业的起点是 xdp_prog_kern.c 中的数据包解析程序,它将使用辅助函数 解析数据包以太网头部。每个作业将通过添加新功能来扩展此程序。程序包含 basic04 的统计辅助函数,你可以在开发时使用它来监控程序采取的动作。 使用 t stats 运行统计监控应用程序(在加载 BPF 程序后)。

作业 1:修复边界检查错误

xdp_prog_kern.c 中的解析器函数将解析以太网头部,进行边界检查,并返回 下一个头部类型和位置。但是,边界检查逻辑中有一个错误,因此程序将被 验证器拒绝(通过编译后运行 t load 测试)。

你的第一个作业是修复这个错误(提示:它在 parse_ethhdr()if 语句中),并确保程序可以成功加载到接口上。

作业 2:解析 IP 头部

现在我们的以太网解析程序可以运行了,我们将添加 IP 头部的解析。为此, 实现 parse_ip6hdr() 函数,它在 parse_ethhdr() 函数下面有一个 注释掉的原型。该函数与 parse_ethhdr() 非常相似,但你需要在 ipv6.h 中查找 IPv6 头部结构定义。

当你添加边界检查时,请注意 parse_ethhdr() 中使用的风格(计算头部大小 并进行逐字节比较)不是唯一可能的方式。你也可以使用指针算术风格的比较, 它利用了递增指针会将它指向的内存移动结构体大小的事实。使用这种方式 会得到如下边界检查:

c
	struct ipv6hdr *ip6h = nh->pos;

	/* 指针算术边界检查;指针 +1 指向被指向内容的末尾之后。
	 * 我们将在本教程的其余部分使用这种风格。
	 */
	if (ip6h + 1 > data_end)
		return -1;

不要忘记也递增 nh->pos 指针,使其指向 IP 头部之后的数据,这是你在 下一个作业中解析 ICMPv6 头部时要查看的内容。

要检查你的程序是否工作,测试它是否可以编译和加载。你也可以更改返回码 以丢弃 IP 数据包,并使用 t tcpdumpt ping 检查这是否有效。

作业 3:解析 ICMPv6 头部并做出响应

现在我们可以成功地将数据包解析到 IP 头部,我们需要添加对我们感兴趣的 有效负载的解析。即 ICMPv6 头部。为此,实现 parse_icmp6hdr() 函数。

解析 ICMPv6 头部后,终于可以根据数据包有效负载做出决定了。在这种情况下, 我们对序列号感兴趣。数据结构嵌套很深,但头文件也定义了一个方便的别名, 因此序列号可以作为 icmp6h->icmp6_sequence 访问(但不要忘记字节序转换)。

有了这个,我们终于可以实现上面提到的丢弃逻辑,只需在序列号为偶数时 返回 XDP_DROP,否则返回 XDP_PASS。通过加载程序并运行 ping 验证 这是否有效;你应该在每隔一个序列号上看到响应:

bash
$ make
$ t load
$ t ping
Running ping from inside test environment:

PING fc00:dead:cafe:1::1(fc00:dead:cafe:1::1) 56 data bytes
64 bytes from fc00:dead:cafe:1::1: icmp_seq=1 ttl=64 time=0.059 ms
64 bytes from fc00:dead:cafe:1::1: icmp_seq=3 ttl=64 time=0.135 ms
^C
--- fc00:dead:cafe:1::1 ping statistics ---
4 packets transmitted, 2 received, 50% packet loss, time 44ms
rtt min/avg/max/mdev = 0.059/0.097/0.135/0.038 ms

作业 4:添加 VLAN 支持

现在我们有了基本功能,我们可以改进它以正确处理以太网数据包上的 VLAN 标签, 作为如何根据有效负载解析多个可变头部的示例。在 Linux 中,VLAN 通过 创建 vlan 类型的虚拟接口来配置;但由于 XDP 程序直接在真实接口上运行, 它会看到所有带有 VLAN 标签的数据包,在内核将它们分配给虚拟 VLAN 接口之前。 我们可以使用它来创建一个可以与任何 VLAN 封装一起工作的解析器 (但请参阅下面关于硬件卸载的说明)。

目前我们只想解析 VLAN 标签并找到封装的 IP 头部(在下一课中我们将继续 添加和删除 VLAN 标签)。这意味着我们可以扩展我们的 parse_ethhdr() 函数来解析 VLAN 标签。如果找到任何标签,我们只需从最内层标签而不是 直接从以太网头部获取下一个头部类型,并将 nexthdr 指针移动到 VLAN 标签 的末尾之后。

不幸的是,VLAN 标签头部没有被任何 IP 头文件导出。但是,它很简单, 所以我们可以自己定义它,像这样(从内部内核头文件复制):

c
struct vlan_hdr {
	__be16	h_vlan_TCI;
	__be16	h_vlan_encapsulated_proto;
};

VLAN 标签的以太类型是 ETH_P_8021QETH_P_8021AD,两者都在 if_ether 中定义。所以我们可以定义一个简单的辅助函数来检查是否 存在 VLAN 标签:

c
static __always_inline int proto_is_vlan(__u16 h_proto)
{
        return !!(h_proto == bpf_htons(ETH_P_8021Q) ||
                  h_proto == bpf_htons(ETH_P_8021AD));
}

可以这样使用:

c
if (proto_is_vlan(eth->h_proto)) {
  /* 处理 VLAN 标签 */
}

另一件要记住的事情是,单个数据包可以有多个嵌套的 VLAN 标签。我们可以 通过使用展开的循环来解析后续的 VLAN 头部来处理这种情况,只要它们的 封装协议继续是 VLAN 类型之一。

使用以上内容,修改你的解析程序以同时处理 VLAN 标签。你可以通过设置 带有 VLAN 接口的测试环境来测试;只需将 --vlan 标签传递给 t setup; 或运行 t reset --vlan 以使用 VLAN 接口重新初始化现有环境。 一旦你初始化了包含 VLAN 的环境,你可以运行 t ping --vlan 在 VLAN 接口上运行 ping,并验证每隔一个数据包仍然被丢弃。

关于 VLAN 卸载的说明

由于 XDP 需要将 VLAN 头部作为数据包头部的一部分看到,重要的是关闭 VLAN 硬件卸载(大多数硬件 NIC 支持),因为它会从数据包头部删除 VLAN 标签,而是通过数据包硬件描述符带外传递给内核。testenv 脚本在设置 环境时已经禁用了 VLAN 卸载,但作为参考,以下是如何使用 ethtool 为 其他设备关闭它:

bash
 # 检查当前设置:
 ethtool -k DEV | grep vlan-offload
 # 为 RX 和 TX 都禁用
 ethtool --offload DEV rxvlan off txvlan off
 # 等同于:
 # ethtool -K DEV rxvlan off txvlan off

作业 5:添加 IPv4 支持

虽然我们显然都希望 IPv6 无处不在,但有时仍然需要处理遗留的 IPv4 数据包。 为此,本课的最后一个作业是扩展我们的程序,对 v4 ICMP 数据包执行与 ICMPv6 数据包相同的功能。这意味着为 IPv4 头部添加两个新的解析函数, 并根据以太网头部的有效负载类型处理每个。

这应该是程序的相当直接的扩展。唯一需要注意的复杂性是 IPv4 头部可以 变化大小,所以你需要分两步进行边界检查:首先验证 iphdr 结构体本身 适合数据包有效负载,然后将实际头部大小计算为 hdrsize iph->ihl * 4=, 最后验证这个完整大小适合数据包(并相应调整 nexthdr 指针)。

要测试 IPv4 支持,你可以运行 t setup --legacy-ip,它将在虚拟接口上 配置 IPv4 地址,然后运行 t ping --legacy-ip 来运行 ping。请注意, 如果你想同时拥有两者,你需要将 --legacy-ip--vlan 都传递给 setup(或 reset)命令;但是,VLAN 接口上不会配置 IPv4 地址, 所以你不能同时使用两者运行 t ping

一旦你添加了 IPv4 支持并验证了在加载程序时每隔一个 v4 ICMP 数据包 被丢弃,你就完成了本课,准备继续学习 packet02 以了解数据包修改!