一、先弄清楚eBPF Map是干嘛的

eBPF 程序像个小侦探,跑在内核里。它需要把发现的东西,比如某个进程打开了文件、某个网络包到了,告诉外面的用户程序。可是内核空间和用户空间是两个不同的世界,不能互相直接读对方的内存。这时候就需要一个“信箱”,这就是 Map。Map 本质上是一块由内核管理的内存空间,既能被 eBPF 程序读写,也能被用户程序通过系统调用访问。

用生活的话说,eBPF Map 就是一张放在寄存柜里的纸条:侦探把消息写在纸条上塞进柜子,外面的人用同一把钥匙打开柜子取走纸条。只要你理解了“互相传纸条”这件事,接下来看它怎么分类就轻松了。

二、认识几种常用的 Map 类型

2.1 哈希表 Map

哈希表 Map 就像一本字典,你给它一个 key(比如进程 ID),它立刻能告诉你对应的 value(比如计数)。它的特点是查找特别快,几乎是瞬间。这种 Map 适合元素个数不固定、需要频繁增删改查的场景。

比如,你想统计网络包里每个源 IP 发了多少包:IP 地址是 key,包数量是 value。一个请求来了,就用 IP 做 key 去查,查到就加一,查不到就新建一条。哈希表在 eBPF 里是出场率最高的 Map,因为大多数跟踪需求都是“按某个标识查信息”。

优点:插入和查找都是常数级别,非常灵活。缺点:内存开销比数组大,因为要维护哈希桶和索引结构;并发访问时,如果多个 CPU 同时改同一个 key,需要加锁同步,可能影响速度。

2.2 数组 Map

数组 Map 和普通 C 语言里的数组一样,就是用下标访问。它的 key 必须是一个整数索引,而且数组大小在创建时就固定了,不能自动变大。因为内存是预先分配好的,访问速度非常快,几乎没有额外开销。

这种 Map 特别适合数量固定的场景。比如你的服务器有 32 个 CPU 核心,你想统计每个核心处理过的包数量,就可以创建一个 32 项的数组,用 CPU 编号当下标。顺便一提,还有 Per-CPU 数组 Map,就是每个 CPU 单独一套数组,这样多个 CPU 同时操作时互不干扰,连锁都不用加,性能更猛。

优点:访问快,内存稳定。缺点:不能动态扩容,key 只能是数字索引,不够灵活。

2.3 LRU Map

LRU 是“Least Recently Used”的缩写,意思是“最近最少被使用”。这种 Map 有点像一个只能放固定数量物品的抽屉,如果抽屉满了,新东西要放进来时,它会自动把最久没碰过的那件东西扔掉,腾出位置。

LRU Map 适合做缓存。比如你想记录最近访问频繁的一些路径,但路径数量太多,不可能全记住,你就设置一个上限,比如一万条。当超过一万条时,内核会自动淘汰最久没访问的记录,让热门数据一直留在里面。

优点:内存可控,能自动淘汰冷数据。缺点:淘汰时需要判断,有一点额外开销;如果碰巧把刚要用到的数据淘汰掉,下一次访问反而会慢。

顺便告诉你,LRU 也有 Per-CPU 版本,用在需要高并发的计数器缓存上,效果很好。

2.4 队列和栈 Map

队列 Map 像食堂排队打饭,先来的人先打饭,也就是 FIFO。栈 Map 像一叠盘子,后放的反而先拿走,也就是 LIFO。它们都是用来传递一组按顺序排列的数据。

使用场景很直观:当 eBPF 程序需要把一串事件逐步交给用户态程序,并且保持先后顺序时,可以往队列里推入一条条数据。用户态程序按顺序弹出。因为只有 push 和 pop 两种操作,其他操作都被禁止,所以逻辑非常简单。

优点:顺序性强,操作简单,不用考虑随机访问。缺点:只能一头进一头出,不能查中间的元素,也不能随机更新。

2.5 不得不提的 Ring Buffer(环形缓冲区)

严格来说,Ring Buffer 也是一种 Map 类型(BPF_MAP_TYPE_RINGBUF),但它的设计目的更专一:专门用来高效地把内核数据传回用户态。它不是简单存储键值,而是一个可读写的环形缓冲区,eBPF 程序往里写数据,用户态程序读出来。

