eBPF/BCC Linux 运行时异常观测项目学习记录 01:从调度、内存到文件系统 I/O
这篇是我学习 Linux 底软项目的第一篇阶段记录。项目源码是一个基于 BCC/eBPF 的运行时异常观测工具集,当前先学习了三个核心模块:
sched_guard.py、mem_guard.py和fs_guard.py。我的目标不是单纯把脚本跑起来,而是逐步理解它们在观测什么、怎么观测、输出代表什么,以及后续怎么把它整理成可以写进简历、可以面试展开的项目。
1. 项目目前的整体理解
这个项目可以暂时理解为一个 Linux 运行时异常观测工具集。它不是业务应用,而是通过 eBPF 在内核事件路径上采集信息,用来观察系统中可能导致卡顿的几类问题。
当前学习的三个模块分别是:
| 模块 | 关注问题 | 挂载方式 | 典型输出 |
|---|---|---|---|
sched_guard.py |
线程长时间 off-CPU | tracepoint:sched:sched_switch |
SCHED_OFFCPU |
mem_guard.py |
direct reclaim 耗时过长 | tracepoint:vmscan:mm_vmscan_direct_reclaim_begin/end |
MEM_DIRECT_RECLAIM |
fs_guard.py |
write/fsync 文件 I/O 慢调用 |
kprobe/kretprobe:vfs_write/vfs_fsync_range |
VFS_WRITE / VFS_FSYNC |
我现在对它的主线理解是:
应用或系统出现卡顿
↓
调度层:线程是否长时间 off-CPU?
↓
内存层:是否卡在 direct reclaim?
↓
文件系统层:是否卡在 write/fsync?
↓
后续通过 dumpctl.py 保存现场,再结合 /proc、dmesg、日志继续分析
2. 环境验证:先确认 BCC/eBPF 能跑
我是在 Ubuntu 虚拟机里学习这个项目。环境大致是 Ubuntu 22.04,内核 6.8,安装了 bcc、clang、bpftool 等工具。
最开始用 execsnoop-bpfcc 做了 BCC 基础验证:
sudo execsnoop-bpfcc
另开终端执行 date、ls 等命令,如果 execsnoop-bpfcc 能捕获到进程执行事件,说明 BCC 基础链路是通的。

这里我先建立了一个基本认识:
eBPF:运行在 Linux 内核事件点上的小程序机制。
BCC:帮助我们编写、编译、加载 eBPF 程序,并把结果传回用户态的开发框架。
在这个项目里,常见的数据流是:
Python 用户态程序
↓
通过 BCC 加载 eBPF C 代码
↓
eBPF 程序挂到 tracepoint / kprobe 上
↓
内核事件触发时执行 eBPF 逻辑
↓
通过 BPF map / perf buffer 传回用户态
↓
Python 打印日志或触发 dumpctl.py
3. sched_guard.py:观察线程长时间 off-CPU
第一个学习的是 sched_guard.py,它用于观察线程从被切出 CPU 到再次被调度回来之间等待了多久,也就是 off-CPU latency。
3.1 off-CPU 是什么
线程的时间可以粗略分成两类:
on-CPU:线程真正占用 CPU 执行代码的时间。
off-CPU:线程不在 CPU 上运行的等待时间。
线程可能因为 sleep、I/O 等待、锁等待、内存回收、调度竞争等原因离开 CPU。sched_guard.py 关注的就是:某个线程离开 CPU 后,隔了多久才重新被调度回来。
3.2 核心挂载点
它挂载的是:
tracepoint:sched:sched_switch
sched_switch 在 CPU 从一个线程切换到另一个线程时触发,里面有两个关键字段:
prev_pid:刚刚被切出 CPU 的线程。
next_pid:即将被调度上 CPU 的线程。
3.3 关键源码
核心 eBPF 逻辑大概如下:
struct offcpu_t {
u64 ts;
long state;
char comm[TASK_COMM_LEN];
};
struct event_t {
u64 ts;
u32 pid;
u64 delta_ns;
long state;
char comm[TASK_COMM_LEN];
};
BPF_HASH(offcpu_start, u32, struct offcpu_t);
BPF_PERF_OUTPUT(events);
offcpu_start 是一个 BPF 哈希表,用来保存线程被切出 CPU 时的时间戳、状态和线程名。
核心算法是:
TRACEPOINT_PROBE(sched, sched_switch)
{
u64 now = bpf_ktime_get_ns();
u32 prev_pid = args->prev_pid;
u32 next_pid = args->next_pid;
if (prev_pid > 0 && pid_allowed(prev_pid)) {
struct offcpu_t s = {};
s.ts = now;
s.state = args->prev_state;
__builtin_memcpy(&s.comm, args->prev_comm, TASK_COMM_LEN);
offcpu_start.update(&prev_pid, &s);
}
if (next_pid > 0 && pid_allowed(next_pid)) {
struct offcpu_t *sp = offcpu_start.lookup(&next_pid);
if (!sp)
return 0;
u64 delta = now - sp->ts;
if (delta >= LAT_NS) {
struct event_t e = {};
e.ts = now;
e.pid = next_pid;
e.delta_ns = delta;
e.state = sp->state;
__builtin_memcpy(&e.comm, sp->comm, TASK_COMM_LEN);
events.perf_submit(args, &e, sizeof(e));
}
offcpu_start.delete(&next_pid);
}
return 0;
}
理解下来,它的逻辑可以概括成:
线程被切出 CPU 时:记录时间。
线程重新上 CPU 时:查找之前的时间,计算 off-CPU latency。
超过阈值:通过 perf buffer 上报到 Python 用户态。
测试时运行:
sudo python3 sched_guard.py --lat-ms 50
可以看到类似输出:

