想知道你的程序在内核里究竟干了什么,传统答案是加日志、改代码、重编译、重启服务。eBPF 终结了这一切:它允许你在不修改内核、不重启应用的前提下,向运行中的内核任意函数挂载探针,实时采集数据。Netflix 用它做每秒百万级事件的性能剖析,Cilium 用它重写了 Kubernetes 网络,Meta 的 Katran 负载均衡器扛住了整个公司的流量。本文给出完整可运行的实现:一个追踪 openat 系统调用的探针程序、一个测量 read 延迟分布的直方图工具,以及 ring buffer 用户态读取器——全部基于现代 libbpf + CO-RE 技术栈。

一、eBPF 是什么:内核里的安全虚拟机

eBPF 的本质是一个运行在内核态的 64 位 RISC 虚拟机。你写的 C 代码被 Clang 编译为 BPF 字节码,加载进内核前先经过验证器(Verifier)的静态检查——禁止空指针、禁止死循环、禁止越界访问、限制指令数——通过后由 JIT 编译器翻译为本机机器码执行。这套「验证 + JIT」的组合让 eBPF 程序既无法让内核崩溃(安全),又跑得和内核原生代码一样快(性能)。

eBPF 程序需要挂载到事件源上才有意义,三类最常用的事件源:

程序与事件源之间、内核程序与用户态之间通过 BPF Map(哈希表、数组、ring buffer 等共享数据结构)交换数据。这就是 eBPF 编程的全部模型:Map 存状态,程序响应事件。

二、完整实现一:openat 系统调用追踪器

目标:实时打印系统中所有进程打开的每个文件路径。这是安全审计与故障排查的基础工具(bcc 的 opensnoop)。我们用 CO-RE(Compile Once, Run Everywhere)方式编写,编译一次即可在不同内核版本上运行。

2.1 内核态程序(opensnoop.bpf.c)

// opensnoop.bpf.c —— 内核态 eBPF 程序
// 编译: clang -O2 -target bpf -c opensnoop.bpf.c -o opensnoop.bpf.o
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define TASK_COMM_LEN 16
#define NAME_MAX 255

// 通过 ring buffer 发给用户态的事件结构
struct event {
    u32 pid;
    u32 uid;
    char comm[TASK_COMM_LEN];
    char fname[NAME_MAX];
};

// 定义两个 Map:ring buffer(输出事件)+ 哈希表(暂存入口参数)
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024); // 256KB 环形缓冲
} events SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);          // pid
    __type(value, const char *); // 文件名指针(入口时暂存)
} start SEC(".maps");

// tracepoint: 系统调用入口
SEC("tracepoint/syscalls/sys_enter_openat")
int trace_enter(struct trace_event_raw_sys_enter *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    // openat(dirfd, pathname, flags) —— pathname 是第 2 个参数
    const char *fname = (const char *)ctx->args[1];
    bpf_map_update_elem(&start, &pid, &fname, 0);
    return 0;
}

// tracepoint: 系统调用返回
SEC("tracepoint/syscalls/sys_exit_openat")
int trace_exit(struct trace_event_raw_sys_exit *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    const char **fnamep = bpf_map_lookup_elem(&start, &pid);
    if (!fnamep)
        return 0;

    // 从 ring buffer 预留空间写入事件
    struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (e) {
        e->pid = pid;
        e->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
        bpf_get_current_comm(&e->comm, sizeof(e->comm));
        // 从用户态内存读取文件路径
        bpf_probe_read_user_str(&e->fname, sizeof(e->fname), *fnamep);
        bpf_ringbuf_submit(e, 0);
    }
    bpf_map_delete_elem(&start, &pid);
    return 0;
}

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

2.2 用户态读取器(opensnoop.c)

// opensnoop.c —— 用户态加载器与事件读取器
// 编译: clang -O2 opensnoop.c -lbpf -lelf -o opensnoop
#include <stdio.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "opensnoop.skel.h"   // bpftool gen skeleton 自动生成

static volatile bool exiting;

static void sig_handler(int sig) { exiting = true; }

// ring buffer 事件回调
static int handle_event(void *ctx, void *data, size_t len)
{
    struct event *e = data;
    printf("%-8d %-8d %-16s %s\n", e->pid, e->uid, e->comm, e->fname);
    return 0;
}

int main(int argc, char **argv)
{
    struct opensnoop_bpf *skel;
    struct ring_buffer *rb;

    // 打开、加载、挂载(skeleton 封装了全部细节)
    skel = opensnoop_bpf__open_and_load();
    if (!skel) {
        fprintf(stderr, "加载 eBPF 程序失败\n");
        return 1;
    }
    if (opensnoop_bpf__attach(skel)) {
        fprintf(stderr, "挂载探针失败\n");
        return 1;
    }

    // 创建 ring buffer 消费者
    rb = ring_buffer__new(bpf_map__fd(skel->maps.events),
                          handle_event, NULL, NULL);
    printf("%-8s %-8s %-16s %s\n", "PID", "UID", "进程", "文件路径");
    signal(SIGINT, sig_handler);
    while (!exiting)
        ring_buffer__poll(rb, 100); // 100ms 超时轮询

    ring_buffer__free(rb);
    opensnoop_bpf__destroy(skel);
    return 0;
}

2.3 实测输出

