内核debug 之 ebpf 的几个开发工具

eBPF (extended Berkeley Packet Filter) 是一种革命性的内核技术,它允许我们在不修改内核源码或加载内核模块的情况下,安全、高效地在内核中执行一小段“沙盒”程序。这使得 eBPF 在网络、安全、性能分析和内核调试等领域大放异彩。

选择合适的开发工具是高效利用 eBPF 的关键。不同的工具在易用性、灵活性、性能和可移植性之间做出了不同的权衡。

以下是三个最核心、最具代表性的 eBPF 开发工具/框架:

  1. BCC (BPF Compiler Collection)
  2. bpftrace
  3. libbpf + BPF CO-RE

1. BCC (BPF Compiler Collection)

BCC 是一个用于创建高效内核追踪和操作程序的工具包,它包含了大量的实用工具和示例。它通过提供 Python/Lua 等高级语言的前端,极大地简化了 eBPF 程序的开发。

开发方式

BCC 的开发模式是“前端脚本 + 内嵌C代码”。

  1. eBPF C代码:你将eBPF的内核态程序(用C语言编写)作为一个字符串嵌入到你的Python(或Lua)脚本中。
  2. Python前端
    • 使用BCC提供的API来加载和编译上述C代码字符串。BCC会在运行时调用内置的LLVM/Clang库将C代码编译成BPF字节码。
    • 将编译好的BPF程序附加(attach)到内核的指定挂载点(如 kprobe, tracepoint, network socket 等)。
    • 创建和管理 BPF Maps(eBPF程序与用户态程序之间通信的桥梁)。
    • 从BPF Maps中读取数据,或者通过 perf_buffer 接收事件,然后在Python脚本中对这些数据进行处理、聚合和展示。

代码示例(一个简单的execsnoop

#!/usr/bin/python3
from bcc import BPF

# 1. 内嵌的eBPF C代码
prog = """
#include <linux/sched.h>

// 定义用于数据传输的结构体
struct data_t {
    u32 pid;
    char comm[TASK_COMM_LEN];
};

// 定义一个BPF Map,类型为perf event output
BPF_PERF_OUTPUT(events);

// kprobe挂载到sys_execve函数执行的入口
int hello_exec(struct pt_regs *ctx) {
    struct data_t data = {};

    data.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    // 将数据提交到perf buffer,用户态程序可以从中读取
    events.perf_submit(ctx, &data, sizeof(data));

    return 0;
}
"""

# 2. Python用户态程序
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("execve"), fn_name="hello_exec")

print("Tracing execve syscalls... Ctrl-C to end.")

# 定义处理从内核收到的数据的回调函数
def print_event(cpu, data, size):
    event = b["events"].event(data)
    print(f"PID: {event.pid}   Comm: {event.comm.decode('utf-8')}")

# 打开perf buffer并开始轮询数据
b["events"].open_perf_buffer(print_event)
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

使用场景

  • 快速原型开发和学习:BCC极大地降低了eBPF的入门门槛。如果你熟悉Python,可以很快上手写出有用的工具。
  • Ad-hoc系统诊断:当你需要快速编写一个脚本来诊断线上特定问题时,BCC非常方便。例如,追踪特定函数的调用、文件打开、网络连接等。
  • 功能丰富的现成工具集:BCC项目自身就提供了一百多个开箱即用的工具(如 execsnoop, tcplife, biolatency 等),这些工具本身就是解决各种性能问题的利器,也是极佳的学习范例。

优点

  • 开发效率高,上手快。
  • Python生态丰富,方便进行数据后处理和可视化。
  • 自带大量实用工具。

缺点

  • 依赖笨重:目标机器上需要安装完整的LLVM/Clang工具链以及内核头文件(kernel-headers)。这对于生产环境的大规模部署来说是个挑战。
  • 可移植性差:由于在目标机器上实时编译,如果内核版本变化导致数据结构(如 struct task_struct)变动,代码可能需要修改才能在不同内核上运行。
  • 性能开销:Python的解释执行和数据拷贝开销相较于纯C/C++的用户态程序要大。

2. bpftrace

bpftrace 是一种用于内核动态追踪的高级语言。它的语法深受 awkC 的启发,并借鉴了 DTraceSystemTap 等传统追踪工具的设计。它专注于“一句话(one-liner)”解决问题。

开发方式

bpftrace 的开发模式是“编写专用的追踪脚本”。你不需要写C代码,也不需要写Python。你只需要编写 bpftrace 语言脚本,然后通过 bpftrace 命令行工具执行即可。

bpftrace 脚本的基本结构是: probe /filter/ { action }

  • probe: 指定要探测的事件,例如 kprobe:do_sys_openat2 (内核函数入口), tracepoint:syscalls:sys_enter_open (静态跟踪点), uprobe:/bin/bash:readline (用户态函数) 等。
  • filter: 可选的过滤条件,只有满足条件的事件才会触发action。例如 /pid == 1234/
  • action: 事件触发时要执行的操作,例如打印信息、记录到map中。bpftrace 提供了丰富的内置变量(如 pid, comm, arg0-argN)和函数(如 printf, hist, count)。

