Linux eBPF map保存内核统计
来源:17golang原创
时间:2026-11-15 16:39:20 167浏览 收藏
如果你需要让 eBPF 程序持续保存包数、字节数或错误次数,最直接的做法不是把结果写进日志,而是定义一个 BPF map:内核程序按约定的 key 找到 value 并更新,用户态程序再通过 map 的文件描述符读取和聚合。对高频计数,优先考虑 per-CPU map;对按连接、进程或其他动态键保存状态,再考虑 HASH。
官方地址:https://www.kernel.org/doc/html/latest/bpf/maps.html
把 map 当成“内核程序与用户态共同遵守的数据结构契约”:先固定 key/value 布局,再选择 map 类型,最后明确用户态读取时是否需要合并每个 CPU 的副本。
先分清 map 保存的是什么
Linux 内核文档把 BPF map 定义为可在 eBPF 程序之间、以及内核与用户态之间共享的通用存储。它至少有类型、最大元素数、key 大小和 value 大小等属性;不同类型还会增加自己的语义。
因此,下面三类需求不要混在一起:
| 需求 | 更适合的方向 | 关键边界 |
|---|---|---|
| 固定下标的累计计数 | BPF_MAP_TYPE_ARRAY 或 per-CPU 变体 | key 空间固定,max_entries 决定容量 |
| 按进程、连接或对象保存状态 | BPF_MAP_TYPE_HASH 或 LRU 变体 | 动态 key 需要关注容量和淘汰策略 |
| 把事件连续送到用户态 | ring buffer、perf event array 等专用 map | 它们是事件通道,不等同于可随时 lookup 的计数表 |
本文只沿着前两类“保存统计值”的路径展开,事件流的消费模型另有一套接口。
用 BTF 风格声明 key 与 value
现代 libbpf 代码通常把 map 放在 .maps 段,并用 __type 描述 key/value。这样加载器能够保留 BTF 类型信息,用户态生成的 skeleton 也更容易与结构体保持一致。
// 这个 value 同时保存包数和字节数,字段顺序是内核与用户态的共同契约
struct traffic_stat {
__u64 packets;
__u64 bytes;
};
// 固定 CPU 下标的统计表;每个 key 对应一个固定位置
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(key, __u32);
__type(value, struct traffic_stat);
__uint(max_entries, 64); // 只允许 0 到 63 的 CPU 统计槽位
} traffic_stats SEC(".maps");
这段声明表达了四个事实:map 类型是 per-CPU array,key 是 32 位整数,value 是两个 64 位计数器,最多保留 64 个 key。它没有自动探测机器 CPU 数,也没有承诺 64 个槽位一定都被使用;生产代码应让容量与采集对象的实际范围一致。

用 per-CPU map 保存高频计数
多个 CPU 同时更新一个共享计数器时,需要考虑竞争和同步开销。per-CPU map 为每个逻辑 CPU 保留自己的 value 副本:当前 CPU 上运行的 eBPF 程序更新本地副本,用户态读取时再把各副本相加。
内核侧更新可以保持很小:
// 在挂载点统计一次数据包;真实项目应按上下文选择可用字段
SEC("tracepoint/net/netif_receive_skb")
int count_packet(void *ctx)
{
__u32 key = 0;
struct traffic_stat *stat;
// lookup 返回当前 CPU 对应的 value,找不到时不要解引用空指针
stat = bpf_map_lookup_elem(&traffic_stats, &key);
if (!stat) {
return 0; // 统计失败不应影响被观测的内核路径
}
// per-CPU 副本只由当前 CPU 更新,避免在这里做跨 CPU 汇总
stat->packets += 1;
stat->bytes += 1500; // 示例固定值仅表达字段更新,不代表真实包长
return 0;
}
这里的固定 1500 只是为了突出 value 的更新位置,不能当作真实包长。实际程序应从当前 hook 的上下文或合适 helper 中取得长度,并确认该上下文允许调用相应 helper。
如果统计对象是动态的,例如按进程 ID 保存,使用 HASH 更自然:
// 动态 key 适合按进程或连接保存状态,但容量需要提前规划
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u32); // 例如进程 ID
__type(value, struct traffic_stat);
__uint(max_entries, 4096); // 达到容量后不能继续假设有无限新 key
} process_stats SEC(".maps");

