一、引言:为什么需要内核动态追踪?

在生产环境中,我们经常遇到这类难题:某个进程偶发性地卡顿数毫秒,某个 syscall 在某些条件下耗时异常升高,某个内核函数被调用的频率远超预期。传统的 strace、ltrace 等工具由于性能开销巨大,几乎无法在生产环境常态化使用。

eBPF(Extended Berkeley Packet Filter)的出现彻底改变了这一格局。它允许在内核空间安全地运行用户定义的字节码,无需修改内核源码、无需加载内核模块,性能开销极低(通常低于 1% CPU)。eBPF 已经成为 Linux 生态中可观测性、网络和安全领域的事实标准。

本文将深入 eBPF 的两大核心探针机制 —— Kprobes 和 Tracepoints,通过完整的实战案例展示如何从零编写、加载、运行一个内核追踪程序。

二、Kprobes 与 Tracepoints:机制对比与选型

2.1 Kprobes:任意内核函数的动态探针

Kprobes 允许在几乎任何内核函数的入口(kprobe)、出口(kretprobe)或任意指令位置插入探针。当 CPU 执行到探针位置时,会触发预设的回调函数,从而获取寄存器状态、函数参数、返回值等信息。

其底层实现依赖于指令替换:内核将探针位置的原始指令替换为 int3(x86)或 brk(ARM)断点指令,触发中断后保存上下文,跳转到回调函数,然后再恢复执行。

Kprobes 的优势:

  • 可以追踪任何导出的内核函数和未导出的函数(通过 kallsyms_lookup_name 查找地址)
  • 灵活性极高,理论上可对任意指令位置插桩
  • 适合追踪函数参数和返回值(通过寄存器上下文获取)

Kprobes 的劣势:

  • 依赖具体内核版本的函数符号和 ABI,内核升级后可能导致探针失效
  • 指令替换存在极微小的竞态安全风险(尽管内核已做了完善处理)
  • 性能开销比 Tracepoints 略高(因为断点中断的上下文切换更重)

2.2 Tracepoints:内核开发者的"埋点"

Tracepoints 是内核开发者在关键代码路径中预先插入的静态探针。它们通过 TRACE_EVENT 宏定义,编译时即嵌入内核,提供稳定的接口和数据格式。

Tracepoints 的优势:

  • 接口稳定:内核版本升级时 Tracepoints 保持向后兼容,不会像函数签名那样变动
  • 性能开销极低:通过静态分支优化(likely/unlikely + jump label),未启用时仅一条 nop 指令
  • 数据结构明确:每个 Tracepoint 有固定的 format 定义,可直接结构体解析

Tracepoints 的劣势:

  • 仅覆盖内核开发者认为关键的代码路径,"

2.3 选型建议

能用 Tracepoints 就优先用 Tracepoints。只有在以下情况下才选择 Kprobes:

  • 需要追踪的路径没有对应的 Tracepoints(如驱动内部函数、非关键子系统)
  • 需要检查特定内核函数的参数或数据结构
  • 问题诊断阶段需要极度灵活性(后续可固化为 Tracepoints)

三、环境准备与依赖

3.1 内核版本与配置

eBPF 需要 Linux 4.16+ 内核以获得完整功能,推荐 5.8+ 以使用 BTF(BPF Type Format)带来的高级特性:

# 检查内核版本
uname -r
# 5.15.0-56-generic

# 确认 eBPF 相关配置
grep CONFIG_BPF /boot/config-$(uname -r)
# CONFIG_BPF=y
# CONFIG_BPF_SYSCALL=y
# CONFIG_BPF_JIT=y
# CONFIG_HAVE_EBPF_JIT=y

# BTF 支持(关键!5.11+ 自动启用)
ls /sys/kernel/btf/vmlinux

3.2 工具链安装

# Ubuntu/Debian
sudo apt update && sudo apt install -y   clang llvm libelf-dev libcap-dev   linux-tools-$(uname -r)   linux-headers-$(uname -r)   bpftool

# 安装 Rust + aya(可选,适合 Rust 用户)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install aya-tool libbpf-cli

四、实战一:使用 Kprobe 追踪 do_sys_openat2

4.1 需求场景

我们需要监控系统上所有进程的文件打开操作,记录进程名、PID、文件名和打开耗时。do_sys_openat2 是 Linux 5.6+ 中 openat 系统调用的底层实现函数。

4.2 编写 eBPF C 程序

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

#define MAX_FILENAME 256

struct event {
    u32 pid;
    u32 uid;
    u64 ts;
    char comm[16];
    char filename[MAX_FILENAME];
    int ret;
};

struct {
    __uint(type, BPF_MAP_TYPE_PERF_OUTPUT);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(struct event));
    __uint(max_entries, 10240);
} events SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(key_size, sizeof(u64));
    __uint(value_size, sizeof(u64));
    __uint(max_entries, 10240);
} start SEC(".maps");

