Skip to content

Latest commit

 

History

History
330 lines (226 loc) · 17.6 KB

File metadata and controls

330 lines (226 loc) · 17.6 KB

eBPF kprobe 浅尝

引出问题


eBPF 中使用 kprobe 捕获 unlink 系统调用.

和前面说使用跟踪点 tracepoint 的技术不同,这次将使用 kprobe 来捕获 unlink 系统调用.

kprobe 是一种用于在内核中插入探测点的技术,它允许在内核中的任何函数中插入探测点,并在函数调用前后执行自定义的回调函数.

解决方案


开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等. 比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程.

而利用 kprobes 技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中(有些函数是不可探测的,例如 kprobes 自身的相关实现函数,后文会有详细说明)动态地插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程. 如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态地移除探测点. 因此 kprobes 技术具有对内核执行流程影响小和操作方便的优点.

kprobes 技术包括的 3 种探测手段分别时 kprobejprobekretprobe.

  • kprobe 是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错 3 种回调方式,分别是 pre_handlerpost_handlerfault_handler,其中 pre_handler 函数将在被探测指令被执行前回调,post_handler 会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler 会在内存访问出错时被调用;

  • jprobe 基于 kprobe 实现,它用于获取被探测函数的入参值;

  • kretprobe 从名字中就可以看出其用途了,它同样基于 kprobe 实现,用于获取被探测函数的返回值.

下面是一个简单的例子,展示如何使用 kprobe 技术捕获 unlink 系统调用.

unlink 系统调用用于在文件系统中删除一个文件的目录项,即移除文件的链接. 在 LinuxUnix 系统中,文件通过目录项来引用它们的实际存储数据,unlink 不直接删除文件内容,而是删除指向文件的链接.

下面是相关的 eBPF 内核态代码 kprobe_unlink.c 的内容:

// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* 版权所有 (c) 2021 Sartura */
#define BPF_NO_GLOBAL_DATA
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>

// 声明许可证类型为 "Dual BSD/GPL"
char LICENSE[] SEC("license") = "Dual BSD/GPL";

// 定义一个 eBPF 程序,附加到 do_unlinkat 函数的入口点
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
    pid_t pid;
    const char *filename;

    // 获取当前进程的 PID
    pid = bpf_get_current_pid_tgid() >> 32;
    // 读取文件名
    filename = BPF_CORE_READ(name, name);
    // 输出调试信息(进入 kprobe)
    bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
    return 0;
}

// 定义一个 eBPF 程序,附加到 do_unlinkat 函数的退出点
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
    pid_t pid;

    // 获取当前进程的 PID
    pid = bpf_get_current_pid_tgid() >> 32;
    // 输出调试信息(退出 kprobe)
    bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
    return 0;
}

在上面的代码中,我们定义了两个 kprobe 探测点,分别是 do_unlinkat 函数的入口和出口. 在 do_unlinkat 函数的入口,我们获取了当前进程的 pid 和文件名,然后打印出来;在 do_unlinkat 函数的出口,我们获取了当前进程的 pid 和返回值,然后打印出来.

首先,我们导入必要的头文件,如 vmlinux.hbpf_helpers.hbpf_tracing.hbpf_core_read.h. 接着,我们定义许可证,以允许程序在内核中运行:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

vmlinux.h 文件是 Linux 内核的头文件,它包含了从内核映像 vmlinux 中提取的结构定义和常量. 它通常在编写与内核交互的 > eBPF 程序或其他内核态程序时使用. 如果机器上没有这个头文件,需要想办法生成这个文件. 这里能显示出 eunomia-bpf ,它能自动生成这个文件编译 eBPF 程序,所以使用它编译的时候没有阻塞. 但是如果使用 clang 或者 culium/ebpf 编译的时候,可能就需要自己生成这个文件.

使用 bpftool 从当前运行的内核生成 vmlinux.h 文件:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

这将在当前目录下生成一个 vmlinux.h 文件. 将生成的 vmlinux.h 文件放在 eBPF 程序所在的目录中. 然后可以使用这个文件来编译 eBPF 程序.

注意这里包含的头文件,注意别漏了,否则编译的时候会报错,甚至运行的时候出错. 如果一开始的时候没有包含bpf_endian.h,就会导致运行的时候出错.

接下来,定义一个名为 BPF_KPROBE(do_unlinkat)kprobe,当进入 do_unlinkat 函数时,它会被触发. 该函数接受两个参数:dfd(文件描述符)和 name(文件名结构体指针). 在这个 kprobe 中,我们获取当前进程的 PID(进程标识符),然后读取文件名. 最后,我们使用 bpf_printk 函数在内核日志中打印 PID 和文件名.

接下来,再定义一个名为 BPF_KRETPROBE(do_unlinkat_exit)kretprobe,当从do_unlinkat 函数退出时,它会被触发. 这个 kretprobe 的目的是捕获函数的返回值(ret). 我们再次获取当前进程的 PID,并使用 bpf_printk 函数在内核日志中打印 PID 和返回值.

