第14章:内核

📑 目录

内核(Kernel)不是一座孤岛——你写的每一行应用代码,最终都会变成系统调用、调度决策和中断处理,而 BPF 让你在不被内核吞噬的情况下,看清它的每一次心跳。


故事场景:老张的"中断风暴"

周一早晨,老张刚到工位,小李就冲了过来:"线上告警!CPU 软中断(SoftIRQ,软中断)飙到80%,业务延迟暴涨,但 top 看不到哪个进程在耗CPU!"

老张打开终端,cat /proc/interrupts 发现 NET_RX 每秒暴增到几十万次——网卡中断 flood。

"先别急着调网卡队列数,"老张边说边敲命令,"看看中断处理的调用路径。"

# 用 BPF 追踪软中断的处理函数入口,打印调用栈
sudo bpftrace -e 'kprobe:__softirq_entry {
    @[kstack] = count();
}'

输出指向了一个异常的网卡驱动函数:ixgbe_poll() 被调用的频率是平时的20倍。再一追查,是某个新上线的容器镜像里,一个日志采集组件开启了 busy-polling 模式,每微轮询一次网卡——不是为了收包,只是因为开发者误把 busy_poll 设成了 1(微秒)而不是 -1(禁用)。

"内核不会撒谎,"老张把结果截图丢到群里,"但内核也不会主动告诉你’这是谁干的’。BPF 就是那个翻译官。"

小李挠头:"可 __softirq_entry 这种函数,我连名字都不知道……"

"所以才要学。"老张指着屏幕,"你看,这个 kprobe 就像在内核函数的入口放了个摄像头。没有它,这个问题我们得猜三天。"


核心内容

14.1 内核分析背景与传统工具

在 BPF 出现之前,Linux 内核的可观测性主要依赖三类工具:

工具类型代表工具能力边界
内核日志dmesg, printk只能输出文本,频率受限,生产环境风险高
伪文件系统/proc, /sys快照式数据,无法追踪事件流
追踪框架ftrace功能强大但接口复杂,原生只支持内核态
graph LR
    subgraph "传统内核工具"
        A1[dmesg/printk] --> A2[文本日志,侵入式]
        B1[/proc /sys] --> B2[快照,无上下文]
        C1[ftrace] --> C2[强大但复杂]
    end
    subgraph "BPF带来的变革"
        D1[可编程追踪] --> D2[内核+用户态联动]
        D2 --> D3[安全沙箱,生产可用]
        D3 --> D4[纳秒级开销]
    end
    A2 --> D1
    B2 --> D1
    C2 --> D1

ftrace(Function Tracer,函数追踪器)是 Linux 内核自带的追踪框架,它通过 GCC 的 -pg 编译选项在内核函数入口插入 mcount 桩(Stub,桩代码),可以在运行时动态启用/禁用特定函数的追踪。ftrace 的核心能力包括:

  • 函数追踪:记录每个内核函数的进入/退出时间戳
  • 函数图(Function Graph):追踪函数的调用关系树
  • 事件追踪(Event Tracing):基于内核中预定义的 tracepoint(静态追踪点)输出结构化事件
  • 动态探针(Kprobe):运行时插入到任意内核函数

BPF 没有取代 ftrace——事实上,BPF 的很多底层机制依赖 ftrace 提供的 kprobe/tracepoint 基础设施。BPF 的价值在于可编程性:ftrace 的输出是固定的文本格式,而 BPF 可以在内核态对事件进行过滤、聚合、关联,只把加工后的结果送到用户态。

# ftrace 的经典用法:追踪 schedule() 函数的调用栈(文本输出)
echo 0 > /sys/kernel/debug/tracing/tracing_on
echo schedule > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/options/func_stack_trace
echo 1 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace_pipe

# BPF 的等价做法:只统计 schedule() 的调用频率,并按调用栈聚合
sudo bpftrace -e 'kprobe:schedule { @[kstack] = count(); }'

14.2 BPF 内核工具:kprobe、tracepoint 与进阶探针

BPF 提供了四种 attach 到内核函数的机制,按稳定性和开销排序:

graph TD
    subgraph "BPF内核探针类型"
        direction TB
        A1[tracepoint] --> A2[静态定义,最稳定,首选]
        B1[kprobe] --> B2[动态插入,最灵活,次选]
        C1[rawtracepoint] --> C2[绕过tracepoint封装,更低开销]
        D1[fentry/fexit] --> D2[BTF-based,最低开销,Linux 5.5+]
    end
    subgraph "选择优先级"
        E1[有tracepoint?] -- 是 --> E2[用tracepoint]
        E1 -- 否 --> E3[有BTF?]
        E3 -- 是 --> E4[用fentry/fexit]
        E3 -- 否 --> E5[用kprobe/kretprobe]
        E5 -- 不稳定 --> E6[用rawtracepoint若可用]
    end

14.2.1 kprobe / kretprobe

