这篇是我学习 Linux 底软项目的第一篇阶段记录。项目源码是一个基于 BCC/eBPF 的运行时异常观测工具集,当前先学习了三个核心模块:sched_guard.pymem_guard.pyfs_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,安装了 bccclangbpftool 等工具。

最开始用 execsnoop-bpfcc 做了 BCC 基础验证:

sudo execsnoop-bpfcc

另开终端执行 datels 等命令,如果 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

可以看到类似输出:

=img-U68QdSDI-1779192768914)


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_startOP_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. 当前阶段的收获和不足

收获

  1. 跑通了 BCC/eBPF 基础环境;
  2. 理解了 BCC 和 eBPF 的基本关系;
  3. 学会了 tracepoint、kprobe、kretprobe 的基本区别;
  4. 初步理解了 off-CPU、direct reclaim、order、VFS、fsync 等概念;
  5. 跑通了 sched_guard.pymem_guard.pyfs_guard.py
  6. 通过 dd 实际验证了 fs_guard.py 能捕获慢 write 和慢 fsync。

不足

  1. mem_guard.py 还没有稳定触发 direct reclaim 输出,需要后续设计更合适的内存压力场景;
  2. 当前三个模块还主要是理解和运行阶段,后续需要增加 CSV 输出、README、实验报告和面试问答;
  3. dumpctl.py 还没有系统学习,它应该是后续把“异常检测”变成“现场采集闭环”的关键模块;
  4. 当前源码里的默认 dumpctl 路径是 ./dump/dumpctl.py,但实际项目里是 ./dumpctl.py,后面可以作为一个小修复点。

8. 下一步计划

下一步准备继续学习:

1. dumpctl.py:异常现场采集模块
2. lkm-fm:fmem 内核模块,了解即可,不作为第一阶段主线
3. 给 sched/mem/fs 三个模块增加 CSV 输出
4. 整理 README 和面试问答

当前第一篇文章就先记录到这里。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