通过这个例子你就学会了如何定义 kprobekretprobe,在内核中捕获某个函数的入口和出口.

注意这里我们使用 CO-RE 技术,通过 BPF_CORE_READ 宏来读取内核数据结构,使用 BPF_KPROBEBPF_KRETPROBE 宏来定义 kprobekretprobe.

方法一:使用 eunomia-bpf 编译和加载 eBPF 程序


要编译这个程序,请使用 ecc 工具:

./ecc ./eBPF/kprobe/kprobe_unlink.c
INFO [ecc_rs::bpf_compiler] Compiling bpf object...
INFO [ecc_rs::bpf_compiler] Generating package json..
INFO [ecc_rs::bpf_compiler] Packing ebpf object and config into ./eBPF/kprobe/package.json...

然后,使用 eunomia-bpf 的工具 ecli加载 eBPF 程序:

sudo ./ecli ./eBPF/kprobe/package.jso

和前面例子一样,在 /sys/kernel/debug/tracing/trace_pipe 文件中,你应该能看到类似下面的 kprobe 演示输出:

sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "KPROBE "

......

 Chrome_ChildIOT-20073   [006] ...21 19209.991609: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.QARMcJ
 Chrome_ChildIOT-20073   [006] ...21 19209.991617: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
 Chrome_ChildIOT-20073   [006] ...21 19210.999385: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.iYE0A0
 Chrome_ChildIOT-20073   [006] ...21 19210.999391: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
 Chrome_ChildIOT-20073   [006] ...21 19212.007375: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.lyjiqf
 Chrome_ChildIOT-20073   [006] ...21 19212.007392: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
 Chrome_ChildIOT-20073   [006] ...21 19213.015314: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.lc18pQ
 Chrome_ChildIOT-20073   [006] ...21 19213.015323: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
 Chrome_ChildIOT-20073   [007] ...21 19214.022435: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.WH1wlE

......

文件删除时,打印出了进程的 PID 和文件名,以及返回值.

方法二:使用 cilium/ebpf 编译和加载 eBPF 程序


使用 cilium/ebpf 就有点麻烦了.

首先可能会遇到 vmlinux.h 文件缺失的问题时,通常是因为该文件未提供或未生成. 不过这个已经说明了如何生成的方法,问题不大.

其次还需要包含<bpf/bpf_endian.h>头文件.

最后还要 go generate 时候指定 -D__TARGET_ARCH_x86.

同时还要执行 cc 编译器为 clang.

执行 go generate 命令, 它会生成绑定的文件,并且支持大端和小端的操作系统.

主要逻辑实现 main.go 如下:

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -D__TARGET_ARCH_x86" --go-package minimal -output-dir minimal Minimal kprobe_unlink.c -- -I/usr/include/bpf -I/usr/include/linux

package main

import (
	"os"
	"os/signal"
	"syscall"

	"C"
	"log"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/rlimit"

	minimal "github.com/767829413/advanced-go/eBPF/kprobe/minimal"
)

func main() {
	// 允许锁定内存
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatalf("Removing memlock limit: %v", err)
	}

	// 加载编译的 eBPF 程序
	objs := minimal.MinimalObjects{}
	if err := minimal.LoadMinimalObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %v", err)
	}
	defer objs.Close()

	// 附加 kprobe 和 kretprobe
	kp, err := link.Kprobe("do_unlinkat", objs.DoUnlinkat, nil)
	if err != nil {
		log.Fatalf("attaching kprobe: %v", err)
	}
	defer kp.Close()

	krp, err := link.Kretprobe("do_unlinkat", objs.DoUnlinkatExit, nil)
	if err != nil {
		log.Fatalf("attaching kretprobe: %v", err)
	}
	defer krp.Close()

	log.Println("eBPF program successfully attached, press Ctrl+C to exit...")

	// 创建一个通道,用于接收信号
	sigs := make(chan os.Signal, 1)
	// 注册信号处理器,监听 SIGINT 和 SIGTERM 信号
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	<-sigs
}

这个加载 eBPF 程序的代码是几乎都是一样的的套路. 剩下的就是想做的其他逻辑了.

整个目录结构如下

.
├── kprobe_unlink.c
├── main.go
├── minimal
│   ├── minimal_bpfeb.go
│   ├── minimal_bpfeb.o
│   ├── minimal_bpfel.go
│   └── minimal_bpfel.o
└── vmlinux.h

运行这个程序,会和上一个例子一样,打印出进程的 PID 和文件名,以及返回值.

sudo go run main.go
2024/11/01 15:36:22 eBPF program successfully attached, press Ctrl+C to exit...

sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "KPROBE "