// --- kprobe: 函数入口 ---
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(trace_enter, int dfd, const char *filename,
               struct open_how *how)
{
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u64 ts = bpf_ktime_get_ns();

    bpf_map_update_elem(&start, &pid_tgid, &ts, BPF_ANY);
    return 0;
}

// --- kretprobe: 函数出口 ---
SEC("kretprobe/do_sys_openat2")
int BPF_KREPROBE(trace_exit, int ret)
{
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u64 *tsp = bpf_map_lookup_elem(&start, &pid_tgid);
    if (!tsp) return 0;

    struct event e = {};
    e.pid = pid;
    e.uid = bpf_get_current_uid_gid();
    e.ts = *tsp;
    e.ret = ret;
    bpf_get_current_comm(&e.comm, sizeof(e.comm));
    bpf_probe_read_user_str(e.filename, sizeof(e.filename),
                            (void *)PT_REGS_PARM2(ctx));

    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
                          &e, sizeof(e));
    bpf_map_delete_elem(&start, &pid_tgid);
    return 0;
}

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

这里有几个关键要点需要解释:

  • BPF_KPROBE 宏自动从 pt_regs 中提取函数参数,无需手动解析寄存器
  • bpf_probe_read_user_str 安全地从用户空间拷贝字符串(可能触发缺页异常,BPF 会自动处理)
  • BPF_MAP_TYPE_PERF_OUTPUT 以零拷贝方式将事件从内核空间传输到用户空间
  • 使用临时 Hash Map 记录时间戳,实现函数入口和出口的数据关联

4.3 编译与加载

# 使用 Clang 编译为 BPF 目标文件(-02 优化必须开启)
clang -O2 -g -target bpf -c opensnoop.bpf.c -o opensnoop.bpf.o

# 验证 BTF 信息
llvm-dwarfdump opensnoop.bpf.o | head -20

4.4 用户态读取程序

#!/usr/bin/env python3
# opensnoop.py
import ctypes
from bcc import BPF

bpf = BPF(src_file="opensnoop.bpf.c")

# 附加 kprobe 和 kretprobe
bpf.attach_kprobe(event="do_sys_openat2", fn_name="trace_enter")
bpf.attach_kretprobe(event="do_sys_openat2", fn_name="trace_exit")

class Event(ctypes.Structure):
    _fields_ = [
        ("pid", ctypes.c_uint32),
        ("uid", ctypes.c_uint32),
        ("ts", ctypes.c_uint64),
        ("comm", ctypes.c_char * 16),
        ("filename", ctypes.c_char * 256),
        ("ret", ctypes.c_int32),
    ]

def print_event(cpu, data, size):
    event = ctypes.cast(data, ctypes.POINTER(Event)).contents
    print(f"{event.pid:8d} {event.comm.decode():16s} "
          f"{event.ret:4d} {event.filename.decode()}")

bpf["events"].open_perf_buffer(print_event)
print("%-8s %-16s %-4s %s" % ("PID", "COMM", "RET", "FILENAME"))

while True:
    try:
        bpf.perf_buffer_poll()
    except KeyboardInterrupt:
        break

4.5 运行效果

$ sudo python3 opensnoop.py
PID      COMM             RET  FILENAME
1234     nginx            5   /var/www/html/index.html
1234     nginx            6   /var/www/html/style.css
5678     python3          7   /app/data/config.yaml
9012     systemd-journal  8   /var/log/journal/abc123.journal

五、实战二:使用 Tracepoint 追踪网络数据包

5.1 场景:TCP 连接耗时分析

我们选择一个稳定的 Tracepoint sock:inet_sock_set_state 来追踪 TCP 状态机转换,测量从 SYN_SENT 到 ESTABLISHED 的耗时。

5.2 查看 Tracepoint 数据格式

$ cat /sys/kernel/debug/tracing/events/sock/inet_sock_set_state/format
name: inet_sock_set_state
ID: 1234
format:
    field:void skcommon; offset:0; size:8;
    field:__data_loc char[] skaddr; offset:8; size:2;
    ...

5.3 编写 Tracepoint eBPF 程序

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

#define TCP_ESTABLISHED 1
#define TCP_SYN_SENT    2
#define TCP_FIN_WAIT1   7
#define TCP_CLOSE_WAIT  8
#define TCP_CLOSE       9

struct conn_key {
    u32 saddr;
    u32 daddr;
    u16 sport;
    u16 dport;
};

struct conn_info {
    u64 set_synsent_ts;
    u64 set_established_ts;
};

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(key_size, sizeof(struct conn_key));
    __uint(value_size, sizeof(struct conn_info));
    __uint(max_entries, 4096);
} conn_map SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_PERF_OUTPUT);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u64));
    __uint(max_entries, 256);
} tcp_rtt_events SEC(".maps");

