eBPF常见map及其使用方式

说明

eBPF中常见的map有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum bpf_map_type
{
BPF_MAP_TYPE_UNSPEC,
BPF_MAP_TYPE_HASH,
BPF_MAP_TYPE_ARRAY,
BPF_MAP_TYPE_PROG_ARRAY,
BPF_MAP_TYPE_PERF_EVENT_ARRAY,
BPF_MAP_TYPE_PERCPU_HASH,
BPF_MAP_TYPE_PERCPU_ARRAY,
BPF_MAP_TYPE_STACK_TRACE,
BPF_MAP_TYPE_CGROUP_ARRAY,
BPF_MAP_TYPE_LRU_HASH,
BPF_MAP_TYPE_LRU_PERCPU_HASH,
BPF_MAP_TYPE_LPM_TRIE,
BPF_MAP_TYPE_ARRAY_OF_MAPS,
BPF_MAP_TYPE_HASH_OF_MAPS,
BPF_MAP_TYPE_DEVMAP,
BPF_MAP_TYPE_SOCKMAP,
BPF_MAP_TYPE_CPUMAP,
};

通过前面的示例代码和相关学习,目前接触到类型有:BPF_MAP_TYPE_PERF_EVENT_ARRAY,BPF_MAP_TYPE_PERCPU_ARRAY,这两种类型主要都是在perf_heap相关程序中接触到的. 具体代码参考:perf_event_map

BPF_MAP_TYPE_PERF_EVENT_ARRAY

BPF_MAP_TYPE_PERF_EVENT_ARRAY是eBPF(Extended Berkeley Packet Filter)中的一种特殊类型的映射(map)。它用于在eBPF程序和用户空间之间传递性能事件(perf events)。
示例程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct bpf_map_def SEC("maps/my_map") my_map = {
.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 1024,
};

struct data_t {
u32 pid;
};

SEC("kprobe/vfs_mkdir")
int kprobe_vfs_mkdir(void *ctx)
{
bpf_printk("mkdir_perf_event (vfs hook point)%u\n",bpf_get_current_pid_tgid());
struct data_t data = {};
data.pid = bpf_get_current_pid_tgid();
bpf_perf_event_output(ctx, &my_map, BPF_F_CURRENT_CPU, &data, sizeof(data));
return 0;
};

其中:

1
2
3
4
5
6
struct bpf_map_def SEC("maps/my_map") my_map = {
.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 1024,
};

这段代码定义了一个名为my_map的eBPF映射,其类型为BPF_MAP_TYPE_PERF_EVENT_ARRAY。该映射用于存储性能事件的数据。它的键大小为sizeof(int),值大小为sizeof(u32),最大条目数为1024。
示例代码的整个逻辑也非常的简单。在vfs_mkdir系统调用的钩子函数中,将当前进程的PID作为性能事件的数据发送到my_map映射中。这样,用户空间程序可以通过读取my_map映射来获取性能事件数据,并进行进一步的分析和处理。

BPF_MAP_TYPE_PERCPU_ARRAY

BPF_MAP_TYPE_PERCPU_ARRAY是eBPF(Extended Berkeley Packet Filter)中的一种特殊类型的映射(map)。它用于在eBPF程序中创建一个数组,每个CPU都有自己的副本,可以在不同的CPU上并发地进行读写操作。BPF_MAP_TYPE_PERCPU_ARRAY是一种用于多核系统的映射类型,它提供了一种在不同CPU上进行并发读写的机制,避免了竞争条件和锁的使用。
示例代码如下:

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
struct bpf_map_def SEC("maps/heap") heap = {
.type = BPF_MAP_TYPE_PERCPU_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 1,
};
struct data_t {
u32 pid;
};
struct bpf_map_def SEC("maps/perf_map") perf_map = {
.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
.key_size = sizeof(int),
.value_size = sizeof(u32),
.max_entries = 1024,
};
SEC("kprobe/vfs_mkdir")
int kprobe_vfs_mkdir(void *ctx)
{
bpf_printk("mkdir_perf_event (vfs hook point)%u\n", bpf_get_current_pid_tgid());
int zero = 0;
struct data_t *data = bpf_map_lookup_elem(&heap, &zero);
if (!data) {
return 0;
}

data->pid = bpf_get_current_pid_tgid();
bpf_perf_event_output(ctx, &perf_map, BPF_F_CURRENT_CPU, data, sizeof(*data));
return 0;
}

向比较BPF_MAP_TYPE_PERF_EVENT_ARRAY的代码,在本例中存在一处不一样的代码,struct data_t *data = bpf_map_lookup_elem(&heap, &zero);
bpf_map_lookup_elem主要在堆上申请空间,bpf_map_lookup_elem的作用是查找heap映射中键为zero的元素。因为heap是一个BPF_MAP_TYPE_PERCPU_ARRAY类型的映射,所以每个CPU都有一个与键zero关联的元素。这个元素的类型是data_t,它是一个结构体,包含一个u32类型的成员pid。如果成功返回了,那么就说明成功申请了这段内存。接下来,就可以将当前进程的PID作为性能事件的数据发送到perf_map映射中。这样,用户空间程序可以通过读取perf_map映射来获取性能事件数据,并通过读取heap映射来获取每个CPU上的数据。
这段代码的含义同样也很简单,在vfs_mkdir系统调用的钩子函数中,将当前进程的PID作为性能事件的数据发送到perf_map映射中。同时,使用heap映射来存储每个CPU的数据,以便在不同CPU上进行并发读写操作。用户空间程序可以通过读取perf_map映射来获取性能事件数据,并通过读取heap映射来获取每个CPU上的数据。