......

 Chrome_IOThread-20035   [002] ...21 23508.343776: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /dev/shm/.org.chromium.Chromium.9Plr1L
 Chrome_IOThread-20035   [002] ...21 23508.343890: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
 Chrome_IOThread-20035   [007] ...21 23508.362259: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /dev/shm/.org.chromium.Chromium.Bf7dRj
 Chrome_IOThread-20035   [007] ...21 23508.362282: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
 Chrome_ChildIOT-20073   [004] ...21 23508.530124: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.QJchoc
 Chrome_ChildIOT-20073   [004] ...21 23508.530133: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
            code-20043   [005] ...21 23508.540117: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /home/fangyuan/.config/Code/User/workspaceStorage/de6c0b23d9bc427a424af2770809fb6c/state.vscdb-journal
            code-20043   [005] ...21 23508.540200: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
 Chrome_ChildIOT-20073   [004] ...21 23509.055616: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.HSUiXd
 Chrome_ChildIOT-20073   [004] ...21 23509.055628: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
 Chrome_ChildIOT-20073   [002] ...21 23510.063102: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.wZHhD0
......

讨论


总结 kprobes 的特点与使用限制(都是抄的,我不懂):

  • kprobes 允许在同一个被探测位置注册多个 kprobe,但是目前 jprobe 不可以;同时也不允许以其他的 jprobe 回调函数和 kprobepost_handler 回调函数作为被探测点.

  • 一般情况下,可以探测内核中的任何函数,包括中断处理函数. 不过在 kernel/kprobes.carch/*/kernel/kprobes.c 程序中用于实现 kprobes 自身的函数是不允许被探测的,另外还有do_page_faultnotifier_call_chain

  • 如果以一个内联函数为探测点,则 kprobes 可能无法保证对该函数的所有实例都注册探测点. 由于 gcc 可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果;

  • 一个探测点的回调函数可能会修改被探测函数的运行上下文,例如通过修改内核的数据结构或者保存与 struct pt_regs 结构体中的触发探测器之前寄存器信息. 因此 kprobes 可以被用来安装 bug 修复代码或者注入故障测试代码;

  • kprobes 会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在 printk() 函数上注册了探测点,而在它的回调函数中可能会再次调用 printk 函数,此时将不再触发 printk 探测点的回调,仅仅是增加了 kprobe 结构体中 nmissed 字段的数值;

  • kprobes 的注册和注销过程中不会使用 mutex 锁和动态的申请内存;

  • kprobes 回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视 CPU 架构而定. 因此不论在何种情况下,在回调函数中不要调用会放弃 CPU 的函数(如信号量、mutex 锁等);

  • kretprobe 通过替换返回地址为预定义的 trampoline 的地址来实现,因此栈回溯和 gcc 内嵌函数 __builtin_return_address() 调用将返回 trampoline 的地址而不是真正的被探测函数的返回地址;

  • 如果一个函数的调用次数和返回次数不相等,则在类似这样的函数上注册 kretprobe 将可能不会达到预期的效果,例如 do_exit() 函数会存在问题,而 do_execve() 函数和 do_fork() 函数不会;

  • 当在进入和退出一个函数时,如果 CPU 运行在非当前任务所有的栈上,那么往该函数上注册 kretprobe 可能会导致不可预料的后果,因此,kprobes 不支持在 X86_64 的结构下为__switch_to() 函数注册 kretprobe,将直接返回-EINVAL.

扩展


kprobe vs tracepoint

kprobetracepointLinux 内核中的两种不同的探测机制,用于性能监控、调试和分析. 它们的不同之处如下:

1. 工作原理

  • kprobekprobe 是一种允许开发者动态插入探针(probe)到任意内核函数的机制. 它可以在运行时指定要探测的函数地址,允许你在任何内核函数的入口或出口处插入钩子代码.

  • tracepointtracepoint 是内核中事先定义好的探测点,开发者可以在编写内核代码时插入这些探点. 它们在内核特定位置手动放置,通常用于监控关键路径或性能敏感的代码段.

2. 灵活性

  • kprobekprobe 提供极大的灵活性,可以监控几乎任何内核函数. 因为探针是动态插入的,所以不需要修改内核源码即可使用.

  • tracepointtracepoint 是静态定义的探测点,只能在内核开发者预定义的点上使用,灵活性相对较低.

3. 开销

  • kprobekprobe 在插入探针时涉及修改指令的流程(例如替换为中断指令),这可能会引入额外的开销,特别是在高频触发的情况下.

  • tracepointtracepoint 由于是静态编译到内核中的,开销较低,特别适合高性能场景下的探测.

4. 使用场景

  • kprobe:适合需要对内核函数进行细粒度监控或调试的场景,例如调试未知问题或分析具体函数的性能.

  • tracepoint:适合性能监控和分析已经被识别为关键路径的内核代码,它们在开发内核时就已被考虑,比较适合大规模的性能分析或监控系统的运行状态.

5. 安全性

  • kprobe:由于可以任意插入探针,使用不当可能导致系统不稳定,尤其是在插入错误探针或探测频繁调用的函数时.

  • tracepoint:由于是内核开发者预定义的点,通常经过优化和验证,因此在使用上更加安全可靠.

总结来说,kprobe 提供更高的灵活性和动态性,而 tracepoint 则更适合高性能的静态监控和分析场景. 如果需要对任意函数进行深入分析,kprobe 是更好的选择;如果只需要监控内核中重要的性能关键点,tracepoint 会是更安全且高效的工具.