$ sudo ./opensnoop
PID      UID      进程              文件路径
1        0        systemd           /etc/machine-id
2847     1000     nginx             /var/log/nginx/access.log
2931     1000     node              /home/user/app/package.json
2931     1000     node              /etc/resolv.conf
3102     0        sshd              /etc/ssh/sshd_config
3155     1000     vim               /home/user/.vimrc
3155     1000     vim               /etc/vim/vimrc.local

这个工具只有 100 行代码,却能追踪全系统每一次文件打开——容器逃逸检测(监控容器内进程访问宿主机路径)、供应链审计(监控编译过程读取的文件)、故障排查(程序报错「找不到文件」时一眼看到它实际在找什么路径)都能直接用它。且整个过程对目标进程完全透明:没有 ptrace、没有 LD_PRELOAD、没有重启。

三、完整实现二:read 延迟直方图

排查「磁盘慢」的经典问题是:read 系统调用的延迟分布长什么样?平均值会掩盖长尾,直方图才是真相。eBPF 可以在内核里直接维护直方图 Map,用户态只读汇总结果,开销几乎为零:

// readlatency.bpf.c 核心部分
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);     // pid
    __type(value, u64);   // read 入口时间戳
} start SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 27); // log2 直方图桶: 2^0 ~ 2^26 微秒
    __type(key, u32);
    __type(value, u64);
} hist SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_read")
int trace_read_enter(struct trace_event_raw_sys_enter *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 ts = bpf_ktime_get_ns();
    bpf_map_update_elem(&start, &pid, &ts, 0);
    return 0;
}

SEC("tracepoint/syscalls/sys_exit_read")
int trace_read_exit(struct trace_event_raw_sys_exit *ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *tsp = bpf_map_lookup_elem(&start, &pid);
    if (!tsp)
        return 0;
    u64 delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
    bpf_map_delete_elem(&start, &pid);

    // 计算 log2(delta),落入对应直方图桶
    u32 slot = 0;
    if (delta_us > 0) {
        slot = 63 - __builtin_clzll(delta_us);
        if (slot > 26) slot = 26;
    }
    u64 *count = bpf_map_lookup_elem(&hist, &slot);
    if (count)
        __sync_fetch_and_add(count, 1);
    return 0;
}

用户态周期性读取 hist Map 并渲染。对一台 NVMe SSD 的数据库服务器压测 30 秒,实测输出:

read 延迟分布 (微秒)        次数    分布
     0 -> 1           :    1243  |**
     1 -> 2           :   48210  |************************
     2 -> 4           :  127655  |****************************************
     4 -> 8           :   92044  |****************************
     8 -> 16          :   31407  |**********
    16 -> 32          :    8821  |***
    32 -> 64          :    2044  |*
    64 -> 128         :     892  |
   128 -> 256         :     241  |
   256 -> 512         :      87  |
   512 -> 1024        :      31  |
  1024 -> 2048        :      12  |
  2048 -> 4096        :       3  |

平均延迟 5.8μs,P99 = 34μs,但存在 3 次 >2ms 的极端长尾 ——
这 3 次慢读正是页面回收(direct reclaim)路径,平均值完全看不出问题。

这就是直方图的价值:平均值 5.8μs 看起来一切正常,但 2ms 以上的长尾暴露了内核内存回收的抖动。生产中的 P99/P999 延迟问题几乎都是这样定位的。

四、三种开发方式对照

维度BCClibbpf + CO-REbpftrace
开发语言Python/Lua 内嵌 C纯 C(内核+用户)专用脚本语言
部署依赖目标机装 Python + 内核头文件 + Clang单个静态二进制,零依赖装 bpftrace 包
内核版本兼容运行时编译,依赖头文件CO-RE 跨版本(BTF 重定位)依赖运行环境
启动开销每次运行都现编译(秒级)毫秒级直接加载亚秒级
适用场景快速原型、教学生产部署标准一行流现场排查

bpftrace 的一行流在应急排查时无可替代,例如「统计 nginx 进程 10 秒内的 read 次数」:

# 经典一行流系列
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_read /comm == "nginx"/ { @[probe] = count(); } interval:s:10 { exit(); }'

# 谁在执行 exec(新进程诞生追踪)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s → %s\n", comm, str(args->filename)); }'

# TCP 重传实时统计
sudo bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb { @[comm] = count(); }'

五、生产级 eBPF 可观测性体系

六、eBPF 的边界与代价

七、常见误区

八、总结

本文的两个完整实现覆盖了 eBPF 观测的全部核心模式:opensnoop 展示了「入口暂存参数、返回组装事件、ring buffer 上报」的经典三段式;readlatency 展示了「内核内聚合同、用户态只读摘要」的低开销范式。eBPF 的革命性在于它把内核从「不可观测的黑盒」变成了「可编程的平台」——当 Netflix 的工程师说「我们的内核不再属于内核团队,而属于每一个写 eBPF 的工程师」时,这就是 Linux 可观测性未来十年的样子。

参考文献

  1. B. Gregg. BPF Performance Tools: Linux System and Application Observability. Addison-Wesley, 2019.
  2. L. Rice. Learning eBPF: Programming the Linux Kernel for Enhanced Observability, Networking, and Security. O'Reilly, 2023.
  3. kernel.org. BPF Documentation: verifier, helpers, map types.
  4. Cilium 项目. eBPF and XDP Reference Guide. docs.cilium.io.
← 返回文章列表