SEC("tracepoint/sock/inet_sock_set_state")
int trace_tcp_state(struct trace_event_raw_tcp_sk_exit *ctx)
{
    u16 old_state = ctx->oldstate;
    u16 new_state = ctx->newstate;
    u32 pid = bpf_get_current_pid_tgid() >> 32;

    if (old_state == TCP_SYN_SENT && new_state == TCP_ESTABLISHED) {
        u64 ts = bpf_ktime_get_ns();
        u64 *tsp = bpf_map_lookup_elem(&start, &pid_tgid);
        if (tsp) {
            u64 delta_us = (ts - *tsp) / 1000;
            bpf_perf_event_output(ctx, &tcp_rtt_events,
                                  BPF_F_CURRENT_CPU, &delta_us, 8);
        }
    }
    return 0;
}

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

六、进阶主题:CO-RE(Compile Once, Run Everywhere)

传统 eBPF 开发的最大痛点是跨内核版本兼容性。同一结构体在不同内核版本中字段偏移不同,导致加载失败。BTF + CO-RE 技术彻底解决了这个问题。

6.1 工作原理

vmlinux.h 由 bpftool 从目标机器的 BTF 信息自动生成,包含所有内核类型定义。eBPF 程序编译时记录需要重定位的字段,libbpf 在加载时自动根据目标内核的 BTF 信息修正字段偏移。

// 无需修改代码即可适应不同内核版本
struct task_struct *task = (void *)bpf_get_current_task();
// BPF_CORE_READ 自动处理重定位
u64 start_time = BPF_CORE_READ(task, start_time);

6.2 生成 vmlinux.h

# 从运行中的内核生成
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

# 从离线 BTF 文件生成
bpftool btf dump file /boot/vmlinux-$(uname -r).btf format c > vmlinux.h

七、性能优化与最佳实践

7.1 Map 选择指南

选择合适的 Map 类型对性能有决定性影响:

  • Per-CPU 系列(BPF_MAP_TYPE_PERCPU_*):消除 CPU 间的缓存同步,适合高频计数/累加场景
  • LRU 系列(BPF_MAP_TYPE_LRU_*):热点数据自动保留,适合缓存类场景
  • Ring Buffer(BPF_MAP_TYPE_RINGBUF):替代 PERF_OUTPUT,吞吐更高(Linux 5.8+)

7.2 减少事件频率

不要无脑输出所有事件,应该在 eBPF 程序中做预聚合:

// 不推荐:每秒输出百万个事件
// 推荐:在内核侧按进程聚合,只输出聚合后的统计
BPF_MAP_TYPE_HASH(counters, struct key, u64, 1024);

7.3 使用 BPF Spin Lock 保护并发写

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(struct counter));
    __uint(max_entries, 256);
} counters SEC(".maps");

// 写操作时加锁
struct counter *c = bpf_map_lookup_elem(&counters, &key);
if (c) {
    __sync_fetch_and_add(&c->count, 1);
}

八、推荐工具链与生态

工具定位适用场景
BCCPython 封装,快速原型开发调试阶段
libbpf + CO-REC 语言原生生产环境
Aya (Rust)Rust 生态安全关键系统
eunomia-bpfWASM + JSON低代码 eBPF
bpftrace一行脚本语言临时诊断

九、实战调试技巧

9.1 检查 BPF 程序是否加载成功

$ sudo bpftool prog list
45: kprobe  name  trace_enter  tag  a1b2c3d4e5f6  gpl
 loaded_at  2026-10-18T04:30:00+0000  xlated 120B  jited 64B

9.2 验证 Map 内容

$ sudo bpftool map dump id 42
key: 01 00 00 00  value: 64 00 00 00 00 00 00 00

9.3 BPF 验证器错误排查

如果加载失败,通常意味着 BPF 验证器拒绝了你的程序。常见原因:

  • 未初始化的寄存器就进行内存访问
  • 循环没有明确的边界(unroll 标记或有限迭代)
  • 无限循环或递归调用
  • 超出栈空间(每 BPF 程序仅 512 字节栈)

十、总结

Kprobes 和 Tracepoints 是 eBPF 内核追踪的两大核心能力。Tracepoints 以其稳定的接口成为生产环境的首选,而 Kprobes 在诊断和灵活性方面不可替代。随着 BTF 和 CO-RE 技术的成熟,eBPF 程序的跨版本部署门槛已经大幅降低,正成为每一位 Linux 工程师必须掌握的核心技能。

下一步建议读者尝试编写一个追踪 block_rq_complete Tracepoints 的 eBPF 程序,测量每个 I/O 请求的延迟并构建直方图(使用 BPF_MAP_TYPE_HISTOGRAM)。这将把今天的知识串联成一个完整的可观测性工具。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部