和队列相比,Ring Buffer 支持一次读走多条数据,也支持内核侧投递额外的元数据,还避免了因为单个 key 太大导致多次拷贝的开销。现在写内核和用户态通信程序,我建议优先考虑 Ring Buffer,而不是传统的 perf array。它也是目前最主流的数据交换方案。

三、动手体验:用 C 语言写一个小功能

下面直接上代码。技术栈统一使用 C 语言和 libbpf 库,既有 eBPF 内核侧程序,也有用户态加载程序。

3.1 示例1:哈希表统计进程打开文件的次数

先看内核侧程序:

// 技术栈:C(eBPF + libbpf)
#include <linux/bpf.h>
#include <linux/ptrace.h>
#include <bpf/bpf_helpers.h>

// 定义哈希表 Map:key 是进程ID(u32),value 是计数(u64)
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10000);    // 最多存 10000 个进程
    __type(key, __u32);
    __type(value, __u64);
} syscall_count_map SEC(".maps");

// 在 openat 系统调用入口触发
SEC("tracepoint/syscalls/sys_enter_openat")
int count_openat(struct trace_event_raw_sys_enter *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *count, init = 1;

    // 先查这个 pid 在不在表里
    count = bpf_map_lookup_elem(&syscall_count_map, &pid);
    if (count) {
        // 在的话,计数加1
        __sync_fetch_and_add(count, 1);
    } else {
        // 不在的话,插入初始值1
        bpf_map_update_elem(&syscall_count_map, &pid, &init, BPF_ANY);
    }
    return 0;
}

char _license[] SEC("license") = "GPL";

再看用户态程序:

// 技术栈:C(libbpf)
#include <stdio.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>

int main()
{
    struct bpf_object *obj;
    struct bpf_map *map;
    int map_fd;
    __u32 key, next_key;
    __u64 value;

    // 打开并加载前面编译好的 eBPF 对象文件
    obj = bpf_object__open_file("count_open_kern.o", NULL);
    if (!obj) {
        perror("打开文件失败");
        return 1;
    }

    if (bpf_object__load(obj)) {
        perror("加载 eBPF 程序失败");
        return 1;
    }

    // 拿到 Map 的文件描述符
    map = bpf_object__find_map_by_name(obj, "syscall_count_map");
    map_fd = bpf_map__fd(map);

    // 遍历所有 key,并打印统计结果
    key = 0;
    while (bpf_map_get_next_key(map_fd, &key, &next_key) == 0) {
        key = next_key;
        if (bpf_map_lookup_elem(map_fd, &key, &value) == 0) {
            printf("进程 %d 打开文件的次数: %ld\n", key, value);
        }
    }

    // 关闭对象
    bpf_object__close(obj);
    return 0;
}

这个例子简单展示了哈希表的核心用法:查、改、插。实际工作中,你可以把 pid 换成 IP、五元组等,统计网络流量,逻辑一模一样。

3.2 示例2:用队列把事件按顺序发给用户态

当 eBPF 程序探测到某个事件时,可以把它压入队列,用户态程序再把它弹出来,整个过程有序可靠。

内核侧代码:

// 技术栈:C(eBPF + libbpf)
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_QUEUE);
    __uint(max_entries, 128);   // 队列容量
    __type(value, __u32);       // 队列里存的是 u32 类型的数据
} event_queue_map SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_mkdir")
int enqueue_mkdir_event(struct trace_event_raw_sys_enter *ctx)
{
    u32 data = bpf_get_current_pid_tgid() >> 32;

    // 往队列尾部压入一个数据;如果队列已满,返回非0,这里选择直接丢弃
    bpf_map_push_elem(&event_queue_map, &data, 0);
    return 0;
}

char _license[] SEC("license") = "GPL";

用户态读取代码:

// 技术栈:C(libbpf)
#include <stdio.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>

int main()
{
    struct bpf_object *obj;
    struct bpf_map *map;
    int queue_fd;
    __u32 value;

    // 打开并加载队列 eBPF 对象文件
    obj = bpf_object__open_file("queue_kern.o", NULL);
    if (!obj) {
        perror("打开文件失败");
        return 1;
    }
    if (bpf_object__load(obj)) {
        perror("加载 eBPF 程序失败");
        return 1;
    }

    map = bpf_object__find_map_by_name(obj, "event_queue_map");
    queue_fd = bpf_map__fd(map);

    // 不断从队列里弹出事件
    while (bpf_map_pop_elem(queue_fd, &value) == 0) {
        printf("检测到进程 %d 创建了目录\n", value);
    }

    bpf_object__close(obj);
    return 0;
}