用户态读取时要先识别 value 形态
普通 ARRAY 或 HASH 的一次 lookup 通常得到一个 value;per-CPU map 则对应多个 CPU 副本。用户态读取库会把这些副本暴露成按 CPU 排列的数据,应用层必须遍历并累加,而不能只取第一个元素。
// 伪代码展示读取边界;具体函数名以使用的 libbpf 封装为准
__u32 key = 0;
struct traffic_stat *per_cpu_values = NULL;
__u64 packets = 0;
__u64 bytes = 0;
// 读取 key 对应的所有 CPU 副本,并取得当前 CPU 数量
int err = read_percpu_value(map_fd, &key, &per_cpu_values, &cpu_count);
if (err != 0) {
// 把“没有 key”和“系统调用失败”分别记录,便于排查采集空洞
report_lookup_error(err);
return;
}
for (int cpu = 0; cpu
上面的 read_percpu_value 是为了说明接口边界的占位函数,不是某个固定库的 API。使用 libbpf、Go 封装或其他加载器时,应查对应版本的读取函数和内存布局。文章真正要保留的是:读取成功后遍历每个 CPU,聚合前不要把单个副本当成全机总量。
容量、生命周期和一致性边界
容量:max_entries 是 map 的设计约束。ARRAY 的 key 空间固定,HASH 还要考虑动态 key 进入后是否可能达到上限;如果业务需要淘汰,应在建表时选择合适的 LRU 变体,而不是等满表后再补救。
生命周期:map 最终由用户态加载器创建并持有文件描述符。普通打开的 map 在相关文件描述符关闭后会进入清理路径;如果希望程序重启后继续找到同一个对象,需要设计 pinning 和固定的 bpffs 路径,并处理旧对象与新结构不兼容的情况。
一致性:用户态把多个 CPU 副本逐个读取并相加时,只能把结果理解成一个采样窗口内的近似快照。内核计数可能正在继续变化;如果业务要求严格的事务性快照,就不能仅凭一次 map lookup 宣称所有字段来自同一时刻。
布局:内核和用户态必须对 key/value 的大小、字段顺序和对齐有共同理解。跨语言读取时,不要直接把语言层面的对象布局当成 BPF value 布局,应该使用明确的定长字段和共享定义。
一条实用的选型口诀
固定下标的总量用 ARRAY;多个 CPU 高频写入用 PERCPU_ARRAY;动态对象键用 HASH;需要把一串事件送到用户态则另选 ring buffer 或 perf event array。无论选择哪一种,先写清楚四件事:key 是什么、value 有多大、最多多少条、用户态是否需要聚合。
当统计结果异常时,按这个顺序排查:先确认 map 类型与 key 是否匹配,再确认 lookup 是否真的成功,然后确认 per-CPU 副本是否全部累加,最后检查 max_entries、pinning 和用户态结构体布局。这样比先怀疑 eBPF 程序“没有执行”更容易找到问题所在。
常见疑问
eBPF map 能不能直接当日志队列?
不应把所有 map 都当日志队列。ARRAY、HASH 更适合可按 key 查询的状态;连续事件应使用专用事件通道,并采用对应的消费模型。
为什么读取 per-CPU map 后数值变大了?
因为用户态看到的是多个 CPU 副本。把它们相加得到的是主机范围总量;如果只想看某个 CPU,应只读取对应副本。
HASH 满了以后会自动扩容吗?
不能把自动扩容当作 map 的默认能力。创建时的 max_entries 是边界,动态键很多时应提前估算容量,或选择带淘汰语义的 map 类型。
总结
Linux eBPF map 的核心价值是把内核侧采集与用户态消费连接成一个明确的数据契约。用 BTF 风格声明 key/value,用 per-CPU map 承载高频计数,再在用户态完整读取并聚合,便能得到可维护的统计路径。最后别忘了把 map 类型、容量、生命周期和快照一致性写进设计说明,否则“能读到数”并不等于“数就能被正确解释”。
-
385 收藏
-
106 收藏
-
411 收藏
-
279 收藏
-
209 收藏
-
308 收藏
-
353 收藏
-
249 收藏
-
380 收藏
-
484 收藏
-
227 收藏
-
173 收藏
-
267 收藏
-
文章 · linux | 16小时前 | 命令行工具 · linux运维 · 故障排查 · Linux systemd journalctl 日志排查 启动日志 Boot ID systemd-journald311 收藏
-
文章 · linux | 16小时前 | linux运维 · 文件系统 · Linux 文件句柄 name_to_handle_at open_by_handle_at file_handle mount_id340 收藏
-
351 收藏
-
490 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习