4. mem_guard.py:观察 direct reclaim 是否过慢
第二个学习的是 mem_guard.py。它主要监控 Linux 内存分配路径中的 direct reclaim 是否耗时过长。
4.1 direct reclaim 是什么
Linux 系统里,进程申请内存时,如果空闲页不够,内核会尝试回收一些可回收页面。后台回收通常由 kswapd 负责,但如果当前申请内存的进程被迫自己进入回收路径,这就叫 direct reclaim。
可以这样理解:
kswapd reclaim:后台线程提前回收,尽量不阻塞应用。
direct reclaim:申请内存的进程自己回收,会阻塞当前进程。
所以 direct reclaim 如果耗时较长,就可能导致应用卡顿或延迟突增。
4.2 order 是什么
学习 mem_guard.py 时还接触到了 order。这个不是多级页表里的概念,而是 Linux 物理内存分配器 buddy system 里的概念。
Linux 通常按 4KB page 管理物理内存,order 表示申请连续物理页的阶数:
连续页数量 = 2^order
连续内存大小 = 2^order × PAGE_SIZE
例如 PAGE_SIZE 为 4KB 时:
order=0 -> 1 个 page -> 4KB
order=1 -> 2 个 page -> 8KB
order=4 -> 16 个 page -> 64KB
order=9 -> 512 个 page -> 2MB
order 越高,需要越大的连续物理内存块,也越容易受到物理内存碎片化影响。
4.3 核心源码
mem_guard.py 使用两个 vmscan tracepoint:
vmscan:mm_vmscan_direct_reclaim_begin
vmscan:mm_vmscan_direct_reclaim_end
核心数据结构是:
struct start_t {
u64 ts;
u32 order;
};
struct event_t {
u64 ts;
u32 pid;
u64 delta_ns;
u32 order;
char comm[TASK_COMM_LEN];
};
BPF_HASH(start, u32, struct start_t);
BPF_PERF_OUTPUT(events);
begin 事件中记录开始时间和 order:
TRACEPOINT_PROBE(vmscan, mm_vmscan_direct_reclaim_begin)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (!pid_allowed(pid))
return 0;
struct start_t s = {};
s.ts = bpf_ktime_get_ns();
s.order = args->order;
start.update(&pid, &s);
return 0;
}
end 事件中计算耗时并上报:
TRACEPOINT_PROBE(vmscan, mm_vmscan_direct_reclaim_end)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (!pid_allowed(pid))
return 0;
struct start_t *sp = start.lookup(&pid);
if (!sp)
return 0;
u64 delta = bpf_ktime_get_ns() - sp->ts;
u32 order = sp->order;
start.delete(&pid);
if (delta < LAT_NS)
return 0;
struct event_t e = {};
e.ts = bpf_ktime_get_ns();
e.pid = pid;
e.delta_ns = delta;
e.order = order;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
events.perf_submit(args, &e, sizeof(e));
return 0;
}
测试时运行:
sudo python3 mem_guard.py --lat-ms 1
可以看到程序成功启动:

这次测试没有稳定触发 MEM_DIRECT_RECLAIM 输出,我的理解是内存压力场景还不够强,或者 direct reclaim 没有超过阈值。后续需要用更合适的内存压力实验继续验证。
5. fs_guard.py:观察 VFS write/fsync 慢调用
第三个学习的是 fs_guard.py,它用于监控文件系统写入和同步落盘是否耗时过长。
5.1 VFS 是什么
VFS 是 Virtual File System,虚拟文件系统。它不是 ext4、xfs、btrfs 这种具体文件系统,而是 Linux 提供的一层统一文件系统抽象。
应用层调用:
open();
read();
write();
fsync();
close();
底层可能对应 ext4、xfs、tmpfs、procfs、nfs 等不同文件系统。VFS 的作用就是向上提供统一接口,向下分发到具体文件系统实现。
5.2 write 和 fsync 的区别
write() 返回成功,不一定代表数据已经真正写入磁盘,很多时候数据只是进入了 page cache。
fsync() 的作用是把文件相关的脏数据和必要元数据同步到存储设备。所以在测试中,fsync 往往比普通 write 更容易出现较长延迟。
5.3 kprobe / kretprobe
fs_guard.py 和前两个模块不同,它主要用的是 kprobe/kretprobe:
kprobe:挂在内核函数入口,用来记录函数开始时间。
kretprobe:挂在内核函数返回点,用来计算函数耗时,并读取返回值。
它监控两个内核函数:
vfs_write
vfs_fsync_range
5.4 关键源码
核心事件结构:
enum op_type {
OP_WRITE = 1,
OP_FSYNC = 2,
};
struct event_t {
u64 ts;
u32 pid;
u32 op;
u64 delta_ns;
s64 ret;
char comm[TASK_COMM_LEN];
};
BPF_HASH(write_start, u32, u64);
BPF_HASH(fsync_start, u32, u64);
BPF_PERF_OUTPUT(events);
统一上报函数:
static __always_inline void submit_event(void *ctx, u32 pid, u32 op, u64 delta, s64 ret)
{
if (delta < LAT_NS)
return;
struct event_t e = {};
e.ts = bpf_ktime_get_ns();
e.pid = pid;
e.op = op;
e.delta_ns = delta;
e.ret = ret;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
events.perf_submit(ctx, &e, sizeof(e));
}
write 入口和返回:
int trace_write_entry(struct pt_regs *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (!pid_allowed(pid))
return 0;
u64 ts = bpf_ktime_get_ns();
write_start.update(&pid, &ts);
return 0;
}
int trace_write_return(struct pt_regs *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (!pid_allowed(pid))
return 0;
u64 *tsp = write_start.lookup(&pid);
if (!tsp)
return 0;
u64 delta = bpf_ktime_get_ns() - *tsp;
write_start.delete(&pid);
submit_event(ctx, pid, OP_WRITE, delta, PT_REGS_RC(ctx));
return 0;
}
fsync 的逻辑类似,只是使用 fsync_start 和 OP_FSYNC。
Python 用户态 attach kprobe/kretprobe:
if not args.no_write:
if has_kprobe("vfs_write"):
b.attach_kprobe(event="vfs_write", fn_name="trace_write_entry")
b.attach_kretprobe(event="vfs_write", fn_name="trace_write_return")
PT_REGS_RC(ctx) 用来读取函数返回值。对 vfs_write 来说,返回值通常是写入字节数;对 vfs_fsync_range 来说,0 表示成功,负数表示错误码。
5.5 测试结果
运行:
sudo python3 fs_guard.py --lat-ms 1
另一个终端执行:
dd if=/dev/zero of=/tmp/fs_test.bin bs=4M count=64 conv=fsync
实际日志中捕获到了大量 VFS_WRITE 和一次明显的 VFS_FSYNC:
VFS_WRITE pid=63229 comm=dd lat= 6.033 ms ret=4194304
VFS_WRITE pid=63229 comm=dd lat= 109.823 ms ret=4194304
VFS_WRITE pid=63229 comm=dd lat=119.341 ms ret=4194304
VFS_FSYNC pid=63229 comm=dd lat=614.464 ms ret=0
这里 ret=4194304 正好对应 bs=4M 的写入大小;VFS_FSYNC 耗时 614ms,说明最后同步落盘确实比普通 write 更慢。
完整测试日志已保存在:
assets/fs_guard_output.txt
6. 这三个模块之间的联系
学习完三个模块后,我觉得这个项目的第一层结构已经比较清楚了。
sched_guard.py:
观察线程有没有长时间 off-CPU。
mem_guard.py:
观察进程有没有卡在 direct reclaim 内存回收路径。
fs_guard.py:
观察进程有没有卡在 write/fsync 文件 I/O 路径。
它们底层都有一个共同模式:
记录开始时间
↓
记录结束时间
↓
计算 delta
↓
超过阈值
↓
通过 perf buffer 上报用户态
↓
Python 打印日志 / 可选触发 dumpctl
区别在于挂载点不同:
sched_guard.py 使用 tracepoint:sched:sched_switch
mem_guard.py 使用 tracepoint:vmscan:mm_vmscan_direct_reclaim_begin/end
fs_guard.py 使用 kprobe/kretprobe:vfs_write/vfs_fsync_range
7. 当前阶段的收获和不足
收获
- 跑通了 BCC/eBPF 基础环境;
- 理解了 BCC 和 eBPF 的基本关系;
- 学会了 tracepoint、kprobe、kretprobe 的基本区别;
- 初步理解了 off-CPU、direct reclaim、order、VFS、fsync 等概念;
- 跑通了
sched_guard.py、mem_guard.py和fs_guard.py; - 通过
dd实际验证了fs_guard.py能捕获慢 write 和慢 fsync。
不足
mem_guard.py还没有稳定触发 direct reclaim 输出,需要后续设计更合适的内存压力场景;- 当前三个模块还主要是理解和运行阶段,后续需要增加 CSV 输出、README、实验报告和面试问答;
dumpctl.py还没有系统学习,它应该是后续把“异常检测”变成“现场采集闭环”的关键模块;- 当前源码里的默认
dumpctl路径是./dump/dumpctl.py,但实际项目里是./dumpctl.py,后面可以作为一个小修复点。
8. 下一步计划
下一步准备继续学习:
1. dumpctl.py:异常现场采集模块
2. lkm-fm:fmem 内核模块,了解即可,不作为第一阶段主线
3. 给 sched/mem/fs 三个模块增加 CSV 输出
4. 整理 README 和面试问答
当前第一篇文章就先记录到这里。
更多推荐




所有评论(0)