为 eBPF 程序注入黑魔法 【错误姿势】 中提出的在加载阶段中动态变更常量值的办法并不可靠,毕竟在
cilium/ebpf
中已提供了重写常量的函数
RewriteConstants。
重写常量的简单例子
tc-dump 是一个使用 tc-bpf
获取网络包信息的工具,支持过滤 IP 地址、端口、4 层协议;比如过滤 IP 地址时,eBPF 中使用的是
1
|
static volatile const __be32 FILTER_ADDR;
|
Go 中使用如下写法去支持动态的 IP 地址过滤:
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
var filterAddr string
flag.StringVar(&filterAddr, "filter-addr", "", "filter IP address")
flag.Parse()
ip := netip.MustParseAddr(filterAddr)
// if !ip.Is4() { ... }
spec.RewriteConstants(map[string]interface{} {
"FILTER_ADDR": ip.As4(),
})
}
|
重写常量的实用方法
但在 tc-dump
中,并没有使用 N 个常量去支持过滤 IP 地址、端口、4 层协议等,而是使用了一个 struct
去封装了所有需要过滤的字段。这样一来,相关代码就变得比较简洁、易于理解了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
typedef struct config_t {
__be16 vlan_id;
__be32 vxlan_vni;
u32 mark;
__be32 saddr;
__be32 daddr;
__be32 addr;
__be16 sport;
__be16 dport;
__be16 port;
u8 l4_proto;
u8 pad1[3];
} __attribute__((packed)) config_t;
static volatile const config_t __cfg = {};
|
而在 Go 中,也使用一个 struct
去提供“常量”内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
type config struct {
VlanID uint16
VxlanVNI uint32
Mark uint32
Saddr uint32
Daddr uint32
Addr uint32
Sport uint16
Dport uint16
Port uint16
L4Proto uint8
Pad [3]uint8
}
func main() {
// ...
flags := parseFlags()
cfg := newConfig(flags)
rewriteConst := map[string]interface{}{
"__cfg": *cfg,
}
// ...
}
|
常量被重写的“本质”
其本质是内存替换,即替换需要被重写的常量对应的那一段内存。
以下为
RewriteConstants
函数的源代码剖析。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
func (cs *CollectionSpec) RewriteConstants(consts map[string]interface{}) error {
rodata := cs.Maps[".rodata"] // 常量是保存在 ELF .rodata secion 中
// ...
kv := rodata.Contents[0]
value, ok := kv.Value.([]byte)
if !ok {
return fmt.Errorf("first value in .rodata is %T not []byte", kv.Value)
}
buf := make([]byte, len(value)) // 准备一段新的 buffer
copy(buf, value) // 复制原有的内容,毕竟不确定是否 patch 所有常量
err := patchValue(buf, rodata.Value, consts) // 使用提供的常量进行替换
if err != nil {
return err
}
rodata.Contents[0] = MapKV{kv.Key, buf} // 使用替换后的 buffer
return nil
}
func patchValue(value []byte, typ btf.Type, replacements map[string]interface{}) error {
replaced := make(map[string]bool)
replace := func(name string, offset, size int, replacement interface{}) error {
if offset+size > len(value) {
return fmt.Errorf("%s: offset %d(+%d) is out of bounds", name, offset, size)
}
buf, err := marshalBytes(replacement, size) // 将提供的常量 marshal 成 buffer
if err != nil {
return fmt.Errorf("marshal %s: %w", name, err)
}
copy(value[offset:offset+size], buf) // 替换对应的 buffer 内容
replaced[name] = true
return nil
}
// ...
}
|
由上代码分析可知,eBPF 的 C 代码中常量定义的类型跟 Go 代码中对应的用于替换的
“常量” 的类型可以是不一致的,
只要保证它们的大小是一致的即可。在项目实践中,就遇到过这种情况;
在 eBPF 的 C 代码中,struct prog_name { __u8 name[16]; }; static volatile const struct prog_name pname;
一直不成功,而使用 struct prog_name { __u64 a, b; };
就没问题;而 Go 代码里只需要提供 [16]byte
即可。
小结
本文从简单例子到复杂例子,再讲解了常量被重写的“本质”,至此能够清晰地知道基于 ELF
是怎么做到常量重写的。
然而,eBPF CO-RE 具体是怎么做到重写常量的特性的,我并没去深入理解,因为这里涉及到
eBPF verifier(内核中的校验器)。而我现在还没有计划去深入理解 eBPF verifier
的工作原理。等未来深入理解 eBPF verifier 后,再来讲解重写常量这一特性背后的工作原理吧。
总结
在我编写 C、Go 代码的经验中,有一条经验有助于理解 eBPF 重写常量特性:类型的本质是一段内存。
比如 Go 中 uint64
类型,占据 8 个字节,使用
unsafe
包能将这 8 个字节转换成其他类型:
https://go.dev/play/p/5P3RWkMSwJ7。而在 C
语言中,类型转换就更加直接了,强转即可:u64 n = (u64)0xFEDCBA9865432101ul; u32 *n2 = (u32*)(&n);
,毕竟 C 中指针是万能的。
而对于在加载阶段重写常量,只是将某常量对应的那一段内存做一次替换,之后再装载进内核中。