一、引言:为什么需要内核动态追踪?
在生产环境中,我们经常遇到这类难题:某个进程偶发性地卡顿数毫秒,某个 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);
}
八、推荐工具链与生态
| 工具 | 定位 | 适用场景 |
|---|---|---|
| BCC | Python 封装,快速原型 | 开发调试阶段 |
| libbpf + CO-RE | C 语言原生 | 生产环境 |
| Aya (Rust) | Rust 生态 | 安全关键系统 |
| eunomia-bpf | WASM + 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)。这将把今天的知识串联成一个完整的可观测性工具。

发表评论 取消回复