挂载点
以下是包含各个 eBPF 挂载点功能的表格:
挂载点 | 挂载点类型(自定义/内核) | 挂载点 | 功能 |
kprobe/skb_by_stackid | 自定义 | 捕获与特定 stack ID 相关的 sk_buff 操作 | |
kprobe/skb_lifetime_termination | 自定义 | kprobe/kfree_skbmem | 捕获 sk_buff 生命周期结束时的事件 |
fexit/skb_clone | 内核 | 在 skb_clone 函数退出时触发,监控 sk_buff 的克隆操作 | |
fexit/skb_copy | 内核 | 在 skb_copy 函数退出时触发,监控 sk_buff 的复制操作 | |
fentry/tc | 内核 | 在流量控制(Traffic Control)入口时触发 | |
fentry/xdp | 内核 | 在 XDP(eXpress Data Path)入口时触发 | |
kprobe/veth_convert_skb_to_xdp_buff | 内核 | kprobe/veth_convert_skb_to_xdp_buff | 捕获 veth_convert_skb_to_xdp_buff 函数的调用 |
kretprobe/veth_convert_skb_to_xdp_buff | 内核 | kretprobe/veth_convert_skb_to_xdp_buff | 捕获 veth_convert_skb_to_xdp_buff 函数的返回事件 |
详细功能描述
• kprobe/skb_by_stackid:用于跟踪和诊断网络数据包的路径,通过捕获
sk_buff
的创建、复制和销毁等操作,可以分析网络数据包在内核中的流动情况,有助于网络性能调优和故障排查。• kprobe/skb_lifetime_termination:用于监控和分析
sk_buff
的生命周期,特别是在网络数据包被处理完成或丢弃时,可以帮助识别网络数据包的终点,分析数据包丢失或处理延迟的原因。• fexit/skb_clone:用于监控和分析
sk_buff
的克隆操作,这在多播和数据包复制场景中非常重要,可以帮助了解数据包复制操作对系统性能的影响。• fexit/skb_copy:用于监控
sk_buff
的复制操作,特别是在需要对数据包进行修改或缓存时,这个挂载点有助于分析数据包处理的性能开销。• fentry/tc:用于流量控制和网络数据包调度的监控和管理,通过在
tc
入口处挂载,可以分析数据包进入流量控制系统的情况,有助于网络带宽管理和流量优化。• fentry/xdp:用于监控和管理 XDP 程序,这些程序在网络数据包接收到网络设备后立即执行。通过在 XDP 入口挂载,可以分析和优化高性能网络数据包处理应用。
• kprobe/veth_convert_skb_to_xdp_buff:用于监控虚拟以太网设备(veth)中的
sk_buff
转换操作,这在容器网络和虚拟化环境中非常重要,可以帮助优化网络性能和数据包处理效率。• kretprobe/veth_convert_skb_to_xdp_buff:用于分析
sk_buff
到 XDP 缓冲区转换的结果和性能,进一步优化虚拟网络设备的处理流程。
数据流
Map 存储
map 名称 | map 类型 | 数据结构解释 | 功能解释 |
skb_heads | BPF_MAP_TYPE_HASH | key: __u64 , value: bool | 用于标记是否已处理过特定的 sk_buff ,避免重复处理 |
veth_skbs | BPF_MAP_TYPE_HASH | key: __u64 , value: __u64* | 存储从 veth 设备转换为 xdp_buff 的 skb 地址,用于跟踪处理过程 |
skb_stackid | BPF_MAP_TYPE_HASH | key: struct sk_buff* , value: __u64 | 存储 skb 和其对应的 stack id,用于通过 stack id 跟踪 skb |
stackid_skb | BPF_MAP_TYPE_HASH | key: __u64 , value: struct skb* | 存储 stack id 和其对应的 skb,用于通过 stack id 查找 skb |
print_stack_map | BPF_MAP_TYPE_STACK_TRACE | key: u32 , value: MAX_STACK_DEPTH * sizeof(u64) | 存储函数调用栈的信息,用于调试和跟踪 |
print_skb_map | BPF_MAP_TYPE_ARRAY | key: u32 , value: struct print_skb_value | 存储 skb 的打印信息,用于输出 skb 的详细信息 |
print_shinfo_map | BPF_MAP_TYPE_ARRAY | key: u32 , value: struct print_shinfo_value | 存储 skb 共享信息的打印信息,用于输出 skb 共享信息的详细内容 |
events | BPF_MAP_TYPE_QUEUE | value: struct event_t | 存储事件数据,用于在用户空间和内核空间之间传递信息 |
skb_heads
函数名称 | 操作类型 |
handle_everything | 查询 (bpf_map_lookup_elem ), 更新 (bpf_map_update_elem ) |
kprobe_skb_lifetime_termination | 删除 (bpf_map_delete_elem ) |
track_skb_clone | 查询 (bpf_map_lookup_elem ), 更新 (bpf_map_update_elem ) |
fentry_xdp | 查询 (bpf_map_lookup_elem ), 更新 (bpf_map_update_elem ) |
kprobe_veth_convert_skb_to_xdp_buff | 查询 (bpf_map_lookup_elem ) |
kretprobe_veth_convert_skb_to_xdp_buff | 更新 (bpf_map_update_elem ), 删除 (bpf_map_delete_elem ) |
veth_skbs
函数名称 | 操作类型 |
kprobe_veth_convert_skb_to_xdp_buff | 更新 (bpf_map_update_elem ) |
kretprobe_veth_convert_skb_to_xdp_buff | 更新 (bpf_map_update_elem ), 删除 (bpf_map_delete_elem ) |
skb_stackid
函数名称 | 操作类型 |
handle_everything | 更新 (bpf_map_update_elem ), 查询 (bpf_map_lookup_elem ), 删除 (bpf_map_delete_elem ) |
kprobe_skb_lifetime_termination | 删除 (bpf_map_delete_elem ) |
stackid_skb
函数名称 | 操作类型 |
handle_everything | 更新 (bpf_map_update_elem ), 查询 (bpf_map_lookup_elem ), 删除 (bpf_map_delete_elem ) |
kprobe_skb_lifetime_termination | 删除 (bpf_map_delete_elem ) |
events
函数名称 | 操作类型 |
kprobe_skb | 插入 (bpf_map_push_elem ) |
fentry_tc | 插入 (bpf_map_push_elem ) |
fentry_xdp | 插入 (bpf_map_push_elem ) |
输入
kprobe_skb
前面在介绍如何追踪所有 skb 网络包的时候,给所有函数中入参带有 skb_buff 挂载采集 kprobe_skb
函数,我们一起来看看该函数的主要逻辑。kprobe_skb
函数的主要作用是处理传入的 sk_buff
结构体,并将处理后的事件数据推送到 events
map 中。以下是该函数的详细步骤:
1. 初始化事件结构体:创建一个
event_t
类型的结构体event
,用于存储事件相关的数据。2. 处理数据:调用
handle_everything
函数,传入skb
(网络包)、ctx
(上下文)、event
(事件结构体)和可选的_stackid
(栈ID)。这个函数负责填充event
结构体的各个字段,包括网络包的元数据、元组信息、栈信息等。如果配置了跟踪skb
或根据栈ID跟踪skb
,还会更新相应的 map。3. 设置事件类型和地址:根据是否使用
kprobe.multi
和是否能获取函数的 IP 地址,设置事件的类型(kprobe
、fentry
、fexit
等)和地址(函数地址或指令指针地址)。4. 推送事件到
events
map:使用bpf_map_push_elem
函数将填充好的event
结构体推送到events
map 中。这样,用户空间程序就可以从这个 map 中检索和处理这些事件数据了。
以下是 kprobe_skb
函数的代码:
static __always_inline int kprobe_skb(struct sk_buff *skb, struct pt_regs *ctx, bool has_get_func_ip, u64 *_stackid) {
struct event_t event = {};
// 处理 skb,填充 event 结构体
if (!handle_everything(skb, ctx, &event, _stackid))
return BPF_OK;
// 设置事件的 skb 头部地址、函数地址、第二个参数和调用者地址
event.skb_head = (u64) BPF_CORE_READ(skb, head);
event.addr = has_get_func_ip ? bpf_get_func_ip(ctx) : PT_REGS_IP(ctx);
event.param_second = PT_REGS_PARM2(ctx);
if (CFG.output_caller)
bpf_probe_read_kernel(&event.caller_addr, sizeof(event.caller_addr), (void *)PT_REGS_SP(ctx));
// 将事件推送到 events map
bpf_map_push_elem(&events, &event, BPF_EXIST);
return BPF_OK;
}
这个函数通过调用 handle_everything
函数来处理 skb
,并将处理后的事件数据推送到 events
map 中。
handle_everything
handle_everything
函数的主要作用是处理传入的 sk_buff
结构体,并根据配置和过滤条件决定是否跟踪该 skb
,以及是否将事件数据推送到 events
map 中。以下是该函数的详细步骤:
1. 初始化变量:
•
tracked_by
:用于记录跟踪的方式。•
skb_head
:读取skb
的头部地址。•
stackid
:如果配置了track_skb_by_stackid
,则获取栈ID。
2. 检查配置:
• 如果配置了
track_skb_by_stackid
,则获取栈ID。• 如果配置了
is_set
,则继续处理。
3. 检查是否跟踪 skb:
• 如果配置了
track_skb
并且skb_heads
map 中存在skb_head
,则设置tracked_by
为TRACKED_BY_SKB
或TRACKED_BY_STACKID
,跳过下面的逻辑跳转到输出部分。• 如果配置了
track_skb_by_stackid
并且stackid_skb
map 中存在stackid
,则设置tracked_by
为TRACKED_BY_STACKID
,跳过下面的逻辑跳转到输出部分。• 如果通过过滤条件,则设置
tracked_by
为TRACKED_BY_FILTER
,跳过下面的逻辑跳转到输出部分。• 如果以上三种条件均不匹配,则返回 false 退出当前函数。
4. 设置输出:
• 如果配置了
track_skb
并且tracked_by
为TRACKED_BY_FILTER
,则更新skb_heads
map。• 条件:cfg->track_skb 为真且 tracked_by 等于 TRACKED_BY_FILTER。
• 操作:将 skb_head 作为键,TRUE 作为值,更新到 skb_heads map 中。
• 目的:如果 skb 是通过过滤条件跟踪的,则将其头部地址记录在 skb_heads map 中,以便后续跟踪
• 如果配置了
track_skb_by_stackid
并且tracked_by
不为TRACKED_BY_STACKID
,则更新stackid_skb
和skb_stackid
map。• 条件:cfg->track_skb_by_stackid 为真且 tracked_by 不等于 TRACKED_BY_STACKID。
• 操作:
• 查找 skb_stackid map 中是否存在当前 skb 的旧栈ID。
• 如果存在且旧栈ID不等于当前栈ID,则从 stackid_skb map 中删除旧栈ID对应的条目。
• 将新的 stackid 和 skb 更新到 stackid_skb 和 skb_stackid map 中。
• 目的:确保 skb 的栈ID与 skb 的映射关系是最新的,并且删除旧的无效映射。
• 设置事件的元数据、元组信息、
skb
信息、共享信息和栈信息。
5. 设置事件的其他字段:
• 设置事件的进程ID、时间戳和CPU ID。
6. 返回:
• 返回
true
表示处理成功,返回false
表示处理失败。
filter
filter_pcap
filter_pcap
函数结合了多个子函数来实现对 sk_buff
结构体的过滤策略。以下是 filter_pcap
及其相关子函数的总结:
1. MAC 层长度检查:
•
filter_pcap
首先检查skb
的mac_len
字段。• 如果
mac_len
为 0,表示数据包没有 MAC 层头部,调用filter_pcap_l3
进行 L3 层过滤。• 否则,调用
filter_pcap_l2
进行 L2 层过滤。
2. L3 层过滤:
•
filter_pcap_l3
读取skb
的头部地址。• 计算网络层头部的起始地址和数据结束地址。
• 调用
filter_pcap_ebpf_l3
函数进行实际的 L3 层过滤。•
filter_pcap_ebpf_l3
检查数据包头部和数据结束地址是否一致。
3. L2 层过滤:
•
filter_pcap_l2
读取skb
的头部地址。• 计算 MAC 层头部的起始地址和数据结束地址。
• 调用
filter_pcap_ebpf_l2
函数进行实际的 L2 层过滤。•
filter_pcap_ebpf_l2
检查数据包头部和数据结束地址是否一致。
4. 确保数据包头部和数据结束地址一致的检查是通过 filter_pcap_ebpf_l3
和 filter_pcap_ebpf_l2
函数实现的。这两个函数的实现如下:
static __noinline bool
filter_pcap_ebpf_l3(void *_skb, void *__skb, void *___skb, void *data, void* data_end) {
return data != data_end && _skb == __skb && __skb == ___skb;
}
static __noinline bool
filter_pcap_ebpf_l2(void *_skb, void *__skb, void *___skb, void *data, void* data_end) {
return data != data_end && _skb == __skb && __skb == ___skb;
}
这两个函数通过以下方式检查数据包头部和数据结束地址的一致性:传递三次 skb
是为了确保在 eBPF 程序中进行指针一致性检查。通过传递三个相同的指针并在函数内部进行比较,可以确保这些指针在传递过程中没有被修改。这种方法可以增加代码的健壮性和安全性。
1. 检查数据包头部和数据结束地址是否一致:
•
data != data_end
:确保数据包头部地址data
和数据结束地址data_end
不相同。
2. 检查 skb
指针的一致性:
•
_skb == __skb && __skb == ___skb
:确保传入的skb
指针_skb
、__skb
和___skb
是相同的。
filter_meta
filter_meta
函数用于解析并过滤 sk_buff
结构体中的元数据。具体来说,它根据配置 (cfg
) 中的网络命名空间 (netns
)、标记 (mark
) 和接口索引 (ifindex
) 来过滤数据包。如果数据包的这些元数据与配置不匹配,则函数返回 false
,表示过滤掉该数据包;否则返回 true
。
static __always_inline bool
filter_meta(struct sk_buff *skb) {
if (cfg->netns && get_netns(skb) != cfg->netns) {
return false;
}
if (cfg->mark && BPF_CORE_READ(skb, mark) != cfg->mark) {
return false;
}
if (cfg->ifindex != 0 && BPF_CORE_READ(skb, dev, ifindex) != cfg->ifindex) {
return false;
}
return true;
}
输出
set_output
主要功能是根据配置 (cfg
) 设置 event
结构体的各个字段。它会调用一系列以 set_
开头的子函数来填充 event
结构体中的元数据、元组、skb
和 shinfo
信息,以及堆栈信息。
• 根据配置 (
cfg
) 调用不同的set_
开头的子函数来填充event
结构体的字段。
1.
set_meta
• 功能:设置
skb_meta
结构体中的元数据字段。• 主要操作:读取
sk_buff
结构体中的网络命名空间、标记、长度、协议、接口索引和 MTU 等字段。
2. set_tuple
• 功能:设置
tuple
结构体中的源地址、目的地址、源端口、目的端口、L3 协议和 L4 协议字段。• 主要操作:根据
sk_buff
结构体中的网络头部和 MAC 头部信息,读取 IP 和传输层协议的相关字段。
3. set_skb_btf
• 功能:设置
event
结构体中的print_skb_id
字段,并将sk_buff
结构体的信息写入print_skb_map
映射中。• 主要操作:使用
bpf_snprintf_btf
函数将sk_buff
结构体的信息格式化为字符串并存储在print_skb_map
映射中。
4. set_shinfo_btf
• 功能:设置
event
结构体中的print_shinfo_id
字段,并将skb_shared_info
结构体的信息写入print_shinfo_map
映射中。• 主要操作:使用
bpf_snprintf_btf
函数将skb_shared_info
结构体的信息格式化为字符串并存储在print_shinfo_map
映射中。
5. set_xdp_meta
• 功能:设置
skb_meta
结构体中的元数据字段(适用于xdp_buff
)。• 主要操作:读取
xdp_buff
结构体中的网络命名空间、接口索引和 MTU 等字段。
6. set_xdp_tuple
• 功能:设置
tuple
结构体中的源地址、目的地址、源端口、目的端口、L3 协议和 L4 协议字段(适用于xdp_buff
)。• 主要操作:根据
xdp_buff
结构体中的数据头部信息,读取 IP 和传输层协议的相关字段。
7. set_xdp_output
• 功能:根据配置 (
cfg
) 调用不同的set_xdp_
开头的子函数来填充event
结构体的字段(适用于xdp_buff
)。• 主要操作:调用
set_xdp_meta
、set_xdp_tuple
和堆栈信息设置函数。
Meta
SKB CPU PROCESS NETNS MARK/x IFACE PROTO MTU LEN FUNC
0xffff888d92d8f400 0 ~r/bin/ping:3432 4026531840 0 0 0x0000 1500 84 __ip_local_out
0xffff888d92d8f400 0 ~r/bin/ping:3432 4026531840 0 0 0x0800 1500 84 ip_output
0xffff888d92d8f400 0 ~r/bin/ping:3432 4026531840 0 enp0s5:2 0x0800 1500 84 nf_hook_slow
set_meta
函数主要设置 skb_meta
结构体中的元数据字段。以下是 skb_meta
结构体中各个字段的含义:
struct skb_meta {
u32 netns; // 网络命名空间标识符
u32 mark; // 数据包的标记
u32 ifindex; // 接收数据包的网络接口索引
u32 len; // 数据包的长度
u32 mtu; // 最大传输单元
u16 protocol; // 数据包的协议类型
u16 pad; // 填充字段,用于对齐
} __attribute__((packed));
字段解释:
•
netns
: 网络命名空间标识符,用于标识数据包所属的网络命名空间。•
mark
: 数据包的标记,通常用于流量控制和分类。•
ifindex
: 接收数据包的网络接口索引,标识数据包是通过哪个网络接口接收的。•
len
: 数据包的长度,以字节为单位。•
mtu
: 最大传输单元,表示网络接口能够传输的最大数据包大小。•
protocol
: 数据包的协议类型,例如 IPv4、IPv6 等。•
pad
: 填充字段,用于对齐结构体。
netns
netns
是指网络命名空间(network namespace)的标识符。它是一个唯一的 ID,用于标识数据包所属的网络命名空间。网络命名空间允许多个网络堆栈在同一主机上共存,每个网络命名空间都有自己的网络设备、IP 地址、路由表等。
在代码中,netns
通常通过读取网络设备或套接字的 ns.inum
字段来获取。例如:
u32 netns = BPF_CORE_READ(skb, dev, nd_net.net, ns.inum);
如果 skb->dev
并没有完成初始化,则尝试从 skb->sk->__sk_common.skc_net.net->ns.inum
中获取 netns
,具体实现如下:
if (netns == 0) {
struct sock *sk = BPF_CORE_READ(skb, sk);
if (sk != NULL) {
netns = BPF_CORE_READ(sk, __sk_common.skc_net.net, ns.inum);
}
}
要在操作系统中查找对应的网络命名空间(network namespace)ID,可以使用以下命令:
# lsns -t net
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531888 net 410 1 root unassigned /run/docker/netns/default /usr/lib/systemd/systemd --switched-root --system --deserialize 18
4026533120 net 2 6925 root 8 /run/docker/netns/4cdb6c37b059 /pause
4026533207 net 17 2121 10000 1 /run/docker/netns/6c0353b4f2cb nginx: master process nginx -g daemon off;
4026533249 net 1 2122 unbound 0 /run/docker/netns/542b5bdf4251 redis-server *:6379
4026533407 net 1 2139 10000 2 /run/docker/netns/fd1784876cc1 /harbor/harbor_core
4026533582 net 6 7019 root 9 /run/docker/netns/f38fd9c6a931 /pause
mark
sk_buff
结构体中的 mark
字段用于存储数据包的标记值。这个标记值可以由内核或用户空间程序设置,并且可以用于多种用途,例如流量控制、路由决策和防火墙规则等。
主要用途
1. 流量控制:
mark
可以用于区分不同类型的流量,从而应用不同的流量控制策略。2. 路由决策:在路由过程中,
mark
可以用于选择不同的路由表或策略路由。3. 防火墙规则:防火墙可以根据
mark
的值来应用不同的规则,从而实现更细粒度的流量过滤。
设置 mark
mark
可以通过多种方式设置,例如:
• iptables:使用
iptables
的MARK
目标来设置数据包的mark
值。• tc:使用
tc
(Traffic Control)命令来设置数据包的mark
值。
# 使用 iptables 设置 mark
iptables -t mangle -A PREROUTING -s 192.168.1.1 -j MARK --set-mark 1
# 使用 tc 设置 mark
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 match ip src 192.168.1.1 flowid 1:1 action skbedit mark 1
读取 mark
在内核代码中,可以使用 skb->mark
来读取数据包的 mark
值。例如,在 eBPF 程序中:
u32 mark = BPF_CORE_READ(skb, mark);
在云原生场景下,skb 中 mark 字段还是被大部分 CNI 组件用来做各种黑魔法,可以查看《Packet mark in a Cloud Native world[1]》这篇文章。
MTU
pwru 在获取数据包 MTU 信息时,先直接从 skb->mtu
中获取,如果 skb
中包含一条 dst_entry
(目标路由条目),则 skb->_skb_refdst->dst_entry->_metrics + RTAX_MTU - 1
计算出准确的 mtu
信息,如果还是获取不到 mtu
,作为兜底策略从 dst->dev->mtu
获取mtu
。
meta->mtu = BPF_CORE_READ(skb, dev, mtu);
struct dst_entry *dst = __SKB_DST_PTR(BPF_CORE_READ(skb, _skb_refdst));
if (dst) {
u32 *metrics = __DST_METRICS_PTR(BPF_CORE_READ(dst, _metrics));
bpf_probe_read_kernel(&meta->mtu, sizeof(meta->mtu), metrics + RTAX_MTU - 1);
if (!meta->mtu)
meta->mtu = BPF_CORE_READ(dst, dev, mtu);
}
metrics
是一个指向 dst_entry
结构体中 _metrics
字段的指针。dst_entry
结构体中的 _metrics
字段通常是一个包含各种路由度量(如 MTU、RTT 等)的数组。通过 __DST_METRICS_PTR
宏,metrics
指针被转换为一个 u32
类型的指针,以便访问这些度量值。
metrics + RTAX_MTU - 1
这种算法的原理是通过指针运算来访问 metrics
数组中的特定元素。具体来说,metrics
是一个指向 u32
类型的指针,而 RTAX_MTU
是一个宏,通常定义为 2。通过这种方式,可以访问 metrics
数组中的第二个元素(即 RTAX_MTU
对应的 MTU 值)。
1.
metrics
是一个指向u32
类型的指针,表示一个数组的起始地址。2.
RTAX_MTU
是一个宏,定义为 2,表示 MTU 在metrics
数组中的索引。3.
metrics + RTAX_MTU
将指针移动到metrics
数组的第二个元素。4.
metrics + RTAX_MTU - 1
将指针移动到metrics
数组的第一个元素。
Tuple
pwru 在获取 tuple 信息时候,先获取 IP 头部的版本字段,再根据 v4 或者 v6 不同版本获取 源地址、目标地址、协议(TCP/UDP),最后根据传输协议获取 源端口、目标端口。
SKB CPU PROCESS TUPLE FUNC
0xffff888d910c5a00 0 ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) __ip_local_out
0xffff888d910c5a00 0 ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) ip_output
0xffff888d910c5a00 0 ~r/bin/ping:1830 10.**.***.114:0->1.1.1.1:0(icmp) nf_hook_slow
如何判断 IP 版本
1. 读取
sk_buff
结构体中的head
和network_header
字段。2. 根据
network_header
偏移量获取 IP 头部。l3_off
是指网络层(Layer 3)的偏移量。这里的l3
代表 OSI 模型中的第三层,即网络层。网络层负责数据包的路由和转发,常见的协议包括 IPv4 和 IPv6。在网络编程中,l3_off
通常用于表示从数据缓冲区起始位置到网络层头部(例如 IP 头部)的偏移量。通过这个偏移量,可以定位到数据包的网络层头部,从而读取和处理 IP 头部信息。3. 读取 IP 头部的版本字段,判断是 IPv4 还是 IPv6。
void *skb_head = BPF_CORE_READ(skb, head);
u16 l3_off = BPF_CORE_READ(skb, network_header);
struct iphdr *l3_hdr = (struct iphdr *) (skb_head + l3_off);
u8 ip_vsn = BPF_CORE_READ_BITFIELD_PROBED(l3_hdr, version);
// 跳过处理非 v4 或者 v6 的连接
if (ip_vsn !=4 && ip_vsn != 6)
return;
bool is_ipv4 = ip_vsn == 4;
如何获取连接信息
1. 通过 is_ipv4 判断是否为 IP v4,反之为 IP v6。
2. 设置
l3_proto
•
ETH_P_IP
和ETH_P_IPV6
是以太网协议类型字段的值,用于标识以太网帧中封装的上层协议类型。这些值是由 IEEE 802.3 标准定义的。•
ETH_P_IP
(0x0800) 表示以太网帧中封装的是 IPv4 协议。•
ETH_P_IPV6
(0x86DD) 表示以太网帧中封装的是 IPv6 协议。
3. 获取 源、目标地址,协议
• 对于 IPv4,读取
struct iphdr
中的saddr
和daddr
字段获取源地址和目标地址,读取protocol
字段获取l4_proto
协议。• 对于 IPv6,读取
struct ipv6hdr
中的saddr
和daddr
字段获取源地址和目标地址,读取nexthdr
字段获取l4_proto
协议。
4. 获取 l4_off
•
l4_off
是指向第 4 层协议头部(如 TCP 或 UDP)的偏移量。它是通过在第 3 层协议头部(如 IPv4 或 IPv6)偏移量的基础上加上第 3 层头部的长度来计算的。• IPv4 头部的长度是可变的,由 ihl(Internet Header Length)字段指定。
ihl
字段的单位是 32 位字(4 字节)。因此,l4_off
的计算方式是:l3_off + ihl * 4
。• IPv6 头部的长度是固定的,通常为 40 字节。但是在代码中,使用了
ipv6_hdrlen(ip6)
函数来计算 IPv6 头部的长度。这是因为 IPv6 头部可能包含扩展头部,扩展头部的长度也需要考虑在内。
5. 获取 源、目标端口
• 检查
tpl->l4_proto
是否为IPPROTO_TCP
或IPPROTO_UDP
。• 如果是
IPPROTO_TCP
,读取tcphdr
结构体中的source
和dest
字段,分别获取源端口和目标端口。• 如果是
IPPROTO_UDP
,读取udphdr
结构体中的source
和dest
字段,分别获取源端口和目标端口。
static __always_inline void
__set_tuple(struct tuple *tpl, void *data, u16 l3_off, bool is_ipv4) {
u16 l4_off;
// 如果是 IPV4
if (is_ipv4) {
// 获取 IPV4 头指针
struct iphdr *ip4 = (struct iphdr *) (data + l3_off);
// 获取 源、目标地址
BPF_CORE_READ_INTO(&tpl->saddr, ip4, saddr);
BPF_CORE_READ_INTO(&tpl->daddr, ip4, daddr);
// 获取协议类型
tpl->l4_proto = BPF_CORE_READ(ip4, protocol);
// 设置 IP 版本
tpl->l3_proto = ETH_P_IP;
// 获取 L4 偏移量,用于获取端口信息
l4_off = l3_off + BPF_CORE_READ_BITFIELD_PROBED(ip4, ihl) * 4;
} else {
struct ipv6hdr *ip6 = (struct ipv6hdr *) (data + l3_off);
BPF_CORE_READ_INTO(&tpl->saddr, ip6, saddr);
BPF_CORE_READ_INTO(&tpl->daddr, ip6, daddr);
tpl->l4_proto = BPF_CORE_READ(ip6, nexthdr); // TODO: ipv6 l4 protocol
tpl->l3_proto = ETH_P_IPV6;
l4_off = l3_off + ipv6_hdrlen(ip6);
}
if (tpl->l4_proto == IPPROTO_TCP) {
// 获取 TCP 协议头部的指针
struct tcphdr *tcp = (struct tcphdr *) (data + l4_off);
tpl->sport= BPF_CORE_READ(tcp, source);
tpl->dport= BPF_CORE_READ(tcp, dest);
} else if (tpl->l4_proto == IPPROTO_UDP) {
// 获取 UDP 协议头部的指针
struct udphdr *udp = (struct udphdr *) (data + l4_off);
tpl->sport= BPF_CORE_READ(udp, source);
tpl->dport= BPF_CORE_READ(udp, dest);
}
}
BTF
set_skb_btf
和 set_shinfo_btf
两个函数的作用如下:
•
set_skb_btf
:• 作用:将
struct sk_buff
结构体的内容格式化为 BTF(BPF Type Format)字符串,并将其存储在print_skb_map
BPF 映射中。• 主要步骤:
1. 初始化 BTF 指针,设置类型 ID 和指针地址。
2. 获取并递增
print_skb_id
,确保每个事件有唯一的 ID。3. 在
print_skb_map
中查找对应 ID 的条目。4. 使用
bpf_snprintf_btf
函数将struct sk_buff
的内容格式化为字符串。5. 将格式化后的字符串长度和内容存储在映射中。
# ./pwru --output-skb 'host 1.1.1.1'
SKB CPU PROCESS FUNC
0xffff888d8b791a00 1 ~r/bin/ping:4317 __ip_local_out
(struct sk_buff){
(union){
.sk = (struct sock *)0x00000000a4c1108e,
.ip_defrag_offset = (int)-2065085440,
},
...
}
set_shinfo_btf
:
• 作用:将
struct skb_shared_info
结构体的内容格式化为 BTF 字符串,并将其存储在print_shinfo_map
BPF 映射中。• 主要步骤:
1. 获取
struct sk_buff
末尾的struct skb_shared_info
结构体。2. 初始化 BTF 指针,设置类型 ID 和指针地址。
3. 获取并递增
print_shinfo_id
,确保每个事件有唯一的 ID。4. 在
print_shinfo_map
中查找对应 ID 的条目。5. 使用
bpf_snprintf_btf
函数将struct skb_shared_info
的内容格式化为字符串。6. 将格式化后的字符串长度和内容存储在映射中。
# ./pwru --output-skb-shared-info 'host 1.1.1.1'
SKB CPU PROCESS FUNC
0xffff888d8c7efa00 0 ~r/bin/ping:4486 __ip_local_out
(struct skb_shared_info){
.dataref = (atomic_t){
.counter = (int)1,
},
}
Stack
# ./pwru --output-stack 'host 1.1.1.1'
SKB CPU PROCESS FUNC
0xffff888d90f6e200 0 ~r/bin/ping:4680 __ip_local_out
__ip_local_out
ping_v4_sendmsg
inet_sendmsg
__sock_sendmsg
__sys_sendto
__x64_sys_sendto
x64_sys_call
do_syscall_64
entry_SYSCALL_64_after_hwframe
内核侧堆栈的写入过程
1. 定义 BPF 映射:在 eBPF 程序中定义一个
BPF_MAP_TYPE_STACK_TRACE
类型的映射,用于存储堆栈信息。struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__uint(max_entries, 256);
__uint(key_size, sizeof(u32));
__uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
} print_stack_map SEC(".maps");2. 捕获堆栈 ID:在 eBPF 程序中,通过
bpf_get_stackid
函数获取堆栈 ID,并将其存储在事件结构中。static __always_inline void
set_output(void *ctx, struct sk_buff *skb, struct event_t *event) {
if (cfg->output_stack) {
event->print_stack_id = bpf_get_stackid(ctx, &print_stack_map, BPF_F_FAST_STACK_CMP);
}
}
用户态侧获取堆栈信息
func getStackData(event *Event, o *output) (stackData string) {
var stack StackData
id := uint32(event.PrintStackId)
// 根据 stack id 获取调用链
if err := o.printStackMap.Lookup(&id, &stack); err == nil {
for _, ip := range stack.IPs {
if ip > 0 {
// 将函数 IP 转换为函数名
stackData += fmt.Sprintf("\n%s", o.addr2name.findNearestSym(ip))
}
}
}
// 清理已经处理的 stack id
_ = o.printStackMap.Delete(&id)
return stackData
}
用户态程序中获取堆栈信息的关键函数是 getStackData
。以下是该函数的详细总结:
1. 定义堆栈数据结构:定义一个
StackData
结构体实例,用于存储堆栈信息。2. 查找堆栈信息:通过
printStackMap.Lookup
函数,根据事件中的PrintStackId
从 BPF 映射中查找堆栈信息。3. 解析堆栈信息:遍历堆栈中的 IP 地址,并使用
addr2name.findNearestSym
函数将 IP 地址解析为符号名称。4. 删除堆栈条目:从
printStackMap
中删除已处理的堆栈条目。5. 返回堆栈数据:将解析后的堆栈信息拼接成字符串并返回。
在 getStackData
函数中,stack
结构体中的 IPs
是一个存储堆栈中每个函数调用地址的数组。每个 ip
是一个程序计数器(PC)地址,表示堆栈中的一个函数调用位置。
这些 ip
地址是通过 eBPF 程序捕获的,当 eBPF 程序调用 bpf_get_stackid
函数时,内核会记录当前的调用堆栈,并将这些地址存储在 BPF_MAP_TYPE_STACK_TRACE
类型的映射中。用户态程序通过查找这些地址并使用符号解析工具(如 addr2name.findNearestSym
)将这些地址转换为可读的函数名称。
func (a *Addr2Name) findNearestSym(ip uint64) string {
total := len(a.Addr2NameSlice)
i, j := 0, total
// 二分法查找最接近 ip
for i < j {
// 中间位置
h := int(uint(i+j) >> 1)
// 如果查找 ip 在 Addr2NameSlice 右侧
if a.Addr2NameSlice[h].addr <= ip {
// 确保 h+1 不会超出数组的边界
// 如果 a.Addr2NameSlice[h].addr 小于等于 ip,并且 a.Addr2NameSlice[h+1].addr 大于 ip,则 a.Addr2NameSlice[h] 是最接近 ip 的符号地址。这样可以确保返回的符号名称是最接近 ip 的符号名称。
if h+1 < total && a.Addr2NameSlice[h+1].addr > ip {
return strings.Replace(a.Addr2NameSlice[h].name, "\t", "", -1)
}
// 当 Addr2NameSlice[h].addr 小于等于 ip 时,说明目标 ip 在 Addr2NameSlice 的右半部分。因此,需要将左边界 i 更新为 h + 1,以缩小查找范围到右半部分。这样可以继续在右半部分进行查找,直到找到最接近 ip 的符号地址。
i = h + 1
} else {
// 在二分查找法中,i 和 j 分别表示查找范围的左右边界。h 是当前查找范围的中间位置。 如果 Addr2NameSlice[h].addr 大于 ip 说明目标 ip 在 Addr2NameSlice 的左半部分。因此,需要将右边界 j 更新为 h,以缩小查找范围到左半部分。这样可以继续在左半部分进行查找,直到找到最接近 ip 的符号地址。
j = h
}
}
// 在二分查找结束后,如果没有在循环中找到确切的匹配项,i 会指向第一个大于 ip 的元素位置。因此,i-1 就是小于等于 ip 的最大元素位置。由于 Addr2NameSlice 是按地址排序的,所以 i-1 位置的元素是最接近且小于等于 ip 的符号地址。
return strings.Replace(a.Addr2NameSlice[i-1].name, "\t", "", -1)
}
findNearestSym
函数从 Addr2NameSlice
获取对应关系。Addr2NameSlice
是一个按地址排序的 ksym
结构体指针数组,存储了地址和符号名称的对应关系。
这些对应关系是在 ParseKallsyms
函数中从 /proc/kallsyms
文件中解析并填充到 Addr2NameSlice
中的。ParseKallsyms
函数读取 /proc/kallsyms
文件的每一行,将地址和符号名称解析出来,并存储在 Addr2NameMap
和 Addr2NameSlice
中。
引用链接
[1]
Packet mark in a Cloud Native world: https://lpc.events/event/7/contributions/683/attachments/554/979/lpc20-pkt-mark-slides.pdf