登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux BPF map 怎样在用户态读取内核数据

来源:17golang原创

时间:2026-11-03 12:44:25 285浏览 收藏

如果你已经有一个 BPF 程序把统计结果写进 map,用户态读取的核心并不是“直接读内核内存”,而是拿到这个 map 的句柄,用 key 查询 value。Linux 内核文档把 BPF map 定义为内核 BPF 程序与用户态程序共享数据的通用存储;用户态可以通过 BPF 系统调用或 libbpf 访问它。

最稳妥的做法是:让 map 的 key/value 结构在内核侧和用户态保持一致,先通过 libbpf 找到 map,再调用 bpf_map__lookup_elem 或文件描述符接口读取。查不到 key、key/value 尺寸不匹配、per-CPU map 和 map 生命周期,分别是四个最容易混在一起的问题。
内核 BPF 程序通过 BPF map 和 map FD 与用户态 libbpf 交换 key 和 value 的结构说明图
图1:BPF map 用户态读取路径的静态说明图,展示句柄与数据对象的关系,不是运行截图或运行证据。

先明确用户态读取 BPF map 的数据路径

BPF map 至少有类型、最大元素数、key 大小和 value 大小这些定义。内核侧的 BPF 程序可以用 helper 访问 map,用户态则通过 map 的文件描述符或者 libbpf 对象访问同一个数据容器。两边共享的是约定好的二进制布局,不是 C 变量的名字本身。

因此,读取一次数据可以拆成四个对象:

  • map:真正保存 key/value 的内核对象。
  • map FD:用户进程持有的文件描述符,低层 BPF 系统调用以它定位 map。
  • key:查询入口,长度必须符合 map 的 key 定义。
  • value:内核把命中的值复制到用户态缓冲区,缓冲区长度必须符合 map 的 value 定义。

一个常见误区是把 bpf_map_lookup_elem 当成用户态可以直接调用的“内核 helper”。实际上,BPF 程序里的 helper 和用户态的 BPF 系统调用接口属于不同层次;使用 libbpf 时,用户态通常调用 libbpf 的包装函数,由它完成参数检查和系统调用衔接。

用 libbpf 找到 map 并读取普通 value

如果 BPF 对象是通过 libbpf 打开的,最容易维护的路径是先按名称找到 map,再用 bpf_map__lookup_elem。下面的示例只展示读取关系,sample_map 的 key/value 结构需要与 BPF 程序中定义的布局一致。

#include 
#include 
#include 
#include 

struct sample_key {
    __u32 pid;
};

struct sample_value {
    __u64 packets;
    __u64 bytes;
};

int read_sample_map(struct bpf_object *obj, __u32 pid) {
    // 按 BPF 对象中的 map 名称取得用户态句柄,避免手工猜测 FD。
    struct bpf_map *map = bpf_object__find_map_by_name(obj, "sample_map");
    if (map == NULL) {
        fprintf(stderr, "找不到 sample_map\\n");
        return -1;
    }

    struct sample_key key = {.pid = pid};
    struct sample_value value = {0};

    // key_sz 和 value_sz 必须与 map 创建时的布局一致。
    int err = bpf_map__lookup_elem(map, &key, sizeof(key),
                                   &value, sizeof(value), 0);
    if (err != 0) {
        // -ENOENT 表示这个 key 当前没有元素,其他错误要单独处理。
        if (err == -ENOENT) {
            fprintf(stderr, "pid=%u 暂无数据\\n", pid);
        } else {
            fprintf(stderr, "读取失败:%s\\n", strerror(-err));
        }
        return err;
    }

    printf("pid=%u packets=%llu bytes=%llu\\n",
           key.pid,
           (unsigned long long)value.packets,
           (unsigned long long)value.bytes);
    return 0;
}

这里的 err 是 libbpf 风格的负错误码。libbpf 文档说明,bpf_map__lookup_elem 会检查 key 和 value 的尺寸;普通 map 命中时返回 0,并把 value 写入用户态缓冲区。示例中的结构体只是表达布局,不能直接替代你自己的 BPF map 定义。

低层文件描述符接口适合什么场景

当程序已经拿到了 map FD,或者需要直接对应内核的 BPF_MAP_LOOKUP_ELEM 命令时,可以使用 libbpf 提供的 FD 包装接口。它的调用形状更接近内核的 BPF 系统调用:传入 FD、key 指针和 value 指针。

#include 
#include 
#include 

int lookup_by_fd(int map_fd, const struct sample_key *key) {
    struct sample_value value = {0};

    // FD 必须仍然指向目标 map,value 缓冲区要按 value_size 准备。
    int err = bpf_map_lookup_elem(map_fd, key, &value);
    if (err != 0) {
        // 读取不存在的 key 通常表现为 ENOENT,不要把它当成 map 损坏。
        if (errno == ENOENT) {
            return 1;
        }
        perror("bpf_map_lookup_elem");
        return -1;
    }

    printf("packets=%llu bytes=%llu\\n",
           (unsigned long long)value.packets,
           (unsigned long long)value.bytes);
    return 0;
}