kprobe(Kernel Probe,内核探针)是 BPF 最通用的内核追踪机制。它在运行时通过替换目标函数的前几条指令为跳转指令,把执行流导向 BPF 程序。这种动态插桩不需要重新编译内核,几乎可以对任何内核函数生效——但代价是** ABI(Application Binary Interface,应用二进制接口)稳定性风险**:内核版本升级后函数签名可能改变,导致 BPF 程序失效。

// 使用 kprobe 追踪 openat 系统调用
SEC("kprobe/__x64_sys_openat")
int BPF_KPROBE(trace_openat, int dfd, const char *filename, int flags) {
    char buf[256];
    bpf_probe_read_user_str(buf, sizeof(buf), (void *)filename);
    bpf_printk("openat: %s", buf);
    return 0;
}

kretprobe(Kernel Return Probe,内核返回探针)则 attach 到函数的返回点,可以读取返回值。它的实现比 kprobe 更复杂:内核在函数入口临时替换返回地址,把执行流导向一个 trampoline(跳板,Trampoline)函数,再由 trampoline 调用 BPF 程序。

14.2.2 tracepoint

tracepoint(静态追踪点)是内核开发者显式埋下的观测点, ABI 稳定,不会随内核版本消失或改变签名。它们位于内核的关键路径上:sched_switch(调度切换)、irq_handler_entry(中断处理入口)、block_rq_issue(块设备请求下发)、net_dev_queue(网络包入队)等。

# 列出所有可用的 tracepoint
ls /sys/kernel/debug/tracing/events/ | head -20

# 用 bpftrace 追踪 sched_switch 事件
sudo bpftrace -e 'tracepoint:sched:sched_switch {
    printf("%s -> %s\n", args->prev_comm, args->next_comm);
}'

tracepoint 的优势是语义清晰性能开销低(不需要动态指令替换)、跨版本兼容。缺点是内核中的 tracepoint 数量是有限的——大约有1000+个,覆盖调度、中断、I/O、网络、内存管理等核心子系统,但并非每个函数都有 tracepoint。

14.2.3 fentry / fexit

fentry(Function Entry,函数入口)和 fexit(Function Exit,函数出口)是 BPF 最先进的内核探针类型,依赖内核的 BTF(BPF Type Format,BPF类型格式) 信息。BTF 包含了内核函数的原型(参数类型、返回值类型),BPF 验证器可以根据 BTF 自动校验程序的参数访问是否合法。

// 使用 fentry 追踪 do_nanosleep(BTF-based,无需手动 probe_read)
SEC("fentry/do_nanosleep")
int BPF_PROG(trace_nanosleep, struct hrtimer_sleeper *t, enum hrtimer_mode mode) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    bpf_printk("PID %d entering do_nanosleep, mode=%d", pid, mode);
    return 0;
}

SEC("fexit/do_nanosleep")
int BPF_PROG(trace_nanosleep_exit, struct hrtimer_sleeper *t, 
             enum hrtimer_mode mode, int ret) {
    bpf_printk("do_nanosleep returned %d", ret);
    return 0;
}

fentry/fexit 的核心优势:

  1. 零额外指令替换:直接利用内核编译时生成的 mcount 桩,比 kprobe 的动态二进制补丁开销更低
  2. 类型安全:BTF 让 BPF 验证器理解内核数据结构,无需 bpf_probe_read() 就可以安全地解引用指针
  3. 可访问函数参数和返回值:fentry 访问参数如同普通 C 函数,fexit 可以直接读取返回值
sequenceDiagram
    participant Kernel as 内核函数
    participant Fentry as BPF fentry程序
    participant Fexit as BPF fexit程序
    participant User as 用户态程序
    
    Note over Kernel: 编译时插入mcount桩
    Kernel->>Fentry: 进入函数,触发fentry
    Fentry->>Fentry: 读取参数(BTF类型安全)
    Fentry->>Kernel: 返回
    Kernel->>Kernel: 执行函数体
    Kernel->>Fexit: 返回前,触发fexit
    Fexit->>Fexit: 读取返回值
    Fexit->>Kernel: 返回
    Kernel->>User: 函数返回

14.3 内核调用栈:从火焰图到根因

内核调用栈(Kernel Call Stack)是性能分析的核武器。当 CPU 被软中断吃满、当 sys_cpu_usage 飙升但进程名是 kworker 时,唯一的办法就是抓调用栈

BPF 获取内核调用栈有两种方式:

  1. bpf_get_stackid():把调用栈哈希成一个 ID,用于聚合统计(如"哪些调用路径导致了最多的缺页中断")
  2. bpf_get_stack():获取原始调用栈地址数组,可以配合用户态符号解析输出函数名
// 统计导致 page fault 的内核调用路径
struct {
    __uint(type, BPF_MAP_TYPE_STACK_TRACE);
    __uint(max_entries, 1024);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(u64) * 64);  // 64层栈深
} stackmap SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);     // stack_id
    __type(value, u64);   // count
} fault_count SEC(".maps");

