为 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 中指针是万能的。

而对于在加载阶段重写常量,只是将某常量对应的那一段内存做一次替换,之后再装载进内核中。