注意,在真实工程里,队列满的时候可以调用 bpf_map_push_elem(&map, &data, BPF_EXIST),这样会覆盖最老的数据,不会造成新事件丢失,但老事件有可能会丢。你可以根据自己的需求选一种。

四、如何把内核和用户态的数据交换做得更顺

4.1 能批量就别单个

如果你需要把内核里收集的数据一次全部拉到用户态,不要傻傻地循环单条查询。常见的做法是使用 bpf_map_lookup_batch、bpf_map_update_batch 这类批量接口,一次系统调用处理一大批数据,速度能快上一个数量级。尤其对于数组和哈希表,批量操作能大幅减少上下文切换。

4.2 能用环形缓冲区就别用队列

虽然队列也能传数据,但队列是一个一个元素进出的。如果一条消息体很大,每次 push 和 pop 都要拷贝一整段数据,效率不高。Ring Buffer 在内核侧通过内存映射的方式直接让用户态读到数据,减少了不止一次拷贝。它更适合大块数据、高频率的传输。

4.3 善用 Per-CPU 类型避免锁竞争

哈希表在多个 CPU 同时访问同一个 key 时,会有锁竞争。而数组或哈希表的 Per-CPU 版本,每个 CPU 有各自独立的副本,不需要加锁,访问速度极快。缺点是用户态查到的数据只是各个 CPU 的碎片,需要自己把每个 CPU 的数据加起来得到总数。

4.4 注意容量规划

Map 在创建时必须指定 max_entries,而且创建后不能修改。如果设得太小,数据多了会插入失败;设得太大,又浪费内核内存。尤其是哈希表,每个 entry 的内存开销比想象中要大,还包括指针和哈希桶的占用。建议根据业务量和内存上限做估算,留一点余量。

五、不同 Map 类型的优缺点对比

这里不用画表格,直接说结论:

  • 哈希表:灵活,查找快;缺点是内存占用稍高,并发需要额外保护。
  • 数组:速度极快,内存预分配;缺点是不能动态扩展,只能用数字索引。
  • LRU:自动淘汰冷数据,让内存使用可控;缺点是有淘汰开销,且淘汰时机不可预测。
  • 队列/栈:完美保证顺序;缺点是只能按固定方向进出,不能做随机访问。
  • Ring Buffer:目前最适合高频大数据量传输;缺点是用户态读取稍复杂,需要处理唤醒机制。

六、实际使用中要避开的坑

第一,别随便把大结构体当 value。比如你定义一个 8KB 的结构体放进哈希表,一旦条目变多,内存会很快爆炸。尽量把 value 定义得紧凑,如 u32、u64 或者小结构体。

第二,注意在 eBPF 程序里执行循环的复杂度。虽然有些 Map 操作很高效,但不规范的遍历会让内核校验器拒绝你的程序。尽量让代码路径清晰,复杂度有上限。

第三,用户态程序在读取 Map 的时候,一定要判断返回值和错误码。比如 -ENOENT 表示 key 不存在,-E2BIG 表示批次操作参数超限。不要默认所有操作都成功。

第四,如果发现 eBPF 程序加载失败,先检查权限。新版内核需要 root 或对应 capability(CAP_BPF),否则无法加载。

第五,当你用哈希表做状态跟踪时,要记得清理。不然一些已经退出的进程还占用着条目,白白浪费内存。可以在退出事件里删除对应的 key。

七、总结

eBPF Map 并不是一个高深难懂的概念,本质上是内核态与用户态之间共享的一组数据结构。关键是要理解不同 Map 类型的性格差异:哈希表适合灵活查询,数组适合固定索引,LRU 适合带容量上限的缓存,队列和栈适合保持顺序,Ring Buffer 则更适合高性能数据传输。选对了 Map,再把批量操作、Per-CPU 技巧用上,你写出的程序在内核里就能跑得又快又稳,和用户态的交互也会异常顺畅。

明白这些,以后再需要统计 HTTP 请求、追踪文件系统事件、排查网络抖动,你就能像挑选合适工具一样,随手挑一个最顺手的数据交换方案。