这条路径的优点是边界直接,缺点是调用方必须自己维护 FD 和结构体布局。使用 BPF skeleton 时,libbpf 会把 map 暴露成 skeleton 的字段,通常比字符串查找更不容易让 BPF 代码和用户态代码失配。

查不到、尺寸错误和并发更新怎么判断

把所有非零返回值统一打印成“读取失败”会让排障变得困难。至少要区分下面几种情况:

现象优先判断处理方向
ENOENT这个 key 当前不存在检查 key 是否已经写入,或把它当成正常的空数据分支
EINVAL 或尺寸相关错误key/value 布局、map 类型或参数不符合要求对照 map 定义检查字节数、对齐和调用 flags
EPERM权限或内核能力不足检查加载、访问 map 所需的权限,不要只重试读取
读取值变化内核 BPF 程序可能正在更新 map根据业务需要使用 per-CPU map、原子字段或快照协议

普通 hash 或 array map 的一次 lookup 能保证接口层面的读取结果,但它不自动给多字段业务对象提供“整组快照”语义。如果内核程序在更新 packets 和 bytes 的过程中,用户态恰好读取到中间状态,问题就不在 lookup 调用本身。需要一致性时,应在 value 中设计序列号、版本字段,或使用适合的原子更新方案。

读取 per-CPU map 时先算清缓冲区

per-CPU map 与普通 map 最大的区别,是一个 key 可能对应每个 CPU 的一份 value。libbpf 文档对 bpf_map__lookup_elem 的说明指出:读取 per-CPU value 时,缓冲区通常要按 value 大小乘以可能 CPU 数量准备,并按 8 字节对齐;具体使用单 CPU 还是全部 CPU,还取决于调用 flags。

#include 
#include 

int prepare_percpu_value(const struct bpf_map *map) {
    __u32 value_size = bpf_map__value_size(map);
    int cpu_count = libbpf_num_possible_cpus();
    if (cpu_count 

不要把普通 map 的 sizeof(value) 习惯直接套到 per-CPU map 上。是否需要聚合所有 CPU 的值,也取决于你的指标含义:分别展示每核数据时保留数组,展示总量时再按明确的字段规则聚合。

普通 map 与 per-CPU map 的 value 大小和 map pin 生命周期边界说明图
图2:per-CPU map 缓冲区与 map 生命周期的静态结构图,帮助理解读取空间和持久引用,不是运行截图。

用 map pin 和生命周期保证数据仍可读

map FD 是进程持有的引用。进程退出后,如果没有其他引用,map 的生命周期可能随之结束;Linux 内核文档也说明,关闭 FD 会删除该引用,进程退出时 map 通常会被清理。需要让另一个用户态进程稍后接管时,可以使用 libbpf 的 pin API 把 map 固定到 BPF 文件系统路径,再由后续程序打开这份 pin。

这里要区分两个问题:pin 解决的是“对象还在不在、另一个进程能否找到”的问题,不解决“value 是否已经写入、读取是否是一致快照”的问题。长期观测程序通常需要同时设计 map 的 pin 路径、访问权限、升级时的兼容策略,以及旧 map 的清理策略。

一张表记住用户态读取的检查顺序

检查项要确认的内容
map 句柄名称、skeleton 字段或 FD 确实指向目标 map
key字节布局、长度和字节序与 BPF 侧约定一致
value普通 map 按 value_size 分配,per-CPU map 额外考虑对齐和 CPU 数
返回值把 ENOENT、参数错误和权限错误分开处理
一致性多字段读数是否需要版本号、原子更新或聚合策略
生命周期临时 FD 还是 pin 后跨进程复用,并明确清理责任

常见延伸问题

用户态能直接读取 BPF 程序里的局部变量吗?

不能把局部变量当作公开接口。需要用户态读取的数据,应放进可访问的 BPF map 或全局数据区域,并明确其布局和生命周期。

为什么 key 明明存在却返回 ENOENT?

先检查 key 的每个字节是否一致,包括整数宽度、填充字节、字节序和字符串是否带结尾零。map 类型也可能改变 key 的解释方式。

读取 map 前一定要自己调用 bpf 系统调用吗?

不一定。libbpf 已经提供了查找、读取、更新和遍历的包装接口;直接使用系统调用只适合需要更底层控制或维护既有 FD 管理代码的场景。

总的来说,用户态读取 BPF map 的关键是把“句柄、key、value、生命周期”四件事对齐。普通 map 先用 libbpf 查找并 lookup;per-CPU map 先计算正确缓冲区;出现空结果时优先判断 key 是否存在;需要跨进程继续访问时再把 pin 纳入生命周期设计。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>