在Ubuntu系统上利用eBPF进行内核调用追踪,本质上是在不修改内核源码、不重启系统的情况下,给运行中的内核安装一个高性能的“监控探头”。这听起来复杂,但实际操作的核心思路非常直接:编写一个极简的eBPF程序,挂载到你想观察的内核函数入口或出口,然后通过专用的map数据结构将捕获的元数据传递到用户空间进行解析。整个过程不需要加载沉重的内核模块,也不会像传统审计系统那样产生海量日志导致磁盘I/O飙升。

要开始动手,首先需要确认你的Ubuntu版本。推荐使用Ubuntu 20.04 LTS或更高版本,内核版本至少要在5.4以上,因为eBPF的很多关键特性,比如对BTF(BPF Type Format)的原生支持,是在较新内核中才稳定下来的。你可以用uname -r查看当前内核版本。如果你的系统内核版本过低,可以通过apt安装linux-image-generic-hwe-20.04或更高版本的HWE内核来获得支持。

接下来是安装必备的工具链。在Ubuntu上,构建eBPF程序的核心依赖主要有这几个:clang用于将C代码编译成BPF字节码,llvm提供后端支持,libbpf-dev是用户态加载eBPF程序的核心库,而linux-tools-common和linux-tools-$(uname -r)则提供了bpftool这个极其重要的命令行工具。一条命令就能搞定:

sudo apt update && sudo apt install -y clang llvm libbpf-dev linux-tools-common linux-tools-$(uname -r) bpftool

安装完成后,务必运行bpftool version来验证工具是否就绪。bpftool不仅能检查已加载的eBPF程序,还能生成内核数据结构的骨架文件,这是实现内核调用追踪的关键一步。

生成BPF骨架文件:打通内核类型信息的通道

传统上,编写eBPF程序最头疼的问题就是内核数据结构的定义。不同内核版本中,struct task_struct或struct sk_buff的内部偏移量可能完全不同,硬编码会导致程序在不同系统间不可移植。CO-RE(一次编译,到处运行)机制解决了这个问题,而它的核心就在于BTF信息。你需要从正在运行的内核中生成一个vmlinux.h头文件,这个文件包含了内核所有数据类型的完整定义。

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

这条命令会生成一个巨大的头文件,你在编写eBPF程序时直接include它即可。有了vmlinux.h,你就能在eBPF代码中安全地访问内核函数的参数和返回值结构,而不必担心跨版本兼容性问题。这是现代eBPF开发的标准做法,也是追踪内核调用的基石。

编写eBPF程序:精确捕获内核函数调用

假设我们要追踪Ubuntu系统中所有进程对openat系统调用的使用情况。openat是用户态程序打开文件时最终会进入的内核函数。我们需要编写一个eBPF程序,挂载到该函数的入口处,记录调用它的进程ID、进程名以及要打开的文件名。

创建一个名为trace_openat.bpf.c的文件,内容如下:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

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

struct {
    __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u32));
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_openat")
int trace_openat(struct trace_event_raw_sys_enter *ctx)
{
    struct {
        u32 pid;
        u8 comm[16];
        char filename[256];
    } data = {};

    data.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    bpf_probe_read_user_str(&data.filename, sizeof(data.filename), 
                            (void *)(ctx->args[1]));

    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, 
                          &data, sizeof(data));
    return 0;
}

这段代码非常紧凑,但每一行都有明确的目的。首先,我们使用了tracepoint类型的挂载点,而不是原始的kprobe。这是因为tracepoint是内核提供的稳定接口,不会因为内核版本变化而改变函数签名。sys_enter_openat这个tracepoint会在每次openat系统调用进入内核时触发。我们从ctx结构中读取第二个参数,也就是文件名指针,然后使用bpf_probe_read_user_str安全地从用户空间拷贝字符串。最后,通过perf_event_array这个map将数据异步发送到用户空间,这种方式比轮询map更高效,适合高频事件。

编译eBPF字节码

将C代码编译成eBPF字节码的命令如下:

clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -c trace_openat.bpf.c -o trace_openat.bpf.o

这里使用了-target bpf来指定生成BPF架构的目标文件。-g选项会包含调试信息和BTF数据,这对于后续加载程序至关重要。如果编译过程中报错找不到vmlinux.h,请确认上一步的生成命令执行正确,并且头文件在当前目录下。

编写用户态加载程序:解析并展示追踪数据

有了字节码,还需要一个用户态程序来加载eBPF程序、挂载perf事件并解析输出。创建一个名为loader.c的文件:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include <sys/resource.h>
#include <linux/perf_event.h>
#include <asm/unistd.h>

struct event_data {
    u32 pid;
    u8 comm[16];
    char filename[256];
};

static void handle_event(void *ctx, int cpu, void *data, __u32 size)
{
    struct event_data *e = data;
    printf("PID: %-6d COMM: %-16s FILE: %s\n", 
           e->pid, e->comm, e->filename);
}