SEC("kprobe/handle_mm_fault")
int BPF_KPROBE(trace_page_fault) {
    u64 *count;
    u32 stack_id = bpf_get_stackid(ctx, &stackmap, BPF_F_FAST_STACK_CMP);
    if (stack_id > 0) {
        count = bpf_map_lookup_elem(&fault_count, &stack_id);
        if (count) {
            (*count)++;
        } else {
            u64 one = 1;
            bpf_map_update_elem(&fault_count, &stack_id, &one, BPF_ANY);
        }
    }
    return 0;
}

用户态程序读取 fault_count map 和 stackmap 后,可以用 /proc/kallsyms 解析栈地址到函数名,最终生成内核火焰图(Kernel Flame Graph)——一张水平条形图,宽度代表采样次数,层级代表调用深度。

# 用 bpftrace 一行命令生成内核调用栈直方图
sudo bpftrace -e 'kprobe:handle_mm_fault {
    @[kstack] = count();
}'

# 输出示例(已符号解析):
# @[
#     handle_mm_fault+1
#     do_page_fault+89
#     page_fault+69
#     __get_user_pages+456
#     get_user_pages_unlocked+89
#     __gfn_to_pfn_memslot+234
#     kvm_vcpu_map+123
#     vmx_handle_exit+456
# ]: 28473

14.4 内核锁分析

内核中的锁(Lock)是并发编程的基础设施,但也是性能问题的经典源头:

  • 锁争用(Lock Contention):多个 CPU 核心争抢同一把自旋锁(Spinlock)或互斥锁(Mutex),导致 CPU 空转
  • 锁顺序死锁(Lock Order Deadlock):AB-BA 锁顺序导致循环等待
  • 锁持有时间过长:持有锁期间执行了阻塞操作(如 I/O)

BPF 可以 attach 到内核的锁函数上,精确测量锁的争用情况。以 mutex_lockmutex_unlock 为例:

# 追踪 mutex 的持锁时间和争用情况
sudo bpftrace -e '
kprobe:mutex_lock {
    @lock_start[tid] = nsecs;
    @lock_addr[tid] = arg0;
}
kprobe:mutex_lock /!@lock_start[tid]/ {
    // 已在等待,记录争用
    @contenders[arg0] = count();
}
kretprobe:mutex_unlock
/@lock_start[tid]/ {
    $duration = (nsecs - @lock_start[tid]) / 1000;
    @hold_time_us[@lock_addr[tid]] = hist($duration);
    delete(@lock_start[tid]);
    delete(@lock_addr[tid]);
}
'
flowchart TD
    subgraph "锁争用诊断流程"
        A1[发现CPU使用率异常] --> A2[kprobe mutex_lock/unlock]
        A2 --> A3[直方图:持锁时间分布]
        A3 --> A4{是否有长尾延迟?}
        A4 -- 是 --> A5[抓取持锁时的调用栈]
        A5 --> A6[定位锁内执行的慢操作]
        A4 -- 否 --> A7[分析争用计数]
        A7 --> A8{争用频繁?}
        A8 -- 是 --> A9[锁拆分或改用RCU]
        A8 --> A10[检查CPU缓存一致性]
    end

一个经典案例:某数据库内核模块的写锁持有时间呈现双峰分布——大部分在微秒级,但有1%的样本超过10毫秒。BPF 抓取这些长尾样本的调用栈后发现:持锁期间调用了 vfs_write(),而 vfs_write() 在 direct I/O 模式下会同步等待磁盘完成。解决方案是把 I/O 提交放到锁外。


总结

mindmap
  root((内核BPF分析))
    传统工具
      dmesg日志
      /proc /sys快照
      ftrace文本追踪
    BPF探针类型
      tracepoint静态追踪点
      kprobe动态探针
      rawtracepoint低开销
      fentry fexitBTF-based
    调用栈分析
      bpf_get_stackid
      内核火焰图
      缺页中断路径
    锁分析
      mutex_lock追踪
      持锁时间直方图
      争用计数
      死锁检测

核心要点:

  1. ftrace 是 BPF 的肩膀:BPF 没有取代 ftrace,而是站在它的肩膀上实现了可编程内核追踪。理解 ftrace 的 tracepoint/kprobe 基础设施,是高效使用 BPF 的前提。

  2. 探针选择有优先级:tracepoint > fentry/fexit > kprobe/kretprobe。能用静态追踪点就不用动态探针,能用 BTF-based 就不用指令替换——稳定性、开销、可维护性依次递增。

  3. 内核火焰图是定位"无进程CPU消耗"的唯一手段:当 top 显示高 CPU 但没有一个用户进程对得上时,抓内核调用栈——软中断、内核线程、驱动代码的真凶就藏在火焰图的顶层。

  4. 锁分析的黄金指标是"持锁时间分布"而非平均:平均持锁1微秒的锁,如果有0.1%的长尾到10毫秒,就足以摧毁延迟敏感型业务。BPF 直方图能暴露这种长尾。

  5. BTF 是 Linux 内核可观测性的基础设施:没有 BTF,fentry/fexit 不可用,BPF 程序需要手写 bpf_probe_read(),类型安全无从谈起。发行版编译内核时开启 CONFIG_DEBUG_INFO_BTF=y 是现代化运维的底线。