BPF_MAP_TYPE_HASH

BPF_MAP_TYPE_HASH一般就是用于内核态和用户态之间传输数据. 这个目前是最为常见。可以参见示例代码:map_rewite

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
struct bpf_map_def SEC("maps/cache") cache = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(u32),
.value_size = sizeof(u32),
.max_entries = 10,
};

SEC("kprobe/vfs_mkdir")
int kprobe_vfs_mkdir(void *ctx)
{
u32 key = 1;
u32 *value;
bpf_printk("map rewrite,mkdir (vfs hook point)\n");
value = bpf_map_lookup_elem(&cache, &key);
if (value) {
u32 new_value = 100;
bpf_printk("Value found in cache: %u\n", *value);
bpf_map_update_elem(&cache, &key, &new_value, BPF_ANY);
value = bpf_map_lookup_elem(&cache, &key);
if (value) {
bpf_printk("Value updated in cache: %u\n", *value);
} else {
bpf_printk("Failed to update value in cache\n");
}
} else {
bpf_printk("Value not found in cache\n");
}
return 0;
};

BPF_MAP_TYPE_LRU_HASH

普通 hash map 的问题是有大小限制,超过最大数量后无法再插入了。LRU map 可以避 免这个问题,如果 map 满了,再插入时它会自动将最久未被使用(least recently used)的 entry 从 map 中移除。
参考Hades中的代码.

1
2
3
4
5
6
7
8
9
10
#define BPF_LRU_HASH(_name, _key_type, _value_type, _max_entries)              \
BPF_MAP(_name, BPF_MAP_TYPE_LRU_HASH, _key_type, _value_type, _max_entries)

typedef struct net_ctx {
int fd;
sa_family_t sa_family;
int addr;
} net_ctx_t;

BPF_LRU_HASH(connect_cache, u64, net_ctx_t, 4096);

其中BPF_LRU_HASH(connect_cache, u64, net_ctx_t, 4096)就等价于:

1
2
3
4
5
6
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__type(key, u64);
__type(value, struct net_ctx);
__uint(max_entries, 4096);
} connect_cache SEC(".maps");

转换成为这种熟悉的格式,就很方便理解了.接下来看有关BPF_MAP_TYPE_LRU_HASH类型的具体使用.

BPF_MAP_TYPE_HASH_OF_MAPS

内核态

map-in-map:第一个 map 内的元素是指向另一个 map 的指针。还是以实际的例子来作说明。map_in_map

1
2
3
4
5
6
7
8
9
10
11
12
struct bpf_map_def SEC("maps/InnerM") InnerM = {
.type = BPF_MAP_TYPE_HASH,
.key_size = sizeof(u32),
.value_size = sizeof(u32),
.max_entries = 10,
};

struct bpf_map_def SEC("maps/OuterM") OuterM = {
.type = BPF_MAP_TYPE_HASH_OF_MAPS,
.key_size = sizeof(u32),
.max_entries = 10,
};

分别定义了两个MAP类型的结构体,其中InnerM结构体的类型是BPF_MAP_TYPE_HASH,作为内层MAPOuterM结构体的类型是BPF_MAP_TYPE_HASH_OF_MAPS,作为外层MAPInnerM结构体的key_sizevalue_size都是sizeof(u32)max_entries是10。OuterM结构体的key_sizesizeof(u32)max_entries是10。
接下来看具体的使用和交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
u32 key = 1;
u32 value = 42;
u32 newKey = 3;
u32 newValue = 3;
void *inner_map = bpf_map_lookup_elem(&OuterM, &key);
if (inner_map == NULL) {
bpf_printk("map lookup failed\n");
return 0;
}

bpf_printk("map rewrite,mkdir (vfs hook point)\n");
int result = bpf_map_update_elem(inner_map, &newKey, &newValue, BPF_ANY);
if (result == 0) {
bpf_printk("add new key-value pair\n");
}
result = bpf_map_update_elem(inner_map, &key, &value, BPF_ANY);
if (result == 0) {
bpf_printk("rewrite key-value pair\n");
}
return 0;

在钩子函数kprobe_vfs_mkdir中,通过调用bpf_map_lookup_elem函数,可以获取OuterM映射中键为1的条目的指针,并将其赋值给inner_map变量。
bpf_map_update_elem(inner_map, &newKey, &newValue, BPF_ANY),如果对应的KEY不存在,就是新建。如果存在就是更新为newValue。在本例中因为newKey不在inner_map就是更新。
bpf_map_update_elem(inner_map, &key, &value, BPF_ANY),在本例中,因为key已经存在,所以inner_map就是更新。

用户态