int main()
{
    struct perf_buffer_opts pb_opts = {};
    struct perf_buffer *pb = NULL;
    struct bpf_object *obj = NULL;
    struct bpf_program *prog = NULL;
    struct bpf_link *link = NULL;
    int err;

    struct rlimit rlim = {RLIM_INFINITY, RLIM_INFINITY};
    setrlimit(RLIMIT_MEMLOCK, &rlim);

    obj = bpf_object__open("trace_openat.bpf.o");
    if (libbpf_get_error(obj)) {
        fprintf(stderr, "Failed to open BPF object\n");
        return 1;
    }

    err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "Failed to load BPF object\n");
        goto cleanup;
    }

    prog = bpf_object__find_program_by_name(obj, "trace_openat");
    if (!prog) {
        fprintf(stderr, "Failed to find program\n");
        goto cleanup;
    }

    link = bpf_program__attach(prog);
    if (libbpf_get_error(link)) {
        fprintf(stderr, "Failed to attach program\n");
        goto cleanup;
    }

    pb = perf_buffer__new(bpf_object__find_map_fd_by_name(obj, "events"), 
                          8, handle_event, NULL, NULL, &pb_opts);
    if (libbpf_get_error(pb)) {
        fprintf(stderr, "Failed to open perf buffer\n");
        goto cleanup;
    }

    printf("Tracing openat syscalls... Press Ctrl+C to stop.\n");
    while (1) {
        perf_buffer__poll(pb, 100);
    }

cleanup:
    perf_buffer__free(pb);
    bpf_link__destroy(link);
    bpf_object__close(obj);
    return err;
}

编译这个加载器需要链接libbpf和libelf:

gcc -o loader loader.c -lbpf -lelf -lz

运行sudo ./loader,然后你在另一个终端执行ls或cat等命令,就能看到实时输出的文件打开事件。这个简单的例子揭示了eBPF追踪的核心模式:定义数据结构、编写内核态探针、通过map传递数据、用户态消费数据。

进阶追踪:捕获函数调用栈和延迟分布

仅仅知道谁调用了哪个函数还不够,很多时候我们需要知道调用栈的上下文,或者某个内核函数执行了多长时间。eBPF提供了bpf_get_stackid()函数来获取用户态或内核态的调用栈,而通过挂载到函数入口和出口两个tracepoint,可以精确计算函数执行耗时。

对于调用栈追踪,你可以在eBPF程序中添加如下逻辑:

int stack_id = bpf_get_stackid(ctx, &stack_map, BPF_F_USER_STACK);

这里的stack_map是一个类型为BPF_MAP_TYPE_STACK_TRACE的map,它存储了栈帧的ID。用户态程序可以通过bpf_map_lookup_elem获取具体的指令指针地址,再结合符号表解析出函数名。这种技术对于分析I/O延迟的根因非常有效,比如你可以直接看到是哪个上层系统调用导致了大量的磁盘写操作。

对于延迟分析,则需要创建两个eBPF程序,一个挂载在函数入口,记录时间戳和上下文信息到一个hash map中,key可以用进程ID加线程ID的组合;另一个挂载在函数出口,从map中查找对应的时间戳,计算差值后输出到perf buffer。这种方式能统计出微秒级的延迟分布,比传统的ftrace直方图更加灵活,因为你可以在eBPF程序中自定义过滤条件,只追踪耗时超过某个阈值的异常情况。

处理高频事件:减少开销的实用技巧

在实际生产环境中,像openat这样的系统调用频率可能高达每秒数万次,直接追踪所有事件会带来不可忽略的性能开销。eBPF提供了几种机制来应对这个问题。一种是在eBPF程序中使用bpf_get_smp_processor_id()结合per-CPU map来做聚合统计,只在用户态定期读取汇总结果,而不是每事件都发送。另一种是利用BPF_MAP_TYPE_RINGBUF替代perf event array,ring buffer在内存效率和跨CPU同步方面表现更好,适合极高吞吐量的场景。

还可以在挂载点之前做过滤。比如,只追踪特定进程或特定文件路径前缀的调用。在eBPF代码中,你可以通过bpf_get_current_pid_tgid()获取PID,与预设的map中的值比较,不匹配则直接返回。这种在内核态完成的过滤几乎不消耗额外资源,远比在用户态丢弃事件要高效。

验证与调试:确认追踪的准确性

完成程序编写后,验证其正确性至关重要。bpftool prog list可以列出所有已加载的eBPF程序及其ID。使用bpftool prog dump xlated id <ID>可以查看翻译后的指令序列,确认编译器没有生成意外的辅助函数调用。如果程序加载失败,内核的verifier会输出详细的错误日志,你可以通过查看/sys/kernel/debug/tracing/trace_pipe来获取这些信息。

另一个常见的陷阱是内存访问越界。eBPF验证器要求所有内存访问都必须有明确的边界检查。在访问用户态传入的字符串或数组时,务必使用bpf_probe_read_user_str这类带有长度限制的辅助函数,而不是直接解引用指针。这既是安全要求,也是程序能通过验证的前提。

通过这套完整的工作流,你可以在Ubuntu系统上构建出功能强大的内核调用追踪工具。无论是排查间歇性的性能抖动,还是分析安全事件的根本原因,eBPF都能提供传统工具难以企及的细粒度和灵活性。关键在于理解eBPF不是魔法,而是一套严谨的接口规范,只要遵循CO-RE原则和验证器的约束,就能稳定地在生产环境中运行。