写在前面
- 博文内容涉及 BCC 工具的基本认知,一个BCC工具是如何构成的
- 一些入门级别的观测工具的源码解析,
怎么借 BCC 快速建立观察直觉 - BCC 最适合的不是"先学透所有底层机制",而是
先看结果、再猜挂点、再做最小改动 - 理解不足小伙伴帮忙指正
你若想得到这世界上最好的东西,先得让世界看到最好的你。------毛姆
BCC 这条路线到底在解决什么问题
对于eBPF来说,BCC 这条路线的最大价值不是"工具多",而是它能让你先看到问题被怎样观测到,再反过来理解挂点和数据,这件事对初学者特别重要,因为很多人一开始不是不会写,而是:
- 不知道该看哪个事件
- 不知道输出应该长什么样
- 不知道为什么有的工具输出事件流,有的是直方图,有的是排行
BCC 很适合先帮你把这层直觉搭起来,带着问题去学习
BCC 到底是什么
你可以先把 BCC 理解成:一套现成 eBPF 工具 + 一套更容易写和改 eBPF 工具的开发框架
它最常见的形态是:
- 一部分内核态逻辑用 C 写
- 一部分用户态控制逻辑用 Python 写
- 仓库里自带大量现成工具
对于初学者现在还没能力从零手搓一个工具,BCC 会先给你一堆能跑的样本,先借这些样本可以了解什么输出值得看,什么问题适合看事件流,什么问题适合看统计分布,什么问题适合看排行
可以先把 BCC 定位成 观察训练线,而不是最终工程交付线,它特别适合你第一次形成这种感觉:原来进程执行、文件打开、调度等待、IO 延迟,真的都可以被这样看见
第一次跑 BCC,最值得先建立哪类直觉
先建立下面 3 类直觉:
事件流:每发生一次动作,就吐出一条记录,典型工具execsnoop,opensnoop,statsnoop分布:不是看"发生了什么",而是看"大多数样本集中在哪、有没有长尾",典型工具runqlat,biolatency排行:不是看单次事件,而是看谁最频繁、谁最慢、谁最重,典型工具:biotop,tcptop
因为后面你在真实排障里第一步经常不是"写代码",而是先判断这个问题应该用哪种输出形式观察
先跑 execsnoop / opensnoop / statsnoop
因为这组最适合建立"动作一发生,就能被看到"的感觉,先开一个终端跑,再在另一个终端执行:
bash
ls
date
sleep 1
你通常会看到类似这种输出:
bash
┌──[root@liruilongs.github.io]-[~]
└─$sudo /usr/share/bcc/tools/execsnoop
COMM PID PPID RET ARGS
ls 68055 65799 0 /usr/bin/ls --color=auto
^C
这里第一次最该看的不是格式,而是一次命令基本对应一次记录,你真的能把"终端里敲的一条命令"和"输出里的一条记录"对上,输出是随着事件实时流出来的,这些列,应该怎么理解
PCOMM/COMM:进程名PID:这个进程自己的编号PPID:它的父进程编号ARGS:它到底执行了什么命令和参数
BCC 工具输出不是随机列,它是在把一次内核里发生的动作翻译成你能看的表格
第一次看 BCC 工具源码,如何阅读
一份典型 BCC 工具源码,你第一次优先看这 4 块:
- 用户态参数解析
- 内嵌的 C 程序文本
- attach 发生在哪里
- 输出格式在哪里定义
因为 BCC 工具经常是"Python 外壳 + C 内核态逻辑"的组合。如果你一上来就死抠所有细节,很容易两边都看不清。
- Python 层:负责拼参数、加载程序、attach、格式化输出
- C 层:负责真正挂在事件上运行的采集逻辑
只要这个分工先立住,后面再看具体代码就会顺很多。无论你看 execsnoop、opensnoop 还是 runqlat,都可以先按下面这个顺序扫一遍:
- 先看命令行参数支持了什么过滤或模式
- 再看 Python 里那段内嵌 C 程序文本
- 再看 attach 到了哪个事件
- 再看数据是怎么送回用户态的
- 最后看输出格式化
第一次只要把这 5 步顺下来,就已经比"从第一行开始硬啃"高效很多。
bash
```bash
┌──[root@liruilongs.github.io]-[~]
└─$sudo /usr/share/bcc/tools/opensnoop
PID COMM FD ERR PATH
68059 ls 3 0 /etc/ld.so.cache
68059 ls 3 0 /lib64/libselinux.so.1
68059 ls 3 0 /lib64/libcap.so.2
68059 ls 3 0 /lib64/libc.so.6
68059 ls 3 0 /lib64/libpcre2-8.so.0
68059 ls 3 0 /proc/filesystems
68059 ls 3 0 /proc/mounts
..........................
920 irqbalance 7 0 /proc/irq/57/smp_affinity
920 irqbalance 7 0 /proc/irq/57/smp_affinity
execsnoop: <https://github.com/iovisor/bcc/blob/master/tools/execsnoop.py>
### 它在哪里定义了内核态 C 程序?
在代码的 **`bpf_text`** 变量中。这是一个非常长的 Python 多行字符串(用 `""" ... """` 包裹),里面包含了完整的 C 语言代码(`#include`、`struct data_t`、`syscall__execve` 函数、`do_ret_sys_execve` 函数等)。
```c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/fs.h>
#define ARGSIZE 128
enum event_type {
EVENT_ARG,
EVENT_RET,
};
struct data_t {
u32 pid; // PID as in the userspace term (i.e. task->tgid in kernel)
u32 ppid; // Parent PID as in the userspace term (i.e task->real_parent->tgid in kernel)
u32 uid;
u32 cpu;
char comm[TASK_COMM_LEN];
char pcomm[TASK_COMM_LEN];
enum event_type type;
char argv[ARGSIZE];
int retval;
};
BPF_PERF_OUTPUT(events);
static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data)
{
bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);
events.perf_submit(ctx, data, sizeof(struct data_t));
return 1;
}
static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data)
{
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), ptr);
if (argp) {
return __submit_arg(ctx, (void *)(argp), data);
}
return 0;
}
int syscall__execve(struct pt_regs *ctx,
const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
u32 uid = bpf_get_current_uid_gid() & 0xffffffff;
UID_FILTER
if (container_should_be_filtered()) {
return 0;
}
// create data here and pass to submit_arg to save stack space (#555)
struct data_t data = {};
struct task_struct *task;
data.pid = bpf_get_current_pid_tgid() >> 32;
task = (struct task_struct *)bpf_get_current_task();
// Some kernels, like Ubuntu 4.13.0-generic, return 0
// as the real_parent->tgid.
// We use the get_ppid function as a fallback in those cases. (#1883)
data.ppid = task->real_parent->tgid;
bpf_probe_read_kernel_str(&data.pcomm, sizeof(data.pcomm), task->real_parent->comm);
PPID_FILTER
bpf_get_current_comm(&data.comm, sizeof(data.comm));
data.type = EVENT_ARG;
__submit_arg(ctx, (void *)filename, &data);
// skip first arg, as we submitted filename
#pragma unroll
for (int i = 1; i < MAXARG; i++) {
if (submit_arg(ctx, (void *)&__argv[i], &data) == 0)
goto out;
}
// handle truncated argument list
char ellipsis[] = "...";
__submit_arg(ctx, (void *)ellipsis, &data);
out:
return 0;
}
int do_ret_sys_execve(struct pt_regs *ctx)
{
if (container_should_be_filtered()) {
return 0;
}
struct data_t data = {};
struct task_struct *task;
u32 uid = bpf_get_current_uid_gid() & 0xffffffff;
UID_FILTER
data.pid = bpf_get_current_pid_tgid() >> 32;
data.uid = uid;
task = (struct task_struct *)bpf_get_current_task();
// Some kernels, like Ubuntu 4.13.0-generic, return 0
// as the real_parent->tgid.
// We use the get_ppid function as a fallback in those cases. (#1883)
data.ppid = task->real_parent->tgid;
bpf_probe_read_kernel_str(&data.pcomm, sizeof(data.pcomm), task->real_parent->comm);
data.cpu = CPU_RUNNING_ON;
PPID_FILTER
bpf_get_current_comm(&data.comm, sizeof(data.comm));
data.type = EVENT_RET;
data.retval = PT_REGS_RC(ctx);
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
它挂的是哪类事件?
它挂的是 execve 系统调用的 kprobe(动态跟踪)事件,具体挂了两个点:
入口探针 (kprobe) :在 execve 系统调用刚进入时触发。捕获 execve 入口 (syscall__execve),在进程刚调用 execve 时,把命令行参数逐个抓出来,通过 events.perf_submit 发给用户态
c
int syscall__execve(struct pt_regs *ctx, ...) {
// 1. 初始化 data
struct data_t data = {};
// 2. 获取当前 PID, TGID, 父进程 PID
data.pid = bpf_get_current_pid_tgid() >> 32;
task = (struct task_struct *)bpf_get_current_task();
data.ppid = task->real_parent->tgid;
// 3. 读取文件名 (即 argv[0])
__submit_arg(ctx, (void *)filename, &data);
// 4. 循环读取后续参数 (argv[1], argv[2]...)
for (int i = 1; i < MAXARG; i++) {
submit_arg(ctx, (void *)&__argv[i], &data);
}
}
返回探针 (kretprobe) :在 execve 系统调用执行完返回时触发。捕获 execve 返回 (do_ret_sys_execve),在 execve 执行完后,把返回值(成功 / 失败)发给用户态。
c
int do_ret_sys_execve(struct pt_regs *ctx) {
// ... 获取 pid/uid 等信息 ...
data.type = EVENT_RET;
data.retval = PT_REGS_RC(ctx); // 获取系统调用的返回值
events.perf_submit(ctx, &data, sizeof(data));
}
用户态粘合逻辑 (Python),加载 BPF 并挂载对应代码位置:
python
b = BPF(text=bpf_text)
execve_fnname = b.get_syscall_fnname("execve")
# 挂载 kprobe (入口)
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
# 挂载 kretprobe (返回)
b.attach_kretprobe(event=execve_fnname, fn_name="do_ret_sys_execve")
eBPF 的 attach_kprobe/attach_kretprobe 就是 Linux 内核版的 AOP,不修改内核代码,在内核函数执行前、执行后插入 eBPF 程序,类比 AOP 的 @Before,@AfterReturning
它把哪些字段送回了用户态?
通过 struct data_t 结构体定义的字段,全部送回了用户态:
pid: 进程 ID (TGID)ppid: 父进程 IDuid: 用户 IDcpu: 运行在哪个 CPU 核上comm: 当前进程名 (Task Comm)pcomm: 父进程名type: 事件类型(是"参数事件"EVENT_ARG还是"返回事件"EVENT_RET)argv: 具体的参数字符串片段retval:execve系统调用的返回值(判断成功或失败)
对应代码位置:
c
struct data_t {
u32 pid;
u32 ppid;
u32 uid;
u32 cpu;
char comm[TASK_COMM_LEN]; // 进程名
char pcomm[TASK_COMM_LEN]; // 父进程名
enum event_type type; // 标记是"参数"还是"返回结果"
char argv[ARGSIZE]; // 存储参数字符串
int retval; // execve 的返回值
};
BPF_PERF_OUTPUT(events); // 定义 perf buffer,用于内核向用户态发送数据
用户态在哪把这些字段打印出来?
在 print_event 回调函数里。这个函数被注册给了 Perf Buffer,每当内核有数据发过来,它就会被调用。在函数内部,当判断收到的是 EVENT_RET(返回事件)时,会将暂存的参数和进程信息一起通过 printb 打印出来。
对应代码位置:
python
# 定义一个字典暂存参数 (因为参数和返回值是分两次事件发上来的)
argv = defaultdict(list)
def print_event(cpu, data, size):
event = b["events"].event(data)
if event.type == EventType.EVENT_ARG:
# 如果是参数事件,先存起来
argv[event.pid].append(event.argv)
elif event.type == EventType.EVENT_RET:
# 如果是返回事件,把之前存的参数拼起来打印
printb(b"%-16s %-7d " % (event.comm, event.pid))
# ... 打印其他列 ...
printb(b"%s" % b' '.join(argv[event.pid]))
del(argv[event.pid]) # 清理内存
# 循环读取 perf buffer
b["events"].open_perf_buffer(print_event)
while 1:
b.perf_buffer_poll()
一个标准的 BPF 追踪程序架构:内核态抓数据 -> Perf Buffer 传数据 -> 用户态聚合展示数据。真正要学的不是"代码技巧",而是这条链路:
事件发生 -> 内核态eBPF 程序取字段 -> 用户态拿到事件 -> 用户态打印
如果这条链路看明白了,看别的 BCC 工具源码会轻松很多。
opensnoop 的源码第一次到底该怎么看
opensnoop.py : https://github.com/iovisor/bcc/blob/master/tools/opensnoop.py
opensnoop 比 execsnoop 更值得学的一点是:它会让你第一次认真意识到"前后两个阶段的信息要拼起来"。因为打开文件这件事,天然带两类信息:
- 进入调用时,你更容易拿到路径等输入参数
- 返回时,你更容易拿到成功失败、FD、错误码这类结果
所以你第一次看 opensnoop,感觉不是"又一个表格工具",而是原来一个工具可以同时关心调用入口和调用返回,实际上 opensnoop 是两条线:
kfunc路线- build_kfunc_bpf_text()
- 不需要 infotmp
- 不需要 attach_kprobe/kretprobe
- 直接 KRETFUNC_PROBE(...) 输出事件
老内核路线- build_legacy_bpf_text()
- 入口存 filename/flags/mode 到 infotmp
- 返回时 trace_return() 拿 ret
- 需要 attach_legacy_probes()
kprobe + kretprobe 是"两阶段拼接",动态跟踪,运行时修改指令插桩,必须手动 attach,而kfunc/kretfunc是规范动态跟踪,内核主动回调 eBPF,不用手动 attach
下面是一个源码的省略版,把最难读的部分拆成了 4 个层次:
resolve_syscall_names():只负责判断系统调用名字build_kfunc_bpf_text():只负责新内核版本build_legacy_bpf_text():只负责老内核版本finalize_bpf_text():只负责统一替换过滤器和提交逻辑
python
# 普通模式下,内核态事件直接提交到 ring buffer
NORMAL_SUBMIT_BLOCK = """
events.ringbuf_submit(data, sizeof(*data));
"""
# 开启 full path 时,把相对路径补全后再提交事件
FULLPATH_SUBMIT_BLOCK = """
if (data->name[0] != '/') { // relative path
struct task_struct *task;
struct dentry *dentry, *parent_dentry, *mnt_root;
struct vfsmount *vfsmnt;
struct fs_struct *fs;
struct path *path;
struct mount *mnt;
size_t filepart_length;
char *payload = data->name;
struct qstr d_name;
int i;
.........................................
}
}
events.ringbuf_submit(data, sizeof(*data));
"""
def resolve_syscall_names():
# 用一个最小 BPF 对象探测当前架构上的 syscall 前缀与 openat2 可用性
probe = BPF(text='')
prefix = probe.get_syscall_prefix().decode()
fnnames = {
"open": prefix + "open",
"openat": prefix + "openat",
"openat2": prefix + "openat2",
}
if probe.ksymname(fnnames["openat2"]) == -1:
fnnames["openat2"] = None
return fnnames
def build_base_bpf_text(args):
# base 部分只放公共 struct/map 定义,以及是否开启 FULLPATH 宏
text = bpf_text
if args.full_path:
text = "#define FULLPATH\n" + text
return text
def build_kfunc_bpf_text(fnnames):
# 新内核走 kfunc/kretfunc:每个 syscall 直接拼一段返回探针逻辑
parts = []
parts.append(
bpf_text_kfunc_header_open.replace("FNNAME", fnnames["open"])
)
parts.append(bpf_text_kfunc_body)
parts.append(
bpf_text_kfunc_header_openat.replace("FNNAME", fnnames["openat"])
)
parts.append(bpf_text_kfunc_body)
if fnnames["openat2"]:
parts.append(
bpf_text_kfunc_header_openat2.replace("FNNAME", fnnames["openat2"])
)
parts.append(bpf_text_kfunc_body)
return "".join(parts)
def build_legacy_bpf_text(fnnames):
# 老内核走 kprobe + kretprobe:入口保存参数,返回阶段再组装结果
parts = [bpf_text_kprobe]
parts.append(bpf_text_kprobe_header_open)
parts.append(bpf_text_kprobe_body)
parts.append(bpf_text_kprobe_header_openat)
parts.append(bpf_text_kprobe_body)
if fnnames["openat2"]:
parts.append(bpf_text_kprobe_header_openat2)
parts.append(bpf_text_kprobe_body)
return "".join(parts)
def apply_pid_tid_filter(text, args, child_pid):
# kprobe 和 kfunc 的丢弃动作不同,所以分别替换两套占位符
if args.tid:
kprobe_filter = 'if (tid != %s) { return 0; }' % args.tid
kfunc_filter = (
'if (tid != %s) { events.ringbuf_discard(data, 0); return 0; }'
% args.tid
)
elif args.pid:
kprobe_filter = 'if (pid != %s) { return 0; }' % args.pid
kfunc_filter = (
'if (pid != %s) { events.ringbuf_discard(data, 0); return 0; }'
% args.pid
)
elif child_pid:
kprobe_filter = 'if (pid != %s) { return 0; }' % child_pid
kfunc_filter = (
'if (pid != %s) { events.ringbuf_discard(data, 0); return 0; }'
% child_pid
)
else:
kprobe_filter = ''
kfunc_filter = ''
text = text.replace('KPROBE_PID_TID_FILTER', kprobe_filter)
text = text.replace('KFUNC_PID_TID_FILTER', kfunc_filter)
return text
def apply_uid_filter(text, args):
# UID 过滤和 PID/TID 一样,也要分别替换两条路径里的占位符
if args.uid:
kprobe_filter = 'if (uid != %s) { return 0; }' % args.uid
kfunc_filter = (
'if (uid != %s) { events.ringbuf_discard(data, 0); return 0; }'
% args.uid
)
else:
kprobe_filter = ''
kfunc_filter = ''
text = text.replace('KPROBE_UID_FILTER', kprobe_filter)
text = text.replace('KFUNC_UID_FILTER', kfunc_filter)
return text
def apply_flag_filter(text, flag_filter_mask):
# flag 过滤只在用户显式指定 -f 时生效
if flag_filter_mask:
kprobe_filter = 'if (!(flags & %d)) { return 0; }' % flag_filter_mask
kfunc_filter = (
'if (!(flags & %d)) { events.ringbuf_discard(data, 0); return 0; }'
% flag_filter_mask
)
else:
kprobe_filter = ''
kfunc_filter = ''
text = text.replace('KPROBE_FLAGS_FILTER', kprobe_filter)
text = text.replace('KFUNC_FLAGS_FILTER', kfunc_filter)
return text
def apply_buffer_pages(text, args):
# ring buffer 页数也是模板替换,不属于 kfunc/legacy 的差异逻辑
if args.buffer_pages:
return text.replace('BUFFER_PAGES', '%s' % args.buffer_pages)
return text.replace('BUFFER_PAGES', '%d' % 64)
def apply_submit_block(text, args):
# SUBMIT_DATA 是公共占位符,最后按是否需要 full path 选择提交代码
submit_block = FULLPATH_SUBMIT_BLOCK if args.full_path else NORMAL_SUBMIT_BLOCK
return text.replace('SUBMIT_DATA', submit_block)
def strip_extended_fields_if_needed(text, args):
# 不显示扩展字段时,把 struct 里标记过的成员直接裁掉
if args.extended_fields or args.flag_filter:
return text
return '\n'.join(
line for line in text.split('\n')
if 'EXTENDED_STRUCT_MEMBER' not in line
)
def finalize_bpf_text(text, args, child_pid, flag_filter_mask):
# 所有"公共补丁"统一在这里收口,避免散落在主流程里
text = apply_pid_tid_filter(text, args, child_pid)
text = apply_uid_filter(text, args)
text = apply_flag_filter(text, flag_filter_mask)
text = apply_buffer_pages(text, args)
text = filter_by_containers(args) + text
text = strip_extended_fields_if_needed(text, args)
text = apply_submit_block(text, args)
return text
def attach_legacy_probes(bpf_obj, fnnames):
# 只有老内核版本需要手工 attach;kfunc 版本在加载时就完成绑定
bpf_obj.attach_kprobe(
event=fnnames["open"],
fn_name="syscall__trace_entry_open"
)
bpf_obj.attach_kretprobe(
event=fnnames["open"],
fn_name="trace_return"
)
bpf_obj.attach_kprobe(
event=fnnames["openat"],
fn_name="syscall__trace_entry_openat"
)
bpf_obj.attach_kretprobe(
event=fnnames["openat"],
fn_name="trace_return"
)
if fnnames["openat2"]:
bpf_obj.attach_kprobe(
event=fnnames["openat2"],
fn_name="syscall__trace_entry_openat2"
)
bpf_obj.attach_kretprobe(
event=fnnames["openat2"],
fn_name="trace_return"
)
child_pid = None
if args.exec:
# --exec 模式先起子进程,再把它的 PID 注入过滤器
child_pid = run_cmd(args.exec)
fnnames = resolve_syscall_names()
is_support_kfunc = BPF.support_kfunc()
base_text = build_base_bpf_text(args)
if is_support_kfunc:
# 新内核:直接拼 kfunc 版本
variant_text = build_kfunc_bpf_text(fnnames)
else:
# 老内核:回退到 kprobe/kretprobe 双阶段版本
variant_text = build_legacy_bpf_text(fnnames)
bpf_text_final = base_text + variant_text
bpf_text_final = finalize_bpf_text(
bpf_text_final,
args,
child_pid,
flag_filter_mask
)
# 到这里才真正拿到最终要加载的 eBPF C 程序
b = BPF(text=bpf_text_final)
if not is_support_kfunc:
attach_legacy_probes(b, fnnames)
简单说明一下:
NORMAL_SUBMIT_BLOCK / FULLPATH_SUBMIT_BLOCK:一个是普通提交,一个是补全相对路径后再提交resolve_syscall_names():它只是探测 syscall 名称和 openat2 是否存在build_kfunc_bpf_text() / build_legacy_bpf_text():明确区分"新内核单阶段"和"老内核双阶段"apply_*_filter() / finalize_bpf_text():说明这是公共补丁收口层,不是采集路径差异attach_legacy_probes()和主流程分支:说明为什么只有老内核才需要手动 attach
实际的代码封装分为两部分:
新内核单阶段 kfunc 的封装:
bash
bpf_text_kfunc_header_open = """
// 1. 架构兼容判断:x86_64 等架构走这里
#if defined(CONFIG_ARCH_HAS_SYSCALL_WRAPPER) && !defined(__s390x__)
// 2. 核心:定义【系统调用返回钩子】!
// KRETFUNC_PROBE = 内核函数返回时执行(AOP 的 @AfterReturning)
// FNNAME = 占位符,后续替换成真实内核函数名(sys_open/__x64_sys_open)
KRETFUNC_PROBE(FNNAME, struct pt_regs *regs, int ret)
{
// 3. 从CPU寄存器里读取 open() 的三个参数:
// 参数1:文件名指针(用户态)
const char __user *filename = (char *)PT_REGS_PARM1(regs);
// 参数2:打开方式(O_RDONLY/O_WRONLY 等)
int flags = PT_REGS_PARM2(regs);
u32 mode = 0;
// 4. open 系统调用规则:只有创建文件时,才需要第三个参数(权限)
if (flags & O_CREAT || (flags & O_TMPFILE) == O_TMPFILE)
mode = PT_REGS_PARM3(regs);
// 5. 其他CPU架构(非x86_64)走这里:直接传参,不用读寄存器
#else
KRETFUNC_PROBE(FNNAME, const char __user *filename, int flags, u32 mode, int ret)
{
#endif
"""
bpf_text_kfunc_body = """
// 1. 获取进程ID:高32位=PID,低32位=线程ID
u64 id = bpf_get_current_pid_tgid();
u32 pid = id >> 32;
u32 tid = id;
// 获取用户ID(哪个用户运行的程序)
u32 uid = bpf_get_current_uid_gid();
// 2. 申请内存:准备一块空间,存放要发给Python的数据
struct data_t *data;
data = events.ringbuf_reserve(sizeof(struct data_t));
if (!data)
return 0;
// 3. 过滤逻辑:只追踪你指定的 PID/UID/文件打开方式
KFUNC_PID_TID_FILTER
KFUNC_UID_FILTER
KFUNC_FLAGS_FILTER
// 容器过滤,不满足条件就丢弃数据
if (container_should_be_filtered()) {
events.ringbuf_discard(data, 0);
return 0;
}
// 4. 采集信息:获取进程名(cat/ls/nginx)
bpf_get_current_comm(&data->comm, sizeof(data->comm));
// 获取时间戳
u64 tsp = bpf_ktime_get_ns();
// 5. 读取文件名(从用户态内存安全读取)
data->path_depth = 0;
bpf_probe_read_user_str(data->name, sizeof(data->name), (void *)filename);
// 6. 填充所有数据
data->id = id; // 进程ID
data->ts = tsp / 1000; // 时间
data->uid = uid; // 用户ID
data->flags = flags; // 文件打开方式
data->mode = mode; // 文件权限
data->ret = ret; // open返回值(成功=FD,失败=负数)
// 7. 提交数据给用户态Python打印
SUBMIT_DATA
return 0;
}
"""
老内核的封装: bpf_text_kprobe_header_open + bpf_text_kprobe_body,传统 kprobe 方案:
syscall__trace_entry_open = @Before(函数刚进入时执行)trace_return = @AfterReturning(函数执行完返回时执行)BPF_HASH(infotmp)= 临时储物柜 → 入口把参数存进去,返回时取出来(因为两个钩子无法直接共享数据)
c
bpf_text_kprobe = """
// 1. 创建一个哈希表(临时储物柜)
// key:进程ID(u64),value:我们要存的参数(struct val_t)
BPF_HASH(infotmp, u64, struct val_t);
// 2. 返回钩子函数:open系统调用 执行完毕、准备返回时 自动运行
int trace_return(struct pt_regs *ctx)
{
// 获取当前进程的唯一ID(高32位PID,低32位TID)
u64 id = bpf_get_current_pid_tgid();
struct val_t *valp; // 指向哈希表里存的数据
struct data_t *data; // 要发给用户态Python的数据
// 获取当前时间戳
u64 tsp = bpf_ktime_get_ns();
// 重点:用进程ID,去哈希表查【入口钩子存的参数】
valp = infotmp.lookup(&id);
// 没查到 = 丢了入口数据,直接退出
if (valp == 0) {
return 0;
}
// 给 ring buffer(内核→用户态通道)申请内存
data = events.ringbuf_reserve(sizeof(struct data_t));
if (!data)
goto cleanup; // 申请失败,直接清理数据
// 从哈希表中,把数据复制到要发送的结构体里
bpf_probe_read_kernel(&data->comm, sizeof(data->comm), valp->comm);
data->path_depth = 0;
// 安全读取用户态的文件路径
bpf_probe_read_user_str(data->name, sizeof(data->name), (void *)valp->fname);
// 填充所有信息
data->id = valp->id;
data->ts = tsp / 1000; // 时间戳
data->uid = bpf_get_current_uid_gid(); // 用户ID
data->flags = valp->flags; // 文件打开方式
data->mode = valp->mode; // 文件权限
data->ret = PT_REGS_RC(ctx); // 核心:open的返回值(成功=FD,失败=负数)
// 提交数据给Python打印(占位符)
SUBMIT_DATA
// 清理:无论成功失败,都删除哈希表的临时数据(避免内存泄漏)
cleanup:
infotmp.delete(&id);
return 0;
}
"""
// 第二部分:函数头(定义入口钩子的参数)
// 对应:open系统调用刚进入内核时执行
bpf_text_kprobe_header_open = """
int syscall__trace_entry_open(struct pt_regs *ctx, const char __user *filename,
int flags, u32 mode)
{
"""
// 第三部分:函数体(核心:把参数存进哈希表)
bpf_text_kprobe_body = """
// 定义存储数据的结构体
struct val_t val = {};
// 获取进程唯一ID
u64 id = bpf_get_current_pid_tgid();
u32 pid = id >> 32; // 高32位 = 进程PID
u32 tid = id; // 低32位 = 线程ID
u32 uid = bpf_get_current_uid_gid(); // 用户ID
// 过滤逻辑:只追踪指定的PID/UID/文件打开方式
KPROBE_PID_TID_FILTER
KPROBE_UID_FILTER
KPROBE_FLAGS_FILTER
// 容器过滤,不满足直接退出
if (container_should_be_filtered()) {
return 0;
}
// 获取进程名(cat/ls/nginx)
if (bpf_get_current_comm(&val.comm, sizeof(val.comm)) == 0) {
// 把所有关键数据赋值
val.id = id;
val.fname = filename; // 文件路径(用户态指针)
val.flags = flags; // 打开方式
val.mode = mode; // 权限
// 重点!!!存入哈希表
// key=进程ID,value=上面的所有数据
infotmp.update(&id, &val);
}
return 0;
};
"""
实际上是 按内核能力选择一条采集路径,然后再把公共过滤器补进去,以老内核的封装看一些问题
路径是在哪一侧拿到的?
路径(Filename)是在内核态拿到的,但分两步:
- 第一步(抓指针) :在 syscall 入口 (内核态 kprobe 处理函数
syscall__trace_entry_open*),先拿到用户态传进来的字符串指针filename,并把这个指针暂存到 BPF Hash 表中。 - 第二步(读内容) :在 syscall 返回 (内核态 kretprobe 处理函数
trace_return),通过bpf_probe_read_user_str函数,从用户态内存把实际的字符串内容读取到内核的struct data_t缓冲区里。
数据来源是用户态内存 ,但读取动作是由内核态 eBPF 程序完成的。
返回值是在哪一侧拿到的?
返回值(Return Value / FD / Errno)完全是在内核态拿到的。在 syscall 返回 处理函数(trace_return 或 kfunc body)中,通过 PT_REGS_RC(ctx) 宏(在 kprobe 模式下)或直接通过 ret 参数(在 kfunc 模式下)读取 CPU 寄存器,获取系统调用的返回值,对应代码:
c
// kprobe 模式下
data.ret = PT_REGS_RC(ctx);
中间有没有临时状态需要保存?
老内核版本的核心逻辑。保存了 "入口时的参数" (文件名 fname、标志位 flags、权限位 mode、进程名 comm)。因为到了 syscall 返回 的时候,寄存器已经被复用了,你已经找不到 filename 指针了,所以必须在刚进入 syscall 时把这些参数记下来。保存在 内核态的 BPF Hash 表 中,变量名叫 infotmp。
对应代码:
c
// 定义 Hash 表
BPF_HASH(infotmp, u64, struct val_t);
// 入口处:存进去
infotmp.update(&id, &val);
// 返回处:取出来
struct val_t *valp = infotmp.lookup(&id);
// ... 组装数据 ...
infotmp.delete(&id); // 用完删掉
最后是哪一段代码把结果打印出来?
是用户态 Python 代码中的 print_event 回调函数。这段代码不会自动执行,而是通过 perf_buffer_poll() 等待。当内核态通过 events.perf_submit() 发送数据后,Python 会被唤醒,自动调用 print_event,对应代码位置:
python
# 👇 就是这个函数
def print_event(cpu, data, size):
event = b["events"].event(data)
# ... 处理 FD 和 ERR ...
# ... 👇 在这里打印 ...
printb(b"%-6d %-16s %4d %3d " % (...), nl="")
printb(b"%s" % event.name)
# 👇 主循环,不断轮询
while 1:
b.perf_buffer_poll() # 一旦有数据,就调用上面的 print_event
可以看到:
- 不是所有工具都能"一次触发就拿齐所有信息"
- 有些工具天然要做前后阶段拼接
- 这类场景里,临时状态保存会非常重要
这对你后面理解 map、enter/return 配合、多阶段事件拼接都会很有帮助。
第一次看完 opensnoop,可以看到文件打开这类工具的关键,不只是看谁调了 open,还要把输入路径和最后的返回结果对应起来
runqlat 的源码第一次到底该怎么看
runqlat 最适合帮你第一次真正理解:分布工具和事件流工具,在源码组织上为什么会不一样
bash
┌──[root@liruilongs.github.io]-[~]
└─$sudo /usr/share/bcc/tools/runqlat
Tracing run queue latency... Hit Ctrl-C to end.
^C
usecs : count distribution
0 -> 1 : 0 | |
2 -> 3 : 0 | |
4 -> 7 : 0 | |
8 -> 15 : 2 | |
16 -> 31 : 158 |********** |
32 -> 63 : 427 |**************************** |
64 -> 127 : 595 |****************************************|
128 -> 255 : 164 |*********** |
256 -> 511 : 46 |*** |
512 -> 1023 : 16 |* |
1024 -> 2047 : 1 | |
runqlat(Run Queue Latency,运行队列延迟)是观察系统调度器性能的经典工具。它的核心逻辑非常直观:记下 "进程开始排队" 的时间,记下 "进程跑上 CPU" 的时间,一算差值就是等待时间。它测量的是 "Runnable" 状态的时间,runqlat 就是测从 Runnable -> Running 这中间花了多久。如果这个时间很长,说明 CPU 很挤,负载很高。
runqlat.py: https://github.com/iovisor/bcc/blob/master/tools/runqlat.py
c代码核心的探针挂载,这里是用了静态跟踪点
c
RAW_TRACEPOINT_PROBE(sched_wakeup)
{
struct task_struct *p = (struct task_struct *)ctx->args[0];
// 任务被唤醒,进入 runnable 队列,记"排队起点"
return trace_enqueue(p->tgid, p->pid);
}
RAW_TRACEPOINT_PROBE(sched_wakeup_new)
{
struct task_struct *p = (struct task_struct *)ctx->args[0];
// 新创建任务第一次可运行,也要记一次起点
return trace_enqueue(p->tgid, p->pid);
}
RAW_TRACEPOINT_PROBE(sched_switch)
{
struct task_struct *prev = (struct task_struct *)ctx->args[1];
struct task_struct *next = (struct task_struct *)ctx->args[2];
// 1) 如果 prev 仍然处于 TASK_RUNNING,说明它是被动被抢占了,
// 它还会继续回到 runnable 队列排队,所以这里也要重新记起点
if (prev->STATE_FIELD == TASK_RUNNING) {
u64 ts = bpf_ktime_get_ns();
start.update(&prev->pid, &ts);
}
// 2) next 现在要真正上 CPU 了,说明它的等待阶段结束
// 去 start map 里查它当初入队的时间
u64 *tsp = start.lookup(&next->pid);
if (!tsp)
return 0;
// 3) 当前时间 - 入队时间 = run queue latency
u64 delta = bpf_ktime_get_ns() - *tsp;
// 4) 把 delta 按桶归类,更新直方图
STORE
// 5) 这个样本用完就删,避免旧时间戳污染后续统计
start.delete(&next->pid);
return 0;
}
这段代码里每个关键变量到底在干什么
start:一个临时状态表,通常是pid -> enqueue timestamp,用来把"开始排队"和"真正运行"串起来tsp:从start里查出来的"这个任务开始排队的时间"delta:等待时长,也就是 run queue latencyFACTOR:把delta从纳秒换成更适合展示的单位,比如微秒/毫秒STORE:把delta放进直方图桶里,本质上是"某个区间的计数 +1"
这里最值得特别注意的是这一句:
c
if (prev->STATE_FIELD == TASK_RUNNING) {
它不是在判断"prev 还在运行,所以忽略",而是在判断:prev 虽然被换下 CPU 了,但它并没有睡眠/阻塞,而是还具备继续运行资格。这意味着它会重新回到 runnable 队列,所以这次被换下去,也应该被当成一次新的排队起点。
换句话说,runqlat 统计的不是"进程总共活了多久",也不是"单次函数执行多久",而是:一个任务每次具备运行资格后,要等多久才能真正拿到 CPU
看一下源码:
python
# -----------------------------
# 1. 内核态 eBPF 程序:map 定义
# -----------------------------
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/nsproxy.h>
#include <linux/pid_namespace.h>
#include <linux/init_task.h>
typedef struct pid_key {
u32 id; // 进程或线程 ID
u64 slot; // 直方图桶编号
} pid_key_t;
typedef struct pidns_key {
u32 id; // PID namespace ID
u64 slot; // 直方图桶编号
} pidns_key_t;
// start: 临时状态表,保存"某个 pid 从什么时候开始排队"
// 这里 BPF_HASH(start, u32) 的 key 是 u32 pid,
// 在 BCC 里 value 没显式写时,默认按 u64 使用,正好拿来放时间戳
BPF_HASH(start, u32);
// STORAGE 是占位符,稍后由 Python 按参数替换成不同版本的 BPF_HISTOGRAM(dist, ...)
STORAGE
// 记录入队时间:任务一旦进入 runnable 队列,就记下当前时间
static int trace_enqueue(u32 tgid, u32 pid)
{
if (FILTER || pid == 0)
return 0;
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
return 0;
}
"""
# ----------------------------------------
# 2. raw tracepoint 版本:直接挂调度 tracepoint
# ----------------------------------------
bpf_text_raw_tp = """
RAW_TRACEPOINT_PROBE(sched_wakeup)
{
struct task_struct *p = (struct task_struct *)ctx->args[0];
// 普通唤醒:任务开始进入 runnable 队列
return trace_enqueue(p->tgid, p->pid);
}
RAW_TRACEPOINT_PROBE(sched_wakeup_new)
{
struct task_struct *p = (struct task_struct *)ctx->args[0];
// 新任务第一次被唤醒,也要记入队时间
return trace_enqueue(p->tgid, p->pid);
}
RAW_TRACEPOINT_PROBE(sched_switch)
{
struct task_struct *prev = (struct task_struct *)ctx->args[1];
struct task_struct *next = (struct task_struct *)ctx->args[2];
u32 pid, tgid;
// 如果 prev 仍处于 TASK_RUNNING,说明它是被抢占下 CPU,
// 还会重新参与调度,因此把"被换下去的这一刻"当成新一轮排队起点
if (prev->STATE_FIELD == TASK_RUNNING) {
tgid = prev->tgid;
pid = prev->pid;
if (!(FILTER || pid == 0)) {
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
}
}
// next 现在要真正上 CPU,说明它的等待结束
tgid = next->tgid;
pid = next->pid;
if (FILTER || pid == 0)
return 0;
// 去 start 里找它最早入队时记录的时间戳
u64 *tsp, delta;
tsp = start.lookup(&pid);
if (tsp == 0) {
return 0; // 没查到,说明漏采了入队事件
}
// 当前时刻 - 入队时刻 = run queue latency
delta = bpf_ktime_get_ns() - *tsp;
// FACTOR 也是占位符:后面按参数替换成 us/ms 单位换算
FACTOR
// STORE 也是占位符:后面替换成"如何把样本写进 histogram"
STORE
// 样本处理完,删除 start 里的旧时间戳
start.delete(&pid);
return 0;
}
"""
# -------------------------------------------------
# 3. Python 侧按参数替换占位符,决定统计口径和展示维度
# -------------------------------------------------
if args.pid:
bpf_text = bpf_text.replace('FILTER', 'tgid != %s' % args.pid)
else:
bpf_text = bpf_text.replace('FILTER', '0')
if args.milliseconds:
# 纳秒 -> 毫秒
bpf_text = bpf_text.replace('FACTOR', 'delta /= 1000000;')
label = "msecs"
else:
# 纳秒 -> 微秒
bpf_text = bpf_text.replace('FACTOR', 'delta /= 1000;')
label = "usecs"
if args.pids or args.tids:
# 按 pid/tid 分组:key 不再只是 slot,而是 {id, slot}
section = "pid"
pid = "tgid"
if args.tids:
pid = "pid"
section = "tid"
bpf_text = bpf_text.replace(
'STORAGE',
'BPF_HISTOGRAM(dist, pid_key_t, MAX_PID);'
)
bpf_text = bpf_text.replace(
'STORE',
'pid_key_t key = {}; key.id = ' + pid +
'; key.slot = bpf_log2l(delta); dist.increment(key);'
)
else:
# 全局 histogram:只按延迟桶统计,不拆 pid
section = ""
bpf_text = bpf_text.replace('STORAGE', 'BPF_HISTOGRAM(dist);')
bpf_text = bpf_text.replace(
'STORE',
'dist.atomic_increment(bpf_log2l(delta));'
)
# -----------------------------------
# 4. 用户态:定时把 histogram 打出来
# -----------------------------------
dist = b.get_table("dist")
while True:
sleep(int(args.interval))
print()
dist.print_log2_hist(label, section, section_print_fn=int)
# 这一轮打印完清空,下一轮重新累计
dist.clear()
看一下设计到的数据结构:
BPF_HASH 和 BPF_HISTOGRAM 到底是什么,可以先把它们理解成:BCC 帮你封装好的两类常用 map 宏
BPF_HASH(start, u32): 即我要一张哈希表,用 pid 当 key,把这个 pid 对应的时间戳存起来
在 runqlat 里,它的角色非常明确:
- key:
u32 pid - value:时间戳
ts - 用途:把"开始排队"和"真正上 CPU"这两个时刻串起来
所以它本质上是一个临时状态表,常见操作就 3 个:
c
start.update(&pid, &ts); // 写入开始时间
u64 *tsp = start.lookup(&pid); // 读取开始时间
start.delete(&pid); // 用完删除
如果你把 opensnoop 里的 infotmp 和这里的 start 对比一下,会发现两者虽然用途不同,但模式很像:
opensnoop:临时存"入口参数"runqlat:临时存"开始时间戳"
也就是说,BPF_HASH 在 BCC 里经常承担一个很关键的角色:把两个不同时间点采到的信息,靠 key 串起来
BPF_HISTOGRAM(dist) 更适合先理解成:我要一张专门做直方图统计的 map,把样本按桶累计起来,在 runqlat 里,它不是拿来保存某一条事件详情,而是拿来做:
- 样本分桶
- 桶内计数
- 用户态按
log2 histogram方式打印
最常见的配套操作就是:
c
u64 slot = bpf_log2l(delta);
dist.atomic_increment(slot);
意思非常直接:
delta是某次排队等待时长bpf_log2l(delta)把这个时长映射到一个对数桶编号dist.atomic_increment(slot)给这个桶的计数加一
可以把 runqlat 里的两张 map 记成这个分工
BPF_HASH(start, u32):临时状态表,负责"记起点"BPF_HISTOGRAM(dist, ...):聚合结果表,负责"按桶累计"
一个负责把前后时刻连起来,一个负责把很多样本堆成分布图。这也是为什么 runqlat 这种工具比 execsnoop 更像"统计器",而不是"事件打印器"。它和 execsnoop 最大的不同是什么,execsnoop 更像发生一次动作,送出一条事件,用户态打印一行,而 runqlat 更像:
- 某个时刻先记一个开始时间
- 另一个时刻再取出来算差值
- 把差值塞进直方图统计
- 用户态定期把整张统计结果打出来
也就是说,它不是"一次动作对应一行文本",而是:很多次样本共同堆出一张分布图,时间戳采样 -> 差值计算 -> 直方图聚合 -> 周期性输出,它不是在追每一次调度事件的文本细节,而是在持续收集等待时间样本,并把这些样本堆成一张延迟分布图
tcptop 的源码第一次应该怎么看
前面你已经看过两类很典型的 BCC 工具:
opensnoop:事件流工具,适合看"谁做了什么"runqlat:分布工具,适合看"延迟样本落在哪些区间"
到 tcptop 这里,你要开始建立第三种观察模型:排行类工具不是追单次事件,也不是做区间分布,而是在一个刷新周期里把同类对象累计起来,再按总量排序输出
tcptop: https://github.com/iovisor/bcc/blob/master/tools/tcptop.py
这一点最好和开头那句"BCC 工具常见三种输出"对上:
- 事件流:一条事件一条输出
- 分布:很多样本堆成直方图
- 排行:很多样本先累计,再按强弱排序
tcptop 和 biotop 很像,它们都属于这第三类。第一次看 tcptop,不要先盯住网络协议细节,而要先回答:
它到底是按什么 key 聚合,按什么值累计,又是谁在用户态做排序,先看现象:为什么它一上来就和前两类工具长得不一样
bash
┌──[root@liruilongs.github.io]-[~]
└─$sudo /usr/share/bcc/tools/biotop
18:00:02 loadavg: 1.08 0.52 0.27 1/436 68084
PID COMM D MAJ MIN DISK I/O Kbytes AVGms
68041 kworker/u256:1 W 259 0 nvme0n1 1 8.0 0.35
Detaching...
┌──[root@liruilongs.github.io]-[~]
└─$
┌──[root@liruilongs.github.io]-[~]
└─$sudo /usr/share/bcc/tools/tcptop
18:00:33 loadavg: 1.18 0.60 0.31 1/436 68092
PID COMM LADDR RADDR RX_KB TX_KB
39301 b'sshd' 192.168.66.69:22 192.168.66.11:56851 0 0
┌──[root@liruilongs.github.io]-[~]
└─$
它不是像 opensnoop 一样,发生一次就打出一行,它也不是像 runqlat 一样,最后给你一张桶分布图,它是在一个时间窗口里,把每条 TCP 会话的收发字节先累计起来,然后用户态按总流量排个序,再刷新屏幕输出
所以 tcptop 最值得学的不是"TCP 字段很多",而是:排行类工具的源码组织,和事件流/直方图工具是不一样的,第一次看源码,建议只回答这 5 个问题
- 它是按什么 key 聚合的
- 它累计的 value 是什么
- 内核态是在什么时机更新累计值的
- 用户态在哪个周期把累计值取出来并清空
- 排行动作到底发生在内核态还是用户态
只要这 5 个问题先立住,tcptop 你就不会看散。
第一个最该先定位的点:它的 key 到底是什么
先看它在 BPF 里定义的 key:
c
struct ipv4_key_t {
u32 pid;
char name[TASK_COMM_LEN];
u32 saddr;
u32 daddr;
u16 lport;
u16 dport;
};
BPF_HASH(ipv4_send_bytes, struct ipv4_key_t);
BPF_HASH(ipv4_recv_bytes, struct ipv4_key_t);
第一次可以把它直接翻译成:一条 TCP 会话 + 谁在用它,也就是pid,comm`,本地地址 远端地址,本地端口 / 远端端口
这说明 tcptop 排行时,不是简单按"进程总网络流量"排,而是按:
某个进程下的某条 TCP 会话来累计收发字节。这和 runqlat 的 key 很不一样:
runqlat关心的是 "pid -> delay bucket"tcptop关心的是 "会话 key -> send/recv bytes"
所以第一次看 tcptop,你最该先留意的是:它的 key 已经天然带了"排行维度"
它累计的值不是"次数",而是"字节数"
再看更新逻辑:
c
if (family == AF_INET) {
struct ipv4_key_t ipv4_key = {.pid = pid};
bpf_get_current_comm(&ipv4_key.name, sizeof(ipv4_key.name));
bpf_probe_read_kernel(&ipv4_key.saddr, sizeof(ipv4_key.saddr),
&sk->__sk_common.skc_rcv_saddr);
bpf_probe_read_kernel(&ipv4_key.daddr, sizeof(ipv4_key.daddr),
&sk->__sk_common.skc_daddr);
bpf_probe_read_kernel(&ipv4_key.lport, sizeof(ipv4_key.lport),
&sk->__sk_common.skc_num);
bpf_probe_read_kernel(&dport, sizeof(dport), &sk->__sk_common.skc_dport);
ipv4_key.dport = ntohs(dport);
if (is_send) {
ipv4_send_bytes.increment(ipv4_key, size);
} else {
ipv4_recv_bytes.increment(ipv4_key, size);
}
}
这里可以看到:
key是 TCP 会话身份,value不是 1,不是次数,value是这次 send/recv 的字节数size,每发生一次 send/recv,就往对应会话 key 上继续累加
所以它本质上更像:session_key -> 本周期累计发送/接收字节,这就是排行工具和事件流工具最核心的差别之一:
- 事件流工具:更关心"每次发生了什么"
- 排行工具:更关心"这段时间内谁累计得最多"
为什么这里也有"前后阶段拼接"
很多人第一次会以为只有 opensnoop 这种 syscall 工具才需要"入口/返回配合"。其实 tcptop 也有这个味道。
它用了两张临时表:
c
BPF_HASH(sock_send, u32, struct sock *);
BPF_HASH(sock_recv, u32, struct sock *);
还有两段对应的探针:
c
KFUNC_PROBE(tcp_sendmsg, struct sock *sk, struct msghdr *msg, size_t size)
{
u32 tid = bpf_get_current_pid_tgid();
sock_send.update(&tid, &sk);
return 0;
}
KRETFUNC_PROBE(tcp_sendmsg, struct sock *sk, struct msghdr *msg,
size_t size, int ret)
{
if (ret > 0)
return tcp_stat(ret, true);
else
return 0;
}
第一次可以把它理解成:
- 入口探针:先把
struct sock *暂存下来 - 返回探针:拿到真正成功发送的字节数
ret - 然后再去做"按会话 key 累加字节"
也就是说,tcptop 这里依然有"前后阶段拼接"的影子,只是它拼出来的不是一条事件,而是一笔累计统计。这一点很值得和前面两节对照着记:
opensnoop:拼的是"路径 + 返回值"runqlat:拼的是"开始时间 + 结束时间"tcptop:拼的是"socket 身份 + 实际传输字节数"
这会帮你形成一个更稳的感觉:不同工具虽然最终输出形式不同,但很多时候都要先把分散在不同时间点的信息接起来
排行不是内核自动排好的,是用户态取出来再排
这是第一次特别容易误判的地方。
很多人看到 tcptop 输出像 top,就会下意识以为"内核里已经把 top 排序做好了"。其实不是。
用户态逻辑大意是这样:
python
ipv4_throughput = defaultdict(lambda: [0, 0])
for k, v in ipv4_send_bytes.items():
key = get_ipv4_session_key(k)
ipv4_throughput[key][0] = v.value
for k, v in ipv4_recv_bytes.items():
key = get_ipv4_session_key(k)
ipv4_throughput[key][1] = v.value
for k, (send_bytes, recv_bytes) in sorted(
ipv4_throughput.items(),
key=lambda kv: sum(kv[1]),
reverse=True):
print(...)
第一次你要抓住这几件事:
- 内核态只负责累计
- 用户态把 send map 和 recv map 合并起来
- 用户态按
RX + TX的总量做排序 - 最后才打印成你看到的 top 风格表格
所以 tcptop 的源码阅读重点,不是"它是不是在内核里维护了一个排名榜",而是:它在内核里维护累计值,在用户态做汇总和排序
为什么它每轮都像"重置后重新统计
这和排行类工具的观察方式直接相关。
用户态每轮 sleep(interval) 之后,会把本轮累计值取出来,再清掉:
python
for k, v in ipv4_send_bytes.items():
...
ipv4_send_bytes.clear()
for k, v in ipv4_recv_bytes.items():
...
ipv4_recv_bytes.clear()
这意味着:
- 你看到的不是"程序启动以来的累计总字节"
- 而是"当前刷新周期内的流量排行"
这也是为什么它看起来更像 top:每个周期重新采一轮、重新排一次、重新展示一次,本周期发生的 send/recv -> 按会话 key 累加字节 -> 周期结束取出 -> 用户态排序 -> 刷新输出
如果把 tcptop 和前面两类工具压成一张对照表
opensnoop:事件流,重点看一条事件怎么形成runqlat:分布统计,重点看样本怎么落桶tcptop:排行统计,重点看累计值怎么聚合、怎么按总量排序
所以第一次看完 tcptop,你最好能在脑子里留下这句话:
排行类工具的关键,不是把单条事件打印清楚,而是先选对聚合 key,再在一个刷新窗口里把 value 累加起来,最后由用户态按总量排序输出
第一次自己改 BCC 工具如何改?
前面你已经分别看了:
opensnoop:事件流runqlat:分布tcptop:排行
所以到了"第一次自己改工具"这一步,最好不要直接上来改最复杂的那个,而是要顺着前面的结构
Demo :改 killsnoop,加一个"只看某个 signal"的过滤
为什么这个工具很适合第一次改:
- 输出是事件流,很直观
- 字段很少,链路短
- 过滤条件语义非常清楚
你看到的原始输出,大意像:
text
TIME PID COMM SIG TPID RESULT
12:01:10 1234 bash 15 5678 0
12:01:11 2234 systemd 9 8888 0
第一次很适合做一个这样的最小改动:
只看 SIGKILL(9)或者 SIGTERM(15)
你真正要改的链路通常很短:
- 内核态入口已经能拿到
sig - 在 BPF 侧加一个简单过滤
- 用户态几乎不用动,或者只补个参数解析
如果把思路写成伪代码,大意就是:
c
if (sig != 9)
return 0;
这个 Demo 最值钱的地方不是"过滤 signal 很高级",而是:
你第一次开始明确知道:过滤最好尽量前置到内核态,这样用户态拿到的数据量会直接变少这会帮你以后看任何 BCC 工具时,都下意识先问一句:
这个过滤应该加在内核态,还是只是用户态打印时再筛一遍
BCC 特别适合前期观察和快速验证,但它不是所有场景下的最终工程答案。它的边界主要在:
- 环境依赖相对更强
- 更偏快速工具和脚本
- 在现代
CO-RE主干工程能力上,不如libbpf路线那样直接
所以更准确的定位应该是BCC帮你先会看问题,libbpf帮你把现代工程主干学扎实
最后总结
BCC 最重要的价值,不是让你背工具名,而是帮你先形成"问题该怎么被观察"的直觉,所以第一阶段你要重点做到:会看事件流,会看分布,会看排行,会从输出反推问题模型,会做一次最小改动,做到这一步,你再进入 libbpf 或 ebpf-go,会顺很多。
博文部分内容参考
© 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 😃
© 2018-至今 liruilonger@gmail.com, 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)