代码示例

  1. 一行命令追踪所有进程打开文件的系统调用: sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s (%d) opening %s\n", comm, pid, str(args->filename)); }'
  2. 统计每个进程执行 execve 的次数: sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { @counts[comm]++; }' (退出时会自动打印 @counts 这个map的内容)
  3. 显示VFS读操作的延迟直方图:
    bash sudo bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs; } kretprobe:vfs_read /@start[tid]/ { @latency = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'

使用场景

  • 实时、交互式的系统分析:当你在线上发现问题,需要快速回答“谁在做某事?”、“某操作为何这么慢?”这类问题时,bpftrace 是无敌的。
  • 性能瓶颈排查:通过其强大的聚合功能(count, sum, avg, hist),可以轻松地分析延迟、统计事件频率。
  • 系统行为探索:对于不熟悉的子系统,可以用 bpftrace 探索其函数调用关系和行为模式。

优点

  • 极其简洁:表达能力强,通常几行代码就能实现BCC几十行代码才能完成的功能。
  • 零样板代码:专注于追踪逻辑,无需关心编译、加载、map创建等细节。
  • 交互性强:非常适合在命令行中即时使用。

缺点

  • 功能限制:虽然强大,但它主要面向“追踪与聚合”,对于需要复杂逻辑控制、精细化状态管理、或与外部系统深度交互的应用,bpftrace 会显得力不从心。
  • 依赖:和BCC一样,通常也需要LLVM/Clang和内核头文件。

3. libbpf + BPF CO-RE (Compile Once – Run Everywhere)

这是目前业界推荐的、用于构建生产级eBPF应用的现代化方法。它解决了BCC和bpftrace在依赖和可移植性上的核心痛点。

开发方式

libbpf 的开发模式是“内核态与用户态代码分离,预编译,动态加载”。

  1. eBPF内核态程序 (.bpf.c):
    • 纯C语言编写,但包含 libbpf 提供的一些头文件(如 bpf_helpers.h)。
    • 使用 SEC("...") 宏来定义程序段,指定程序的类型和挂载点。
    • 通过 struct { ... } BPF_HASH_MAP(...) 的方式声明 maps。
    • 核心是CO-RE:代码不直接 #include 内核头文件,而是依赖于内核提供的 BTF (BPF Type Format) 类型信息。通过 bpf_core_read() 等辅助函数来访问数据结构成员,libbpf 会在加载时根据目标内核的BTF信息自动调整偏移量,实现可移植性。
  2. 用户态加载程序 (.c, .cpp, .go, .rust …):
    • 一个常规的用户态应用程序。
    • 首先,使用 clang.bpf.c 文件编译成一个轻量的 .bpf.o 对象文件。这个步骤可以在开发机上完成。
    • 用户态程序中使用 libbpf 库提供的API函数(如 bpf_object__open, bpf_object__load, bpf_program__attach, bpf_map__lookup_elem 等)来加载和操作 .bpf.o 文件。
    • 这个用户态程序负责整个eBPF应用的生命周期管理。

代码结构示例

minimal.bpf.c (内核态程序)

#include "vmlinux.h" // 由bpftool生成的、包含BTF信息的头文件
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, u32);
    __type(value, u64);
} my_map SEC(".maps");

SEC("kprobe/sys_execve")
int BPF_KPROBE(handle_exec)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *count, one = 1;

    count = bpf_map_lookup_elem(&my_map, &pid);
    if (count) {
        __sync_fetch_and_add(count, 1);
    } else {
        bpf_map_update_elem(&my_map, &pid, &one, BPF_ANY);
    }
    return 0;
}
char LICENSE[] SEC("license") = "GPL";

minimal.c (用户态程序)

#include <bpf/libbpf.h>
#include "minimal.skel.h" // 由bpftool根据.bpf.c自动生成的骨架头文件

int main(int argc, char **argv)
{
    struct minimal_bpf *skel;
    int err;

    // 打开、加载、验证BPF程序
    skel = minimal_bpf__open_and_load();
    if (!skel) {
        fprintf(stderr, "Failed to open and load BPF skeleton\n");
        return 1;
    }

    // 附加到kprobe
    err = minimal_bpf__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to attach BPF skeleton\n");
        goto cleanup;
    }

    printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
           "to see output of the BPF programs.\n");
    // ... 在这里可以轮询map,处理数据 ...

cleanup:
    minimal_bpf__destroy(skel);
    return -err;
}

使用场景

  • 生产环境的可观测性(Observability)和安全工具:例如 Prometheus eBPF exporter, Parca, Cilium, Falco 等项目都深度使用 libbpf + CO-RE。
  • 需要长期运行的守护进程(Daemon):构建需要稳定运行、资源占用可控的监控或安全代理。
  • 大规模部署:当你需要将一个eBPF工具部署到成百上千台不同内核版本的服务器上时,CO-RE是唯一的选择。

优点

  • 极致的可移植性(CO-RE):真正做到“一次编译,到处运行”,无需在目标机上重新编译。
  • 最小化依赖:目标机器上只需要启用BTF的内核(现代主流发行版内核都已支持),无需LLVM/Clang和内核头文件。
  • 高性能:用户态程序可以用C/C++/Go/Rust等高性能语言编写,开销极小。
  • 强大的生态libbpf 已经成为事实上的标准库,得到了内核社区的大力支持。

缺点

  • 学习曲线陡峭:需要理解eBPF程序的生命周期、libbpf的API、BTF和CO-RE的概念,开发流程更复杂。
  • 开发效率较低:相较于bpftrace的“一句话”,libbpf需要编写更多的样板代码。

总结与选择建议

特性 / 工具BCCbpftracelibbpf + BPF CO-RE
易用性中 (Python接口友好) (专用高级语言)低 (C/C++ + API)
开发效率高 (快速原型)极高 (即时分析)低 (工程化)
运行时依赖 (LLVM/Clang, Kernel Headers) (LLVM/Clang, Kernel Headers) (只需内核支持BTF)
可移植性极好 (CO-RE)
性能中 (Python开销)中 (解释器开销) (原生C/C++/Go/Rust)
主要应用场景学习、工具原型、快速诊断线上即时调试、性能瓶颈排查生产级监控/安全产品、大规模部署

本文版权归原作者zhaofujian所有,采用 CC BY-NC-ND 4.0 协议进行许可,转载请注明出处。

发表评论