初始化Map对象,初始化Manager和之前一样没有差别。不过初始化BPF_MAP_TYPE_HASHBPF_MAP_TYPE_HASH_OF_MAPS类型存在差别。

初始化Map

具体代码如下:

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
m := &manager.Manager{
Probes: []*manager.Probe{
{
UID: "KprobeVFSMkdir",
Section: "kprobe/vfs_mkdir",
EbpfFuncName: "kprobe_vfs_mkdir",
AttachToFuncName: "vfs_mkdir",
},
},
Maps: []*manager.Map{
{
Name: "InnerM",
Contents: []ebpf.MapKV{
{Key: uint32(1), Value: uint32(1)},
{Key: uint32(2), Value: uint32(2)},
},
},
},
}

options := manager.Options{
MapSpecEditors: map[string]manager.MapSpecEditor{
"OuterM": {
InnerMap: &ebpf.MapSpec{
Name: "InnerM",
Type: ebpf.Hash,
KeySize: 4,
ValueSize: 4,
MaxEntries: 10,
Flags: 0,
},
EditorFlag: manager.EditInnerMap,
},
},
}

err := m.InitWithOptions(bytes.NewReader(_bytecode), options)

InnerM的定义是直接在定义Manager时定义的:

1
2
3
4
5
6
7
8
9
Maps: []*manager.Map{
{
Name: "InnerM",
Contents: []ebpf.MapKV{
{Key: uint32(1), Value: uint32(1)},
{Key: uint32(2), Value: uint32(2)},
},
},
},

定义的类型是ebpf.MapKV,并且初始化了两个键值对。这两个键值对会在InnerM初始化时插入到InnerM中。
OuterM的定义是通过MapSpecEditors定义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
MapSpecEditors: map[string]manager.MapSpecEditor{
"OuterM": {
InnerMap: &ebpf.MapSpec{
Name: "InnerM",
Type: ebpf.Hash,
KeySize: 4,
ValueSize: 4,
MaxEntries: 10,
Flags: 0,
},
EditorFlag: manager.EditInnerMap,
},
},

通过InnerMap定义了内部Map类型和名称。EditorFlag定义了编辑标志,这里是manager.EditInnerMap,表示编辑内部Map。这样在初始化Manager时,就会自动创建OuterMInnerM两个Map,并将InnerM插入到OuterM中。

读取Map

通过GetMap的方式就可以读取定义的BPF_MAP_TYPE_HASH类型的Map了。

1
2
3
4
5
6
7
8
9
10
11
12
sharedCache, found, err := m.GetMap("InnerM")
if err != nil || !found {
fmt.Println(fmt.Errorf("error:%v, %s", err, "couldn't find shared_cache1 in m1"))
}

// Iterate over the map
entries := sharedCache.Iterate()
var key, val uint32
for entries.Next(&key, &val) {
// Order of keys is non-deterministic due to randomized map seed
fmt.Printf("%v contains %v at key %v\n", sharedCache, val, key)
}

实际运行输出的是如下的结果,符合预期。(因为这两个值是在初始化时插入的)

1
2
Hash(InnerM)#3 contains 2 at key 2
Hash(InnerM)#3 contains 1 at key 1

接下来分析OuterM的加载。

1
2
3
4
5
router := manager.MapRoute{RoutingMapName: "OuterM", Key: uint32(1), Map: sharedCache}
if err := m.UpdateMapRoutes(router); err != nil {
fmt.Println("update error", err)
return
}

通过MapRoute定义了OuterMInnerM关联关系。OuterM中的键为1对应的值就是sharedCache,然后通过m.UpdateMapRoutes(router)执行更新操作。
通过这种关联,所以当我们在内核态中的代码void *inner_map = bpf_map_lookup_elem(&OuterM, &key)才会成功执行。

更新Map

当我们在用户态触发了vfs_mkdir事件之后,就会执行下面的代码:

  • bpf_map_update_elem(inner_map, &newKey, &newValue, BPF_ANY);,内层Map会新增加一个key=3,value=3的键值对
  • bpf_map_update_elem(inner_map, &key, &value, BPF_ANY),内存Map会更新key=1的值为value=42
    实际运行的结果如下:
    1
    2
    3
    4
    Generating events to trigger the probes ...
    Hash(InnerM)#3 contains 2 at key 2
    Hash(InnerM)#3 contains 3 at key 3
    Hash(InnerM)#3 contains 42 at key 1

符合代码执行预期。
这种map嵌套map的方式,并且内存map还可以保存复杂的数据结构时,可以更好地用与保存各种复杂规则。在字节开源的 vArmor-ebpf 中就是使用这种方式来保存各种规则的。有关其中具体的规则应用可以参考之前写的文章,字节vArmor客户端代码解读

总结

按照在 bpf_map_type 中的定义还存在很多其他类型的Map,但是就目前我所观察到的,目前使用的比较多的就是上述的几种类型。上面这几种常见的类型也基本上可以满足场景需求。

参考

http://arthurchiao.art/blog/bpf-advanced-notes-2-zh/
https://elixir.bootlin.com/linux/latest/source/samples/bpf/test_map_in_map.bpf.c